fix(video-canvas): preserve story length and archive generated videos
This commit is contained in:
parent
2ba093b714
commit
f7550251ec
@ -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) {
|
||||
|
||||
23
app/api/canvas/video/archive/route.ts
Normal file
23
app/api/canvas/video/archive/route.ts
Normal 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); }
|
||||
}
|
||||
@ -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<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) {
|
||||
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: {
|
||||
|
||||
@ -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<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({
|
||||
title: projectTitle,
|
||||
canvasData: {
|
||||
@ -10881,6 +10914,16 @@ export function LiblibVideoCanvasReplica({
|
||||
{selectedVideoMediaNode && selectedVideoAnchor ? (
|
||||
<div className="pointer-events-none absolute inset-0 z-30" data-canvas-overlay="true">
|
||||
<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}
|
||||
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}
|
||||
<OfficialVideoToolbarButton
|
||||
active={activeMode === "clip"}
|
||||
icon={<Scissors className="h-[18px] w-[18px]" strokeWidth={2.1} />}
|
||||
@ -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 }> = [
|
||||
|
||||
23
components/canvas/VideoArchiveButton.tsx
Normal file
23
components/canvas/VideoArchiveButton.tsx
Normal 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>;
|
||||
}
|
||||
@ -1,5 +1,7 @@
|
||||
# 视频画布继续完善与复测(2026-09-16)
|
||||
|
||||
> 后续第三轮进展见 [脚本、参考图与视频转存回归](video-canvas-followup-2026-09-16.md)。
|
||||
|
||||
本记录接续 [上一轮真实测试](video-canvas-real-test-2026-09-16.md)。独立工作树本地验证,沿用真实服务商和数据库配置,使用专用测试账号;没有启用模拟接口,没有修改真实用户画布或默认模型。
|
||||
|
||||
## 本轮完成的功能
|
||||
|
||||
36
docs/video-canvas-followup-2026-09-16.md
Normal file
36
docs/video-canvas-followup-2026-09-16.md
Normal 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 检查通过;专用测试账号已禁用并撤销会话,临时凭据已删除,本轮开发服务和数据库隧道已停止;开发工具生成的配置改动不提交。
|
||||
@ -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)。因此下表区分接口与参数核验、界面核验、实际生成,不能把未生成的功能标为产物验收通过,也不把局部对齐称为整站像素级复刻完成。
|
||||
|
||||
@ -15,12 +15,12 @@ function withTimeout(timeoutMs: number) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function isRemoteUrlReachable(
|
||||
export async function probeRemoteUrl(
|
||||
url: string,
|
||||
options: UrlAccessOptions = {}
|
||||
): Promise<boolean> {
|
||||
): 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<boolean> {
|
||||
return (await probeRemoteUrl(url, options)) === "reachable";
|
||||
}
|
||||
|
||||
export async function filterReachableRemoteUrls(
|
||||
urls: string[],
|
||||
options: UrlAccessOptions = {}
|
||||
|
||||
19
lib/video-canvas/archive-result.ts
Normal file
19
lib/video-canvas/archive-result.ts
Normal 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 } };
|
||||
}
|
||||
}
|
||||
35
lib/video-canvas/archive-video.ts
Normal file
35
lib/video-canvas/archive-video.ts
Normal 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); }
|
||||
}
|
||||
25
lib/video-canvas/reference-input.ts
Normal file
25
lib/video-canvas/reference-input.ts
Normal 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 };
|
||||
}
|
||||
138
lib/video-canvas/story-script.ts
Normal file
138
lib/video-canvas/story-script.ts
Normal 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);
|
||||
}
|
||||
@ -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/,
|
||||
|
||||
47
tests/routes/video-canvas-followup.test.ts
Normal file
47
tests/routes/video-canvas-followup.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user