fix(video-canvas): preserve story length and archive generated videos

This commit is contained in:
134 2026-09-16 22:12:35 +08:00
parent 2ba093b714
commit f7550251ec
15 changed files with 427 additions and 248 deletions

View File

@ -1,7 +1,8 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { executeDirectVideoGeneration } from "@/lib/ai/tools/generate-video"; 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"; import type { VideoGenerationMode, VideoRatio, VideoResolution } from "@/types/ai";
export const maxDuration = 300; export const maxDuration = 300;
@ -48,18 +49,11 @@ export async function POST(request: Request) {
); );
} }
const inputImages = Array.isArray(body.images) const referenceCheck = await validateVideoReferences(body.images, url => probeRemoteUrl(url, { timeoutMs: 8000 }));
? body.images.map((item) => String(item || "").trim()).filter(Boolean).slice(0, 8) if (referenceCheck.error) {
: []; return NextResponse.json({ success: false, error: referenceCheck.error }, { status: referenceCheck.status });
const { kept: reachableImages, dropped: unreachableImages } =
await filterReachableRemoteUrls(inputImages, { timeoutMs: 3000 });
if (unreachableImages.length > 0) {
return NextResponse.json(
{ success: false, error: "参考图片链接不可访问,请重新上传后再试" },
{ status: 400 }
);
} }
const reachableImages = referenceCheck.images;
const mode = body.mode || inferMode(reachableImages); const mode = body.mode || inferMode(reachableImages);
const result = await executeDirectVideoGeneration({ const result = await executeDirectVideoGeneration({
@ -94,13 +88,13 @@ export async function POST(request: Request) {
success: true, success: true,
data: { data: {
url: result.videoUrl, url: result.videoUrl,
metadata: result.metadata || null, metadata: { ...result.metadata, temporaryVideoUrl: result.videoUrl, storageStatus: result.metadata?.permanentVideoUrl === result.videoUrl ? "archived" : "temporary" },
modelDisplayName: result.modelDisplayName, modelDisplayName: result.modelDisplayName,
modelId: result.modelId, modelId: result.modelId,
modelRef: result.modelRef, modelRef: result.modelRef,
mode, mode,
images: reachableImages, images: reachableImages,
droppedImages: unreachableImages, droppedImages: [],
}, },
}); });
} catch (error) { } catch (error) {

View File

@ -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<string>();
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); }
}

View File

