太害怕了,差点代码就没了

This commit is contained in:
海龙 2025-08-16 21:38:05 +08:00
parent cc973ba4ac
commit 188379c93c
14 changed files with 2280 additions and 1438 deletions

33
api/movie_start.ts Normal file
View File

@ -0,0 +1,33 @@
import { ApiResponse } from "./common";
import { get, post } from "./request";
import {
StoryTemplateEntity,
ImageStoryEntity,
} from "@/app/service/domain/Entities";
/**
*
*/
export const getTemplateStoryList = async () => {
return await get<ApiResponse<StoryTemplateEntity[]>>("/template-story/list");
};
/**
*
*/
export const actionTemplateStory = async (template: StoryTemplateEntity) => {
return await post<ApiResponse<{ projectId: string }>>(
"/template-story/action",
template
);
};
/**
* AI分析图片
*/
export const AIGenerateImageStory = async (imageStory: ImageStoryEntity) => {
return await post<ApiResponse<{ imageAnalysis: string; category: string }>>(
"/image-story/ai-generate",
imageStory
);
};

View File

@ -0,0 +1,160 @@
import { ImageStoryEntity } from "../domain/Entities";
import { ImageStoryUseCase } from "../usecase/imageStoryUseCase";
import { useState, useCallback, useMemo } from "react";
interface UseImageStoryService {
/** 当前图片故事数据 */
imageStory: Partial<ImageStoryEntity>;
/** 当前活跃的图片地址 */
activeImageUrl: string;
/** 当前活跃的文本信息 */
activeTextContent: string;
/** 当前选中的分类 */
selectedCategory: string;
/** 是否正在分析图片 */
isAnalyzing: boolean;
/** 是否正在上传 */
isUploading: boolean;
/** 故事类型选项 */
storyTypeOptions: Array<{ key: string; label: string }>;
/** 上传图片并分析 */
uploadAndAnalyzeImage: (imageUrl: string) => Promise<void>;
/** 触发生成剧本函数 */
generateScript: () => Promise<string>;
/** 更新故事类型 */
updateStoryType: (storyType: string) => void;
/** 更新故事内容 */
updateStoryContent: (content: string) => void;
/** 重置图片故事数据 */
resetImageStory: () => void;
}
export const useImageStoryServiceHook = (): UseImageStoryService => {
const [imageStory, setImageStory] = useState<Partial<ImageStoryEntity>>({
imageUrl: "",
imageStory: "",
storyType: "auto",
});
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [isUploading, setIsUploading] = useState(false);
/** 图片故事用例实例 */
const imageStoryUseCase = useMemo(() => new ImageStoryUseCase(), []);
/** 当前活跃的图片地址 */
const activeImageUrl = imageStory.imageUrl || "";
/** 当前活跃的文本信息 */
const activeTextContent = imageStory.imageStory || "";
/** 当前选中的分类 */
const selectedCategory = imageStory.storyType || "auto";
/** 故事类型选项 */
const storyTypeOptions = useMemo(() => imageStoryUseCase.getStoryTypeOptions(), [imageStoryUseCase]);
/**
*
* @param {string} imageUrl - URL
*/
const uploadAndAnalyzeImage = useCallback(async (imageUrl: string): Promise<void> => {
try {
setIsUploading(true);
setIsAnalyzing(true);
// 调用用例处理图片上传和分析
await imageStoryUseCase.handleImageUpload(imageUrl);
// 获取更新后的数据
const updatedStory = imageStoryUseCase.getImageStory();
setImageStory(updatedStory);
} catch (error) {
console.error('图片上传分析失败:', error);
throw error;
} finally {
setIsUploading(false);
setIsAnalyzing(false);
}
}, [imageStoryUseCase]);
/**
*
* @returns {Promise<string>} ID或内容
*/
const generateScript = useCallback(async (): Promise<string> => {
if (!activeImageUrl) {
throw new Error('请先上传图片');
}
if (!activeTextContent) {
throw new Error('请先输入或生成故事内容');
}
try {
setIsAnalyzing(true);
// 这里可以调用后端API生成剧本
// 暂时返回一个模拟的剧本ID
const scriptId = `script_${Date.now()}`;
// TODO: 实现实际的剧本生成逻辑
// const response = await generateScriptFromImage(imageStory);
// return response.scriptId;
return scriptId;
} catch (error) {
console.error('生成剧本失败:', error);
throw error;
} finally {
setIsAnalyzing(false);
}
}, [activeImageUrl, activeTextContent, imageStory]);
/**
*
* @param {string} storyType -
*/
const updateStoryType = useCallback((storyType: string): void => {
imageStoryUseCase.updateStoryType(storyType);
setImageStory(prev => ({ ...prev, storyType }));
}, [imageStoryUseCase]);
/**
*
* @param {string} content -
*/
const updateStoryContent = useCallback((content: string): void => {
imageStoryUseCase.updateStoryContent(content);
setImageStory(prev => ({ ...prev, imageStory: content }));
}, [imageStoryUseCase]);
/**
*
*/
const resetImageStory = useCallback((): void => {
imageStoryUseCase.resetImageStory();
setImageStory({
imageUrl: "",
imageStory: "",
storyType: "auto",
});
setIsAnalyzing(false);
setIsUploading(false);
}, [imageStoryUseCase]);
return {
imageStory,
activeImageUrl,
activeTextContent,
selectedCategory,
isAnalyzing,
isUploading,
storyTypeOptions,
uploadAndAnalyzeImage,
generateScript,
updateStoryType,
updateStoryContent,
resetImageStory,
};
};

