diff --git a/app/api/canvas/media/upload/route.ts b/app/api/canvas/media/upload/route.ts new file mode 100644 index 000000000..6cd31303d --- /dev/null +++ b/app/api/canvas/media/upload/route.ts @@ -0,0 +1,50 @@ +import { execFile } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { NextResponse } from "next/server"; +import { auth } from "@/app/(auth)/auth"; +import { uploadFile } from "@/lib/storage/service"; + +export const runtime = "nodejs"; +export const maxDuration = 180; +const probe = promisify(execFile); +const running = new Set(); +const formats: Record = { + "video/mp4": "mp4", "video/quicktime": "mov", "video/webm": "webm", + "audio/mpeg": "mp3", "audio/mp3": "mp3", "audio/mp4": "m4a", "audio/x-m4a": "m4a", + "audio/wav": "wav", "audio/x-wav": "wav", "audio/ogg": "ogg", "audio/flac": "flac", "audio/webm": "webm", +}; + +export async function POST(request: Request) { + const session = await auth(); + if (!session?.user?.id) return NextResponse.json({ success: false, error: "未登录" }, { status: 401 }); + if (running.has(session.user.id) || running.size >= 4) return NextResponse.json({ success: false, error: "正在上传,请稍后重试" }, { status: 429 }); + running.add(session.user.id); + let dir = ""; + try { + const form = await request.formData(); + const file = form.get("file"); + if (!(file instanceof File) || !formats[file.type]) return NextResponse.json({ success: false, error: "请上传 MP4、MOV、WebM 视频,或 MP3、M4A、WAV、OGG、FLAC 音频" }, { status: 400 }); + if (!file.size || file.size > 125 * 1024 * 1024) return NextResponse.json({ success: false, error: "素材必须非空且不超过 125 MiB" }, { status: 413 }); + dir = await mkdtemp(join(tmpdir(), "canvas-upload-")); + const input = join(dir, "input"); + const buffer = await file.arrayBuffer(); + await writeFile(input, Buffer.from(buffer)); + const { stdout } = await probe("ffprobe", ["-v", "error", "-protocol_whitelist", "file", "-format_whitelist", "mov,matroska,webm,mp3,wav,ogg,flac", "-show_streams", "-show_format", "-of", "json", input], { timeout: 15000, maxBuffer: 1024 * 1024 }); + const info = JSON.parse(stdout); + const kind = file.type.startsWith("video/") ? "video" : "audio"; + const stream = info.streams?.find((item: { codec_type: string }) => item.codec_type === kind); + const duration = Number(info.format?.duration); + if (!stream || !Number.isFinite(duration) || duration <= 0 || duration > 600) return NextResponse.json({ success: false, error: "请上传内容完整、10 分钟以内的音视频" }, { status: 400 }); + const uploaded = await uploadFile(`canvas-${randomUUID()}.${formats[file.type]}`, buffer); + return NextResponse.json({ success: true, data: { url: uploaded.url, duration, width: stream.width, height: stream.height } }); + } catch { + return NextResponse.json({ success: false, error: "素材上传失败,请确认文件完整或稍后重试" }, { status: 400 }); + } finally { + running.delete(session.user.id); + if (dir) await rm(dir, { recursive: true, force: true }); + } +} diff --git a/app/api/canvas/prompt-tools/route.ts b/app/api/canvas/prompt-tools/route.ts new file mode 100644 index 000000000..1fb8be196 --- /dev/null +++ b/app/api/canvas/prompt-tools/route.ts @@ -0,0 +1,57 @@ +import { generateText } from "ai"; +import { NextResponse } from "next/server"; +import { auth } from "@/app/(auth)/auth"; +import { getLanguageModelById } from "@/lib/ai/dynamic-provider"; +import { getEnabledAiModelsWithProvider, getSystemSetting } from "@/lib/db/queries"; +import { VIDEO_CANVAS_FUNCTION_CONFIG_KEY, normalizeVideoCanvasFunctionConfig } from "@/lib/video-canvas/config"; +import { promptToolErrorMessage, promptToolFunctionKey, promptToolSchema, selectPromptToolModel } from "@/lib/video-canvas/prompt-tools"; + +export const runtime = "nodejs"; +export const maxDuration = 120; +const running = new Set(); + +export async function POST(request: Request) { + const session = await auth(); + if (!session?.user?.id) return NextResponse.json({ success: false, error: "未登录" }, { status: 401 }); + const parsed = promptToolSchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) return NextResponse.json({ success: false, error: parsed.error.issues[0]?.message || "参数无效" }, { status: 400 }); + if (running.has(session.user.id)) return NextResponse.json({ success: false, error: "提示词处理中,请稍候" }, { status: 429 }); + running.add(session.user.id); + try { + const [setting, available] = await Promise.all([ + getSystemSetting({ key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY }), + getEnabledAiModelsWithProvider(false), + ]); + const config = normalizeVideoCanvasFunctionConfig(setting?.value); + const definition = config.functions[promptToolFunctionKey(parsed.data.mode)]; + if (!config.enabled || !definition?.enabled) return NextResponse.json({ success: false, error: "此提示词功能已停用" }, { status: 403 }); + const requested = parsed.data.model && parsed.data.model !== "auto" + ? parsed.data.model : definition.model && definition.model !== "auto" + ? definition.model : config.defaultMultimodalModel; + let selected; + try { + selected = selectPromptToolModel(available + .filter(item => item.provider.isEnabled && item.model.isEnabled && !item.model.isSubModel) + .map(item => ({ ...item.model, providerType: item.provider.type })), requested); + } catch (error) { + return NextResponse.json({ success: false, error: (error as Error).message }, { status: 400 }); + } + const model = await getLanguageModelById(selected.id); + if (!model) return NextResponse.json({ success: false, error: "提示词模型初始化失败" }, { status: 502 }); + const { text } = await generateText({ + model, + system: definition.prompt, + prompt: parsed.data.prompt, + temperature: parsed.data.mode === "translate" ? 0.1 : 0.4, + maxOutputTokens: 4000, + maxRetries: 0, + abortSignal: AbortSignal.any([request.signal, AbortSignal.timeout(90_000)]), + }); + if (!text.trim()) throw new Error("empty model output"); + return NextResponse.json({ success: true, data: { text: text.trim(), model: selected.id, modelName: selected.displayName || selected.modelId } }); + } catch (error) { + return NextResponse.json({ success: false, error: promptToolErrorMessage(error) }, { status: 502 }); + } finally { + running.delete(session.user.id); + } +} diff --git a/app/api/canvas/video/process/route.ts b/app/api/canvas/video/process/route.ts index 923d94e60..459049544 100644 --- a/app/api/canvas/video/process/route.ts +++ b/app/api/canvas/video/process/route.ts @@ -1,6 +1,5 @@ import {auth} from '@/app/(auth)/auth'; import {NextResponse} from 'next/server'; -import {z} from 'zod'; import {mkdtemp,readFile,rm} from 'node:fs/promises'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; @@ -8,14 +7,14 @@ import {randomUUID} from 'node:crypto'; import {uploadFile} from '@/lib/storage/service'; import {downloadCanvasMedia} from '@/lib/video-canvas/download-media'; import {processVideoFile} from '@/lib/video-canvas/media-processing'; +import {videoProcessSchema} from '@/lib/video-canvas/media-contract'; export const runtime='nodejs'; export const maxDuration=300; const running=new Set(); -const schema=z.object({operation:z.enum(['crop','audio-video']),url:z.string().url(),rect:z.object({x:z.number(),y:z.number(),width:z.number(),height:z.number()}).optional()}); export async function POST(request:Request) { const session=await auth(); if(!session?.user?.id) return NextResponse.json({success:false,error:'未登录'},{status:401}); - const parsed=schema.safeParse(await request.json().catch(()=>null)); + const parsed=videoProcessSchema.safeParse(await request.json().catch(()=>null)); if(!parsed.success) return NextResponse.json({success:false,error:'视频处理参数无效'},{status:400}); if(running.has(session.user.id)||running.size>=2) return NextResponse.json({success:false,error:'视频处理中,请稍后再提交'},{status:429}); running.add(session.user.id); @@ -25,6 +24,10 @@ export async function POST(request:Request) { const input=join(dir,'input'); await downloadCanvasMedia(parsed.data.url,input); const save=async(name:string)=>{const buffer=await readFile(join(dir,name));return (await uploadFile(`canvas-video-${randomUUID()}-${name}`,new Uint8Array(buffer).buffer)).url;}; + if(parsed.data.operation==='trim') { + const metadata=await processVideoFile(input,join(dir,'clip.mp4'),'trim',undefined,parsed.data.range); + return NextResponse.json({success:true,data:{url:await save('clip.mp4'),...metadata}}); + } if(parsed.data.operation==='crop') { const metadata=await processVideoFile(input,join(dir,'crop.mp4'),'crop',parsed.data.rect); return NextResponse.json({success:true,data:{url:await save('crop.mp4'),...metadata}}); diff --git a/app/api/canvas/workflow/analyze/route.ts b/app/api/canvas/workflow/analyze/route.ts index 08c5633db..2e63e68b6 100644 --- a/app/api/canvas/workflow/analyze/route.ts +++ b/app/api/canvas/workflow/analyze/route.ts @@ -883,7 +883,7 @@ export async function POST(request: Request) { ? [...inlinedImageReferences, ...extractedVideoFrames].slice(0, 6) : []; - const { text } = await generateText({ + const { text, finishReason } = await generateText({ model, messages: [ { @@ -895,10 +895,18 @@ export async function POST(request: Request) { }, ], temperature: 0.2, - maxTokens: parsed.data.mode === "story-script" ? 4200 : parsed.data.mode === "video-analysis" ? 2200 : 1600, - } as any); + maxOutputTokens: parsed.data.mode === "story-script" ? 12000 : parsed.data.mode === "video-analysis" ? 2200 : 1600, + maxRetries: 0, + abortSignal: AbortSignal.any([request.signal, AbortSignal.timeout(150_000)]), + }); - const parsedJson = extractJsonBlock>(text) || {}; + if (finishReason === "length" || !text.trim()) { + return NextResponse.json({ success: false, error: "分析结果不完整,请缩短输入或重新生成" }, { status: 502 }); + } + const parsedJson = extractJsonBlock>(text); + if (!parsedJson) { + return NextResponse.json({ success: false, error: "分析模型未返回完整结构化结果,请重新生成" }, { status: 502 }); + } const summary = normalizeText(parsedJson.summary) || normalizeText(text).slice(0, 160); @@ -982,12 +990,13 @@ export async function POST(request: Request) { }); } catch (error) { console.error("[Canvas Workflow Analyze] Error:", error); + const timedOut = error instanceof Error && /timeout|timed out|abort/i.test(`${error.name} ${error.message}`); return NextResponse.json( { success: false, - error: error instanceof Error ? error.message : "工作流分析失败", + error: timedOut ? "分析超时,请稍后重试或选择其他分析模型" : error instanceof Error ? error.message : "工作流分析失败", }, - { status: 500 } + { status: timedOut ? 504 : 500 } ); } } diff --git a/components/canvas/LiblibVideoCanvasReplica.tsx b/components/canvas/LiblibVideoCanvasReplica.tsx index be90f96e3..d2e399ce4 100644 --- a/components/canvas/LiblibVideoCanvasReplica.tsx +++ b/components/canvas/LiblibVideoCanvasReplica.tsx @@ -92,6 +92,8 @@ import { videoGenerationService, workflowAnalysisService, } from "@/services/canvas"; +import { VideoPromptToolDialog } from "@/components/canvas/VideoPromptToolDialog"; +import type { PromptToolMode } from "@/lib/video-canvas/prompt-tools"; import { useVideoCanvasPersistence } from "@/hooks/use-video-canvas-persistence"; import type { CanvasInitialProject } from "@/types/canvas"; import { @@ -3404,7 +3406,7 @@ function getConfiguredToolType(definition: VideoCanvasFunctionDefinition) { if (definition.kind === "audio") return "audio"; if (definition.kind === "video" || definition.route === "canvas-generate-video") return "video"; if (definition.kind === "workflow") return "compose"; - if (definition.kind === "multimodal" || definition.route === "canvas-workflow-analyze") return "text"; + if (definition.kind === "multimodal" || definition.route === "canvas-workflow-analyze" || definition.route === "canvas-prompt-tools") return "text"; return "image"; } @@ -3545,7 +3547,7 @@ function getFunctionModel( const fallback = definition.kind === "video" ? config.defaultVideoModel - : definition.kind === "multimodal" + : definition.kind === "multimodal" || definition.route === "canvas-prompt-tools" ? config.defaultMultimodalModel : config.defaultImageModel; return normalizeModelRef(definition.model || fallback) || undefined; @@ -3558,7 +3560,7 @@ function getFunctionModelValue( const fallback = definition.kind === "video" ? config.defaultVideoModel - : definition.kind === "multimodal" + : definition.kind === "multimodal" || definition.route === "canvas-prompt-tools" ? config.defaultMultimodalModel : config.defaultImageModel; return String(definition.model || fallback || "auto").trim() || "auto"; @@ -4404,6 +4406,11 @@ function normalizeTargetModuleNode(node: CloneNode, kind: AddModuleKind, fallbac } function normalizeRestoredNode(node: CloneNode, fallbackIndex: number): CloneNode { + if (getDraftString(node, "uploadStatus") === "uploading" || + (node.kind === "media" && getNodeMediaUrl(node).startsWith("blob:"))) { + return { ...node, draftValues: { ...node.draftValues, mediaUrl: "", url: [], + uploadStatus: "failed", uploadError: "上次素材未完成上传,请重新上传文件" } }; + } const action = getDraftString(node, "action"); const isCharacterThreeViewResultNode = isCharacterThreeViewNode(node); const isNineGridResultNode = @@ -4781,6 +4788,8 @@ export function LiblibVideoCanvasReplica({ const [historyListDownloadPendingId, setHistoryListDownloadPendingId] = useState(null); const [historyWatermarkRulesOpen, setHistoryWatermarkRulesOpen] = useState(false); const [isProjectLoading, setIsProjectLoading] = useState(true); + const [projectLoadError, setProjectLoadError] = useState(""); + const [projectLoadAttempt, setProjectLoadAttempt] = useState(0); const initialProjectRef = useRef(initialProject); initialProjectRef.current = initialProject; const [loadedProject, setLoadedProject] = useState<{ id: string; revision: string } | null>(null); @@ -4809,6 +4818,8 @@ export function LiblibVideoCanvasReplica({ const [subtitleEraseRegions, setSubtitleEraseRegions] = useState>({}); const [subtitleEraseRegionHistory, setSubtitleEraseRegionHistory] = useState>({}); + const clipRequestRunning = useRef(false); + const [clipProcessingNodeId, setClipProcessingNodeId] = useState(null); const [videoClipRanges, setVideoClipRanges] = useState>({}); const [videoAnalysisStates, setVideoAnalysisStates] = useState>({}); const [videoCropGenerateStates, setVideoCropGenerateStates] = useState>({}); @@ -4898,10 +4909,11 @@ export function LiblibVideoCanvasReplica({ async function loadProject() { setIsProjectLoading(true); + setProjectLoadError(""); setLoadedProject(null); try { if (initialProjectRef.current?.id === canvasId) hydrateProject(initialProjectRef.current); - const response = await fetch(`/api/canvas/${encodeURIComponent(canvasId)}`); + const response = await fetch(`/api/canvas/${encodeURIComponent(canvasId)}`, { signal: AbortSignal.timeout(45_000) }); let result = await response.json(); if (!active) return; if (response.status === 404) { @@ -4923,14 +4935,16 @@ export function LiblibVideoCanvasReplica({ hydrateProject(result.data); setLoadedProject({ id: canvasId, revision: String(result.data.updatedAt) }); } catch (error) { - if (active) toast.error(error instanceof Error ? error.message : "加载画布失败,请刷新重试"); + if (active) setProjectLoadError(error instanceof Error && error.name === "TimeoutError" + ? "画布加载超时,请检查网络后重试" + : error instanceof Error ? error.message : "加载画布失败,请重试"); } finally { if (active) setIsProjectLoading(false); } } void loadProject(); return () => { active = false; }; - }, [canvasId]); + }, [canvasId, projectLoadAttempt]); const canvasSnapshot = useMemo(() => JSON.stringify({ title: projectTitle, @@ -5654,19 +5668,48 @@ export function LiblibVideoCanvasReplica({ const addUploadedFilesAtPoint = useCallback( (files: File[], center: { x: number; y: number }) => { if (files.length === 0) return; - const newNodes = files.slice(0, 10).map((file, index) => { - const created = createUploadedMediaNode(file, URL.createObjectURL(file), 0, 0, nodes.length + index + 1); - return { - ...created, - x: Math.round(center.x - created.width / 2 + index * 450), + const batch = files.slice(0, 10); + const newNodes = batch.map((file, index) => { + const created = createUploadedMediaNode(file, "", 0, 0, nodes.length + index + 1); + return { ...created, x: Math.round(center.x - created.width / 2 + index * 450), y: Math.round(center.y - created.height / 2), - }; + draftValues: { ...created.draftValues, uploadStatus: "uploading" } }; }); addNodes(newNodes, []); setActivePanel(null); setCanvasCommand(null); setConnectionMenu(null); - toast.success(files.length === 1 ? `已添加 ${newNodes[0]?.title || "素材"}` : `已添加 ${newNodes.length} 个素材`); + void (async () => { + for (const [index, file] of batch.entries()) { + const id = newNodes[index].id; + try { + let data: { url: string; duration?: number; width?: number; height?: number }; + if (file.type.startsWith("image/")) { + const result = await imageGenerationService.uploadImage(file); + if (!result.success || !result.data?.url) throw new Error(result.error || "图片上传失败"); + data = result.data; + } else { + const form = new FormData(); + form.append("file", file); + const response = await fetch("/api/canvas/media/upload", { method: "POST", body: form, signal: AbortSignal.timeout(180_000) }); + const result = await response.json(); + if (!response.ok || !result.success || !result.data?.url) throw new Error(result.error || "上传失败"); + data = result.data; + } + setNodes(current => current.map(node => node.id === id ? { ...node, draftValues: { + ...node.draftValues, mediaUrl: data.url, url: [data.url], uploadStatus: "complete", + durationSeconds: data.duration, outputSizeLabel: data.width && data.height ? `${data.width} × ${data.height}` : undefined, + } } : node)); + toast.success(`${newNodes[index].title} 上传完成`); + } catch (error) { + const message = error instanceof Error ? error.message : "上传失败,请重新上传"; + setNodes(current => current.map(node => node.id === id ? { ...node, draftValues: { + ...node.draftValues, uploadStatus: "failed", uploadError: message, + } } : node)); + toast.error(`${newNodes[index].title}:${message}`); + } + } + })(); }, [addNodes, nodes.length] ); @@ -8540,46 +8583,46 @@ export function LiblibVideoCanvasReplica({ ); const updateVideoClipRange = useCallback((nodeId: string, nextRange: VideoClipRange) => { - setVideoClipRanges((current) => ({ - ...current, - [nodeId]: { - start: Math.max(0, Math.min(nextRange.start, 0.94)), - end: Math.max(Math.min(nextRange.start + 0.04, 1), Math.min(nextRange.end, 1)), - }, - })); + const start = Math.max(0, Math.min(nextRange.start, 0.94)); + const range = { start, end: Math.max(start + 0.04, Math.min(nextRange.end, 1)) }; + setVideoClipRanges(current => ({ ...current, [nodeId]: range })); + setNodes(current => current.map(node => node.id === nodeId + ? { ...node, draftValues: { ...node.draftValues, clipRange: range } } : node)); }, []); - const applyVideoClipSelection = useCallback( - (nodeId: string) => { - const node = nodes.find((item) => item.id === nodeId); - if (!node) return; - const existingRange = normalizeVideoClipRange( - videoClipRanges[nodeId] || nodes.find((item) => item.id === nodeId)?.draftValues?.clipRange - ); - const normalizedRange = - existingRange.end > existingRange.start - ? existingRange - : { start: existingRange.start, end: Math.min(1, existingRange.start + 0.12) }; - const durationSeconds = getVideoNodeDurationSeconds(node) * Math.max(0.04, normalizedRange.end - normalizedRange.start); - setNodes((current) => - current.map((item) => - item.id === nodeId - ? { - ...item, - draftValues: { - ...(item.draftValues || {}), - clipDurationLabel: formatClipDurationLabel(durationSeconds), - clipRange: normalizedRange, - }, - } - : item - ) - ); + const applyVideoClipSelection = useCallback(async (nodeId: string) => { + const source = nodes.find(item => item.id === nodeId); + if (!source || clipRequestRunning.current) return; + const url = getNodeReferenceMediaUrl(source) || getNodeMediaUrl(source); + if (!url) { toast.error("请先上传视频"); return; } + const range = normalizeVideoClipRange(videoClipRanges[nodeId] || source.draftValues?.clipRange); + clipRequestRunning.current = true; + setClipProcessingNodeId(nodeId); + try { + const response = await fetch("/api/canvas/video/process", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ operation: "trim", url, range }), + signal: AbortSignal.timeout(295_000), + }); + const result = await response.json(); + if (!response.ok || !result.success || !result.data?.url) throw new Error(result.error || "视频剪辑失败"); + const created = createMediaResourceNode({ action: "VIDEO_TRIM", mediaType: "video", mediaUrl: result.data.url, + title: `${source.title} · 剪辑`, x: source.x + source.width + 80, y: source.y }); + const clipNode = { ...created, subtitle: "剪辑结果", draftValues: { ...created.draftValues, + generated: true, generationStatus: "complete", generationProgress: 100, uploadStatus: "complete", + durationSeconds: result.data.duration, outputSizeLabel: `${result.data.width} × ${result.data.height}`, + sourceVideoNodeId: source.id, sourceClipRange: range } }; + addNodes([clipNode], [{ id: makeId("edge"), source: nodeId, target: clipNode.id }]); + setSelectedNodeId(clipNode.id); setActiveVideoToolbarMode(null); - toast.success("已记录当前剪辑范围,视频裁剪导出后端待接入。"); - }, - [nodes, videoClipRanges] - ); + toast.success("视频剪辑完成,已生成新视频"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "视频剪辑失败,原视频已保留"); + } finally { + clipRequestRunning.current = false; + setClipProcessingNodeId(null); + } + }, [addNodes, nodes, videoClipRanges]); const downloadMediaNode = useCallback((node: CloneNode) => { const mediaUrl = getNodeMediaUrl(node) || getNodeReferenceMediaUrl(node); @@ -10914,6 +10957,7 @@ export function LiblibVideoCanvasReplica({ ) : null} {activeVideoToolbarMode === "clip" ? ( ) : null} - {isBusy ? ( + {projectLoadError ? ( +
event.stopPropagation()}> +
+

画布加载失败

{projectLoadError}

+

重新加载成功后即可继续编辑。

+ +
+
+ ) : isBusy ? (
@@ -13445,6 +13497,7 @@ function VideoStoryCanvasNode({ node, selected }: { node: CloneNode; selected: b function VideoClipEditorBar({ anchor, + busy = false, clipRange, durationSeconds, onApply, @@ -13454,6 +13507,7 @@ function VideoClipEditorBar({ title, }: { anchor?: PresetControlAnchor; + busy?: boolean; clipRange: VideoClipRange; durationSeconds: number; onApply: () => void; @@ -13470,7 +13524,7 @@ function VideoClipEditorBar({ ); useEffect(() => { - if (!dragHandle) return; + if (!dragHandle || busy) return; const handlePointerMove = (event: PointerEvent) => { const rect = trackRef.current?.getBoundingClientRect(); if (!rect || rect.width <= 0) return; @@ -13494,7 +13548,7 @@ function VideoClipEditorBar({ window.removeEventListener("pointermove", handlePointerMove); window.removeEventListener("pointerup", handlePointerUp); }; - }, [clipRange.end, clipRange.start, dragHandle, onChange]); + }, [busy, clipRange.end, clipRange.start, dragHandle, onChange]); const selectionWidth = Math.max(clipRange.end - clipRange.start, 0.04); const durationLabel = formatClipDurationLabel(durationSeconds * selectionWidth); @@ -13540,6 +13594,7 @@ function VideoClipEditorBar({ >
@@ -14620,6 +14681,56 @@ function VideoTargetModuleComposer({ const [assistMenu, setAssistMenu] = useState<"reference" | "mark" | "effects" | "motion" | "role" | null>(null); const [advancedSettingsOpen, setAdvancedSettingsOpen] = useState(false); const [prompt, setPrompt] = useState(initialPrompt); + const promptEditorRef = useRef(null); + // Keep typing browser-owned; remount only when applying an external replacement. + const [promptEditorSeed, setPromptEditorSeed] = useState({ revision: 0, text: initialPrompt }); + const [promptToolMode, setPromptToolMode] = useState(null); + const [promptToolModel, setPromptToolModel] = useState("auto"); + const [promptToolBusy, setPromptToolBusy] = useState(false); + const [promptToolOutput, setPromptToolOutput] = useState(""); + const [promptToolSource, setPromptToolSource] = useState(""); + const [promptToolError, setPromptToolError] = useState(""); + const promptToolRequest = useRef(null); + const closePromptTool = () => { + promptToolRequest.current?.abort(); + promptToolRequest.current = null; + setPromptToolMode(null); + setPromptToolBusy(false); + }; + useEffect(() => { + setPromptToolMode(null); + setPromptToolBusy(false); + return () => { promptToolRequest.current?.abort(); promptToolRequest.current = null; }; + }, [node.id]); + const runPromptTool = async (mode: PromptToolMode, modelRef = promptToolModel) => { + if (!prompt.trim()) { toast.info("请先输入提示词"); return; } + promptToolRequest.current?.abort(); + const controller = new AbortController(); + promptToolRequest.current = controller; + setPromptToolMode(mode); + setPromptToolSource(prompt); + setPromptToolOutput(""); + setPromptToolError(""); + setPromptToolBusy(true); + try { + const response = await fetch("/api/canvas/prompt-tools", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mode, prompt, model: modelRef }), + signal: AbortSignal.any([controller.signal, AbortSignal.timeout(100_000)]), + }); + const result = await response.json(); + if (!response.ok || !result.success || !result.data?.text) throw new Error(result.error || "提示词处理失败"); + if (promptToolRequest.current === controller) setPromptToolOutput(result.data.text); + } catch (error) { + if (promptToolRequest.current === controller && !controller.signal.aborted) { + setPromptToolError(error instanceof Error && error.name === "TimeoutError" + ? "提示词处理超时,请重试。原提示词已保留。" + : error instanceof Error ? error.message : "提示词处理失败,原文已保留"); + } + } finally { + if (promptToolRequest.current === controller) { promptToolRequest.current = null; setPromptToolBusy(false); } + } + }; const [markMenuAnchor, setMarkMenuAnchor] = useState<{ id: string; left: number; @@ -14653,6 +14764,11 @@ function VideoTargetModuleComposer({ useEffect(() => { setActiveTab(initialTab); + const editorClone = promptEditorRef.current?.cloneNode(true) as HTMLElement | undefined; + editorClone?.querySelectorAll("[data-magic-id]").forEach(element => element.remove()); + if ((editorClone?.textContent || "").replace(/\u00a0/g, " ").trim() !== initialPrompt) { + setPromptEditorSeed(current => ({ revision: current.revision + 1, text: initialPrompt })); + } setPrompt(initialPrompt); setSettings(initialSettings); }, [initialPrompt, initialTab, node.id]); @@ -14781,6 +14897,17 @@ function VideoTargetModuleComposer({ width: composerWidth, }} > + {promptToolMode ? ({ id: getCanvasModelRef(item), label: getCanvasModelDisplayName(getCanvasModelRef(item), modelOptions) }))} + onModelChange={value => { setPromptToolModel(value); setPromptToolOutput(""); setPromptToolError(""); }} + onOutputChange={setPromptToolOutput} onClose={closePromptTool} + onRun={() => { void runPromptTool(promptToolMode); }} + onApply={() => { setPromptEditorSeed(current => ({ revision: current.revision + 1, text: promptToolOutput })); setPrompt(promptToolOutput); onPromptChange(promptToolOutput); closePromptTool(); toast.success("已填入提示词"); }} + /> : null} +
{ const clone = event.currentTarget.cloneNode(true) as HTMLElement; clone.querySelectorAll("[data-magic-id]").forEach((element) => element.remove()); @@ -15035,7 +15164,7 @@ function VideoTargetModuleComposer({ suppressContentEditableWarning tabIndex={0} > - {prompt} + {promptEditorSeed.text} {magicMarks.length > 0 ? " " : null} {magicMarks.map((mark) => { const label = mark.labelOptions[mark.selectedIndex] || mark.labelOptions[0]; @@ -15171,7 +15300,7 @@ function VideoTargetModuleComposer({
- {mediaType === "audio" ? ( + {getDraftString(node, "uploadStatus") === "uploading" || getDraftString(node, "uploadStatus") === "failed" ? ( +
+ {getDraftString(node, "uploadStatus") === "uploading" ? <>正在上传素材… : {getDraftString(node, "uploadError") || "上传失败,请重新上传"}} +
+ ) : mediaType === "audio" ? ( ) : mediaType === "video" ? (
void; + onOutputChange: (value: string) => void; + onRun: () => void; + onApply: () => void; + onClose: () => void; +}) { + const title = mode === "optimize" ? "提示词优化" : "提示词翻译"; + return ( + { if (!open) onClose(); }}> + event.stopPropagation()} className="flex max-h-[90dvh] w-[620px] max-w-full flex-col gap-4 overflow-y-auto rounded-2xl border border-white/10 bg-[#262626] p-5 text-white shadow-2xl"> +
+ {title} + +
+ +
查看原提示词

{source}

+ {error ?

{error}

: null} +