From f7550251ec99391c76d5e614a907fa437432437c Mon Sep 17 00:00:00 2001 From: 134 Date: Wed, 16 Sep 2026 22:12:35 +0800 Subject: [PATCH] fix(video-canvas): preserve story length and archive generated videos --- app/api/canvas/generate/video/route.ts | 22 +- app/api/canvas/video/archive/route.ts | 23 ++ app/api/canvas/workflow/analyze/route.ts | 232 +----------------- .../canvas/LiblibVideoCanvasReplica.tsx | 48 +++- components/canvas/VideoArchiveButton.tsx | 23 ++ docs/video-canvas-completion-2026-09-16.md | 2 + docs/video-canvas-followup-2026-09-16.md | 36 +++ docs/video-canvas-parity-2026-09-16.md | 2 + lib/net/url-access.ts | 22 +- lib/video-canvas/archive-result.ts | 19 ++ lib/video-canvas/archive-video.ts | 35 +++ lib/video-canvas/reference-input.ts | 25 ++ lib/video-canvas/story-script.ts | 138 +++++++++++ playwright.helpers.config.ts | 1 + tests/routes/video-canvas-followup.test.ts | 47 ++++ 15 files changed, 427 insertions(+), 248 deletions(-) create mode 100644 app/api/canvas/video/archive/route.ts create mode 100644 components/canvas/VideoArchiveButton.tsx create mode 100644 docs/video-canvas-followup-2026-09-16.md create mode 100644 lib/video-canvas/archive-result.ts create mode 100644 lib/video-canvas/archive-video.ts create mode 100644 lib/video-canvas/reference-input.ts create mode 100644 lib/video-canvas/story-script.ts create mode 100644 tests/routes/video-canvas-followup.test.ts diff --git a/app/api/canvas/generate/video/route.ts b/app/api/canvas/generate/video/route.ts index f36270cfb..860817982 100644 --- a/app/api/canvas/generate/video/route.ts +++ b/app/api/canvas/generate/video/route.ts @@ -1,7 +1,8 @@ import { NextResponse } from "next/server"; import { auth } from "@/app/(auth)/auth"; import { executeDirectVideoGeneration } from "@/lib/ai/tools/generate-video"; -import { filterReachableRemoteUrls } from "@/lib/net/url-access"; +import { probeRemoteUrl } from "@/lib/net/url-access"; +import { validateVideoReferences } from "@/lib/video-canvas/reference-input"; import type { VideoGenerationMode, VideoRatio, VideoResolution } from "@/types/ai"; export const maxDuration = 300; @@ -48,18 +49,11 @@ export async function POST(request: Request) { ); } - const inputImages = Array.isArray(body.images) - ? body.images.map((item) => String(item || "").trim()).filter(Boolean).slice(0, 8) - : []; - const { kept: reachableImages, dropped: unreachableImages } = - await filterReachableRemoteUrls(inputImages, { timeoutMs: 3000 }); - - if (unreachableImages.length > 0) { - return NextResponse.json( - { success: false, error: "参考图片链接不可访问,请重新上传后再试" }, - { status: 400 } - ); + const referenceCheck = await validateVideoReferences(body.images, url => probeRemoteUrl(url, { timeoutMs: 8000 })); + if (referenceCheck.error) { + return NextResponse.json({ success: false, error: referenceCheck.error }, { status: referenceCheck.status }); } + const reachableImages = referenceCheck.images; const mode = body.mode || inferMode(reachableImages); const result = await executeDirectVideoGeneration({ @@ -94,13 +88,13 @@ export async function POST(request: Request) { success: true, data: { url: result.videoUrl, - metadata: result.metadata || null, + metadata: { ...result.metadata, temporaryVideoUrl: result.videoUrl, storageStatus: result.metadata?.permanentVideoUrl === result.videoUrl ? "archived" : "temporary" }, modelDisplayName: result.modelDisplayName, modelId: result.modelId, modelRef: result.modelRef, mode, images: reachableImages, - droppedImages: unreachableImages, + droppedImages: [], }, }); } catch (error) { diff --git a/app/api/canvas/video/archive/route.ts b/app/api/canvas/video/archive/route.ts new file mode 100644 index 000000000..f76790464 --- /dev/null +++ b/app/api/canvas/video/archive/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { auth } from "@/app/(auth)/auth"; +import { archiveCanvasVideo } from "@/lib/video-canvas/archive-video"; + +export const runtime = "nodejs"; +export const maxDuration = 300; +const running = new Set(); +const schema = z.object({ url: z.string().url().max(12000) }); +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)); + if (!parsed.success) return NextResponse.json({ success: false, error: "视频地址无效" }, { status: 400 }); + if (running.has(session.user.id)) return NextResponse.json({ success: false, error: "视频正在保存,请稍候" }, { status: 429 }); + running.add(session.user.id); + try { + const url = await archiveCanvasVideo(parsed.data.url); + return NextResponse.json({ success: true, data: { url, storageStatus: "archived", permanentVideoUrl: url } }); + } catch { + return NextResponse.json({ success: false, error: "视频转存失败,原视频仍保留;请稍后重试保存,无需重新生成" }, { status: 502 }); + } finally { running.delete(session.user.id); } +} diff --git a/app/api/canvas/workflow/analyze/route.ts b/app/api/canvas/workflow/analyze/route.ts index 2e63e68b6..627943d10 100644 --- a/app/api/canvas/workflow/analyze/route.ts +++ b/app/api/canvas/workflow/analyze/route.ts @@ -1,3 +1,4 @@ +import { normalizeStoryScriptRows } from "@/lib/video-canvas/story-script"; import { isCanvasLanguageModel, isCanvasVisionModel } from "@/lib/video-canvas/model-matching"; import { generateText } from "ai"; import { execFile } from "node:child_process"; @@ -221,226 +222,6 @@ function normalizeVideoAnalysisRows( .slice(0, 8); } -function normalizeStoryShot(value: unknown, index: number) { - const text = normalizeText(value); - if (/^\d+$/.test(text)) return text.padStart(2, "0"); - return text || String(index + 1).padStart(2, "0"); -} - -type NormalizedStoryScriptRow = { - shot: string; - duration: string; - scene: string; - sceneDescription: string; - shotSize: string; - lighting: string; - dialogue: string; - backgroundMusic: string; - voiceAndSfx: string; - cameraMovement: string; - imagePrompt: string; - motionPrompt: string; - keyframeUrl: string; -}; - -function normalizeStoryScriptRows(value: unknown): NormalizedStoryScriptRow[] { - if (!Array.isArray(value)) return []; - return value - .map((item, index) => { - if (Array.isArray(item)) { - const cells = item.map((cell) => normalizeText(cell)); - const shot = normalizeStoryShot(cells[0], index); - const secondCellLooksLikeDuration = /^\d+(\.\d+)?\s*(s|秒)?$/i.test(cells[1] || ""); - const duration = secondCellLooksLikeDuration ? cells[1] : "5s"; - const scene = secondCellLooksLikeDuration ? cells[2] || `镜头 ${shot}` : cells[1] || `镜头 ${shot}`; - const sceneDescription = secondCellLooksLikeDuration ? cells[2] || scene : cells[2] || cells[3] || scene; - return { - shot, - duration, - scene, - sceneDescription, - shotSize: (secondCellLooksLikeDuration ? cells[3] : "") || "中景", - lighting: (secondCellLooksLikeDuration ? cells[4] : cells[4]) || "电影感光影", - dialogue: (secondCellLooksLikeDuration ? cells[5] : "") || "+", - backgroundMusic: "无", - voiceAndSfx: (secondCellLooksLikeDuration ? cells[6] : cells[3]) || "无", - cameraMovement: (secondCellLooksLikeDuration ? cells[7] : "") || "缓慢推镜头", - imagePrompt: (secondCellLooksLikeDuration ? cells[8] : cells[5]) || sceneDescription, - motionPrompt: - (secondCellLooksLikeDuration ? cells[9] || cells[7] : cells[6]) || - "保持镜头运动自然、连贯,围绕主体动作推进。", - keyframeUrl: "", - }; - } - - const raw = - item && typeof item === "object" && !Array.isArray(item) - ? (item as Record) - : {}; - const shot = normalizeStoryShot(raw.shot || raw.shotNumber || raw.index || raw.id, index); - const duration = normalizeText(raw.duration || raw.length || raw.time || raw.seconds); - const scene = normalizeText(raw.scene || raw.location || raw.title || `镜头 ${shot}`); - const sceneDescription = normalizeText( - raw.sceneDescription || - raw.description || - raw.visual || - raw.picture || - raw.screen || - raw.summary, - ); - const shotSize = normalizeText( - raw.shotSize || raw.frameSize || raw.shot_type || raw.sceneScale || raw.cameraShot, - ); - const lighting = normalizeText( - raw.lighting || raw.light || raw.atmosphere || raw.mood || raw.lightMood, - ); - const dialogue = normalizeText( - raw.dialogue || raw.dialog || raw.narration || raw.voiceover || raw.monologue || raw.line, - ); - const cameraMovement = normalizeText( - raw.cameraMovement || raw.cameraMove || raw.movement || raw.lensMovement || raw.motion, - ); - const imagePrompt = normalizeText( - raw.imagePrompt || - raw.image_generation_prompt || - raw.image || - raw.keyframePrompt || - raw.keyFramePrompt, - ); - const motionPrompt = normalizeText( - raw.motionPrompt || - raw.videoPrompt || - raw.video_motion_prompt || - raw.motion || - raw.cameraMovement, - ); - const backgroundMusic = normalizeText( - raw.backgroundMusic || raw.music || raw.bgm || raw.soundtrack, - ); - const voiceAndSfx = normalizeText( - raw.voiceAndSfx || raw.voice || raw.sound || raw.audio || raw.sfx, - ); - - if ( - !scene && - !sceneDescription && - !imagePrompt && - !motionPrompt && - !backgroundMusic && - !voiceAndSfx - ) { - return null; - } - - return { - shot, - duration: duration || "5s", - scene, - sceneDescription: sceneDescription || imagePrompt || scene || `镜头 ${shot}`, - shotSize: shotSize || "中景", - lighting: lighting || "电影感光影", - dialogue: dialogue || "+", - backgroundMusic: backgroundMusic || "无", - voiceAndSfx: voiceAndSfx || "无", - cameraMovement: cameraMovement || "缓慢推镜头", - imagePrompt: imagePrompt || sceneDescription || scene || `镜头 ${shot}关键帧`, - motionPrompt: motionPrompt || cameraMovement || "保持镜头运动自然、连贯,围绕主体动作推进。", - keyframeUrl: "", - }; - }) - .filter( - ( - item, - ): item is NormalizedStoryScriptRow => Boolean(item), - ) - .slice(0, 18); -} - -const storyExpansionPhases = [ - { - description: "建立镜头,交代环境、人物位置和当前压力", - cameraMovement: "缓慢推镜头", - imagePrompt: "广角或中景建立画面,环境信息清晰,主体位置明确", - lighting: "环境主光明确,氛围基调清晰", - motionPrompt: "镜头缓慢推进或轻微横移,建立空间关系", - shotSize: "中景", - }, - { - description: "动作推进,突出人物的关键行为或剧情转折", - cameraMovement: "跟随主体动作推进", - imagePrompt: "中景表现人物动作,服装、道具和光影保持连续", - lighting: "主体光影连续,动作区域清晰", - motionPrompt: "跟随主体动作推进,节奏自然连贯", - shotSize: "中景", - }, - { - description: "情绪反应,用近景或特写强化人物心理和冲突", - cameraMovement: "轻微推近或稳定特写", - imagePrompt: "近景或特写,突出表情、眼神、手部或关键道具细节", - lighting: "面部光线突出,情绪反差增强", - motionPrompt: "轻微推近或稳定特写,强调情绪变化", - shotSize: "特写", - }, - { - description: "关系与氛围,表现周围人物、环境反馈或声画张力", - cameraMovement: "横移扫过环境或切换视角", - imagePrompt: "加入环境反馈、群像反应或空间层次,电影感光影", - lighting: "场景氛围增强,空间层次明显", - motionPrompt: "镜头扫过环境或切换视角,保持节奏递进", - shotSize: "全景", - }, - { - description: "转场收束,为下一镜头保留动作、视线或情绪钩子", - cameraMovement: "顺势后移、上摇或转场", - imagePrompt: "构图留出转场方向,动作或视线指向下一段剧情", - lighting: "光影引导视线并保留转场方向", - motionPrompt: "镜头后移、上摇或顺势转场,衔接下一镜头", - shotSize: "大远景", - }, -]; - -function expandStoryScriptRowsToTarget( - rows: NormalizedStoryScriptRow[], - targetCount = 18, -): NormalizedStoryScriptRow[] { - if (rows.length === 0) return []; - if (rows.length >= targetCount) { - return rows.slice(0, targetCount).map((row, index) => ({ - ...row, - shot: String(index + 1).padStart(2, "0"), - })); - } - - const baseCount = Math.floor(targetCount / rows.length); - const extraCount = targetCount % rows.length; - const expanded: NormalizedStoryScriptRow[] = []; - - rows.forEach((row, rowIndex) => { - const segmentCount = baseCount + (rowIndex < extraCount ? 1 : 0); - for (let segmentIndex = 0; segmentIndex < segmentCount; segmentIndex += 1) { - const phase = - storyExpansionPhases[ - Math.min( - storyExpansionPhases.length - 1, - Math.floor((segmentIndex / Math.max(segmentCount, 1)) * storyExpansionPhases.length), - ) - ]; - expanded.push({ - ...row, - shot: String(expanded.length + 1).padStart(2, "0"), - sceneDescription: `${phase.description}:${row.sceneDescription}`, - shotSize: row.shotSize || phase.shotSize, - lighting: row.lighting || phase.lighting, - cameraMovement: row.cameraMovement || phase.cameraMovement, - imagePrompt: `${row.imagePrompt};${phase.imagePrompt}`, - motionPrompt: `${phase.motionPrompt};${row.motionPrompt}`, - }); - } - }); - - return expanded.slice(0, targetCount); -} - function normalizeCharacters(value: unknown) { if (!Array.isArray(value)) return []; return value @@ -764,11 +545,11 @@ function buildStoryScriptPrompt(params: { ], "recommendedNextSteps": ["2-4条建议的下游动作"] }`, - "analysisRows 必须直接输出 18 行,按镜头时间顺序编号 01-18;每行默认 5s 或 6s,剧情高潮可用 6s。", + "优先严格遵守用户指定的镜头数量和总时长;未指定时生成 6 个镜头,最多 18 个。镜头时长之和必须符合用户要求,8 秒短片不得扩写成几十秒。只生成实际需要的镜头,不重复凑数。", "每行要覆盖竞品脚本表同等信息:镜号、时长、画面描述、景别、光影氛围、对白·旁白、音效、运镜、最终图像提示词和视频运动提示词。", "画面描述中用 @角色名 或 @场景/道具名 标出可复用资产,例如 @沈昭昭、@金銮殿、@侍卫长刀。", "imagePrompt 要像可直接生成分镜图的中文提示词,包含主体、环境、构图、光影、风格与一致性要求;motionPrompt 要按起始状态、动作过程、结束状态、音效组织。", - "assetCandidates 根据 18 镜头抽取 3-6 个角色、2-5 个场景和 1-4 个道具,方便后续准备资产。", + "assetCandidates 只提取脚本中确实出现的角色、场景和道具;产品视频没有人物时 characters 必须为空,禁止为凑数虚构角色。", "每行要像专业视频画布脚本表:场景具体、画面可生成、声音可执行、镜头节奏明确,不要只写剧情摘要。", ] .filter(Boolean) @@ -955,9 +736,12 @@ export async function POST(request: Request) { } if (parsed.data.mode === "story-script") { - const analysisRows = expandStoryScriptRowsToTarget(normalizeStoryScriptRows( + const analysisRows = normalizeStoryScriptRows( parsedJson.analysisRows || parsedJson.storyRows || parsedJson.rows || parsedJson.shots, - )); + ); + if (analysisRows.length === 0) { + return NextResponse.json({ success: false, error: "分析模型没有返回有效镜头,请重新生成" }, { status: 502 }); + } return NextResponse.json({ success: true, data: { diff --git a/components/canvas/LiblibVideoCanvasReplica.tsx b/components/canvas/LiblibVideoCanvasReplica.tsx index d2e399ce4..30931e39a 100644 --- a/components/canvas/LiblibVideoCanvasReplica.tsx +++ b/components/canvas/LiblibVideoCanvasReplica.tsx @@ -92,6 +92,8 @@ import { videoGenerationService, workflowAnalysisService, } from "@/services/canvas"; +import { preserveGeneratedVideo } from "@/lib/video-canvas/archive-result"; +import { VideoArchiveButton } from "@/components/canvas/VideoArchiveButton"; import { VideoPromptToolDialog } from "@/components/canvas/VideoPromptToolDialog"; import type { PromptToolMode } from "@/lib/video-canvas/prompt-tools"; import { useVideoCanvasPersistence } from "@/hooks/use-video-canvas-persistence"; @@ -2230,6 +2232,8 @@ function getVideoNodePreviewUrl(node: CloneNode | null | undefined) { } function getVideoNodeDurationSeconds(node: CloneNode | null | undefined) { + const recordedDuration = Number(node?.draftValues?.durationSeconds); + if (Number.isFinite(recordedDuration) && recordedDuration > 0) return recordedDuration; const params = getNodeParams(node); const paramsSettings = params.settings && typeof params.settings === "object" @@ -4946,6 +4950,35 @@ export function LiblibVideoCanvasReplica({ return () => { active = false; }; }, [canvasId, projectLoadAttempt]); + const videoArchiveRunning = useRef(false); + const attemptedVideoArchives = useRef(new Set()); + const [videoArchiveTick, setVideoArchiveTick] = useState(0); + useEffect(() => { + if (!loadedProject || isProjectLoading || videoArchiveRunning.current) return; + const target = nodes.find(node => { + const meta = node.draftValues?.videoMeta as Record | undefined; + return getDraftString(node, "generationStatus") === "complete" && meta?.temporaryVideoUrl && ["temporary", "archiving"].includes(String(meta.storageStatus)) && !meta.archiveError && + getNodeMediaUrl(node) && !attemptedVideoArchives.current.has(`${node.id}:${getNodeMediaUrl(node)}`); + }); + if (!target) return; + const sourceUrl = getNodeMediaUrl(target); + const metadata = target.draftValues?.videoMeta as Record; + attemptedVideoArchives.current.add(`${target.id}:${sourceUrl}`); + videoArchiveRunning.current = true; + const update = (url: string, meta: Record) => setNodes(current => current.map(node => + node.id === target.id && getNodeMediaUrl(node) === sourceUrl ? { ...node, draftValues: { + ...node.draftValues, mediaUrl: url, url: [url], videoMeta: meta, + } } : node)); + update(sourceUrl, { ...metadata, storageStatus: "archiving" }); + void preserveGeneratedVideo(sourceUrl, metadata, async url => { + const response = await fetch("/api/canvas/video/archive", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url }), signal: AbortSignal.timeout(290_000) }); + const result = await response.json(); + if (!response.ok || !result.success || !result.data?.url) throw new Error("视频转存失败"); + return result.data.url; + }).then(stored => update(stored.url, { ...stored.metadata, archiveError: stored.metadata.storageStatus === "temporary" ? "转存失败,可重试保存" : undefined })) + .finally(() => { videoArchiveRunning.current = false; setVideoArchiveTick(value => value + 1); }); + }, [nodes, loadedProject, isProjectLoading, videoArchiveTick]); + const canvasSnapshot = useMemo(() => JSON.stringify({ title: projectTitle, canvasData: { @@ -10881,6 +10914,16 @@ export function LiblibVideoCanvasReplica({ {selectedVideoMediaNode && selectedVideoAnchor ? (
{ + const meta = selectedVideoMediaNode.draftValues?.videoMeta as Record | undefined; + const sourceUrl = getNodeMediaUrl(selectedVideoMediaNode); + if (!meta?.temporaryVideoUrl || meta.storageStatus === "archived" || !sourceUrl) return null; + const nodeId = selectedVideoMediaNode.id; + return setNodes(current => current.map(node => + node.id === nodeId && getNodeMediaUrl(node) === sourceUrl ? { ...node, draftValues: { + ...node.draftValues, mediaUrl: url, url: [url], videoMeta: { ...meta, storageStatus: "archived", permanentVideoUrl: url }, + } } : node))} />; + })()} activeMode={activeVideoToolbarMode} analysisLoading={videoAnalysisStates[selectedVideoMediaNode.id]?.status === "loading"} anchor={selectedVideoAnchor} @@ -12530,6 +12573,7 @@ function VideoToolbarActionButton({ } function OfficialVideoMediaToolbar({ + archiveControl, activeMode, analysisLoading = false, anchor, @@ -12542,6 +12586,7 @@ function OfficialVideoMediaToolbar({ onToggleClip, onVoiceSplit, }: { + archiveControl?: ReactNode; activeMode: VideoToolbarMode; analysisLoading?: boolean; anchor?: PresetControlAnchor; @@ -12576,6 +12621,7 @@ function OfficialVideoMediaToolbar({ } } > + {archiveControl} } @@ -21371,7 +21417,7 @@ function OfficialVideoResultCard({ const [currentTime, setCurrentTime] = useState(0); const [frameMenuOpen, setFrameMenuOpen] = useState(false); const [hovered, setHovered] = useState(false); - const safeDuration = Math.max(1, durationSeconds || 5); + const safeDuration = Math.max(0.1, durationSeconds || 5); const progress = Math.max(0, Math.min(currentTime / safeDuration, 1)); const controlsVisible = hovered || selected || frameMenuOpen; const frameActions: Array<{ kind: VideoFrameExtractionKind; label: string }> = [ diff --git a/components/canvas/VideoArchiveButton.tsx b/components/canvas/VideoArchiveButton.tsx new file mode 100644 index 000000000..a1bb8b1d2 --- /dev/null +++ b/components/canvas/VideoArchiveButton.tsx @@ -0,0 +1,23 @@ +"use client"; +import { useRef, useState } from "react"; +import { Download, Loader2 } from "lucide-react"; +import { toast } from "sonner"; + +export function VideoArchiveButton({ url, automaticBusy = false, onSaved }: { url: string; automaticBusy?: boolean; onSaved: (url: string) => void }) { + const running = useRef(false); + const [busy, setBusy] = useState(false); + return ; +} diff --git a/docs/video-canvas-completion-2026-09-16.md b/docs/video-canvas-completion-2026-09-16.md index 9b801c5a4..ff7b653ca 100644 --- a/docs/video-canvas-completion-2026-09-16.md +++ b/docs/video-canvas-completion-2026-09-16.md @@ -1,5 +1,7 @@ # 视频画布继续完善与复测(2026-09-16) +> 后续第三轮进展见 [脚本、参考图与视频转存回归](video-canvas-followup-2026-09-16.md)。 + 本记录接续 [上一轮真实测试](video-canvas-real-test-2026-09-16.md)。独立工作树本地验证,沿用真实服务商和数据库配置,使用专用测试账号;没有启用模拟接口,没有修改真实用户画布或默认模型。 ## 本轮完成的功能 diff --git a/docs/video-canvas-followup-2026-09-16.md b/docs/video-canvas-followup-2026-09-16.md new file mode 100644 index 000000000..a11ae04d7 --- /dev/null +++ b/docs/video-canvas-followup-2026-09-16.md @@ -0,0 +1,36 @@ +# 视频画布第三轮完善与回归(2026-09-16) + +接续 [上一轮记录](video-canvas-completion-2026-09-16.md)。继续在独立本地工作树测试,未部署生产,未更改官方千问 3.8 默认模型。 + +## 逐项完成 + +| 功能 | 查明的问题 | 本轮修复与验证 | +|---|---|---| +| 故事脚本 | 系统强制 18 镜头,不足时再复制内容扩充;用户要求 3 镜头也被扩大 | 删除自动补镜头;优先遵守用户镜头数、总时长;未指定时默认 6 镜头、最多 18。空行不再变成虚构镜头 | +| 参考图检查 | 同一张图作为首尾帧被去重;网络波动和链接失效都报 400 | 探测去重但保持输入位置;无效输入明确拒绝;临时连接故障报 503、失效或无权限报 400;HEAD 不成功时继续 GET 检查并释放响应体 | +| 视频长期保存 | 画布直接保留服务商临时链接,没有进入聊天消息的归档任务 | 视频先显示,然后后台转存至现有存储,成功后自动更新画布 URL;读取视频文件并确认视频流后才上传 | +| 转存失败恢复 | 缺少单独重试,只能重新生成 | 保留原视频及临时状态,提供“转存视频”;保存操作不调用生成模型。请求去重、并发限制、公共 IP 校验、125 MiB 下载限制沿用或补齐 | +| 视频时长显示 | 上传与剪辑保存 durationSeconds,但播放器和剪辑栏未读取,常回退 5 秒 | 优先使用实际文件时长,短片允许不足 1 秒的真实时长 | + +自动转存由打开的画布触发。关闭页面前尚未完成的转存,会在重新打开、恢复临时状态后再尝试;这不是新增服务器持久化生成队列,不能保证用户离开后所有生成任务继续交付。已归档的节点使用存储地址,不依赖原服务商链接。 + +## 真实测试 + +| 测试 | 结果 | 证据 | +|---|---|---| +| 官方千问 3.8 三镜头脚本 | **通过,71.853 秒** | 返回且只返回 3 行,时长 3s / 2s / 3s,总计 8 秒,分别为外观、杯柄、咖啡液面;未虚构人物 | +| 已生成 Veo 视频转存 | **通过,20.233 秒** | 七牛存储新地址可下载;ffprobe 为 H264 + AAC、1280×720、8 秒、1,005,090 字节,与转存前一致 | +| 首帧图生视频复测 | **未通过,193.066 秒** | 参考图通过检查并提交生成;查询结果时上游返回 502。任务 `8aa87fb0-ef63-4f38-8930-0b1796331be4` | +| 页面自动转存、播放与刷新恢复 | **通过** | 使用上一轮真实成片作为测试节点,页面出现正在转存状态,完成后 video.currentSrc 从 api.vip98k.com 切至 scqny.jiansun.vip;8 秒时长、播放器完整播放至 8 秒;画布保存后刷新仍使用七牛地址,readyState 为 4;本地冷编译和数据库隧道使该次转存约 2.2 分钟,不当作生产耗时 | +| 同一任务从服务器只读复查 | **上游确认失败** | HTTP 409,`task_failed` / `Video generation failed`,请求 ID `852b2e2e-d288-47ec-8a94-9ed272f4070e`;未重复提交生成 | +| 定向回归 | **65 项通过** | 7 项新契约 + 58 项已有回归;覆盖镜头不扩充、空行、首尾帧重复 URL、无效输入、暂时网络失败、归档成功与保留失败产物 | + +浏览器回归使用专用账号和写入正确画布节点格式的测试素材,素材来自上一轮真实 Veo 成片。该测试用于验证自动转存、状态持久化和刷新后媒体读取,不冒充本轮新生成。原始证据保存在 `.qa/video-followup/`,带临时签名的地址不提交 Git。 + +## 尚未完成 + +首帧/首尾帧/多参考视频不能因文生视频成功而全部验收;本轮首帧已拿到明确上游失败状态。音频驱动视频、视频超清、去字幕、专业人声分离、完整 AutoLink 和智能剪辑等继续保持待完成状态。没有宣称整体像素级复刻完成。 + +另发现独立问题:对尚不存在的画布 ID 首次 PUT 且携带节点时,接口先写 CanvasNode、后创建 CanvasProject,触发外键约束失败;标准 POST 新建流程不受此特定顺序问题影响。该问题尚未在本轮修改,需另行修复并验证并发创建行为。 + +源码 TypeScript 检查通过;专用测试账号已禁用并撤销会话,临时凭据已删除,本轮开发服务和数据库隧道已停止;开发工具生成的配置改动不提交。 diff --git a/docs/video-canvas-parity-2026-09-16.md b/docs/video-canvas-parity-2026-09-16.md index b09c46edc..a0ecd7a36 100644 --- a/docs/video-canvas-parity-2026-09-16.md +++ b/docs/video-canvas-parity-2026-09-16.md @@ -1,5 +1,7 @@ # 视频画布模型匹配与逐项对照(2026-09-16) +> 后续第三轮进展见 [脚本、参考图与视频转存回归](video-canvas-followup-2026-09-16.md)。 + > 后续进展见 [继续完善与复测记录](video-canvas-completion-2026-09-16.md):官方千问 3.8 提示词处理、Veo 文生视频、完整媒体处理 API 已出现真实成功结果。下文保留上一轮当时的测试状态。 参考:[LibTV 画布](https://www.liblib.tv/canvas)。以登录后的 1280×720 画布为界面对照基准。本记录对应此前“模型接通即可,不继续真实生成测试”的阶段;用户随后已要求恢复真实测试,最新结果见 [真实测试与参考站对照](video-canvas-real-test-2026-09-16.md)。因此下表区分接口与参数核验、界面核验、实际生成,不能把未生成的功能标为产物验收通过,也不把局部对齐称为整站像素级复刻完成。 diff --git a/lib/net/url-access.ts b/lib/net/url-access.ts index 8742a1a87..b6cacaa65 100644 --- a/lib/net/url-access.ts +++ b/lib/net/url-access.ts @@ -15,12 +15,12 @@ function withTimeout(timeoutMs: number) { }; } -export async function isRemoteUrlReachable( +export async function probeRemoteUrl( url: string, options: UrlAccessOptions = {} -): Promise { +): Promise<"reachable" | "unreachable" | "unknown"> { const target = String(url || "").trim(); - if (!/^https?:\/\//i.test(target)) return false; + if (!/^https?:\/\//i.test(target)) return "unreachable"; const timeoutMs = Math.max(800, Number(options.timeoutMs || DEFAULT_TIMEOUT_MS)); const headCtx = withTimeout(timeoutMs); @@ -31,10 +31,8 @@ export async function isRemoteUrlReachable( cache: "no-store", signal: headCtx.signal, }); - if (headResponse.ok) return true; - if (![403, 405, 429, 500, 501, 502, 503, 504].includes(headResponse.status)) { - return false; - } + await headResponse.body?.cancel(); + if (headResponse.ok) return "reachable"; } catch {} finally { headCtx.clear(); @@ -49,14 +47,20 @@ export async function isRemoteUrlReachable( cache: "no-store", signal: getCtx.signal, }); - return getResponse.ok || getResponse.status === 206; + await getResponse.body?.cancel(); + if (getResponse.ok || getResponse.status === 206) return "reachable"; + return [400, 401, 403, 404, 410].includes(getResponse.status) ? "unreachable" : "unknown"; } catch { - return false; + return "unknown"; } finally { getCtx.clear(); } } +export async function isRemoteUrlReachable(url: string, options: UrlAccessOptions = {}): Promise { + return (await probeRemoteUrl(url, options)) === "reachable"; +} + export async function filterReachableRemoteUrls( urls: string[], options: UrlAccessOptions = {} diff --git a/lib/video-canvas/archive-result.ts b/lib/video-canvas/archive-result.ts new file mode 100644 index 000000000..6feb9ebad --- /dev/null +++ b/lib/video-canvas/archive-result.ts @@ -0,0 +1,19 @@ +export type VideoArchiveMetadata = Record & { + storageStatus: "archived" | "temporary"; + temporaryVideoUrl: string; + permanentVideoUrl?: string; +}; + +export async function preserveGeneratedVideo( + sourceUrl: string, + metadata: Record | null | undefined, + archive: (url: string) => Promise, +): Promise<{ url: string; metadata: VideoArchiveMetadata }> { + try { + const url = await archive(sourceUrl); + if (!url || url === sourceUrl) throw new Error("No permanent copy created"); + return { url, metadata: { ...metadata, storageStatus: "archived", temporaryVideoUrl: sourceUrl, permanentVideoUrl: url } }; + } catch { + return { url: sourceUrl, metadata: { ...metadata, storageStatus: "temporary", temporaryVideoUrl: sourceUrl, permanentVideoUrl: undefined } }; + } +} diff --git a/lib/video-canvas/archive-video.ts b/lib/video-canvas/archive-video.ts new file mode 100644 index 000000000..832a55452 --- /dev/null +++ b/lib/video-canvas/archive-video.ts @@ -0,0 +1,35 @@ +import { randomUUID } from "node:crypto"; +import { execFile } from "node:child_process"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { uploadFile } from "@/lib/storage/service"; +import { downloadCanvasMedia } from "./download-media"; + +const probe = promisify(execFile); +const pending = new Map>(); + +export async function archiveCanvasVideo(url: string): Promise { + const existing = pending.get(url); + if (existing) return existing; + if (pending.size >= 2) throw new Error("视频保存繁忙,请稍后重试"); + const job = (async () => { + const dir = await mkdtemp(join(tmpdir(), "canvas-video-archive-")); + try { + const input = join(dir, "video"); + await downloadCanvasMedia(url, input); + const { stdout } = await probe("ffprobe", ["-v", "error", "-protocol_whitelist", "file", "-format_whitelist", "mov,matroska,webm", "-show_streams", "-show_format", "-of", "json", input], { timeout: 15000, maxBuffer: 1024 * 1024 }); + const media = JSON.parse(stdout); + if (!media.streams?.some((stream: { codec_type: string }) => stream.codec_type === "video") || !(Number(media.format?.duration) > 0)) throw new Error("视频文件不完整"); + const extension = String(media.format.format_name).includes("mov") ? "mp4" : "webm"; + const data = await readFile(input); + const uploaded = await uploadFile(`canvas-generated-${randomUUID()}.${extension}`, new Uint8Array(data).buffer); + return uploaded.url; + } finally { + await rm(dir, { recursive: true, force: true }); + } + })(); + pending.set(url, job); + try { return await job; } finally { pending.delete(url); } +} diff --git a/lib/video-canvas/reference-input.ts b/lib/video-canvas/reference-input.ts new file mode 100644 index 000000000..e9d90b773 --- /dev/null +++ b/lib/video-canvas/reference-input.ts @@ -0,0 +1,25 @@ +export type ReferenceAccess = "reachable" | "unreachable" | "unknown"; + +export async function validateVideoReferences( + input: unknown, + probe: (url: string) => Promise, +): Promise<{ images: string[]; error?: string; status?: number }> { + if (input === undefined) return { images: [] }; + if (!Array.isArray(input) || input.length > 8) return { images: [], error: "请提供不超过 8 张参考图", status: 400 }; + const images: string[] = []; + for (const value of input) { + try { + if (typeof value !== "string") throw new Error(); + const parsed = new URL(value.trim()); + if (!["http:", "https:"].includes(parsed.protocol) || parsed.username || parsed.password) throw new Error(); + images.push(value.trim()); + } catch { + return { images: [], error: "参考图地址无效,请重新上传", status: 400 }; + } + } + const checks = await Promise.all([...new Set(images)].map(url => probe(url).catch(() => "unknown" as const))); + if (checks.includes("unreachable")) return { images: [], error: "参考图片链接已失效或无访问权限,请重新上传", status: 400 }; + if (checks.includes("unknown")) return { images: [], error: "参考图片连接暂时超时或服务繁忙,请重试;尚未提交生成任务", status: 503 }; + // First and last frames are positional; identical URLs must remain two entries. + return { images }; +} diff --git a/lib/video-canvas/story-script.ts b/lib/video-canvas/story-script.ts new file mode 100644 index 000000000..fb3505719 --- /dev/null +++ b/lib/video-canvas/story-script.ts @@ -0,0 +1,138 @@ +const normalizeText = (value: unknown) => String(value || "").trim(); + +function normalizeStoryShot(value: unknown, index: number) { + const text = normalizeText(value); + if (/^\d+$/.test(text)) return text.padStart(2, "0"); + return text || String(index + 1).padStart(2, "0"); +} + +type NormalizedStoryScriptRow = { + shot: string; + duration: string; + scene: string; + sceneDescription: string; + shotSize: string; + lighting: string; + dialogue: string; + backgroundMusic: string; + voiceAndSfx: string; + cameraMovement: string; + imagePrompt: string; + motionPrompt: string; + keyframeUrl: string; +}; + +export function normalizeStoryScriptRows(value: unknown): NormalizedStoryScriptRow[] { + if (!Array.isArray(value)) return []; + return value + .map((item, index) => { + if (Array.isArray(item)) { + const cells = item.map((cell) => normalizeText(cell)); + if (!cells.some(Boolean)) return null; + const shot = normalizeStoryShot(cells[0], index); + const secondCellLooksLikeDuration = /^\d+(\.\d+)?\s*(s|秒)?$/i.test(cells[1] || ""); + if (!(secondCellLooksLikeDuration ? cells.slice(2) : cells.slice(1)).some(Boolean)) return null; + const duration = secondCellLooksLikeDuration ? cells[1] : "5s"; + const scene = secondCellLooksLikeDuration ? cells[2] || `镜头 ${shot}` : cells[1] || `镜头 ${shot}`; + const sceneDescription = secondCellLooksLikeDuration ? cells[2] || scene : cells[2] || cells[3] || scene; + return { + shot, + duration, + scene, + sceneDescription, + shotSize: (secondCellLooksLikeDuration ? cells[3] : "") || "中景", + lighting: (secondCellLooksLikeDuration ? cells[4] : cells[4]) || "电影感光影", + dialogue: (secondCellLooksLikeDuration ? cells[5] : "") || "+", + backgroundMusic: "无", + voiceAndSfx: (secondCellLooksLikeDuration ? cells[6] : cells[3]) || "无", + cameraMovement: (secondCellLooksLikeDuration ? cells[7] : "") || "缓慢推镜头", + imagePrompt: (secondCellLooksLikeDuration ? cells[8] : cells[5]) || sceneDescription, + motionPrompt: + (secondCellLooksLikeDuration ? cells[9] || cells[7] : cells[6]) || + "保持镜头运动自然、连贯,围绕主体动作推进。", + keyframeUrl: "", + }; + } + + const raw = + item && typeof item === "object" && !Array.isArray(item) + ? (item as Record) + : {}; + const shot = normalizeStoryShot(raw.shot || raw.shotNumber || raw.index || raw.id, index); + const duration = normalizeText(raw.duration || raw.length || raw.time || raw.seconds); + const scene = normalizeText(raw.scene || raw.location || raw.title); + const sceneDescription = normalizeText( + raw.sceneDescription || + raw.description || + raw.visual || + raw.picture || + raw.screen || + raw.summary, + ); + const shotSize = normalizeText( + raw.shotSize || raw.frameSize || raw.shot_type || raw.sceneScale || raw.cameraShot, + ); + const lighting = normalizeText( + raw.lighting || raw.light || raw.atmosphere || raw.mood || raw.lightMood, + ); + const dialogue = normalizeText( + raw.dialogue || raw.dialog || raw.narration || raw.voiceover || raw.monologue || raw.line, + ); + const cameraMovement = normalizeText( + raw.cameraMovement || raw.cameraMove || raw.movement || raw.lensMovement || raw.motion, + ); + const imagePrompt = normalizeText( + raw.imagePrompt || + raw.image_generation_prompt || + raw.image || + raw.keyframePrompt || + raw.keyFramePrompt, + ); + const motionPrompt = normalizeText( + raw.motionPrompt || + raw.videoPrompt || + raw.video_motion_prompt || + raw.motion || + raw.cameraMovement, + ); + const backgroundMusic = normalizeText( + raw.backgroundMusic || raw.music || raw.bgm || raw.soundtrack, + ); + const voiceAndSfx = normalizeText( + raw.voiceAndSfx || raw.voice || raw.sound || raw.audio || raw.sfx, + ); + + if ( + !scene && + !sceneDescription && + !imagePrompt && + !motionPrompt && + !backgroundMusic && + !voiceAndSfx + ) { + return null; + } + + return { + shot, + duration: duration || "5s", + scene, + sceneDescription: sceneDescription || imagePrompt || scene || `镜头 ${shot}`, + shotSize: shotSize || "中景", + lighting: lighting || "电影感光影", + dialogue: dialogue || "+", + backgroundMusic: backgroundMusic || "无", + voiceAndSfx: voiceAndSfx || "无", + cameraMovement: cameraMovement || "缓慢推镜头", + imagePrompt: imagePrompt || sceneDescription || scene || `镜头 ${shot}关键帧`, + motionPrompt: motionPrompt || cameraMovement || "保持镜头运动自然、连贯,围绕主体动作推进。", + keyframeUrl: "", + }; + }) + .filter( + ( + item, + ): item is NormalizedStoryScriptRow => Boolean(item), + ) + .slice(0, 18); +} diff --git a/playwright.helpers.config.ts b/playwright.helpers.config.ts index 52470a9ea..947e00f54 100644 --- a/playwright.helpers.config.ts +++ b/playwright.helpers.config.ts @@ -3,6 +3,7 @@ import { defineConfig } from "@playwright/test"; export default defineConfig({ testDir: "./tests", testMatch: [ + /routes\/video-canvas-followup\.test\.ts/, /routes\/video-canvas-tools\.test\.ts/, /routes\/video-canvas-save-queue\.test\.ts/, /routes\/video-canvas-model-matching\.test\.ts/, diff --git a/tests/routes/video-canvas-followup.test.ts b/tests/routes/video-canvas-followup.test.ts new file mode 100644 index 000000000..db517f2b5 --- /dev/null +++ b/tests/routes/video-canvas-followup.test.ts @@ -0,0 +1,47 @@ +import { test, expect } from "@playwright/test"; +import { normalizeStoryScriptRows } from "@/lib/video-canvas/story-script"; +import { validateVideoReferences } from "@/lib/video-canvas/reference-input"; +import { preserveGeneratedVideo } from "@/lib/video-canvas/archive-result"; + +test("three requested shots retain their content and durations without cloned filler", () => { + const rows = [{ sceneDescription: "杯子全景", duration: "3s" }, { sceneDescription: "杯柄特写", duration: "2s" }, { sceneDescription: "咖啡液面", duration: "3s" }]; + const result = normalizeStoryScriptRows(rows); + expect(result).toHaveLength(3); + expect(result.map(row => row.sceneDescription)).toEqual(rows.map(row => row.sceneDescription)); + expect(result.map(row => row.duration)).toEqual(["3s", "2s", "3s"]); +}); +test("empty model rows do not create invented shots", () => { + expect(normalizeStoryScriptRows([null, {}, { shot: "01", duration: "3s" }, [], ["", ""], ["01"], ["01", "3s"]])).toEqual([]); +}); +test("same first and last frame survive while only probing the URL once", async () => { + const seen: string[] = []; + const images = ["https://example.com/one.png", "https://example.com/one.png"]; + const out = await validateVideoReferences(images, async url => { seen.push(url); return "reachable"; }); + expect(out.images).toEqual(images); + expect(seen).toHaveLength(1); +}); +test("invalid references never silently turn into text-only generation", async () => { + for (const input of [["not a url"], [null], "https://example.com/a.png", ["https://user:pass@example.com/a.png"]]) { + let calls = 0; + expect((await validateVideoReferences(input, async () => { calls++; return "reachable"; })).status).toBe(400); + expect(calls).toBe(0); + } +}); +test("temporary network errors are distinguished from dead images", async () => { + const images = ["https://example.com/a.png"]; + expect((await validateVideoReferences(images, async () => "unknown")).status).toBe(503); + expect((await validateVideoReferences(images, async () => { throw new Error("network"); })).status).toBe(503); + expect((await validateVideoReferences(images, async () => "unreachable")).status).toBe(400); +}); +test("archive success replaces URL and preserves generation metadata", async () => { + const out = await preserveGeneratedVideo("https://provider.example/video", { duration: 8, model: "veo" }, async () => "https://storage.example/file.mp4"); + expect(out).toMatchObject({ url: "https://storage.example/file.mp4", metadata: { storageStatus: "archived", duration: 8, model: "veo", temporaryVideoUrl: "https://provider.example/video" } }); +}); +test("archive failure preserves the generated output and cannot mark it permanently saved", async () => { + for (const archive of [async () => { throw new Error("storage unavailable"); }, async (url: string) => url]) { + const out = await preserveGeneratedVideo("https://provider.example/video", { permanentVideoUrl: "stale" }, archive); + expect(out.url).toBe("https://provider.example/video"); + expect(out.metadata.storageStatus).toBe("temporary"); + expect(out.metadata.permanentVideoUrl).toBeUndefined(); + } +});