View File

@ -0,0 +1,82 @@
import { StoryTemplateEntity, RoleEntity } from "../domain/Entities";
import { TemplateStoryUseCase } from "../usecase/templateStoryUseCase";
import { getUploadToken, uploadToQiniu } from "@/api/common";
import { useState, useCallback, useMemo } from "react";
interface UseTemplateStoryService {
/** 模板列表 */
templateStoryList: StoryTemplateEntity[];
/** 当前选中要使用的模板 */
selectedTemplate: StoryTemplateEntity | null;
/** 当前选中的活跃的角色 */
activeRole: RoleEntity | null;
/** 加载状态 */
isLoading: boolean;
/** 获取模板列表函数 */
getTemplateStoryList: () => Promise<void>;
/** action 生成电影函数 */
actionStory: () => Promise<string>;
/** 设置选中的模板 */
setSelectedTemplate: (template: StoryTemplateEntity | null) => void;
/** 设置活跃角色 */
setActiveRole: (role: RoleEntity | null) => void;
}
export const useTemplateStoryServiceHook = (): UseTemplateStoryService => {
const [templateStoryList, setTemplateStoryList] = useState<StoryTemplateEntity[]>([]);
const [selectedTemplate, setSelectedTemplate] = useState<StoryTemplateEntity | null>(null);
const [activeRole, setActiveRole] = useState<RoleEntity | null>(null);
const [isLoading, setIsLoading] = useState(false);
/** 模板故事用例实例 */
const templateStoryUseCase = useMemo(() => new TemplateStoryUseCase(), []);
/**
*
*/
const getTemplateStoryList = useCallback(async (): Promise<void> => {
try {
setIsLoading(true);
const templates = await templateStoryUseCase.getTemplateStoryList();
setTemplateStoryList(templates);
} catch (err) {
console.error('获取模板列表失败:', err);
} finally {
setIsLoading(false);
}
}, [templateStoryUseCase]);
/**
* action
*/
const actionStory = useCallback(async (): Promise<string> => {
if (!selectedTemplate) {
throw new Error('请先选择一个故事模板');
}
try {
setIsLoading(true);
const projectId = await templateStoryUseCase.actionStory(selectedTemplate);
return projectId;
} catch (err) {
console.error('生成电影失败:', err);
throw err;
} finally {
setIsLoading(false);
}
}, [selectedTemplate, templateStoryUseCase]);
return {
templateStoryList,
selectedTemplate,
activeRole,
isLoading,
getTemplateStoryList,
actionStory,
setSelectedTemplate,
setActiveRole,
};
};

View File