@ -1,3 +1,4 @@
import { normalizeStoryScriptRows } from "@/lib/video-canvas/story-script";
import { isCanvasLanguageModel, isCanvasVisionModel } from "@/lib/video-canvas/model-matching"; import { isCanvasLanguageModel, isCanvasVisionModel } from "@/lib/video-canvas/model-matching";
import { generateText } from "ai"; import { generateText } from "ai";
import { execFile } from "node:child_process"; import { execFile } from "node:child_process";
@ -221,226 +222,6 @@ function normalizeVideoAnalysisRows(
.slice(0, 8); .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<string, unknown>)
: {};
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) { function normalizeCharacters(value: unknown) {
if (!Array.isArray(value)) return []; if (!Array.isArray(value)) return [];
return value return value
@ -764,11 +545,11 @@ function buildStoryScriptPrompt(params: {
], ],
"recommendedNextSteps": ["2-4条建议的下游动作"] "recommendedNextSteps": ["2-4条建议的下游动作"]
}`, }`,
"analysisRows 必须直接输出 18 行,按镜头时间顺序编号 01-18;每行默认 5s 或 6s,剧情高潮可用 6s。", "优先严格遵守用户指定的镜头数量和总时长;未指定时生成 6 个镜头,最多 18 个。镜头时长之和必须符合用户要求,8 秒短片不得扩写成几十秒。只生成实际需要的镜头,不重复凑数。",
"每行要覆盖竞品脚本表同等信息:镜号、时长、画面描述、景别、光影氛围、对白·旁白、音效、运镜、最终图像提示词和视频运动提示词。", "每行要覆盖竞品脚本表同等信息:镜号、时长、画面描述、景别、光影氛围、对白·旁白、音效、运镜、最终图像提示词和视频运动提示词。",
"画面描述中用 @角色名 或 @场景/道具名 标出可复用资产,例如 @沈昭昭、@金銮殿、@侍卫长刀。", "画面描述中用 @角色名 或 @场景/道具名 标出可复用资产,例如 @沈昭昭、@金銮殿、@侍卫长刀。",
"imagePrompt 要像可直接生成分镜图的中文提示词,包含主体、环境、构图、光影、风格与一致性要求;motionPrompt 要按起始状态、动作过程、结束状态、音效组织。", "imagePrompt 要像可直接生成分镜图的中文提示词,包含主体、环境、构图、光影、风格与一致性要求;motionPrompt 要按起始状态、动作过程、结束状态、音效组织。",
"assetCandidates 根据 18 镜头抽取 3-6 个角色、2-5 个场景和 1-4 个道具,方便后续准备资产。", "assetCandidates 只提取脚本中确实出现的角色、场景和道具;产品视频没有人物时 characters 必须为空,禁止为凑数虚构角色。",
"每行要像专业视频画布脚本表:场景具体、画面可生成、声音可执行、镜头节奏明确,不要只写剧情摘要。", "每行要像专业视频画布脚本表:场景具体、画面可生成、声音可执行、镜头节奏明确,不要只写剧情摘要。",
] ]
.filter(Boolean) .filter(Boolean)
@ -955,9 +736,12 @@ export async function POST(request: Request) {
} }
if (parsed.data.mode === "story-script") { if (parsed.data.mode === "story-script") {
const analysisRows = expandStoryScriptRowsToTarget(normalizeStoryScriptRows( const analysisRows = normalizeStoryScriptRows(
parsedJson.analysisRows || parsedJson.storyRows || parsedJson.rows || parsedJson.shots, parsedJson.analysisRows || parsedJson.storyRows || parsedJson.rows || parsedJson.shots,
)); );
if (analysisRows.length === 0) {
return NextResponse.json({ success: false, error: "分析模型没有返回有效镜头,请重新生成" }, { status: 502 });
}
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
data: { data: {

View File

@ -92,6 +92,8 @@ import {
videoGenerationService, videoGenerationService,
workflowAnalysisService, workflowAnalysisService,
} from "@/services/canvas"; } from "@/services/canvas";
import { preserveGeneratedVideo } from "@/lib/video-canvas/archive-result";
import { VideoArchiveButton } from "@/components/canvas/VideoArchiveButton";
import { VideoPromptToolDialog } from "@/components/canvas/VideoPromptToolDialog"; import { VideoPromptToolDialog } from "@/components/canvas/VideoPromptToolDialog";
import type { PromptToolMode } from "@/lib/video-canvas/prompt-tools"; import type { PromptToolMode } from "@/lib/video-canvas/prompt-tools";
import { useVideoCanvasPersistence } from "@/hooks/use-video-canvas-persistence"; import { useVideoCanvasPersistence } from "@/hooks/use-video-canvas-persistence";
@ -2230,6 +2232,8 @@ function getVideoNodePreviewUrl(node: CloneNode | null | undefined) {
} }
function getVideoNodeDurationSeconds(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 params = getNodeParams(node);
const paramsSettings = const paramsSettings =
params.settings && typeof params.settings === "object" params.settings && typeof params.settings === "object"
@ -4946,6 +4950,35 @@ export function LiblibVideoCanvasReplica({
return () => { active = false; }; return () => { active = false; };
}, [canvasId, projectLoadAttempt]); }, [canvasId, projectLoadAttempt]);
const videoArchiveRunning = useRef(false);
const attemptedVideoArchives = useRef(new Set<string>());
const [videoArchiveTick, setVideoArchiveTick] = useState(0);
useEffect(() => {
if (!loadedProject || isProjectLoading || videoArchiveRunning.current) return;
const target = nodes.find(node => {
const meta = node.draftValues?.videoMeta as Record<string, unknown> | 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<string, unknown>;
attemptedVideoArchives.current.add(`${target.id}:${sourceUrl}`);
videoArchiveRunning.current = true;
const update = (url: string, meta: Record<string, unknown>) => 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({ const canvasSnapshot = useMemo(() => JSON.stringify({
title: projectTitle, title: projectTitle,
canvasData: { canvasData: {
@ -10881,6 +10914,16 @@ export function LiblibVideoCanvasReplica({
{selectedVideoMediaNode && selectedVideoAnchor ? ( {selectedVideoMediaNode && selectedVideoAnchor ? (
<div className="pointer-events-none absolute inset-0 z-30" data-canvas-overlay="true"> <div className="pointer-events-none absolute inset-0 z-30" data-canvas-overlay="true">
<OfficialVideoMediaToolbar <OfficialVideoMediaToolbar
archiveControl={(() => {
const meta = selectedVideoMediaNode.draftValues?.videoMeta as Record<string, unknown> | undefined;
const sourceUrl = getNodeMediaUrl(selectedVideoMediaNode);
if (!meta?.temporaryVideoUrl || meta.storageStatus === "archived" || !sourceUrl) return null;
const nodeId = selectedVideoMediaNode.id;
return <VideoArchiveButton key={nodeId} automaticBusy={meta.storageStatus === "archiving"} url={sourceUrl} onSaved={url => 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} activeMode={activeVideoToolbarMode}
analysisLoading={videoAnalysisStates[selectedVideoMediaNode.id]?.status === "loading"} analysisLoading={videoAnalysisStates[selectedVideoMediaNode.id]?.status === "loading"}
anchor={selectedVideoAnchor} anchor={selectedVideoAnchor}
@ -12530,6 +12573,7 @@ function VideoToolbarActionButton({
} }
function OfficialVideoMediaToolbar({ function OfficialVideoMediaToolbar({
archiveControl,
activeMode, activeMode,
analysisLoading = false, analysisLoading = false,
anchor, anchor,
@ -12542,6 +12586,7 @@ function OfficialVideoMediaToolbar({
onToggleClip, onToggleClip,
onVoiceSplit, onVoiceSplit,
}: { }: {
archiveControl?: ReactNode;
activeMode: VideoToolbarMode; activeMode: VideoToolbarMode;
analysisLoading?: boolean; analysisLoading?: boolean;
anchor?: PresetControlAnchor; anchor?: PresetControlAnchor;
@ -12576,6 +12621,7 @@ function OfficialVideoMediaToolbar({
} }
} }
> >
{archiveControl}
<OfficialVideoToolbarButton <OfficialVideoToolbarButton
active={activeMode === "clip"} active={activeMode === "clip"}
icon={<Scissors className="h-[18px] w-[18px]" strokeWidth={2.1} />} icon={<Scissors className="h-[18px] w-[18px]" strokeWidth={2.1} />}
@ -21371,7 +21417,7 @@ function OfficialVideoResultCard({
const [currentTime, setCurrentTime] = useState(0); const [currentTime, setCurrentTime] = useState(0);
const [frameMenuOpen, setFrameMenuOpen] = useState(false); const [frameMenuOpen, setFrameMenuOpen] = useState(false);
const [hovered, setHovered] = 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 progress = Math.max(0, Math.min(currentTime / safeDuration, 1));
const controlsVisible = hovered || selected || frameMenuOpen; const controlsVisible = hovered || selected || frameMenuOpen;
const frameActions: Array<{ kind: VideoFrameExtractionKind; label: string }> = [ const frameActions: Array<{ kind: VideoFrameExtractionKind; label: string }> = [

View File

@ -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 <button type="button" disabled={busy || automaticBusy} title="当前视频使用临时链接,转存后可长期保留,无需重新生成" className="flex h-10 items-center gap-2 whitespace-nowrap rounded-lg px-3 text-[15px] text-amber-200 hover:bg-white/10 disabled:opacity-50" onClick={async () => {
if (running.current) return;
running.current = true;
setBusy(true);
try {
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(result.error || "视频转存失败");
onSaved(result.data.url);
toast.success("视频已转存,画布正在保存新地址");
} catch (error) {
toast.error(error instanceof Error && error.name === "TimeoutError" ? "视频保存超时,原视频已保留,请稍后重试" : error instanceof Error ? error.message : "视频转存失败");
} finally { running.current = false; setBusy(false); }
}}>{busy || automaticBusy ? <Loader2 className="size-4 animate-spin" /> : <Download className="size-4" />}{busy || automaticBusy ? "正在转存" : "转存视频"}</button>;
}

View File

@ -1,5 +1,7 @@
# 视频画布继续完善与复测(2026-09-16) # 视频画布继续完善与复测(2026-09-16)
> 后续第三轮进展见 [脚本、参考图与视频转存回归](video-canvas-followup-2026-09-16.md)。
本记录接续 [上一轮真实测试](video-canvas-real-test-2026-09-16.md)。独立工作树本地验证,沿用真实服务商和数据库配置,使用专用测试账号;没有启用模拟接口,没有修改真实用户画布或默认模型。 本记录接续 [上一轮真实测试](video-canvas-real-test-2026-09-16.md)。独立工作树本地验证,沿用真实服务商和数据库配置,使用专用测试账号;没有启用模拟接口,没有修改真实用户画布或默认模型。
## 本轮完成的功能 ## 本轮完成的功能

View File

@ -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 检查通过;专用测试账号已禁用并撤销会话,临时凭据已删除,本轮开发服务和数据库隧道已停止;开发工具生成的配置改动不提交。

View File

@ -1,5 +1,7 @@
# 视频画布模型匹配与逐项对照(2026-09-16) # 视频画布模型匹配与逐项对照(2026-09-16)
> 后续第三轮进展见 [脚本、参考图与视频转存回归](video-canvas-followup-2026-09-16.md)。
> 后续进展见 [继续完善与复测记录](video-canvas-completion-2026-09-16.md):官方千问 3.8 提示词处理、Veo 文生视频、完整媒体处理 API 已出现真实成功结果。下文保留上一轮当时的测试状态。 > 后续进展见 [继续完善与复测记录](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)。因此下表区分接口与参数核验、界面核验、实际生成,不能把未生成的功能标为产物验收通过,也不把局部对齐称为整站像素级复刻完成。 参考:[LibTV 画布](https://www.liblib.tv/canvas)。以登录后的 1280×720 画布为界面对照基准。本记录对应此前“模型接通即可,不继续真实生成测试”的阶段;用户随后已要求恢复真实测试,最新结果见 [真实测试与参考站对照](video-canvas-real-test-2026-09-16.md)。因此下表区分接口与参数核验、界面核验、实际生成,不能把未生成的功能标为产物验收通过,也不把局部对齐称为整站像素级复刻完成。

View File

@ -15,12 +15,12 @@ function withTimeout(timeoutMs: number) {
}; };
} }
export async function isRemoteUrlReachable( export async function probeRemoteUrl(
url: string, url: string,
options: UrlAccessOptions = {} options: UrlAccessOptions = {}
): Promise<boolean> { ): Promise<"reachable" | "unreachable" | "unknown"> {
const target = String(url || "").trim(); 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 timeoutMs = Math.max(800, Number(options.timeoutMs || DEFAULT_TIMEOUT_MS));
const headCtx = withTimeout(timeoutMs); const headCtx = withTimeout(timeoutMs);
@ -31,10 +31,8 @@ export async function isRemoteUrlReachable(
cache: "no-store", cache: "no-store",
signal: headCtx.signal, signal: headCtx.signal,
}); });
if (headResponse.ok) return true; await headResponse.body?.cancel();
if (![403, 405, 429, 500, 501, 502, 503, 504].includes(headResponse.status)) { if (headResponse.ok) return "reachable";
return false;
}
} catch {} } catch {}
finally { finally {
headCtx.clear(); headCtx.clear();
@ -49,14 +47,20 @@ export async function isRemoteUrlReachable(
cache: "no-store", cache: "no-store",
signal: getCtx.signal, 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 { } catch {
return false; return "unknown";
} finally { } finally {
getCtx.clear(); getCtx.clear();
} }
} }
export async function isRemoteUrlReachable(url: string, options: UrlAccessOptions = {}): Promise<boolean> {
return (await probeRemoteUrl(url, options)) === "reachable";
}
export async function filterReachableRemoteUrls( export async function filterReachableRemoteUrls(
urls: string[], urls: string[],
options: UrlAccessOptions = {} options: UrlAccessOptions = {}

View File

@ -0,0 +1,19 @@
export type VideoArchiveMetadata = Record<string, unknown> & {
storageStatus: "archived" | "temporary";
temporaryVideoUrl: string;
permanentVideoUrl?: string;
};
export async function preserveGeneratedVideo(
sourceUrl: string,
metadata: Record<string, unknown> | null | undefined,
archive: (url: string) => Promise<string>,
): 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 } };
}
}

View File

@ -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<string, Promise<string>>();
export async function archiveCanvasVideo(url: string): Promise<string> {
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); }
}

View File

@ -0,0 +1,25 @@
export type ReferenceAccess = "reachable" | "unreachable" | "unknown";
export async function validateVideoReferences(
input: unknown,
probe: (url: string) => Promise<ReferenceAccess>,
): 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 };
}

View File

@ -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<string, unknown>)
: {};
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);
}

View File

@ -3,6 +3,7 @@ import { defineConfig } from "@playwright/test";
export default defineConfig({ export default defineConfig({
testDir: "./tests", testDir: "./tests",
testMatch: [ testMatch: [
/routes\/video-canvas-followup\.test\.ts/,
/routes\/video-canvas-tools\.test\.ts/, /routes\/video-canvas-tools\.test\.ts/,
/routes\/video-canvas-save-queue\.test\.ts/, /routes\/video-canvas-save-queue\.test\.ts/,
/routes\/video-canvas-model-matching\.test\.ts/, /routes\/video-canvas-model-matching\.test\.ts/,

View File

@ -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();
}
});