@ -90,12 +90,10 @@ export interface ImageStoryEntity {
readonly id: string;
/** 图片URL */
imageUrl: string;
/** 图片故事内容 */
/** 图片故事用户描述 */
imageStory: string;
/** 图片故事剧本 */
imageScript: string;
/** 故事涉及的角色 */
storyRole: RoleEntity[];
/** 图片故事分析结果 */
imageAnalysis: string;
/** 故事分类 */
storyType: string;
}
@ -109,14 +107,12 @@ export interface StoryTemplateEntity {
/** 故事模板名称 */
name: string;
/** 故事模板图片 */
imageUrl: string;
/** 故事模板提示词 */
imageUrl: string[];
/** 故事模板概览*/
generateText: string;
/**故事角色 */
storyRole: string[];
/**用户自定义演绎资源 */
userResources: {
/**对应角色名 */
storyRole: {
/**角色名 */
role_name: string;
/**照片URL */
photo_url: string;

View File

@ -1,4 +1,6 @@
import { getUploadToken, uploadToQiniu } from "@/api/common";
import { useState, useCallback } from "react";
import { ScriptEditKey } from "../usecase/ScriptEditUseCase";
/**
*
@ -27,4 +29,37 @@ export function parseScriptBlock(
/**
* Hook
* @returns {object} -
*/
export function useUploadFile() {
/** 加载状态 */
const [isUploading, setIsUploading] = useState(false);
/**
*
* @param {File} file -
* @param {(progress: number) => void} [onProgress] -
* @returns {Promise<string>} - URL
* @throws {Error} -
*/
const uploadFile = useCallback(
async (file: File, onProgress?: (progress: number) => void): Promise<string> => {
try {
setIsUploading(true);
const { token } = await getUploadToken();
const fileUrl = await uploadToQiniu(file, token, onProgress);
return fileUrl;
} catch (err) {
console.error('文件上传失败:', err);
throw err;
} finally {
setIsUploading(false);
}
},
[]
);
return { uploadFile, isUploading };
}

View File

@ -0,0 +1,153 @@
import { ImageStoryEntity } from "../domain/Entities";
import { AIGenerateImageStory } from "@/api/movie_start";
/**
*
* AI分析和故事生成
*/
export class ImageStoryUseCase {
/** 当前图片故事数据 */
private imageStory: Partial<ImageStoryEntity> = {
imageUrl: "",
imageStory: "",
storyType: "auto",
};
/** 是否正在分析图片 */
private isAnalyzing: boolean = false;
/** 是否正在上传 */
private isUploading: boolean = false;
constructor() {}
/**
*
* @returns {Partial<ImageStoryEntity>}
*/
getImageStory(): Partial<ImageStoryEntity> {
return { ...this.imageStory };
}
/**
*
* @returns {boolean}
*/
getAnalyzingStatus(): boolean {
return this.isAnalyzing;
}
/**
*
* @returns {boolean}
*/
getUploadingStatus(): boolean {
return this.isUploading;
}
/**
*
* @param {Partial<ImageStoryEntity>} data -
*/
setImageStory(data: Partial<ImageStoryEntity>): void {
this.imageStory = { ...this.imageStory, ...data };
}
/**
*
*/
resetImageStory(): void {
this.imageStory = {
imageUrl: "",
imageStory: "",
storyType: "auto",
};
this.isAnalyzing = false;
this.isUploading = false;
}
/**
*
* @param {string} imageUrl - URL
* @returns {Promise<void>}
*/
async handleImageUpload(imageUrl: string): Promise<void> {
try {
this.isUploading = false; // 图片已上传设置上传状态为false
this.isAnalyzing = true;
// 设置上传后的图片URL
this.setImageStory({ imageUrl });
// 调用AI分析接口
await this.analyzeImageWithAI();
} catch (error) {
console.error("图片分析失败:", error);
// 分析失败时清空图片URL
this.setImageStory({ imageUrl: "" });
throw error;
} finally {
this.isAnalyzing = false;
}
}
/**
* 使AI分析图片
* @returns {Promise<void>}
*/
private async analyzeImageWithAI(): Promise<void> {
try {
// 调用AI分析接口
const response = await AIGenerateImageStory(this.imageStory as ImageStoryEntity);
if (response.successful && response.data) {
const { imageAnalysis, category } = response.data;
// 更新分析结果和分类
this.setImageStory({
imageAnalysis,
storyType: category || "auto",
imageStory: imageAnalysis, // 将AI分析结果作为默认故事内容
});
} else {
throw new Error("AI分析失败");
}
} catch (error) {
console.error("AI分析失败:", error);
throw error;
}
}
/**
*
* @param {string} storyType -
*/
updateStoryType(storyType: string): void {
this.setImageStory({ storyType });
}
/**
*
* @param {string} storyContent -
*/
updateStoryContent(storyContent: string): void {
this.setImageStory({ imageStory: storyContent });
}
/**
*
* @returns {Array<{key: string, label: string}>}
*/
getStoryTypeOptions(): Array<{ key: string; label: string }> {
return [
{ key: "auto", label: "Auto" },
{ key: "adventure", label: "Adventure" },
{ key: "romance", label: "Romance" },
{ key: "mystery", label: "Mystery" },
{ key: "fantasy", label: "Fantasy" },
{ key: "comedy", label: "Comedy" },
];
}
}

View File

@ -0,0 +1,56 @@
import { StoryTemplateEntity } from "../domain/Entities";
import { getTemplateStoryList, actionTemplateStory } from "@/api/movie_start";
/**
*
*
*/
export class TemplateStoryUseCase {
/** 故事模板列表 */
templateStoryList: StoryTemplateEntity[] = [];
/** 当前选中的故事模板 */
selectedTemplate: StoryTemplateEntity | null = null;
constructor() {}
/**
*
* @returns {Promise<StoryTemplateEntity[]>} -
*/
async getTemplateStoryList(): Promise<StoryTemplateEntity[]> {
try {
const response = await getTemplateStoryList();
if (response.successful && response.data) {
this.templateStoryList = response.data;
return response.data;
}
throw new Error(response.message || '获取故事模板列表失败');
} catch (error) {
console.error('获取故事模板列表失败:', error);
throw error;
}
}
/**
*
* @param {StoryTemplateEntity} template -
* @returns {Promise<string>} - id
*/
async actionStory(template: StoryTemplateEntity): Promise<string> {
try {
if (!template) {
throw new Error('故事模板不能为空');
}
const response = await actionTemplateStory(template);
if (response.successful && response.data) {
this.selectedTemplate = template;
return response.data.projectId;
}
throw new Error(response.message || '执行故事模板操作失败');
} catch (error) {
console.error('执行故事模板操作失败:', error);
throw error;
}
}
}

View File

@ -0,0 +1,472 @@
"use client";
import React, { useState, useRef, useEffect } from "react";
import { Mic, MicOff, Upload, Play, Pause, Trash2, X } from "lucide-react";
import { Tooltip, Upload as AntdUpload } from "antd";
import { InboxOutlined } from "@ant-design/icons";
import WaveSurfer from "wavesurfer.js";
import { useUploadFile } from "../../app/service/domain/service";
// 自定义样式
const audioRecorderStyles = `
.slider {
-webkit-appearance: none;
appearance: none;
background: transparent;
cursor: pointer;
}
.slider::-webkit-slider-track {
background: rgba(255, 255, 255, 0.2);
height: 4px;
border-radius: 2px;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
background: #3b82f6;
height: 12px;
width: 12px;
border-radius: 50%;
cursor: pointer;
transition: all 0.2s ease;
}
.slider::-webkit-slider-thumb:hover {
background: #2563eb;
transform: scale(1.1);
}
.slider::-moz-range-track {
background: rgba(255, 255, 255, 0.2);
height: 4px;
border-radius: 2px;
border: none;
}
.slider::-moz-range-thumb {
background: #3b82f6;
height: 12px;
width: 12px;
border-radius: 50%;
cursor: pointer;
border: none;
transition: all 0.2s ease;
}
.slider::-moz-range-thumb:hover {
background: #2563eb;
transform: scale(1.1);
}
`;
interface AudioRecorderProps {
/** 当前音频URL */
audioUrl?: string;
/** 录制完成回调 */
onAudioRecorded: (audioBlob: Blob, audioUrl: string) => void;
/** 删除音频回调 */
onAudioDeleted: () => void;
/** 组件标题 */
title?: string;
/** 是否显示关闭按钮 */
showCloseButton?: boolean;
/** 关闭回调 */
onClose?: () => void;
}
/**
*
*/
export function AudioRecorder({
audioUrl,
onAudioRecorded,
onAudioDeleted,
title = "请上传参考音频",
showCloseButton = false,
onClose,
}: AudioRecorderProps) {
const [mode, setMode] = useState<"upload" | "record">("upload"); // 当前模式:上传或录制
const [isRecording, setIsRecording] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [volume, setVolume] = useState(1);
const [isMuted, setIsMuted] = useState(false);
const { uploadFile, isUploading } = useUploadFile();
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
// 开始录制
const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mediaRecorder = new MediaRecorder(stream);
mediaRecorderRef.current = mediaRecorder;
chunksRef.current = [];
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunksRef.current.push(event.data);
}
};
mediaRecorder.onstop = () => {
const audioBlob = new Blob(chunksRef.current, { type: "audio/wav" });
const audioUrl = URL.createObjectURL(audioBlob);
onAudioRecorded(audioBlob, audioUrl);
stream.getTracks().forEach((track) => track.stop());
};
mediaRecorder.start();
setIsRecording(true);
} catch (error) {
console.error("录制失败:", error);
}
};
// 停止录制
const stopRecording = () => {
if (mediaRecorderRef.current && isRecording) {
mediaRecorderRef.current.stop();
setIsRecording(false);
}
};
// 播放/暂停控制
const togglePlay = () => {
setIsPlaying(!isPlaying);
};
// 音量控制
const toggleMute = () => {
setIsMuted(!isMuted);
};
// 音量调节
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newVolume = parseFloat(e.target.value);
setVolume(newVolume);
if (isMuted && newVolume > 0) {
setIsMuted(false);
}
};
// 删除音频
const handleDelete = () => {
setIsPlaying(false);
onAudioDeleted();
};
// 渲染上传/录制状态
if (!audioUrl) {
return (
<>
<style>{audioRecorderStyles}</style>
<div className="relative bg-white/[0.05] border border-white/[0.1] rounded-lg p-4">
{/* 头部 - 只显示关闭按钮 */}
{showCloseButton && (
<div className="flex justify-end mb-2">
<button
onClick={onClose}
className="text-white/60 hover:text-white/80 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
)}
{/* 主要内容区域 */}
<div className="flex items-center justify-center min-h-[60px]">
{mode === "upload" ? (
// 上传模式
<div className="text-center w-full">
<Tooltip
title="Please clearly read the story description above and record a 15-second audio file for upload"
placement="top"
overlayClassName="max-w-xs"
>
<div>
<AntdUpload.Dragger
accept="audio/*"
beforeUpload={() => false}
customRequest={async ({ file, onSuccess, onError }) => {
try {
const fileObj = file as File;
console.log("开始上传文件:", fileObj.name, fileObj.type, fileObj.size);
if (fileObj && fileObj.type.startsWith("audio/")) {
// 使用 hook 上传文件到七牛云
console.log("调用 uploadFile hook...");
const uploadedUrl = await uploadFile(fileObj);
console.log("上传成功URL:", uploadedUrl);
// 上传成功后,调用回调函数
onAudioRecorded(fileObj, uploadedUrl);
onSuccess?.(uploadedUrl);
} else {
console.log("文件类型不是音频:", fileObj?.type);
const error = new Error("文件类型不是音频文件");
onError?.(error);
}
} catch (error) {
console.error("上传失败:", error);
// 上传失败时直接报告错误,不使用本地文件作为备选
onError?.(error as Error);
}
}}
showUploadList={false}
className="bg-transparent border-dashed border-white/20 hover:border-white/40"
disabled={isUploading}
>
<div className="text-2xl text-white/40 mb-2">
<InboxOutlined />
</div>
<div className="text-xs text-white/60">
{isUploading
? "Uploading..."
: "Drag audio file here or click to upload"}
</div>
</AntdUpload.Dragger>
</div>
</Tooltip>
</div>
) : (
// 录制模式
<div className="text-center w-full">
{isRecording ? (
// 录制中状态
<div className="space-y-3">
<div className="flex items-center justify-center gap-3">
<button
onClick={stopRecording}
className="flex items-center gap-1.5 px-3 py-1.5 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors text-sm"
>
<MicOff className="w-3 h-3" />
<span>Stop</span>
</button>
<div className="text-xs text-white/60">Recording...</div>
</div>
{/* 录制状态指示器 */}
<div className="w-full h-12 bg-white/[0.05] rounded-lg flex items-center justify-center">
<div className="flex items-center gap-2 text-white/60">
<div className="w-2 h-2 bg-red-500 rounded-full animate-pulse"></div>
<span className="text-xs">Recording...</span>
</div>
</div>
</div>
) : (
// 录制准备状态
<div className="text-center">
<div className="text-2xl text-white/40 mb-2">🎙</div>
<div className="text-xs text-white/60 mb-3">
Click to start recording
</div>
<Tooltip
title="Please clearly read the story description above and record a 15-second audio"
placement="top"
overlayClassName="max-w-xs"
>
<button
onClick={startRecording}
className="flex items-center gap-1.5 px-3 py-1.5 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors text-sm mx-auto"
>
<Mic className="w-3 h-3" />
<span>Record</span>
</button>
</Tooltip>
</div>
)}
</div>
)}
</div>
{/* 底部模式切换图标 */}
<div className="flex justify-center gap-6 mt-3 pt-3 border-t border-white/[0.1]">
<Tooltip title="Switch to upload mode" placement="top">
<button
onClick={() => setMode("upload")}
className={`transition-colors ${
mode === "upload"
? "text-blue-300"
: "text-white/40 hover:text-white/60"
}`}
>
<Upload className="w-5 h-5" />
</button>
</Tooltip>
<Tooltip title="Switch to recording mode" placement="top">
<button
onClick={() => setMode("record")}
className={`transition-colors ${
mode === "record"
? "text-green-300"
: "text-white/40 hover:text-white/60"
}`}
>
<Mic className="w-5 h-5" />
</button>
</Tooltip>
</div>
</div>
</>
);
}
// 渲染播放状态
return (
<>
<style>{audioRecorderStyles}</style>
<div className="relative bg-white/[0.05] border border-white/[0.1] rounded-lg p-4">
{/* 头部 - 只显示操作按钮 */}
<div className="flex justify-end gap-2 mb-3">
<button
onClick={handleDelete}
className="text-white/60 hover:text-red-400 transition-colors"
title="Delete audio"
>
<Trash2 className="w-4 h-4" />
</button>
{showCloseButton && (
<button
onClick={onClose}
className="text-white/60 hover:text-white/80 transition-colors"
>
<X className="w-4 h-4" />
</button>
)}
</div>
{/* WaveSurfer 波形图区域 */}
<div className="mb-4">
<div className="h-16 bg-white/[0.05] rounded-lg overflow-hidden">
<WaveformPlayer
audioUrl={audioUrl}
isPlaying={isPlaying}
onPlayStateChange={setIsPlaying}
volume={volume}
isMuted={isMuted}
/>
</div>
</div>
{/* 播放控制 */}
<div className="flex items-center justify-center gap-4 mb-4">
<button
onClick={togglePlay}
className="flex items-center justify-center w-12 h-12 bg-blue-500 text-white rounded-full hover:bg-blue-600 transition-colors"
>
{isPlaying ? (
<Pause className="w-5 h-5" />
) : (
<Play className="w-5 h-5" />
)}
</button>
</div>
{/* 音频设置 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={toggleMute}
className="text-white/60 hover:text-white/80 transition-colors"
title={isMuted ? "Unmute" : "Mute"}
>
{isMuted ? (
<MicOff className="w-4 h-4" />
) : (
<Mic className="w-4 h-4" />
)}
</button>
<div className="flex items-center gap-2">
<input
type="range"
min="0"
max="1"
step="0.1"
value={volume}
onChange={handleVolumeChange}
className="w-16 h-1 bg-white/20 rounded-lg appearance-none cursor-pointer slider"
/>
<span className="text-xs text-white/60 w-8">
{Math.round(volume * 100)}%
</span>
</div>
</div>
<div className="text-xs text-white/40">1x</div>
</div>
</div>
</>
);
}
interface WaveformPlayerProps {
audioUrl: string;
isPlaying: boolean;
onPlayStateChange: (isPlaying: boolean) => void;
volume?: number;
isMuted?: boolean;
}
function WaveformPlayer({
audioUrl,
isPlaying,
onPlayStateChange,
volume = 1,
isMuted = false,
}: WaveformPlayerProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const wavesurferRef = useRef<WaveSurfer | null>(null);
useEffect(() => {
if (!containerRef.current || !audioUrl) return;
const ws = WaveSurfer.create({
container: containerRef.current,
waveColor: "#3b82f6",
progressColor: "#1d4ed8",
cursorColor: "#1e40af",
height: 64,
barWidth: 2,
barGap: 1,
normalize: true,
url: audioUrl,
});
// 监听播放状态变化
ws.on("play", () => onPlayStateChange(true));
ws.on("pause", () => onPlayStateChange(false));
ws.on("finish", () => onPlayStateChange(false));
wavesurferRef.current = ws;
return () => {
ws.destroy();
};
}, [audioUrl, onPlayStateChange]);
// 同步外部播放状态和音量
useEffect(() => {
if (!wavesurferRef.current) return;
if (isPlaying && !wavesurferRef.current.isPlaying()) {
wavesurferRef.current.play();
} else if (!isPlaying && wavesurferRef.current.isPlaying()) {
wavesurferRef.current.pause();
}
// 设置音量
const currentVolume = isMuted ? 0 : volume;
wavesurferRef.current.setVolume(currentVolume);
}, [isPlaying, volume, isMuted]);
return (
<div className="w-full h-full">
<div ref={containerRef} className="w-full h-full" />
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,100 @@
"use client";
import { Loader2, Trash2, ChevronDown } from "lucide-react";
import { Dropdown, Image } from "antd";
import { EyeOutlined } from "@ant-design/icons";
import { useImageStoryServiceHook } from "@/app/service/Interaction/ImageStoryService";
/**
*
*
* 使ImageStoryService hook管理状态和业务逻辑
*/
export function PhotoStoryMode() {
// 使用图片故事服务hook
const {
activeImageUrl,
selectedCategory,
isAnalyzing,
isUploading,
storyTypeOptions,
updateStoryType,
resetImageStory,
} = useImageStoryServiceHook();
return (
<div className="absolute top-[-110px] left-14 right-0 flex items-center justify-between">
{/* 左侧:图片预览区域和分析状态指示器 */}
<div className="flex items-center gap-3">
{/* 图片预览区域 - 使用Ant Design Image组件 */}
<div className="relative w-24 h-24 rounded-lg overflow-hidden bg-white/[0.05] border border-white/[0.1] shadow-[0_4px_16px_rgba(0,0,0,0.2)]">
{activeImageUrl && (
<Image
src={activeImageUrl}
alt="Story inspiration"
className="w-full h-full object-cover"
preview={{
mask: (
<EyeOutlined className="w-6 h-6 text-white/80" />
),
maskClassName:
"flex items-center justify-center bg-black/50 hover:bg-black/70 transition-colors",
}}
/>
)}
</div>
{/* 删除图片按钮 - 简洁样式 */}
{activeImageUrl && (
<button
onClick={resetImageStory}
className="absolute -top-2 left-24 w-6 h-6 bg-black/60 hover:bg-black/80 rounded-full flex items-center justify-center text-white/80 hover:text-white transition-all duration-200 z-10"
title="删除图片并退出图片故事模式"
>
<Trash2 className="w-2.5 h-2.5" />
</button>
)}
{/* 分析状态指示器 */}
{isAnalyzing && (
<div className="flex items-center gap-2 px-3 py-2 bg-white/[0.1] rounded-lg">
<Loader2 className="w-4 h-4 animate-spin text-white/80" />
<span className="text-sm text-white/80">
{isUploading
? "Uploading image..."
: "Analyzing image..."}
</span>
</div>
)}
</div>
{/* 右侧:故事类型选择器 */}
{activeImageUrl && (
<Dropdown
menu={{
items: storyTypeOptions.map((type) => ({
key: type.key,
label: (
<div className="px-3 py-2 text-sm text-white/90">
{type.label}
</div>
),
})),
onClick: ({ key }) => updateStoryType(key),
}}
trigger={["click"]}
placement="bottomRight"
>
<button className="px-3 py-2 bg-white/[0.1] hover:bg-white/[0.15] border border-white/[0.2] rounded-lg text-white/80 text-sm transition-colors flex items-center gap-2">
<span>
{storyTypeOptions.find(
(t) => t.key === selectedCategory
)?.label || "Auto"}
</span>
<ChevronDown className="w-3 h-3" />
</button>
</Dropdown>
)}
</div>
);
}

View File

@ -0,0 +1,264 @@
import React from "react";
interface TemplateCardProps {
/** 图片URL */
imageUrl: string;
/** 图片alt文本 */
imageAlt?: string;
/** 标题 */
title: string;
/** 描述文字 */
description: string;
/** 是否选中默认false */
isSelected?: boolean;
/** 卡片宽度默认150px */
width?: number;
/** 卡片高度默认200px */
height?: number;
}
/**
* 3D翻转卡片组件
*
*/
const TemplateCard: React.FC<TemplateCardProps> = ({
imageUrl,
imageAlt = "",
title,
description,
isSelected = false,
width = 150,
height = 200,
}) => {
return (
<div
className={`card ${isSelected ? "selected" : ""}`}
style={{ width: `${width}px`, height: `${height}px` }}
data-alt="template-card"
>
<div className="card-container">
{/* 背面 - 显示图片 */}
<div className="card-back">
<div className="back-image-wrapper">
<img src={imageUrl} alt={imageAlt} className="back-image" />
</div>
</div>
{/* 正面 - 显示文字和流光效果 */}
<div className="card-front">
<div className="floating-circle floating-circle-1"></div>
<div className="floating-circle floating-circle-2"></div>
<div className="floating-circle floating-circle-3"></div>
<div className="front-content-overlay">
<div className="free-badge">Free</div>
<div className="text-content">
<h3 className="card-title">{title}</h3>
<p className="card-description">{description}</p>
</div>
</div>
</div>
</div>
<style jsx>{`
.card {
overflow: visible;
}
.card-container {
width: 100%;
height: 100%;
transform-style: preserve-3d;
transition: transform 300ms;
box-shadow: 0px 0px 10px 1px #000000ee;
border-radius: 5px;
cursor: pointer;
}
.card-front,
.card-back {
background-color: #151515;
position: absolute;
width: 100%;
height: 100%;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
border-radius: 5px;
overflow: hidden;
}
.card-back {
width: 100%;
height: 100%;
justify-content: center;
display: flex;
align-items: center;
overflow: hidden;
}
.card-back::before {
position: absolute;
content: " ";
display: block;
width: 160px;
height: 160%;
background: linear-gradient(
90deg,
transparent,
rgb(106, 244, 249),
rgb(199, 59, 255),
rgb(106, 244, 249),
rgb(199, 59, 255),
transparent
);
animation: rotation_481 5000ms infinite linear;
opacity: 0;
transition: opacity 0.3s ease;
}
.card.selected .card-back::before {
opacity: 1;
}
.back-image-wrapper {
position: absolute;
width: 99%;
height: 99%;
background-color: #151515;
border-radius: 5px;
color: white;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: flex-start;
padding: 0px;
box-sizing: border-box;
}
.free-badge {
background: linear-gradient(
135deg,
rgb(106, 244, 249),
rgb(199, 59, 255)
);
color: white;
padding: 3px 10px;
border-radius: 10px;
font-size: 10px;
font-weight: bold;
text-transform: uppercase;
}
.text-content {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
gap: 15px;
}
.card-title {
font-size: 16px;
font-weight: bold;
margin: 0;
line-height: 1.2;
}
.card-description {
font-size: 12px;
line-height: 1.4;
margin: 0;
color: #cccccc;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.card:hover .card-container {
transform: rotateY(180deg);
}
@keyframes rotation_481 {
0% {
transform: rotateZ(0deg);
}
100% {
transform: rotateZ(360deg);
}
}
.card-front {
transform: rotateY(180deg);
color: white;
}
.front-content-overlay {
position: absolute;
width: 100%;
height: 100%;
background-color: rgba(21, 21, 21, 0.6);
backdrop-filter: blur(10px);
border-radius: 5px;
color: white;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: flex-start;
padding: 1rem;
box-sizing: border-box;
z-index: 100;
}
.back-image {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
}
.floating-circle {
width: 60px;
height: 60px;
border-radius: 50%;
background-color: #ffbb66;
position: absolute;
filter: blur(12px);
animation: floating 2600ms infinite linear;
}
.floating-circle-2 {
background-color: #ff8866;
left: 30px;
top: 70px;
width: 100px;
height: 100px;
animation-delay: -800ms;
}
.floating-circle-3 {
background-color: #ff2233;
left: 100px;
top: 30px;
width: 20px;
height: 20px;
animation-delay: -1800ms;
}
@keyframes floating {
0% {
transform: translateY(0px);
}
50% {
transform: translateY(10px);
}
100% {
transform: translateY(0px);
}
}
`}</style>
</div>
);
};
export default TemplateCard;

View File

@ -3,14 +3,9 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { ArrowLeft, ListOrdered, Play, Loader2, Pause, MoreHorizontal, Edit2, Check, X, RefreshCw, Calendar, Clock, Eye, Heart, Share2, Video } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Input } from "@/components/ui/input";
import './style/create-to-video2.css';
import { Dropdown, Menu } from 'antd';
import type { MenuProps } from 'antd';
import dynamic from 'next/dynamic';
import { ModeEnum, ResolutionEnum } from "@/app/model/enums";
import { createScriptEpisodeNew, getScriptEpisodeListNew, CreateScriptEpisodeRequest } from "@/api/script_episode";
import { getUploadToken, uploadToQiniu } from "@/api/common";
import { EmptyStateAnimation } from '@/components/common/EmptyStateAnimation2';
import { ChatInputBox } from '@/components/common/ChatInputBox';

56
package-lock.json generated
View File

@ -88,7 +88,6 @@
"react-day-picker": "^8.10.1",
"react-dom": "18.2.0",
"react-grid-layout": "^1.5.1",
"react-h5-audio-player": "^3.10.0",
"react-hook-form": "^7.53.0",
"react-intersection-observer": "^9.16.0",
"react-joyride": "^2.9.3",
@ -98,6 +97,7 @@
"react-resizable-panels": "^2.1.3",
"react-rough-notation": "^1.0.5",
"react-textarea-autosize": "^8.5.9",
"react-wavesurfer.js": "^0.0.8",
"recharts": "^2.15.4",
"remark-gfm": "^4.0.1",
"sonner": "^1.5.0",
@ -109,7 +109,7 @@
"three": "^0.177.0",
"typescript": "5.2.2",
"vaul": "^0.9.9",
"wavesurfer.js": "^7.9.9",
"wavesurfer.js": "^7.10.1",
"zod": "^3.23.8"
},
"devDependencies": {
@ -1020,27 +1020,6 @@
"integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==",
"license": "BSD-3-Clause"
},
"node_modules/@iconify/react": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/@iconify/react/-/react-5.2.1.tgz",
"integrity": "sha512-37GDR3fYDZmnmUn9RagyaX+zca24jfVOMY8E1IXTqJuE8pxNtN51KWPQe3VODOWvuUurq7q9uUu3CFrpqj5Iqg==",
"license": "MIT",
"dependencies": {
"@iconify/types": "^2.0.0"
},
"funding": {
"url": "https://github.com/sponsors/cyberalien"
},
"peerDependencies": {
"react": ">=16"
}
},
"node_modules/@iconify/types": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
"integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==",
"license": "MIT"
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@ -17829,20 +17808,6 @@
"react-dom": ">= 16.3.0"
}
},
"node_modules/react-h5-audio-player": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/react-h5-audio-player/-/react-h5-audio-player-3.10.0.tgz",
"integrity": "sha512-y1PRCwGy8TfpTQaoV3BTusrmSMDfET5yAiUCzbosAZrF15E3QahzG/SLsuGXDv4QVy/lgwlhThaFNvL5kkS09w==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.10.2",
"@iconify/react": "^5"
},
"peerDependencies": {
"react": "^16.3.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.3.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-hook-form": {
"version": "7.59.0",
"resolved": "https://registry.npmmirror.com/react-hook-form/-/react-hook-form-7.59.0.tgz",
@ -18137,6 +18102,17 @@
"react-dom": ">=16.6.0"
}
},
"node_modules/react-wavesurfer.js": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/react-wavesurfer.js/-/react-wavesurfer.js-0.0.8.tgz",
"integrity": "sha512-73UDCIbHolcKsT8mVKiMxsgBwNvjOtq1eVisLV/d7W7+1W/y0cpBsEfSHfEIS63au9bXpepC+pGiN5kepWrUuw==",
"license": "MIT",
"peerDependencies": {
"react": ">=17.0.2",
"react-dom": ">=17.0.2",
"wavesurfer.js": "5.x.x || 6.x.x"
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@ -20447,9 +20423,9 @@
}
},
"node_modules/wavesurfer.js": {
"version": "7.9.9",
"resolved": "https://registry.npmmirror.com/wavesurfer.js/-/wavesurfer.js-7.9.9.tgz",
"integrity": "sha512-8O/zu+RC7yjikxiuhsXzRZ8vvjV+Qq4PUKZBQsLLcq6fqbrSF3Vh99l7fT8zeEjKjDBNH2Qxsxq5mRJIuBmM3Q==",
"version": "7.10.1",
"resolved": "https://registry.npmjs.org/wavesurfer.js/-/wavesurfer.js-7.10.1.tgz",
"integrity": "sha512-tF1ptFCAi8SAqKbM1e7705zouLC3z4ulXCg15kSP5dQ7VDV30Q3x/xFRcuVIYTT5+jB/PdkhiBRCfsMshZG1Ug==",
"license": "BSD-3-Clause"
},
"node_modules/webidl-conversions": {

View File

@ -91,7 +91,6 @@
"react-day-picker": "^8.10.1",
"react-dom": "18.2.0",
"react-grid-layout": "^1.5.1",
"react-h5-audio-player": "^3.10.0",
"react-hook-form": "^7.53.0",
"react-intersection-observer": "^9.16.0",
"react-joyride": "^2.9.3",
@ -101,6 +100,7 @@
"react-resizable-panels": "^2.1.3",
"react-rough-notation": "^1.0.5",
"react-textarea-autosize": "^8.5.9",
"react-wavesurfer.js": "^0.0.8",
"recharts": "^2.15.4",
"remark-gfm": "^4.0.1",
"sonner": "^1.5.0",
@ -112,7 +112,7 @@
"three": "^0.177.0",
"typescript": "5.2.2",
"vaul": "^0.9.9",
"wavesurfer.js": "^7.9.9",
"wavesurfer.js": "^7.10.1",
"zod": "^3.23.8"
},
"devDependencies": {