Compare commits

...

12 Commits

109 changed files with 44288 additions and 537 deletions

12
.gitignore vendored
View File

@ -112,3 +112,15 @@ public/vendor/zoo-modeling-app/
.cache/smart-edit/ .cache/smart-edit/
services/video-render/dist/ services/video-render/dist/
# Downloaded and normalized subtitle fonts are runtime assets.
.cache/smart-edit-fonts/
services/smart-edit/__pycache__/
# Local verification artefacts (also excluded from Tailwind's automatic source scan).
.qa/
.next-video-*/
services/video-upscale/dist/
services/video-upscale/__pycache__/
services/video-upscale/vendor/__pycache__/

View File

@ -163,6 +163,8 @@ export async function PUT(
.values({ .values({
id, id,
userId: session.user.id, userId: session.user.id,
createdAt: new Date(),
updatedAt: new Date(),
title: title || getDefaultWorkspaceTitle(workspaceType || "canvas"), title: title || getDefaultWorkspaceTitle(workspaceType || "canvas"),
workspaceType: workspaceType || "canvas", workspaceType: workspaceType || "canvas",
canvasData: { canvasData: {

View File

@ -1,3 +1,4 @@
import { videoUpscaleSchema } from "@/lib/video-canvas/video-upscale-contract";
import { smartEditRequestSchema } from "@/lib/video-canvas/smart-edit/contract"; import { smartEditRequestSchema } from "@/lib/video-canvas/smart-edit/contract";
import { audioRequestSchema } from "@/lib/video-canvas/audio-contract"; import { audioRequestSchema } from "@/lib/video-canvas/audio-contract";
import { canResumeCanvasTask } from "@/lib/video-canvas/task-recovery"; import { canResumeCanvasTask } from "@/lib/video-canvas/task-recovery";
@ -43,6 +44,7 @@ export async function POST(request: Request) {
throw Object.assign(Error("任务数据过大,请使用已上传的素材链接"), { status: 413 }); throw Object.assign(Error("任务数据过大,请使用已上传的素材链接"), { status: 413 });
const parsed = canvasTaskRequestSchema.safeParse(JSON.parse(text)); const parsed = canvasTaskRequestSchema.safeParse(JSON.parse(text));
if (!parsed.success) throw Object.assign(Error("任务参数不合法"), { status: 400 }); if (!parsed.success) throw Object.assign(Error("任务参数不合法"), { status: 400 });
if(parsed.data.workflow)throw Object.assign(Error("编组任务必须使用整组提交接口"),{status:400});
if ( if (
parsed.data.kind === "image-operation" && parsed.data.kind === "image-operation" &&
!imageOperationSchema.safeParse(parsed.data.params).success !imageOperationSchema.safeParse(parsed.data.params).success
@ -61,6 +63,7 @@ export async function POST(request: Request) {
!smartEditRequestSchema.safeParse(parsed.data.params).success !smartEditRequestSchema.safeParse(parsed.data.params).success
) )
throw Object.assign(Error("剪辑参数无效"), { status: 400 }); throw Object.assign(Error("剪辑参数无效"), { status: 400 });
if (parsed.data.kind === "video-upscale" && !videoUpscaleSchema.safeParse(parsed.data.params).success) throw Object.assign(Error("视频高清参数无效"), {status:400});
const userId = await owner(parsed.data.projectId); const userId = await owner(parsed.data.projectId);
const task = await createCanvasGenerationTask(userId, parsed.data); const task = await createCanvasGenerationTask(userId, parsed.data);
kick(userId, parsed.data.projectId); kick(userId, parsed.data.projectId);

View File

@ -0,0 +1,39 @@
import { auth } from "@/app/(auth)/auth";
import { after, NextResponse } from "next/server";
import { canvasTaskRequestSchema } from "@/lib/video-canvas/task-contract";
import {
createCanvasGroupTasks,
ownsTaskProject,
} from "@/lib/video-canvas/task-store";
import { runCanvasGenerationQueue } from "@/lib/video-canvas/task-worker";
export const runtime = "nodejs",
maxDuration = 900;
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user?.id)
return NextResponse.json({ error: "未登录" }, { status: 401 });
const text = await request.text();
if (Buffer.byteLength(text) > 1048576)
return NextResponse.json({ error: "编组数据过大" }, { status: 413 });
const parsed = canvasTaskRequestSchema
.array()
.min(1)
.max(40)
.safeParse(JSON.parse(text));
if (!parsed.success)
return NextResponse.json({ error: "编组参数无效" }, { status: 400 });
const projectId = parsed.data[0].projectId,
userId = session.user.id;
if (!(await ownsTaskProject(userId, projectId)))
return NextResponse.json({ error: "画布不存在" }, { status: 404 });
const tasks = await createCanvasGroupTasks(userId, parsed.data);
after(() => runCanvasGenerationQueue(userId, projectId));
return NextResponse.json({ success: true, tasks }, { status: 202 });
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : "编组执行失败" },
{ status: Number((e as any)?.status) || 400 }
);
}
}

View File

@ -0,0 +1,40 @@
import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db";
import { canvasProject } from "@/lib/db/schema";
import { eq, and, sql, desc } from "drizzle-orm";
import { NextResponse } from "next/server";
export async function GET() {
const session = await auth();
if (!session?.user?.id)
return NextResponse.json({ error: "未登录" }, { status: 401 });
const rows = await db
.select({
projectId: canvasProject.id,
title: canvasProject.title,
templates: sql<any[]>`${canvasProject.canvasData}->'groupTemplates'`,
})
.from(canvasProject)
.where(
and(
eq(canvasProject.userId, session.user.id),
eq(canvasProject.workspaceType, "video"),
sql`${canvasProject.canvasData} ? 'groupTemplates'`
)
)
.orderBy(desc(canvasProject.updatedAt))
.limit(100);
return NextResponse.json(
{
templates: rows.flatMap((row) =>
Array.isArray(row.templates)
? row.templates.map((template) => ({
...template,
projectId: row.projectId,
projectTitle: row.title,
}))
: []
),
},
{ headers: { "Cache-Control": "no-store" } }
);
}

View File

@ -100,6 +100,8 @@ export async function POST(request: NextRequest) {
.insert(canvasProject) .insert(canvasProject)
.values({ .values({
userId: session.user.id, userId: session.user.id,
createdAt: new Date(),
updatedAt: new Date(),
title: title || getDefaultWorkspaceTitle(workspaceType), title: title || getDefaultWorkspaceTitle(workspaceType),
description, description,
workspaceType, workspaceType,

View File

@ -10,7 +10,14 @@ export async function GET() {
const session = await auth(); const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 }); if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
try { try {
let capabilities = { scenes: false, beats: false, asr: false, ffmpeg: false, ffprobe: false }; let capabilities = {
separation: false,
scenes: false,
beats: false,
asr: false,
ffmpeg: false,
ffprobe: false,
};
try { try {
const { stdout } = await promisify(execFile)( const { stdout } = await promisify(execFile)(
process.env.SMART_EDIT_PYTHON || "python3", process.env.SMART_EDIT_PYTHON || "python3",
@ -35,7 +42,9 @@ export async function GET() {
} }
} }
const models = (await getEnabledAiModelsWithProvider(false)) const models = (await getEnabledAiModelsWithProvider(false))
.filter((item) => isCanvasVisionModel({ ...item.model, providerType: item.provider.type })) .filter((item) =>
isCanvasVisionModel({ ...item.model, providerType: item.provider.type }),
)
.map(({ model }) => ({ id: model.id, name: model.displayName, modelId: model.modelId })); .map(({ model }) => ({ id: model.id, name: model.displayName, modelId: model.modelId }));
return NextResponse.json( return NextResponse.json(
{ capabilities: { ...capabilities, render: renderAvailable }, models }, { capabilities: { ...capabilities, render: renderAvailable }, models },

View File

@ -0,0 +1,22 @@
import { auth } from "@/app/(auth)/auth";
import { captionFont } from "@/lib/video-canvas/smart-edit/fonts";
import { captionFontFile } from "@/lib/video-canvas/smart-edit/font-files";
import { readFile } from "node:fs/promises";
export const runtime = "nodejs";
export const maxDuration = 180;
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
if (!(await auth())?.user?.id) return Response.json({ error: "未登录" }, { status: 401 });
const { id } = await params;
if (!captionFont(id)) return Response.json({ error: "字体不存在" }, { status: 404 });
try {
const data = await readFile(await captionFontFile(id));
return new Response(new Uint8Array(data), {
headers: {
"Content-Type": "font/ttf",
"Cache-Control": "private, max-age=31536000, immutable",
},
});
} catch {
return Response.json({ error: "字体加载失败,请检查字体服务配置后重试" }, { status: 503 });
}
}

View File

@ -0,0 +1,39 @@
import { auth } from "@/app/(auth)/auth";
import { upscaleConfig } from "@/lib/video-canvas/video-upscale-remote";
import { NextResponse } from "next/server";
export async function GET() {
if (!(await auth())?.user?.id)
return NextResponse.json({ error: "未登录" }, { status: 401 });
try {
const config = upscaleConfig();
if (!config)
return NextResponse.json({
available: false,
message: "视频高清服务尚未接通",
});
const response = await fetch(config.base + "/health", {
headers: { authorization: "Bearer " + config.token },
signal: AbortSignal.timeout(5000),
redirect: "error",
cache: "no-store",
});
if (!response.ok) throw Error("unavailable");
const data = await response.json();
return NextResponse.json(
{
available: data.status === "ok",
engine: "Real-ESRGAN",
resolutions: ["1080P", "2K", "4K"],
maxScale: 4,
maxSeconds: 120,
queued: data.queued,
},
{ headers: { "Cache-Control": "no-store" } }
);
} catch {
return NextResponse.json(
{ available: false, message: "视频高清服务暂时不可用,请稍后重试" },
{ headers: { "Cache-Control": "no-store" } }
);
}
}

View File

@ -9,14 +9,16 @@ import {
type Model = { id: string; modelId: string; name: string; kind: string }; type Model = { id: string; modelId: string; name: string; kind: string };
export function AudioGenerationComposer({ export function AudioGenerationComposer({
initial, initial,
connectedText = "",
busy, busy,
onSubmit, onSubmit,
onUpload, onUpload,
onDraft, onDraft,
}: { }: {
initial?: Record<string, unknown>; initial?: Record<string, unknown>;
connectedText?: string;
busy: boolean; busy: boolean;
onSubmit: (params: AudioRequest) => Promise<unknown>; onSubmit: (params: AudioRequest, draft: AudioRequest) => Promise<unknown>;
onUpload: (file: File) => Promise<void>; onUpload: (file: File) => Promise<void>;
onDraft: (params: Record<string, unknown>) => void; onDraft: (params: Record<string, unknown>) => void;
}) { }) {
@ -73,6 +75,7 @@ export function AudioGenerationComposer({
if (!model) throw Error("当前模式没有已启用的官方模型"); if (!model) throw Error("当前模式没有已启用的官方模型");
const params = audioRequestSchema.parse({ const params = audioRequestSchema.parse({
...draft, ...draft,
text: [draft.text, connectedText].filter(Boolean).join("\n\n"),
modelRef: model.id, modelRef: model.id,
voice, voice,
format, format,
@ -80,8 +83,9 @@ export function AudioGenerationComposer({
...(!contract?.instructions ? { instructions: "" } : {}), ...(!contract?.instructions ? { instructions: "" } : {}),
}); });
buildAudioRequest(params, model.modelId); buildAudioRequest(params, model.modelId);
patch(params); const savedDraft = { ...params, text: String(draft.text || "") };
await onSubmit(params); patch(savedDraft);
await onSubmit(params, savedDraft);
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "提交失败"); setError(e instanceof Error ? e.message : "提交失败");
} finally { } finally {
@ -96,6 +100,7 @@ export function AudioGenerationComposer({
> >
<h3 className="font-semibold">语音与音乐 · 阿里云官方</h3> <h3 className="font-semibold">语音与音乐 · 阿里云官方</h3>
<fieldset disabled={busy || sending} className="mt-3 space-y-3 disabled:opacity-60"> <fieldset disabled={busy || sending} className="mt-3 space-y-3 disabled:opacity-60">
{connectedText && <p className="mb-3 whitespace-pre-wrap rounded bg-white/5 p-2 text-xs" aria-label="已连接文本">已连接文本:{connectedText}</p>}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<label> <label>
生成类型 生成类型
@ -142,7 +147,7 @@ export function AudioGenerationComposer({
</label> </label>
{draft.mode === "tts" ? ( {draft.mode === "tts" ? (
<> <>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<label> <label>
音色 音色
<select <select

View File

@ -0,0 +1,125 @@
"use client";
import {
Download,
Play,
Ungroup,
Share2,
Grid3X3,
ScanLine,
} from "lucide-react";
export function CanvasGroupToolbar({
title,
color,
status,
busy,
storyboard,
canStoryboard,
onRename,
onColor,
onLayout,
onRun,
onUngroup,
onSave,
onDownload,
onStoryboard,
onCancel,
}: {
title: string;
color: string;
status: string;
busy: boolean;
storyboard: boolean;
canStoryboard: boolean;
onRename: (s: string) => void;
onColor: (s: string) => void;
onLayout: (s: "grid" | "horizontal" | "vertical") => void;
onRun: () => void;
onUngroup: () => void;
onSave: () => void;
onDownload: () => void;
onStoryboard: () => void;
onCancel: () => void;
}) {
return (
<div
data-canvas-overlay="true"
data-group-toolbar="true"
className="absolute left-1/2 top-[80px] z-[70] -translate-x-1/2 rounded-[22px] border border-white/10 bg-[#252525] p-4 text-white shadow-xl"
onPointerDown={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-5 whitespace-nowrap text-[16px]">
<input
aria-label="编组颜色"
type="color"
value={color}
onChange={(e) => onColor(e.target.value)}
className="h-7 w-7 cursor-pointer rounded-full border-0 bg-transparent"
/>
<label title="编组布局" className="flex items-center gap-1">
<Grid3X3 size={20} />
<select
aria-label="编组布局"
defaultValue=""
onChange={(e) => {
onLayout(e.target.value as any);
e.target.value = "";
}}
className="w-5 bg-[#252525]"
>
<option value="" disabled>
⌄
</option>
<option value="grid">网格布局</option>
<option value="horizontal">横向布局</option>
<option value="vertical">纵向布局</option>
</select>
</label>
<span className="h-8 w-px bg-white/10" />
<button
type="button"
disabled={busy}
onClick={onRun}
className="flex items-center gap-2 disabled:opacity-40"
>
<Play size={22} />
整组执行
</button>
<button onClick={onSave} className="flex items-center gap-2">
<Share2 size={22} />
添加到工具箱
</button>
<button
type="button"
disabled={!canStoryboard}
title={
canStoryboard ? "" : "分镜组需要全部成员都是已就绪的图片或视频"
}
onClick={onStoryboard}
className="flex items-center gap-2 disabled:opacity-35"
>
<ScanLine size={22} />
{storyboard ? "预览分镜" : "转分镜组"}
</button>
<button onClick={onUngroup} className="flex items-center gap-2">
<Ungroup size={22} />
解组
</button>
<span className="h-8 w-px bg-white/10" />
<button title="下载编组模板" onClick={onDownload}>
<Download size={22} />
</button>
</div>
<div className="mt-3 flex items-center justify-between gap-4 text-xs text-white/55">
<input
aria-label="编组名称"
value={title}
onChange={(e) => onRename(e.target.value)}
maxLength={120}
className="w-48 border-b border-white/10 bg-transparent py-1"
/>
<span>{status || "Shift 点击多选 · ⌘/Ctrl+G 编组"}</span>
{busy && <button onClick={onCancel}>取消未开始任务</button>}
</div>
</div>
);
}

View File

@ -0,0 +1,122 @@
"use client";
import { useEffect, useState } from "react";
import { ArrowLeft, ArrowRight, X } from "lucide-react";
export type GroupShot = {
id: string;
title: string;
url: string;
type: "image" | "video";
};
export function GroupStoryboardPreview({
shots,
onOrder,
onClose,
}: {
shots: GroupShot[];
onOrder: (ids: string[]) => void;
onClose: () => void;
}) {
const [index, setIndex] = useState(0),
shot = shots[Math.min(index, shots.length - 1)];
useEffect(() => {
const key = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", key);
return () => window.removeEventListener("keydown", key);
}, [onClose]);
if (!shot) return null;
const move = (delta: number) => {
const next = index + delta;
if (next < 0 || next >= shots.length) return;
const ids = shots.map((s) => s.id);
[ids[index], ids[next]] = [ids[next], ids[index]];
onOrder(ids);
setIndex(next);
};
return (
<div
data-canvas-overlay="true"
role="dialog"
aria-label="编组分镜预览"
className="fixed inset-0 z-[120] flex flex-col bg-black/95 p-8 text-white"
onPointerDown={(e) => e.stopPropagation()}
>
<header className="mb-4 flex items-center justify-between">
<h2>
分镜 {index + 1}/{shots.length} · {shot.title}
</h2>
<button aria-label="关闭分镜预览" onClick={onClose}>
<X />
</button>
</header>
<div className="flex min-h-0 flex-1 items-center justify-center">
{shot.type === "video" ? (
<video
key={shot.id}
src={shot.url}
controls
autoPlay
className="h-full max-w-full object-contain"
onEnded={() => setIndex((i) => Math.min(i + 1, shots.length - 1))}
/>
) : (
<img
src={shot.url}
alt={shot.title}
className="h-full max-w-full object-contain"
/>
)}
</div>
<div className="mt-4 flex items-center justify-center gap-6">
<button
type="button"
disabled={index === 0}
onClick={() => setIndex((i) => i - 1)}
className="disabled:opacity-30"
>
<ArrowLeft />
</button>
<span>
{index + 1} / {shots.length}
</span>
<button
type="button"
disabled={index === shots.length - 1}
onClick={() => setIndex((i) => i + 1)}
className="disabled:opacity-30"
>
<ArrowRight />
</button>
<button
type="button"
disabled={index === 0}
onClick={() => move(-1)}
className="disabled:opacity-30"
>
镜头前移
</button>
<button
type="button"
disabled={index === shots.length - 1}
onClick={() => move(1)}
className="disabled:opacity-30"
>
镜头后移
</button>
</div>
<div className="mt-5 flex justify-center gap-3 overflow-x-auto">
{shots.map((s, i) => (
<button
type="button"
key={s.id}
onClick={() => setIndex(i)}
className={`shrink-0 rounded-lg border px-4 py-2 text-sm ${i === index ? "border-white bg-white/20" : "border-white/20"}`}
>
{i + 1} · {s.title}
</button>
))}
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,226 @@
"use client";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { Check, ChevronDown, Search } from "lucide-react";
import { captionFonts, captionFont } from "@/lib/video-canvas/smart-edit/fonts";
import type { CaptionStyle } from "@/lib/video-canvas/smart-edit/contract";
const loaded = new Map<string, Promise<FontFace>>();
export function loadCaptionFont(id: string) {
const font = captionFont(id);
if (!font) return Promise.resolve();
if (!loaded.has(id)) {
const face = new FontFace(
font.family,
`url(${font.url ? `/api/canvas/smart-edit/fonts/${font.id}` : `/fonts/smart-edit/${font.file}`})`,
{
weight: "400",
},
);
loaded.set(
id,
face
.load()
.then((loadedFace) => {
document.fonts.add(loadedFace);
return loadedFace;
})
.catch((error) => {
loaded.delete(id);
throw error;
}),
);
}
return loaded.get(id)!;
}
export function CaptionFontPicker({
value,
onChange,
onError,
}: {
value: CaptionStyle["fontFamily"];
onChange: (id: NonNullable<CaptionStyle["fontFamily"]>) => void;
onError: (message: string) => void;
}) {
const [open, setOpen] = useState(false),
[query, setQuery] = useState(""),
[category, setCategory] = useState("全部"),
[categoriesOpen, setCategoriesOpen] = useState(false),
[busy, setBusy] = useState(false),
[scrollTop, setScrollTop] = useState(0);
const root = useRef<HTMLDivElement>(null);
const [placement, setPlacement] = useState({ left: 0, top: 0, width: 300, height: 300 });
useLayoutEffect(() => {
if (!open) return;
const place = () => {
const rect = root.current?.getBoundingClientRect();
if (!rect) return;
const below = window.innerHeight - rect.bottom - 16;
const above = rect.top - 16;
const up = below < 394 && above > below;
const height = Math.max(40, Math.min(300, (up ? above : below) - 94));
const width = Math.min(window.innerWidth - 16, rect.width + 32);
setPlacement({
left: Math.max(8, Math.min(rect.left - 16, window.innerWidth - width - 8)),
top: up ? Math.max(8, rect.top - height - 94 - 8) : rect.bottom + 8,
width,
height,
});
};
place();
const scroll = (event: Event) => {
if (!root.current?.contains(event.target as Node)) place();
};
window.addEventListener("resize", place);
window.addEventListener("scroll", scroll, true);
return () => {
window.removeEventListener("resize", place);
window.removeEventListener("scroll", scroll, true);
};
}, [open]);
useEffect(() => {
const close = (e: globalThis.PointerEvent) => {
if (!root.current?.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("pointerdown", close);
return () => document.removeEventListener("pointerdown", close);
}, []);
const options = captionFonts.filter(
(font) =>
font.name.toLowerCase().includes(query.toLowerCase()) &&
(category === "全部" ||
(category === "常用" && font.common) ||
(category === "推荐" && font.recommended) ||
(category === "Google" && font.google)),
);
const first = Math.max(0, Math.floor(scrollTop / 40) - 4);
useEffect(() => {
setScrollTop(0);
}, [query, category, open]);
useEffect(() => {
if (!open) return;
let stopped = false,
index = 0;
const visible = options.slice(first, first + 18);
const worker = async () => {
while (!stopped && index < visible.length) {
const font = visible[index++];
try {
await loadCaptionFont(font.id);
} catch {
/* Selection reports any download failure. */
}
}
};
void worker();
void worker();
return () => {
stopped = true;
};
}, [open, first, query, category]);
return (
<div className="se-font-picker" ref={root}>
<button
className="se-font"
aria-label="字幕字体"
aria-expanded={open}
onClick={() => setOpen(!open)}
>
{captionFont(value)?.name || "默认字体"}
<ChevronDown size={14} />
</button>
{open && (
<div
className="se-font-menu"
style={{
position: "fixed",
left: placement.left,
top: placement.top,
width: placement.width,
right: "auto",
}}
>
<div className="se-search">
<Search size={14} />
<input
aria-label="搜索字体"
placeholder="搜索字体"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
<div className="se-font-categories">
<button
type="button"
aria-haspopup="menu"
aria-expanded={categoriesOpen}
onClick={() => setCategoriesOpen(!categoriesOpen)}
>
{category === "Google" ? "Google 字体" : `${category}字体`}
<ChevronDown size={12} />
</button>
{categoriesOpen && (
<div role="menu" aria-label="字体分类">
{["全部", "常用", "推荐", "Google"].map((name) => (
<button
type="button"
role="menuitemradio"
aria-checked={category === name}
key={name}
onClick={() => {
setCategory(name);
setCategoriesOpen(false);
}}
>
{name === "Google" ? "Google 字体" : `${name}字体`}
{category === name && <Check size={14} />}
</button>
))}
</div>
)}
</div>
<div
key={`${query}:${category}`}
role="listbox"
style={{ height: placement.height }}
aria-label={`字体库,共 ${options.length} 项`}
onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
>
<div style={{ height: options.length * 40, position: "relative" }}>
{options.slice(first, first + 18).map((font, index) => (
<button
style={{
position: "absolute",
top: (first + index) * 40,
fontFamily: `"${font.family}", "Source Han Sans SC", sans-serif`,
}}
aria-posinset={first + index + 1}
aria-setsize={options.length}
role="option"
aria-selected={value === font.id}
key={font.id}
disabled={busy}
onClick={async () => {
setBusy(true);
try {
await loadCaptionFont(font.id);
onChange(font.id);
setOpen(false);
} catch {
onError("字体加载失败,请检查网络后重试");
} finally {
setBusy(false);
}
}}
>
{font.name}
{value === font.id && <Check size={14} />}
</button>
))}
</div>
</div>
{!options.length && <div className="se-muted">没有匹配的字体</div>}
{busy && <div role="status">正在加载字体…</div>}
</div>
)}
</div>
);
}

View File

@ -1,10 +1,18 @@
"use client"; "use client";
import { useEffect, useRef } from "react"; import { useEffect, useRef, useState } from "react";
import { captionFont } from "@/lib/video-canvas/smart-edit/fonts";
import { loadCaptionFont } from "./CaptionFonts";
import { usePreviewGain } from "./usePreviewGain";
import { import {
compiledSubtitles, editableCaptions,
defaultCaptionStyle,
type CaptionStyle,
planTimeline, planTimeline,
SECOND, SECOND,
type EditPlan, type EditPlan,
defaultVideoTransform,
type VideoTransform,
type TimelineCaption,
} from "@/lib/video-canvas/smart-edit/contract"; } from "@/lib/video-canvas/smart-edit/contract";
export function EditPreview({ export function EditPreview({
plan, plan,
@ -13,7 +21,25 @@ export function EditPreview({
onTime, onTime,
onPlaying, onPlaying,
onError, onError,
fit = false,
cover = false,
externalClock = false,
selectedClipId,
selectedCaptionId,
onSelectClip,
onSelectCaption,
onTransform,
onCaptionPosition,
}: { }: {
fit?: boolean;
cover?: boolean;
externalClock?: boolean;
selectedClipId?: string;
selectedCaptionId?: string;
onSelectClip?: (id: string) => void;
onSelectCaption?: (id: string) => void;
onTransform?: (id: string, value: VideoTransform) => void;
onCaptionPosition?: (id: string, value: { x: number; y: number }) => void;
plan: EditPlan; plan: EditPlan;
time: number; time: number;
playing: boolean; playing: boolean;
@ -23,13 +49,51 @@ export function EditPreview({
}) { }) {
const videos = useRef(new Map<string, HTMLVideoElement>()), const videos = useRef(new Map<string, HTMLVideoElement>()),
music = useRef<HTMLAudioElement | null>(null), music = useRef<HTMLAudioElement | null>(null),
audios = useRef(new Map<string, HTMLAudioElement>()),
latest = useRef({ time, plan, onTime, onPlaying }); latest = useRef({ time, plan, onTime, onPlaying });
latest.current = { time, plan, onTime, onPlaying }; latest.current = { time, plan, onTime, onPlaying };
const timeline = planTimeline(plan), const fontIds = [
current = timeline.clips.filter((c) => time * SECOND >= c.start && time * SECOND < c.end), ...new Set(
subs = compiledSubtitles(plan).filter((s) => time * SECOND >= s.start && time * SECOND < s.end); editableCaptions(plan)
.map((c) => c.style?.fontFamily)
.filter(Boolean),
),
].join(",");
useEffect(() => { useEffect(() => {
if (!playing) return; let canceled = false;
void Promise.all(
[
...new Set([...fontIds.split(",").filter(Boolean), ...(fontIds ? ["source-han"] : [])]),
].map(loadCaptionFont),
).catch(() => {
if (!canceled) onError("字幕字体加载失败,请检查网络后重试");
});
return () => {
canceled = true;
};
}, [fontIds]);
const audioGain = usePreviewGain(plan, onError);
const frameRef = useRef<HTMLDivElement>(null);
const [frameSize, setFrameSize] = useState({ width: 0, height: 0 });
useEffect(() => {
const el = frameRef.current;
if (!el) return;
const observer = new ResizeObserver(([entry]) =>
setFrameSize({ width: entry.contentRect.width, height: entry.contentRect.height }),
);
observer.observe(el);
return () => observer.disconnect();
}, []);
const timeline = planTimeline(plan),
visibleTime = Math.min(time * SECOND, timeline.duration - 1),
current = timeline.clips.filter(
(c) => visibleTime >= c.renderStart && visibleTime < c.renderEnd,
),
subs = plan.subtitles
? editableCaptions(plan).filter((s) => time * SECOND >= s.start && time * SECOND < s.end)
: [];
useEffect(() => {
if (!playing || externalClock) return;
let frame = 0, let frame = 0,
last = 0; last = 0;
const tick = (now: number) => { const tick = (now: number) => {
@ -57,8 +121,9 @@ export function EditPreview({
cancelAnimationFrame(frame); cancelAnimationFrame(frame);
document.removeEventListener("visibilitychange", hide); document.removeEventListener("visibilitychange", hide);
}; };
}, [playing]); }, [playing, externalClock]);
useEffect(() => { useEffect(() => {
audioGain.retain([...videos.current.values(), ...audios.current.values()]);
for (const clip of timeline.clips) { for (const clip of timeline.clips) {
const video = videos.current.get(clip.id); const video = videos.current.get(clip.id);
if (!video) continue; if (!video) continue;
@ -67,25 +132,97 @@ export function EditPreview({
video.pause(); video.pause();
continue; continue;
} }
const target = clip.sourceIn / SECOND + (time - clip.start / SECOND) * clip.speed; const target = Math.max(
clip.sourceIn / SECOND,
Math.min(
clip.sourceOut / SECOND - 1 / 30,
clip.sourceIn / SECOND + (time - clip.start / SECOND) * clip.speed,
),
);
const held = time * SECOND < clip.start || time * SECOND >= clip.end;
if (Number.isFinite(target) && Math.abs(video.currentTime - target) > 0.1) if (Number.isFinite(target) && Math.abs(video.currentTime - target) > 0.1)
video.currentTime = target; video.currentTime = target;
video.playbackRate = clip.speed; video.playbackRate = clip.speed;
const index = timeline.clips.findIndex((c) => c.id === clip.id), const index = timeline.clips.findIndex((c) => c.id === clip.id),
next = timeline.clips[index + 1]; next = timeline.clips[index + 1];
let gain = 1; let gain = 1;
if (clip.overlap && time * SECOND < clip.start + clip.overlap) if (clip.overlap && time * SECOND < clip.transitionStart + clip.overlap)
gain = (time * SECOND - clip.start) / clip.overlap; gain = (time * SECOND - clip.transitionStart) / clip.overlap;
if (next?.overlap && time * SECOND > next.start) if (next?.overlap && time * SECOND > next.transitionStart)
gain = Math.min(gain, (clip.end - time * SECOND) / next.overlap); gain = Math.min(
video.volume = Math.min(1, Math.max(0, clip.gain * gain)); gain,
if (playing) (next.transitionStart + next.overlap - time * SECOND) / next.overlap,
void video.play().catch(() => { );
const source = plan.assets.find((a) => a.id === clip.assetId)!.url;
if (!audioGain.ready(source)) {
video.pause();
if (playing) {
audioGain.retry();
onPlaying(false);
onError("正在准备音量预览素材,请稍后点击播放");
}
continue;
}
audioGain.volume(
video,
clip.audioDetached || clip.muted || held ? 0 : clip.gain * gain,
playing,
);
if (playing && !held)
void video.play().catch((error) => {
if (error?.name === "AbortError") return;
onPlaying(false); onPlaying(false);
onError("预览播放失败,请检查素材链接或点击播放重试"); onError("预览播放失败,请检查素材链接或点击播放重试");
}); });
else video.pause(); else video.pause();
} }
for (const clip of timeline.audioClips) {
const audio = audios.current.get(clip.id);
if (!audio) continue;
const relative = time - clip.start / SECOND;
if (relative < 0 || time * SECOND >= clip.end) {
audio.pause();
continue;
}
const target = clip.sourceIn / SECOND + relative * clip.speed;
if (Math.abs(audio.currentTime - target) > 0.1) audio.currentTime = target;
audio.playbackRate = clip.speed;
const length = clip.duration / SECOND,
fadeIn = Math.min(clip.fadeIn, length),
fadeOut = Math.min(clip.fadeOut, length);
const gain =
Math.min(
1,
fadeIn
? (clip.fadeInStart || 0) + ((1 - (clip.fadeInStart || 0)) * relative) / fadeIn
: 1,
) *
Math.min(
1,
fadeOut
? (clip.fadeOutEnd || 0) +
((1 - (clip.fadeOutEnd || 0)) * (length - relative)) / fadeOut
: 1,
);
const source = plan.assets.find((a) => a.id === clip.assetId)!.url;
if (!audioGain.ready(source)) {
audio.pause();
if (playing) {
audioGain.retry();
onPlaying(false);
onError("正在准备音量预览素材,请稍后点击播放");
}
continue;
}
audioGain.volume(audio, clip.muted ? 0 : clip.gain * gain, playing);
if (playing)
void audio.play().catch((error) => {
if (error?.name === "AbortError") return;
onPlaying(false);
onError("独立音轨无法播放");
});
else audio.pause();
}
const audio = music.current; const audio = music.current;
if (audio && plan.music) { if (audio && plan.music) {
const target = time + plan.music.start / SECOND; const target = time + plan.music.start / SECOND;
@ -96,53 +233,328 @@ export function EditPreview({
if (Math.abs(audio.currentTime - target) > 0.15) audio.currentTime = target; if (Math.abs(audio.currentTime - target) > 0.15) audio.currentTime = target;
audio.volume = Math.min(1, plan.music.gain); audio.volume = Math.min(1, plan.music.gain);
if (playing) if (playing)
void audio.play().catch(() => { void audio.play().catch((error) => {
if (error?.name === "AbortError") return;
onPlaying(false); onPlaying(false);
onError("背景音乐无法播放"); onError("背景音乐无法播放");
}); });
else audio.pause(); else audio.pause();
} }
}, [time, playing, plan]); }, [time, playing, plan]);
const [ratioWidth, ratioHeight] = plan.ratio.split(":").map(Number);
const ratio = ratioWidth / ratioHeight;
const height = Math.min(frameSize.height || 720, (frameSize.width || 1280) / ratio);
const incomingClip = current.find(
(c) => c.overlap && visibleTime < c.transitionStart + c.overlap,
);
const progress = incomingClip
? (visibleTime - incomingClip.transitionStart) / incomingClip.overlap
: 1;
const [gesture, setGesture] = useState<{
clipId?: string;
captionId?: string;
transform?: VideoTransform;
x?: number;
y?: number;
}>();
const gestureCleanup = useRef<() => void>();
useEffect(() => () => gestureCleanup.current?.(), []);
const moveTransform = (e: React.PointerEvent, id: string, corner?: [number, number]) => {
e.preventDefault();
e.stopPropagation();
onPlaying(false);
onSelectClip?.(id);
const c = plan.clips.find((c) => c.id === id)!,
a = plan.assets.find((a) => a.id === c.assetId)!,
original = c.transform || defaultVideoTransform;
const frameWidth = height * ratio,
baseWidth = Math.min(frameWidth, (height * a.width) / a.height),
baseHeight = (baseWidth * a.height) / a.width,
startX = e.clientX,
startY = e.clientY;
let value = original;
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
const move = (ev: PointerEvent) => {
const dx = ev.clientX - startX,
dy = ev.clientY - startY;
if (corner) {
const nextScale = clamp(
original.scale + ((dx * corner[0]) / baseWidth + (dy * corner[1]) / baseHeight) / 2,
0.1,
3,
),
delta = nextScale - original.scale;
value = {
scale: nextScale,
x: clamp(original.x + (delta * baseWidth * corner[0]) / 2 / frameWidth, -1, 1),
y: clamp(original.y + (delta * baseHeight * corner[1]) / 2 / height, -1, 1),
};
} else
value = {
...original,
x: clamp(original.x + dx / frameWidth, -1, 1),
y: clamp(original.y + dy / height, -1, 1),
};
setGesture({ clipId: id, transform: value });
};
const cleanup = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", end);
window.removeEventListener("pointercancel", cancel);
gestureCleanup.current = undefined;
};
const end = () => {
cleanup();
setGesture(undefined);
onTransform?.(id, value);
},
cancel = () => {
cleanup();
setGesture(undefined);
};
gestureCleanup.current?.();
gestureCleanup.current = cleanup;
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", end);
window.addEventListener("pointercancel", cancel);
};
const moveCaption = (e: React.PointerEvent, id: string, style: CaptionStyle) => {
e.preventDefault();
e.stopPropagation();
onPlaying(false);
onSelectCaption?.(id);
const startX = e.clientX,
startY = e.clientY;
let value = { x: style.x ?? 0.5, y: style.y ?? 0.95 };
const move = (ev: PointerEvent) => {
value = {
x: Math.max(
0,
Math.min(1, (style.x ?? 0.5) + (ev.clientX - startX) / (height * ratio)),
),
y: Math.max(0, Math.min(1, (style.y ?? 0.95) + (ev.clientY - startY) / height)),
};
setGesture({ captionId: id, ...value });
};
const cleanup = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", end);
window.removeEventListener("pointercancel", cancel);
gestureCleanup.current = undefined;
};
const end = () => {
cleanup();
setGesture(undefined);
onCaptionPosition?.(id, value);
},
cancel = () => {
cleanup();
setGesture(undefined);
};
gestureCleanup.current?.();
gestureCleanup.current = cleanup;
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", end);
window.addEventListener("pointercancel", cancel);
};
const selectedClip = current.find((c) => c.id === selectedClipId);
const selectedAsset = selectedClip && plan.assets.find((a) => a.id === selectedClip.assetId);
const selectedTransform =
gesture && gesture.clipId === selectedClipId
? gesture.transform
: selectedClip?.transform || defaultVideoTransform;
return ( return (
<div <div
className="relative mx-auto w-full max-w-4xl overflow-hidden bg-black" ref={frameRef}
style={{ aspectRatio: plan.ratio.replace(":", "/") }} className={
fit
? "relative h-full w-full overflow-hidden"
: "relative mx-auto w-full max-w-4xl overflow-hidden bg-black"
}
style={fit ? undefined : { aspectRatio: plan.ratio.replace(":", "/") }}
aria-label="剪辑实时预览" aria-label="剪辑实时预览"
> >
{timeline.clips.map((clip) => { <div
const asset = plan.assets.find((a) => a.id === clip.assetId)!; style={
const active = current.some((c) => c.id === clip.id), fit && !cover
incoming = clip.overlap && time * SECOND < clip.start + clip.overlap; ? {
return ( position: "absolute",
<video width: height * ratio,
key={clip.id} height,
ref={(element) => { left: "50%",
if (element) videos.current.set(clip.id, element); top: "50%",
else videos.current.delete(clip.id); transform: "translate(-50%,-50%)",
overflow: "hidden",
background: "black",
}
: { position: "absolute", inset: 0 }
}
>
{timeline.clips.map((clip) => {
const asset = plan.assets.find((a) => a.id === clip.assetId)!;
const active = current.some((c) => c.id === clip.id),
incoming = clip.overlap && visibleTime < clip.transitionStart + clip.overlap;
return (
<video
key={`${clip.id}:${audioGain.source(asset.url)}`}
ref={(element) => {
if (element) videos.current.set(clip.id, element);
else videos.current.delete(clip.id);
}}
src={audioGain.source(asset.url)}
muted={clip.audioDetached || clip.muted || clip.gain === 0}
playsInline
preload="metadata"
className={`absolute inset-0 h-full w-full ${cover ? "object-cover" : "object-contain"}`}
style={{
visibility: active ? "visible" : "hidden",
transform: (() => {
const t =
gesture?.clipId === clip.id
? gesture.transform!
: clip.transform || defaultVideoTransform;
return `translate(${t.x * 100}%,${t.y * 100}%) scale(${t.scale})`;
})(),
opacity: incoming
? clip.transitionType === "fade"
? progress
: progress < 0.5
? 0
: 1
: 1,
}}
onLoadedMetadata={(e) => {
const target =
clip.sourceIn / SECOND +
(Math.max(0, visibleTime - clip.start) / SECOND) * clip.speed;
e.currentTarget.currentTime = Math.min(clip.sourceOut / SECOND - 0.001, target);
}}
onError={() => {
onPlaying(false);
onError(`${asset.name} 预览加载失败`);
}}
/>
);
})}
{timeline.audioClips.map((c) => (
<audio
key={`${c.id}:${audioGain.source(plan.assets.find((a) => a.id === c.assetId)!.url)}`}
ref={(el) => {
if (el) audios.current.set(c.id, el);
else audios.current.delete(c.id);
}} }}
src={asset.url} src={audioGain.source(plan.assets.find((a) => a.id === c.assetId)!.url)}
playsInline
preload="metadata" preload="metadata"
className="absolute inset-0 h-full w-full object-contain" />
))}
{plan.music ? <audio ref={music} src={plan.music.url} preload="metadata" /> : null}
{incomingClip && incomingClip.transitionType !== "fade" && (
<div
style={{ style={{
visibility: active ? "visible" : "hidden", position: "absolute",
opacity: incoming ? Math.max(0, (time * SECOND - clip.start) / clip.overlap) : 1, inset: 0,
}} pointerEvents: "none",
onError={() => { background: incomingClip.transitionType === "fadeblack" ? "black" : "white",
onPlaying(false); opacity: 1 - Math.abs(2 * progress - 1),
onError(`${asset.name} 预览加载失败`);
}} }}
/> />
); )}
})} {selectedClip && selectedAsset && selectedTransform && onTransform && (
{plan.music ? <audio ref={music} src={plan.music.url} preload="metadata" /> : null} <div
<div aria-label="调整画面位置"
className="pointer-events-none absolute inset-x-4 bottom-[5%] text-center text-[clamp(12px,2vw,22px)] text-white" onClick={(e) => e.stopPropagation()}
style={{ textShadow: "-1px -1px 2px black,1px 1px 2px black" }} onPointerDown={(e) => moveTransform(e, selectedClip.id)}
> style={{
{subs.map((s, i) => ( position: "absolute",
<div key={i}>{s.text}</div> left: `${(selectedTransform.x + 0.5) * 100}%`,
))} top: `${(selectedTransform.y + 0.5) * 100}%`,
width:
Math.min(
height * ratio,
(height * selectedAsset.width) / selectedAsset.height,
) * selectedTransform.scale,
height:
Math.min(
height,
(height * ratio * selectedAsset.height) / selectedAsset.width,
) * selectedTransform.scale,
transform: "translate(-50%,-50%)",
border: "2px solid #87c9ff",
cursor: "move",
zIndex: 3,
}}
>
{(
[
[-1, -1],
[1, -1],
[1, 1],
[-1, 1],
] as [number, number][]
).map(([x, y]) => (
<button
key={`${x}:${y}`}
type="button"
aria-label="调整视频尺寸"
onPointerDown={(e) => moveTransform(e, selectedClip.id, [x, y])}
style={{
position: "absolute",
width: 12,
height: 12,
left: x < 0 ? 0 : "100%",
top: y < 0 ? 0 : "100%",
transform: "translate(-50%,-50%)",
border: "1px solid white",
background: "#87c9ff",
borderRadius: 2,
cursor: x === y ? "nwse-resize" : "nesw-resize",
}}
/>
))}
</div>
)}
<div className="pointer-events-none absolute inset-0 text-center" style={{ zIndex: 4 }}>
{subs.map((s, i) => {
const style = (s as { style?: CaptionStyle }).style || defaultCaptionStyle;
return (
<div
key={i}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => {
if (onCaptionPosition && "id" in s) moveCaption(e, s.id as string, style);
}}
style={{
position: "absolute",
left: `${((gesture && gesture.captionId === (s as TimelineCaption).id ? gesture.x : style.x) ?? 0.5) * 100}%`,
bottom: `${(1 - ((gesture && gesture.captionId === (s as TimelineCaption).id ? gesture.y : style.y) ?? 0.95)) * 100}%`,
transform: "translateX(-50%)",
maxWidth: "95%",
pointerEvents: onCaptionPosition ? "auto" : "none",
cursor: onCaptionPosition ? "move" : undefined,
outline:
selectedCaptionId === (s as TimelineCaption).id
? "1px solid #87c9ff"
: undefined,
fontFamily: `"${captionFont(style.fontFamily)?.family || "Noto Sans CJK SC"}", "Source Han Sans SC", sans-serif`,
whiteSpace: "pre-wrap",
fontSize: (style.fontSize * height) / (style.referenceHeight || 720),
letterSpacing: (style.spacing * height) / (style.referenceHeight || 720),
color: style.color,
opacity: style.opacity,
WebkitTextStroke: `${((style.outlineWidth * height) / (style.referenceHeight || 720)) * 2}px ${style.outlineColor}${Math.round(
(style.outlineOpacity ?? 1) * 255,
)
.toString(16)
.padStart(2, "0")}`,
paintOrder: "stroke fill",
lineHeight: 1.2,
}}
>
{s.text}
</div>
);
})}
</div>
</div> </div>
</div> </div>
); );

View File

@ -1,6 +1,9 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { X, Upload } from "lucide-react"; import { X, Upload } from "lucide-react";
import { hasRecoverableCanvasTask } from "@/lib/video-canvas/task-recovery";
import { SmartEditEditor, SmartEditEmptyEditor } from "./SmartEditEditor";
import { assertBeatMusic } from "@/lib/video-canvas/smart-edit/intent";
import { EditPreview } from "./EditPreview"; import { EditPreview } from "./EditPreview";
import { import {
smartEditRequestSchema, smartEditRequestSchema,
@ -19,6 +22,7 @@ export function SmartEditDialog({
plans: serverPlans, plans: serverPlans,
planTaskId, planTaskId,
sources, sources,
connectedSourceIds = [],
audios, audios,
preferredModelRef, preferredModelRef,
tasks, tasks,
@ -26,11 +30,13 @@ export function SmartEditDialog({
onDraft, onDraft,
onPlan, onPlan,
onRender, onRender,
onCancel,
}: { }: {
initial?: Record<string, any>; initial?: Record<string, any>;
plans?: EditPlan[]; plans?: EditPlan[];
planTaskId?: string; planTaskId?: string;
sources: Asset[]; sources: Asset[];
connectedSourceIds?: string[];
audios: { url: string; name: string }[]; audios: { url: string; name: string }[];
preferredModelRef?: string; preferredModelRef?: string;
tasks: CanvasGenerationTask[]; tasks: CanvasGenerationTask[];
@ -38,14 +44,15 @@ export function SmartEditDialog({
onDraft: (draft: Record<string, unknown>) => void; onDraft: (draft: Record<string, unknown>) => void;
onPlan: (params: SmartEditRequest) => Promise<unknown>; onPlan: (params: SmartEditRequest) => Promise<unknown>;
onRender: (plan: EditPlan) => Promise<unknown>; onRender: (plan: EditPlan) => Promise<unknown>;
onCancel?: (taskId: string) => Promise<unknown>;
}) { }) {
const [draft, setDraft] = useState<Record<string, any>>(() => ({ const [draft, setDraft] = useState<Record<string, any>>(() => ({
assets: [], assets: [],
mode: "montage", mode: "auto",
prompt: "", prompt: "",
targetDuration: 20, targetDuration: 20,
ratio: "16:9", ratio: "16:9",
resolution: "720p", resolution: "1080p",
semantic: true, semantic: true,
removeSilence: false, removeSilence: false,
transcribe: false, transcribe: false,
@ -55,7 +62,9 @@ export function SmartEditDialog({
preferredModelRef, preferredModelRef,
...initial, ...initial,
plans: plans:
initial?.planTaskId === planTaskId ? initial?.plans || serverPlans || [] : serverPlans || [], initial?.planTaskId === planTaskId
? initial?.plans || serverPlans || []
: serverPlans || [],
planTaskId, planTaskId,
})); }));
const latest = useRef(draft); const latest = useRef(draft);
@ -66,7 +75,12 @@ export function SmartEditDialog({
setDraft(next); setDraft(next);
onDraft(next); onDraft(next);
}; };
const [selected, setSelected] = useState(0), const [selected, setSelected] = useState(() =>
Math.max(
0,
Math.min((draft.plans?.length || 1) - 1, Number(initial?.selectedPlanIndex) || 0),
),
),
[clipIndex, setClipIndex] = useState(0), [clipIndex, setClipIndex] = useState(0),
[time, setTime] = useState(0), [time, setTime] = useState(0),
[playing, setPlaying] = useState(false), [playing, setPlaying] = useState(false),
@ -75,14 +89,22 @@ export function SmartEditDialog({
[cap, setCap] = useState<any>(), [cap, setCap] = useState<any>(),
[zoom, setZoom] = useState(1), [zoom, setZoom] = useState(1),
[snap, setSnap] = useState(true); [snap, setSnap] = useState(true);
const [configure, setConfigure] = useState(false);
const applied = useRef(planTaskId); const applied = useRef(planTaskId);
useEffect(() => { useEffect(() => {
if (planTaskId && planTaskId !== applied.current) { if (planTaskId && planTaskId !== applied.current) {
applied.current = planTaskId; applied.current = planTaskId;
patch({ plans: serverPlans || [], planTaskId }); const processing = tasks.some(
setSelected(0); (t) =>
t.id === planTaskId &&
["separate", "transcribe"].includes(String(t.request.params.operation)),
);
const index = processing ? Math.min(selected, (serverPlans?.length || 1) - 1) : 0;
patch({ plans: serverPlans || [], planTaskId, selectedPlanIndex: index });
setSelected(index);
setTime(0); setTime(0);
setPlaying(false); setPlaying(false);
setConfigure(false);
} }
}, [planTaskId, serverPlans]); }, [planTaskId, serverPlans]);
useEffect(() => { useEffect(() => {
@ -108,9 +130,51 @@ export function SmartEditDialog({
} catch (e) { } catch (e) {
validation = (e as Error).message; validation = (e as Error).message;
} }
const autoPrepared = useRef(false);
const startSequence = async () => {
const assets = [...new Set(connectedSourceIds)]
.map((id) => sources.find((a) => a.id === id))
.filter((a): a is Asset => Boolean(a));
if (!assets.length) return;
autoPrepared.current = true;
setBusy(true);
setError("");
try {
const params = smartEditRequestSchema.parse({
operation: "plan",
assets,
mode: "sequence",
targetDuration: 300,
semantic: false,
ratio: draft.ratio,
resolution: draft.resolution,
});
patch({ assets });
const preparationRequestId = await onPlan(params);
patch({ preparationRequestId });
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
};
useEffect(() => {
if (
!plan &&
!configure &&
!autoPrepared.current &&
!draft.preparationRequestId &&
!waiting &&
connectedSourceIds.length
)
void startSequence();
}, [Boolean(plan), configure, connectedSourceIds.join("|"), waiting]);
const changePlan = (next: EditPlan) => { const changePlan = (next: EditPlan) => {
setPlaying(false); setPlaying(false);
patch({ plans: draft.plans.map((p: EditPlan, i: number) => (i === selected ? next : p)) }); patch({
selectedPlanIndex: selected,
plans: draft.plans.map((p: EditPlan, i: number) => (i === selected ? next : p)),
});
}; };
const saveFile = (name: string, text: string) => { const saveFile = (name: string, text: string) => {
const url = URL.createObjectURL(new Blob([text], { type: "text/plain;charset=utf-8" })); const url = URL.createObjectURL(new Blob([text], { type: "text/plain;charset=utf-8" }));
@ -166,13 +230,78 @@ export function SmartEditDialog({
right = { ...plan.clips[clipIndex], id: crypto.randomUUID(), sourceIn: point }; right = { ...plan.clips[clipIndex], id: crypto.randomUUID(), sourceIn: point };
changePlan({ changePlan({
...plan, ...plan,
clips: [...plan.clips.slice(0, clipIndex), left, right, ...plan.clips.slice(clipIndex + 1)], clips: [
...plan.clips.slice(0, clipIndex),
left,
right,
...plan.clips.slice(clipIndex + 1),
],
}); });
} else clipEdit(clipIndex, action === "left" ? { sourceIn: point } : { sourceOut: point }); } else clipEdit(clipIndex, action === "left" ? { sourceIn: point } : { sourceOut: point });
}; };
const button = const button =
"rounded border border-white/20 px-3 py-2 text-xs hover:bg-white/10 disabled:opacity-40", "rounded border border-white/20 px-3 py-2 text-xs hover:bg-white/10 disabled:opacity-40",
input = "rounded border border-white/15 bg-[#303030] px-2 py-1 text-sm"; input = "rounded border border-white/15 bg-[#303030] px-2 py-1 text-sm";
if (!plan && !configure)
return (
<SmartEditEmptyEditor
onClose={onClose}
onConfigure={() => setConfigure(true)}
onRetry={() => void startSequence()}
loading={busy || waiting || Boolean(draft.preparationRequestId && !last)}
message={
error ||
last?.error ||
last?.result?.smartEdit?.stage ||
(connectedSourceIds.length ? "读取已连接的视频素材" : "连接视频素材后开始剪辑")
}
/>
);
const audioTask = [...tasks]
.reverse()
.find((t) => ["separate", "transcribe"].includes(String(t.request.params.operation)));
const audioRecovering =
audioTask?.status === "interrupted" && hasRecoverableCanvasTask(audioTask);
if (audioTask && (["pending", "running"].includes(audioTask.status) || audioRecovering))
return (
<SmartEditEmptyEditor
onClose={onClose}
onConfigure={() => {}}
onRetry={() => {}}
loading
message={
audioTask.result?.smartEdit?.stage || "等待音频处理,可退出剪辑,稍后重新打开查看结果"
}
/>
);
if (plan && timeline && !configure)
return (
<SmartEditEditor
key={`${planTaskId || "draft"}:${selected}`}
plan={plan}
onChange={changePlan}
onClose={onClose}
onConfigure={() => setConfigure(true)}
onRender={onRender}
onCancel={onCancel}
canSeparate={Boolean(cap?.capabilities?.separation)}
canTranscribe={Boolean(cap?.capabilities?.asr)}
processError={
audioTask &&
(["failed", "cancelled"].includes(audioTask.status) ||
(audioTask.status === "interrupted" && !audioRecovering))
? audioTask.error || "音频处理失败,原内容已保留"
: undefined
}
onProcess={(operation, plan, clipId, track, keep) =>
onPlan(smartEditRequestSchema.parse({ operation, plan, clipId, track, keep }))
}
tasks={tasks}
canRender={Boolean(cap?.capabilities?.render)}
exportState={draft.exportState}
onExportState={(exportState) => patch({ exportState })}
/>
);
return ( return (
<div <div
role="dialog" role="dialog"
@ -182,6 +311,11 @@ export function SmartEditDialog({
> >
<header className="flex shrink-0 items-center justify-between border-b border-white/15 px-5 py-3"> <header className="flex shrink-0 items-center justify-between border-b border-white/15 px-5 py-3">
<h2>智能剪辑 · {plan?.title || "从素材到成片"}</h2> <h2>智能剪辑 · {plan?.title || "从素材到成片"}</h2>
{plan && (
<button type="button" onClick={() => setConfigure(false)}>
返回时间轴
</button>
)}
<button type="button" aria-label="关闭智能剪辑" onClick={onClose}> <button type="button" aria-label="关闭智能剪辑" onClick={onClose}>
<X /> <X />
</button> </button>
@ -197,6 +331,8 @@ export function SmartEditDialog({
value={draft.mode} value={draft.mode}
onChange={(e) => patch({ mode: e.target.value })} onChange={(e) => patch({ mode: e.target.value })}
> >
<option value="auto">默认模式</option>
<option value="sequence">顺序编辑</option>
<option value="montage">素材混剪</option> <option value="montage">素材混剪</option>
<option value="talk">口播视频</option> <option value="talk">口播视频</option>
<option value="explain">讲解视频</option> <option value="explain">讲解视频</option>
@ -204,14 +340,15 @@ export function SmartEditDialog({
</select> </select>
</label> </label>
<div> <div>
<div className="mb-2 text-sm">素材({draft.assets.length}/6)</div> <div className="mb-2 text-sm">素材({draft.assets.length}/12)</div>
<select <select
aria-label="添加画布视频" aria-label="添加画布视频"
className={`${input} w-full`} className={`${input} w-full`}
value="" value=""
onChange={(e) => { onChange={(e) => {
const asset = sources.find((a) => a.id === e.target.value); const asset = sources.find((a) => a.id === e.target.value);
if (asset && draft.assets.length < 6) patch({ assets: [...draft.assets, asset] }); if (asset && draft.assets.length < 12)
patch({ assets: [...draft.assets, asset] });
}} }}
> >
<option value="">选择画布已有视频</option> <option value="">选择画布已有视频</option>
@ -234,8 +371,8 @@ export function SmartEditDialog({
onChange={async (e) => { onChange={async (e) => {
const files = Array.from(e.target.files || []); const files = Array.from(e.target.files || []);
e.target.value = ""; e.target.value = "";
if (files.length + draft.assets.length > 6) { if (files.length + draft.assets.length > 12) {
setError("最多 6 个素材"); setError("最多 12 个素材");
return; return;
} }
setBusy(true); setBusy(true);
@ -308,7 +445,7 @@ export function SmartEditDialog({
className={`${input} w-full`} className={`${input} w-full`}
type="number" type="number"
min={3} min={3}
max={90} max={300}
value={draft.targetDuration} value={draft.targetDuration}
onChange={(e) => patch({ targetDuration: Number(e.target.value) })} onChange={(e) => patch({ targetDuration: Number(e.target.value) })}
/> />
@ -321,7 +458,7 @@ export function SmartEditDialog({
value={draft.ratio} value={draft.ratio}
onChange={(e) => patch({ ratio: e.target.value })} onChange={(e) => patch({ ratio: e.target.value })}
> >
{["16:9", "9:16", "1:1"].map((v) => ( {["21:9", "16:9", "9:16", "1:1", "4:3", "3:4"].map((v) => (
<option key={v}>{v}</option> <option key={v}>{v}</option>
))} ))}
</select> </select>
@ -470,7 +607,9 @@ export function SmartEditDialog({
</select> </select>
) : null} ) : null}
{cap && {cap &&
(!cap.capabilities.scenes || !cap.capabilities.ffmpeg || !cap.capabilities.ffprobe) ? ( ((draft.mode !== "sequence" && !cap.capabilities.scenes) ||
!cap.capabilities.ffmpeg ||
!cap.capabilities.ffprobe) ? (
<p role="alert" className="text-sm text-amber-200"> <p role="alert" className="text-sm text-amber-200">
剪辑处理环境尚未准备好,请配置本地分析服务。 剪辑处理环境尚未准备好,请配置本地分析服务。
</p> </p>
@ -480,7 +619,7 @@ export function SmartEditDialog({
className={`${button} w-full bg-white/10`} className={`${button} w-full bg-white/10`}
disabled={ disabled={
!draft.assets.length || !draft.assets.length ||
!cap?.capabilities.scenes || (draft.mode !== "sequence" && !cap?.capabilities.scenes) ||
!cap?.capabilities.ffmpeg || !cap?.capabilities.ffmpeg ||
!cap?.capabilities.ffprobe !cap?.capabilities.ffprobe
} }
@ -490,6 +629,7 @@ export function SmartEditDialog({
setPlaying(false); setPlaying(false);
try { try {
const params = smartEditRequestSchema.parse({ ...draft, operation: "plan" }); const params = smartEditRequestSchema.parse({ ...draft, operation: "plan" });
if (params.operation === "plan") assertBeatMusic(params);
await onPlan(params); await onPlan(params);
} catch (e) { } catch (e) {
setError((e as Error).message); setError((e as Error).message);
@ -519,7 +659,9 @@ export function SmartEditDialog({
</p> </p>
) : null} ) : null}
{cap && !cap.capabilities.render ? ( {cap && !cap.capabilities.render ? (
<p className="mb-3 text-sm text-amber-200">视频合成服务暂不可用,剪辑方案仍可保存。</p> <p className="mb-3 text-sm text-amber-200">
视频合成服务暂不可用,剪辑方案仍可保存。
</p>
) : null} ) : null}
{plan ? ( {plan ? (
<> <>
@ -531,6 +673,7 @@ export function SmartEditDialog({
className={`${button} ${selected === i ? "bg-white/15" : ""}`} className={`${button} ${selected === i ? "bg-white/15" : ""}`}
onClick={() => { onClick={() => {
setSelected(i); setSelected(i);
patch({ selectedPlanIndex: i });
setTime(0); setTime(0);
setPlaying(false); setPlaying(false);
setClipIndex(0); setClipIndex(0);
@ -587,7 +730,9 @@ export function SmartEditDialog({
<button <button
type="button" type="button"
className={button} className={button}
onClick={() => saveFile("字幕.srt", subtitlesToSrt(compiledSubtitles(plan)))} onClick={() =>
saveFile("字幕.srt", subtitlesToSrt(compiledSubtitles(plan)))
}
> >
下载字幕 下载字幕
</button> </button>
@ -695,7 +840,9 @@ export function SmartEditDialog({
aria-label="剪辑转场" aria-label="剪辑转场"
className={input} className={input}
value={plan.transition} value={plan.transition}
onChange={(e) => changePlan({ ...plan, transition: Number(e.target.value) })} onChange={(e) =>
changePlan({ ...plan, transition: Number(e.target.value) })
}
> >
<option value={0}>直接切换</option> <option value={0}>直接切换</option>
<option value={0.25}>叠化 0.25 秒</option> <option value={0.25}>叠化 0.25 秒</option>
@ -774,27 +921,29 @@ export function SmartEditDialog({
{i + 1} · {plan.assets.find((a) => a.id === clip.assetId)?.name} {i + 1} · {plan.assets.find((a) => a.id === clip.assetId)?.name}
</button> </button>
</td> </td>
{(["sourceIn", "sourceOut", "speed", "gain"] as const).map((key, j) => ( {(["sourceIn", "sourceOut", "speed", "gain"] as const).map(
<td className="px-2 py-2" key={key}> (key, j) => (
<input <td className="px-2 py-2" key={key}>
aria-label={`片段${i + 1}${["入点", "出点", "速度", "音量"][j]}`} <input
type="number" aria-label={`片段${i + 1}${["入点", "出点", "速度", "音量"][j]}`}
className={`${input} w-24`} type="number"
min={key === "speed" ? 0.5 : 0} className={`${input} w-24`}
max={key === "speed" ? 2 : key === "gain" ? 1 : 180} min={key === "speed" ? 0.5 : 0}
step={j < 2 ? 0.1 : 0.1} max={key === "speed" ? 2 : key === "gain" ? 1 : 180}
value={j < 2 ? clip[key] / SECOND : clip[key]} step={j < 2 ? 0.1 : 0.1}
onChange={(e) => value={j < 2 ? clip[key] / SECOND : clip[key]}
clipEdit(i, { onChange={(e) =>
[key]: clipEdit(i, {
j < 2 [key]:
? Math.round(Number(e.target.value) * SECOND) j < 2
: Number(e.target.value), ? Math.round(Number(e.target.value) * SECOND)
}) : Number(e.target.value),
} })
/> }
</td> />
))} </td>
),
)}
<td className="whitespace-nowrap px-2 py-2"> <td className="whitespace-nowrap px-2 py-2">
{[-1, 1].map((delta) => ( {[-1, 1].map((delta) => (
<button <button

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,386 @@
.card {
position: relative;
width: 100%;
height: 100%;
padding: 8px;
border: 1px solid #454545;
border-radius: 18px;
background: #262626;
color: #eee;
font-family: Arial, "PingFang SC", sans-serif;
}
.title {
position: absolute;
top: -28px;
left: 0;
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
color: #929292;
white-space: nowrap;
}
.preview {
position: relative;
height: calc(100% - 80px);
overflow: hidden;
border-radius: 10px 10px 0 0;
background: #202020;
}
.preview > video {
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0.35;
}
.open {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
}
.open button {
height: 32px;
padding: 0 12px;
border: 0;
border-radius: 8px;
background: white;
color: #181818;
font-size: 16px;
cursor: pointer;
}
.ruler {
height: 26px;
display: flex;
justify-content: space-around;
align-items: center;
border-bottom: 1px solid #393939;
font-size: 8px;
color: #929292;
}
.tracks {
padding: 8px;
}
.tracks > div {
display: flex;
gap: 3px;
height: 20px;
margin-bottom: 3px;
}
.tracks i {
flex: 1;
background: #333;
border-radius: 3px;
}
.composer {
position: absolute;
z-index: 30;
transform: translateX(-50%);
width: 660px;
max-width: calc(100vw - 32px);
padding: 8px;
border: 0.5px solid #333;
border-radius: 16px;
background: #262626;
color: #eee;
box-shadow: 0 4px 16px #0002;
font:
14px / 1.5 Arial,
"PingFang SC",
sans-serif;
display: flex;
flex-direction: column;
gap: 8px;
}
.composer button {
cursor: pointer;
border: 0;
color: inherit;
font: inherit;
background: none;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
}
.composer button:disabled {
opacity: 0.4;
cursor: default;
}
.referenceRow {
height: 30px;
display: flex;
justify-content: space-between;
padding: 4px 4px 0;
}
.referenceRow > button:first-child {
font-size: 12px;
border-radius: 16px;
background: #333;
color: #999;
padding: 0 6px;
height: 26px;
}
.referenceRow > button:last-child {
color: #999;
width: 24px;
height: 24px;
}
.sources {
display: flex;
gap: 8px;
min-height: 54.8px;
flex-wrap: wrap;
padding: 0 4px;
}
.source {
width: 48px;
height: 48px;
position: relative;
border-radius: 8px;
background: #191919;
}
.source video {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 8px;
}
.source b {
position: absolute;
top: 3px;
left: 3px;
font-size: 9px;
font-weight: 400;
background: #0009;
border-radius: 8px;
padding: 0 3px;
}
.source > svg {
position: absolute;
top: 16px;
left: 16px;
width: 16px;
height: 16px;
padding: 3px;
border-radius: 50%;
background: #0009;
}
.source > span {
position: absolute;
bottom: 2px;
left: 3px;
font-size: 8px;
background: #0009;
line-height: 11px;
}
.source > button {
display: none;
position: absolute;
top: -4px;
right: -4px;
width: 15px;
height: 15px;
border-radius: 50%;
background: #666;
}
.source:hover > button,
.source:focus-within > button {
display: flex;
}
.composer textarea {
display: block;
width: 100%;
resize: none;
border: 0;
outline: 0;
background: transparent;
color: #eee;
padding: 8px 4px;
font:
14px / 1.8 Arial,
"PingFang SC",
sans-serif;
}
.composer textarea::placeholder {
color: #777;
}
.controls {
height: 32px;
display: flex;
gap: 8px;
}
.controls .mode,
.controls .parameters {
font-size: 12px;
height: 32px;
padding: 0 8px;
border-radius: 8px;
background: #333;
}
.controls .mode {
background: none;
}
.controls .send {
margin-left: auto;
width: 32px;
height: 32px;
border-radius: 12px;
background: white;
color: #222;
}
.popup {
position: absolute;
z-index: 5;
bottom: 40px;
padding: 12px;
background: #262626;
border: 0.5px solid #393939;
border-radius: 16px;
box-shadow: 0 4px 16px #0006;
color: #929292;
}
.settings {
left: 112px;
width: 340px;
}
.settings label {
display: block;
font-size: 12px;
margin: 0 0 8px;
}
.ratios {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
margin-bottom: 8px;
}
.ratios button {
height: 62px;
flex-direction: column;
gap: 8px;
font-size: 12px;
border: 1px solid #393939;
border-radius: 8px;
}
.ratios i {
display: block;
border: 1.5px solid currentColor;
border-radius: 1px;
}
.settings button[aria-pressed="true"] {
color: white;
background: #414141;
border-color: #999;
}
.duration {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
height: 22px;
}
.duration input[type="range"] {
min-width: 0;
flex: 1;
accent-color: white;
height: 4px;
}
.duration input[type="number"] {
width: 48px;
height: 22px;
appearance: textfield;
background: #333;
border: 0;
outline: 0;
border-radius: 6px;
color: #eee;
text-align: center;
font-size: 12px;
}
.duration input::-webkit-inner-spin-button {
appearance: none;
}
.duration > span {
font-size: 12px;
}
.resolutions {
display: flex;
gap: 8px;
}
.resolutions button {
flex: 1;
height: 34px;
border: 1px solid #393939;
border-radius: 10px;
font-size: 12px;
}
.modeMenu {
left: 8px;
width: 160px;
padding: 6px;
}
.modeMenu button {
width: 100%;
height: 36px;
padding: 0 8px;
justify-content: space-between;
color: #ddd;
font-size: 13px;
border-radius: 8px;
}
.modeMenu button:hover,
.sourceMenu button:hover {
background: #383838;
}
.sourceMenu {
left: 8px;
bottom: calc(100% - 40px);
width: 260px;
max-height: 320px;
overflow: auto;
}
.sourceMenu > div,
.sourceMenu > p {
font-size: 12px;
padding: 8px 4px;
}
.sourceMenu button {
width: 100%;
height: 36px;
justify-content: flex-start;
padding: 0 8px;
border-radius: 8px;
font-size: 13px;
color: #ddd;
}
.sourceMenu button span {
flex: 1;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
text-align: left;
}
.error {
color: #fca5a5;
font-size: 12px;
padding: 4px;
}
@media (max-width: 500px) {
.settings {
left: 4px;
max-width: calc(100vw - 48px);
}
}
.tracks { max-height: 54px; overflow: hidden; }
.tracks > div { position: relative; height: 12px; }
.tracks i { position: absolute; top: 0; bottom: 0; border-right: 2px solid #262626; }
.settings { max-height: min(600px, calc(100vh - 180px)); overflow-y: auto; }
.settings select { width: 100%; height: 30px; padding: 0 8px; border-radius: 6px; background: #333; color: #eee; border: 0; }
.musicUpload { display: flex; align-items: center; gap: 4px; margin-top: 8px; font-size: 12px; }
.settings label.beatToggle { display: flex; gap: 8px; align-items: center; }

View File

@ -0,0 +1,476 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {
ArrowUp,
Check,
ChevronDown,
Maximize2,
Play,
Plus,
Scissors,
Upload,
X,
} from "lucide-react";
import {
editRatios,
editResolutions,
planTimeline,
editableCaptions,
SECOND,
type EditPlan,
} from "@/lib/video-canvas/smart-edit/contract";
import { resolveSmartEditIntent, assertBeatMusic } from "@/lib/video-canvas/smart-edit/intent";
import styles from "./SmartEditNode.module.css";
export type SmartEditSource = { id: string; name: string; url: string; duration?: number };
const modes = [
{ id: "auto", name: "默认模式" },
{ id: "explain", name: "讲解视频" },
{ id: "ads", name: "批量广告" },
{ id: "talk", name: "口播视频" },
{ id: "montage", name: "素材混剪" },
];
export function SmartEditNodeCard({
title,
previewUrl,
plan,
onOpen,
selected = false,
}: {
title: string;
selected?: boolean;
previewUrl?: string;
plan?: EditPlan;
onOpen: () => void;
}) {
let timeline: ReturnType<typeof planTimeline> | undefined;
try {
if (plan) timeline = planTimeline(plan);
} catch {
/* Invalid saved drafts open in the editor for recovery. */
}
const length = timeline?.duration || 0,
tick = Math.max(1, Math.ceil(length / SECOND / 5));
return (
<div className={styles.card} style={{ borderColor: selected ? "#888" : "#454545" }}>
<div className={styles.title}>
<Play size={12} fill="currentColor" />
{title}
</div>
<div className={styles.preview}>
{previewUrl && <video src={`${previewUrl}#t=0.1`} muted preload="metadata" />}
<div className={styles.open}>
<Scissors size={36} />
<button onPointerDown={(e) => e.stopPropagation()} onClick={onOpen}>
打开智能剪辑
</button>
</div>
</div>
<div className={styles.ruler} aria-label="剪辑时间刻度">
{timeline ? (
Array.from({ length: Math.floor(length / SECOND / tick) + 1 }, (_, i) => {
const seconds = i * tick;
return (
<span key={i}>
{Math.floor(seconds / 60)}:{String(seconds % 60).padStart(2, "0")}
</span>
);
})
) : (
<span>尚未生成时间轴</span>
)}
</div>
<div className={styles.tracks} aria-label="剪辑时间轴缩略图">
{timeline && (
<>
<div>
{timeline.clips.map((clip) => (
<i
key={clip.id}
title={clip.label}
style={{
left: `${(clip.start / length) * 100}%`,
width: `${(clip.duration / length) * 100}%`,
}}
/>
))}
</div>
{!!timeline.audioClips.length && (
<div>
{timeline.audioClips.map((clip) => (
<i
key={clip.id}
style={{
left: `${(clip.start / length) * 100}%`,
width: `${(clip.duration / length) * 100}%`,
background: "#346752",
}}
/>
))}
</div>
)}
{plan?.subtitles && (
<div>
{editableCaptions(plan).map((caption) => (
<i
key={caption.id}
title={caption.text}
style={{
left: `${(caption.start / length) * 100}%`,
width: `${((caption.end - caption.start) / length) * 100}%`,
background: "#75602e",
}}
/>
))}
</div>
)}
</>
)}
</div>
</div>
);
}
export function SmartEditNodeComposer({
anchor,
draft,
sources,
availableSources,
availableAudios = [],
onDraft,
onAddSource,
onRemoveSource,
onUpload,
onReference,
onSubmit,
busy,
}: {
anchor: { centerX: number; bottomY: number; zoom: number };
draft: Record<string, any>;
sources: SmartEditSource[];
availableSources: SmartEditSource[];
availableAudios?: { name: string; url: string }[];
onDraft: (patch: Record<string, unknown>) => void;
onAddSource: (id: string) => void;
onRemoveSource: (id: string) => void;
onUpload: (files: File[]) => void;
onReference?: () => void;
onSubmit: () => Promise<unknown>;
busy: boolean;
}) {
const [menu, setMenu] = useState<"mode" | "settings" | "sources" | null>(null),
[expanded, setExpanded] = useState(false),
[error, setError] = useState(""),
[submitting, setSubmitting] = useState(false);
useEffect(() => setError(""), [draft.music?.url, draft.beatSync]);
const root = useRef<HTMLDivElement>(null),
file = useRef<HTMLInputElement>(null),
musicFile = useRef<HTMLInputElement>(null);
useEffect(() => {
const close = (e: PointerEvent) => {
if (!root.current?.contains(e.target as Node)) setMenu(null);
};
document.addEventListener("pointerdown", close);
return () => document.removeEventListener("pointerdown", close);
}, []);
const ratio = draft.ratio || "16:9",
resolution = draft.resolution || "1080p",
duration = draft.targetDuration ?? 20,
mode = modes.find((m) => m.id === draft.mode) || modes[0];
const toggle = (value: typeof menu) => setMenu(menu === value ? null : value);
return (
<div
ref={root}
className={styles.composer}
style={{ left: anchor.centerX, top: anchor.bottomY + 16 * anchor.zoom }}
onPointerDown={(e) => e.stopPropagation()}
onWheel={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<div className={styles.referenceRow}>
<button
onClick={() => (onReference ? onReference() : toggle("sources"))}
onContextMenu={(e) => {
e.preventDefault();
toggle("sources");
}}
>
<Plus size={14} />
参考
</button>
<button
aria-label={expanded ? "收起输入框" : "展开输入框"}
onClick={() => setExpanded(!expanded)}
>
<Maximize2 size={16} />
</button>
</div>
{!!sources.length && (
<div className={styles.sources}>
{sources.map((s, i) => (
<div className={styles.source} key={s.id} title={s.name}>
<video src={`${s.url}#t=0.1`} preload="metadata" muted />
<b>{i + 1}</b>
<Play size={12} fill="white" />
<span>{s.duration ? `${s.duration.toFixed(1)}s` : "视频"}</span>
<button aria-label={`移除 ${s.name}`} onClick={() => onRemoveSource(s.id)}>
<X size={10} />
</button>
</div>
))}
</div>
)}
<input
ref={musicFile}
hidden
type="file"
accept="audio/*"
onChange={async (e) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
if (file.size > 125 * 1024 * 1024) {
setError("单个素材不得超过 125 MiB");
return;
}
setSubmitting(true);
setError("");
try {
const body = new FormData();
body.append("file", file);
const response = await fetch("/api/canvas/media/upload", {
method: "POST",
body,
signal: AbortSignal.timeout(120000),
});
const data = await response.json(),
url = data.url || data.data?.url;
if (!response.ok || !url) throw Error(data.error || "音乐上传失败");
onDraft({ music: { url, gain: 0.2, start: 0 } });
} catch (e) {
setError(e instanceof Error ? e.message : "音乐上传失败");
} finally {
setSubmitting(false);
}
}}
/>
<textarea
aria-label="智能剪辑要求"
placeholder="描述你想要的剪辑效果"
value={draft.prompt || ""}
onChange={(e) => onDraft({ prompt: e.target.value })}
style={{ height: expanded ? 192 : 96 }}
/>
<div className={styles.controls}>
<button className={styles.mode} onClick={() => toggle("mode")}>
{mode.name}
<ChevronDown size={12} />
</button>
<button className={styles.parameters} onClick={() => toggle("settings")}>
{ratio} · {String(resolution).toUpperCase()} · {duration ? `${duration}s` : "自动"}
<ChevronDown size={12} />
</button>
<button
className={styles.send}
disabled={busy || submitting || sources.length === 0}
aria-label="发送"
onClick={async () => {
setError("");
setSubmitting(true);
try {
assertBeatMusic(resolveSmartEditIntent(draft));
await onSubmit();
} catch (e) {
setError(e instanceof Error ? e.message : "提交失败");
} finally {
setSubmitting(false);
}
}}
>
<ArrowUp size={20} />
</button>
</div>
{error && (
<div role="alert" className={styles.error}>
{error}
</div>
)}
{menu === "mode" && (
<div className={`${styles.popup} ${styles.modeMenu}`}>
{modes.map((m) => (
<button
key={m.id}
onClick={() => {
onDraft({ mode: m.id });
setMenu(null);
}}
>
{m.name}
{m.id === mode.id && <Check size={14} />}
</button>
))}
</div>
)}
{menu === "settings" && (
<div className={`${styles.popup} ${styles.settings}`}>
<label>比例</label>
<div className={styles.ratios}>
{editRatios.map((r) => {
const [w, h] = r.split(":").map(Number);
return (
<button
key={r}
aria-pressed={r === ratio}
onClick={() => onDraft({ ratio: r })}
>
<i
style={{
width: Math.min(16, (16 * w) / h),
height: Math.min(16, (16 * h) / w),
}}
/>
{r}
</button>
);
})}
</div>
<label htmlFor="smart-edit-duration">时长</label>
<div className={styles.duration}>
<input
aria-label="目标时长"
type="range"
min={0}
max={300}
step={1}
value={duration}
onChange={(e) => onDraft({ targetDuration: Number(e.target.value) })}
/>
<input
id="smart-edit-duration"
type="number"
min={0}
max={300}
value={duration}
onChange={(e) =>
onDraft({ targetDuration: Math.max(0, Math.min(300, Number(e.target.value))) })
}
/>
<span>s</span>
</div>
{mode.id === "ads" && (
<>
<label>广告版本</label>
<div className={styles.resolutions}>
{[1, 2, 3].map((count) => (
<button
key={count}
aria-pressed={(draft.variants ?? 3) === count}
onClick={() => onDraft({ variants: count })}
>
{count} 版
</button>
))}
</div>
</>
)}
<label htmlFor="smart-edit-music">背景音乐</label>
<select
id="smart-edit-music"
value={draft.music?.url || ""}
onChange={(e) =>
onDraft({
music: e.target.value
? { url: e.target.value, gain: 0.2, start: 0 }
: undefined,
})
}
>
<option value="">未选择</option>
{draft.music?.url && !availableAudios.some((a) => a.url === draft.music.url) && (
<option value={draft.music.url}>已上传音乐</option>
)}
{availableAudios.map((audio) => (
<option key={audio.url} value={audio.url}>
{audio.name}
</option>
))}
</select>
<button
className={styles.musicUpload}
disabled={submitting}
onClick={() => musicFile.current?.click()}
>
<Upload size={12} />
上传音乐
</button>
<label className={styles.beatToggle}>
<input
type="checkbox"
checked={resolveSmartEditIntent(draft).beatSync}
onChange={(e) => onDraft({ beatSync: e.target.checked })}
/>
音乐卡点
</label>
{resolveSmartEditIntent(draft).beatSync && (
<div className={styles.resolutions}>
{["2", "4"].map((beats) => (
<button
key={beats}
aria-pressed={(draft.beatsPerCut || "4") === beats}
onClick={() => onDraft({ beatsPerCut: beats })}
>
{beats} 拍切换
</button>
))}
</div>
)}
<label>清晰度</label>
<div className={styles.resolutions}>
{editResolutions.map((r) => (
<button
key={r}
aria-pressed={r === resolution}
onClick={() => onDraft({ resolution: r })}
>
{r.toUpperCase()}
</button>
))}
</div>
</div>
)}
{menu === "sources" && (
<div className={`${styles.popup} ${styles.sourceMenu}`}>
<button onClick={() => file.current?.click()} disabled={sources.length >= 12}>
<Upload size={14} />
上传视频
</button>
<div>画布素材 · {sources.length}/12</div>
{availableSources.map((s) => (
<button
key={s.id}
disabled={sources.length >= 12 && !sources.some((a) => a.id === s.id)}
onClick={() =>
sources.some((a) => a.id === s.id) ? onRemoveSource(s.id) : onAddSource(s.id)
}
>
<Play size={14} />
<span>{s.name}</span>
{sources.some((a) => a.id === s.id) && <Check size={14} />}
</button>
))}
{!availableSources.length && <p>上传视频或连接画布视频素材</p>}
</div>
)}
<input
ref={file}
type="file"
hidden
accept="video/*"
multiple
onChange={(e) => {
onUpload(Array.from(e.target.files || []).slice(0, 12 - sources.length));
e.target.value = "";
setMenu(null);
}}
/>
</div>
);
}

View File

@ -0,0 +1,75 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { type EditPlan, planTimeline, SECOND } from "@/lib/video-canvas/smart-edit/contract";
import { EditPreview } from "./EditPreview";
/** Preview the selected edit boundary with the same timing/compositing used by the editor. */
export function TransitionPreview({
plan,
index,
cover = false,
}: {
plan: EditPlan;
index: number;
cover?: boolean;
}) {
const preview = useMemo(
() => ({
...plan,
subtitles: false,
music: undefined,
audioClips: [],
clips: plan.clips
.slice(index - 1, index + 1)
.map((c) => ({ ...c, gain: 0, audioDetached: true })),
}),
[plan, index],
);
const timeline = useMemo(() => planTimeline(preview), [preview]);
const boundary = timeline.clips[1];
const start = Math.max(0, boundary.transitionStart / SECOND - 0.25);
const end = Math.min(
timeline.duration / SECOND,
(boundary.transitionStart + boundary.overlap) / SECOND + 0.25,
);
const [time, setTime] = useState(start),
[error, setError] = useState(""),
[visible, setVisible] = useState(true);
useEffect(() => {
const change = () => setVisible(!document.hidden);
document.addEventListener("visibilitychange", change);
return () => document.removeEventListener("visibilitychange", change);
}, []);
useEffect(() => {
let request = 0,
epoch = 0;
setError("");
setTime(start);
const tick = (now: number) => {
if (document.hidden) epoch = 0;
else {
if (!epoch) epoch = now;
setTime(start + (((now - epoch) / 1000) % (end - start)));
}
request = requestAnimationFrame(tick);
};
request = requestAnimationFrame(tick);
return () => cancelAnimationFrame(request);
}, [start, end, preview]);
return (
<div className="se-transition-preview" aria-label="当前片段转场动态预览">
<EditPreview
plan={preview}
time={time}
playing={visible}
externalClock
onTime={setTime}
onPlaying={() => {}}
onError={setError}
fit
cover={cover}
/>
{error && <div className="se-transition-preview-error">{error}</div>}
</div>
);
}

View File

@ -0,0 +1,111 @@
"use client";
import { useEffect, useRef, useState } from "react";
import type { EditPlan } from "@/lib/video-canvas/smart-edit/contract";
/** Only amplified cross-origin sources need a same-origin copy for Web Audio. */
export function usePreviewGain(plan: EditPlan, onError: (message: string) => void) {
const remote = [
...new Set(
[...plan.clips, ...(plan.audioClips || [])]
.filter((clip) => clip.gain > 1)
.map((clip) => plan.assets.find((asset) => asset.id === clip.assetId)!.url)
.filter(
(url) =>
typeof window !== "undefined" &&
new URL(url, window.location.href).origin !== window.location.origin,
),
),
].sort();
const key = JSON.stringify(remote);
const [copies, setCopies] = useState<{ key: string; urls: Record<string, string> }>({
key: "",
urls: {},
});
const [attempt, setAttempt] = useState(0),
[failed, setFailed] = useState(false);
const error = useRef(onError);
error.current = onError;
const graph = useRef<{
context: AudioContext;
nodes: Map<HTMLMediaElement, { source: MediaElementAudioSourceNode; gain: GainNode }>;
}>();
useEffect(() => {
setFailed(false);
const controller = new AbortController(),
urls: Record<string, string> = {};
void Promise.all(
(JSON.parse(key) as string[]).map(async (url) => {
const response = await fetch("/api/canvas/download-image", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url, name: "剪辑音量预览" }),
signal: controller.signal,
});
if (!response.ok) throw Error("无法读取音量预览素材,请检查链接后重试");
const blob = await response.blob();
if (!blob.size) throw Error("音量预览素材为空");
if (!controller.signal.aborted) urls[url] = URL.createObjectURL(blob);
}),
)
.then(() => {
if (!controller.signal.aborted) setCopies({ key, urls });
})
.catch((e) => {
if (!controller.signal.aborted) {
setFailed(true);
error.current(`${e.message},可点击播放重试`);
}
});
return () => {
controller.abort();
Object.values(urls).forEach(URL.revokeObjectURL);
};
}, [key, attempt]);
useEffect(
() => () => {
void graph.current?.context.close();
graph.current = undefined;
},
[],
);
return {
retry() {
if (failed) setAttempt((value) => value + 1);
},
source(url: string) {
return copies.key === key ? copies.urls[url] || url : url;
},
ready(url: string) {
return !remote.includes(url) || (copies.key === key && Boolean(copies.urls[url]));
},
retain(elements: HTMLMediaElement[]) {
for (const [element, node] of graph.current?.nodes || [])
if (!elements.includes(element)) {
element.pause();
node.source.disconnect();
node.gain.disconnect();
graph.current!.nodes.delete(element);
}
},
volume(element: HTMLMediaElement, gain: number, playing: boolean) {
let node = graph.current?.nodes.get(element);
if (gain > 1 && !node) {
graph.current ||= { context: new AudioContext(), nodes: new Map() };
const source = graph.current.context.createMediaElementSource(element),
amplifier = graph.current.context.createGain();
source.connect(amplifier);
amplifier.connect(graph.current.context.destination);
node = { source, gain: amplifier };
graph.current.nodes.set(element, node);
}
if (node) {
element.volume = 1;
node.gain.gain.value = Math.max(0, gain);
if (playing && graph.current?.context.state === "suspended")
void graph.current.context
.resume()
.catch(() => error.current("请再次点击播放以启用音频预览"));
} else element.volume = Math.min(1, Math.max(0, gain));
},
};
}

View File

@ -0,0 +1,63 @@
# 智能剪辑四项续修与本地验收(2026-09-18)
本轮承接 `e18413a4`,范围为人声分离、自动转写、完整字体目录、主站智能剪辑链路。仅本地修改和验证,未部署、未推送。并非全部网站功能或所有视觉状态完成像素级验收。
## 功能结果
| 项目 | 实现与验证 |
| --- | --- |
| 人声分离 | 离线 HTDemucs,CPU 2线程、7秒分块、每素材180秒上限。视频和独立音轨均可选择“仅保留人声”“仅保留背景音”“分离为两个音轨”。保留裁剪、变速、增益、静音和转场音量包络,关闭已分离的视频原声,防止音量叠加;处理失败保留原方案。 |
| 自动转写 | 本地 FunASR Paraformer/FSMN-VAD/CT-Transformer。字幕面板入口直接创建持久化任务,保留手动字幕、替换此前 ASR 字幕。修复缺失 `pred_timestamp` 导致没有逐句时间戳的问题,负起点归零,按照裁剪及变速映射到时间轴。 |
| 完整字体库 | 参考目录共2002项,5项随应用打包、1997项新增目录全部下载、解析及SHA校验通过;198个参考源字体,其他字体来自官方 Google Fonts。原始与处理后字节均固定校验和。浏览器和合成使用相同字体文件;字体内版权信息保留。支持搜索、全部/常用/推荐/Google分类、虚拟滚动。修复小高度窗口弹层被参数栏截断。 |
| 主站智能剪辑链路 | 实际 Next 生产构建、登录、隔离数据库、上传、任务队列、模型处理、FFmpeg合成、成片与SRT、画布回填播放、编辑方案保存和刷新恢复均验证。未使用独立演示页的样例任务回调。 |
模型官方来源:[Demucs](https://github.com/facebookresearch/demucs)、[FunASR](https://github.com/modelscope/FunASR)。部署安装及缓存配置见 [服务说明](../services/smart-edit/README.md)。
## 真实数据
中文样例8.3935秒,视频8.9秒。实际识别三句话:
- 0–1.349秒:“欢迎使用智能剪辑,”
- 1.349–4.710秒:“我们正在测试人声分离和自动字幕,”
- 4.710–8.205秒:“请保留原始视频的画面和声音。”
实际混音分离后,人声及背景声相加与原信号的最大误差 `1.49e-8`。这验证音量与时间同步;不等于对任意歌曲的人声提取质量作保证。
生产构建 API 链路:方案1.6秒、视频分离10.6秒、转写10.6秒、独立音轨分离9.1秒、合成1.6秒。两个单轨保留分支另测11.6/10.2秒,均只新增所选的一轨。此为本机短素材实测,不是线上性能承诺。成片854×480、H.264/AAC、8.900秒。
新增字体1997项全部处理通过,缓存793,509,828字节(约757MiB)。实际FFmpeg验证原有5种及新增6种(大宝桃桃体、ABeeZee、Abril Fatface、CJGDGMH、Molle、SCJSSHT),检查实际字形像素。主站实际选择大宝桃桃体,应用全部字幕并确认数据库保存为所选字体。两款参考字体OS/2版本字段与长度不匹配,仅修正字段版本,未改变字形轮廓。
## 发现并修复的主站问题
- 新画布依赖数据库 `now()` 写入无时区时间时,与应用生成的任务时间产生8小时时差,导致任务回填被判为旧结果。新建画布的两个接口改为显式应用时间;隔离测试库为 Asia/Shanghai,新增项目时间断言通过。未迁移或修改生产历史数据。
- 视频卡片原先忽略 `videoMeta.durationSeconds`,显示5秒而实际8.9秒。优先读取成片元数据及浏览器解码时长,现显示9秒。
- 音频处理结果不得把编辑器节点变成视频节点;更新当前方案时保留其他备选方案,反复回填保持幂等。
- 开发热更新期间曾遇到编辑器样式失效;最终生产构建重新加载、播放和操作正常。未将开发热更新现象误判为已定位的线上缺陷。
## 验证与复现
- TypeScript及Next生产构建通过。
- Helper回归498通过、1跳过。
- 真实FFmpeg:6比例、3转场、居中转场、裁剪/变速/音量/静音/字幕位置与字体;PySceneDetect 3镜头、RMS 2有效区间、librosa 22拍。
- 独立合成服务:鉴权、真实成片、进程中断后恢复、幂等、冲突拒绝、完成结果重启保留。
- 主站浏览器:实际自动转写、字体搜索/选择/全部应用、两个分离子菜单、仅人声提交、刷新后回填、成片播放和导出到画布。
可复现脚本:
```sh
$SMART_EDIT_PYTHON scripts/qa/smart-edit-models.py input.wav .qa/model-check
SMART_EDIT_TEST_CATALOG_FONTS=1 node scripts/qa/video-canvas-smart-edit.cjs
SMART_EDIT_QA_DIR=.qa/isolated-main-chain node scripts/qa/smart-edit-main-chain.cjs
node services/video-render/build.cjs
node scripts/qa/video-render-service.cjs
```
主站脚本仅允许localhost/127.0.0.1,需独立测试库、实际登录账号 `account.json`(id/email/password)和含“智能剪辑”的中文 `speech.mp4`。不得使用生产账号或生产库。数据库、模型权重、字体缓存、测试凭据和成片不提交Git。
本地证据目录 `.qa/smart-edit/remaining-0918/`:`model-regression/report.json`、`production-chain.log`、`production-chain/main-result.mp4`、`branches-report.json`、`catalog-render.log`、`browser-verification.json`、`main-editor-final.jpg`、`browser-export.mp4`、`build-release.log`、`helpers-final.log`、`render-service.log`。
## 验收边界
本地主站使用隔离 PostgreSQL 数据库及本地S3兼容服务。测试进程注入仅限本地下载传输的映射,使真实下载器可访问该存储;没有放宽生产SSRF规则。尚未验证线上对象存储或部署。
目录及字体文件完整,不代表2002字体在所有DPR、中文混排、字形抗锯齿方面都完成逐像素对拍。后台休眠/断网/所有窄屏矩阵、运行中远程任务主动取消、五种模式算法与参考逐案一致性仍单列待验,不混入本轮完成项。

View File

@ -0,0 +1,777 @@
{
"scope": "智能剪辑节点、编辑器及直接依赖的音频/导出链路;不是全站所有模块",
"baseline": "68f62dae",
"local_only": true,
"counts": {
"通过": 39,
"待验": 6,
"代码核对": 17,
"已修复": 28,
"部分修复": 4,
"既有证据": 1
},
"items": [
{
"id": "SE-001",
"group": "节点与输入",
"item": "打开剪辑",
"status": "通过",
"detail": "参考和本地均有入口;本地进入真实编辑组件",
"evidence": "本轮UI"
},
{
"id": "SE-002",
"group": "节点与输入",
"item": "五种模式名称和顺序",
"status": "通过",
"detail": "默认、讲解、批量广告、口播、素材混剪相同",
"evidence": "参考node-modes截图/SmartEditNode"
},
{
"id": "SE-003",
"group": "节点与输入",
"item": "五种模式算法效果",
"status": "待验",
"detail": "本地有模式分支;没有重新提交参考站付费生成,不能认定效果等同",
"evidence": "execute/contract"
},
{
"id": "SE-004",
"group": "节点与输入",
"item": "六种比例",
"status": "通过",
"detail": "21:9、16:9、9:16、1:1、4:3、3:4;实际合成覆盖",
"evidence": "参考node-settings/本轮FFmpeg"
},
{
"id": "SE-005",
"group": "节点与输入",
"item": "三种清晰度",
"status": "通过",
"detail": "480P、720P、1080P;控件一致",
"evidence": "参考node-settings/SmartEditNode"
},
{
"id": "SE-006",
"group": "节点与输入",
"item": "时长范围",
"status": "通过",
"detail": "0–300秒、步长1秒;本地0为自动",
"evidence": "参考可见DOM/contract"
},
{
"id": "SE-007",
"group": "节点与输入",
"item": "提示词编辑与展开",
"status": "代码核对",
"detail": "本地受控输入、展开及提交;参考既有提示词未改动",
"evidence": "SmartEditNode"
},
{
"id": "SE-008",
"group": "节点与输入",
"item": "引用画布素材",
"status": "代码核对",
"detail": "参考进入引用选择;生产组件onReference连接连线状态,本地独立测试页只是候选列表",
"evidence": "参考引用模式/主画布调用点"
},
{
"id": "SE-009",
"group": "节点与输入",
"item": "素材序号、时长、移除",
"status": "代码核对",
"detail": "本地有缩略图、序号、时长及移除;未删除参考原素材",
"evidence": "SmartEditNode"
},
{
"id": "SE-010",
"group": "节点与输入",
"item": "本地上传与数量限制",
"status": "代码核对",
"detail": "真实上传入口,上限12个素材、单个125MiB;参考上限尚未验证",
"evidence": "SmartEditNode/SmartEditDialog"
},
{
"id": "SE-011",
"group": "节点与输入",
"item": "节点缩略时间轴",
"status": "已修复",
"detail": "节点刻度、视频、字幕、独立音轨从实际计划生成;重新生成后按任务版本选择新计划,保留所选版本。",
"evidence": "本轮节点UI/SmartEditNodeCard"
},
{
"id": "SE-012",
"group": "节点与输入",
"item": "批量广告多版本",
"status": "部分修复",
"detail": "节点新增1–3版选择,未指定时广告默认3版;实际仍是候选顺序变体,未认定为参考站的多创意算法。",
"evidence": "本轮UI/intent helpers"
},
{
"id": "SE-013",
"group": "节点与输入",
"item": "提示词要求卡点",
"status": "已修复",
"detail": "节点提示词的常见“卡点/beat sync”表达关联开关,显式关闭优先;支持画布音乐选择/上传及2或4拍切换,缺音乐阻止提交。并非通用自然语言意图解析。",
"evidence": "本轮UI/intent helpers/真实librosa 22拍"
},
{
"id": "SE-014",
"group": "布局与操作",
"item": "顶栏高度和边距",
"status": "通过",
"detail": "48px顶栏、8px页面边距;本轮图像对照",
"evidence": "参考DOM/本地CSS"
},
{
"id": "SE-015",
"group": "布局与操作",
"item": "左右参数栏宽度",
"status": "通过",
"detail": "转场库372px、参数栏300px,均可拖动调整",
"evidence": "参考DOM/本地CSS"
},
{
"id": "SE-016",
"group": "布局与操作",
"item": "面板背景与关闭按钮",
"status": "已修复",
"detail": "背景统一#202020,边框白色10%,关闭按钮24px,标题500/14px",
"evidence": "参考transition.json/本地CSS"
},
{
"id": "SE-017",
"group": "布局与操作",
"item": "默认时间轴高度",
"status": "已修复",
"detail": "保留打开时的像素高度,窗口缩小时仅为可用空间收缩,不再每次重算35vh。",
"evidence": "resize代码"
},
{
"id": "SE-018",
"group": "布局与操作",
"item": "折叠预览",
"status": "已修复",
"detail": "收起后背景透明、可操作下方画布,仅底部保留时间轴;预览组件保留,实测能播放到片尾。独立页面下层为测试按钮,主画布接入仍需整体验收。",
"evidence": "本轮本地UI"
},
{
"id": "SE-019",
"group": "布局与操作",
"item": "全屏预览",
"status": "代码核对",
"detail": "本地调用Fullscreen API并显示拒绝错误;本轮未重测不同浏览器权限状态",
"evidence": "SmartEditEditor"
},
{
"id": "SE-020",
"group": "布局与操作",
"item": "撤销重做",
"status": "通过",
"detail": "本轮分离音轨后撤销成功;参考宽屏有按钮、窄屏隐藏,先前未见不代表没有",
"evidence": "本轮UI/参考1512宽度"
},
{
"id": "SE-021",
"group": "布局与操作",
"item": "播放、暂停、逐帧",
"status": "代码核对",
"detail": "Space与左右键、30fps步进、片尾停止;前批浏览器已验证,本轮保留代码核对口径",
"evidence": "SmartEditEditor/EditPreview"
},
{
"id": "SE-022",
"group": "布局与操作",
"item": "吸附默认值",
"status": "通过",
"detail": "参考关闭,本地SmartEditEditor也是false;纠正旧文档“本地开启”误记",
"evidence": "参考AX/SmartEditEditor:state"
},
{
"id": "SE-023",
"group": "布局与操作",
"item": "退出与Esc",
"status": "已修复",
"detail": "Esc退出整个编辑器,输入框编辑时不误退出;收起时保留关闭按钮。",
"evidence": "本轮本地UI"
},
{
"id": "SE-024",
"group": "布局与操作",
"item": "窄屏可用性",
"status": "待验",
"detail": "参考1024宽度测得左右仍372/300;本地三栏与工具条更窄窗口尚无完整可用性验收",
"evidence": "reference-transition-1024"
},
{
"id": "SE-025",
"group": "视频片段",
"item": "四角缩放、移动与重置",
"status": "通过",
"detail": "本轮真实合成像素验证;本地有选框和重置,参考四角手柄可见",
"evidence": "本轮FFmpeg/参考video"
},
{
"id": "SE-026",
"group": "视频片段",
"item": "变速上下调步长",
"status": "已修复",
"detail": "参考1.00→1.01,本地0.05改为0.01;浏览器已复测",
"evidence": "本轮UI"
},
{
"id": "SE-027",
"group": "视频片段",
"item": "变速支持范围",
"status": "待验",
"detail": "本地0.5–2倍;参考未穷尽上下限,不能因步长一致声称范围一致",
"evidence": "contract/参考video"
},
{
"id": "SE-028",
"group": "视频片段",
"item": "时长输入",
"status": "代码核对",
"detail": "按原素材剩余长度和变速约束,不能超过素材",
"evidence": "SmartEditEditor/contract"
},
{
"id": "SE-029",
"group": "视频片段",
"item": "数值越界后的显示",
"status": "已修复",
"detail": "到上限2后再输入99,显示回到2.00,不残留非法数字",
"evidence": "本轮UI"
},
{
"id": "SE-030",
"group": "视频片段",
"item": "增益步长",
"status": "已修复",
"detail": "参考0.5dB,本地由1改0.5;浏览器0→-0.5确认",
"evidence": "本轮UI"
},
{
"id": "SE-031",
"group": "视频片段",
"item": "正增益",
"status": "已修复",
"detail": "本地支持最高+12dB,预览使用GainNode,导出实际音量放大;参考上限尚未确认。跨域正增益素材经登录下载接口取得同源副本,需另测真实主站链路。",
"evidence": "本轮浏览器+0.5dB/FFmpeg约2倍振幅"
},
{
"id": "SE-032",
"group": "视频片段",
"item": "边缘裁剪和排序",
"status": "代码核对",
"detail": "已有按source时间/速度换算和撤销;本轮未重新拖遍所有方向",
"evidence": "SmartEditEditor"
},
{
"id": "SE-033",
"group": "视频片段",
"item": "分割和左右裁剪",
"status": "通过",
"detail": "边界禁用、源范围及片长合同回归通过;本轮未对拍参考破坏性编辑",
"evidence": "本轮helpers/SmartEditEditor"
},
{
"id": "SE-034",
"group": "视频片段",
"item": "至少保留一个片段",
"status": "待验",
"detail": "本地阻止删最后一段;参考空时间轴行为未删除原素材验证",
"evidence": "SmartEditEditor"
},
{
"id": "SE-035",
"group": "视频片段",
"item": "右键菜单",
"status": "部分修复",
"detail": "已增加人声分离子菜单及仅保留人声、仅保留背景音、两轨输出;菜单其他文案与参考仍有差异。",
"evidence": "本轮主站UI/真实HTDemucs"
},
{
"id": "SE-036",
"group": "视频片段",
"item": "转场覆盖区右键",
"status": "已修复",
"detail": "转场覆盖区域按指针时间选择底层片段并打开视频右键菜单。",
"evidence": "本轮浏览器:覆盖框右键选中有声片段并成功分离"
},
{
"id": "SE-037",
"group": "视频片段",
"item": "静音后恢复原增益",
"status": "已修复",
"detail": "新增可选muted独立于gain保存,原声和独立音轨恢复不会覆盖原增益;分离时继承静音状态。",
"evidence": "浏览器原声/独立轨+0.5dB静音恢复;真实FFmpeg两类静音"
},
{
"id": "SE-038",
"group": "字幕",
"item": "空列表、新建、搜索",
"status": "通过",
"detail": "参考空列表和新建已操作;本地新建成功、搜索有真实过滤",
"evidence": "本轮UI/源码"
},
{
"id": "SE-039",
"group": "字幕",
"item": "列表逐条新增与删除",
"status": "已修复",
"detail": "每行新增在后面插入/删除,插入按时间排序、遇下条字幕或片尾缩短,并定位新字幕;片尾不能插入时给提示。",
"evidence": "本轮UI/代码"
},
{
"id": "SE-040",
"group": "字幕",
"item": "列表时间格式",
"status": "已修复",
"detail": "时间框采用MM:SS.xx,可输入小数秒;失焦校验范围,非法值还原,Esc取消编辑。参考只展示整秒的像素细节尚未作为黄金图验收。",
"evidence": "本轮UI"
},
{
"id": "SE-041",
"group": "字幕",
"item": "选中文本、编辑与位置",
"status": "通过",
"detail": "本轮本地样式编辑和实际字幕位置像素测试通过",
"evidence": "本轮UI/FFmpeg"
},
{
"id": "SE-042",
"group": "字幕",
"item": "显示和隐藏",
"status": "代码核对",
"detail": "subtitles控制预览和导出;关闭面板仅隐藏编辑轨道",
"evidence": "SmartEditEditor/compiledSubtitles"
},
{
"id": "SE-043",
"group": "字幕",
"item": "字体搜索和长列表",
"status": "通过",
"detail": "参考目录2002项完整收录,1997新增文件全部下载解析与双SHA校验;搜索、四分类、虚拟滚动、按需加载可用。",
"evidence": "remaining-0918/reference-fonts.json、prepare-catalog.log;主站字体切换"
},
{
"id": "SE-044",
"group": "字幕",
"item": "字体分类",
"status": "已修复",
"detail": "新增全部/常用/推荐/Google四类筛选;范围限已打包的实际字体。",
"evidence": "本轮UI/CaptionFonts"
},
{
"id": "SE-045",
"group": "字幕",
"item": "预览与导出的字形",
"status": "部分修复",
"detail": "2002字体目录已齐;预览与FFmpeg使用同一固定文件,5原有+6新增字体实际合成通过;未对2002种字体逐字形、行高、DPR作黄金图对拍。",
"evidence": "catalog-render.log/主站大宝桃桃体切换"
},
{
"id": "SE-046",
"group": "字幕",
"item": "字号范围和显示单位",
"status": "已修复",
"detail": "从参考可见iframe确认1920×1080、56px;新字幕明确1080基准,默认56/12–72px;旧720基准按比例显示并保留历史超范围值。",
"evidence": "本轮参考DOM/legacy兼容helpers/本地UI"
},
{
"id": "SE-047",
"group": "字幕",
"item": "字间距范围和控件",
"status": "已修复",
"detail": "字间距改X数值框0–100;旧工程负值保留,预览与ASS依各自基准缩放。",
"evidence": "StyleNumber/contract"
},
{
"id": "SE-048",
"group": "字幕",
"item": "字体颜色和十六进制",
"status": "代码核对",
"detail": "两处均有取色和hex;本地失焦校验非法值;样式烧录已回归",
"evidence": "StyleColor/FFmpeg"
},
{
"id": "SE-049",
"group": "字幕",
"item": "六种预设入口",
"status": "通过",
"detail": "默认、白黑、黑白、黄黑、白粉、粉黑均有",
"evidence": "本轮参考及本地UI"
},
{
"id": "SE-050",
"group": "字幕",
"item": "预设按钮外观",
"status": "已修复",
"detail": "默认预设显示“默认”,其他为T;选中描边按实际样式匹配。",
"evidence": "本轮字幕UI"
},
{
"id": "SE-051",
"group": "字幕",
"item": "描边颜色和粗细",
"status": "已修复",
"detail": "新字幕默认白色描边,粗细0–20;旧字幕不变。预览和ASS同步缩放。",
"evidence": "本轮UI/contract/FFmpeg"
},
{
"id": "SE-052",
"group": "字幕",
"item": "描边不透明度语义",
"status": "已修复",
"detail": "独立outlineOpacity;0时保留文字,只去描边;旧整行opacity保持兼容",
"evidence": "UI截图/ASS合同/实际像素测试"
},
{
"id": "SE-053",
"group": "字幕",
"item": "字号等数值单位装饰",
"status": "已修复",
"detail": "增加px/%单位和上下步进按钮;数值失焦校验,不提交编辑中的空值。",
"evidence": "StyleNumber/本地构建"
},
{
"id": "SE-054",
"group": "字幕",
"item": "应用全部",
"status": "代码核对",
"detail": "复制完整style含新outlineOpacity;本轮未覆盖所有字幕数量组合",
"evidence": "SmartEditEditor"
},
{
"id": "SE-055",
"group": "字幕",
"item": "自动字幕与长转场",
"status": "已修复",
"detail": "居中转场不再把后片段字幕向后延迟整个转场时长,避免片头字幕丢失",
"evidence": "新增合同回归"
},
{
"id": "SE-056",
"group": "字幕",
"item": "手工字幕与SRT",
"status": "通过",
"detail": "手工字幕保留位置和样式,SRT仅保留文本时间;ASS字符转义回归通过",
"evidence": "本轮helpers/FFmpeg"
},
{
"id": "SE-057",
"group": "字幕",
"item": "语音自动转写",
"status": "通过",
"detail": "真实FunASR转写成功;修复缺少pred_timestamp造成整段无标点时间戳的问题,限制负起点并映射剪辑速度/裁剪。字幕按钮提交主站任务,完成后刷新恢复。",
"evidence": "model-regression/report.json;production-chain.log;主站浏览器"
},
{
"id": "SE-058",
"group": "转场",
"item": "三种基础转场入口",
"status": "通过",
"detail": "淡入淡出、黑场过渡、白场过渡;这不是全站视频生成特效库",
"evidence": "本轮参考UI"
},
{
"id": "SE-059",
"group": "转场",
"item": "原始动态素材",
"status": "通过",
"detail": "直接使用参考原始WebP,每张75帧/3秒;本地动画可见",
"evidence": "public/transitions/本轮UI"
},
{
"id": "SE-060",
"group": "转场",
"item": "分类栏与卡片位置",
"status": "已修复",
"detail": "分类48px高、13px字体,卡片108×130,图框100×100,列距7px",
"evidence": "参考DOM/本地CSS"
},
{
"id": "SE-061",
"group": "转场",
"item": "选中边框和颜色",
"status": "已修复",
"detail": "2px改1px,避免图片缩小;边框/文字改#09caf5,圆角6px",
"evidence": "参考DOM/本地截图"
},
{
"id": "SE-062",
"group": "转场",
"item": "时长与名称行间距",
"status": "已修复",
"detail": "改16px行高,时长框与90px小预览纵向位置对齐",
"evidence": "参考DOM/本地CSS"
},
{
"id": "SE-063",
"group": "转场",
"item": "时长上下限与步长",
"status": "通过",
"detail": "0.2–4秒,0.1步进,非法值失焦归一化",
"evidence": "前批UI/本轮合同"
},
{
"id": "SE-064",
"group": "转场",
"item": "相邻素材小预览",
"status": "通过",
"detail": "本地使用相邻真实视频,静音循环并应用效果",
"evidence": "本轮UI"
},
{
"id": "SE-065",
"group": "转场",
"item": "主画面预览时机",
"status": "已修复",
"detail": "主画面保持播放头,右侧小预览独立循环;取消选择转场时自动替换整个主画面的分支。",
"evidence": "本轮浏览器主画面停0秒、小预览独立循环/既有参考观察"
},
{
"id": "SE-066",
"group": "转场",
"item": "居中时序和总片长",
"status": "通过",
"detail": "三种2.2/4秒真实成片均保留4秒总长",
"evidence": "本轮FFmpeg"
},
{
"id": "SE-067",
"group": "转场",
"item": "黑场与白场中点",
"status": "通过",
"detail": "真实像素验证纯黑/纯白中点",
"evidence": "本轮FFmpeg"
},
{
"id": "SE-068",
"group": "转场",
"item": "极短片段和连续接点",
"status": "通过",
"detail": "短片段约束和避免三片同时重叠的合同通过",
"evidence": "本轮helpers/前批压力合成"
},
{
"id": "SE-069",
"group": "转场",
"item": "删除与硬切",
"status": "通过",
"detail": "已有删除恢复硬切;本轮逐片段与硬切组合实际合成通过",
"evidence": "FFmpeg/源码"
},
{
"id": "SE-070",
"group": "转场",
"item": "时间轴覆盖框",
"status": "已修复",
"detail": "覆盖框top4、高60,最小点击宽44;补选中填充、两侧标记和16px参考图标。缩放滑块扩0–100,以参考19.6787=100px/s、50=251px/s、100=500px/s测量映射。",
"evidence": "本轮参考可见DOM/本地CSS"
},
{
"id": "SE-071",
"group": "转场",
"item": "旧方案兼容",
"status": "通过",
"detail": "未设置alignment的旧重叠方案保留原片长语义",
"evidence": "本轮helpers/FFmpeg"
},
{
"id": "SE-072",
"group": "音频",
"item": "有声视频分离",
"status": "通过",
"detail": "本轮真实点按生成独立音轨,撤销后消失",
"evidence": "本轮本地UI"
},
{
"id": "SE-073",
"group": "音频",
"item": "无音轨时分离禁用",
"status": "通过",
"detail": "参考当前无音轨素材禁用;本地按hasAudio禁用",
"evidence": "本轮参考UI/源码"
},
{
"id": "SE-074",
"group": "音频",
"item": "分离后声音不叠加",
"status": "通过",
"detail": "实际音频RMS与转场包络回归通过",
"evidence": "本轮FFmpeg"
},
{
"id": "SE-075",
"group": "音频",
"item": "独立音轨变速、淡入淡出",
"status": "通过",
"detail": "实际2倍速、增益、延迟、淡入淡出及长音轨黑尾回归通过",
"evidence": "本轮FFmpeg"
},
{
"id": "SE-076",
"group": "音频",
"item": "独立音轨裁剪、分割、移动",
"status": "代码核对",
"detail": "有完整source范围与时间轴处理;前批UI通过,本轮未全部重拖",
"evidence": "SmartEditEditor/contract"
},
{
"id": "SE-077",
"group": "音频",
"item": "人声分离两个分支",
"status": "通过",
"detail": "HTDemucs真实离线分离,视频和独立音轨可用;支持仅人声、仅背景及两轨输出,保留速度/增益/静音/包络。混合信号重构最大误差1.49e-8。",
"evidence": "smart-edit-models.py;主站API与UI"
},
{
"id": "SE-078",
"group": "音频",
"item": "音频参数栏调整宽度",
"status": "已修复",
"detail": "音频参数栏新增同一宽度拖拽柄;参考有声参数更多行为仍待验。",
"evidence": "SmartEditEditor"
},
{
"id": "SE-079",
"group": "音频",
"item": "真实波形",
"status": "待验",
"detail": "本地音频条是纯色色块,没有采样波形;本轮未取得参考有声轨波形证据",
"evidence": "se-wave/se-audio-clip"
},
{
"id": "SE-080",
"group": "音频",
"item": "音乐与卡点",
"status": "通过",
"detail": "节拍与候选剪辑合同回归通过,音乐混音真实输出通过;不是参考算法效果对拍",
"evidence": "本轮helpers/FFmpeg"
},
{
"id": "SE-081",
"group": "导出与恢复",
"item": "导出菜单位置",
"status": "通过",
"detail": "本地/画布两个目的地和参考相同",
"evidence": "参考export-menu/源码"
},
{
"id": "SE-082",
"group": "导出与恢复",
"item": "导出菜单视觉",
"status": "代码核对",
"detail": "本地有200px菜单、40px条目;边框/图标需独立像素精调",
"evidence": "参考截图/CSS"
},
{
"id": "SE-083",
"group": "导出与恢复",
"item": "真实本地合成",
"status": "通过",
"detail": "FFmpeg实际生成结果,不用模拟成功替代",
"evidence": "本轮ffmpeg.log"
},
{
"id": "SE-084",
"group": "导出与恢复",
"item": "提交任务ID和幂等",
"status": "通过",
"detail": "请求ID回归与远程确定性任务身份测试通过",
"evidence": "本轮helpers"
},
{
"id": "SE-085",
"group": "导出与恢复",
"item": "刷新恢复导出任务",
"status": "通过",
"detail": "任务检查点/reconcile合同通过;本轮未执行真实Next页面断网恢复",
"evidence": "本轮helpers"
},
{
"id": "SE-086",
"group": "导出与恢复",
"item": "浏览器中返回画布结果",
"status": "通过",
"detail": "主站Next生产构建:真实登录/隔离数据库/上传/排队/分离/转写/合成/MP4和SRT下载/浏览器回填播放及刷新恢复通过。存储用本地S3兼容服务,未测线上存储或部署。修复新画布时区错位漏回填与成片时长显示。",
"evidence": "production-chain.log;main-result.mp4;主站浏览器"
},
{
"id": "SE-087",
"group": "导出与恢复",
"item": "任务错误和服务不可用",
"status": "代码核对",
"detail": "导出按钮按能力禁用,失败状态显示错误;本轮未切断实际服务故障注入",
"evidence": "SmartEditEditor/SmartEditDialog"
},
{
"id": "SE-088",
"group": "导出与恢复",
"item": "取消远程导出",
"status": "部分修复",
"detail": "已接排队导出的取消按钮到现有鉴权取消API,取消状态不再等待。运行中远程FFmpeg的主动终止仍未实现,主站取消按钮全链路未验。",
"evidence": "onCancel/现有task-store"
},
{
"id": "SE-089",
"group": "导出与恢复",
"item": "多版本全导出",
"status": "代码核对",
"detail": "高级设置有导出全部版本;主节点默认只一版,参考完整多版结果未生成",
"evidence": "SmartEditDialog"
},
{
"id": "SE-090",
"group": "导出与恢复",
"item": "最高时长与比例",
"status": "通过",
"detail": "300秒约束、宽屏远程结果下载合同、6比例真实本地输出通过",
"evidence": "本轮helpers/FFmpeg"
},
{
"id": "SE-091",
"group": "边界与性能",
"item": "最大片段数",
"status": "代码核对",
"detail": "最多12视频片段、24独立音轨、500字幕;不是参考站已确认相同限制",
"evidence": "contract"
},
{
"id": "SE-092",
"group": "边界与性能",
"item": "后台标签恢复",
"status": "待验",
"detail": "循环预览监听visibilitychange;本轮未覆盖系统休眠/后台节流完整矩阵",
"evidence": "TransitionPreview"
},
{
"id": "SE-093",
"group": "边界与性能",
"item": "原生媒体加载失败",
"status": "代码核对",
"detail": "play的AbortError忽略,其余报错可见;本轮未测试全部外部域名/CORS",
"evidence": "EditPreview"
},
{
"id": "SE-094",
"group": "边界与性能",
"item": "高清与12片段性能",
"status": "既有证据",
"detail": "前批1080P4秒合成12.81秒、12片段7.604秒;本轮未重复此压力测试",
"evidence": "transition-stress-native.log"
},
{
"id": "SE-095",
"group": "边界与性能",
"item": "服务部署状态",
"status": "通过",
"detail": "本轮所有修改仅本地,未更新48或算力服务器",
"evidence": "git/操作记录"
}
],
"date": "2026-09-18",
"note": "09-18续修:人声分离、自动转写、2002字体目录和本地主站完整智能剪辑链路已接通;仍非全站所有功能验收,未部署或推送;逐字体像素/DPR与休眠断网矩阵未完成。"
}

View File

@ -0,0 +1,177 @@
# 智能剪辑修复进度(2026-09-18)
基线 `68f62dae`,承接09-17的95项清单;**仅本地修改和验证,未部署、未推送**。累计状态不是全项本轮复测,保留每行的证据边界。尚不能认定为全部功能齐全或像素级完全一致。
## 本轮结果
已处理字幕行操作、字体搜索/分类、五种实际字体与成片一致性、新字幕1080坐标基准、字间距/描边/单位、正音量增益和静音恢复、收起预览与Esc、真实节点时间轴、音乐卡点与广告版本设置、音频面板拖宽、转场覆盖框与主画面预览时机。
增加排队导出的取消入口。独立合成服务构建携带字体包;真实测试发现临时字体目录清理会误报合成失败,已修正为递归清理,非关键清理失败不删除已验证成片。
## 验证
- TypeScript:通过。
- 回归:496通过、1跳过;最后定向剪辑回归21通过。
- 真实FFmpeg:六种比例、三种转场、居中/旧重叠时序、字幕位置/透明度;正增益振幅、原声及独立音轨静音;五种字体实际字形输出。
- 真实分析:PySceneDetect 3镜头、RMS 2有效区段、librosa 22个节拍。
- 真实本地HTTP合成服务:鉴权、真实字体成片、进程强制中断后恢复、幂等、冲突拒绝、完成记录重启保留。
- 浏览器独立测试:字幕新增和字体切换、新字幕56px、+0.5dB静音后恢复、折叠播放到片尾、Esc退出、卡点缺音乐拦截、选择音乐、广告默认3版。
本地独立页面仍用样例任务回调,不能冒充主站Next API端到端成功;上面的真实HTTP测试针对独立合成服务。测试目录 `.qa/smart-edit/fixes-0918/` 的日志与截图不提交到Git,复现测试脚本提交。
## 09-18续修:本轮指定的四项
人声分离、自动转写、2002项字体目录、主站智能剪辑本地全链路已接通并使用真实模型/FFmpeg验证。详细环境、数据及边界见 [续修验收记录](smart-edit-completion-2026-09-18.md)。以上早先的“独立测试页”结论保留为历史;本轮新增真实Next生产构建验收。
## 仍未完成的其他范围
- 全部2002字体逐字形/行高/DPR黄金图对拍;字体目录和实际字体文件已齐全。
- 运行中远程合成主动取消;后台休眠/断网/各窄窗口完整矩阵。
- 五种模式与参考的算法效果逐案对比、广告多创意策略、真实波形及未穷尽边界。
- 线上部署与真实线上对象存储验收。本轮只用隔离本地数据库及S3兼容测试服务。
参考字号证据:预览iframe可见SPAN为56px,父画布1920×1080。缩放证据:slider实际状态19.67871485943775时2.2秒转场宽220px,50时552.2px,100时1100px;采用2+4.98×slider的映射。最小转场覆盖框44px单独约束。
## 累计95项
- 通过:39
- 待验:6
- 代码核对:17
- 已修复:28
- 部分修复:4
- 既有证据:1
### 节点与输入
| ID | 检查项 | 状态 | 当前结论 | 证据 |
| --- | --- | --- | --- | --- |
| SE-001 | 打开剪辑 | 通过 | 参考和本地均有入口;本地进入真实编辑组件 | 本轮UI |
| SE-002 | 五种模式名称和顺序 | 通过 | 默认、讲解、批量广告、口播、素材混剪相同 | 参考node-modes截图/SmartEditNode |
| SE-003 | 五种模式算法效果 | 待验 | 本地有模式分支;没有重新提交参考站付费生成,不能认定效果等同 | execute/contract |
| SE-004 | 六种比例 | 通过 | 21:9、16:9、9:16、1:1、4:3、3:4;实际合成覆盖 | 参考node-settings/本轮FFmpeg |
| SE-005 | 三种清晰度 | 通过 | 480P、720P、1080P;控件一致 | 参考node-settings/SmartEditNode |
| SE-006 | 时长范围 | 通过 | 0–300秒、步长1秒;本地0为自动 | 参考可见DOM/contract |
| SE-007 | 提示词编辑与展开 | 代码核对 | 本地受控输入、展开及提交;参考既有提示词未改动 | SmartEditNode |
| SE-008 | 引用画布素材 | 代码核对 | 参考进入引用选择;生产组件onReference连接连线状态,本地独立测试页只是候选列表 | 参考引用模式/主画布调用点 |
| SE-009 | 素材序号、时长、移除 | 代码核对 | 本地有缩略图、序号、时长及移除;未删除参考原素材 | SmartEditNode |
| SE-010 | 本地上传与数量限制 | 代码核对 | 真实上传入口,上限12个素材、单个125MiB;参考上限尚未验证 | SmartEditNode/SmartEditDialog |
| SE-011 | 节点缩略时间轴 | 已修复 | 节点刻度、视频、字幕、独立音轨从实际计划生成;重新生成后按任务版本选择新计划,保留所选版本。 | 本轮节点UI/SmartEditNodeCard |
| SE-012 | 批量广告多版本 | 部分修复 | 节点新增1–3版选择,未指定时广告默认3版;实际仍是候选顺序变体,未认定为参考站的多创意算法。 | 本轮UI/intent helpers |
| SE-013 | 提示词要求卡点 | 已修复 | 节点提示词的常见“卡点/beat sync”表达关联开关,显式关闭优先;支持画布音乐选择/上传及2或4拍切换,缺音乐阻止提交。并非通用自然语言意图解析。 | 本轮UI/intent helpers/真实librosa 22拍 |
### 布局与操作
| ID | 检查项 | 状态 | 当前结论 | 证据 |
| --- | --- | --- | --- | --- |
| SE-014 | 顶栏高度和边距 | 通过 | 48px顶栏、8px页面边距;本轮图像对照 | 参考DOM/本地CSS |
| SE-015 | 左右参数栏宽度 | 通过 | 转场库372px、参数栏300px,均可拖动调整 | 参考DOM/本地CSS |
| SE-016 | 面板背景与关闭按钮 | 已修复 | 背景统一#202020,边框白色10%,关闭按钮24px,标题500/14px | 参考transition.json/本地CSS |
| SE-017 | 默认时间轴高度 | 已修复 | 保留打开时的像素高度,窗口缩小时仅为可用空间收缩,不再每次重算35vh。 | resize代码 |
| SE-018 | 折叠预览 | 已修复 | 收起后背景透明、可操作下方画布,仅底部保留时间轴;预览组件保留,实测能播放到片尾。独立页面下层为测试按钮,主画布接入仍需整体验收。 | 本轮本地UI |
| SE-019 | 全屏预览 | 代码核对 | 本地调用Fullscreen API并显示拒绝错误;本轮未重测不同浏览器权限状态 | SmartEditEditor |
| SE-020 | 撤销重做 | 通过 | 本轮分离音轨后撤销成功;参考宽屏有按钮、窄屏隐藏,先前未见不代表没有 | 本轮UI/参考1512宽度 |
| SE-021 | 播放、暂停、逐帧 | 代码核对 | Space与左右键、30fps步进、片尾停止;前批浏览器已验证,本轮保留代码核对口径 | SmartEditEditor/EditPreview |
| SE-022 | 吸附默认值 | 通过 | 参考关闭,本地SmartEditEditor也是false;纠正旧文档“本地开启”误记 | 参考AX/SmartEditEditor:state |
| SE-023 | 退出与Esc | 已修复 | Esc退出整个编辑器,输入框编辑时不误退出;收起时保留关闭按钮。 | 本轮本地UI |
| SE-024 | 窄屏可用性 | 待验 | 参考1024宽度测得左右仍372/300;本地三栏与工具条更窄窗口尚无完整可用性验收 | reference-transition-1024 |
### 视频片段
| ID | 检查项 | 状态 | 当前结论 | 证据 |
| --- | --- | --- | --- | --- |
| SE-025 | 四角缩放、移动与重置 | 通过 | 本轮真实合成像素验证;本地有选框和重置,参考四角手柄可见 | 本轮FFmpeg/参考video |
| SE-026 | 变速上下调步长 | 已修复 | 参考1.00→1.01,本地0.05改为0.01;浏览器已复测 | 本轮UI |
| SE-027 | 变速支持范围 | 待验 | 本地0.5–2倍;参考未穷尽上下限,不能因步长一致声称范围一致 | contract/参考video |
| SE-028 | 时长输入 | 代码核对 | 按原素材剩余长度和变速约束,不能超过素材 | SmartEditEditor/contract |
| SE-029 | 数值越界后的显示 | 已修复 | 到上限2后再输入99,显示回到2.00,不残留非法数字 | 本轮UI |
| SE-030 | 增益步长 | 已修复 | 参考0.5dB,本地由1改0.5;浏览器0→-0.5确认 | 本轮UI |
| SE-031 | 正增益 | 已修复 | 本地支持最高+12dB,预览使用GainNode,导出实际音量放大;参考上限尚未确认。跨域正增益素材经登录下载接口取得同源副本,需另测真实主站链路。 | 本轮浏览器+0.5dB/FFmpeg约2倍振幅 |
| SE-032 | 边缘裁剪和排序 | 代码核对 | 已有按source时间/速度换算和撤销;本轮未重新拖遍所有方向 | SmartEditEditor |
| SE-033 | 分割和左右裁剪 | 通过 | 边界禁用、源范围及片长合同回归通过;本轮未对拍参考破坏性编辑 | 本轮helpers/SmartEditEditor |
| SE-034 | 至少保留一个片段 | 待验 | 本地阻止删最后一段;参考空时间轴行为未删除原素材验证 | SmartEditEditor |
| SE-035 | 右键菜单 | 部分修复 | 已增加人声分离子菜单及仅保留人声、仅保留背景音、两轨输出;菜单其他文案与参考仍有差异。 | 本轮主站UI/真实HTDemucs |
| SE-036 | 转场覆盖区右键 | 已修复 | 转场覆盖区域按指针时间选择底层片段并打开视频右键菜单。 | 本轮浏览器:覆盖框右键选中有声片段并成功分离 |
| SE-037 | 静音后恢复原增益 | 已修复 | 新增可选muted独立于gain保存,原声和独立音轨恢复不会覆盖原增益;分离时继承静音状态。 | 浏览器原声及独立轨+0.5dB静音恢复/真实FFmpeg两类静音 |
### 字幕
| ID | 检查项 | 状态 | 当前结论 | 证据 |
| --- | --- | --- | --- | --- |
| SE-038 | 空列表、新建、搜索 | 通过 | 参考空列表和新建已操作;本地新建成功、搜索有真实过滤 | 本轮UI/源码 |
| SE-039 | 列表逐条新增与删除 | 已修复 | 每行新增在后面插入/删除,插入按时间排序、遇下条字幕或片尾缩短,并定位新字幕;片尾不能插入时给提示。 | 本轮UI/代码 |
| SE-040 | 列表时间格式 | 已修复 | 时间框采用MM:SS.xx,可输入小数秒;失焦校验范围,非法值还原,Esc取消编辑。参考只展示整秒的像素细节尚未作为黄金图验收。 | 本轮UI |
| SE-041 | 选中文本、编辑与位置 | 通过 | 本轮本地样式编辑和实际字幕位置像素测试通过 | 本轮UI/FFmpeg |
| SE-042 | 显示和隐藏 | 代码核对 | subtitles控制预览和导出;关闭面板仅隐藏编辑轨道 | SmartEditEditor/compiledSubtitles |
| SE-043 | 字体搜索和长列表 | 通过 | 参考目录2002项完整收录,1997新增文件全部下载解析与双SHA校验;搜索、四分类、虚拟滚动、按需加载可用。 | remaining-0918/reference-fonts.json、prepare-catalog.log;主站字体切换 |
| SE-044 | 字体分类 | 已修复 | 新增全部/常用/推荐/Google四类筛选;范围限已打包的实际字体。 | 本轮UI/CaptionFonts |
| SE-045 | 预览与导出的字形 | 部分修复 | 2002字体目录已齐;预览与FFmpeg使用同一固定文件,5原有+6新增字体实际合成通过;未对2002种字体逐字形、行高、DPR作黄金图对拍。 | catalog-render.log/主站大宝桃桃体切换 |
| SE-046 | 字号范围和显示单位 | 已修复 | 从参考可见iframe确认1920×1080、56px;新字幕明确1080基准,默认56/12–72px;旧720基准按比例显示并保留历史超范围值。 | 本轮参考DOM/legacy兼容helpers/本地UI |
| SE-047 | 字间距范围和控件 | 已修复 | 字间距改X数值框0–100;旧工程负值保留,预览与ASS依各自基准缩放。 | StyleNumber/contract |
| SE-048 | 字体颜色和十六进制 | 代码核对 | 两处均有取色和hex;本地失焦校验非法值;样式烧录已回归 | StyleColor/FFmpeg |
| SE-049 | 六种预设入口 | 通过 | 默认、白黑、黑白、黄黑、白粉、粉黑均有 | 本轮参考及本地UI |
| SE-050 | 预设按钮外观 | 已修复 | 默认预设显示“默认”,其他为T;选中描边按实际样式匹配。 | 本轮字幕UI |
| SE-051 | 描边颜色和粗细 | 已修复 | 新字幕默认白色描边,粗细0–20;旧字幕不变。预览和ASS同步缩放。 | 本轮UI/contract/FFmpeg |
| SE-052 | 描边不透明度语义 | 已修复 | 独立outlineOpacity;0时保留文字,只去描边;旧整行opacity保持兼容 | UI截图/ASS合同/实际像素测试 |
| SE-053 | 字号等数值单位装饰 | 已修复 | 增加px/%单位和上下步进按钮;数值失焦校验,不提交编辑中的空值。 | StyleNumber/本地构建 |
| SE-054 | 应用全部 | 代码核对 | 复制完整style含新outlineOpacity;本轮未覆盖所有字幕数量组合 | SmartEditEditor |
| SE-055 | 自动字幕与长转场 | 已修复 | 居中转场不再把后片段字幕向后延迟整个转场时长,避免片头字幕丢失 | 新增合同回归 |
| SE-056 | 手工字幕与SRT | 通过 | 手工字幕保留位置和样式,SRT仅保留文本时间;ASS字符转义回归通过 | 本轮helpers/FFmpeg |
| SE-057 | 语音自动转写 | 通过 | 真实FunASR转写成功;修复缺少pred_timestamp造成整段无标点时间戳的问题,限制负起点并映射剪辑速度/裁剪。字幕按钮提交主站任务,完成后刷新恢复。 | model-regression/report.json;production-chain.log;主站浏览器 |
### 转场
| ID | 检查项 | 状态 | 当前结论 | 证据 |
| --- | --- | --- | --- | --- |
| SE-058 | 三种基础转场入口 | 通过 | 淡入淡出、黑场过渡、白场过渡;这不是全站视频生成特效库 | 本轮参考UI |
| SE-059 | 原始动态素材 | 通过 | 直接使用参考原始WebP,每张75帧/3秒;本地动画可见 | public/transitions/本轮UI |
| SE-060 | 分类栏与卡片位置 | 已修复 | 分类48px高、13px字体,卡片108×130,图框100×100,列距7px | 参考DOM/本地CSS |
| SE-061 | 选中边框和颜色 | 已修复 | 2px改1px,避免图片缩小;边框/文字改#09caf5,圆角6px | 参考DOM/本地截图 |
| SE-062 | 时长与名称行间距 | 已修复 | 改16px行高,时长框与90px小预览纵向位置对齐 | 参考DOM/本地CSS |
| SE-063 | 时长上下限与步长 | 通过 | 0.2–4秒,0.1步进,非法值失焦归一化 | 前批UI/本轮合同 |
| SE-064 | 相邻素材小预览 | 通过 | 本地使用相邻真实视频,静音循环并应用效果 | 本轮UI |
| SE-065 | 主画面预览时机 | 已修复 | 主画面保持播放头,右侧小预览独立循环;取消选择转场时自动替换整个主画面的分支。 | 本轮浏览器主画面停0秒、小预览独立循环/既有参考观察 |
| SE-066 | 居中时序和总片长 | 通过 | 三种2.2/4秒真实成片均保留4秒总长 | 本轮FFmpeg |
| SE-067 | 黑场与白场中点 | 通过 | 真实像素验证纯黑/纯白中点 | 本轮FFmpeg |
| SE-068 | 极短片段和连续接点 | 通过 | 短片段约束和避免三片同时重叠的合同通过 | 本轮helpers/前批压力合成 |
| SE-069 | 删除与硬切 | 通过 | 已有删除恢复硬切;本轮逐片段与硬切组合实际合成通过 | FFmpeg/源码 |
| SE-070 | 时间轴覆盖框 | 已修复 | 覆盖框top4、高60,最小点击宽44;补选中填充、两侧标记和16px参考图标。缩放滑块扩0–100,以参考19.6787=100px/s、50=251px/s、100=500px/s测量映射。 | 本轮参考可见DOM/本地CSS |
| SE-071 | 旧方案兼容 | 通过 | 未设置alignment的旧重叠方案保留原片长语义 | 本轮helpers/FFmpeg |
### 音频
| ID | 检查项 | 状态 | 当前结论 | 证据 |
| --- | --- | --- | --- | --- |
| SE-072 | 有声视频分离 | 通过 | 本轮真实点按生成独立音轨,撤销后消失 | 本轮本地UI |
| SE-073 | 无音轨时分离禁用 | 通过 | 参考当前无音轨素材禁用;本地按hasAudio禁用 | 本轮参考UI/源码 |
| SE-074 | 分离后声音不叠加 | 通过 | 实际音频RMS与转场包络回归通过 | 本轮FFmpeg |
| SE-075 | 独立音轨变速、淡入淡出 | 通过 | 实际2倍速、增益、延迟、淡入淡出及长音轨黑尾回归通过 | 本轮FFmpeg |
| SE-076 | 独立音轨裁剪、分割、移动 | 代码核对 | 有完整source范围与时间轴处理;前批UI通过,本轮未全部重拖 | SmartEditEditor/contract |
| SE-077 | 人声分离两个分支 | 通过 | HTDemucs真实离线分离,视频和独立音轨可用;支持仅人声、仅背景及两轨输出,保留速度/增益/静音/包络。混合信号重构最大误差1.49e-8。 | smart-edit-models.py;主站API与UI |
| SE-078 | 音频参数栏调整宽度 | 已修复 | 音频参数栏新增同一宽度拖拽柄;参考有声参数更多行为仍待验。 | SmartEditEditor |
| SE-079 | 真实波形 | 待验 | 本地音频条是纯色色块,没有采样波形;本轮未取得参考有声轨波形证据 | se-wave/se-audio-clip |
| SE-080 | 音乐与卡点 | 通过 | 节拍与候选剪辑合同回归通过,音乐混音真实输出通过;不是参考算法效果对拍 | 本轮helpers/FFmpeg |
### 导出与恢复
| ID | 检查项 | 状态 | 当前结论 | 证据 |
| --- | --- | --- | --- | --- |
| SE-081 | 导出菜单位置 | 通过 | 本地/画布两个目的地和参考相同 | 参考export-menu/源码 |
| SE-082 | 导出菜单视觉 | 代码核对 | 本地有200px菜单、40px条目;边框/图标需独立像素精调 | 参考截图/CSS |
| SE-083 | 真实本地合成 | 通过 | FFmpeg实际生成结果,不用模拟成功替代 | 本轮ffmpeg.log |
| SE-084 | 提交任务ID和幂等 | 通过 | 请求ID回归与远程确定性任务身份测试通过 | 本轮helpers |
| SE-085 | 刷新恢复导出任务 | 通过 | 任务检查点/reconcile合同通过;本轮未执行真实Next页面断网恢复 | 本轮helpers |
| SE-086 | 浏览器中返回画布结果 | 通过 | 主站Next生产构建:真实登录/隔离数据库/上传/排队/分离/转写/合成/MP4和SRT下载/浏览器回填播放及刷新恢复通过。存储用本地S3兼容服务,未测线上存储或部署。修复新画布时区错位漏回填与成片时长显示。 | production-chain.log;main-result.mp4;主站浏览器 |
| SE-087 | 任务错误和服务不可用 | 代码核对 | 导出按钮按能力禁用,失败状态显示错误;本轮未切断实际服务故障注入 | SmartEditEditor/SmartEditDialog |
| SE-088 | 取消远程导出 | 部分修复 | 已接排队导出的取消按钮到现有鉴权取消API,取消状态不再等待。运行中远程FFmpeg的主动终止仍未实现,主站取消按钮全链路未验。 | onCancel/现有task-store |
| SE-089 | 多版本全导出 | 代码核对 | 高级设置有导出全部版本;主节点默认只一版,参考完整多版结果未生成 | SmartEditDialog |
| SE-090 | 最高时长与比例 | 通过 | 300秒约束、宽屏远程结果下载合同、6比例真实本地输出通过 | 本轮helpers/FFmpeg |
### 边界与性能
| ID | 检查项 | 状态 | 当前结论 | 证据 |
| --- | --- | --- | --- | --- |
| SE-091 | 最大片段数 | 代码核对 | 最多12视频片段、24独立音轨、500字幕;不是参考站已确认相同限制 | contract |
| SE-092 | 后台标签恢复 | 待验 | 循环预览监听visibilitychange;本轮未覆盖系统休眠/后台节流完整矩阵 | TransitionPreview |
| SE-093 | 原生媒体加载失败 | 代码核对 | play的AbortError忽略,其余报错可见;本轮未测试全部外部域名/CORS | EditPreview |
| SE-094 | 高清与12片段性能 | 既有证据 | 前批1080P4秒合成12.81秒、12片段7.604秒;本轮未重复此压力测试 | transition-stress-native.log |
| SE-095 | 服务部署状态 | 通过 | 本轮所有修改仅本地,未更新48或算力服务器 | git/操作记录 |

View File

@ -0,0 +1,775 @@
{
"scope": "智能剪辑节点、编辑器及直接依赖的音频/导出链路;不是全站所有模块",
"baseline": "431ca03f",
"local_only": true,
"counts": {
"通过": 35,
"待验": 8,
"代码核对": 17,
"差异": 25,
"已修复": 9,
"既有证据": 1
},
"items": [
{
"id": "SE-001",
"group": "节点与输入",
"item": "打开剪辑",
"status": "通过",
"detail": "参考和本地均有入口;本地进入真实编辑组件",
"evidence": "本轮UI"
},
{
"id": "SE-002",
"group": "节点与输入",
"item": "五种模式名称和顺序",
"status": "通过",
"detail": "默认、讲解、批量广告、口播、素材混剪相同",
"evidence": "参考node-modes截图/SmartEditNode"
},
{
"id": "SE-003",
"group": "节点与输入",
"item": "五种模式算法效果",
"status": "待验",
"detail": "本地有模式分支;没有重新提交参考站付费生成,不能认定效果等同",
"evidence": "execute/contract"
},
{
"id": "SE-004",
"group": "节点与输入",
"item": "六种比例",
"status": "通过",
"detail": "21:9、16:9、9:16、1:1、4:3、3:4;实际合成覆盖",
"evidence": "参考node-settings/本轮FFmpeg"
},
{
"id": "SE-005",
"group": "节点与输入",
"item": "三种清晰度",
"status": "通过",
"detail": "480P、720P、1080P;控件一致",
"evidence": "参考node-settings/SmartEditNode"
},
{
"id": "SE-006",
"group": "节点与输入",
"item": "时长范围",
"status": "通过",
"detail": "0–300秒、步长1秒;本地0为自动",
"evidence": "参考可见DOM/contract"
},
{
"id": "SE-007",
"group": "节点与输入",
"item": "提示词编辑与展开",
"status": "代码核对",
"detail": "本地受控输入、展开及提交;参考既有提示词未改动",
"evidence": "SmartEditNode"
},
{
"id": "SE-008",
"group": "节点与输入",
"item": "引用画布素材",
"status": "代码核对",
"detail": "参考进入引用选择;生产组件onReference连接连线状态,本地独立测试页只是候选列表",
"evidence": "参考引用模式/主画布调用点"
},
{
"id": "SE-009",
"group": "节点与输入",
"item": "素材序号、时长、移除",
"status": "代码核对",
"detail": "本地有缩略图、序号、时长及移除;未删除参考原素材",
"evidence": "SmartEditNode"
},
{
"id": "SE-010",
"group": "节点与输入",
"item": "本地上传与数量限制",
"status": "代码核对",
"detail": "真实上传入口,上限12个素材、单个125MiB;参考上限尚未验证",
"evidence": "SmartEditNode/SmartEditDialog"
},
{
"id": "SE-011",
"group": "节点与输入",
"item": "节点缩略时间轴",
"status": "差异",
"detail": "本地节点卡片仍固定生成12个刻度、两行8个色块,未绑定实际剪辑轨道",
"evidence": "SmartEditNodeCard"
},
{
"id": "SE-012",
"group": "节点与输入",
"item": "批量广告多版本",
"status": "差异",
"detail": "本地高级设置支持1–3版,节点默认variants为1;候选顺序变体不等于多套广告创意",
"evidence": "contract/buildEditPlans"
},
{
"id": "SE-013",
"group": "节点与输入",
"item": "提示词要求卡点",
"status": "差异",
"detail": "真正卡点要求音乐和beatSync;仅在提示词写“卡点”不会自动打开节拍流程",
"evidence": "execute/主画布onSubmit"
},
{
"id": "SE-014",
"group": "布局与操作",
"item": "顶栏高度和边距",
"status": "通过",
"detail": "48px顶栏、8px页面边距;本轮图像对照",
"evidence": "参考DOM/本地CSS"
},
{
"id": "SE-015",
"group": "布局与操作",
"item": "左右参数栏宽度",
"status": "通过",
"detail": "转场库372px、参数栏300px,均可拖动调整",
"evidence": "参考DOM/本地CSS"
},
{
"id": "SE-016",
"group": "布局与操作",
"item": "面板背景与关闭按钮",
"status": "已修复",
"detail": "背景统一#202020,边框白色10%,关闭按钮24px,标题500/14px",
"evidence": "参考transition.json/本地CSS"
},
{
"id": "SE-017",
"group": "布局与操作",
"item": "默认时间轴高度",
"status": "差异",
"detail": "打开时均约35vh;参考改变视口保留原像素高度,本地未手动拖动时随窗口重算",
"evidence": "本轮宽屏切换/resize处理"
},
{
"id": "SE-018",
"group": "布局与操作",
"item": "折叠预览",
"status": "差异",
"detail": "参考保留底部时间轴并露出画布;本地把时间轴铺满编辑器且保留顶栏",
"evidence": "参考collapsed截图/SmartEditEditor"
},
{
"id": "SE-019",
"group": "布局与操作",
"item": "全屏预览",
"status": "代码核对",
"detail": "本地调用Fullscreen API并显示拒绝错误;本轮未重测不同浏览器权限状态",
"evidence": "SmartEditEditor"
},
{
"id": "SE-020",
"group": "布局与操作",
"item": "撤销重做",
"status": "通过",
"detail": "本轮分离音轨后撤销成功;参考宽屏有按钮、窄屏隐藏,先前未见不代表没有",
"evidence": "本轮UI/参考1512宽度"
},
{
"id": "SE-021",
"group": "布局与操作",
"item": "播放、暂停、逐帧",
"status": "代码核对",
"detail": "Space与左右键、30fps步进、片尾停止;前批浏览器已验证,本轮保留代码核对口径",
"evidence": "SmartEditEditor/EditPreview"
},
{
"id": "SE-022",
"group": "布局与操作",
"item": "吸附默认值",
"status": "通过",
"detail": "参考关闭,本地SmartEditEditor也是false;纠正旧文档“本地开启”误记",
"evidence": "参考AX/SmartEditEditor:state"
},
{
"id": "SE-023",
"group": "布局与操作",
"item": "退出与Esc",
"status": "差异",
"detail": "参考Esc退出整个编辑器;本地Esc只关闭菜单/参数和取消选中",
"evidence": "前批参考UI/本轮源码"
},
{
"id": "SE-024",
"group": "布局与操作",
"item": "窄屏可用性",
"status": "待验",
"detail": "参考1024宽度测得左右仍372/300;本地三栏与工具条更窄窗口尚无完整可用性验收",
"evidence": "reference-transition-1024"
},
{
"id": "SE-025",
"group": "视频片段",
"item": "四角缩放、移动与重置",
"status": "通过",
"detail": "本轮真实合成像素验证;本地有选框和重置,参考四角手柄可见",
"evidence": "本轮FFmpeg/参考video"
},
{
"id": "SE-026",
"group": "视频片段",
"item": "变速上下调步长",
"status": "已修复",
"detail": "参考1.00→1.01,本地0.05改为0.01;浏览器已复测",
"evidence": "本轮UI"
},
{
"id": "SE-027",
"group": "视频片段",
"item": "变速支持范围",
"status": "待验",
"detail": "本地0.5–2倍;参考未穷尽上下限,不能因步长一致声称范围一致",
"evidence": "contract/参考video"
},
{
"id": "SE-028",
"group": "视频片段",
"item": "时长输入",
"status": "代码核对",
"detail": "按原素材剩余长度和变速约束,不能超过素材",
"evidence": "SmartEditEditor/contract"
},
{
"id": "SE-029",
"group": "视频片段",
"item": "数值越界后的显示",
"status": "已修复",
"detail": "到上限2后再输入99,显示回到2.00,不残留非法数字",
"evidence": "本轮UI"
},
{
"id": "SE-030",
"group": "视频片段",
"item": "增益步长",
"status": "已修复",
"detail": "参考0.5dB,本地由1改0.5;浏览器0→-0.5确认",
"evidence": "本轮UI"
},
{
"id": "SE-031",
"group": "视频片段",
"item": "正增益",
"status": "差异",
"detail": "参考能到+0.5dB;本地最大0dB,预览及合同最大gain=1",
"evidence": "参考UI/contract/EditPreview"
},
{
"id": "SE-032",
"group": "视频片段",
"item": "边缘裁剪和排序",
"status": "代码核对",
"detail": "已有按source时间/速度换算和撤销;本轮未重新拖遍所有方向",
"evidence": "SmartEditEditor"
},
{
"id": "SE-033",
"group": "视频片段",
"item": "分割和左右裁剪",
"status": "通过",
"detail": "边界禁用、源范围及片长合同回归通过;本轮未对拍参考破坏性编辑",
"evidence": "本轮helpers/SmartEditEditor"
},
{
"id": "SE-034",
"group": "视频片段",
"item": "至少保留一个片段",
"status": "待验",
"detail": "本地阻止删最后一段;参考空时间轴行为未删除原素材验证",
"evidence": "SmartEditEditor"
},
{
"id": "SE-035",
"group": "视频片段",
"item": "右键菜单",
"status": "差异",
"detail": "本地缺人声分离子菜单,增加撤销/重做/设置;“删除片段”与参考“删除”不同",
"evidence": "本轮参考及本地UI"
},
{
"id": "SE-036",
"group": "视频片段",
"item": "转场覆盖区右键",
"status": "差异",
"detail": "本地右键落在覆盖区时显示时间轴菜单,不能直接选中底下视频;从未覆盖位置可正常分离",
"evidence": "本轮本地UI"
},
{
"id": "SE-037",
"group": "视频片段",
"item": "静音后恢复原增益",
"status": "差异",
"detail": "本地静音后恢复统一gain=1,会丢失原先降低的增益;独立音轨也同样处理",
"evidence": "SmartEditEditor静音处理"
},
{
"id": "SE-038",
"group": "字幕",
"item": "空列表、新建、搜索",
"status": "通过",
"detail": "参考空列表和新建已操作;本地新建成功、搜索有真实过滤",
"evidence": "本轮UI/源码"
},
{
"id": "SE-039",
"group": "字幕",
"item": "列表逐条新增与删除",
"status": "差异",
"detail": "参考行内有“在后面新增字幕/删除字幕”;本地只有顶端新建、右键删除",
"evidence": "reference-subtitle-list/源码"
},
{
"id": "SE-040",
"group": "字幕",
"item": "列表时间格式",
"status": "差异",
"detail": "参考00:00–00:02;本地数字秒输入,布局与交互不同",
"evidence": "参考截图/本轮本地UI"
},
{
"id": "SE-041",
"group": "字幕",
"item": "选中文本、编辑与位置",
"status": "通过",
"detail": "本轮本地样式编辑和实际字幕位置像素测试通过",
"evidence": "本轮UI/FFmpeg"
},
{
"id": "SE-042",
"group": "字幕",
"item": "显示和隐藏",
"status": "代码核对",
"detail": "subtitles控制预览和导出;关闭面板仅隐藏编辑轨道",
"evidence": "SmartEditEditor/compiledSubtitles"
},
{
"id": "SE-043",
"group": "字幕",
"item": "字体搜索和长列表",
"status": "差异",
"detail": "参考可搜索Source Han、Noto、Roboto、Inter等;本地单一select且onChange为空",
"evidence": "reference-font-menu/源码"
},
{
"id": "SE-044",
"group": "字幕",
"item": "字体分类",
"status": "差异",
"detail": "参考全部/常用/推荐/Google字体;本地无分类",
"evidence": "reference-font-categories"
},
{
"id": "SE-045",
"group": "字幕",
"item": "预览与导出的字形",
"status": "差异",
"detail": "本地浏览器依赖系统字体回退,导出固定Noto CJK;不能声明与参考字体逐字形一致",
"evidence": "EditPreview/subtitlesToAss"
},
{
"id": "SE-046",
"group": "字幕",
"item": "字号范围和显示单位",
"status": "差异",
"detail": "参考默认56、12–72;本地默认28、8–160且以720为基准,需统一单位再调整",
"evidence": "参考DOM/contract"
},
{
"id": "SE-047",
"group": "字幕",
"item": "字间距范围和控件",
"status": "差异",
"detail": "参考X数值框0–100;本地滑条与数值框-5–30",
"evidence": "参考DOM/本地UI"
},
{
"id": "SE-048",
"group": "字幕",
"item": "字体颜色和十六进制",
"status": "代码核对",
"detail": "两处均有取色和hex;本地失焦校验非法值;样式烧录已回归",
"evidence": "StyleColor/FFmpeg"
},
{
"id": "SE-049",
"group": "字幕",
"item": "六种预设入口",
"status": "通过",
"detail": "默认、白黑、黑白、黄黑、白粉、粉黑均有",
"evidence": "本轮参考及本地UI"
},
{
"id": "SE-050",
"group": "字幕",
"item": "预设按钮外观",
"status": "差异",
"detail": "本地六个按钮都显示T,参考默认项显示“默认”,间距和选中态仍不同",
"evidence": "参考subtitle-style/源码"
},
{
"id": "SE-051",
"group": "字幕",
"item": "描边颜色和粗细",
"status": "差异",
"detail": "参考默认白色、粗细0–20;本地默认黑色、0–10;颜色选择可用",
"evidence": "参考DOM/contract"
},
{
"id": "SE-052",
"group": "字幕",
"item": "描边不透明度语义",
"status": "已修复",
"detail": "独立outlineOpacity;0时保留文字,只去描边;旧整行opacity保持兼容",
"evidence": "UI截图/ASS合同/实际像素测试"
},
{
"id": "SE-053",
"group": "字幕",
"item": "字号等数值单位装饰",
"status": "差异",
"detail": "参考px/%以及上下箭头;本地多为原生数字框,尺寸及结构未完全一致",
"evidence": "参考subtitle-style/StyleNumber"
},
{
"id": "SE-054",
"group": "字幕",
"item": "应用全部",
"status": "代码核对",
"detail": "复制完整style含新outlineOpacity;本轮未覆盖所有字幕数量组合",
"evidence": "SmartEditEditor"
},
{
"id": "SE-055",
"group": "字幕",
"item": "自动字幕与长转场",
"status": "已修复",
"detail": "居中转场不再把后片段字幕向后延迟整个转场时长,避免片头字幕丢失",
"evidence": "新增合同回归"
},
{
"id": "SE-056",
"group": "字幕",
"item": "手工字幕与SRT",
"status": "通过",
"detail": "手工字幕保留位置和样式,SRT仅保留文本时间;ASS字符转义回归通过",
"evidence": "本轮helpers/FFmpeg"
},
{
"id": "SE-057",
"group": "字幕",
"item": "语音自动转写",
"status": "差异",
"detail": "已有能力探测,但当前本地测试服务asr=false;完整转写尚无可用验收结果",
"evidence": "本地capabilities/已有部署记录"
},
{
"id": "SE-058",
"group": "转场",
"item": "三种基础转场入口",
"status": "通过",
"detail": "淡入淡出、黑场过渡、白场过渡;这不是全站视频生成特效库",
"evidence": "本轮参考UI"
},
{
"id": "SE-059",
"group": "转场",
"item": "原始动态素材",
"status": "通过",
"detail": "直接使用参考原始WebP,每张75帧/3秒;本地动画可见",
"evidence": "public/transitions/本轮UI"
},
{
"id": "SE-060",
"group": "转场",
"item": "分类栏与卡片位置",
"status": "已修复",
"detail": "分类48px高、13px字体,卡片108×130,图框100×100,列距7px",
"evidence": "参考DOM/本地CSS"
},
{
"id": "SE-061",
"group": "转场",
"item": "选中边框和颜色",
"status": "已修复",
"detail": "2px改1px,避免图片缩小;边框/文字改#09caf5,圆角6px",
"evidence": "参考DOM/本地截图"
},
{
"id": "SE-062",
"group": "转场",
"item": "时长与名称行间距",
"status": "已修复",
"detail": "改16px行高,时长框与90px小预览纵向位置对齐",
"evidence": "参考DOM/本地CSS"
},
{
"id": "SE-063",
"group": "转场",
"item": "时长上下限与步长",
"status": "通过",
"detail": "0.2–4秒,0.1步进,非法值失焦归一化",
"evidence": "前批UI/本轮合同"
},
{
"id": "SE-064",
"group": "转场",
"item": "相邻素材小预览",
"status": "通过",
"detail": "本地使用相邻真实视频,静音循环并应用效果",
"evidence": "本轮UI"
},
{
"id": "SE-065",
"group": "转场",
"item": "主画面预览时机",
"status": "待验",
"detail": "本地选中转场会循环预览;参考本轮主画面多保持原播放头,小预览循环,触发规则需进一步对拍",
"evidence": "reference-transition/TransitionPreview"
},
{
"id": "SE-066",
"group": "转场",
"item": "居中时序和总片长",
"status": "通过",
"detail": "三种2.2/4秒真实成片均保留4秒总长",
"evidence": "本轮FFmpeg"
},
{
"id": "SE-067",
"group": "转场",
"item": "黑场与白场中点",
"status": "通过",
"detail": "真实像素验证纯黑/纯白中点",
"evidence": "本轮FFmpeg"
},
{
"id": "SE-068",
"group": "转场",
"item": "极短片段和连续接点",
"status": "通过",
"detail": "短片段约束和避免三片同时重叠的合同通过",
"evidence": "本轮helpers/前批压力合成"
},
{
"id": "SE-069",
"group": "转场",
"item": "删除与硬切",
"status": "通过",
"detail": "已有删除恢复硬切;本轮逐片段与硬切组合实际合成通过",
"evidence": "FFmpeg/源码"
},
{
"id": "SE-070",
"group": "转场",
"item": "时间轴覆盖框",
"status": "差异",
"detail": "参考top=4px、高60px;本地top=0、高64px,选中填充/图标仍需精调",
"evidence": "参考DOM/本地CSS"
},
{
"id": "SE-071",
"group": "转场",
"item": "旧方案兼容",
"status": "通过",
"detail": "未设置alignment的旧重叠方案保留原片长语义",
"evidence": "本轮helpers/FFmpeg"
},
{
"id": "SE-072",
"group": "音频",
"item": "有声视频分离",
"status": "通过",
"detail": "本轮真实点按生成独立音轨,撤销后消失",
"evidence": "本轮本地UI"
},
{
"id": "SE-073",
"group": "音频",
"item": "无音轨时分离禁用",
"status": "通过",
"detail": "参考当前无音轨素材禁用;本地按hasAudio禁用",
"evidence": "本轮参考UI/源码"
},
{
"id": "SE-074",
"group": "音频",
"item": "分离后声音不叠加",
"status": "通过",
"detail": "实际音频RMS与转场包络回归通过",
"evidence": "本轮FFmpeg"
},
{
"id": "SE-075",
"group": "音频",
"item": "独立音轨变速、淡入淡出",
"status": "通过",
"detail": "实际2倍速、增益、延迟、淡入淡出及长音轨黑尾回归通过",
"evidence": "本轮FFmpeg"
},
{
"id": "SE-076",
"group": "音频",
"item": "独立音轨裁剪、分割、移动",
"status": "代码核对",
"detail": "有完整source范围与时间轴处理;前批UI通过,本轮未全部重拖",
"evidence": "SmartEditEditor/contract"
},
{
"id": "SE-077",
"group": "音频",
"item": "人声分离两个分支",
"status": "差异",
"detail": "参考“仅保留人声/仅保留背景音”子菜单;本地未接专用分离服务",
"evidence": "reference-video-menu"
},
{
"id": "SE-078",
"group": "音频",
"item": "音频参数栏调整宽度",
"status": "差异",
"detail": "本地视频/字幕/转场栏有拖拽柄,音频栏缺同类柄;参考有声参数栏未验",
"evidence": "SmartEditEditor"
},
{
"id": "SE-079",
"group": "音频",
"item": "真实波形",
"status": "待验",
"detail": "本地音频条是纯色色块,没有采样波形;本轮未取得参考有声轨波形证据",
"evidence": "se-wave/se-audio-clip"
},
{
"id": "SE-080",
"group": "音频",
"item": "音乐与卡点",
"status": "通过",
"detail": "节拍与候选剪辑合同回归通过,音乐混音真实输出通过;不是参考算法效果对拍",
"evidence": "本轮helpers/FFmpeg"
},
{
"id": "SE-081",
"group": "导出与恢复",
"item": "导出菜单位置",
"status": "通过",
"detail": "本地/画布两个目的地和参考相同",
"evidence": "参考export-menu/源码"
},
{
"id": "SE-082",
"group": "导出与恢复",
"item": "导出菜单视觉",
"status": "代码核对",
"detail": "本地有200px菜单、40px条目;边框/图标需独立像素精调",
"evidence": "参考截图/CSS"
},
{
"id": "SE-083",
"group": "导出与恢复",
"item": "真实本地合成",
"status": "通过",
"detail": "FFmpeg实际生成结果,不用模拟成功替代",
"evidence": "本轮ffmpeg.log"
},
{
"id": "SE-084",
"group": "导出与恢复",
"item": "提交任务ID和幂等",
"status": "通过",
"detail": "请求ID回归与远程确定性任务身份测试通过",
"evidence": "本轮helpers"
},
{
"id": "SE-085",
"group": "导出与恢复",
"item": "刷新恢复导出任务",
"status": "通过",
"detail": "任务检查点/reconcile合同通过;本轮未执行真实Next页面断网恢复",
"evidence": "本轮helpers"
},
{
"id": "SE-086",
"group": "导出与恢复",
"item": "浏览器中返回画布结果",
"status": "待验",
"detail": "独立测试页任务回调仍是样例;本轮未跑主站Next API全链路",
"evidence": "ui-entry/onRender"
},
{
"id": "SE-087",
"group": "导出与恢复",
"item": "任务错误和服务不可用",
"status": "代码核对",
"detail": "导出按钮按能力禁用,失败状态显示错误;本轮未切断实际服务故障注入",
"evidence": "SmartEditEditor/SmartEditDialog"
},
{
"id": "SE-088",
"group": "导出与恢复",
"item": "取消远程导出",
"status": "差异",
"detail": "没有主动取消运行中合成任务的完整UI/API链路;本轮参考菜单也未验证取消",
"evidence": "源码"
},
{
"id": "SE-089",
"group": "导出与恢复",
"item": "多版本全导出",
"status": "代码核对",
"detail": "高级设置有导出全部版本;主节点默认只一版,参考完整多版结果未生成",
"evidence": "SmartEditDialog"
},
{
"id": "SE-090",
"group": "导出与恢复",
"item": "最高时长与比例",
"status": "通过",
"detail": "300秒约束、宽屏远程结果下载合同、6比例真实本地输出通过",
"evidence": "本轮helpers/FFmpeg"
},
{
"id": "SE-091",
"group": "边界与性能",
"item": "最大片段数",
"status": "代码核对",
"detail": "最多12视频片段、24独立音轨、500字幕;不是参考站已确认相同限制",
"evidence": "contract"
},
{
"id": "SE-092",
"group": "边界与性能",
"item": "后台标签恢复",
"status": "待验",
"detail": "循环预览监听visibilitychange;本轮未覆盖系统休眠/后台节流完整矩阵",
"evidence": "TransitionPreview"
},
{
"id": "SE-093",
"group": "边界与性能",
"item": "原生媒体加载失败",
"status": "代码核对",
"detail": "play的AbortError忽略,其余报错可见;本轮未测试全部外部域名/CORS",
"evidence": "EditPreview"
},
{
"id": "SE-094",
"group": "边界与性能",
"item": "高清与12片段性能",
"status": "既有证据",
"detail": "前批1080P4秒合成12.81秒、12片段7.604秒;本轮未重复此压力测试",
"evidence": "transition-stress-native.log"
},
{
"id": "SE-095",
"group": "边界与性能",
"item": "服务部署状态",
"status": "通过",
"detail": "本轮所有修改仅本地,未更新48或算力服务器",
"evidence": "git/操作记录"
}
]
}

View File

@ -0,0 +1,208 @@
# 智能剪辑全面复核与像素差异(2026-09-17)
> 后续修复已更新至 [2026-09-18记录](smart-edit-fixes-2026-09-18.md)。本文保留09-17历史审计状态。
本轮在基线`431ca03f`之上复核 **95个检查点**,覆盖智能剪辑节点输入、编辑器布局、视频、字幕、转场、音频、导出恢复和边界。结论:**仍有遗漏,不能验收为全部功能齐全或100%像素一致**。这轮继续只在本地修复和验证,没有更新服务器。
该范围承接本次“转场预览图”对话,不扩大为整个视频画布、3D导演台或参考站全站重新验收;它们的旧清单不能被本轮结果覆盖。各检查点区别本轮UI、真实合成、代码核对、既有证据和待验,避免把功能存在误写为全链路成功。
## 结果分布
| 状态 | 数量 |
| --- | ---: |
| 通过 | 35 |
| 待验 | 8 |
| 代码核对 | 17 |
| 差异 | 25 |
| 已修复 | 9 |
| 既有证据 | 1 |
“通过”仅对该行明确的测试边界成立,不能换算为全站完成率。“差异”包含缺失能力、交互不同和视觉偏差,不代表同等严重程度。
## 本轮修复
- 自动字幕时序:居中转场不能按旧重叠转场推迟后片段字幕,否则片头字幕会被丢弃。
- 独立描边透明度:新增可选`outlineOpacity`,预览和ASS输出保持一致;旧`opacity`仍保持历史整行透明度语义,避免改坏已保存工程。
- 变速按钮步长改0.01、音量步长改0.5dB;输入超上限后立即显示约束后的数值,包括已经到上限再输入的情况。
- 转场分类栏、卡片内边距、圆角、选中蓝色、边框粗细、名称/时长行高及参数面板背景、关闭按钮尺寸按参考可见DOM修正。
## 像素测量
在参考站1331×681、1512×752和1024×768 CSS视口测量可见DOM。独立本地页也设置过1331×681设备视口检查三栏;最终宽屏截图来自本机原生浏览器。原生截图包含浏览器外壳且经过显示缩放,汇总图裁去顶部106px并将参考图归一到1331×662显示尺寸。为排除面板状态差异,宽屏对照临时把参考时间轴增高25px后截图,随后恢复。
这些图用于布局叠位和控件对照,不计算混入不同视频、不同动画帧、系统字体抗锯齿的整屏“相似度百分比”。严格逐像素验收仍需要固定同一视频帧、字体文件、DPR和全部交互状态;本轮没有宣称完成这种全矩阵黄金截图验收。
| 控件 | 参考测量 | 本轮本地修正后 | 结论 |
| --- | --- | --- | --- |
| 顶栏 | 48px | 48px | 一致 |
| 页面边距/栏间距 | 8px/8px | 8px/8px | 一致 |
| 转场库/参数栏 | 372px/300px | 372px/300px | 一致 |
| 参数面板 | #202020、白10%边框、12px圆角 | 相同CSS值 | 修正 |
| 面板关闭按钮 | 24×24px | 24×24px | 修正 |
| 分类栏 | 48px高、13px字 | 相同 | 修正 |
| 卡片 | 默认栏宽下108×130px,列距7px | 相同盒模型 | 修正 |
| 动态图框 | 100×100px,1px边框 | 选中也保留1px,内图不缩小 | 修正 |
| 选中边框/文字 | #09caf5 | #09caf5 | 修正 |
| 卡片圆角/文字内边距 | 6px / 左右4px | 相同 | 修正 |
| 名称/时长文字 | 12px/16px行高 | 相同 | 修正 |
| 右侧实际预览 | 90px高 | 90px | 一致 |
| 转场轨道覆盖框 | top4、高60px | top0、高64px | 仍有差异 |
| 时间轴缩放数值范围 | 0–100、默认20 | 0–40、默认20 | 范围不同;尚未对齐最大倍率与刻度密度 |
| 字幕数值控件 | px/%装饰、自绘步进、不同字号范围 | 原生数值输入、旧720基准 | 仍有差异 |
## 尚未完成的优先事项
1. 字体库、搜索/分类、真实字体文件与预览/导出映射;字号、字间距、描边粗细的单位及范围。
2. 正音量增益、静音恢复原增益、人声/背景音分离。
3. 字幕列表逐条新增/删除、时间格式与样式控件;折叠预览露出画布。
4. 节点缩略时间轴绑定实际计划;节点卡点需求到音乐/节拍设置的联动;批量广告版本设置。
5. 转场覆盖框和时间轴缩放细节,长转场覆盖区域的右键交互,以及主画面转场演示触发规则对拍。
6. 主站端到端导出、断网/后台恢复、有声参考轨更多功能、窄窗口完整验收;目前不能用本地样例任务回调代替这些结果。
## 逐项记录
### 节点与输入
| ID | 检查项 | 状态 | 结论/范围 | 证据 |
| --- | --- | --- | --- | --- |
| SE-001 | 打开剪辑 | 通过 | 参考和本地均有入口;本地进入真实编辑组件 | 本轮UI |
| SE-002 | 五种模式名称和顺序 | 通过 | 默认、讲解、批量广告、口播、素材混剪相同 | 参考node-modes截图/SmartEditNode |
| SE-003 | 五种模式算法效果 | 待验 | 本地有模式分支;没有重新提交参考站付费生成,不能认定效果等同 | execute/contract |
| SE-004 | 六种比例 | 通过 | 21:9、16:9、9:16、1:1、4:3、3:4;实际合成覆盖 | 参考node-settings/本轮FFmpeg |
| SE-005 | 三种清晰度 | 通过 | 480P、720P、1080P;控件一致 | 参考node-settings/SmartEditNode |
| SE-006 | 时长范围 | 通过 | 0–300秒、步长1秒;本地0为自动 | 参考可见DOM/contract |
| SE-007 | 提示词编辑与展开 | 代码核对 | 本地受控输入、展开及提交;参考既有提示词未改动 | SmartEditNode |
| SE-008 | 引用画布素材 | 代码核对 | 参考进入引用选择;生产组件onReference连接连线状态,本地独立测试页只是候选列表 | 参考引用模式/主画布调用点 |
| SE-009 | 素材序号、时长、移除 | 代码核对 | 本地有缩略图、序号、时长及移除;未删除参考原素材 | SmartEditNode |
| SE-010 | 本地上传与数量限制 | 代码核对 | 真实上传入口,上限12个素材、单个125MiB;参考上限尚未验证 | SmartEditNode/SmartEditDialog |
| SE-011 | 节点缩略时间轴 | 差异 | 本地节点卡片仍固定生成12个刻度、两行8个色块,未绑定实际剪辑轨道 | SmartEditNodeCard |
| SE-012 | 批量广告多版本 | 差异 | 本地高级设置支持1–3版,节点默认variants为1;候选顺序变体不等于多套广告创意 | contract/buildEditPlans |
| SE-013 | 提示词要求卡点 | 差异 | 真正卡点要求音乐和beatSync;仅在提示词写“卡点”不会自动打开节拍流程 | execute/主画布onSubmit |
### 布局与操作
| ID | 检查项 | 状态 | 结论/范围 | 证据 |
| --- | --- | --- | --- | --- |
| SE-014 | 顶栏高度和边距 | 通过 | 48px顶栏、8px页面边距;本轮图像对照 | 参考DOM/本地CSS |
| SE-015 | 左右参数栏宽度 | 通过 | 转场库372px、参数栏300px,均可拖动调整 | 参考DOM/本地CSS |
| SE-016 | 面板背景与关闭按钮 | 已修复 | 背景统一#202020,边框白色10%,关闭按钮24px,标题500/14px | 参考transition.json/本地CSS |
| SE-017 | 默认时间轴高度 | 差异 | 打开时均约35vh;参考改变视口保留原像素高度,本地未手动拖动时随窗口重算 | 本轮宽屏切换/resize处理 |
| SE-018 | 折叠预览 | 差异 | 参考保留底部时间轴并露出画布;本地把时间轴铺满编辑器且保留顶栏 | 参考collapsed截图/SmartEditEditor |
| SE-019 | 全屏预览 | 代码核对 | 本地调用Fullscreen API并显示拒绝错误;本轮未重测不同浏览器权限状态 | SmartEditEditor |
| SE-020 | 撤销重做 | 通过 | 本轮分离音轨后撤销成功;参考宽屏有按钮、窄屏隐藏,先前未见不代表没有 | 本轮UI/参考1512宽度 |
| SE-021 | 播放、暂停、逐帧 | 代码核对 | Space与左右键、30fps步进、片尾停止;前批浏览器已验证,本轮保留代码核对口径 | SmartEditEditor/EditPreview |
| SE-022 | 吸附默认值 | 通过 | 参考关闭,本地SmartEditEditor也是false;纠正旧文档“本地开启”误记 | 参考AX/SmartEditEditor:state |
| SE-023 | 退出与Esc | 差异 | 参考Esc退出整个编辑器;本地Esc只关闭菜单/参数和取消选中 | 前批参考UI/本轮源码 |
| SE-024 | 窄屏可用性 | 待验 | 参考1024宽度测得左右仍372/300;本地三栏与工具条更窄窗口尚无完整可用性验收 | reference-transition-1024 |
### 视频片段
| ID | 检查项 | 状态 | 结论/范围 | 证据 |
| --- | --- | --- | --- | --- |
| SE-025 | 四角缩放、移动与重置 | 通过 | 本轮真实合成像素验证;本地有选框和重置,参考四角手柄可见 | 本轮FFmpeg/参考video |
| SE-026 | 变速上下调步长 | 已修复 | 参考1.00→1.01,本地0.05改为0.01;浏览器已复测 | 本轮UI |
| SE-027 | 变速支持范围 | 待验 | 本地0.5–2倍;参考未穷尽上下限,不能因步长一致声称范围一致 | contract/参考video |
| SE-028 | 时长输入 | 代码核对 | 按原素材剩余长度和变速约束,不能超过素材 | SmartEditEditor/contract |
| SE-029 | 数值越界后的显示 | 已修复 | 到上限2后再输入99,显示回到2.00,不残留非法数字 | 本轮UI |
| SE-030 | 增益步长 | 已修复 | 参考0.5dB,本地由1改0.5;浏览器0→-0.5确认 | 本轮UI |
| SE-031 | 正增益 | 差异 | 参考能到+0.5dB;本地最大0dB,预览及合同最大gain=1 | 参考UI/contract/EditPreview |
| SE-032 | 边缘裁剪和排序 | 代码核对 | 已有按source时间/速度换算和撤销;本轮未重新拖遍所有方向 | SmartEditEditor |
| SE-033 | 分割和左右裁剪 | 通过 | 边界禁用、源范围及片长合同回归通过;本轮未对拍参考破坏性编辑 | 本轮helpers/SmartEditEditor |
| SE-034 | 至少保留一个片段 | 待验 | 本地阻止删最后一段;参考空时间轴行为未删除原素材验证 | SmartEditEditor |
| SE-035 | 右键菜单 | 差异 | 本地缺人声分离子菜单,增加撤销/重做/设置;“删除片段”与参考“删除”不同 | 本轮参考及本地UI |
| SE-036 | 转场覆盖区右键 | 差异 | 本地右键落在覆盖区时显示时间轴菜单,不能直接选中底下视频;从未覆盖位置可正常分离 | 本轮本地UI |
| SE-037 | 静音后恢复原增益 | 差异 | 本地静音后恢复统一gain=1,会丢失原先降低的增益;独立音轨也同样处理 | SmartEditEditor静音处理 |
### 字幕
| ID | 检查项 | 状态 | 结论/范围 | 证据 |
| --- | --- | --- | --- | --- |
| SE-038 | 空列表、新建、搜索 | 通过 | 参考空列表和新建已操作;本地新建成功、搜索有真实过滤 | 本轮UI/源码 |
| SE-039 | 列表逐条新增与删除 | 差异 | 参考行内有“在后面新增字幕/删除字幕”;本地只有顶端新建、右键删除 | reference-subtitle-list/源码 |
| SE-040 | 列表时间格式 | 差异 | 参考00:00–00:02;本地数字秒输入,布局与交互不同 | 参考截图/本轮本地UI |
| SE-041 | 选中文本、编辑与位置 | 通过 | 本轮本地样式编辑和实际字幕位置像素测试通过 | 本轮UI/FFmpeg |
| SE-042 | 显示和隐藏 | 代码核对 | subtitles控制预览和导出;关闭面板仅隐藏编辑轨道 | SmartEditEditor/compiledSubtitles |
| SE-043 | 字体搜索和长列表 | 差异 | 参考可搜索Source Han、Noto、Roboto、Inter等;本地单一select且onChange为空 | reference-font-menu/源码 |
| SE-044 | 字体分类 | 差异 | 参考全部/常用/推荐/Google字体;本地无分类 | reference-font-categories |
| SE-045 | 预览与导出的字形 | 差异 | 本地浏览器依赖系统字体回退,导出固定Noto CJK;不能声明与参考字体逐字形一致 | EditPreview/subtitlesToAss |
| SE-046 | 字号范围和显示单位 | 差异 | 参考默认56、12–72;本地默认28、8–160且以720为基准,需统一单位再调整 | 参考DOM/contract |
| SE-047 | 字间距范围和控件 | 差异 | 参考X数值框0–100;本地滑条与数值框-5–30 | 参考DOM/本地UI |
| SE-048 | 字体颜色和十六进制 | 代码核对 | 两处均有取色和hex;本地失焦校验非法值;样式烧录已回归 | StyleColor/FFmpeg |
| SE-049 | 六种预设入口 | 通过 | 默认、白黑、黑白、黄黑、白粉、粉黑均有 | 本轮参考及本地UI |
| SE-050 | 预设按钮外观 | 差异 | 本地六个按钮都显示T,参考默认项显示“默认”,间距和选中态仍不同 | 参考subtitle-style/源码 |
| SE-051 | 描边颜色和粗细 | 差异 | 参考默认白色、粗细0–20;本地默认黑色、0–10;颜色选择可用 | 参考DOM/contract |
| SE-052 | 描边不透明度语义 | 已修复 | 独立outlineOpacity;0时保留文字,只去描边;旧整行opacity保持兼容 | UI截图/ASS合同/实际像素测试 |
| SE-053 | 字号等数值单位装饰 | 差异 | 参考px/%以及上下箭头;本地多为原生数字框,尺寸及结构未完全一致 | 参考subtitle-style/StyleNumber |
| SE-054 | 应用全部 | 代码核对 | 复制完整style含新outlineOpacity;本轮未覆盖所有字幕数量组合 | SmartEditEditor |
| SE-055 | 自动字幕与长转场 | 已修复 | 居中转场不再把后片段字幕向后延迟整个转场时长,避免片头字幕丢失 | 新增合同回归 |
| SE-056 | 手工字幕与SRT | 通过 | 手工字幕保留位置和样式,SRT仅保留文本时间;ASS字符转义回归通过 | 本轮helpers/FFmpeg |
| SE-057 | 语音自动转写 | 差异 | 已有能力探测,但当前本地测试服务asr=false;完整转写尚无可用验收结果 | 本地capabilities/已有部署记录 |
### 转场
| ID | 检查项 | 状态 | 结论/范围 | 证据 |
| --- | --- | --- | --- | --- |
| SE-058 | 三种基础转场入口 | 通过 | 淡入淡出、黑场过渡、白场过渡;这不是全站视频生成特效库 | 本轮参考UI |
| SE-059 | 原始动态素材 | 通过 | 直接使用参考原始WebP,每张75帧/3秒;本地动画可见 | public/transitions/本轮UI |
| SE-060 | 分类栏与卡片位置 | 已修复 | 分类48px高、13px字体,卡片108×130,图框100×100,列距7px | 参考DOM/本地CSS |
| SE-061 | 选中边框和颜色 | 已修复 | 2px改1px,避免图片缩小;边框/文字改#09caf5,圆角6px | 参考DOM/本地截图 |
| SE-062 | 时长与名称行间距 | 已修复 | 改16px行高,时长框与90px小预览纵向位置对齐 | 参考DOM/本地CSS |
| SE-063 | 时长上下限与步长 | 通过 | 0.2–4秒,0.1步进,非法值失焦归一化 | 前批UI/本轮合同 |
| SE-064 | 相邻素材小预览 | 通过 | 本地使用相邻真实视频,静音循环并应用效果 | 本轮UI |
| SE-065 | 主画面预览时机 | 待验 | 本地选中转场会循环预览;参考本轮主画面多保持原播放头,小预览循环,触发规则需进一步对拍 | reference-transition/TransitionPreview |
| SE-066 | 居中时序和总片长 | 通过 | 三种2.2/4秒真实成片均保留4秒总长 | 本轮FFmpeg |
| SE-067 | 黑场与白场中点 | 通过 | 真实像素验证纯黑/纯白中点 | 本轮FFmpeg |
| SE-068 | 极短片段和连续接点 | 通过 | 短片段约束和避免三片同时重叠的合同通过 | 本轮helpers/前批压力合成 |
| SE-069 | 删除与硬切 | 通过 | 已有删除恢复硬切;本轮逐片段与硬切组合实际合成通过 | FFmpeg/源码 |
| SE-070 | 时间轴覆盖框 | 差异 | 参考top=4px、高60px;本地top=0、高64px,选中填充/图标仍需精调 | 参考DOM/本地CSS |
| SE-071 | 旧方案兼容 | 通过 | 未设置alignment的旧重叠方案保留原片长语义 | 本轮helpers/FFmpeg |
### 音频
| ID | 检查项 | 状态 | 结论/范围 | 证据 |
| --- | --- | --- | --- | --- |
| SE-072 | 有声视频分离 | 通过 | 本轮真实点按生成独立音轨,撤销后消失 | 本轮本地UI |
| SE-073 | 无音轨时分离禁用 | 通过 | 参考当前无音轨素材禁用;本地按hasAudio禁用 | 本轮参考UI/源码 |
| SE-074 | 分离后声音不叠加 | 通过 | 实际音频RMS与转场包络回归通过 | 本轮FFmpeg |
| SE-075 | 独立音轨变速、淡入淡出 | 通过 | 实际2倍速、增益、延迟、淡入淡出及长音轨黑尾回归通过 | 本轮FFmpeg |
| SE-076 | 独立音轨裁剪、分割、移动 | 代码核对 | 有完整source范围与时间轴处理;前批UI通过,本轮未全部重拖 | SmartEditEditor/contract |
| SE-077 | 人声分离两个分支 | 差异 | 参考“仅保留人声/仅保留背景音”子菜单;本地未接专用分离服务 | reference-video-menu |
| SE-078 | 音频参数栏调整宽度 | 差异 | 本地视频/字幕/转场栏有拖拽柄,音频栏缺同类柄;参考有声参数栏未验 | SmartEditEditor |
| SE-079 | 真实波形 | 待验 | 本地音频条是纯色色块,没有采样波形;本轮未取得参考有声轨波形证据 | se-wave/se-audio-clip |
| SE-080 | 音乐与卡点 | 通过 | 节拍与候选剪辑合同回归通过,音乐混音真实输出通过;不是参考算法效果对拍 | 本轮helpers/FFmpeg |
### 导出与恢复
| ID | 检查项 | 状态 | 结论/范围 | 证据 |
| --- | --- | --- | --- | --- |
| SE-081 | 导出菜单位置 | 通过 | 本地/画布两个目的地和参考相同 | 参考export-menu/源码 |
| SE-082 | 导出菜单视觉 | 代码核对 | 本地有200px菜单、40px条目;边框/图标需独立像素精调 | 参考截图/CSS |
| SE-083 | 真实本地合成 | 通过 | FFmpeg实际生成结果,不用模拟成功替代 | 本轮ffmpeg.log |
| SE-084 | 提交任务ID和幂等 | 通过 | 请求ID回归与远程确定性任务身份测试通过 | 本轮helpers |
| SE-085 | 刷新恢复导出任务 | 通过 | 任务检查点/reconcile合同通过;本轮未执行真实Next页面断网恢复 | 本轮helpers |
| SE-086 | 浏览器中返回画布结果 | 待验 | 独立测试页任务回调仍是样例;本轮未跑主站Next API全链路 | ui-entry/onRender |
| SE-087 | 任务错误和服务不可用 | 代码核对 | 导出按钮按能力禁用,失败状态显示错误;本轮未切断实际服务故障注入 | SmartEditEditor/SmartEditDialog |
| SE-088 | 取消远程导出 | 差异 | 没有主动取消运行中合成任务的完整UI/API链路;本轮参考菜单也未验证取消 | 源码 |
| SE-089 | 多版本全导出 | 代码核对 | 高级设置有导出全部版本;主节点默认只一版,参考完整多版结果未生成 | SmartEditDialog |
| SE-090 | 最高时长与比例 | 通过 | 300秒约束、宽屏远程结果下载合同、6比例真实本地输出通过 | 本轮helpers/FFmpeg |
### 边界与性能
| ID | 检查项 | 状态 | 结论/范围 | 证据 |
| --- | --- | --- | --- | --- |
| SE-091 | 最大片段数 | 代码核对 | 最多12视频片段、24独立音轨、500字幕;不是参考站已确认相同限制 | contract |
| SE-092 | 后台标签恢复 | 待验 | 循环预览监听visibilitychange;本轮未覆盖系统休眠/后台节流完整矩阵 | TransitionPreview |
| SE-093 | 原生媒体加载失败 | 代码核对 | play的AbortError忽略,其余报错可见;本轮未测试全部外部域名/CORS | EditPreview |
| SE-094 | 高清与12片段性能 | 既有证据 | 前批1080P4秒合成12.81秒、12片段7.604秒;本轮未重复此压力测试 | transition-stress-native.log |
| SE-095 | 服务部署状态 | 通过 | 本轮所有修改仅本地,未更新48或算力服务器 | git/操作记录 |
## 验证与原始证据
- TypeScript通过;智能剪辑18项回归通过;完整helpers493项通过、1项跳过。
- 本轮真实FFmpeg回归通过:旧重叠转场、三种居中2.2/4秒转场、六比例、字幕烧录、画面变换、音轨变速/延迟/包络、描边透明度。
- 新的描边像素检查:白字像素仍存在,原黑描边像素消失,避免只测表单值。
- 本轮未调用参考站付费生成或重新部署服务;参考原有2.2秒白场、1倍速、0dB保持;临时字幕已从列表删除,引用模式已退出。
- 原始截图、DOM测量、测试日志位于`.qa/smart-edit/recheck/`;`transition-side-by-side.png`为并排对照,`local-outline-opacity-zero.jpg`为描边语义验证。
- 参考站一度在字幕列表为空后仍显示旧字幕预览,重开编辑器后消失;这不是本地持久化成功或失败的证据。
- 机器可读逐项清单:[JSON](smart-edit-full-recheck-2026-09-17.json)。旧报告如有冲突,以本轮明确复核的条目为准。

View File

@ -0,0 +1,59 @@
# 智能剪辑子功能再核对
> 后续全面复核见[95项检查及像素差异](smart-edit-full-recheck-2026-09-17.md)。本文保留此前快照:描边透明度、步长和转场样式已在后续修复;本地吸附默认关闭,本文旧“默认开启”结论不准确。
范围:2026-09-17 在用户指定的 LibTV 项目逐层展开编辑器,结合此前节点输入卡片核对记录。只报告可见界面与实际操作,不推断参考站私有实现。用户随后明确要求直接使用参考网页动态预览图。所有新增代码只在本地;继续遵守“本地完整验收后再更新服务器”。
## 本轮转场差异及修复
| 子功能 | 参考实测 | 本地处理 |
| --- | --- | --- |
| 库分类与类型 | 基础转场:淡入淡出、黑场过渡、白场过渡 | 三项保留;当前展开未见其他分类,不等同于完整特效库 |
| 卡片素材 | 三张动态WebP,人物/场景演示;75帧、3秒 | 原始文件保存到public/video-canvas/transitions,替换原素材缩略图+渐变遮罩 |
| 选中样式 | 灰色底、蓝色边框和文字 | 本地CSS已对应调整 |
| 动态效果预览 | 右侧90px高预览当前相邻视频;主画面展示选中转场 | 新增TransitionPreview,复用编辑器合成时序;小预览静音循环、关闭卸载;普通播放/拖动播放头可退出效果演示 |
| 时长输入 | 最小0.2、最大4、步长0.1秒,左右减/加按钮 | 修正旧0–0.5秒上限;输入失焦提交,避免输入小数时截断 |
| 总片长 | 把已有白场2.2改成0.2、再恢复2.2,总片长仍14秒 | 新设置使用接点居中的转场,保留剪辑总长;旧保存方案保留原重叠语义,避免自动改旧工程 |
| 时间轴转场区域 | 覆盖接点两侧的矩形区域 | 按实际生效时长展示整个轨道高度的区域 |
| 极短相邻片段 | 本轮未穷尽所有参考边界 | 本地约束防止相邻转场争用同一片段,面板提示实际生效时长;不谎报完整4秒 |
| 删除 | 右下删除,恢复片段接点 | 已保留;新转场删除不改变总片长 |
| 实际黑白场 | 黑/白覆盖从透明达到全色,再恢复下一视频 | 修正FFmpeg内置曲线与浏览器线性遮罩差异;以两个原生fade经过纯色中间帧合成,避免慢速逐像素表达式 |
| 分离音轨联动 | 参考源无音轨,未验证其音频细节 | 本地补齐居中转场的半程音量包络,分离后保持声音增益 |
导出使用的原生滤镜依据[FFmpeg官方xfade说明](https://ffmpeg.org/ffmpeg-filters.html#xfade),效果以本地像素采样验证,未声称取得参考站私有算法。
## 其他入口逐项状态
| 入口/子项 | 核对结果 | 状态 |
| --- | --- | --- |
| 视频预览四角缩放、移动 | 参考可见四角手柄;本地已有画面变换与实际导出验证 | 已实现,见前批验收 |
| 视频参数:速度、时长 | 参考两项均可输入、上下调;本地已提供 | 入口已对齐,参考完整速度范围仍待边界核对 |
| 视频音量增益 | 参考0dB可点到+0.5dB;本地最高0dB且步进1dB | **待补齐正增益及步长**,预览放大需要音频增益节点,不能只放宽表单 |
| 视频右键:音视频分离 | 参考当前无音轨源显示禁用;本地有声源已可分离 | 已实现基础分离;参考有声源分支仍待核对 |
| 视频右键:人声分离 | 参考可见带子菜单的入口;当前源无音轨,未执行 | **待接入/验证真实人声分离**,不放置假按钮冒充成功 |
| 视频右键:删除 | 可见删除入口 | 本地已有,保留至少一个视频片段 |
| 时间轴:分割、向左裁剪、向右裁剪 | 与选中片段、播放头位置联动禁用 | 本地已有,前批真实操作验证 |
| 时间轴:缩放、吸附、折叠预览、拖动高度 | 参考工具栏可见 | 本地已有,前批浏览器验证;参考吸附默认关闭,本地默认开启,默认状态仍有差异 |
| 字幕:显示/隐藏、列表、搜索、新建 | 参考均可展开;本轮临时新建字幕检查后已删除恢复 | 本地已有 |
| 字幕:编辑文字、移动、时间范围 | 参考提供文字面板和起止手柄 | 本地已有,前批浏览器及实际烧录验证 |
| 字幕:字体 | 参考有搜索框、“全部字体”、Source Han/Noto/Roboto/Inter等长列表;本地只有一个字体选项 | **待补齐可搜索字体库和导出字体映射** |
| 字幕:字号 | 本次参考默认56,范围12–72;本地默认28、范围8–160(720基准) | **待统一显示单位和字号范围**,不能仅照抄数值导致导出大小翻倍 |
| 字幕:字间距 | 参考0–100;本地-5–30 | **待对齐范围/单位** |
| 字幕:颜色、六种预设 | 参考默认/白黑/黑白/黄黑/白粉/粉黑 | 本地六种已有,不能据此推导所有字体效果一致 |
| 字幕:描边粗细 | 参考0–20;本地0–10 | **待对齐范围/单位** |
| 字幕:描边不透明度 | 参考专门作用于描边;本地opacity作用于整行文字 | **待拆分描边透明度**,此前“透明度已支持”的记录不代表语义一致 |
| 字幕:应用到全部 | 参考有底部应用按钮 | 本地已有,字体库扩展后需覆盖字体字段 |
| 导出:本地/画布 | 参考菜单只有这两个目的地 | 本地已有;完整线上页面联动仍未发布验收 |
| 独立音轨更深功能 | 当前参考素材无音轨,不能以灰色入口断言无其他功能 | **待有声样本核对**;本地独立音轨已有裁剪、移动、分割、增益和淡入淡出 |
| 画布节点模式/参数/连接 | 参考原项目七素材、素材混剪、16:9/1080P/20s;详情见前批文档 | 已有此前核对记录,不重复宣称本轮重测所有模式 |
## 证据与发布边界
- 动态图片逐文件SHA-256、帧数及周期:素材目录README和`.qa/smart-edit/transition-assets.json`。
- TypeScript及16项智能剪辑合同回归:`transition-parity-tsc.log`、`transition-parity-helpers.log`。
- 完整helpers回归:491项通过、1项跳过,见`transition-parity-all-helpers.log`。
- 本地浏览器效果截图:`.qa/smart-edit/transition-reference-assets-local.jpg`;已检查动态卡片、循环预览、整段播放及4秒转场。
- 真实合成回归:`transition-parity-ffmpeg.log`;三种效果均验证2.2秒/4秒居中转场、保持4秒总长和中点像素。
- 高清与连续短片段:`transition-stress-native.log`。使用独立本地组件与本地FFmpeg,未把模拟的导出任务回调当作真实Next API端到端成功。
- 参考项目的2.2秒白场和0dB参数已恢复;临时新建字幕已清理;没有扣费生成或发布。
- 字体、人声分离、字幕细节和正增益这些差异仍然存在,**不能标记“全部功能已齐/全站像素级一致”**。服务器更新继续冻结。

View File

@ -0,0 +1,56 @@
# 智能剪辑界面对照与验收(2026-09-17)
> 最新状态见[全面复核与像素差异](smart-edit-full-recheck-2026-09-17.md):95个检查点,本轮修复9项,仍有25项明确差异及8项待验。下方各批验收结果是历史记录,不能解释为所有功能或全部像素已一致。
参考:LibTV 已登录画布中的智能剪辑节点及编辑器。通过可见 DOM、截图和实际点击测量,不读取参考站隐藏状态;没有提交参考站生成任务。
## 已逐项实现
| 功能 | 对照与实现 | 验证 |
| --- | --- | --- |
| 画布入口 | 566×350 节点、标题外置、暗化素材预览、剪刀图标、打开按钮、简化时间尺和轨道 | 本地实际组件截图检查;旧节点恢复时按新尺寸归一化 |
| 节点输入卡片 | 660 宽、16 圆角、48×48 素材缩略图、序号/时长/移除、提示词及发送 | 浏览器打开、参数修改;参考按钮连接画布视频,上限12份 |
| 五种模式 | 默认/讲解/批量广告/口播/素材混剪 | 映射已有方案服务;默认模式使用通用选片,广告多版仍是候选顺序变体 |
| 输出参数 | 六比例、480P/720P/1080P、0–300秒(0自动) | 浏览器选择21:9/480P/300s;六比例真实FFmpeg输出通过;远程21:9/1080P 2520×1080通过 |
| 编辑器默认视图 | 48px顶栏、导出/关闭、默认大预览、底部35vh时间轴 | 同窗口截图比较;左侧配置改为按需打开 |
| 视频参数 | 300px右栏、变速、时长、音量、暗色数值步进器 | 输入小数、选片及预览实际操作 |
| 时间轴 | 刻度、视频缩略图、音轨条、播放头、缩放、吸附、裁剪和分割 | 鼠标排序/边缘裁剪通过;修复拖动结束遗漏历史记录;一次撤销只撤回一次操作 |
| 字幕 | 列表/搜索/新建/时间范围/位置拖动、字体尺寸、间距、颜色、描边、透明度及样式预设 | 手动中文预览、实际ASS烧录;SRT保留文字,不把样式伪装成SRT能力 |
| 转场 | 左侧转场库、右侧时长/删除;淡入淡出/黑场/白场 | 真实成片含硬切混用;像素采样确认黑场变暗、白场变亮 |
| 导出 | “导出到本地/画布”菜单、提交阶段、关闭后重新打开恢复状态 | UI任务生命周期样例通过;真实远程接口回传与对象存储归档另行通过 |
| 打开已有连接 | 自动建立顺序时间轴,不调用视觉模型/Python分析 | 七素材顺序方案测试通过;不会覆盖用户选择的智能剪辑参数 |
| 任务标识 | 使用服务端实际接受的requestId;明确4xx拒绝不显示为持续导出 | 类型/回归检查,避免幂等返回旧任务后UI追踪新ID |
## 验证结果和边界
- 全量 helpers:488通过、1跳过(此批包含新增合同与远程宽屏结果测试)。
- 真实FFmpeg:两种全局转场、三种逐片段转场、中文字幕样式、六种480P比例全部通过。
- 真实300秒21:9/480P成片通过。AAC封装使实际容器时长300.022秒,输出探测允许0.2秒封装尾差,并仍要求成片与方案差异≤0.15秒;方案上限严格300秒。
- 真实远程2520×1080合成与回传通过;普通合成没有调用付费模型或本地千问。
- 浏览器验收使用独立本地入口加载真实React组件;任务回调样例与真正的Next API/服务器合成验证分开记录,不把样例回调当作生产端到端成功。
- 当前记录不等于“全站所有分辨率100%像素一致”:参考站素材、浏览器缩放、窗口尺寸会影响结果;主项目集成页及窄屏还需发布候选验证。字幕内核使用现有Noto CJK字体,不能承诺与参考站未授权字体逐字形相同。
- FunASR权重仍未安装,自动转写保持不可用;可导入SRT、手工编辑字幕。完整复杂对象多轨、音频等其他模块的状态不由这份记录扩大承诺。
部署状态以 `video-render-service-2026-09-17.md` 最后更新为准。
## 本地补齐与发布冻结
用户要求“在本地测试确保功能都全了再更新到服务器”后,停止服务器更新。以下为此后仅在本地实现、验证的增量,不能视为已经上线。
| 子功能 | 当前实现 | 本地验证 |
| --- | --- | --- |
| 预览画面变换 | 拖动画面、四角等比例缩放、重置、撤销;导出保留位置/缩放 | 浏览器实操;真实成片采样确认移动后的颜色区域和黑色留白 |
| 字幕画面位置 | 在预览中拖动字幕;受控坐标生成ASS位置,用户文字继续转义 | 浏览器拖动;真实字幕输出像素区域检查;ASS注入回归通过 |
| 音视频分离 | 有音轨视频右键分离;原视频静音,音频独立保存,不随后续视频排序移动 | 浏览器分离、拖动、分割、撤销;真实音频RMS确认音量未翻倍 |
| 独立音轨编辑 | 起止裁剪、变速、音量、淡入淡出、静音、删除、最多24片段 | 合同边界和真实2倍速/延迟/增益/淡入淡出验证;超过视频尾部输出黑场 |
| 转场库联动 | 三种基础转场,接点选择、时长和删除;与字幕/画面变换/独立音轨共用时间轴 | 三种转场再次通过FFmpeg像素检验;浏览器黑场转场操作通过 |
本轮类型检查通过,智能剪辑合同测试15项通过;真实FFmpeg覆盖既有两种全局转场、三种逐接点转场、六种比例及新增画面/音频/字幕编辑。浏览器测试仍使用真实组件的独立本地入口,不把模拟任务回调当作线上端到端验证。转场库与完整特效库是不同范围:参考站目前展开可见的基础转场为三项,未确认的其他特效不能登记为已完成。
证据保存在本地 `.qa/smart-edit/local-extension-helpers.log`、`local-extension-tsc.log`、`local-extension-ffmpeg.log` 和 `transition-library-local.png`。这些测试产物不随源码提交。
## 动态转场与逐项再核对
后续按用户要求直接采用参考站原始动态WebP(三张、每张75帧/3秒),并补齐当前相邻素材的右侧/主画面转场预览。新增0.2–4秒、0.1秒步进及不缩短总片长的居中转场;修正黑白场中点和独立音轨音量包络。三种转场2.2秒/4秒、1080P及12片段混用均已本地真实合成验证;整段播放可退出转场循环,完成后自动停止。
逐层核对仍发现字体库、字幕数值/描边透明度、正值音量增益、人声分离等差异。详见 [子功能逐项清单](smart-edit-subfeature-audit-2026-09-17.md),不要把此前概括性“已实现”解释为参考站所有子功能均已一致。该批继续保持本地,服务器未更新。

View File

@ -0,0 +1,59 @@
# 视频画布基础功能核对与 P40 资源调整(2026-09-18)
## 本轮范围与结论
直接核对 LibTV 现有画布的九宫格菜单、手写剧本工作流、资产准备和编组工具栏,并检查本地实现;没有触发参考站付费生成。应用改动仅在本地验收,未更新主站。本次服务器操作仅降低千问实例数量,未部署人声分离或视频超分模型。
| 功能 | 参考站 | 本地核对结果 |
| --- | --- | --- |
| 九宫格菜单 | 多机位九宫格、剧情推演四宫格、角色脸部三视图、角色设定图、场景设定图、产品设定图、25宫格连贯分镜、电影级光影校正、角色三视图、3秒后/5秒前推演,共11项 | 均有配置与生成入口;参考图模型路由及宫格拆分已实现。本轮未重复实际付费生图 |
| 宫格默认提示词 | 应随输入素材保持主体与场景 | 发现4项写死绿色服装或森林;已改成素材通用约束,保留2x2/3x3/5x5布局。仅迁移已知旧默认值,保留管理员自定义提示词 |
| 自己编写分镜脚本 | 直接打开确认镜头,初始1个空镜头 | 原入口仅选中节点,未打开编辑器;已修复。编辑、新增、自动保存及刷新持久化在本地浏览器通过 |
| 分镜编辑 | 确认镜头、准备资产、合成提示词;单元格弹层、行拖动 | 本地已有三阶段、逐格编辑、增删/上移、逐行/全部合成、导出;弹层样式和拖动排序未完全对齐 |
| 资产准备 | 角色/场景/道具;AI生成、当前画布、本地上传、个人资产库 | 已有前3种来源,个人资产库入口及逐资产模型/比例参数仍缺;不能标记完全复刻 |
| 批量生成条件 | 三阶段完成状态及视频生成门槛 | 本地已统计资产/提示词就绪数,但批量入口尚未完整落实三阶段门槛,空镜头也应补校验 |
| 编组 | 组颜色、布局、整组执行、添加到工具箱、转分镜组、解组、批量下载 | 现有 TOOLBOX_TEMPLATE_GROUP 为预设背景框,没有完整成员关系和通用编组操作,尚未完整实现 |
| 视频高清 | 视频超分功能 | 已有1080/2K/4K节点参数,但执行函数明确报未接通;没有实际超分服务 |
| 人声分离 | 人声/伴奏处理 | 本地已实现HTDemucs实际分离,但当前Python脚本使用主应用所在机器CPU,尚未迁移算力服务器 |
## 千问与 GPU 调整:已执行
- 服务器4张Tesla P40,每张驱动报告23040MiB。
- 调整前 GPU0、1、3 各运行一个 `qwen38-27b-32k` 实例,单实例 `--parallel 1`;GPU2 被 masked。
- GPU2 的既有 Nginx 配置明确记录历史 `NVIDIA Xid 79 / fallen off bus`。虽然当前可枚举、显存5MiB,不能因此宣称可稳定计算。本轮没有解除该卡禁用。
- 将 `127.0.0.1:18233` 从 Nginx upstream 移除,`nginx -t` 通过并 reload。
- 连续3次确认18233的槽位空闲后,disable并stop `qwen38-27b-32k@3.service`。
- GPU3显存从19055MiB降到5MiB,无计算进程,复查利用率0%。GPU0、1仍各19055MiB。
- 并发由3路降为2路;模型、量化和单实例32K上下文保持不变。超过两路的请求等待或上游限流风险相应增加,未扩大请求队列。
- 18230、18231直接健康检查、18080/18188代理健康检查通过;18084鉴权模型列表返回200。
- 两个保留实例同时执行短文本推理,均200且返回2个输出token。
- 手动运行CUDA驻留守护检查后,GPU3仍inactive。守护脚本会跳过disabled/masked实例。
- 原视频服务所用18231保留;video-render与FRP服务均active。
- 配置备份位于服务器 `/root/qwen-gpu-release-20260918-104112/qwen38-27b-local.conf`,备份目录仅root可访问。
### 回滚
先启用并启动 `qwen38-27b-32k@3.service`,等待18233健康且确认GPU3模型已驻留,再从备份恢复原Nginx配置,检查语法后reload。不能先恢复路由到尚未就绪的实例。部署媒体模型后回滚应先排空媒体任务并释放GPU3。
## 算力服务方案与开源检索
优先把新释放的GPU3作为媒体任务卡,人声分离和视频超分共享有界队列,初期每卡仅一个重任务。GPU2故障卡另行稳定性测试后才考虑加入。主应用仅处理鉴权、任务状态和结果引用;媒体下载、推理、封装及上传在算力服务执行,禁止失败后静默回落主站CPU。
| 项目 | 依据与用途 | 本机边界 |
| --- | --- | --- |
| [Demucs](https://github.com/facebookresearch/demucs) | MIT,官方支持GPU;可沿用当前HTDemucs模型与人声/伴奏输出 | 将脚本硬编码CPU改为显式GPU设备,独立依赖环境、异步任务和取消/超时;目前未部署GPU实例 |
| [Real-ESRGAN](https://github.com/xinntao/Real-ESRGAN) | BSD-3-Clause,官方包含视频推理、分块和FP32选项;优先作为可验证的超分基线 | P40先验证兼容环境和分块推理;逐帧方法可能闪烁,实际速度和质量未测,不能承诺实时4K |
| [RealBasicVSR](https://github.com/ckkelvinchan/RealBasicVSR) | Apache-2.0,面向真实视频恢复,具有时序处理 | 旧MMCV/MMEditing依赖需隔离。原示例整段读帧、默认25fps,不可原样上线;应分段并保持源帧率及音轨 |
| [FlashVSR](https://github.com/OpenImagingLab/FlashVSR) | Apache-2.0,官方性能展示基于A100等,依赖稀疏注意力实现 | 官方性能不能套用P40,不作为首选部署目标 |
| [SeedVR2](https://github.com/ByteDance-Seed/SeedVR) | Apache-2.0,官方高规格GPU视频恢复方案 | 官方H100示例不能用于推断P40吞吐,暂不作为首选 |
Real-ESRGAN的视频示例存在shell路径调用、FPS表达式求值和帧失败继续执行等行为,不能直接封装为公网API。实现时使用安全argv调用FFmpeg、验证媒体参数、限制时长/像素/磁盘量;输入帧失败应明确失败,成片校验帧率、帧数、时长及音轨,不把普通缩放标成AI超分。
## 本轮验证
- 本地浏览器3027:手写入口打开含1个空镜头的工作流;填写柜门画面描述,新增第2镜头;保存后刷新,2个镜头和描述均保留。
- 四个相关测试文件:66通过,1项FFmpeg集成测试因当前命令PATH无FFmpeg跳过。本轮没有修改媒体编解码实现。
- TypeScript 全量 `tsc --noEmit --incremental false` 通过。
- 新增回归覆盖:旧示例提示词迁移、布局约束保留,以及管理员自定义绿色服装/森林提示词不被覆盖。
- 参考站临时测试节点已删除,原11个节点保留,恢复到素材混剪分组视图。
- 本轮不宣称编组、超分服务、像素级分镜编辑器或GPU人声分离已完成。

View File

@ -0,0 +1,54 @@
# 画布连线语义与请求验收(2026-09-18)
## 核对范围
参考 LibTV 同一项目的素材混剪:7条视频输入边进入智能剪辑节点,进入真实剪辑器可见14秒时间轴。只查看原有节点、连线及编辑器,未调用参考站生成、修改素材或导出。
本地读取前端节点、连线、任务提交、轮询恢复及后端请求契约,并使用真实Next页面、隔离账号和隔离画布进行浏览器验收。拦截付费生成请求,检查浏览器实际序列化的请求体;不以“画出一条线”作为功能完成依据。
## 连线应该表示什么
- `source → target`:target在执行时读取source当前已完成的输出。节点移动不改变依赖,输入顺序由保存的连线顺序决定。
- 图片/视频/音频输出与输入预览是两回事。`sourceUrl`、poster不能证明上游已生成成功;失败、排队和未完成的上游应阻止下游提交。
- 普通多图参考可以按URL去重;首尾帧是两个位置,即使两张图URL相同也必须保留两个槽位。
- 文字输出作为下游提示词或配音内容;视频素材作为剪辑输入;角色图片和角色/特效描述应进入相应视频请求。
- 当前连线是下一次执行的输入关系,已提交任务中的参数是不可变的历史快照。改线不会自动付费重生成,也不篡改已经生成的成片。
- 完整“整组执行”还需要独立调度器:拓扑排序、等待上游成功、失败阻断、重试和幂等提交。当前没有这套完整实现,不能把连线动画或逐个点生成当作整组调度。
## 本轮实际修复
| 问题 | 修复 |
| --- | --- |
| 视频/图片重跑会混入params.referenceImages中的旧URL | 标准重跑路径从当前连线重新解析输入,断线后不使用旧URL或节点自身预览补位 |
| 首尾帧相同URL被Set去重 | 保留连线顺序及两个槽位,后端引用校验也保留相同URL |
| 文字连线没有参与视频/图片/配音请求 | 接入文字输出;再次执行替换此前连接的文本区段。音频保存原始表单,提交时组合当前文本,避免重复朗读;编辑表单移除解析器标记,任务参数仍保留已解析文本 |
| 上游失败但参考预览仍可被使用 | 提交前检查上游状态与实际输出,缺失节点、空文本、未完成媒体阻止下游 |
| 可以自连、循环连接或把分组背景当输入 | 手动连线、从端口创建节点和选参考图统一检查;旧画布循环在执行前也拦截 |
| 不匹配素材可以挂一条无作用的线 | 常用图片、视频、文字、音频、剪辑节点检查输入类型;多余图片超出视频模式容量明确提示,不静默截取 |
| 任务响应/轮询晚到重新补回删除的线 | 记录本页断开的source/target关系,恢复任务边时不覆盖这次断线;明确重连会解除该限制 |
| 角色素材只有描述没有参考图 | 视频请求同时包含角色图片和角色描述 |
| 宫格裁切失败会退回整张图或少数切片 | 所选切片必须全部准备成功,失败则停止提交并提示重试 |
| 空闲连线一直流动 | 仅下游任务处于pending/running时显示流动,空闲关系保留静态连线 |
有成品的图片继续派生新图时,该成品本身作为新输入,不自动把它所有历史图片依赖再追加一遍。尚无成品的图像节点则读取其当前直接输入。显式选取的额外参考仍会建立对应输入边。
## 验收证据
浏览器脚本位于本地忽略目录 `.qa/connection-audit-0918/browser.cjs`,请求证据位于同目录`request-evidence.json`。使用隔离测试账号,不提交供应商,不导出账号凭据。
- 真实视频页面提交:相同URL的首尾帧保留2项,连接文字进入prompt,历史stale.png没有进入请求。
- 改接素材且更新源URL后,下一次请求只包含新地址,断开的文字不再进入prompt。
- 删除所有图片连线后,首帧模式提示缺少参考图,无新增请求。
- 上游failed时提示先完成上游,无新增请求。
- 暂缓任务POST响应,在页面删除连线后再释放响应,连线不会重新出现。
- 文字连入音频节点,真实生成音频提交中的text包含该文字;通过右键再次执行,文字没有重复追加。
- TypeScript全量检查 `tsc --noEmit --incremental false` 通过。
- 回归测试:62通过、1跳过(命令PATH未提供FFmpeg;本轮未修改编解码实现)。覆盖循环、端点缺失、类型、模式容量、宫格部分失败、文本更新及首尾帧位置语义。
## 尚未完成与边界
- 整组依赖执行器、完整编组成员关系尚未实现。
- 剪辑器内部独立上传的素材、编辑好的时间轴与画布输入边是不同层数据,现有成片/时间轴不会被改线自动改写;编辑器内素材增删和画布编组之间还需要完整双向同步验收。
- 部分专用节点有自己的提交路径,本轮不宣称所有导演台、标记对象和第三方工具组合已全链路验收。
- 模型结果质量、耗时、真实供应商生成没有在本轮重测。本轮验证输入关系和请求边界。
- 代码仅本地修复,未推送、未更新主站服务器。

View File

@ -0,0 +1,39 @@
# 编组和独立视频高清验收 — 2026-09-18
工作分支 `codex/video-canvas-parity-0916`。本次没有更新主站前端/主项目版本。
## 编组
- Shift 点击/框选、Ctrl/⌘+G、新建组、嵌套成员、整体移动、四角调整边框、颜色/名称、网格/横排/竖排、移动到组/移出、解组保留节点和连线。旧预设背景框按几何包含关系迁移一次,后续按明确 parentGroupId 工作。
- 全部就绪图片/视频可转分镜组,预览、前后镜头、调整顺序;混合工具或未就绪素材时禁用转换。顺序保存在画布中,导出重建 ID。
- 我的工具箱保存到所属画布,按用户权限跨画布查询;模板 JSON 导入/导出。内部节点和连线重建 ID,运行编号清除;外部媒体依赖转换为必填输入节点,缺素材时整批拒绝。角色/特效外部依赖要求先纳入组内。
- 整组执行一次性原子入库,客户端持久 outbox 使用同一 runId/requestId 重试;关闭/刷新浏览器后服务端继续执行。按真实连线获取本次上游结果,拒绝循环、错误类型、缺输入、跨批次依赖。上游失败/取消/无可恢复编号的中断会阻断下游,不取旧输出。
- 支持现有图片/视频/文字分析/音频/图片处理/视频高清任务。智能剪辑遇到本次新视频时拆成隐藏选片阶段 + 成片阶段,依赖新生成素材;保留已确认且输入未变的现有剪辑方案。默认新组合使用顺序剪辑,已有智能参数按保存配置。取消只取消尚未提交的任务。
- 选中宫格切片需先落成独立图片节点再整组执行,有明确拦截,未用整张原图冒充切片。
## 视频高清
Real-ESRGAN 独立部署到算力服务器物理 GPU 3(P40)。保留千问 GPU 0/1,GPU 2 不使用。服务与 48→算力私有 API 隧道均启用;48 的 `/etc/video-upscale-client.env` 已准备,尚未注入/发布主项目版本。部署和约束见 `services/video-upscale/README.md`。
真实样例均由色彩图案/正弦音合成,无付费模型:
| 输入 | 输出 | 验证 |
| --- | --- | --- |
| 960×540、2fps、1秒、音轨 | 3840×2160 | 2帧,2fps,音轨保留,1.002秒 |
| 270×480、2fps、1秒、无音轨 | 1080×1920 | 2帧,2fps,1秒 |
| 960×540 + 90度旋转信息、含音轨 | 1080×1920 | 归一化方向,音轨/帧率/帧数保留 |
| 主项目编组提交 960×540 | 2560×1440 | 真实 GPU → 持久任务成功 → 本地对象存储归档 → 页面恢复 src |
第一轮横屏/竖屏短片含传输与轮询分别约 8.4/6.9 秒。仅是 1 秒 2 帧链路测试,不能推算常见 24/30fps 长视频吞吐。
重启测试发现并修复两处问题:128核机器 FFmpeg 默认滤镜线程超过服务限额;SIGTERM 杀子进程时曾将正在处理的任务记成 failed。修复后同一任务重启续跑,attempts=2,返回真实 4K 结果。未鉴权 401、重复请求复用记录、参数冲突 409 均通过。
## 验证证据与边界
- 75 项定向回归通过,包含连线、编组、任务恢复、智能剪辑;新增组内自动选片/成片依赖测试。
- 浏览器实测:多选编组、布局、整体拖动、调整边框、模板保存导入/必填输入拦截、分镜转换/预览/排序、真实提交参数依赖、刷新恢复排队、解组保留成员。
- 队列读取仅查询当前待处理任务及直接依赖,避免每次 claim 读取整个项目历史。
- 真实 PostgreSQL:原子批量提交、幂等、冲突拒绝、项目所有权;上游失败时下游 attempts=0。
- 真实主项目 API:2K 成片归档成功,FFprobe 验证 H.264/AAC、2560×1440、帧数/时长;刷新后保留结果链接。
- 当前验收 Chrome 对 `127.0.0.1:19440` 本地对象存储的媒体加载返回 `Media load rejected by URL safety check`。没有绕过浏览器策略;网页实际播放不能记为通过。界面增加明确“结果已保存、预览加载失败”的提示与打开链接,避免黑屏被理解为仍在处理。尚需在获准访问的正式/测试对象存储域名上完成播放验收后发布主项目。
- 本地证据保存在忽略目录 `.qa/group-upscale-0918/`,可复用 GPU 验证脚本 `scripts/qa/video-upscale-service.cjs`。正式运行代码/文档不包含密钥或 QA 账号。

View File

@ -26,3 +26,15 @@
FunASR适配与安装脚本已实现,未装权重、未做真实ASR验收,界面禁用自动转写并支持导入SRT。当前本地千问接口不能替代视觉选片,视觉分析继续使用主项目已有模型配置。 FunASR适配与安装脚本已实现,未装权重、未做真实ASR验收,界面禁用自动转写并支持导入SRT。当前本地千问接口不能替代视觉选片,视觉分析继续使用主项目已有模型配置。
部署边界:独立合成服务已启动且两机链路实测通过;主项目正式应用的切换状态将在发布验证后追加。此前音频/导演台等开发分支改动不能凭本记录视为已经上线。 部署边界:独立合成服务已启动且两机链路实测通过;主项目正式应用的切换状态将在发布验证后追加。此前音频/导演台等开发分支改动不能凭本记录视为已经上线。
## 2026-09-17 智能剪辑界面与输出范围补齐
用户再次要求按参考站像素级对齐。新增独立编辑器和画布节点输入卡片,逐项记录见 `smart-edit-ui-parity-2026-09-17.md`。新增手动字幕样式、逐片段三种转场、六种比例、480P和300秒上限,主项目与算力服务共用合同;修正远程返回结果旧90秒/1920限制。真实300秒输出发现AAC封装尾差,输出探测及校验保留0.2/0.15秒容差。
48候选应用仍运行独立目录 `/var/www/ai-chatbot-render-0917` 和回环3135,公网应用3125尚未切换。候选旧版已完成真实Next API鉴权、排队、选片、节拍、远程渲染和归档测试;新UI必须完成候选重建与验证后再更新此状态。不要将算力服务已部署理解成主站UI已上线。
## 最新状态:本地验收优先,停止服务器更新
上述候选随后已在 `6869bd8c` 完成构建及启动;七个素材、手工字幕、21:9/480P、重复提交幂等、真实Next API到远程渲染和对象存储回传通过。此时公网仍为3125旧应用,未切换。算力服务当时的包为 `0f7d1c0eeb42971c`。
用户进一步要求先在本地补齐和测试特效等缺项,再更新服务器。因此此后的画面变换、字幕坐标和独立音轨等增量只在本地开发验证,尚未同步至候选或算力服务;需要将主项目与算力服务的共享合同同步发布后才能在线使用。完成已确认功能不代表全部参考站子功能已核对完毕,继续对照的记录见智能剪辑界面验收文档。

View File

@ -0,0 +1,90 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import {
canvasTaskRequestSchema,
type CanvasTaskRequest,
} from "@/lib/video-canvas/task-contract";
import { toast } from "sonner";
export function useCanvasGroupRuns(projectId: string, refresh: () => void) {
const [submitting, setSubmitting] = useState(false),
active = useRef(projectId),
refreshRef = useRef(refresh),
sending = useRef(false);
active.current = projectId;
refreshRef.current = refresh;
const key = `video-canvas-group-outbox:${projectId}`;
const read = useCallback(() => {
try {
const parsed = canvasTaskRequestSchema
.array()
.min(1)
.max(40)
.safeParse(JSON.parse(localStorage.getItem(key) || "null"));
return parsed.success &&
parsed.data.every((r) => r.projectId === projectId && r.workflow)
? parsed.data
: null;
} catch {
return null;
}
}, [key, projectId]);
const send = useCallback(
async (requests: CanvasTaskRequest[]) => {
if (sending.current) return;
sending.current = true;
setSubmitting(true);
try {
const response = await fetch("/api/canvas/group-runs", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(requests),
signal: AbortSignal.timeout(20000),
});
if (!response.ok) {
if (response.status < 500 && response.status !== 429) {
localStorage.removeItem(key);
const body = await response.json();
throw Error(body.error || "编组请求被拒绝");
}
throw Error("编组提交尚未确认,已保存,将查询同一批任务");
}
localStorage.removeItem(key);
if (active.current === projectId) refreshRef.current();
} finally {
sending.current = false;
if (active.current === projectId) setSubmitting(false);
}
},
[key, projectId]
);
const submit = useCallback(
async (requests: CanvasTaskRequest[]) => {
const pending = read();
if (pending && pending[0].workflow?.runId !== requests[0].workflow?.runId)
throw Error("上一批编组提交仍在确认,请稍后重试");
localStorage.setItem(key, JSON.stringify(requests));
await send(requests);
},
[key, read, send]
);
useEffect(() => {
setSubmitting(false);
const retry = () => {
const requests = read();
if (requests && !sending.current)
void send(requests).catch((error) => {
if (!read()) toast.error(error.message);
});
};
retry();
const timer = setInterval(retry, 10000);
window.addEventListener("online", retry);
window.addEventListener("focus", retry);
return () => {
clearInterval(timer);
window.removeEventListener("online", retry);
window.removeEventListener("focus", retry);
};
}, [read, send]);
return { submit, submitting };
}

View File

@ -1,4 +1,5 @@
"use client"; "use client";
import { withConnectedText } from "@/lib/video-canvas/connections";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
@ -7,6 +8,8 @@ import {
type CanvasTaskRequest, type CanvasTaskRequest,
} from "@/lib/video-canvas/task-contract"; } from "@/lib/video-canvas/task-contract";
class CanvasTaskRejectedError extends Error {}
type Node = CanvasTaskRequest["node"]; type Node = CanvasTaskRequest["node"];
type Edge = CanvasTaskRequest["edges"][number]; type Edge = CanvasTaskRequest["edges"][number];
const storageKey = (projectId: string) => `video-canvas-outbox:${projectId}`; const storageKey = (projectId: string) => `video-canvas-outbox:${projectId}`;
@ -38,10 +41,18 @@ export function useVideoCanvasTasks<T extends Node>(
loaded: { id: string; revision: string } | null, loaded: { id: string; revision: string } | null,
setNodes: React.Dispatch<React.SetStateAction<T[]>>, setNodes: React.Dispatch<React.SetStateAction<T[]>>,
setEdges: React.Dispatch<React.SetStateAction<Edge[]>>, setEdges: React.Dispatch<React.SetStateAction<Edge[]>>,
canvasEdges: Edge[] = [],
) { ) {
const [tasks, setTasks] = useState<CanvasGenerationTask[]>([]); const [tasks, setTasks] = useState<CanvasGenerationTask[]>([]);
const hiddenNodes = useRef(new Set<string>()); const hiddenNodes = useRef(new Set<string>());
const recoveredEdges = useRef(new Set<string>()); const recoveredEdges = useRef(new Set<string>());
const edgeEdits = useRef({ projectId, current: new Set<string>(), removed: new Set<string>() });
if (edgeEdits.current.projectId !== projectId)
edgeEdits.current = { projectId, current: new Set(), removed: new Set() };
const currentEdgeKeys = new Set(canvasEdges.map(edge => `${edge.source}>${edge.target}`));
for (const key of edgeEdits.current.current) if (!currentEdgeKeys.has(key)) edgeEdits.current.removed.add(key);
for (const key of currentEdgeKeys) edgeEdits.current.removed.delete(key);
edgeEdits.current.current = currentEdgeKeys;
const refreshRef = useRef<() => void>(() => {}); const refreshRef = useRef<() => void>(() => {});
const activeProject = useRef(projectId); const activeProject = useRef(projectId);
activeProject.current = projectId; activeProject.current = projectId;
@ -78,7 +89,9 @@ export function useVideoCanvasTasks<T extends Node>(
) )
.filter((edge) => { .filter((edge) => {
const key = `${edge.source}>${edge.target}`; const key = `${edge.source}>${edge.target}`;
if (existing.has(key)) return false; // A request is a historical snapshot, not permission to undo a
// later disconnect while its response/poll is in flight.
if (existing.has(key) || edgeEdits.current.removed.has(key)) return false;
existing.add(key); existing.add(key);
return true; return true;
}); });
@ -115,6 +128,8 @@ export function useVideoCanvasTasks<T extends Node>(
), ),
); );
} }
if (response.status >= 400 && response.status < 500)
throw new CanvasTaskRejectedError(body.error || "任务参数未通过校验");
throw Error(body.error || "提交暂时失败,恢复连接后自动核对"); throw Error(body.error || "提交暂时失败,恢复连接后自动核对");
} }
forget(projectId, request.requestId); forget(projectId, request.requestId);
@ -149,10 +164,19 @@ export function useVideoCanvasTasks<T extends Node>(
) => { ) => {
if (!loaded || loaded.id !== projectId) throw Error("画布尚未加载完成,请稍后提交"); if (!loaded || loaded.id !== projectId) throw Error("画布尚未加载完成,请稍后提交");
const requestId = crypto.randomUUID(); const requestId = crypto.randomUUID();
// The task params retain resolved text; the editable form retains only
// the user's prompt, so linked content is neither duplicated nor exposed
// as resolver markup when the node is reopened.
const editableDraft = { ...node.draftValues };
if (typeof editableDraft.prompt === "string") editableDraft.prompt = withConnectedText(editableDraft.prompt, []);
if (editableDraft.params && typeof editableDraft.params === "object" && !Array.isArray(editableDraft.params)) {
const params = editableDraft.params as Record<string, unknown>;
if (typeof params.prompt === "string") editableDraft.params = { ...params, prompt: withConnectedText(params.prompt, []) };
}
const snapshot = { const snapshot = {
...node, ...node,
draftValues: { draftValues: {
...node.draftValues, ...editableDraft,
taskRequestId: requestId, taskRequestId: requestId,
generationTaskId: undefined, generationTaskId: undefined,
appliedTaskStatus: undefined, appliedTaskStatus: undefined,
@ -175,13 +199,17 @@ export function useVideoCanvasTasks<T extends Node>(
remember(request); remember(request);
setNodes((nodes) => nodes.map((item) => (item.id === node.id ? snapshot : item))); setNodes((nodes) => nodes.map((item) => (item.id === node.id ? snapshot : item)));
try { try {
await send(request); const accepted = await send(request);
refreshRef.current();
return accepted.requestId;
} catch (error) { } catch (error) {
if (kind === "smart-edit" && error instanceof CanvasTaskRejectedError) throw error;
toast.error( toast.error(
error instanceof Error ? error.message : "连接中断,恢复后将核对任务,请勿重复提交", error instanceof Error ? error.message : "连接中断,恢复后将核对任务,请勿重复提交",
); );
} }
refreshRef.current(); refreshRef.current();
return requestId;
}, },
[loaded, projectId, send, setNodes], [loaded, projectId, send, setNodes],
); );

View File

@ -125,7 +125,7 @@ const defaultFunctions: VideoCanvasFunctionDefinition[] = [
route: "canvas-generate-image", route: "canvas-generate-image",
enabled: true, enabled: true,
prompt: prompt:
"以选中图片作为角色身份、服装、发型、场景光线、镜头距离和画面质感的唯一参考,生成一张 2x2 剧情推演四宫格合成图。画面中只能有 4 个画面区域,只允许一条竖向分割线和一条横向分割线;严禁出现 3 行 3 列、9 个画面区域或九宫格布局。四格都必须保持参考图的近景/半身近中景人物构图,主体占画面主要面积,不要远景、全景、森林空镜或主体很小的画面。四格是同一角色在同一自然光场景中的连续轻微剧情变化:1 正面安静凝视,2 保留参考图抬手/袖摆动作的延续,3 视线或表情轻微变化,4 情绪收束的近中景反应。保持五官、发型、绿色服装、柔和森林逆光、色调和景深一致;每格角度和动作有轻微差异,但不能像 9 个不同机位分镜。输出单张 16:9 图片,2x2 等分排版,分割线清晰干净,不能出现文字、水印、编号、边框标签或 3x3/5x5 宫格。", "以选中图片作为主体身份、外观、场景光线、镜头距离和画面质感的唯一参考,生成一张 2x2 剧情推演四宫格合成图。只能有 4 个等大画面区域,一条横向和一条竖向分割线,严禁生成 3x3 或 5x5 布局。四格依次呈现初始状态、动作或事件延续、细微变化、结果或反应,形成连贯的短叙事。根据参考图实际主体安排变化,保持原有身份、数量、服装或产品结构、配色、场景、光线和景别一致,不引入无依据的人物、服装颜色或环境。输出单张 16:9 图片,2x2 等分排版,分割线清晰干净,不添加文字、水印、编号或边框标签。",
description: "官方剧情推演四宫格:基于参考图生成 2x2 连续剧情分镜合成图。", description: "官方剧情推演四宫格:基于参考图生成 2x2 连续剧情分镜合成图。",
}, },
{ {
@ -135,7 +135,7 @@ const defaultFunctions: VideoCanvasFunctionDefinition[] = [
route: "canvas-generate-image", route: "canvas-generate-image",
enabled: true, enabled: true,
prompt: prompt:
"以选中图片作为唯一角色参考,生成一张角色三视图设定图。只输出正面、侧面、背面 3 个完整站姿人物,三人横向并排在同一张 16:9 画面中,白色或浅灰干净背景。必须保持同一角色身份、五官、发型、绿色服装、材质、体型比例和配饰一致;正面、侧面、背面视角要清晰可辨,人物全身完整入画,站姿自然。禁止生成 3x3 九宫格、多机位分镜、黑色分割线、重复头像、文字、水印、编号、环境场景或额外人物。", "以选中图片作为唯一角色参考,生成一张角色三视图设定图。只输出同一角色的正面、侧面、背面 3 个完整站姿视图,横向并排在同一张 16:9 画面中,白色或浅灰干净背景。保持参考图中的身份、五官、发型、服装颜色、材质、体型比例和配饰一致,不擅自添加或改变服装颜色。三个视角清晰可辨,全身完整入画,站姿自然。禁止生成 3x3 九宫格、多机位分镜、黑色分割线、重复头像、文字、水印、编号、环境场景或额外人物。",
description: "官方角色三视图预设,使用参考图生图/编辑模型。", description: "官方角色三视图预设,使用参考图生图/编辑模型。",
}, },
{ {
@ -145,7 +145,7 @@ const defaultFunctions: VideoCanvasFunctionDefinition[] = [
route: "canvas-generate-image", route: "canvas-generate-image",
enabled: true, enabled: true,
prompt: prompt:
"以选中图片作为角色、服装、发型、场景光线的唯一参考,生成一张 3x3 多机位分镜合成图。必须是 9 个明显不同的镜头画面,不是同一张图裁切或重复人脸。九格分别包含:1 森林环境远景/主体很小,2 全身远景,3 中景正面,4 侧面半身,5 低机位或俯拍,6 面部近景,7 手部/衣袖/动作细节,8 背面或侧背,9 仰视或情绪特写。保持同一角色身份、绿色服装、自然森林光线一致,每格构图、景别、机位和主体大小必须不同。输出为单张 16:9 图片,九格之间使用清晰黑色分割线,不能出现文字、水印、编号,不能白底棚拍。", "以选中图片作为主体、外观、场景和光线的唯一参考,生成一张 3x3 多机位分镜合成图。必须是 9 个明显不同的镜头,不是同一张图裁切或重复。九格分别呈现:1 环境建立远景,2 主体完整全景,3 正面中景,4 侧面中景,5 俯拍,6 主体近景,7 关键局部细节,8 背面或侧背视角,9 低机位或特写。根据实际主体选择合理构图,保持身份、数量、服装或产品结构、配色、材质、场景和光线一致,不凭空加入森林、指定服装颜色或人物。输出单张 16:9 图片,严格 3x3 等分排版,九格之间使用清晰黑色分割线,不添加文字、水印或编号。",
description: "官方九宫格工具:先生成带黑色分割线的 3x3 分镜图,再由宫格切分拆成单张镜头。", description: "官方九宫格工具:先生成带黑色分割线的 3x3 分镜图,再由宫格切分拆成单张镜头。",
}, },
{ {
@ -155,7 +155,7 @@ const defaultFunctions: VideoCanvasFunctionDefinition[] = [
route: "canvas-generate-image", route: "canvas-generate-image",
enabled: true, enabled: true,
prompt: prompt:
"以选中图片作为角色、服装、发型、场景光线和电影质感的唯一参考,生成一张 5x5 连贯分镜合成图。必须是 25 个连续镜头,体现从远景建立场景、人物动作推进、情绪变化、细节特写到结尾反应的完整微型叙事,不是同一张图重复裁切。保持同一角色身份和绿色服装一致,森林自然光线统一;每格景别、机位、主体大小或动作都要有明显差异。输出为单张 16:9 图片,必须严格 5x5 等分排版,25 个宫格的宽高一致、间距一致、边界完整,所有宫格之间使用清晰纯黑分割线,分割线等宽且横线竖线都要贯穿整张图;禁止使用白线、浅色线、柔边、渐变线、不规则拼贴、错位排版、跨格大图、留白边距、文字、水印、编号或额外边框说明。", "以选中图片作为主体、外观、场景光线和画面质感的唯一参考,生成一张 5x5 连贯分镜合成图。必须是 25 个连续镜头,从建立场景、动作或事件推进、状态变化、细节特写到结尾反应形成完整微型叙事,不是同一张图重复裁切。保持参考图中的主体身份、数量、服装或产品结构、配色、材质和场景光线一致,不引入无依据的人物、指定服装颜色或环境。每格景别、机位、主体大小或动作要有合理差异。输出单张 16:9 图片,严格 5x5 等分排版,25 格宽高和间距一致、边界完整。使用清晰纯黑等宽分割线,横线竖线贯穿整张图。禁止白线、浅色线、柔边、渐变线、不规则拼贴、错位排版、跨格大图、留白边距、文字、水印、编号或额外边框说明。",
description: "官方 25 宫格连贯分镜:生成 5x5 连续镜头合成图。", description: "官方 25 宫格连贯分镜:生成 5x5 连续镜头合成图。",
}, },
{ {
@ -696,14 +696,20 @@ function isRecord(value: unknown): value is Record<string, unknown> {
} }
const legacyFunctionPrompts: Record<string, string[]> = { const legacyFunctionPrompts: Record<string, string[]> = {
"multi-view-nine-grid": [
"以选中图片作为角色、服装、发型、场景光线的唯一参考,生成一张 3x3 多机位分镜合成图。必须是 9 个明显不同的镜头画面,不是同一张图裁切或重复人脸。九格分别包含:1 森林环境远景/主体很小,2 全身远景,3 中景正面,4 侧面半身,5 低机位或俯拍,6 面部近景,7 手部/衣袖/动作细节,8 背面或侧背,9 仰视或情绪特写。保持同一角色身份、绿色服装、自然森林光线一致,每格构图、景别、机位和主体大小必须不同。输出为单张 16:9 图片,九格之间使用清晰黑色分割线,不能出现文字、水印、编号,不能白底棚拍。",
],
"story-beat-grid-4": [ "story-beat-grid-4": [
"以选中图片作为角色身份、服装、发型、场景光线、镜头距离和画面质感的唯一参考,生成一张 2x2 剧情推演四宫格合成图。画面中只能有 4 个画面区域,只允许一条竖向分割线和一条横向分割线;严禁出现 3 行 3 列、9 个画面区域或九宫格布局。四格都必须保持参考图的近景/半身近中景人物构图,主体占画面主要面积,不要远景、全景、森林空镜或主体很小的画面。四格是同一角色在同一自然光场景中的连续轻微剧情变化:1 正面安静凝视,2 保留参考图抬手/袖摆动作的延续,3 视线或表情轻微变化,4 情绪收束的近中景反应。保持五官、发型、绿色服装、柔和森林逆光、色调和景深一致;每格角度和动作有轻微差异,但不能像 9 个不同机位分镜。输出单张 16:9 图片,2x2 等分排版,分割线清晰干净,不能出现文字、水印、编号、边框标签或 3x3/5x5 宫格。",
"以选中图片作为角色身份、服装、发型、场景光线、镜头距离和画面质感的唯一参考,生成一张 2x2 剧情推演四宫格合成图。四格都必须保持参考图的近景/半身近中景人物构图,主体占画面主要面积,不要远景、全景、森林空镜或主体很小的画面。四格是同一角色在同一自然光场景中的连续轻微剧情变化:1 正面安静凝视,2 保留参考图抬手/袖摆动作的延续,3 视线或表情轻微变化,4 情绪收束的近中景反应。保持五官、发型、绿色服装、柔和森林逆光、色调和景深一致;每格角度和动作有轻微差异,但不能像 9 个不同机位分镜。输出单张 16:9 图片,2x2 等分排版,分割线清晰干净,不能出现文字、水印、编号、边框标签或 3x3/5x5 宫格。", "以选中图片作为角色身份、服装、发型、场景光线、镜头距离和画面质感的唯一参考,生成一张 2x2 剧情推演四宫格合成图。四格都必须保持参考图的近景/半身近中景人物构图,主体占画面主要面积,不要远景、全景、森林空镜或主体很小的画面。四格是同一角色在同一自然光场景中的连续轻微剧情变化:1 正面安静凝视,2 保留参考图抬手/袖摆动作的延续,3 视线或表情轻微变化,4 情绪收束的近中景反应。保持五官、发型、绿色服装、柔和森林逆光、色调和景深一致;每格角度和动作有轻微差异,但不能像 9 个不同机位分镜。输出单张 16:9 图片,2x2 等分排版,分割线清晰干净,不能出现文字、水印、编号、边框标签或 3x3/5x5 宫格。",
"以选中图片作为角色、服装、发型、场景光线的唯一参考,生成一张 3x3 多机位分镜合成图。必须是 9 个明显不同的镜头画面,不是同一张图裁切或重复人脸。九格分别包含:1 森林环境远景/主体很小,2 全身远景,3 中景正面,4 侧面半身,5 低机位或俯拍,6 面部近景,7 手部/衣袖/动作细节,8 背面或侧背,9 仰视或情绪特写。保持同一角色身份、绿色服装、自然森林光线一致,每格构图、景别、机位和主体大小必须不同。输出为单张 16:9 图片,九格之间使用清晰黑色分割线,不能出现文字、水印、编号,不能白底棚拍。", "以选中图片作为角色、服装、发型、场景光线的唯一参考,生成一张 3x3 多机位分镜合成图。必须是 9 个明显不同的镜头画面,不是同一张图裁切或重复人脸。九格分别包含:1 森林环境远景/主体很小,2 全身远景,3 中景正面,4 侧面半身,5 低机位或俯拍,6 面部近景,7 手部/衣袖/动作细节,8 背面或侧背,9 仰视或情绪特写。保持同一角色身份、绿色服装、自然森林光线一致,每格构图、景别、机位和主体大小必须不同。输出为单张 16:9 图片,九格之间使用清晰黑色分割线,不能出现文字、水印、编号,不能白底棚拍。",
], ],
"continuous-storyboard-grid-25": [ "continuous-storyboard-grid-25": [
"以选中图片作为角色、服装、发型、场景光线和电影质感的唯一参考,生成一张 5x5 连贯分镜合成图。必须是 25 个连续镜头,体现从远景建立场景、人物动作推进、情绪变化、细节特写到结尾反应的完整微型叙事,不是同一张图重复裁切。保持同一角色身份和绿色服装一致,森林自然光线统一;每格景别、机位、主体大小或动作都要有明显差异。输出为单张 16:9 图片,必须严格 5x5 等分排版,25 个宫格的宽高一致、间距一致、边界完整,所有宫格之间使用清晰纯黑分割线,分割线等宽且横线竖线都要贯穿整张图;禁止使用白线、浅色线、柔边、渐变线、不规则拼贴、错位排版、跨格大图、留白边距、文字、水印、编号或额外边框说明。",
"以选中图片作为角色、服装、发型、场景光线和电影质感的唯一参考,生成一张 5x5 连贯分镜合成图。必须是 25 个连续镜头,体现从远景建立场景、人物动作推进、情绪变化、细节特写到结尾反应的完整微型叙事,不是同一张图重复裁切。保持同一角色身份和绿色服装一致,森林自然光线统一;每格景别、机位、主体大小或动作都要有明显差异。输出为单张 16:9 图片,5x5 等分排版,使用清晰分割线,不能出现文字、水印、编号或多余边框说明。", "以选中图片作为角色、服装、发型、场景光线和电影质感的唯一参考,生成一张 5x5 连贯分镜合成图。必须是 25 个连续镜头,体现从远景建立场景、人物动作推进、情绪变化、细节特写到结尾反应的完整微型叙事,不是同一张图重复裁切。保持同一角色身份和绿色服装一致,森林自然光线统一;每格景别、机位、主体大小或动作都要有明显差异。输出为单张 16:9 图片,5x5 等分排版,使用清晰分割线,不能出现文字、水印、编号或多余边框说明。",
], ],
"character-three-view": [ "character-three-view": [
"以选中图片作为唯一角色参考,生成一张角色三视图设定图。只输出正面、侧面、背面 3 个完整站姿人物,三人横向并排在同一张 16:9 画面中,白色或浅灰干净背景。必须保持同一角色身份、五官、发型、绿色服装、材质、体型比例和配饰一致;正面、侧面、背面视角要清晰可辨,人物全身完整入画,站姿自然。禁止生成 3x3 九宫格、多机位分镜、黑色分割线、重复头像、文字、水印、编号、环境场景或额外人物。",
"以输入角色图为唯一角色参考,生成正面、侧面、背面三视图角色设定图。保持五官、发型、服装、颜色和体型一致,干净白底,三视图水平排布,适合后续视频角色一致性使用。", "以输入角色图为唯一角色参考,生成正面、侧面、背面三视图角色设定图。保持五官、发型、服装、颜色和体型一致,干净白底,三视图水平排布,适合后续视频角色一致性使用。",
], ],
}; };

View File

@ -0,0 +1,116 @@
/** Directed canvas edges are live inputs, not a cache of earlier request URLs. */
export type CanvasConnection = { id: string; source: string; target: string };
export type ConnectionNode = {
id: string;
title?: string;
kind?: string;
inputType?: string;
draftValues?: Record<string, any>;
};
export function connectionError(nodes: ConnectionNode[], edges: CanvasConnection[], source: string, target: string) {
if (source === target) return "不能连接到同一个节点";
const from = nodes.find(node => node.id === source), to = nodes.find(node => node.id === target);
if (!from || !to) return "连接的节点已不存在";
if ([from, to].some(node => node.kind === "group" || node.draftValues?.action === "TOOLBOX_TEMPLATE_GROUP"))
return "请连接分组内的具体节点,分组框不是素材输入";
if (edges.some(edge => edge.source === source && edge.target === target)) return "这两个节点已经连接";
const pending = [target], seen = new Set<string>();
while (pending.length) {
const id = pending.pop()!;
if (id === source) return "这条连线会形成循环依赖,请调整连接方向";
if (seen.has(id)) continue;
seen.add(id);
pending.push(...edges.filter(edge => edge.source === id).map(edge => edge.target));
}
return null;
}
export function connectedInputNodes<T extends ConnectionNode>(nodes: T[], edges: CanvasConnection[], target: string): T[] {
const seen = new Set<string>();
return edges.filter(edge => edge.target === target).flatMap(edge => {
if (seen.has(edge.source)) return [];
seen.add(edge.source);
const node = nodes.find(item => item.id === edge.source);
if (!node) throw Error("上游节点已删除,请移除失效连线后重试");
// Also reject cycles restored from an older saved canvas.
const remaining = edges.filter(item => item.id !== edge.id);
const error = connectionError(nodes, remaining, edge.source, target);
if (error && !error.includes("已经连接")) throw Error(error);
return [node];
});
}
export function connectedText(node: ConnectionNode) {
if (node.inputType !== "text" && node.kind !== "script" && node.kind !== "text") return "";
const content = node.draftValues?.content;
if (typeof content === "string") return content.trim();
if (Array.isArray(content)) return content.filter(value => typeof value === "string").join("\n").trim();
const rows = node.draftValues?.rows;
return Array.isArray(rows) ? rows.map(row => [row.shot, row.sceneDescription, row.dialogue, row.imagePrompt, row.motionPrompt].filter(Boolean).join(" · ")).join("\n").trim() : "";
}
export function assertConnectedInputsReady(nodes: ConnectionNode[]) {
for (const node of nodes) {
const draft = node.draftValues || {};
if (["pending", "running", "queued", "failed", "cancelled", "canceled", "interrupted"].includes(draft.generationStatus))
throw Error(`上游「${node.title || node.id}」尚未成功完成,请先完成该节点`);
if (node.inputType === "text" || node.kind === "script" || node.kind === "text") {
if (!connectedText(node)) throw Error(`上游「${node.title || node.id}」没有文本内容`);
} else if (["image", "video", "audio"].includes(node.inputType || "")) {
// sourceUrl/poster is an input preview; it is never proof of a generated output.
const output = draft.mediaUrl || draft.resultImage || draft.url?.[0] || draft.thumbnailUrl;
if (!output) throw Error(`上游「${node.title || node.id}」尚无可用素材,请先上传或生成`);
}
}
}
const inputStart = "\n\n<canvas-connected-text>\n";
const inputEnd = "\n</canvas-connected-text>";
export function withConnectedText(prompt: string, nodes: ConnectionNode[]) {
// Remove only a section created by this resolver, so editing/disconnecting an
// upstream text node replaces its previous content on a subsequent run.
const start = prompt.indexOf(inputStart);
const base = start >= 0 && prompt.endsWith(inputEnd) ? prompt.slice(0, start) : prompt;
const text = nodes.map(connectedText).filter(Boolean).join("\n\n");
return text ? `${base.trim()}${inputStart}${text}${inputEnd}` : base.trim();
}
export function connectedImageUrls(lists: string[][], extra: string[] = []) {
// Keep edge order and identical URLs in separate slots (first/last frame).
return [...lists.flat(), ...extra].map(url => url.trim()).filter(Boolean);
}
export function connectionTypeError(source: ConnectionNode, target: ConnectionNode) {
const type = source.inputType || source.draftValues?.mediaType || (source.kind === "text" || source.kind === "script" ? "text" : "");
const draft = target.draftValues || {};
const key = draft.functionKey || draft.params?.functionKey || draft.targetPreset;
const nodeType = draft.nodeType;
if (source.kind === "material-effect" || source.kind === "material-role")
return target.inputType === "video" && nodeType !== "smart-edit" ? null : "角色和视频特效素材只能连接到视频生成节点";
const allowed = nodeType === "smart-edit" || nodeType === "video-clip" || draft.action === "VIDEO_HD_UPSCALE" ? ["video"]
: target.inputType === "image" ? ["image", "text"]
: target.inputType === "video" ? (key === "audio-to-video" ? ["image", "text", "audio"] : ["image", "text"])
: target.inputType === "audio" ? ["text"]
: target.inputType === "text" || target.kind === "script" || target.kind === "text" ? ["image", "video", "text"]
: null;
return allowed && !allowed.includes(type) ? "此节点不能消费该类型的输入,请连接匹配的素材或文本节点" : null;
}
export function videoInputLimitError(mode: string, count: number) {
if (mode === "text-to-video" && count) return "已连接图片,请切换图生视频或全能参考模式后生成";
if (mode === "first-frame" && count > 1) return "首帧模式只能使用 1 张图片,请移除多余连线或切换模式";
if (mode === "first-last-frame" && count > 2) return "首尾帧模式只能使用 2 张图片,请移除多余连线或切换模式";
if (mode === "reference-images" && count > 8) return "全能参考最多使用 8 张图片,请减少输入";
return null;
}
export function requireResolvedReferenceImages(urls: string[], expectedCount: number) {
const images = urls.map(url => url.trim());
if (images.length !== expectedCount || images.some(url => !url))
throw Error("部分宫格参考图准备失败,尚未提交生成,请重试;不会使用整张原图代替");
return images;
}

View File

@ -0,0 +1,333 @@
import {
assertConnectedInputsReady,
connectedInputNodes,
connectedText,
connectionTypeError,
videoInputLimitError,
withConnectedText,
type ConnectionNode,
type CanvasConnection,
} from "./connections";
import { groupMembers, isCanvasGroup, type GroupNode } from "./groups";
import { hasRecoverableCanvasTask } from "./task-recovery";
import type { CanvasTaskRequest, CanvasGenerationTask } from "./task-contract";
export function executableGroupNode(node: GroupNode) {
const d = node.draftValues || {},
action = String(d.action || "");
if (isCanvasGroup(node) || node.kind?.startsWith("material-")) return false;
if (
d.generationTaskId ||
d.audioParams ||
/GENERATE|UPSCALE|STORY_ANALYSIS|SMART_EDIT/.test(action)
)
return true;
if (
/_RESOURCE$/.test(action) ||
(node.kind === "text" &&
connectedText(node) &&
!d.params?.prompt &&
!d.prompt)
)
return false;
return (
["model", "workflow", "script", "preset"].includes(node.kind || "") ||
!!d.params?.prompt
);
}
export function compileGroupRun(
nodes: GroupNode[],
edges: CanvasConnection[],
groupId: string,
projectId: string,
prepare: (node: GroupNode) => Pick<CanvasTaskRequest, "kind" | "params">,
uuid: () => string
) {
const members = groupMembers(nodes, groupId).filter(executableGroupNode);
if (!members.length) throw Error("编组没有可执行节点");
if (members.length > 40) throw Error("整组最多执行 40 个节点");
const runId = uuid(),
ids = new Map(members.map((n) => [n.id, uuid()]));
const requests: CanvasTaskRequest[] = members.map((node) => {
const inputs = connectedInputNodes(nodes, edges, node.id).map((source) => {
const error = connectionTypeError(source, node);
if (error) throw Error(`${node.title}:${error}`);
if (!ids.has(source.id)) assertConnectedInputsReady([source]);
if (source.draftValues?.gridSplit?.selectedIndices?.length)
throw Error("编组执行前请将选中的宫格保存为独立图片节点");
return { node: source, requestId: ids.get(source.id) };
});
return {
...prepare(node),
requestId: ids.get(node.id)!,
projectId,
node: node as CanvasTaskRequest["node"],
edges: edges.filter((e) => e.target === node.id),
workflow: { runId, groupId, inputs },
};
});
const expanded = requests.flatMap((request) => {
if (request.kind !== "smart-edit" || request.params.autoRender !== true)
return [request];
const planNodeId = `group-plan-${uuid()}`,
planRequestId = uuid(),
planNode = {
...request.node,
id: planNodeId,
title: `${request.node.title} · 选片方案`,
};
const { autoRender, ...params } = request.params;
const plan = {
...request,
node: planNode,
requestId: planRequestId,
params,
hiddenNodeIds: [planNodeId],
edges: request.edges.map((e) => ({
...e,
id: uuid(),
target: planNodeId,
})),
};
const render = {
...request,
params: { operation: "render" },
hiddenNodeIds: [planNodeId],
workflow: {
...request.workflow!,
inputs: [
{
node: planNode,
requestId: planRequestId,
output: "edit-plan" as const,
},
],
},
edges: [{ id: uuid(), source: planNodeId, target: request.node.id }],
};
return [plan, render];
});
if (expanded.length > 40)
throw Error("包含选片阶段后整组超过 40 个任务,请拆分编组");
validateGroupBatch(expanded);
return expanded;
}
export function validateGroupBatch(requests: CanvasTaskRequest[]) {
if (!requests.length || requests.length > 40) throw Error("整组任务数量无效");
const first = requests[0],
map = new Map(requests.map((r) => [r.requestId, r]));
if (
map.size !== requests.length ||
new Set(requests.map((r) => r.node.id)).size !== requests.length
)
throw Error("重复的整组任务");
for (const r of requests) {
if (
!r.workflow ||
r.projectId !== first.projectId ||
r.workflow.runId !== first.workflow?.runId ||
r.workflow.groupId !== first.workflow?.groupId
)
throw Error("整组任务不属于同一画布和编组");
for (const input of r.workflow.inputs) {
if (
!r.edges.some(
(e) => e.source === input.node.id && e.target === r.node.id
)
)
throw Error("依赖缺少对应连线");
if (input.output === "edit-plan") {
const source = input.requestId ? map.get(input.requestId) : null;
if (
r.kind !== "smart-edit" ||
r.params.operation !== "render" ||
source?.kind !== "smart-edit" ||
source.params.operation !== "plan"
)
throw Error("剪辑方案依赖无效");
} else {
const error = connectionTypeError(input.node, r.node);
if (error) throw Error(error);
}
if (
input.requestId &&
map.get(input.requestId)?.node.id !== input.node.id
)
throw Error("依赖不属于本次编组执行");
if (!input.requestId) assertConnectedInputsReady([input.node]);
}
if (
r.edges.some(
(e) =>
e.target !== r.node.id ||
!r.workflow!.inputs.some((i) => i.node.id === e.source)
)
)
throw Error("连线缺少对应输入");
}
const visited = new Set<string>(),
visiting = new Set<string>();
const visit = (id: string) => {
if (visiting.has(id)) throw Error("编组存在循环依赖");
if (visited.has(id)) return;
visiting.add(id);
for (const input of map.get(id)!.workflow!.inputs)
if (input.requestId) visit(input.requestId);
visiting.delete(id);
visited.add(id);
};
for (const id of map.keys()) visit(id);
}
export function groupDependencyState(
request: CanvasTaskRequest,
tasks: CanvasGenerationTask[]
) {
const dependencies =
request.workflow?.inputs
.filter((i) => i.requestId)
.map((i) =>
tasks.find(
(t) =>
t.requestId === i.requestId &&
t.request.workflow?.runId === request.workflow?.runId
)
) || [];
if (
dependencies.some(
(t) =>
!t ||
["failed", "cancelled"].includes(t.status) ||
(t.status === "interrupted" && !hasRecoverableCanvasTask(t))
)
)
return "failed";
return dependencies.every((t) => t?.status === "succeeded")
? "ready"
: "waiting";
}
export function resolveGroupInputs(
request: CanvasTaskRequest,
tasks: CanvasGenerationTask[]
): Record<string, unknown> {
if (!request.workflow) return request.params;
if (groupDependencyState(request, tasks) !== "ready")
throw Error("编组上游任务尚未成功完成");
const planInput = request.workflow.inputs.find(
(i) => i.output === "edit-plan"
);
if (planInput) {
const plan = tasks.find((t) => t.requestId === planInput.requestId)?.result
?.plans?.[0];
if (!plan) throw Error("上游未返回剪辑方案");
return { ...request.params, plan };
}
const nodes = request.workflow.inputs.map((input) => {
if (!input.requestId) return input.node;
const task = tasks.find((t) => t.requestId === input.requestId)!;
const data = task.result || {};
const urls =
task.request.kind === "image" || task.request.kind === "image-operation"
? (data.images || [])
.map((i: any) => i.originalUrl || i.thumbnailUrl)
.filter(Boolean)
: data.url
? [data.url]
: [];
const text = data.raw || data.summary || (data.agentPlan || []).join("\n");
return {
...input.node,
draftValues: {
...input.node.draftValues,
generationStatus: "complete",
mediaUrl: urls[0],
url: urls,
content: text,
rows: data.analysisRows || data.shotIdeas,
},
};
});
assertConnectedInputsReady(nodes);
const urls = (type: string) =>
nodes
.filter((n) => n.inputType === type)
.flatMap((n) =>
n.draftValues?.url?.length
? n.draftValues.url
: [
n.draftValues?.mediaUrl ||
n.draftValues?.resultImage ||
n.draftValues?.thumbnailUrl,
]
)
.filter(Boolean);
const roleImages = nodes
.filter((n) => n.kind === "material-role" && n.inputType !== "image")
.map((n) => n.draftValues?.thumbnailUrl || n.draftValues?.mediaUrl)
.filter(Boolean);
const images = [...urls("image"), ...roleImages],
videos = urls("video"),
audio = urls("audio");
const p: Record<string, unknown> = {
...request.params,
prompt: withConnectedText(String(request.params.prompt || ""), nodes),
};
if (request.kind === "image")
return {
...p,
referenceImage: images[0],
referenceImages: images.slice(1),
};
if (request.kind === "video") {
const error = videoInputLimitError(String(p.mode), images.length);
if (error) throw Error(error);
return {
...p,
prompt: [
p.prompt,
...nodes.map(
(n) => n.draftValues?.rolePrompt || n.draftValues?.effectPrompt
),
]
.filter(Boolean)
.join("\n\n"),
images,
audioUrls: audio,
};
}
if (request.kind === "analysis")
return { ...p, imageUrls: images, videoUrls: videos };
if (request.kind === "audio")
return {
...p,
text: [request.params.text, ...nodes.map(connectedText)]
.filter(Boolean)
.join("\n\n"),
};
if (request.kind === "video-upscale") {
if (videos.length !== 1) throw Error("视频高清需要一个上游视频");
return { ...p, url: videos[0] };
}
if (request.kind === "image-operation") {
if (images.length !== 1) throw Error("图片处理需要一个上游图片");
return { ...p, imageUrl: images[0] };
}
if (request.kind === "smart-edit") {
if (request.params.operation === "plan") {
if (!videos.length) throw Error("智能剪辑没有已就绪的视频输入");
return {
...p,
assets: videos.map((url: string, i: number) => ({
id: `group-asset-${i + 1}`,
name:
nodes.filter((n) => n.inputType === "video")[i]?.title ||
`素材 ${i + 1}`,
url,
})),
};
}
const plan = request.params.plan as any;
if (videos.some((url) => !plan?.assets?.some((a: any) => a.url === url)))
throw Error("剪辑方案与当前连线素材不一致,请重新确认方案");
return p;
}
throw Error("编组节点暂不支持执行");
}

398
lib/video-canvas/groups.ts Normal file
View File

@ -0,0 +1,398 @@
import { connectionError } from "./connections";
import type { CanvasConnection, ConnectionNode } from "./connections";
export type GroupNode = ConnectionNode & {
x: number;
y: number;
width: number;
height: number;
title: string;
};
export const isCanvasGroup = (node: ConnectionNode) =>
node.draftValues?.action === "TOOLBOX_TEMPLATE_GROUP";
export const parentGroup = (node: ConnectionNode) =>
String(node.draftValues?.parentGroupId || "");
export function groupMembers<T extends GroupNode>(
nodes: T[],
groupId: string,
deep = true
): T[] {
const result: T[] = [],
seen = new Set([groupId]);
const visit = (id: string) => {
for (const node of nodes.filter((n) => parentGroup(n) === id)) {
if (seen.has(node.id)) throw Error("编组包含循环成员关系");
seen.add(node.id);
result.push(node);
if (deep && isCanvasGroup(node)) visit(node.id);
}
};
visit(groupId);
return result;
}
export function groupBounds(nodes: GroupNode[]) {
if (!nodes.length) return { x: 0, y: 0, width: 400, height: 260 };
const x = Math.min(...nodes.map((n) => n.x)) - 32,
y = Math.min(...nodes.map((n) => n.y)) - 48;
return {
x,
y,
width: Math.max(...nodes.map((n) => n.x + n.width)) - x + 32,
height: Math.max(...nodes.map((n) => n.y + n.height)) - y + 32,
};
}
export function selectedRoots<T extends GroupNode>(nodes: T[], ids: string[]) {
const selected = new Set(ids);
return nodes.filter(
(node) =>
selected.has(node.id) &&
!nodes.some(
(group) =>
selected.has(group.id) &&
isCanvasGroup(group) &&
groupMembers(nodes, group.id).some((member) => member.id === node.id)
)
);
}
export function makeCanvasGroup<T extends GroupNode>(
nodes: T[],
ids: string[],
id: string,
title = "未命名编组"
) {
const members = selectedRoots(nodes, ids);
if (members.length < 2) throw Error("请至少选择两个节点进行编组");
const parents = new Set(members.map(parentGroup)),
parent = parents.size === 1 ? parentGroup(members[0]) : "";
const group = {
id,
kind: "model",
title,
...groupBounds(members),
accent: "#a0a0a0",
draftValues: {
action: "TOOLBOX_TEMPLATE_GROUP",
nodeType: "group",
parentGroupId: parent,
groupColor: "#a0a0a0",
groupVersion: 1,
},
};
const selected = new Set(members.map((n) => n.id));
return [
...nodes.map((n) =>
selected.has(n.id)
? { ...n, draftValues: { ...n.draftValues, parentGroupId: id } }
: n
),
group as unknown as T,
];
}
export function ungroupCanvasNodes<T extends GroupNode>(
nodes: T[],
groupId: string
) {
const group = nodes.find((n) => n.id === groupId);
if (!group || !isCanvasGroup(group)) throw Error("未找到编组");
return nodes
.filter((n) => n.id !== groupId)
.map((n) =>
parentGroup(n) === groupId
? {
...n,
draftValues: {
...n.draftValues,
parentGroupId: parentGroup(group),
},
}
: n
);
}
export function moveCanvasSelection<T extends GroupNode>(
nodes: T[],
ids: string[],
dx: number,
dy: number
) {
const moving = new Set(ids);
for (const id of ids)
if (nodes.some((n) => n.id === id && isCanvasGroup(n)))
for (const child of groupMembers(nodes, id)) moving.add(child.id);
let result = nodes.map((n) =>
moving.has(n.id) ? { ...n, x: n.x + dx, y: n.y + dy } : n
);
// Expand stationary ancestor frames when a member is dragged outside them.
for (let pass = 0; pass < nodes.length; pass++) {
let changed = false;
result = result.map((group) => {
if (!isCanvasGroup(group) || moving.has(group.id)) return group;
const children = groupMembers(result, group.id, false);
if (!children.length) return group;
const bound = groupBounds(children),
x = Math.min(group.x, bound.x),
y = Math.min(group.y, bound.y);
const width = Math.max(group.x + group.width, bound.x + bound.width) - x,
height = Math.max(group.y + group.height, bound.y + bound.height) - y;
if (
x === group.x &&
y === group.y &&
width === group.width &&
height === group.height
)
return group;
changed = true;
return { ...group, x, y, width, height };
});
if (!changed) break;
}
return result;
}
export function layoutCanvasGroup<T extends GroupNode>(
nodes: T[],
groupId: string,
layout: "grid" | "horizontal" | "vertical"
) {
const group = nodes.find((n) => n.id === groupId);
if (!group) throw Error("编组不存在");
const members = groupMembers(nodes, groupId, false);
const cols =
layout === "horizontal"
? members.length
: layout === "vertical"
? 1
: Math.max(1, Math.ceil(Math.sqrt(members.length)));
const cellWidth = Math.max(180, ...members.map((n) => n.width)) + 48,
cellHeight = Math.max(120, ...members.map((n) => n.height)) + 64;
let result = nodes;
for (const [i, member] of members.entries())
result = moveCanvasSelection(
result,
[member.id],
group.x + 32 + (i % cols) * cellWidth - member.x,
group.y + 48 + Math.floor(i / cols) * cellHeight - member.y
);
const bounds = groupBounds(groupMembers(result, groupId, false));
return result.map((n) => (n.id === groupId ? { ...n, ...bounds } : n));
}
export function migrateLegacyGroups<T extends GroupNode>(nodes: T[]) {
// Old preset frames stored geometry but no membership. Infer once on load.
const groups = nodes.filter(
(n) => isCanvasGroup(n) && !n.draftValues?.groupVersion
);
return nodes.map((n) => {
if (isCanvasGroup(n))
return { ...n, draftValues: { ...n.draftValues, groupVersion: 1 } };
if (parentGroup(n)) return n;
const enclosing = groups
.filter(
(g) =>
n.x >= g.x &&
n.y >= g.y &&
n.x + n.width <= g.x + g.width &&
n.y + n.height <= g.y + g.height
)
.sort((a, b) => a.width * a.height - b.width * b.height)[0];
return enclosing
? { ...n, draftValues: { ...n.draftValues, parentGroupId: enclosing.id } }
: n;
});
}
export function exportCanvasGroup<T extends GroupNode>(
nodes: T[],
edges: CanvasConnection[],
groupId: string
) {
const group = nodes.find((n) => n.id === groupId);
if (!group) throw Error("编组不存在");
let all = [group, ...groupMembers(nodes, groupId)];
const ids = new Set(all.map((n) => n.id));
const externalIds = [
...new Set(
edges
.filter((e) => ids.has(e.target) && !ids.has(e.source))
.map((e) => e.source)
),
];
const inputs = externalIds.map((id, index) => {
const source = nodes.find((n) => n.id === id);
if (
!source ||
!["image", "video", "audio", "text"].includes(source.inputType || "")
)
throw Error("请先将组外角色、特效等上游节点加入编组,再保存模板");
return {
id,
title: `输入:${source.title}`.slice(0, 120),
kind: "input",
inputType: source.inputType,
x: group.x + 32,
y: group.y + 48 + index * 210,
width: 240,
height: 160,
accent: "#aaaaaa",
draftValues: {
action: `${source.inputType!.toUpperCase()}_RESOURCE`,
parentGroupId: group.id,
mediaType: source.inputType,
url: [],
content: [],
generated: false,
generationStatus: "idle",
templateInput: true,
},
} as unknown as T;
});
if (inputs.length) {
all = all.map((n) =>
n.id === group.id
? {
...n,
width: n.width + 280,
height: Math.max(n.height, inputs.length * 210 + 80),
}
: { ...n, x: n.x + 280 }
);
all.push(...inputs);
for (const input of inputs) ids.add(input.id);
}
return {
version: 1,
name: group.title,
nodes: all.map((n) => ({
...n,
draftValues: {
...n.draftValues,
params: n.draftValues?.params
? {
...n.draftValues.params,
taskId: undefined,
sourceNodeId: undefined,
}
: undefined,
...(n.id === groupId ? { parentGroupId: "" } : {}),
generationTaskId: undefined,
taskRequestId: undefined,
appliedTaskStatus: undefined,
generationStatus:
n.draftValues?.mediaUrl || n.draftValues?.content
? "complete"
: "idle",
generationError: undefined,
pendingLabel: undefined,
},
})),
edges: edges.filter((e) => ids.has(e.source) && ids.has(e.target)),
};
}
export function instantiateCanvasGroup<T extends GroupNode>(
template: { nodes: T[]; edges: CanvasConnection[] },
id: () => string,
x: number,
y: number
) {
if (
!template ||
!Array.isArray(template.nodes) ||
!template.nodes.length ||
template.nodes.length > 100
)
throw Error("编组模板节点数量无效");
if (
!Array.isArray(template.edges) ||
template.edges.length > 300 ||
new Set(template.nodes.map((n) => n.id)).size !== template.nodes.length ||
template.nodes.some(
(n) =>
!n.id ||
![n.x, n.y, n.width, n.height].every(Number.isFinite) ||
n.width <= 0 ||
n.height <= 0 ||
n.width > 10000 ||
n.height > 10000
)
)
throw Error("编组模板数据无效");
for (const node of template.nodes) {
if (
parentGroup(node) &&
!template.nodes.some(
(n) => n.id === parentGroup(node) && isCanvasGroup(n)
)
)
throw Error("编组模板成员关系无效");
if (isCanvasGroup(node)) groupMembers(template.nodes, node.id);
}
for (const edge of template.edges) {
const error = connectionError(
template.nodes,
template.edges.filter((e) => e !== edge),
edge.source,
edge.target
);
if (error) throw Error(error);
}
const map = new Map(template.nodes.map((n) => [n.id, id()]));
const root = template.nodes.find((n) => isCanvasGroup(n) && !parentGroup(n));
if (!root) throw Error("编组模板缺少根组");
if (
groupMembers(template.nodes, root.id).length !==
template.nodes.length - 1
)
throw Error("模板必须只有一个完整根组");
const nodes = template.nodes.map((n) => ({
...n,
id: map.get(n.id)!,
x: n.x + x - root.x,
y: n.y + y - root.y,
draftValues: {
...n.draftValues,
parentGroupId: map.get(parentGroup(n)) || "",
shotNodeIds: n.draftValues?.shotNodeIds
?.map((key: string) => map.get(key))
.filter(Boolean),
sourceNodeId: map.get(n.draftValues?.sourceNodeId) || undefined,
sourceVideoNodeId: map.get(n.draftValues?.sourceVideoNodeId) || undefined,
},
}));
return {
nodes,
edges: template.edges
.map((e) => ({
...e,
id: id(),
source: map.get(e.source)!,
target: map.get(e.target)!,
}))
.filter((e) => e.source && e.target),
groupId: map.get(root.id)!,
};
}
export function assignGroupMembers<T extends GroupNode>(
nodes: T[],
ids: string[],
groupId: string
) {
if (groupId && !nodes.some((n) => n.id === groupId && isCanvasGroup(n)))
throw Error("编组不存在");
const roots = selectedRoots(nodes, ids);
if (
roots.some(
(n) =>
n.id === groupId ||
(isCanvasGroup(n) &&
groupMembers(nodes, n.id).some((child) => child.id === groupId))
)
)
throw Error("不能将编组加入自身或自己的子组");
const selected = new Set(roots.map((n) => n.id));
return moveCanvasSelection(
nodes.map((n) =>
selected.has(n.id)
? { ...n, draftValues: { ...n.draftValues, parentGroupId: groupId } }
: n
),
[],
0,
0
);
}

View File

@ -0,0 +1,178 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { createHash, randomUUID } from "node:crypto";
import { readFile } from "node:fs/promises";
import path from "node:path";
import {
SECOND,
editPlanSchema,
planTimeline,
editableCaptions,
newCaptionStyle,
type EditPlan,
type TimelineCaption,
} from "./contract";
import {
materialize,
probeMedia,
ffmpeg,
mediaSafety,
prepareAudio,
pythonAnalysis,
} from "./local";
export async function processEditAudio(
operation: "separate" | "transcribe",
plan: EditPlan,
clipId: string | undefined,
userId: string,
dir: string,
progress: (stage: string) => Promise<void>,
upload: (file: string, suffix: string) => Promise<string>,
track: "video" | "audio" = "video",
keep: "both" | "vocals" | "background" = "both",
): Promise<EditPlan> {
const timeline = planTimeline(plan);
if (operation === "separate") {
const index = plan.clips.findIndex((c) => c.id === clipId);
const audio = track === "audio" ? plan.audioClips?.find((c) => c.id === clipId) : undefined;
const clip = track === "audio" ? audio : plan.clips[index];
if (!clip || (track === "video" && plan.clips[index].audioDetached))
throw Error("请选择可分离的音视频片段");
if ((plan.audioClips?.length || 0) + (keep === "both" ? 2 : 1) - (audio ? 1 : 0) > 24)
throw Error("分离需要空闲音轨,最多支持 24 个音频片段");
const asset = plan.assets.find((a) => a.id === clip.assetId)!;
if (!asset.hasAudio) throw Error("当前视频没有音轨");
await progress("准备人声分离素材");
const media = await materialize(asset.url, userId);
if (media.hash !== asset.hash) throw Error("原素材内容已变化,请重新解析");
const actual = await probeMedia(media.file);
if (!actual.hasAudio || Math.abs(actual.duration - asset.duration) > 100000)
throw Error("原音轨参数已变化,请重新解析");
const inputAudio = path.join(dir, "separation-input.wav");
await ffmpeg([
...mediaSafety,
"-ss",
String(clip.sourceIn / SECOND),
"-i",
media.file,
"-t",
String((clip.sourceOut - clip.sourceIn) / SECOND),
"-vn",
"-af",
"apad",
"-ac",
"2",
"-ar",
"44100",
"-c:a",
"pcm_f32le",
inputAudio,
]);
await progress("正在分离人声与背景声");
try {
await promisify(execFile)(
process.env.SMART_EDIT_PYTHON || "python3",
[path.resolve("services/smart-edit/separate.py"), inputAudio, dir],
{ timeout: 600000, maxBuffer: 2 * 1024 * 1024 },
);
} catch (e) {
const detail = String((e as { stderr?: string }).stderr || "");
throw Error(
detail.includes("尚未配置")
? "人声分离模型尚未配置"
: "人声分离失败或超时,原音轨已保留",
);
}
await progress("保存人声与背景声音轨");
const audioClips = (plan.audioClips || []).filter((c) => !audio || c.id !== audio.id);
const used = new Set([...plan.clips, ...audioClips].map((c) => c.assetId));
const assets = plan.assets.filter((a) => used.has(a.id));
const t = timeline.clips[index],
next = timeline.clips[index + 1];
for (const [stem, title] of [
["vocals", "人声"],
["background", "背景声"],
]) {
if (keep !== "both" && keep !== stem) continue;
const file = path.join(dir, stem + ".wav"),
metadata = await probeMedia(file);
if (Math.abs(metadata.duration - (clip.sourceOut - clip.sourceIn)) > 50000)
throw Error("分离音轨时长异常,原音轨已保留");
const id = randomUUID(),
bytes = await readFile(file);
assets.push({
id,
name: `${asset.name.slice(0, 180)} · ${title}`,
url: await upload(file, "wav"),
hash: createHash("sha256").update(bytes).digest("hex"),
subtitles: [],
...metadata,
});
audioClips.push({
id: randomUUID(),
assetId: id,
start: audio ? audio.start : t.start,
sourceIn: 0,
sourceOut: clip.sourceOut - clip.sourceIn,
speed: clip.speed,
gain: clip.gain,
muted: clip.muted,
fadeIn: audio ? audio.fadeIn : (t.overlap - t.padBefore) / SECOND,
fadeInStart: audio ? audio.fadeInStart : t.overlap ? t.padBefore / t.overlap : 0,
fadeOut: audio ? audio.fadeOut : next ? (next.overlap - t.padAfter) / SECOND : 0,
fadeOutEnd: audio ? audio.fadeOutEnd : next?.overlap ? t.padAfter / next.overlap : 0,
});
}
return editPlanSchema.parse({
...plan,
assets,
audioClips,
clips: plan.clips.map((c) =>
!audio && c.id === clip.id ? { ...c, audioDetached: true } : c,
),
});
}
const captions: TimelineCaption[] = editableCaptions(plan).filter(
(c) => !c.id.startsWith("asr-"),
);
let recognized = 0;
for (const [index, clip] of timeline.clips.entries()) {
const asset = plan.assets.find((a) => a.id === clip.assetId)!;
if (!asset.hasAudio) continue;
await progress(`自动转写 ${index + 1}/${plan.clips.length}`);
const media = await materialize(asset.url, userId);
if (media.hash !== asset.hash) throw Error("原素材内容已变化,请重新解析");
const actual = await probeMedia(media.file);
if (!actual.hasAudio || Math.abs(actual.duration - asset.duration) > 100000)
throw Error("原音轨参数已变化,请重新解析");
const audio = await prepareAudio(media.file, path.join(media.dir, media.hash + ".wav"));
const analysis = await pythonAnalysis({ version: 1, audio, transcribe: true }, media.dir);
for (const [j, s] of analysis.subtitles.entries()) {
const from = Math.max(s.start, clip.sourceIn),
to = Math.min(s.end, clip.sourceOut);
if (to <= from) continue;
const start = Math.round(clip.start + (from - clip.sourceIn) / clip.speed);
const end = Math.min(
timeline.duration,
Math.round(clip.start + (to - clip.sourceIn) / clip.speed),
);
if (end <= start) continue;
captions.push({
id: `asr-${clip.id}-${j}`,
start,
end,
text: s.text,
style: { ...newCaptionStyle },
});
recognized++;
}
}
if (!recognized) throw Error("未识别到有效人声,原字幕已保留");
if (captions.length > 500) throw Error("转写后字幕超过 500 条,请缩短素材");
return editPlanSchema.parse({
...plan,
subtitles: true,
captions: captions.sort((a, b) => a.start - b.start),
});
}

View File

@ -1,5 +1,9 @@
import { z } from "zod"; import { z } from "zod";
import { captionFont } from "./fonts";
export const SECOND = 1_000_000; export const SECOND = 1_000_000;
export const MAX_EDIT_SECONDS = 300;
export const editRatios = ["21:9", "16:9", "9:16", "1:1", "4:3", "3:4"] as const;
export const editResolutions = ["480p", "720p", "1080p"] as const;
const time = z const time = z
.number() .number()
.int() .int()
@ -14,6 +18,55 @@ const url = z
export const subtitleSchema = z export const subtitleSchema = z
.object({ start: time, end: time, text: z.string().max(500) }) .object({ start: time, end: time, text: z.string().max(500) })
.refine((x) => x.end > x.start); .refine((x) => x.end > x.start);
export const captionStyleSchema = z.object({
fontFamily: z
.string()
.max(180)
.refine((id) => Boolean(captionFont(id)), "未知字幕字体")
.optional(),
referenceHeight: z.union([z.literal(720), z.literal(1080)]).optional(),
fontSize: z.number().min(8).max(160).default(28),
color: z
.string()
.regex(/^#[0-9a-f]{6}$/i)
.default("#ffffff"),
outlineColor: z
.string()
.regex(/^#[0-9a-f]{6}$/i)
.default("#000000"),
outlineWidth: z.number().min(0).max(20).default(0),
opacity: z.number().min(0).max(1).default(1),
// Optional so existing edits keep their whole-caption opacity unchanged.
outlineOpacity: z.number().min(0).max(1).optional(),
spacing: z.number().min(-5).max(100).default(0),
x: z.number().min(0).max(1).optional(),
y: z.number().min(0).max(1).optional(),
});
export const timelineCaptionSchema = z
.object({
id,
start: time,
end: time,
text: z.string().max(500),
style: captionStyleSchema.optional(),
})
.refine((x) => x.end > x.start);
export type TimelineCaption = z.infer<typeof timelineCaptionSchema>;
export type CaptionStyle = z.infer<typeof captionStyleSchema>;
export const defaultCaptionStyle: CaptionStyle = captionStyleSchema.parse({});
export const newCaptionStyle: CaptionStyle = {
...defaultCaptionStyle,
fontFamily: "source-han",
referenceHeight: 1080,
fontSize: 56,
outlineColor: "#ffffff",
};
const transitionSchema = z.object({
duration: z.number().min(0).max(4),
// Centered transitions preserve the edit duration; absent keeps saved legacy overlaps.
alignment: z.enum(["overlap", "center"]).optional(),
type: z.enum(["fade", "fadeblack", "fadewhite"]),
});
export const assetInputSchema = z.object({ export const assetInputSchema = z.object({
id, id,
url, url,
@ -22,14 +75,21 @@ export const assetInputSchema = z.object({
}); });
export const editAssetSchema = assetInputSchema.extend({ export const editAssetSchema = assetInputSchema.extend({
duration: time, duration: time,
width: z.number().int().positive().max(8192), width: z.number().int().min(0).max(8192),
height: z.number().int().positive().max(8192), height: z.number().int().min(0).max(8192),
hasAudio: z.boolean(), hasAudio: z.boolean(),
hash: z.string().regex(/^[a-f0-9]{64}$/), hash: z.string().regex(/^[a-f0-9]{64}$/),
}); });
export const candidateSchema = z export const candidateSchema = z
.object({ id, assetId: id, start: time, end: time, text: z.string().max(1500).default("") }) .object({ id, assetId: id, start: time, end: time, text: z.string().max(1500).default("") })
.refine((x) => x.end - x.start >= 200000); .refine((x) => x.end - x.start >= 200000);
export const videoTransformSchema = z.object({
x: z.number().min(-1).max(1).default(0),
y: z.number().min(-1).max(1).default(0),
scale: z.number().min(0.1).max(3).default(1),
});
export type VideoTransform = z.infer<typeof videoTransformSchema>;
export const defaultVideoTransform: VideoTransform = { x: 0, y: 0, scale: 1 };
export const editClipSchema = z export const editClipSchema = z
.object({ .object({
id, id,
@ -37,19 +97,43 @@ export const editClipSchema = z
sourceIn: time, sourceIn: time,
sourceOut: time, sourceOut: time,
speed: z.number().min(0.5).max(2).default(1), speed: z.number().min(0.5).max(2).default(1),
gain: z.number().min(0).max(1).default(1), gain: z.number().min(0).max(4).default(1),
muted: z.boolean().optional(),
label: z.string().max(300).default(""), label: z.string().max(300).default(""),
// The transition belongs to the incoming clip, so it follows that clip when reordered.
transition: transitionSchema.optional(),
transform: videoTransformSchema.optional(),
audioDetached: z.boolean().optional(),
}) })
.refine((x) => x.sourceOut - x.sourceIn >= 200000); .refine((x) => x.sourceOut - x.sourceIn >= 200000);
export const audioClipSchema = z
.object({
id,
assetId: id,
start: time,
sourceIn: time,
sourceOut: time,
speed: z.number().min(0.5).max(2).default(1),
gain: z.number().min(0).max(4).default(1),
muted: z.boolean().optional(),
fadeIn: z.number().min(0).max(5).default(0),
fadeOut: z.number().min(0).max(5).default(0),
fadeInStart: z.number().min(0).max(1).optional(),
fadeOutEnd: z.number().min(0).max(1).optional(),
})
.refine((c) => c.sourceOut - c.sourceIn >= 200000);
export type AudioClip = z.infer<typeof audioClipSchema>;
export const editPlanSchema = z.object({ export const editPlanSchema = z.object({
version: z.literal(1), version: z.literal(1),
title: z.string().max(200), title: z.string().max(200),
assets: z.array(editAssetSchema).min(1).max(6), assets: z.array(editAssetSchema).min(1).max(36),
clips: z.array(editClipSchema).min(1).max(12), clips: z.array(editClipSchema).min(1).max(12),
ratio: z.enum(["16:9", "9:16", "1:1"]), ratio: z.enum(editRatios),
resolution: z.enum(["720p", "1080p"]).default("720p"), resolution: z.enum(editResolutions).default("720p"),
transition: z.number().min(0).max(0.5).default(0), transition: z.number().min(0).max(0.5).default(0),
subtitles: z.boolean().default(true), subtitles: z.boolean().default(true),
captions: z.array(timelineCaptionSchema).max(500).optional(),
audioClips: z.array(audioClipSchema).max(24).optional(),
music: z music: z
.object({ url, gain: z.number().min(0).max(1).default(0.2), start: time.default(0) }) .object({ url, gain: z.number().min(0).max(1).default(0.2), start: time.default(0) })
.optional(), .optional(),
@ -61,12 +145,12 @@ export type EditCandidate = z.infer<typeof candidateSchema>;
export const smartEditRequestSchema = z.discriminatedUnion("operation", [ export const smartEditRequestSchema = z.discriminatedUnion("operation", [
z.object({ z.object({
operation: z.literal("plan"), operation: z.literal("plan"),
assets: z.array(assetInputSchema).min(1).max(6), assets: z.array(assetInputSchema).min(1).max(12),
mode: z.enum(["montage", "talk", "explain", "ads"]).default("montage"), mode: z.enum(["sequence", "auto", "montage", "talk", "explain", "ads"]).default("montage"),
prompt: z.string().max(3000).default(""), prompt: z.string().max(3000).default(""),
targetDuration: z.number().int().min(3).max(90).default(20), targetDuration: z.number().int().min(0).max(MAX_EDIT_SECONDS).default(20),
ratio: z.enum(["16:9", "9:16", "1:1"]).default("16:9"), ratio: z.enum(editRatios).default("16:9"),
resolution: z.enum(["720p", "1080p"]).default("720p"), resolution: z.enum(editResolutions).default("720p"),
removeSilence: z.boolean().default(false), removeSilence: z.boolean().default(false),
transcribe: z.boolean().default(false), transcribe: z.boolean().default(false),
semantic: z.boolean().default(true), semantic: z.boolean().default(true),
@ -78,6 +162,17 @@ export const smartEditRequestSchema = z.discriminatedUnion("operation", [
beatsPerCut: z.enum(["2", "4"]).default("4"), beatsPerCut: z.enum(["2", "4"]).default("4"),
variants: z.number().int().min(1).max(3).default(1), variants: z.number().int().min(1).max(3).default(1),
}), }),
z.object({
operation: z.literal("separate"),
plan: editPlanSchema,
clipId: id,
track: z.enum(["video", "audio"]).default("video"),
keep: z.enum(["both", "vocals", "background"]).default("both"),
}),
z.object({
operation: z.literal("transcribe"),
plan: editPlanSchema,
}),
z.object({ z.object({
operation: z.literal("render"), operation: z.literal("render"),
plan: editPlanSchema, plan: editPlanSchema,
@ -85,38 +180,102 @@ export const smartEditRequestSchema = z.discriminatedUnion("operation", [
}), }),
]); ]);
export type SmartEditRequest = z.infer<typeof smartEditRequestSchema>; export type SmartEditRequest = z.infer<typeof smartEditRequestSchema>;
export function editOutputSize(plan: Pick<EditPlan, "ratio" | "resolution">) {
const size = Number.parseInt(plan.resolution, 10),
[w, h] = plan.ratio.split(":").map(Number);
const even = (v: number) => Math.round(v / 2) * 2;
return w >= h
? { width: even((size * w) / h), height: size }
: { width: size, height: even((size * h) / w) };
}
export function planTimeline(input: EditPlan) { export function planTimeline(input: EditPlan) {
const plan = editPlanSchema.parse(input), const plan = editPlanSchema.parse(input),
ids = new Set<string>(); ids = new Set<string>();
const durations = plan.clips.map((c) =>
Math.round(
(Math.round(((c.sourceOut - c.sourceIn) / c.speed / SECOND) * 30) * SECOND) / 30,
),
);
const centered = plan.clips.map((c) => c.transition?.alignment === "center");
const overlaps = plan.clips.map((c, i) =>
i
? Math.min(
Math.round((c.transition?.duration ?? plan.transition) * SECOND),
centered[i] ? durations[i] * 2 : Math.floor(durations[i] / 3),
centered[i] ? durations[i - 1] * 2 : Math.floor(durations[i - 1] / 3),
)
: 0,
);
// Neighboring transitions cannot consume the same frames in a short middle clip.
for (let i = 0; i < durations.length; i++) {
const incoming = overlaps[i] / (centered[i] ? 2 : 1),
outgoing = (overlaps[i + 1] || 0) / (centered[i + 1] ? 2 : 1);
if (incoming + outgoing > durations[i]) {
const factor = durations[i] / (incoming + outgoing);
overlaps[i] = Math.floor(overlaps[i] * factor);
if (i + 1 < overlaps.length) overlaps[i + 1] = Math.floor(overlaps[i + 1] * factor);
}
}
const before = overlaps.map((d, i) =>
centered[i] ? Math.round((Math.floor(((d / SECOND) * 30) / 2) * SECOND) / 30) : 0,
);
const after = overlaps.map((d, i) => (centered[i] ? d - before[i] : 0));
let cursor = 0; let cursor = 0;
const clips = plan.clips.map((clip, index) => { const clips = plan.clips.map((clip, index) => {
if (ids.has(clip.id)) throw Error("片段 ID 重复"); if (ids.has(clip.id)) throw Error("片段 ID 重复");
ids.add(clip.id); ids.add(clip.id);
const asset = plan.assets.find((a) => a.id === clip.assetId); const asset = plan.assets.find((a) => a.id === clip.assetId);
if (!asset || clip.sourceOut > asset.duration) throw Error("片段超出原始素材范围"); if (!asset || !asset.width || !asset.height || clip.sourceOut > asset.duration)
const duration = Math.round( throw Error("片段超出原始素材范围");
(Math.round(((clip.sourceOut - clip.sourceIn) / clip.speed / SECOND) * 30) * SECOND) / 30, const duration = durations[index],
); overlap = overlaps[index];
const previous = index ? plan.clips[index - 1] : null; if (!centered[index]) cursor -= overlap;
const overlap = previous
? Math.min(
Math.round(plan.transition * SECOND),
Math.floor(duration / 3),
Math.floor((previous.sourceOut - previous.sourceIn) / previous.speed / 3),
)
: 0;
cursor -= overlap;
const start = cursor; const start = cursor;
cursor += duration; cursor += duration;
return { ...clip, start, end: cursor, duration, overlap }; const padBefore = before[index],
padAfter = after[index + 1] || 0;
return {
...clip,
start,
end: cursor,
duration,
overlap,
padBefore,
padAfter,
renderStart: start - padBefore,
renderEnd: cursor + padAfter,
transitionStart: start - padBefore,
transitionType: clip.transition?.type || "fade",
};
}); });
if (new Set(plan.assets.map((a) => a.id)).size !== plan.assets.length) if (new Set(plan.assets.map((a) => a.id)).size !== plan.assets.length)
throw Error("素材 ID 重复"); throw Error("素材 ID 重复");
if (cursor > 90 * SECOND || cursor < 200000) throw Error("成片时长需在 0.2–90 秒内"); if (plan.captions && new Set(plan.captions.map((s) => s.id)).size !== plan.captions.length)
return { clips, duration: cursor }; throw Error("字幕 ID 重复");
if (cursor > MAX_EDIT_SECONDS * SECOND || cursor < 200000)
throw Error("成片时长需在 0.2–300 秒内");
const audioClips = (plan.audioClips || []).map((c) => {
const asset = plan.assets.find((a) => a.id === c.assetId);
if (!asset?.hasAudio || c.sourceOut > asset.duration)
throw Error("独立音轨素材无效或超出范围");
const duration = Math.round((c.sourceOut - c.sourceIn) / c.speed);
return { ...c, duration, end: c.start + duration };
});
if (new Set(audioClips.map((c) => c.id)).size !== audioClips.length)
throw Error("独立音轨 ID 重复");
cursor = Math.max(cursor, ...audioClips.map((c) => c.end));
if (cursor > MAX_EDIT_SECONDS * SECOND) throw Error("成片时长需在 0.2–300 秒内");
return { clips, audioClips, duration: cursor };
} }
export function compiledSubtitles(plan: EditPlan) { export function compiledSubtitles(plan: EditPlan) {
if (!plan.subtitles) return []; if (!plan.subtitles) return [];
if (plan.captions) {
const duration = planTimeline(plan).duration;
return plan.captions.flatMap((s) => {
const end = Math.min(s.end, duration);
return end > s.start ? [{ ...s, end }] : [];
});
}
return planTimeline(plan).clips.flatMap((clip) => return planTimeline(plan).clips.flatMap((clip) =>
plan.assets plan.assets
.find((a) => a.id === clip.assetId)! .find((a) => a.id === clip.assetId)!
@ -124,16 +283,63 @@ export function compiledSubtitles(plan: EditPlan) {
const a = Math.max(s.start, clip.sourceIn), const a = Math.max(s.start, clip.sourceIn),
b = Math.min(s.end, clip.sourceOut); b = Math.min(s.end, clip.sourceOut);
if (b <= a) return []; if (b <= a) return [];
// A crossfade's incoming subtitle starts when the outgoing subtitle ends. // Only legacy overlapping edits delay incoming captions until the outgoing clip ends.
const start = Math.max( const start = Math.max(
clip.start + clip.overlap, clip.start + (clip.transition?.alignment === "center" ? 0 : clip.overlap),
clip.start + Math.round((a - clip.sourceIn) / clip.speed), clip.start + Math.round((a - clip.sourceIn) / clip.speed),
); );
const end = Math.min(clip.end, clip.start + Math.round((b - clip.sourceIn) / clip.speed)); const end = Math.min(
clip.end,
clip.start + Math.round((b - clip.sourceIn) / clip.speed),
);
return end > start ? [{ start, end, text: s.text }] : []; return end > start ? [{ start, end, text: s.text }] : [];
}), }),
); );
} }
export function editableCaptions(plan: EditPlan): TimelineCaption[] {
if (plan.captions) return plan.captions;
return compiledSubtitles({ ...plan, subtitles: true }).map((s, i) => ({
...s,
id: `caption-${i}`,
}));
}
export function subtitlesToAss(plan: EditPlan, width: number, height: number) {
const stamp = (v: number) => {
const cs = Math.round(v / 10000);
return `${Math.floor(cs / 360000)}:${String(Math.floor(cs / 6000) % 60).padStart(2, "0")}:${String(Math.floor(cs / 100) % 60).padStart(2, "0")}.${String(cs % 100).padStart(2, "0")}`;
};
const color = (hex: string, opacity: number) =>
`&H${Math.round((1 - opacity) * 255)
.toString(16)
.padStart(2, "0")}${hex.slice(5, 7)}${hex.slice(3, 5)}${hex.slice(1, 3)}`;
// Saved legacy styles use 720px; new reference-aligned styles explicitly use 1080px.
const rows = compiledSubtitles(plan) as Array<{
start: number;
end: number;
text: string;
style?: CaptionStyle;
}>;
const styles = rows.map((s, i) => {
const style = s.style || defaultCaptionStyle;
const scale = height / (style.referenceHeight || 720);
return `Style: c${i},${captionFont(style.fontFamily)?.family || "Noto Sans CJK SC"},${style.fontSize * scale},${color(style.color, style.opacity)},&H00FFFFFF,${color(style.outlineColor, style.opacity * (style.outlineOpacity ?? 1))},&HFF000000,0,0,0,0,100,100,${style.spacing * scale},0,1,${style.outlineWidth * scale},0,2,20,20,${Math.round(height * 0.05)},1`;
});
const events = rows.map((s, i) => {
const text = s.text
.replace(/\\/g, "\")
.replace(/\{/g, "{")
.replace(/\}/g, "}")
.replace(/\r/g, "")
.replace(/\n/g, "\\N");
const style = s.style || defaultCaptionStyle;
const position =
style.x !== undefined || style.y !== undefined
? `{\\pos(${Math.round((style.x ?? 0.5) * width)},${Math.round((style.y ?? 0.95) * height)})}`
: "";
return `Dialogue: 0,${stamp(s.start)},${stamp(s.end)},c${i},,0,0,0,,${position}${text}`;
});
return `[Script Info]\nScriptType: v4.00+\nPlayResX: ${width}\nPlayResY: ${height}\nWrapStyle: 0\nScaledBorderAndShadow: yes\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n${styles.join("\n")}\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n${events.join("\n")}\n`;
}
export function subtitlesToSrt(subtitles: z.infer<typeof subtitleSchema>[]) { export function subtitlesToSrt(subtitles: z.infer<typeof subtitleSchema>[]) {
const stamp = (v: number) => { const stamp = (v: number) => {
const ms = Math.round(v / 1000); const ms = Math.round(v / 1000);
@ -194,22 +400,39 @@ export function buildEditPlans(
if (!ordered.length) throw Error("没有选中可用片段"); if (!ordered.length) throw Error("没有选中可用片段");
return Array.from({ length: params.mode === "ads" ? params.variants : 1 }, (_, variant) => { return Array.from({ length: params.mode === "ads" ? params.variants : 1 }, (_, variant) => {
const list = variant const list = variant
? [...ordered.slice(variant % ordered.length), ...ordered.slice(0, variant % ordered.length)] ? [
...ordered.slice(variant % ordered.length),
...ordered.slice(0, variant % ordered.length),
]
: ordered; : ordered;
const clips: EditPlan["clips"] = []; const clips: EditPlan["clips"] = [];
let duration = 0; let duration = 0;
const target =
params.mode === "sequence"
? MAX_EDIT_SECONDS
: params.targetDuration ||
Math.min(
MAX_EDIT_SECONDS,
assets.reduce((sum, a) => sum + a.duration / SECOND, 0),
);
const visualLength = Math.max(5, Math.min(30, target / 12)) * SECOND;
for (const candidate of list) { for (const candidate of list) {
if (duration >= params.targetDuration * SECOND || clips.length >= 12) break; if (duration >= target * SECOND || clips.length >= 12) break;
const available = candidate.end - candidate.start, const available = candidate.end - candidate.start,
remaining = params.targetDuration * SECOND - duration; remaining = target * SECOND - duration;
const speech = params.mode === "talk" || params.mode === "explain"; const speech = params.mode === "talk" || params.mode === "explain";
// Speech selections keep their sentence boundary even if it slightly misses requested length. // Speech selections keep their sentence boundary even if it slightly misses requested length.
let length = speech ? available : Math.min(available, remaining, 5 * SECOND); let length =
if (speech && duration + length > 90 * SECOND) continue; params.mode === "sequence"
if (params.beatSync && !speech && beats.length) { ? Math.min(available, remaining)
: speech
? available
: Math.min(available, remaining, visualLength);
if (speech && duration + length > MAX_EDIT_SECONDS * SECOND) continue;
if (params.mode !== "sequence" && params.beatSync && !speech && beats.length) {
const stride = Number(params.beatsPerCut); const stride = Number(params.beatsPerCut);
const desired = Math.min( const desired = Math.min(
5 * SECOND, visualLength,
available, available,
remaining / Math.max(1, Math.min(12 - clips.length, list.length - clips.length)), remaining / Math.max(1, Math.min(12 - clips.length, list.length - clips.length)),
); );
@ -217,9 +440,12 @@ export function buildEditPlans(
.filter((_, i) => i % stride === 0) .filter((_, i) => i % stride === 0)
.map((t) => t - params.music!.start) .map((t) => t - params.music!.start)
.filter( .filter(
(t) => t - duration >= 0.5 * SECOND && t - duration <= Math.min(available, remaining), (t) =>
t - duration >= 0.5 * SECOND && t - duration <= Math.min(available, remaining),
) )
.sort((a, b) => Math.abs(a - duration - desired) - Math.abs(b - duration - desired))[0]; .sort(
(a, b) => Math.abs(a - duration - desired) - Math.abs(b - duration - desired),
)[0];
if (next !== undefined) length = next - duration; if (next !== undefined) length = next - duration;
} }
if (length < 200000) continue; if (length < 200000) continue;
@ -236,7 +462,7 @@ export function buildEditPlans(
} }
const plan: EditPlan = { const plan: EditPlan = {
version: 1, version: 1,
title: `${{ montage: "素材混剪", talk: "口播视频", explain: "讲解视频", ads: "批量广告" }[params.mode]}${variant ? ` ${variant + 1}` : ""}`, title: `${{ sequence: "智能剪辑", auto: "默认模式", montage: "素材混剪", talk: "口播视频", explain: "讲解视频", ads: "批量广告" }[params.mode]}${variant ? ` ${variant + 1}` : ""}`,
assets, assets,
clips, clips,
ratio: params.ratio, ratio: params.ratio,
@ -247,7 +473,16 @@ export function buildEditPlans(
warnings: [...warnings], warnings: [...warnings],
}; };
const actual = planTimeline(plan).duration / SECOND; const actual = planTimeline(plan).duration / SECOND;
if (Math.abs(actual - params.targetDuration) > 0.5) if (
params.mode === "sequence" &&
assets.reduce((total, a) => total + a.duration, 0) > duration
)
plan.warnings.push("时间轴已载入前 300 秒,超出的素材未加入;可在设置中另建剪辑方案。");
if (
params.mode !== "sequence" &&
params.targetDuration > 0 &&
Math.abs(actual - params.targetDuration) > 0.5
)
plan.warnings.push( plan.warnings.push(
`目标 ${params.targetDuration} 秒,实际 ${actual.toFixed(2)} 秒;保留完整语句或受素材长度限制。`, `目标 ${params.targetDuration} 秒,实际 ${actual.toFixed(2)} 秒;保留完整语句或受素材长度限制。`,
); );

View File

@ -1,4 +1,5 @@
import "server-only"; import "server-only";
import { processEditAudio } from "./audio-process";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { uploadFile } from "@/lib/storage/service"; import { uploadFile } from "@/lib/storage/service";
@ -31,7 +32,7 @@ import { remoteRender, remoteRenderConfig, type RemoteRenderCheckpoint } from ".
import { ProviderTaskUncertainError } from "../video-task-lifecycle"; import { ProviderTaskUncertainError } from "../video-task-lifecycle";
export type SmartEditCheckpoint = { export type SmartEditCheckpoint = {
operation: "smart-edit"; operation: "smart-edit";
action: "plan" | "render"; action: "plan" | "render" | "separate" | "transcribe";
phase: "local" | "analyzed" | "selecting" | "selected" | "remote-render"; phase: "local" | "analyzed" | "selecting" | "selected" | "remote-render";
remote?: RemoteRenderCheckpoint; remote?: RemoteRenderCheckpoint;
stage: string; stage: string;
@ -67,8 +68,9 @@ export async function executeSmartEdit(
const upload = async (file: string, suffix: string) => { const upload = async (file: string, suffix: string) => {
await lifecycle.assertActive(); await lifecycle.assertActive();
const bytes = await readFile(file); const bytes = await readFile(file);
return (await uploadFile(`smart-edit-${randomUUID()}.${suffix}`, Uint8Array.from(bytes).buffer)) return (
.url; await uploadFile(`smart-edit-${randomUUID()}.${suffix}`, Uint8Array.from(bytes).buffer)
).url;
}; };
if (params.operation === "render" && (state.remote || remoteRenderConfig())) { if (params.operation === "render" && (state.remote || remoteRenderConfig())) {
const dir = await mkdtemp(path.join(tmpdir(), "remote-render-")); const dir = await mkdtemp(path.join(tmpdir(), "remote-render-"));
@ -78,9 +80,10 @@ export async function executeSmartEdit(
active: lifecycle.assertActive, active: lifecycle.assertActive,
save: (remote, stage) => save({ phase: "remote-render", remote, stage }), save: (remote, stage) => save({ phase: "remote-render", remote, stage }),
}); });
const actual = await probeMedia(rendered.file); const actual = await probeMedia(rendered.file, 300.2);
if ( if (
Math.abs(actual.duration / SECOND - planTimeline(params.plan).duration / SECOND) > 0.15 || Math.abs(actual.duration / SECOND - planTimeline(params.plan).duration / SECOND) >
0.15 ||
actual.width !== rendered.width || actual.width !== rendered.width ||
actual.height !== rendered.height actual.height !== rendered.height
) )
@ -135,22 +138,33 @@ export async function executeSmartEdit(
if (!meta.width || !meta.height) throw Error(`${input.name} 不包含视频画面`); if (!meta.width || !meta.height) throw Error(`${input.name} 不包含视频画面`);
total += meta.duration; total += meta.duration;
if (total > 600 * SECOND) throw Error("素材总时长不能超过 10 分钟"); if (total > 600 * SECOND) throw Error("素材总时长不能超过 10 分钟");
const prepared = await prepareAnalysis( // Opening a timeline only probes source files; it must not run scene analysis or models.
media.file, const analysis =
media.hash, params.mode === "sequence"
media.dir, ? {
meta.hasAudio, scenes: [[0, meta.duration]],
); active: [[0, meta.duration]],
const analysis = await pythonAnalysis( subtitles: input.subtitles,
{ warnings: [],
version: 1, }
video: prepared.proxy, : await (async () => {
audio: prepared.audio, const prepared = await prepareAnalysis(
duration: meta.duration, media.file,
transcribe: params.transcribe && !input.subtitles.length, media.hash,
}, media.dir,
media.dir, meta.hasAudio,
); );
return pythonAnalysis(
{
version: 1,
video: prepared.proxy,
audio: prepared.audio,
duration: meta.duration,
transcribe: params.transcribe && !input.subtitles.length,
},
media.dir,
);
})();
const subtitles = input.subtitles.length ? input.subtitles : analysis.subtitles; const subtitles = input.subtitles.length ? input.subtitles : analysis.subtitles;
if (subtitles.some((s) => s.end > meta.duration + 100000)) if (subtitles.some((s) => s.end > meta.duration + 100000))
throw Error(`${input.name} 的字幕时间超出视频长度`); throw Error(`${input.name} 的字幕时间超出视频长度`);
@ -183,9 +197,14 @@ export async function executeSmartEdit(
// Keep full speech sentences; split long visual scenes into bounded candidates. // Keep full speech sentences; split long visual scenes into bounded candidates.
for (let start = range.start; start < range.end; ) { for (let start = range.start; start < range.end; ) {
const end = const end =
speech && subtitles.length params.mode === "sequence" || (speech && subtitles.length)
? range.end ? range.end
: Math.min(range.end, start + 5 * SECOND); : Math.min(
range.end,
start +
Math.max(5, Math.min(30, (params.targetDuration || 20) / 12)) *
SECOND,
);
if (end - start >= 200000) if (end - start >= 200000)
candidates.push({ candidates.push({
id: `candidate-${candidates.length}`, id: `candidate-${candidates.length}`,
@ -205,7 +224,9 @@ export async function executeSmartEdit(
for (let i = 0; fair.length < 24 && grouped.some((g) => i < g.length); i++) for (let i = 0; fair.length < 24 && grouped.some((g) => i < g.length); i++)
for (const group of grouped) if (group[i] && fair.length < 24) fair.push(group[i]); for (const group of grouped) if (group[i] && fair.length < 24) fair.push(group[i]);
if (fair.length < candidates.length) if (fair.length < candidates.length)
warnings.push(`共识别 ${candidates.length} 个候选,本轮按素材轮询选取 24 个进入选片。`); warnings.push(
`共识别 ${candidates.length} 个候选,本轮按素材轮询选取 24 个进入选片。`,
);
candidates = fair; candidates = fair;
if (params.beatSync && params.music) { if (params.beatSync && params.music) {
await save({ stage: "检测音乐节拍" }); await save({ stage: "检测音乐节拍" });
@ -213,8 +234,14 @@ export async function executeSmartEdit(
meta = await probeMedia(music.file); meta = await probeMedia(music.file);
if (!meta.hasAudio) throw Error("背景音乐没有音轨"); if (!meta.hasAudio) throw Error("背景音乐没有音轨");
if (params.music.start >= meta.duration) throw Error("音乐开始时间超出文件"); if (params.music.start >= meta.duration) throw Error("音乐开始时间超出文件");
const audio = await prepareAudio(music.file, path.join(music.dir, music.hash + ".wav")); const audio = await prepareAudio(
const analysis = await pythonAnalysis({ version: 1, audio, beats: true }, music.dir); music.file,
path.join(music.dir, music.hash + ".wav"),
);
const analysis = await pythonAnalysis(
{ version: 1, audio, beats: true },
music.dir,
);
beats = analysis.beats; beats = analysis.beats;
warnings.push(...analysis.warnings); warnings.push(...analysis.warnings);
} else if (params.beatSync) warnings.push("未选择音乐,跳过音乐卡点。"); } else if (params.beatSync) warnings.push("未选择音乐,跳过音乐卡点。");
@ -229,7 +256,7 @@ export async function executeSmartEdit(
} }
if (!candidates.length) throw Error("没有可用片段,请关闭去静音或更换素材"); if (!candidates.length) throw Error("没有可用片段,请关闭去静音或更换素材");
let order = candidates.map((c) => c.id); let order = candidates.map((c) => c.id);
if (params.semantic) { if (params.semantic && params.mode !== "sequence") {
const images: string[] = []; const images: string[] = [];
for (const candidate of candidates) { for (const candidate of candidates) {
await lifecycle.assertActive(); await lifecycle.assertActive();
@ -271,10 +298,26 @@ export async function executeSmartEdit(
await save({ phase: "selected", stage: "剪辑方案已生成", plans }); await save({ phase: "selected", stage: "剪辑方案已生成", plans });
return { plans, summary: "剪辑方案已生成" }; return { plans, summary: "剪辑方案已生成" };
} }
if (params.operation === "separate" || params.operation === "transcribe") {
if (state.phase === "selected" && state.plans) return { plans: state.plans };
const plan = await processEditAudio(
params.operation,
params.plan,
params.operation === "separate" ? params.clipId : undefined,
userId,
dir,
(stage) => save({ stage }),
upload,
params.operation === "separate" ? params.track : "video",
params.operation === "separate" ? params.keep : "both",
);
await save({ phase: "selected", stage: "音频处理完成", plans: [plan] });
return { plans: [plan] };
}
const plan = editPlanSchema.parse(params.plan); const plan = editPlanSchema.parse(params.plan);
planTimeline(plan); planTimeline(plan);
const rendered = await renderJob(plan, userId, dir, (stage) => save({ stage })); const rendered = await renderJob(plan, userId, dir, (stage) => save({ stage }));
const actual = await probeMedia(rendered.file); const actual = await probeMedia(rendered.file, 300.2);
if (Math.abs(actual.duration / SECOND - rendered.duration) > 0.15) if (Math.abs(actual.duration / SECOND - rendered.duration) > 0.15)
throw Error("导出时长与剪辑方案不一致,未发布结果"); throw Error("导出时长与剪辑方案不一致,未发布结果");
await save({ stage: "转存成片" }); await save({ stage: "转存成片" });

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,88 @@
import path from "node:path";
import { readFile, writeFile, mkdir, rename, rm } from "node:fs/promises";
import { createHash, randomUUID } from "node:crypto";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { captionFont } from "./fonts";
const pending = new Map<string, Promise<string>>();
const sha = (data: Uint8Array) => createHash("sha256").update(data).digest("hex");
export function captionFontFile(id: string): Promise<string> {
const font = captionFont(id);
if (!font) return Promise.reject(Error("未知字幕字体"));
if (!font.url)
return Promise.resolve(
path.join(
process.env.SMART_EDIT_FONTS_DIR || path.resolve("public/fonts/smart-edit"),
font.file,
),
);
if (pending.has(id)) return pending.get(id)!;
const work = (async () => {
const root = path.resolve(
process.env.SMART_EDIT_FONT_CACHE_DIR || ".cache/smart-edit-fonts",
);
await mkdir(root, { recursive: true });
const output = path.join(root, font.file);
try {
const data = await readFile(output);
if (font.preparedSha256 && sha(data) === font.preparedSha256) return output;
} catch {}
if (pending.size > 4) throw Error("字体正在加载,请稍后重试");
const source = new URL(font.url!);
if (
source.protocol !== "https:" ||
!["fonts.gstatic.com", "fonts.gstatic.cn", "libtv-res.liblib.art"].includes(
source.hostname,
)
)
throw Error("字体来源无效");
const response = await fetch(source, {
signal: AbortSignal.timeout(60000),
redirect: "error",
cache: "no-store",
});
if (!response.ok || Number(response.headers.get("content-length")) > 40 * 1024 * 1024)
throw Error("字体下载失败");
const reader = response.body!.getReader(),
chunks: Uint8Array[] = [];
let bytes = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
bytes += value.length;
if (bytes > 40 * 1024 * 1024) throw Error("字体文件过大");
chunks.push(value);
}
} finally {
await reader.cancel();
}
const data = Buffer.concat(chunks);
if (!font.sha256 || sha(data) !== font.sha256)
throw Error("字体资源校验失败,请更新字体目录");
const temp = path.join(root, randomUUID()),
prepared = temp + ".ttf";
try {
await writeFile(temp, data);
await promisify(execFile)(
process.env.SMART_EDIT_FONT_PYTHON || process.env.SMART_EDIT_PYTHON || "python3",
[
process.env.SMART_EDIT_FONT_SCRIPT || path.resolve("services/smart-edit/font.py"),
temp,
prepared,
font.family,
],
{ timeout: 120000, maxBuffer: 512 * 1024 },
);
if (sha(await readFile(prepared)) !== font.preparedSha256)
throw Error("字体处理版本不一致,请安装指定字体依赖");
await rename(prepared, output);
return output;
} finally {
await rm(temp, { force: true });
await rm(prepared, { force: true });
}
})().finally(() => pending.delete(id));
pending.set(id, work);
return work;
}

View File

@ -0,0 +1,64 @@
import catalog from "./font-catalog.json";
export type CaptionFont = {
id: string;
name: string;
family: string;
file: string;
common: boolean;
recommended: boolean;
google: boolean;
url?: string;
sha256?: string;
preparedSha256?: string;
};
/** Same packaged font bytes are used in browser previews and FFmpeg exports. */
const bundledFonts: CaptionFont[] = [
{
id: "source-han",
name: "Source Han Sans CN",
family: "Source Han Sans SC",
file: "source-han.otf",
common: true,
recommended: true,
google: false,
},
{
id: "noto-sans-sc",
name: "Noto Sans SC",
family: "Noto Sans SC",
file: "noto-sans-sc.ttf",
common: true,
recommended: true,
google: true,
},
{
id: "noto-serif-sc",
name: "Noto Serif SC",
family: "Noto Serif SC",
file: "noto-serif-sc.ttf",
common: true,
recommended: true,
google: true,
},
{
id: "roboto",
name: "Roboto",
family: "Roboto",
file: "roboto.ttf",
common: true,
recommended: false,
google: true,
},
{
id: "inter",
name: "Inter",
family: "Inter",
file: "inter.ttf",
common: true,
recommended: false,
google: true,
},
];
export const captionFonts: CaptionFont[] = [...bundledFonts, ...catalog];
const byId = new Map(captionFonts.map((font) => [font.id, font]));
export const captionFont = (id?: string) => byId.get(id || "");

View File

@ -0,0 +1,20 @@
/** Explicit switches take precedence over the common beat-sync phrase shortcut. */
export function resolveSmartEditIntent<
T extends {
mode?: string;
prompt?: string;
beatSync?: boolean;
variants?: number;
music?: { url: string };
},
>(draft: T) {
const prompt = draft.prompt || "";
const beatSync =
draft.beatSync ??
(/卡点|beat[ -]?sync/i.test(prompt) &&
!/(?:不|不要|无需|不用|取消|关闭).{0,5}卡点|no beat[ -]?sync/i.test(prompt));
return { ...draft, beatSync, variants: draft.variants ?? (draft.mode === "ads" ? 3 : 1) };
}
export function assertBeatMusic(draft: { beatSync?: boolean; music?: { url: string } }) {
if (draft.beatSync && !draft.music?.url) throw Error("音乐卡点需要先选择或上传背景音乐");
}

View File

@ -12,7 +12,7 @@ export const mediaSafety = [
"-format_whitelist", "-format_whitelist",
"mov,matroska,webm,avi,mpeg,mpegts,flv,ogg,wav,mp3,flac,aac", "mov,matroska,webm,avi,mpeg,mpegts,flv,ogg,wav,mp3,flac,aac",
]; ];
export async function ffmpeg(args: string[], cwd?: string) { export async function ffmpeg(args: string[], cwd?: string, timeout = 240000) {
await exec( await exec(
"ffmpeg", "ffmpeg",
[ [
@ -28,10 +28,10 @@ export async function ffmpeg(args: string[], cwd?: string) {
"1", "1",
...args, ...args,
], ],
{ cwd, timeout: 240000, maxBuffer: 2 * 1024 * 1024 }, { cwd, timeout, maxBuffer: 2 * 1024 * 1024 },
); );
} }
export async function probeMedia(file: string) { export async function probeMedia(file: string, maxSeconds = 180) {
const { stdout } = await exec( const { stdout } = await exec(
"ffprobe", "ffprobe",
["-v", "error", ...mediaSafety, "-show_streams", "-show_format", "-of", "json", file], ["-v", "error", ...mediaSafety, "-show_streams", "-show_format", "-of", "json", file],
@ -40,8 +40,8 @@ export async function probeMedia(file: string) {
const info = JSON.parse(stdout), const info = JSON.parse(stdout),
v = info.streams?.find((s: any) => s.codec_type === "video"); v = info.streams?.find((s: any) => s.codec_type === "video");
const duration = Math.round(Number(info.format?.duration || v?.duration) * SECOND); const duration = Math.round(Number(info.format?.duration || v?.duration) * SECOND);
if (!Number.isFinite(duration) || duration <= 0 || duration > 180 * SECOND) if (!Number.isFinite(duration) || duration <= 0 || duration > maxSeconds * SECOND)
throw Error("单个素材需在 0–180 秒以内"); throw Error(`媒体时长需在 0–${maxSeconds} 秒以内`);
if (v && (!v.width || !v.height || v.width * v.height > 33554432)) if (v && (!v.width || !v.height || v.width * v.height > 33554432))
throw Error("素材画面尺寸超过处理上限"); throw Error("素材画面尺寸超过处理上限");
return { return {
@ -114,7 +114,10 @@ export async function pythonAnalysis(
data: Record<string, unknown>, data: Record<string, unknown>,
dir: string, dir: string,
): Promise<LocalAnalysis> { ): Promise<LocalAnalysis> {
const key = createHash("sha256").update(JSON.stringify(data)).digest("hex"), const key = createHash("sha256")
.update("analysis-v3:")
.update(JSON.stringify(data))
.digest("hex"),
input = path.join(dir, key + ".analysis-input.json"), input = path.join(dir, key + ".analysis-input.json"),
output = path.join(dir, key + ".analysis.json"); output = path.join(dir, key + ".analysis.json");
try { try {
@ -147,7 +150,12 @@ export async function pythonAnalysis(
.slice(0, 500); .slice(0, 500);
return value; return value;
} }
export async function prepareAnalysis(file: string, hash: string, dir: string, hasAudio: boolean) { export async function prepareAnalysis(
file: string,
hash: string,
dir: string,
hasAudio: boolean,
) {
const proxy = path.join(dir, hash + ".proxy.mp4"), const proxy = path.join(dir, hash + ".proxy.mp4"),
audio = path.join(dir, hash + ".wav"); audio = path.join(dir, hash + ".wav");
try { try {

View File

@ -4,7 +4,7 @@ import { Readable, Transform } from "node:stream";
import { pipeline } from "node:stream/promises"; import { pipeline } from "node:stream/promises";
import path from "node:path"; import path from "node:path";
import { z } from "zod"; import { z } from "zod";
import type { EditPlan } from "./contract"; import { MAX_EDIT_SECONDS, type EditPlan } from "./contract";
import { ProviderTaskUncertainError } from "../video-task-lifecycle"; import { ProviderTaskUncertainError } from "../video-task-lifecycle";
export type RemoteRenderCheckpoint = { id: string; endpointHash: string }; export type RemoteRenderCheckpoint = { id: string; endpointHash: string };
@ -33,9 +33,12 @@ const statusSchema = z.object({
error: z.string().max(500).optional(), error: z.string().max(500).optional(),
result: z result: z
.object({ .object({
width: z.number().int().positive().max(1920), width: z.number().int().positive().max(2520),
height: z.number().int().positive().max(1920), height: z.number().int().positive().max(2520),
durationSeconds: z.number().positive().max(90.2), durationSeconds: z
.number()
.positive()
.max(MAX_EDIT_SECONDS + 0.2),
}) })
.optional(), .optional(),
}); });

View File

@ -11,8 +11,8 @@ export async function renderJob(
) { ) {
const plan = editPlanSchema.parse(value); const plan = editPlanSchema.parse(value);
planTimeline(plan); planTimeline(plan);
if (plan.assets.reduce((n, a) => n + a.duration, 0) > 600 * SECOND) if (plan.assets.reduce((n, a) => n + a.duration, 0) > 1800 * SECOND)
throw Error("素材总时长不能超过 10 分钟"); throw Error("素材与分离音轨总时长不能超过 30 分钟");
const files: Record<string, string> = {}; const files: Record<string, string> = {};
for (const asset of plan.assets) { for (const asset of plan.assets) {
await progress("读取原始素材"); await progress("读取原始素材");
@ -32,11 +32,12 @@ export async function renderJob(
if (plan.music) { if (plan.music) {
const media = await materialize(plan.music.url, userId), const media = await materialize(plan.music.url, userId),
meta = await probeMedia(media.file); meta = await probeMedia(media.file);
if (!meta.hasAudio || plan.music.start >= meta.duration) throw Error("背景音乐或起始时间无效"); if (!meta.hasAudio || plan.music.start >= meta.duration)
throw Error("背景音乐或起始时间无效");
musicFile = media.file; musicFile = media.file;
} }
const rendered = await renderEditPlan(plan, files, musicFile, dir, progress); const rendered = await renderEditPlan(plan, files, musicFile, dir, progress);
const actual = await probeMedia(rendered.file); const actual = await probeMedia(rendered.file, 300.2);
if (Math.abs(actual.duration / SECOND - rendered.duration) > 0.15) if (Math.abs(actual.duration / SECOND - rendered.duration) > 0.15)
throw Error("导出时长与剪辑方案不一致,未发布结果"); throw Error("导出时长与剪辑方案不一致,未发布结果");
return { ...rendered, duration: actual.duration / SECOND }; return { ...rendered, duration: actual.duration / SECOND };

View File

@ -1,7 +1,19 @@
import { readFile, writeFile } from "node:fs/promises"; import { readFile, writeFile, mkdir, copyFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { type EditPlan, planTimeline, compiledSubtitles, subtitlesToSrt, SECOND } from "./contract"; import { captionFontFile } from "./font-files";
import { captionFont } from "./fonts";
import {
type EditPlan,
editOutputSize,
planTimeline,
compiledSubtitles,
subtitlesToSrt,
subtitlesToAss,
SECOND,
defaultVideoTransform,
} from "./contract";
import { ffmpeg, mediaSafety } from "./local"; import { ffmpeg, mediaSafety } from "./local";
const renderFfmpeg = (args: string[], cwd?: string) => ffmpeg(args, cwd, 600000);
export async function renderEditPlan( export async function renderEditPlan(
plan: EditPlan, plan: EditPlan,
files: Record<string, string>, files: Record<string, string>,
@ -11,13 +23,36 @@ export async function renderEditPlan(
) { ) {
const timeline = planTimeline(plan), const timeline = planTimeline(plan),
duration = timeline.duration / SECOND; duration = timeline.duration / SECOND;
const size = plan.resolution === "1080p" ? 1080 : 720; const { width, height } = editOutputSize(plan);
const [width, height] = const selectedFonts = [
plan.ratio === "16:9" ...new Set(
? [Math.round((size * 16) / 9), size] compiledSubtitles(plan)
: plan.ratio === "9:16" .map(
? [size, Math.round((size * 16) / 9)] (caption) =>
: [size, size]; captionFont((caption as { style?: { fontFamily?: string } }).style?.fontFamily)?.id,
)
.filter((file): file is NonNullable<typeof file> => Boolean(file)),
),
];
if (selectedFonts.length) {
const fontDir = path.join(dir, "fonts");
await mkdir(fontDir, { recursive: true });
const source =
process.env.SMART_EDIT_FONTS_DIR ||
path.resolve(process.cwd(), "public/fonts/smart-edit");
for (const id of selectedFonts) {
const file = captionFont(id)!.file;
try {
await progress("准备字幕字体");
await copyFile(await captionFontFile(id), path.join(fontDir, file));
} catch {
throw Error(`字幕字体文件缺失:${file},请安装剪辑字体包后重新导出`);
}
}
// Latin faces use the same packaged Chinese fallback in both renderers.
if (!selectedFonts.includes("source-han"))
await copyFile(path.join(source, "source-han.otf"), path.join(fontDir, "source-han.otf"));
}
const normalized: string[] = []; const normalized: string[] = [];
for (let i = 0; i < timeline.clips.length; i++) { for (let i = 0; i < timeline.clips.length; i++) {
await progress(`处理片段 ${i + 1}/${timeline.clips.length}`); await progress(`处理片段 ${i + 1}/${timeline.clips.length}`);
@ -35,24 +70,43 @@ export async function renderEditPlan(
files[clip.assetId], files[clip.assetId],
]; ];
if (!asset.hasAudio) inputs.push("-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo"); if (!asset.hasAudio) inputs.push("-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo");
await ffmpeg([ const t = clip.transform || defaultVideoTransform;
const transformWidth = Math.max(2, Math.round((width * t.scale) / 2) * 2),
transformHeight = Math.max(2, Math.round((height * t.scale) / 2) * 2);
const transformX = Math.round((width - transformWidth) / 2 + t.x * width),
transformY = Math.round((height - transformHeight) / 2 + t.y * height);
const pad =
clip.padBefore || clip.padAfter
? `,tpad=start_mode=clone:start_duration=${clip.padBefore / SECOND}:stop_mode=clone:stop_duration=${clip.padAfter / SECOND}`
: "";
const normalize = `setpts=(PTS-STARTPTS)/${clip.speed},scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=30,trim=duration=${clip.duration / SECOND},format=yuv420p${pad}`;
const transformed = t.scale !== 1 || t.x !== 0 || t.y !== 0;
await renderFfmpeg([
...inputs, ...inputs,
"-map", "-map",
"0:v:0", transformed ? "[v]" : "0:v:0",
"-map", "-map",
asset.hasAudio ? "0:a:0" : "1:a:0", asset.hasAudio ? "0:a:0" : "1:a:0",
"-vf", ...(transformed
`setpts=(PTS-STARTPTS)/${clip.speed},scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=30,format=yuv420p`, ? [
"-filter_complex",
`[0:v]${normalize},scale=${transformWidth}:${transformHeight}[fg];color=c=black:s=${width}x${height}:r=30[bg];[bg][fg]overlay=x=${transformX}:y=${transformY}:shortest=1[v]`,
]
: ["-vf", normalize]),
"-af", "-af",
`asetpts=PTS-STARTPTS,atempo=${clip.speed},aresample=48000,aformat=channel_layouts=stereo,volume=${clip.gain},apad`, `asetpts=PTS-STARTPTS,atempo=${clip.speed},aresample=48000,aformat=channel_layouts=stereo,volume=${clip.audioDetached || clip.muted ? 0 : clip.gain},atrim=duration=${clip.duration / SECOND}${clip.padBefore ? `,adelay=${Math.round(clip.padBefore / 1000)}:all=1` : ""},apad`,
"-t", "-t",
String(clip.duration / SECOND), String((clip.duration + clip.padBefore + clip.padAfter) / SECOND),
"-c:v", "-c:v",
"libx264", "libx264",
"-preset", "-preset",
"veryfast", "veryfast",
"-crf", "-crf",
"21", "21",
"-maxrate",
"6M",
"-bufsize",
"12M",
"-threads", "-threads",
"2", "2",
"-c:a", "-c:a",
@ -67,78 +121,192 @@ export async function renderEditPlan(
} }
await progress("合成时间轴"); await progress("合成时间轴");
const joined = path.join(dir, "joined.mp4"); const joined = path.join(dir, "joined.mp4");
if (plan.transition === 0 || normalized.length === 1) { if (!timeline.clips.some((clip) => clip.overlap) || normalized.length === 1) {
await writeFile( await writeFile(
path.join(dir, "concat.txt"), path.join(dir, "concat.txt"),
normalized.map((_, i) => `file 'clip-${i}.mp4'`).join("\n"), normalized.map((_, i) => `file 'clip-${i}.mp4'`).join("\n"),
); );
await ffmpeg(["-f", "concat", "-safe", "1", "-i", "concat.txt", "-c", "copy", joined], dir); await renderFfmpeg(
["-f", "concat", "-safe", "1", "-i", "concat.txt", "-c", "copy", joined],
dir,
);
} else { } else {
// Normalize first: all xfade inputs have identical pixel format, timebase and dimensions. // Normalize first: all xfade inputs have identical pixel format, timebase and dimensions.
const filters: string[] = []; const filters: string[] = normalized.map(
let video = "0:v", (_, i) => `[${i}:v]format=yuv444p,settb=AVTB,setpts=PTS-STARTPTS[vin${i}]`,
audio = "0:a"; );
const audioFilters = timeline.clips.map(
(c, i) =>
`[${i}:a]atrim=duration=${(c.duration + c.padBefore + c.padAfter) / SECOND},asetpts=PTS-STARTPTS[ain${i}]`,
);
let video = "vin0",
audio = "ain0";
for (let i = 1; i < normalized.length; i++) { for (let i = 1; i < normalized.length; i++) {
const clip = timeline.clips[i]; const clip = timeline.clips[i];
filters.push( if (clip.overlap) {
`[${video}][${i}:v]xfade=transition=fade:duration=${clip.overlap / SECOND}:offset=${clip.start / SECOND}[v${i}]`, const length = clip.overlap / SECOND,
`[${audio}][${i}:a]acrossfade=d=${clip.overlap / SECOND}:c1=tri:c2=tri[a${i}]`, offset = clip.transitionStart / SECOND;
); if (clip.transitionType === "fade") {
filters.push(
`[${video}][vin${i}]xfade=transition=fade:duration=${length}:offset=${offset}[v${i}]`,
);
} else {
// Two native fades meet at a solid-color midpoint, matching the browser overlay.
// Avoid custom per-pixel expressions: they are prohibitively slow at 1080P.
const half = length / 2,
color = clip.transitionType === "fadeblack" ? "black" : "white";
filters.push(
`color=c=${color}:s=${width}x${height}:r=30:d=${length},format=yuv444p,settb=AVTB[solid${i}]`,
`[vin${i}]trim=start=${half},setpts=PTS-STARTPTS[tail${i}]`,
`[${video}][solid${i}]xfade=transition=fade:duration=${half}:offset=${offset}[dip${i}]`,
`[dip${i}][tail${i}]xfade=transition=fade:duration=${half}:offset=${offset + half}[v${i}]`,
);
}
audioFilters.push(`[${audio}][ain${i}]acrossfade=d=${length}:c1=tri:c2=tri[a${i}]`);
} else {
filters.push(`[${video}][vin${i}]concat=n=2:v=1:a=0[v${i}]`);
audioFilters.push(`[${audio}][ain${i}]concat=n=2:v=0:a=1[a${i}]`);
}
video = `v${i}`; video = `v${i}`;
audio = `a${i}`; audio = `a${i}`;
} }
await ffmpeg([ const videoFile = path.join(dir, "joined-video.mp4"),
...normalized.flatMap((file) => ["-i", file]), audioFile = path.join(dir, "joined-audio.m4a");
await renderFfmpeg([
...normalized.flatMap((file) => ["-threads", "2", "-i", file]),
"-filter_complex", "-filter_complex",
filters.join(";"), filters.join(";"),
"-map", "-map",
`[${video}]`, `[${video}]`,
"-map", "-an",
`[${audio}]`,
"-t",
String(duration),
"-c:v", "-c:v",
"libx264", "libx264",
"-preset", "-preset",
"veryfast", "veryfast",
"-crf", "-crf",
"21", "21",
"-maxrate",
"6M",
"-bufsize",
"12M",
"-threads", "-threads",
"2", "2",
"-pix_fmt",
"yuv420p",
videoFile,
]);
// Keep framesync video and full-length audio fades in separate passes. Otherwise
// FFmpeg 4 can finish the audio graph before emitting buffered video at offset 0.
await renderFfmpeg([
...normalized.flatMap((file) => ["-i", file]),
"-filter_complex",
audioFilters.join(";"),
"-map",
`[${audio}]`,
"-vn",
"-c:a", "-c:a",
"aac", "aac",
audioFile,
]);
await renderFfmpeg([
"-i",
videoFile,
"-i",
audioFile,
"-map",
"0:v:0",
"-map",
"1:a:0",
"-c",
"copy",
joined, joined,
]); ]);
} }
const subtitles = compiledSubtitles(plan); const subtitles = compiledSubtitles(plan);
const srt = path.join(dir, "subtitles.srt"); const srt = path.join(dir, "subtitles.srt");
await writeFile(srt, subtitlesToSrt(subtitles)); await writeFile(srt, subtitlesToSrt(subtitles));
await writeFile(path.join(dir, "subtitles.ass"), subtitlesToAss(plan, width, height));
const output = path.join(dir, "result.mp4"); const output = path.join(dir, "result.mp4");
const inputs = ["-i", joined]; const inputs = ["-i", joined];
const filters: string[] = []; const filters: string[] = [];
if (subtitles.length) const videoTail = Math.max(
filters.push( 0,
"[0:v]subtitles=filename=subtitles.srt:force_style='FontSize=22,Outline=2,MarginV=30'[v]", duration - timeline.clips[timeline.clips.length - 1].end / SECOND,
); );
const videoFilters = [
...(videoTail ? [`tpad=stop_mode=add:stop_duration=${videoTail}`] : []),
...(subtitles.length
? [
selectedFonts.length
? "ass=filename=subtitles.ass:fontsdir=fonts"
: "ass=filename=subtitles.ass",
]
: []),
];
if (videoFilters.length) filters.push(`[0:v]${videoFilters.join(",")}[v]`);
filters.push(`[0:a]apad,atrim=duration=${duration}[baseaudio]`);
const mixLabels = ["[baseaudio]"];
let audioInput = 1;
if (musicFile && plan.music) { if (musicFile && plan.music) {
inputs.push("-ss", String(plan.music.start / SECOND), "-i", musicFile); inputs.push("-ss", String(plan.music.start / SECOND), "-i", musicFile);
filters.push( filters.push(
`[1:a]asetpts=PTS-STARTPTS,aresample=48000,aformat=channel_layouts=stereo,volume=${plan.music.gain},apad,atrim=duration=${duration}[music]`, `[${audioInput}:a]asetpts=PTS-STARTPTS,aresample=48000,aformat=channel_layouts=stereo,volume=${plan.music.gain},apad,atrim=duration=${duration}[music]`,
`[0:a][music]amix=inputs=2:duration=first:normalize=0,alimiter=limit=0.95:level=0[a]`,
); );
mixLabels.push("[music]");
audioInput++;
} }
for (const [index, clip] of timeline.audioClips.entries()) {
inputs.push(
...mediaSafety,
"-ss",
String(clip.sourceIn / SECOND),
"-t",
String((clip.sourceOut - clip.sourceIn) / SECOND),
"-i",
files[clip.assetId],
);
const length = clip.duration / SECOND,
fadeIn = Math.min(clip.fadeIn, length),
fadeOut = Math.min(clip.fadeOut, length);
const offsetEnvelope =
clip.fadeInStart || clip.fadeOutEnd
? `,volume='${fadeIn ? `min(1,${clip.fadeInStart || 0}+(1-${clip.fadeInStart || 0})*t/${fadeIn})` : "1"}*${fadeOut ? `min(1,${clip.fadeOutEnd || 0}+(1-${clip.fadeOutEnd || 0})*(${length}-t)/${fadeOut})` : "1"}':eval=frame`
: `${fadeIn ? `,afade=t=in:d=${fadeIn}` : ""}${fadeOut ? `,afade=t=out:st=${length - fadeOut}:d=${fadeOut}` : ""}`;
filters.push(
`[${audioInput}:a]asetpts=PTS-STARTPTS,atempo=${clip.speed},aresample=48000,aformat=channel_layouts=stereo,volume=${clip.muted ? 0 : clip.gain},atrim=duration=${length}${offsetEnvelope},adelay=${Math.round(clip.start / 1000)}:all=1[track${index}]`,
);
mixLabels.push(`[track${index}]`);
audioInput++;
}
if (mixLabels.length > 1)
filters.push(
`${mixLabels.join("")}amix=inputs=${mixLabels.length}:duration=first:normalize=0,alimiter=limit=0.95:level=0[a]`,
);
await progress("输出成片"); await progress("输出成片");
await ffmpeg( await renderFfmpeg(
[ [
...inputs, ...inputs,
...(filters.length ? ["-filter_complex", filters.join(";")] : []), ...(filters.length ? ["-filter_complex", filters.join(";")] : []),
"-map", "-map",
subtitles.length ? "[v]" : "0:v:0", videoFilters.length ? "[v]" : "0:v:0",
"-map", "-map",
musicFile && plan.music ? "[a]" : "0:a:0", mixLabels.length > 1 ? "[a]" : "[baseaudio]",
"-c:v", "-c:v",
subtitles.length ? "libx264" : "copy", videoFilters.length ? "libx264" : "copy",
...(subtitles.length ? ["-preset", "veryfast", "-crf", "21", "-threads", "2"] : []), ...(videoFilters.length
? [
"-preset",
"veryfast",
"-crf",
"21",
"-maxrate",
"6M",
"-bufsize",
"12M",
"-threads",
"2",
]
: []),
"-c:a", "-c:a",
"aac", "aac",
"-t", "-t",

View File

@ -1,12 +1,23 @@
import { hasRecoverableCanvasTask } from "./task-recovery"; import { hasRecoverableCanvasTask } from "./task-recovery";
import { normalizeVideoAnalysisRows } from "./video-story"; import { normalizeVideoAnalysisRows } from "./video-story";
import { storyTaskAttachmentSchema, mergeStoryTaskResult } from "./story-task-attachment"; import {
storyTaskAttachmentSchema,
mergeStoryTaskResult,
} from "./story-task-attachment";
import { z } from "zod"; import { z } from "zod";
export const canvasTaskRequestSchema = z.object({ export const canvasTaskRequestSchema = z.object({
requestId: z.string().uuid(), requestId: z.string().uuid(),
projectId: z.string().uuid(), projectId: z.string().uuid(),
kind: z.enum(["video", "image", "analysis", "image-operation", "audio", "smart-edit"]), kind: z.enum([
"video",
"image",
"analysis",
"image-operation",
"audio",
"smart-edit",
"video-upscale",
]),
node: z node: z
.object({ .object({
id: z.string().min(1).max(180), id: z.string().min(1).max(180),
@ -21,6 +32,27 @@ export const canvasTaskRequestSchema = z.object({
}) })
.passthrough(), .passthrough(),
params: z.record(z.unknown()), params: z.record(z.unknown()),
workflow: z
.object({
runId: z.string().uuid(),
groupId: z.string().min(1).max(180),
inputs: z
.array(
z.object({
output: z.literal("edit-plan").optional(),
requestId: z.string().uuid().optional(),
node: z.object({
id: z.string().min(1).max(180),
title: z.string().optional(),
kind: z.string().optional(),
inputType: z.string().optional(),
draftValues: z.record(z.any()).optional(),
}),
})
)
.max(30),
})
.optional(),
attachment: storyTaskAttachmentSchema.optional(), attachment: storyTaskAttachmentSchema.optional(),
hiddenNodeIds: z.array(z.string()).max(20).optional(), hiddenNodeIds: z.array(z.string()).max(20).optional(),
edges: z edges: z
@ -47,9 +79,15 @@ export function canCancelCanvasTask(status: string) {
return status === "pending"; return status === "pending";
} }
export function requireAnalysisResult(data: Record<string, any> | undefined, mode: string) { export function requireAnalysisResult(
data: Record<string, any> | undefined,
mode: string
) {
if (!data) throw Error("分析没有返回结果,原内容已保留"); if (!data) throw Error("分析没有返回结果,原内容已保留");
if (mode === "video-analysis" && !normalizeVideoAnalysisRows(data.analysisRows).length) if (
mode === "video-analysis" &&
!normalizeVideoAnalysisRows(data.analysisRows).length
)
throw Error("视频解析没有返回有效镜头,原内容已保留"); throw Error("视频解析没有返回有效镜头,原内容已保留");
if ( if (
mode === "director-reconstruct" && mode === "director-reconstruct" &&
@ -66,8 +104,8 @@ export function requireAnalysisResult(data: Record<string, any> | undefined, mod
!rows.length || !rows.length ||
!rows.some((row) => !rows.some((row) =>
[row.sceneDescription, row.description, row.imagePrompt].some( [row.sceneDescription, row.description, row.imagePrompt].some(
(value) => typeof value === "string" && value.trim(), (value) => typeof value === "string" && value.trim()
), )
)) ))
) { ) {
throw Error("脚本没有返回有效镜头,原内容已保留"); throw Error("脚本没有返回有效镜头,原内容已保留");
@ -76,7 +114,7 @@ export function requireAnalysisResult(data: Record<string, any> | undefined, mod
mode !== "story-script" && mode !== "story-script" &&
mode !== "video-analysis" && mode !== "video-analysis" &&
![data.raw, data.summary, ...(data.agentPlan || [])].some( ![data.raw, data.summary, ...(data.agentPlan || [])].some(
(value) => typeof value === "string" && value.trim(), (value) => typeof value === "string" && value.trim()
) )
) { ) {
throw Error("分析返回了空内容,原内容已保留"); throw Error("分析返回了空内容,原内容已保留");
@ -87,13 +125,20 @@ export function requireAnalysisResult(data: Record<string, any> | undefined, mod
// Stable result IDs and request IDs prevent repeat polling from duplicating images // Stable result IDs and request IDs prevent repeat polling from duplicating images
// or an old task from overwriting a later generation on the same node. // or an old task from overwriting a later generation on the same node.
export function reconcileCanvasTasks< export function reconcileCanvasTasks<
T extends { id: string; draftValues?: Record<string, any>; [key: string]: any }, T extends {
id: string;
draftValues?: Record<string, any>;
[key: string]: any;
},
>(nodes: T[], tasks: CanvasGenerationTask[], projectRevision: string) { >(nodes: T[], tasks: CanvasGenerationTask[], projectRevision: string) {
let result = nodes; let result = nodes;
const latestByNode = new Map<string, CanvasGenerationTask>(); const latestByNode = new Map<string, CanvasGenerationTask>();
for (const task of tasks) { for (const task of tasks) {
const previous = latestByNode.get(task.request.node.id); const previous = latestByNode.get(task.request.node.id);
if (!previous || Date.parse(task.createdAt) >= Date.parse(previous.createdAt)) if (
!previous ||
Date.parse(task.createdAt) >= Date.parse(previous.createdAt)
)
latestByNode.set(task.request.node.id, task); latestByNode.set(task.request.node.id, task);
} }
for (const task of latestByNode.values()) { for (const task of latestByNode.values()) {
@ -109,7 +154,9 @@ export function reconcileCanvasTasks<
const node = result[index]; const node = result[index];
const draft = node.draftValues || {}; const draft = node.draftValues || {};
if (draft.taskRequestId && draft.taskRequestId !== task.requestId) { if (draft.taskRequestId && draft.taskRequestId !== task.requestId) {
const savedTask = tasks.find((item) => item.requestId === draft.taskRequestId); const savedTask = tasks.find(
(item) => item.requestId === draft.taskRequestId
);
if ( if (
!savedTask || !savedTask ||
Date.parse(task.createdAt) <= Date.parse(savedTask.createdAt) || Date.parse(task.createdAt) <= Date.parse(savedTask.createdAt) ||
@ -117,7 +164,7 @@ export function reconcileCanvasTasks<
) )
continue; continue;
} }
const appliedStatus = `${task.id}:${task.status}${task.request.kind === "smart-edit" ? `:${task.result?.smartEdit?.stage || ""}` : ""}`; const appliedStatus = `${task.id}:${task.status}${["smart-edit", "video-upscale"].includes(task.request.kind) ? `:${task.result?.upscale?.stage || task.result?.smartEdit?.stage || ""}` : ""}`;
if (draft.appliedTaskStatus === appliedStatus) continue; if (draft.appliedTaskStatus === appliedStatus) continue;
const nextDraft = { const nextDraft = {
...draft, ...draft,
@ -128,7 +175,11 @@ export function reconcileCanvasTasks<
generationProgress: 0, generationProgress: 0,
generationStatus: "pending", generationStatus: "pending",
pendingLabel: pendingLabel:
task.status === "pending" ? "排队中" : task.result?.smartEdit?.stage || "生成中", task.status === "pending"
? "排队中"
: task.result?.upscale?.stage ||
task.result?.smartEdit?.stage ||
"生成中",
}; };
let subtitle = nextDraft.pendingLabel; let subtitle = nextDraft.pendingLabel;
if (task.status === "succeeded") { if (task.status === "succeeded") {
@ -141,13 +192,33 @@ export function reconcileCanvasTasks<
}); });
subtitle = "生成结果"; subtitle = "生成结果";
if (task.request.kind === "smart-edit") { if (task.request.kind === "smart-edit") {
if (task.request.params.operation === "plan") if (task.request.params.operation !== "render") {
Object.assign(nextDraft, { smartEditPlans: data.plans, smartEditPlanTaskId: task.id }); const saved = draft.smartEditDraft;
else const processing = ["separate", "transcribe"].includes(
String(task.request.params.operation)
);
const plans =
processing && saved?.plans?.length
? saved.plans.map((plan: unknown, i: number) =>
i === (saved.selectedPlanIndex || 0)
? data.plans?.[0] || plan
: plan
)
: data.plans;
Object.assign(nextDraft, {
smartEditPlans: plans,
smartEditPlanTaskId: task.id,
});
if (processing && saved)
Object.assign(nextDraft, {
smartEditDraft: { ...saved, plans, planTaskId: task.id },
});
} else
Object.assign(nextDraft, { Object.assign(nextDraft, {
mediaUrl: data.url, mediaUrl: data.url,
url: [data.url], url: [data.url],
videoMeta: data.metadata, videoMeta: data.metadata,
durationSeconds: data.metadata?.durationSeconds,
subtitleUrl: data.subtitleUrl, subtitleUrl: data.subtitleUrl,
editPlan: data.plan, editPlan: data.plan,
action: "VIDEO_RESOURCE", action: "VIDEO_RESOURCE",
@ -161,18 +232,29 @@ export function reconcileCanvasTasks<
action: "AUDIO_RESOURCE", action: "AUDIO_RESOURCE",
mediaType: "audio", mediaType: "audio",
audioMeta: data.metadata, audioMeta: data.metadata,
audioParams: task.request.params, audioParams:
task.request.node.draftValues?.audioParams || task.request.params,
}); });
if (task.request.kind === "video") if (["video", "video-upscale"].includes(task.request.kind))
Object.assign(nextDraft, { mediaUrl: data.url, url: [data.url], videoMeta: data.metadata }); Object.assign(nextDraft, {
if (task.request.kind === "image" || task.request.kind === "image-operation") { mediaUrl: data.url,
url: [data.url],
videoMeta: data.metadata,
});
if (
task.request.kind === "image" ||
task.request.kind === "image-operation"
) {
const urls = (data.images || []) const urls = (data.images || [])
.map((image: any) => image.originalUrl || image.thumbnailUrl) .map((image: any) => image.originalUrl || image.thumbnailUrl)
.filter(Boolean); .filter(Boolean);
Object.assign(nextDraft, { Object.assign(nextDraft, {
mediaUrl: urls[0], mediaUrl: urls[0],
url: urls, url: urls,
action: draft.action === "IMAGE_GENERATE_NINE_GRID" ? draft.action : "IMAGE_RESOURCE", action:
draft.action === "IMAGE_GENERATE_NINE_GRID"
? draft.action
: "IMAGE_RESOURCE",
}); });
for (let i = 1; i < urls.length; i++) { for (let i = 1; i < urls.length; i++) {
const id = `${source.id}-result-${task.id}-${i}`; const id = `${source.id}-result-${task.id}-${i}`;
@ -184,19 +266,28 @@ export function reconcileCanvasTasks<
id, id,
title: `${node.title} ${i + 1}`, title: `${node.title} ${i + 1}`,
y: node.y + i * (node.height + 40), y: node.y + i * (node.height + 40),
draftValues: { ...nextDraft, mediaUrl: urls[i], url: [urls[i]] }, draftValues: {
...nextDraft,
mediaUrl: urls[i],
url: [urls[i]],
},
} as T, } as T,
]; ];
} }
} }
if (task.request.kind === "analysis" && task.request.params.mode === "director-reconstruct") if (
task.request.kind === "analysis" &&
task.request.params.mode === "director-reconstruct"
)
Object.assign(nextDraft, { Object.assign(nextDraft, {
directorScene: data.directorScene, directorScene: data.directorScene,
reconstructionSummary: data.summary, reconstructionSummary: data.summary,
}); });
if (task.request.kind === "analysis") if (task.request.kind === "analysis")
Object.assign(nextDraft, { Object.assign(nextDraft, {
content: [data.raw || data.summary || data.agentPlan?.join("\n") || ""], content: [
data.raw || data.summary || data.agentPlan?.join("\n") || "",
],
...(Array.isArray(data.analysisRows || data.shotIdeas) ...(Array.isArray(data.analysisRows || data.shotIdeas)
? { rows: data.analysisRows || data.shotIdeas } ? { rows: data.analysisRows || data.shotIdeas }
: {}), : {}),
@ -208,10 +299,13 @@ export function reconcileCanvasTasks<
pendingLabel: "", pendingLabel: "",
generationError: generationError:
task.error || task.error ||
(task.status === "cancelled" ? "已取消排队,未提交供应商" : "任务失败,请查看任务状态"), (task.status === "cancelled"
? "已取消排队,未提交供应商"
: "任务失败,请查看任务状态"),
}); });
if (task.status === "interrupted" && hasRecoverableCanvasTask(task)) if (task.status === "interrupted" && hasRecoverableCanvasTask(task))
nextDraft.generationError = "任务查询中断,稍后会自动查询原任务,请勿重复生成。"; nextDraft.generationError =
"任务查询中断,稍后会自动查询原任务,请勿重复生成。";
subtitle = subtitle =
task.status === "cancelled" task.status === "cancelled"
? "已取消" ? "已取消"
@ -221,7 +315,10 @@ export function reconcileCanvasTasks<
: "待核实" : "待核实"
: "生成失败"; : "生成失败";
} }
if (task.request.kind === "analysis" && task.request.params.mode === "video-analysis") { if (
task.request.kind === "analysis" &&
task.request.params.mode === "video-analysis"
) {
Object.assign(nextDraft, { Object.assign(nextDraft, {
status: status:
task.status === "succeeded" task.status === "succeeded"
@ -236,7 +333,7 @@ export function reconcileCanvasTasks<
rows: normalizeVideoAnalysisRows( rows: normalizeVideoAnalysisRows(
task.result?.analysisRows, task.result?.analysisRows,
task.result?.framePreviews || [], task.result?.framePreviews || [],
source.id, source.id
), ),
framePreviews: task.result?.framePreviews || [], framePreviews: task.result?.framePreviews || [],
summary: task.result?.summary || "", summary: task.result?.summary || "",
@ -249,11 +346,15 @@ export function reconcileCanvasTasks<
...item, ...item,
...(task.status === "succeeded" && ...(task.status === "succeeded" &&
task.request.kind !== "analysis" && task.request.kind !== "analysis" &&
!(task.request.kind === "smart-edit" && task.request.params.operation === "plan") !(
task.request.kind === "smart-edit" &&
task.request.params.operation !== "render"
)
? { ? {
kind: "media", kind: "media",
inputType: inputType:
task.request.kind === "video" || task.request.kind === "smart-edit" ["video", "video-upscale"].includes(task.request.kind) ||
task.request.kind === "smart-edit"
? "video" ? "video"
: task.request.kind === "audio" : task.request.kind === "audio"
? "audio" ? "audio"
@ -263,7 +364,7 @@ export function reconcileCanvasTasks<
subtitle, subtitle,
draftValues: nextDraft, draftValues: nextDraft,
} }
: item, : item
); );
} }
for (const task of tasks) { for (const task of tasks) {
@ -275,14 +376,21 @@ export function reconcileCanvasTasks<
!result.some((node) => node.id === task.request.node.id) !result.some((node) => node.id === task.request.node.id)
) )
continue; continue;
const targetIndex = result.findIndex((node) => node.id === attachment.nodeId); const targetIndex = result.findIndex(
(node) => node.id === attachment.nodeId
);
if (targetIndex < 0) continue; if (targetIndex < 0) continue;
const node = result[targetIndex], const node = result[targetIndex],
draft = node.draftValues || {}; draft = node.draftValues || {};
const next = mergeStoryTaskResult(draft, attachment, task.result || {}, task.id); const next = mergeStoryTaskResult(
draft,
attachment,
task.result || {},
task.id
);
if (next !== draft) if (next !== draft)
result = result.map((item, index) => result = result.map((item, index) =>
index === targetIndex ? { ...item, draftValues: next } : item, index === targetIndex ? { ...item, draftValues: next } : item
); );
} }
return result; return result;

View File

@ -10,7 +10,9 @@ export type RecoveryTask = {
request: { kind: string; params: Record<string, unknown> }; request: { kind: string; params: Record<string, unknown> };
result?: Record<string, any> | null; result?: Record<string, any> | null;
}; };
export function getEnhanceCheckpoint(task: Pick<RecoveryTask, "request" | "result">) { export function getEnhanceCheckpoint(
task: Pick<RecoveryTask, "request" | "result">
) {
const upstream = task.result?.upstreamTask; const upstream = task.result?.upstreamTask;
if ( if (
task.request.kind !== "image-operation" || task.request.kind !== "image-operation" ||
@ -27,7 +29,9 @@ export function getEnhanceCheckpoint(task: Pick<RecoveryTask, "request" | "resul
sourceImageUrl: String(upstream.sourceImageUrl), sourceImageUrl: String(upstream.sourceImageUrl),
}; };
} }
export function getVideoCheckpoint(task: Pick<RecoveryTask, "request" | "result">) { export function getVideoCheckpoint(
task: Pick<RecoveryTask, "request" | "result">
) {
if (task.request.kind !== "video") return null; if (task.request.kind !== "video") return null;
const parsed = videoCheckpointSchema.safeParse(task.result?.upstreamTask); const parsed = videoCheckpointSchema.safeParse(task.result?.upstreamTask);
if (!parsed.success) return null; if (!parsed.success) return null;
@ -41,7 +45,9 @@ export function getVideoCheckpoint(task: Pick<RecoveryTask, "request" | "result"
return null; return null;
return parsed.data; return parsed.data;
} }
export function getAudioCheckpoint(task: Pick<RecoveryTask, "request" | "result">) { export function getAudioCheckpoint(
task: Pick<RecoveryTask, "request" | "result">
) {
if (task.request.kind !== "audio") return null; if (task.request.kind !== "audio") return null;
const parsed = audioCheckpointSchema.safeParse(task.result?.upstreamAudio); const parsed = audioCheckpointSchema.safeParse(task.result?.upstreamAudio);
return parsed.success && parsed.data.modelRef === task.request.params.modelRef return parsed.success && parsed.data.modelRef === task.request.params.modelRef
@ -49,7 +55,7 @@ export function getAudioCheckpoint(task: Pick<RecoveryTask, "request" | "result"
: null; : null;
} }
export function getSmartEditCheckpoint( export function getSmartEditCheckpoint(
task: Pick<RecoveryTask, "request" | "result">, task: Pick<RecoveryTask, "request" | "result">
): SmartEditCheckpoint | null { ): SmartEditCheckpoint | null {
const value = task.result?.smartEdit; const value = task.result?.smartEdit;
if ( if (
@ -75,7 +81,8 @@ export function hasRecoverableCanvasTask(task: RecoveryTask, now = Date.now()) {
getEnhanceCheckpoint(task) || getEnhanceCheckpoint(task) ||
getVideoCheckpoint(task) || getVideoCheckpoint(task) ||
getAudioCheckpoint(task) || getAudioCheckpoint(task) ||
getSmartEditCheckpoint(task), getSmartEditCheckpoint(task) ||
getUpscaleCheckpoint(task)
) && ) &&
Number.isFinite(created) && Number.isFinite(created) &&
now >= created && now >= created &&
@ -84,8 +91,23 @@ export function hasRecoverableCanvasTask(task: RecoveryTask, now = Date.now()) {
); );
} }
export function canResumeCanvasTask(task: RecoveryTask, now = Date.now()) { export function canResumeCanvasTask(task: RecoveryTask, now = Date.now()) {
if (task.status !== "interrupted" || !hasRecoverableCanvasTask(task, now)) return false; if (task.status !== "interrupted" || !hasRecoverableCanvasTask(task, now))
return false;
const updated = Date.parse(task.updatedAt || ""); const updated = Date.parse(task.updatedAt || "");
const delay = Math.min(300000, 30000 * 2 ** Math.min(4, Math.max(0, (task.attempts || 1) - 1))); const delay = Math.min(
300000,
30000 * 2 ** Math.min(4, Math.max(0, (task.attempts || 1) - 1))
);
return Number.isFinite(updated) && now - updated >= delay; return Number.isFinite(updated) && now - updated >= delay;
} }
export function getUpscaleCheckpoint(
task: Pick<RecoveryTask, "request" | "result">
) {
const value = task.result?.upscale?.remote;
return task.request.kind === "video-upscale" &&
/^[a-f0-9]{64}$/.test(value?.id || "") &&
/^[a-f0-9]{64}$/.test(value?.endpointHash || "")
? (value as { id: string; endpointHash: string })
: null;
}

View File

@ -1,16 +1,20 @@
import { validateGroupBatch, groupDependencyState } from "./group-execution";
import { canResumeCanvasTask, hasRecoverableCanvasTask } from "./task-recovery"; import { canResumeCanvasTask, hasRecoverableCanvasTask } from "./task-recovery";
import "server-only"; import "server-only";
import { and, asc, desc, eq, gte, lt, sql } from "drizzle-orm"; import { and, asc, desc, eq, gte, lt, sql, inArray } from "drizzle-orm";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { canvasCapabilityTask as table, canvasProject } from "@/lib/db/schema"; import { canvasCapabilityTask as table, canvasProject } from "@/lib/db/schema";
import { type CanvasTaskRequest, type CanvasGenerationTask } from "./task-contract"; import {
type CanvasTaskRequest,
type CanvasGenerationTask,
} from "./task-contract";
const capabilityId = "video-canvas.generation"; const capabilityId = "video-canvas.generation";
const scope = (userId: string, projectId: string) => const scope = (userId: string, projectId: string) =>
and( and(
eq(table.userId, userId), eq(table.userId, userId),
eq(table.projectId, projectId), eq(table.projectId, projectId),
eq(table.capabilityId, capabilityId), eq(table.capabilityId, capabilityId)
); );
const serialize = (row: typeof table.$inferSelect): CanvasGenerationTask => ({ const serialize = (row: typeof table.$inferSelect): CanvasGenerationTask => ({
id: row.id, id: row.id,
@ -29,26 +33,37 @@ export async function ownsTaskProject(userId: string, projectId: string) {
await db await db
.select({ id: canvasProject.id }) .select({ id: canvasProject.id })
.from(canvasProject) .from(canvasProject)
.where(and(eq(canvasProject.id, projectId), eq(canvasProject.userId, userId))) .where(
and(eq(canvasProject.id, projectId), eq(canvasProject.userId, userId))
)
.limit(1) .limit(1)
)[0], )[0]
); );
} }
export async function createCanvasGenerationTask(userId: string, request: CanvasTaskRequest) { export async function createCanvasGenerationTask(
userId: string,
request: CanvasTaskRequest
) {
return db.transaction(async (tx) => { return db.transaction(async (tx) => {
await tx.execute( await tx.execute(
sql`select pg_advisory_xact_lock(hashtext(${capabilityId}), hashtext(${request.projectId}))`, sql`select pg_advisory_xact_lock(hashtext(${capabilityId}), hashtext(${request.projectId}))`
); );
const existing = ( const existing = (
await tx await tx
.select() .select()
.from(table) .from(table)
.where(and(scope(userId, request.projectId), eq(table.requestId, request.requestId))) .where(
and(
scope(userId, request.projectId),
eq(table.requestId, request.requestId)
)
)
.limit(1) .limit(1)
)[0]; )[0];
if (existing) { if (existing) {
if ( if (
JSON.stringify(existing.request) !== JSON.stringify(JSON.parse(JSON.stringify(request))) JSON.stringify(existing.request) !==
JSON.stringify(JSON.parse(JSON.stringify(request)))
) { ) {
// JSONB property order is not stable. Compare the immutable logical request. // JSONB property order is not stable. Compare the immutable logical request.
const old = existing.request as CanvasTaskRequest; const old = existing.request as CanvasTaskRequest;
@ -57,33 +72,46 @@ export async function createCanvasGenerationTask(userId: string, request: Canvas
old.kind !== request.kind || old.kind !== request.kind ||
JSON.stringify(sortObject(old.attachment)) !== JSON.stringify(sortObject(old.attachment)) !==
JSON.stringify(sortObject(request.attachment)) || JSON.stringify(sortObject(request.attachment)) ||
JSON.stringify(sortObject(old.params)) !== JSON.stringify(sortObject(request.params)) JSON.stringify(sortObject(old.params)) !==
JSON.stringify(sortObject(request.params))
) )
throw Object.assign(Error("同一任务 ID 不能用于不同请求"), { status: 409 }); throw Object.assign(Error("同一任务 ID 不能用于不同请求"), {
status: 409,
});
} }
return serialize(existing); return serialize(existing);
} }
const active = await tx const active = await tx
.select() .select()
.from(table) .from(table)
.where(and(scope(userId, request.projectId), sql`${table.status} in ('pending','running')`)); .where(
and(
scope(userId, request.projectId),
sql`${table.status} in ('pending','running')`
)
);
const duplicate = active.find( const duplicate = active.find(
(row) => (row.request as CanvasTaskRequest).node.id === request.node.id, (row) => (row.request as CanvasTaskRequest).node.id === request.node.id
); );
if (duplicate) return serialize(duplicate); if (duplicate) return serialize(duplicate);
const interrupted = await tx const interrupted = await tx
.select() .select()
.from(table) .from(table)
.where(and(scope(userId, request.projectId), eq(table.status, "interrupted"))); .where(
and(scope(userId, request.projectId), eq(table.status, "interrupted"))
);
const recovering = interrupted.find( const recovering = interrupted.find(
(row) => (row) =>
(row.request as CanvasTaskRequest).node.id === request.node.id && (row.request as CanvasTaskRequest).node.id === request.node.id &&
hasRecoverableCanvasTask(serialize(row)), hasRecoverableCanvasTask(serialize(row))
); );
if (recovering) return serialize(recovering); if (recovering) return serialize(recovering);
if (active.length >= 40) if (active.length >= 40)
throw Object.assign(Error("当前画布排队任务已达 40 个,请等待完成后继续"), { status: 429 }); throw Object.assign(
Error("当前画布排队任务已达 40 个,请等待完成后继续"),
{ status: 429 }
);
const [created] = await tx const [created] = await tx
.insert(table) .insert(table)
.values({ .values({
@ -107,11 +135,14 @@ function sortObject(value: any): any {
? Object.fromEntries( ? Object.fromEntries(
Object.keys(value) Object.keys(value)
.sort() .sort()
.map((key) => [key, sortObject(value[key])]), .map((key) => [key, sortObject(value[key])])
) )
: value; : value;
} }
export async function listCanvasGenerationTasks(userId: string, projectId: string) { export async function listCanvasGenerationTasks(
userId: string,
projectId: string
) {
// A worker lost to process shutdown must never silently re-submit a paid request. // A worker lost to process shutdown must never silently re-submit a paid request.
await db await db
.update(table) .update(table)
@ -125,8 +156,8 @@ export async function listCanvasGenerationTasks(userId: string, projectId: strin
and( and(
scope(userId, projectId), scope(userId, projectId),
eq(table.status, "running"), eq(table.status, "running"),
lt(table.updatedAt, new Date(Date.now() - 120000)), lt(table.updatedAt, new Date(Date.now() - 120000))
), )
); );
const rows = await db const rows = await db
.select() .select()
@ -136,10 +167,13 @@ export async function listCanvasGenerationTasks(userId: string, projectId: strin
.limit(240); .limit(240);
return rows.map(serialize).reverse(); return rows.map(serialize).reverse();
} }
export async function claimCanvasGenerationTask(userId: string, projectId: string) { export async function claimCanvasGenerationTask(
userId: string,
projectId: string
) {
return db.transaction(async (tx) => { return db.transaction(async (tx) => {
await tx.execute( await tx.execute(
sql`select pg_advisory_xact_lock(hashtext(${capabilityId}), hashtext(${projectId}))`, sql`select pg_advisory_xact_lock(hashtext(${capabilityId}), hashtext(${projectId}))`
); );
if ( if (
( (
@ -151,12 +185,57 @@ export async function claimCanvasGenerationTask(userId: string, projectId: strin
).length ).length
) )
return null; return null;
let [next] = await tx const pending = await tx
.select() .select()
.from(table) .from(table)
.where(and(scope(userId, projectId), eq(table.status, "pending"))) .where(and(scope(userId, projectId), eq(table.status, "pending")))
.orderBy(asc(table.createdAt)) .orderBy(asc(table.createdAt))
.limit(1); .limit(40);
const dependencyIds = [
...new Set(
pending.flatMap(
(row) =>
(row.request as CanvasTaskRequest).workflow?.inputs
.map((input) => input.requestId)
.filter((id): id is string => Boolean(id)) || []
)
),
];
const predecessors = dependencyIds.length
? await tx
.select()
.from(table)
.where(
and(
scope(userId, projectId),
inArray(table.requestId, dependencyIds)
)
)
: [];
const tasks = [
...new Map(
[...pending, ...predecessors].map((row) => [row.id, serialize(row)])
).values(),
];
let next: typeof table.$inferSelect | undefined;
for (const candidate of pending) {
const state = groupDependencyState(
candidate.request as CanvasTaskRequest,
tasks
);
if (state === "failed") {
await tx
.update(table)
.set({
status: "failed",
error: "上游任务失败或取消,未提交此节点",
updatedAt: new Date(),
completedAt: new Date(),
})
.where(eq(table.id, candidate.id));
tasks.find((t) => t.id === candidate.id)!.status = "failed";
} else if (state === "ready" && !next) next = candidate;
}
if (!next) { if (!next) {
const interrupted = await tx const interrupted = await tx
.select() .select()
@ -166,8 +245,8 @@ export async function claimCanvasGenerationTask(userId: string, projectId: strin
scope(userId, projectId), scope(userId, projectId),
eq(table.status, "interrupted"), eq(table.status, "interrupted"),
gte(table.createdAt, new Date(Date.now() - 86400000)), gte(table.createdAt, new Date(Date.now() - 86400000)),
lt(table.attempts, 20), lt(table.attempts, 20)
), )
) )
.orderBy(asc(table.updatedAt)); .orderBy(asc(table.updatedAt));
next = interrupted.find((row) => canResumeCanvasTask(serialize(row)))!; next = interrupted.find((row) => canResumeCanvasTask(serialize(row)))!;
@ -187,7 +266,10 @@ export async function claimCanvasGenerationTask(userId: string, projectId: strin
return claimed ? serialize(claimed) : null; return claimed ? serialize(claimed) : null;
}); });
} }
export async function heartbeatCanvasGenerationTask(id: string, attempt?: number) { export async function heartbeatCanvasGenerationTask(
id: string,
attempt?: number
) {
await db await db
.update(table) .update(table)
.set({ updatedAt: new Date() }) .set({ updatedAt: new Date() })
@ -195,15 +277,15 @@ export async function heartbeatCanvasGenerationTask(id: string, attempt?: number
and( and(
eq(table.id, id), eq(table.id, id),
eq(table.status, "running"), eq(table.status, "running"),
attempt === undefined ? undefined : eq(table.attempts, attempt), attempt === undefined ? undefined : eq(table.attempts, attempt)
), )
); );
} }
export async function finishCanvasGenerationTask( export async function finishCanvasGenerationTask(
id: string, id: string,
result?: Record<string, any>, result?: Record<string, any>,
error?: string, error?: string,
attempt?: number, attempt?: number
) { ) {
// A late real result can recover an interrupted task, but cannot replace a newer request ID. // A late real result can recover an interrupted task, but cannot replace a newer request ID.
await db await db
@ -219,11 +301,15 @@ export async function finishCanvasGenerationTask(
and( and(
eq(table.id, id), eq(table.id, id),
sql`${table.status} in ('running','interrupted')`, sql`${table.status} in ('running','interrupted')`,
attempt === undefined ? undefined : eq(table.attempts, attempt), attempt === undefined ? undefined : eq(table.attempts, attempt)
), )
); );
} }
export async function cancelCanvasGenerationTask(userId: string, projectId: string, id: string) { export async function cancelCanvasGenerationTask(
userId: string,
projectId: string,
id: string
) {
const [cancelled] = await db const [cancelled] = await db
.update(table) .update(table)
.set({ .set({
@ -232,7 +318,13 @@ export async function cancelCanvasGenerationTask(userId: string, projectId: stri
updatedAt: new Date(), updatedAt: new Date(),
completedAt: new Date(), completedAt: new Date(),
}) })
.where(and(scope(userId, projectId), eq(table.id, id), eq(table.status, "pending"))) .where(
and(
scope(userId, projectId),
eq(table.id, id),
eq(table.status, "pending")
)
)
.returning(); .returning();
if (cancelled) return { ok: true, task: serialize(cancelled) }; if (cancelled) return { ok: true, task: serialize(cancelled) };
const [current] = await db const [current] = await db
@ -240,13 +332,16 @@ export async function cancelCanvasGenerationTask(userId: string, projectId: stri
.from(table) .from(table)
.where(and(scope(userId, projectId), eq(table.id, id))) .where(and(scope(userId, projectId), eq(table.id, id)))
.limit(1); .limit(1);
return { ok: current?.status === "cancelled", task: current ? serialize(current) : null }; return {
ok: current?.status === "cancelled",
task: current ? serialize(current) : null,
};
} }
export async function hideCanvasGenerationNode( export async function hideCanvasGenerationNode(
userId: string, userId: string,
projectId: string, projectId: string,
id: string, id: string,
nodeId: string, nodeId: string
) { ) {
return db.transaction(async (tx) => { return db.transaction(async (tx) => {
const [task] = await tx const [task] = await tx
@ -255,15 +350,22 @@ export async function hideCanvasGenerationNode(
.where(and(scope(userId, projectId), eq(table.id, id))) .where(and(scope(userId, projectId), eq(table.id, id)))
.for("update"); .for("update");
if (!task) return false; if (!task) return false;
const request = task.request as CanvasTaskRequest & { hiddenNodeIds?: string[] }; const request = task.request as CanvasTaskRequest & {
if (nodeId !== request.node.id && !nodeId.startsWith(`${request.node.id}-result-`)) hiddenNodeIds?: string[];
};
if (
nodeId !== request.node.id &&
!nodeId.startsWith(`${request.node.id}-result-`)
)
return false; return false;
await tx await tx
.update(table) .update(table)
.set({ .set({
request: { request: {
...request, ...request,
hiddenNodeIds: [...new Set([...(request.hiddenNodeIds || []), nodeId])], hiddenNodeIds: [
...new Set([...(request.hiddenNodeIds || []), nodeId]),
],
}, },
}) })
.where(eq(table.id, id)); .where(eq(table.id, id));
@ -274,7 +376,7 @@ export async function hideCanvasGenerationNode(
export async function checkpointCanvasGenerationTask( export async function checkpointCanvasGenerationTask(
id: string, id: string,
result: Record<string, unknown>, result: Record<string, unknown>,
attempt?: number, attempt?: number
) { ) {
await db await db
.update(table) .update(table)
@ -283,11 +385,15 @@ export async function checkpointCanvasGenerationTask(
and( and(
eq(table.id, id), eq(table.id, id),
eq(table.status, "running"), eq(table.status, "running"),
attempt === undefined ? undefined : eq(table.attempts, attempt), attempt === undefined ? undefined : eq(table.attempts, attempt)
), )
); );
} }
export async function interruptCanvasGenerationTask(id: string, error: string, attempt?: number) { export async function interruptCanvasGenerationTask(
id: string,
error: string,
attempt?: number
) {
await db await db
.update(table) .update(table)
.set({ status: "interrupted", error, updatedAt: new Date() }) .set({ status: "interrupted", error, updatedAt: new Date() })
@ -295,8 +401,8 @@ export async function interruptCanvasGenerationTask(id: string, error: string, a
and( and(
eq(table.id, id), eq(table.id, id),
eq(table.status, "running"), eq(table.status, "running"),
attempt === undefined ? undefined : eq(table.attempts, attempt), attempt === undefined ? undefined : eq(table.attempts, attempt)
), )
); );
} }
@ -304,7 +410,114 @@ export async function ownsCanvasTaskLease(id: string, attempt: number) {
const [row] = await db const [row] = await db
.select({ id: table.id }) .select({ id: table.id })
.from(table) .from(table)
.where(and(eq(table.id, id), eq(table.attempts, attempt), eq(table.status, "running"))) .where(
and(
eq(table.id, id),
eq(table.attempts, attempt),
eq(table.status, "running")
)
)
.limit(1); .limit(1);
return Boolean(row); return Boolean(row);
} }
export async function createCanvasGroupTasks(
userId: string,
requests: CanvasTaskRequest[]
) {
validateGroupBatch(requests);
return db.transaction(async (tx) => {
const projectId = requests[0].projectId;
await tx.execute(
sql`select pg_advisory_xact_lock(hashtext(${capabilityId}), hashtext(${projectId}))`
);
const same = await tx
.select()
.from(table)
.where(
and(
scope(userId, projectId),
sql`${table.request}->'workflow'->>'runId' = ${requests[0].workflow!.runId}`
)
);
if (same.length) {
if (
same.length !== requests.length ||
requests.some((r) => {
const old = same.find((t) => t.requestId === r.requestId);
return (
!old ||
JSON.stringify(sortObject(old.request)) !==
JSON.stringify(sortObject(r))
);
})
)
throw Object.assign(Error("同一编组执行编号不能用于不同参数"), {
status: 409,
});
return requests.map((request) =>
serialize(same.find((row) => row.requestId === request.requestId)!)
);
}
const candidates = await tx
.select()
.from(table)
.where(
and(
scope(userId, projectId),
sql`(${table.status} in ('pending','running') or (${table.status}='interrupted' and ${table.createdAt} > now() - interval '24 hours'))`
)
);
const active = candidates.filter(
(t) =>
["pending", "running"].includes(t.status) ||
(t.status === "interrupted" && hasRecoverableCanvasTask(serialize(t)))
);
if (active.length + requests.length > 40)
throw Object.assign(Error("画布任务队列已满"), { status: 429 });
if (
active.some((t) =>
requests.some(
(r) => r.node.id === (t.request as CanvasTaskRequest).node.id
)
)
)
throw Object.assign(Error("编组中已有节点正在执行,请等待完成"), {
status: 409,
});
const now = Date.now();
const rows = await tx
.insert(table)
.values(
requests.map((request, i) => ({
requestId: request.requestId,
userId,
projectId,
capabilityId,
source: "video-canvas",
request,
createdAt: new Date(now + i),
updatedAt: new Date(now),
}))
)
.returning();
return rows.map(serialize);
});
}
export async function groupTaskInputs(
userId: string,
request: CanvasTaskRequest
) {
const rows = await db
.select()
.from(table)
.where(
and(
scope(userId, request.projectId),
sql`${table.request}->'workflow'->>'runId' = ${request.workflow?.runId || ""}`
)
);
return rows
.map(serialize)
.filter((t) => t.request.workflow?.runId === request.workflow?.runId);
}

View File

@ -1,3 +1,7 @@
import { resolveGroupInputs } from "./group-execution";
import { groupTaskInputs } from "./task-store";
import { executeVideoUpscale } from "./video-upscale";
import { getUpscaleCheckpoint } from "./task-recovery";
import { executeSmartEdit } from "./smart-edit/execute"; import { executeSmartEdit } from "./smart-edit/execute";
import { executeCanvasAudio } from "./audio-generation"; import { executeCanvasAudio } from "./audio-generation";
import { ProviderTaskUncertainError } from "./video-task-lifecycle"; import { ProviderTaskUncertainError } from "./video-task-lifecycle";
@ -13,7 +17,10 @@ import {
ImageOperationUncertainError, ImageOperationUncertainError,
CanvasTaskLeaseLostError, CanvasTaskLeaseLostError,
} from "./image-operation"; } from "./image-operation";
import { executeEnhancePOST, executeEnhanceGET } from "@/lib/canvas/operations/enhance"; import {
executeEnhancePOST,
executeEnhanceGET,
} from "@/lib/canvas/operations/enhance";
import { executeExtendPOST } from "@/lib/canvas/operations/extend"; import { executeExtendPOST } from "@/lib/canvas/operations/extend";
import { executeRemoveBackgroundPOST } from "@/lib/canvas/operations/rmbg"; import { executeRemoveBackgroundPOST } from "@/lib/canvas/operations/rmbg";
import { executeAngleEditPOST } from "@/lib/canvas/operations/angle-edit"; import { executeAngleEditPOST } from "@/lib/canvas/operations/angle-edit";
@ -26,7 +33,10 @@ import "server-only";
import { executeDirectVideoGeneration } from "@/lib/ai/tools/generate-video"; import { executeDirectVideoGeneration } from "@/lib/ai/tools/generate-video";
import { executeCanvasImageResponse } from "@/lib/canvas/generate-image-response"; import { executeCanvasImageResponse } from "@/lib/canvas/generate-image-response";
import { executeWorkflowAnalysisResponse } from "./workflow-analysis-response"; import { executeWorkflowAnalysisResponse } from "./workflow-analysis-response";
import { requireAnalysisResult, type CanvasGenerationTask } from "./task-contract"; import {
requireAnalysisResult,
type CanvasGenerationTask,
} from "./task-contract";
import { validateVideoReferences } from "./reference-input"; import { validateVideoReferences } from "./reference-input";
import { probeRemoteUrl } from "@/lib/net/url-access"; import { probeRemoteUrl } from "@/lib/net/url-access";
import { import {
@ -46,21 +56,40 @@ async function execute(task: CanvasGenerationTask, userId: string) {
const videoResume = getVideoCheckpoint(task); const videoResume = getVideoCheckpoint(task);
const audioResume = getAudioCheckpoint(task); const audioResume = getAudioCheckpoint(task);
const editResume = getSmartEditCheckpoint(task); const editResume = getSmartEditCheckpoint(task);
if ((task.attempts || 1) > 1 && !resume && !videoResume && !audioResume && !editResume) const upscaleResume = getUpscaleCheckpoint(task);
if (
(task.attempts || 1) > 1 &&
!resume &&
!videoResume &&
!audioResume &&
!editResume &&
!upscaleResume
)
throw Error("缺少上游任务编号,禁止重新提交"); throw Error("缺少上游任务编号,禁止重新提交");
const params = request.params; const params = request.workflow
? resolveGroupInputs(request, await groupTaskInputs(userId, request))
: request.params;
if (request.kind === "video-upscale")
return executeVideoUpscale(params, userId, task.id, {
resume: upscaleResume || undefined,
active: assertLease,
save: (value) =>
checkpointCanvasGenerationTask(task.id, value, task.attempts),
});
if (request.kind === "smart-edit") if (request.kind === "smart-edit")
return executeSmartEdit(params, userId, { return executeSmartEdit(params, userId, {
taskId: task.id, taskId: task.id,
resume: editResume, resume: editResume,
assertActive: assertLease, assertActive: assertLease,
checkpoint: (value) => checkpointCanvasGenerationTask(task.id, value, task.attempts), checkpoint: (value) =>
checkpointCanvasGenerationTask(task.id, value, task.attempts),
}); });
if (request.kind === "audio") if (request.kind === "audio")
return executeCanvasAudio(params, { return executeCanvasAudio(params, {
resume: audioResume, resume: audioResume,
assertActive: assertLease, assertActive: assertLease,
checkpoint: (value) => checkpointCanvasGenerationTask(task.id, value, task.attempts), checkpoint: (value) =>
checkpointCanvasGenerationTask(task.id, value, task.attempts),
}); });
if (request.kind === "image-operation") if (request.kind === "image-operation")
return executeImageOperation( return executeImageOperation(
@ -76,14 +105,15 @@ async function execute(task: CanvasGenerationTask, userId: string) {
{ {
resume, resume,
assertLease, assertLease,
checkpoint: (result) => checkpointCanvasGenerationTask(task.id, result, task.attempts), checkpoint: (result) =>
}, checkpointCanvasGenerationTask(task.id, result, task.attempts),
}
); );
if (request.kind === "video") { if (request.kind === "video") {
const checked = videoResume const checked = videoResume
? { images: params.images as string[] | undefined, error: undefined } ? { images: params.images as string[] | undefined, error: undefined }
: await validateVideoReferences(params.images, (url) => : await validateVideoReferences(params.images, (url) =>
probeRemoteUrl(url, { timeoutMs: 8000 }), probeRemoteUrl(url, { timeoutMs: 8000 })
); );
if (checked.error) throw Error(checked.error); if (checked.error) throw Error(checked.error);
const result = await executeDirectVideoGeneration({ const result = await executeDirectVideoGeneration({
@ -95,7 +125,11 @@ async function execute(task: CanvasGenerationTask, userId: string) {
resume: videoResume, resume: videoResume,
assertActive: assertLease, assertActive: assertLease,
save: (checkpoint) => save: (checkpoint) =>
checkpointCanvasGenerationTask(task.id, { upstreamTask: checkpoint }, task.attempts), checkpointCanvasGenerationTask(
task.id,
{ upstreamTask: checkpoint },
task.attempts
),
}, },
}); });
if (!result.success || !result.videoUrl) throw Error("视频生成未返回结果"); if (!result.success || !result.videoUrl) throw Error("视频生成未返回结果");
@ -105,7 +139,9 @@ async function execute(task: CanvasGenerationTask, userId: string) {
...result.metadata, ...result.metadata,
temporaryVideoUrl: result.videoUrl, temporaryVideoUrl: result.videoUrl,
storageStatus: storageStatus:
result.metadata?.permanentVideoUrl === result.videoUrl ? "archived" : "temporary", result.metadata?.permanentVideoUrl === result.videoUrl
? "archived"
: "temporary",
}, },
modelId: result.modelId, modelId: result.modelId,
modelRef: result.modelRef, modelRef: result.modelRef,
@ -115,35 +151,44 @@ async function execute(task: CanvasGenerationTask, userId: string) {
request.kind === "image" request.kind === "image"
? await executeCanvasImageResponse( ? await executeCanvasImageResponse(
{ ...params, prompt: String(params.prompt || ""), async: false }, { ...params, prompt: String(params.prompt || ""), async: false },
userId, userId
) )
: await executeWorkflowAnalysisResponse(params); : await executeWorkflowAnalysisResponse(params);
const body = await response.json(); const body = await response.json();
if (!response.ok || !body.success) throw Error(body.error || "任务执行失败"); if (!response.ok || !body.success) throw Error(body.error || "任务执行失败");
if ( if (
request.kind === "image" && request.kind === "image" &&
!body.data?.images?.some((image: any) => image.originalUrl || image.thumbnailUrl) !body.data?.images?.some(
(image: any) => image.originalUrl || image.thumbnailUrl
)
) )
throw Error("图片生成没有返回结果"); throw Error("图片生成没有返回结果");
if (request.kind === "analysis") requireAnalysisResult(body.data, String(params.mode)); if (request.kind === "analysis")
requireAnalysisResult(body.data, String(params.mode));
requireStoryPromptResult(body.data, request.attachment); requireStoryPromptResult(body.data, request.attachment);
return body.data; return body.data;
} }
export async function runCanvasGenerationQueue(userId: string, projectId: string) { export async function runCanvasGenerationQueue(
userId: string,
projectId: string
) {
// Work is independent of the browser request. Atomic DB claiming also covers multiple processes. // Work is independent of the browser request. Atomic DB claiming also covers multiple processes.
for (let i = 0; i < 40; i++) { for (let i = 0; i < 40; i++) {
const task = await claimCanvasGenerationTask(userId, projectId); const task = await claimCanvasGenerationTask(userId, projectId);
if (!task) return; if (!task) return;
const timer = setInterval( const timer = setInterval(
() => void heartbeatCanvasGenerationTask(task.id, task.attempts).catch(() => {}), () =>
20000, void heartbeatCanvasGenerationTask(task.id, task.attempts).catch(
() => {}
),
20000
); );
try { try {
await finishCanvasGenerationTask( await finishCanvasGenerationTask(
task.id, task.id,
await execute(task, userId), await execute(task, userId),
undefined, undefined,
task.attempts, task.attempts
); );
} catch (error) { } catch (error) {
if (error instanceof CanvasTaskLeaseLostError) return; if (error instanceof CanvasTaskLeaseLostError) return;
@ -151,14 +196,18 @@ export async function runCanvasGenerationQueue(userId: string, projectId: string
error instanceof ImageOperationUncertainError || error instanceof ImageOperationUncertainError ||
error instanceof ProviderTaskUncertainError error instanceof ProviderTaskUncertainError
) { ) {
await interruptCanvasGenerationTask(task.id, error.message, task.attempts); await interruptCanvasGenerationTask(
task.id,
error.message,
task.attempts
);
continue; continue;
} }
await finishCanvasGenerationTask( await finishCanvasGenerationTask(
task.id, task.id,
undefined, undefined,
error instanceof Error ? error.message : "任务执行失败", error instanceof Error ? error.message : "任务执行失败",
task.attempts, task.attempts
); );
} finally { } finally {
clearInterval(timer); clearInterval(timer);

View File

@ -0,0 +1,33 @@
import { z } from "zod";
export const videoUpscaleSchema = z.object({
url: z
.string()
.url()
.refine((v) => /^https?:\/\//.test(v)),
resolution: z.enum(["1080P", "2K", "4K"]),
});
export type VideoUpscaleInput = z.infer<typeof videoUpscaleSchema>;
export function upscaleDimensions(
width: number,
height: number,
resolution: VideoUpscaleInput["resolution"]
) {
if (![width, height].every((v) => Number.isInteger(v) && v > 0))
throw Error("视频尺寸无效");
const [long, short] =
resolution === "4K"
? [3840, 2160]
: resolution === "2K"
? [2560, 1440]
: [1920, 1080];
const ratio = Math.min(
(width >= height ? long : short) / width,
(width >= height ? short : long) / height
);
if (ratio <= 1) throw Error("源视频已达到所选分辨率,请选择更高分辨率");
if (ratio > 4) throw Error("单次 AI 超分最大 4 倍,请先选择较低目标分辨率");
return {
width: Math.floor((width * ratio) / 2) * 2,
height: Math.floor((height * ratio) / 2) * 2,
};
}

View File

@ -0,0 +1,181 @@
import { createHash } from "node:crypto";
import { createWriteStream } from "node:fs";
import { Readable, Transform } from "node:stream";
import { pipeline } from "node:stream/promises";
import path from "node:path";
import { z } from "zod";
import type { VideoUpscaleInput } from "./video-upscale-contract";
import { ProviderTaskUncertainError } from "./video-task-lifecycle";
export type UpscaleCheckpoint = { id: string; endpointHash: string };
const sha = (value: string) => createHash("sha256").update(value).digest("hex");
export function upscaleConfig() {
const value = process.env.VIDEO_UPSCALE_URL;
if (!value) return null;
const url = new URL(value);
if (
!["http:", "https:"].includes(url.protocol) ||
url.username ||
url.password ||
url.search ||
url.hash
)
throw Error("视频超分服务地址配置无效");
const base = value.replace(/\/$/, ""),
token = process.env.VIDEO_UPSCALE_TOKEN;
if (!token || token.length < 32) throw Error("视频超分服务认证未配置");
return { base, token };
}
const statusSchema = z.object({
id: z.string().regex(/^[a-f0-9]{64}$/),
status: z.enum(["queued", "running", "succeeded", "failed"]),
stage: z.string().max(300),
error: z.string().max(500).optional(),
result: z
.object({
width: z.number().int().positive().max(3840),
height: z.number().int().positive().max(3840),
durationSeconds: z.number().positive().max(120.2),
fps: z.number().positive().max(60),
frames: z.number().int().positive().max(7200),
audio: z.boolean(),
})
.optional(),
});
export async function remoteUpscale(
plan: VideoUpscaleInput,
userId: string,
taskId: string,
dir: string,
lifecycle: {
resume?: UpscaleCheckpoint;
active: () => Promise<void>;
save: (checkpoint: UpscaleCheckpoint, stage: string) => Promise<void>;
}
) {
const config = upscaleConfig();
if (!config)
throw Error(
lifecycle.resume
? "远程超分任务的服务配置已移除,不能回退本机重复超分"
: "视频高清服务尚未接通"
);
const checkpoint = lifecycle.resume || {
id: sha("video-upscale-v1:" + userId + ":" + taskId),
endpointHash: sha(config.base),
};
if (
!/^[a-f0-9]{64}$/.test(checkpoint.id) ||
checkpoint.endpointHash !== sha(config.base)
)
throw Error("超分任务所属服务器已变化,请恢复原服务配置后继续");
await lifecycle.save(checkpoint, "提交算力服务器");
const headers = {
authorization: "Bearer " + config.token,
"content-type": "application/json",
};
async function request(route: string, body?: unknown) {
await lifecycle.active();
let response: Response;
try {
response = await fetch(config!.base + route, {
method: body ? "POST" : "GET",
headers,
body: body ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(20000),
redirect: "error",
cache: "no-store",
});
} catch {
throw new ProviderTaskUncertainError(
"算力服务器连接中断,任务已保存,可继续查询"
);
}
if ([429, 500, 502, 503, 504].includes(response.status))
throw new ProviderTaskUncertainError(
"算力服务器繁忙,任务已保存等待继续"
);
if (!response.ok)
throw Error(
`超分服务请求失败(${response.status}),请检查服务状态或任务参数`
);
let data: unknown;
try {
data = await response.json();
} catch {
throw new ProviderTaskUncertainError("超分服务响应中断,将查询同一任务");
}
const result = statusSchema.parse(data);
if (result.id !== checkpoint.id)
throw Error("超分服务返回了不匹配的任务编号");
return result;
}
let status = await request("/v1/upscales", {
id: checkpoint.id,
tenant: sha(userId),
plan,
});
const deadline = Date.now() + 8 * 60000;
while (status.status === "queued" || status.status === "running") {
await lifecycle.save(checkpoint, status.stage);
if (Date.now() > deadline)
throw new ProviderTaskUncertainError("远程超分仍在运行,已保存任务进度");
await new Promise((resolve) => setTimeout(resolve, 3000));
status = await request("/v1/upscales/" + checkpoint.id);
}
if (status.status === "failed")
throw Error(status.error || "算力服务器超分失败");
if (!status.result) throw Error("超分服务未返回成片参数");
await lifecycle.save(checkpoint, "读取远程成片");
async function download(kind: string, destination: string, maxBytes: number) {
await lifecycle.active();
try {
const response = await fetch(
config!.base + "/v1/upscales/" + checkpoint.id + "/" + kind,
{
headers,
signal: AbortSignal.timeout(180000),
redirect: "error",
cache: "no-store",
}
);
if (
!response.ok ||
!response.body ||
Number(response.headers.get("content-length")) > maxBytes
)
throw Error("远程成片不可读取或超过处理上限");
let size = 0;
const limit = new Transform({
transform(chunk, _encoding, callback) {
size += chunk.length;
callback(
size > maxBytes ? Error("远程成片超过处理上限") : null,
chunk
);
},
});
await pipeline(
Readable.fromWeb(response.body as any),
limit,
createWriteStream(destination, { mode: 0o600 })
);
} catch {
throw new ProviderTaskUncertainError(
"成片已生成但读取中断,将继续读取同一任务"
);
}
}
const file = path.join(dir, "result.mp4");
await download("video", file, 300 * 1024 * 1024);
return {
file,
width: status.result.width,
height: status.result.height,
duration: status.result.durationSeconds,
fps: status.result.fps,
frames: status.result.frames,
audio: status.result.audio,
};
}

View File

@ -0,0 +1,62 @@
import { ProviderTaskUncertainError } from "./video-task-lifecycle";
import "server-only";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { uploadFile } from "@/lib/storage/service";
import { videoUpscaleSchema } from "./video-upscale-contract";
import { remoteUpscale, type UpscaleCheckpoint } from "./video-upscale-remote";
export async function executeVideoUpscale(
params: Record<string, unknown>,
userId: string,
taskId: string,
lifecycle: {
resume?: UpscaleCheckpoint;
active: () => Promise<void>;
save: (value: Record<string, unknown>) => Promise<void>;
}
) {
const input = videoUpscaleSchema.parse(params),
dir = await mkdtemp(path.join(tmpdir(), "upscale-"));
try {
const output = await remoteUpscale(input, userId, taskId, dir, {
resume: lifecycle.resume,
active: lifecycle.active,
save: (remote, stage) => lifecycle.save({ upscale: { remote, stage } }),
});
await lifecycle.active();
const file = await readFile(output.file);
let stored;
try {
stored = await uploadFile(
`video-upscale-${taskId}.mp4`,
(file.byteOffset === 0 && file.byteLength === file.buffer.byteLength
? file.buffer
: file.buffer.slice(
file.byteOffset,
file.byteOffset + file.byteLength
)) as ArrayBuffer
);
} catch {
throw new ProviderTaskUncertainError(
"高清结果已生成,归档暂时失败,将继续归档同一结果"
);
}
return {
url: stored.url,
metadata: {
width: output.width,
height: output.height,
durationSeconds: output.duration,
fps: output.fps,
frames: output.frames,
hasAudio: output.audio,
engine: "Real-ESRGAN",
model: "realesr-general-x4v3",
resolution: input.resolution,
},
};
} finally {
await rm(dir, { recursive: true, force: true });
}
}

View File

@ -37,8 +37,9 @@ const nextConfig: NextConfig = {
// 外部包配置(Next.js 16+) // 外部包配置(Next.js 16+)
serverExternalPackages: ["sharp"], serverExternalPackages: ["sharp"],
outputFileTracingIncludes: { outputFileTracingIncludes: {
"/api/canvas/generation-tasks": ["./services/smart-edit/analyze.py"], "/api/canvas/generation-tasks": ["./services/smart-edit/analyze.py", "./services/smart-edit/separate.py", "./services/smart-edit/font.py"],
"/api/canvas/smart-edit/capabilities": ["./services/smart-edit/analyze.py"], "/api/canvas/smart-edit/fonts/[id]": ["./services/smart-edit/font.py"],
"/api/canvas/smart-edit/capabilities": ["./services/smart-edit/analyze.py", "./services/smart-edit/separate.py", "./services/smart-edit/font.py"],
}, },
transpilePackages: ["@sparkjsdev/spark"], transpilePackages: ["@sparkjsdev/spark"],
typescript: { typescript: {

View File

@ -3,6 +3,8 @@ import { defineConfig } from "@playwright/test";
export default defineConfig({ export default defineConfig({
testDir: "./tests", testDir: "./tests",
testMatch: [ testMatch: [
/routes\/video-canvas-groups-upscale\.test\.ts/,
/routes\/video-canvas-connections\.test\.ts/,
/routes\/video-canvas-smart-edit\.test\.ts/, /routes\/video-canvas-smart-edit\.test\.ts/,
/routes\/video-canvas-tasks\.test\.ts/, /routes\/video-canvas-tasks\.test\.ts/,
/routes\/video-canvas-director\.test\.ts/, /routes\/video-canvas-director\.test\.ts/,

View File

@ -0,0 +1,36 @@
[
{
"file": "roboto.ttf",
"upstreamSha256": "d7598e12c5dbef095ff8272cfc55da0250bd07fbdecbac8a530b9b277872a134",
"sha256": "b55c3f5427be8b29e743ebc6a64128af5824c0cffd198fcfc643e4c5eafde0c8",
"family": "Roboto",
"weight": 400
},
{
"file": "noto-serif-sc.ttf",
"upstreamSha256": "050080d9255a86808f2945bffac582b31ef32bc36411ce29563b4961670c66f9",
"sha256": "08ac21d3d82d929cb493f2523532dd32dce1d797964de493071b64df87afa6ed",
"family": "Noto Serif SC ExtraLight",
"weight": 400
},
{
"file": "noto-sans-sc.ttf",
"upstreamSha256": "a3041811a78c361b1de50f953c805e0244951c21c5bd412f7232ef0d899af0da",
"sha256": "36716fbd3b964480d238030cab171b0ea756e959adc3d323995e2433a06c61e5",
"family": "Noto Sans SC Thin",
"weight": 400
},
{
"file": "inter.ttf",
"upstreamSha256": "29160a80ff49ddcab2c97711247e08b1fab27a484a329ce8b813d820dc559031",
"sha256": "54d85e725cf1f763acda27f27bda373f0d0b9117fbf6f1d9015f04004d1ebd3d",
"family": "Inter",
"weight": 400
},
{
"file": "source-han.otf",
"sha256": "f1d8611151880c6c336aabeac4640ef434fa13cbfbf1ffe82d0a71b2a5637256",
"family": "Source Han Sans SC",
"weight": 400
}
]

View File

@ -0,0 +1,9 @@
# 智能剪辑字幕字体
来源:Adobe 官方 [Source Han Sans](https://github.com/adobe-fonts/source-han-sans/tree/release/OTF/SimplifiedChinese) 与 [Google Fonts](https://github.com/google/fonts/tree/main/ofl)。各字体的完整 SIL Open Font License 文本随文件保存。`SOURCES.json` 保存下载来源;`MANIFEST.json` 保存文件 SHA-256 和上游下载摘要。
Google 的可变 TTF 使用 fontTools 4.59.1 实例化为 400 字重,其他轴保持上游默认值,以让浏览器与 libass 使用相同静态轮廓。Adobe 文件为未修改的 Regular OTF。Source Han 在选单保留参考名称 Source Han Sans CN,实际文件的内置家族名为 Source Han Sans SC;Google CJK 文件保留上游 name 表,其排版家族名由 `nameID=16` 定义,旧版家族名可能包含 Thin/ExtraLight,不代表当前实例的字重。
浏览器字体按需加载,使用所选 TTF/OTF 原文件;导出把同一文件和中文回退 Source Han 复制到任务的 `fonts/` 目录,并通过 libass `fontsdir` 引用。构建独立合成服务时会把整个字体包复制到 `dist/fonts`。
这五个实际可用字体不等于参考站完整目录;未添加只有名称、没有字体文件的假选项。

View File

@ -0,0 +1,37 @@
[
[
"source-han",
"adobe-fonts/source-han-sans",
"release",
"OTF/SimplifiedChinese/SourceHanSansSC-Regular.otf",
"LICENSE.txt"
],
[
"noto-sans-sc",
"google/fonts",
"main",
"ofl/notosanssc/NotoSansSC[wght].ttf",
"ofl/notosanssc/OFL.txt"
],
[
"noto-serif-sc",
"google/fonts",
"main",
"ofl/notoserifsc/NotoSerifSC[wght].ttf",
"ofl/notoserifsc/OFL.txt"
],
[
"roboto",
"google/fonts",
"main",
"ofl/roboto/Roboto[wdth,wght].ttf",
"ofl/roboto/OFL.txt"
],
[
"inter",
"google/fonts",
"main",
"ofl/inter/Inter[opsz,wght].ttf",
"ofl/inter/OFL.txt"
]
]

View File

@ -0,0 +1,93 @@
Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

View File

@ -0,0 +1,93 @@
Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

View File

@ -0,0 +1,94 @@
Copyright 2012 Google Inc. All Rights Reserved.
This Font Software is licensed under the SIL Open Font License,
Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to
provide a free and open framework in which fonts may be shared and
improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software
components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to,
deleting, or substituting -- in part or in whole -- any of the
components of the Original Version, by changing formats or by porting
the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed,
modify, redistribute, and sell modified and unmodified copies of the
Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created using
the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

View File

@ -0,0 +1,93 @@
Copyright 2011 The Roboto Project Authors (https://github.com/googlefonts/roboto-classic)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

View File

@ -0,0 +1,96 @@
Copyright 2014-2025 Adobe (http://www.adobe.com/), with Reserved Font
Name 'Source'. Source is a trademark of Adobe in the United States
and/or other countries.
This Font Software is licensed under the SIL Open Font License,
Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to
provide a free and open framework in which fonts may be shared and
improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software
components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to,
deleting, or substituting -- in part or in whole -- any of the
components of the Original Version, by changing formats or by porting
the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed,
modify, redistribute, and sell modified and unmodified copies of the
Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created using
the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

View File

@ -0,0 +1,13 @@
# 转场库动态预览素材
按用户明确要求从参考站可见的转场卡片取得原始动态 WebP,原字节保存,不截成静态图片、不生成替代图,也不使用运行时外链。每张为 75 帧、3 秒循环。
来源页面:https://www.liblib.tv/canvas
| 本地文件 | 参考页面实际图片地址 | SHA-256 |
| --- | --- | --- |
| fade.webp | https://libtv-res.liblib.art/upload-images/7769a090dda6411dbed73c8da22ea01e/52744c87f20f2d682118c51360237c8ecf2e5cf0.webp | bee8d24aa6db9c4898645e4540fa647fbd720d2640ecda823be26b1c7ef59579 |
| fadeblack.webp | https://libtv-res.liblib.art/upload-images/7769a090dda6411dbed73c8da22ea01e/33b60ac24835284581b432ec3dc6770c28c4cdfd.webp | dbfdb0f9b17389a5239fecee2322d7eef64a9cb387600d7b77e9ec0a05408f66 |
| fadewhite.webp | https://libtv-res.liblib.art/upload-images/7769a090dda6411dbed73c8da22ea01e/887e2a7ed6539e258a98fa01d003ca60cb1a4c79.webp | 648d6025e39206ddbc5f63b8a6415d62b5a44f1ca9d58822d9df58928dec9ce9 |
卡片图片只用于展示效果类别。右侧预览与成片合成使用用户选中的相邻素材,不把示例图作为用户成片素材。

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@ -0,0 +1,214 @@
// Real local Next/auth/database/storage/task/model/render chain. Requires an isolated account.json and speech.mp4 in SMART_EDIT_QA_DIR. Never target production.
const fs = require("fs"),
crypto = require("crypto"),
path = require("path"),
assert = require("assert/strict");
const q = path.resolve(process.env.SMART_EDIT_QA_DIR || ".qa/smart-edit/main-chain"),
base = process.env.SMART_EDIT_QA_URL || "http://127.0.0.1:3025";
assert(
["127.0.0.1", "localhost"].includes(new URL(base).hostname),
"Use an isolated local server only",
);
fs.mkdirSync(q, { recursive: true });
let cookies = {};
let report = [];
async function request(p, o = {}) {
const r = await fetch(base + p, {
...o,
headers: {
...o.headers,
Cookie: Object.entries(cookies)
.map(([k, v]) => k + "=" + v)
.join("; "),
},
redirect: "manual",
signal: AbortSignal.timeout(180000),
});
for (const c of r.headers.getSetCookie()) {
let t = c.split(";")[0],
i = t.indexOf("=");
cookies[t.slice(0, i)] = t.slice(i + 1);
}
let raw = await r.text();
let data;
try {
data = JSON.parse(raw);
} catch {
data = raw.slice(0, 300);
}
return { status: r.status, data };
}
const post = (p, b) =>
request(p, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(b),
});
(async () => {
const a = JSON.parse(fs.readFileSync(path.join(q, "account.json")));
let csrf = await request("/api/auth/csrf");
assert.equal(csrf.status, 200, JSON.stringify(csrf));
await request("/api/auth/callback/credentials", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"X-Auth-Return-Redirect": "1",
},
body: new URLSearchParams({
csrfToken: csrf.data.csrfToken,
email: a.email,
password: a.password,
callbackUrl: base + "/video-canvas",
}),
});
let session = await request("/api/auth/session");
assert.equal(session.data.user?.id, a.id, "real login");
fs.writeFileSync(path.join(q, "cookies.json"), JSON.stringify(cookies), { mode: 0o600 });
console.log("LOGIN_OK");
let cap = await request("/api/canvas/smart-edit/capabilities");
assert.equal(cap.data.capabilities.asr, true);
assert.equal(cap.data.capabilities.separation, true);
console.log("CAPABILITIES_OK");
let project = await post("/api/canvas", {
title: "智能剪辑本地全链路验收0918",
workspaceType: "video",
});
assert.equal(project.status, 200, JSON.stringify(project));
let id = project.data.data.id;
assert(
Math.abs(Date.now() - new Date(project.data.data.createdAt).getTime()) < 60000,
"new canvas timestamp must agree with task timestamps",
);
fs.writeFileSync(path.join(q, "project-id.txt"), id);
let form = new FormData();
form.append(
"file",
new Blob([fs.readFileSync(path.join(q, "speech.mp4"))], { type: "video/mp4" }),
"测试中文视频.mp4",
);
let uploaded = await request("/api/canvas/media/upload", { method: "POST", body: form });
assert.equal(uploaded.status, 200, JSON.stringify(uploaded));
let asset = {
id: "qa-video",
name: "中文实测视频",
url: uploaded.data.data.url,
subtitles: [],
};
console.log("UPLOAD_OK");
const node = {
id: "qa-edit",
title: "智能剪辑全链路验收",
x: 200,
y: 200,
width: 440,
height: 350,
kind: "text",
inputType: "text",
draftValues: { action: "SMART_EDIT_WORKFLOW", nodeType: "smart-edit" },
};
async function task(params, target = node) {
let req = {
requestId: crypto.randomUUID(),
projectId: id,
kind: "smart-edit",
node: target,
params,
edges: [],
};
let start = Date.now();
let r = await post("/api/canvas/generation-tasks", req);
assert.equal(r.status, 202, JSON.stringify(r));
let repeat = await post("/api/canvas/generation-tasks", req);
assert.equal(repeat.data.task.id, r.data.task.id, "idempotent task");
while (Date.now() - start < 900000) {
await new Promise((r) => setTimeout(r, 1500));
let all = await request("/api/canvas/generation-tasks?projectId=" + id);
assert.equal(all.status, 200);
let t = all.data.tasks.find((t) => t.requestId === req.requestId);
if (t && ["succeeded", "failed", "interrupted"].includes(t.status)) {
console.log(
params.operation,
t.status,
t.error || "",
((Date.now() - start) / 1000).toFixed(1) + "s",
);
report.push({
operation: params.operation,
status: t.status,
seconds: (Date.now() - start) / 1000,
});
assert.equal(t.status, "succeeded", t.error);
fs.writeFileSync(
path.join(q, params.operation + "-task.json"),
JSON.stringify(t, null, 2),
);
return t.result;
}
}
throw Error("timeout " + params.operation);
}
let result = await task({
operation: "plan",
mode: "sequence",
assets: [asset],
targetDuration: 300,
semantic: false,
resolution: "480p",
});
let plan = result.plans[0];
result = await task({ operation: "separate", plan, clipId: plan.clips[0].id });
plan = result.plans[0];
assert.equal(plan.audioClips.length, 2);
assert.equal(plan.clips[0].audioDetached, true);
assert.equal(plan.assets.length, 3);
result = await task({ operation: "transcribe", plan });
plan = result.plans[0];
assert(plan.captions.some((s) => s.text.includes("智能剪辑")));
result = await task({
operation: "separate",
plan,
clipId: plan.audioClips[0].id,
track: "audio",
});
plan = result.plans[0];
assert.equal(plan.audioClips.length, 3, "independent audio separation");
for (const keep of ["vocals", "background"]) {
const processed = await task({
operation: "separate",
plan,
clipId: plan.audioClips[0].id,
track: "audio",
keep,
});
const next = processed.plans[0];
assert.equal(next.audioClips.length, plan.audioClips.length);
const added = next.audioClips.filter(
(c) => !plan.audioClips.some((old) => old.id === c.id),
);
assert.equal(added.length, 1);
assert(
next.assets
.find((a) => a.id === added[0].assetId)
.name.endsWith(keep === "vocals" ? "人声" : "背景声"),
);
}
plan.captions[0].style = { ...plan.captions[0].style, fontFamily: "catalog-0006-font" };
let render = await task(
{ operation: "render", plan, sourceNodeId: "qa-edit" },
{ ...node, id: "qa-render", title: "本地验收成片", kind: "media", inputType: "video" },
);
let media = await fetch(render.url);
assert.equal(media.status, 200);
fs.writeFileSync(path.join(q, "main-result.mp4"), Buffer.from(await media.arrayBuffer()));
let srt = await fetch(render.subtitleUrl);
assert.equal(srt.status, 200);
assert((await srt.text()).includes("智能剪辑"));
fs.writeFileSync(
path.join(q, "main-result.json"),
JSON.stringify({ projectId: id, plan, render, report }, null, 2),
);
console.log("MAIN_CHAIN_OK", JSON.stringify(report));
})().catch((e) => {
console.error(e);
process.exitCode = 1;
});

View File

@ -0,0 +1,30 @@
"""Real local ASR + separation regression. Pass an actual Chinese speech WAV fixture.
Usage: SMART_EDIT_*_MODEL=... python scripts/qa/smart-edit-models.py speech.wav output-dir
No provider calls; the input and all derived files stay local.
"""
import json,sys,subprocess,os
from pathlib import Path
import numpy as np
import soundfile as sf
sys.path.insert(0,str(Path('services/smart-edit').resolve()))
from analyze import analyze
from separate import separate
source=Path(sys.argv[1]).resolve();root=Path(sys.argv[2]).resolve();root.mkdir(parents=True,exist_ok=True)
subprocess.run(['ffmpeg','-v','error','-y','-i',str(source),'-ac','1','-ar','16000',str(root/'speech.wav')],check=True)
result=analyze({'audio':str(root/'speech.wav'),'transcribe':True})
text=''.join(s['text'] for s in result['subtitles']);assert '智能剪辑' in text and '人声分离' in text,text
assert len(result['subtitles'])>=2,'Sentence timestamps must be enabled'
assert all(0<=s['start']<s['end'] for s in result['subtitles'])
subprocess.run(['ffmpeg','-v','error','-y','-i',str(source),'-ac','2','-ar','44100',str(root/'clean.wav')],check=True)
clean,sr=sf.read(root/'clean.wav',dtype='float32',always_2d=True)
t=np.arange(len(clean))/sr
music=(.06*np.sin(2*np.pi*220*t)+.04*np.sin(2*np.pi*330*t))*(.6+.4*np.sin(2*np.pi*2*t)**2)
mix=clean+music[:,None];sf.write(root/'mixed.wav',mix,sr,subtype='FLOAT')
separate(str(root/'mixed.wav'),str(root))
v,_=sf.read(root/'vocals.wav',dtype='float32',always_2d=True);b,_=sf.read(root/'background.wav',dtype='float32',always_2d=True)
assert v.shape==b.shape==mix.shape
error=float(np.max(np.abs(v+b-mix)));assert error<1e-6,error
assert float(np.sqrt(np.mean(v*v)))>.005 and float(np.sqrt(np.mean(b*b)))>.005
assert not np.allclose(v,mix),'Separation must not return the original track'
report={'subtitles':result['subtitles'],'duration':len(clean)/sr,'stemReconstructionMaxError':error,'vocalRms':float(np.sqrt(np.mean(v*v))),'backgroundRms':float(np.sqrt(np.mean(b*b)))}
(root/'report.json').write_text(json.dumps(report,ensure_ascii=False,indent=2));print(json.dumps(report,ensure_ascii=False))

View File

@ -50,7 +50,10 @@ const fs = require("node:fs"),
), ),
); );
await esbuild.build({ await esbuild.build({
entryPoints: ["lib/video-canvas/smart-edit/render.ts", "lib/video-canvas/smart-edit/local.ts"], entryPoints: [
"lib/video-canvas/smart-edit/render.ts",
"lib/video-canvas/smart-edit/local.ts",
],
outdir: ".qa/smart-edit/build", outdir: ".qa/smart-edit/build",
bundle: true, bundle: true,
platform: "node", platform: "node",
@ -86,7 +89,15 @@ const fs = require("node:fs"),
subtitles: false, subtitles: false,
warnings: [], warnings: [],
clips: [ clips: [
{ id: "1", assetId: "a", sourceIn: 0, sourceOut: 2000000, speed: 1, gain: 1, label: "red" }, {
id: "1",
assetId: "a",
sourceIn: 0,
sourceOut: 2000000,
speed: 1,
gain: 1,
label: "red",
},
{ {
id: "2", id: "2",
assetId: "b", assetId: "b",
@ -128,6 +139,468 @@ const fs = require("node:fs"),
probe.duration / 1e6, probe.duration / 1e6,
); );
} }
for (const type of ["fade", "fadeblack", "fadewhite"]) {
const out = path.join(dir, "manual-" + type);
fs.mkdirSync(out, { recursive: true });
const mixed = {
...plan,
subtitles: true,
clips: Array.from({ length: 4 }, (_, i) => ({
...plan.clips[0],
id: "mixed-" + i,
sourceIn: (i % 3) * 1000000,
sourceOut: ((i % 3) + 1) * 1000000,
transition: { type, duration: i === 2 ? 0 : 0.3 },
})),
captions: [
{
id: "manual",
start: 0,
end: 2000000,
text: "手动字幕 测试",
style: {
fontSize: 36,
color: "#ffff00",
outlineColor: "#000000",
outlineWidth: 2,
opacity: 1,
spacing: 2,
},
},
],
};
const result = await renderEditPlan(mixed, files, undefined, out);
const probe = await probeMedia(result.file);
assert.ok(Math.abs(probe.duration / 1e6 - 3.4) < 0.15, JSON.stringify(probe));
assert.match(
fs.readFileSync(path.join(out, "subtitles.ass"), "utf8"),
/Noto Sans CJK SC,36/,
);
assert.match(fs.readFileSync(result.srt, "utf8"), /手动字幕/);
const pixel = execFileSync("ffmpeg", [
"-v",
"error",
"-ss",
"0.833333",
"-i",
result.file,
"-frames:v",
"1",
"-vf",
"crop=2:2:400:200",
"-f",
"rawvideo",
"-pix_fmt",
"rgb24",
"-",
]);
if (type === "fadeblack")
assert.ok(pixel[0] < 70, "black transition must darken the midpoint");
if (type === "fadewhite")
assert.ok(pixel[1] > 180, "white transition must brighten the midpoint");
if (type === "fade")
assert.ok(pixel[0] > 220 && pixel[1] < 20, "crossfade must preserve equal red sources");
console.log(
"PASS: real " +
type +
" → hard cut → " +
type +
", styled manual caption, duration " +
probe.duration / 1e6,
);
}
for (const ratio of ["21:9", "16:9", "9:16", "1:1", "4:3", "3:4"]) {
const out = path.join(dir, "ratio-" + ratio.replace(":", "-"));
fs.mkdirSync(out, { recursive: true });
const result = await renderEditPlan(
{ ...plan, ratio, resolution: "480p", clips: [{ ...plan.clips[0], sourceOut: 1000000 }] },
files,
undefined,
out,
);
const probe = await probeMedia(result.file);
const [w, h] = ratio.split(":").map(Number);
assert.equal(Math.min(probe.width, probe.height), 480);
assert.ok(Math.abs(probe.width / probe.height - w / h) < 0.005, JSON.stringify(probe));
assert.equal(probe.width % 2, 0);
assert.equal(probe.height % 2, 0);
console.log("PASS: real 480P " + ratio + " " + probe.width + "×" + probe.height);
}
for (const type of ["fade", "fadeblack", "fadewhite"]) {
for (const duration of [2.2, 4]) {
const out = path.join(dir, `center-${type}-${duration}`);
fs.mkdirSync(out, { recursive: true });
const centered = {
...plan,
resolution: "480p",
clips: [
{ ...plan.clips[0], sourceOut: 2e6 },
{
...plan.clips[0],
id: "incoming",
sourceIn: 2e6,
sourceOut: 4e6,
transition: { type, duration, alignment: "center" },
},
],
};
const result = await renderEditPlan(centered, files, undefined, out),
probe = await probeMedia(result.file);
assert.ok(
Math.abs(probe.duration - 4e6) < 150000,
`centered transition must keep 4 seconds: ${JSON.stringify(probe)}`,
);
const pixel = execFileSync("ffmpeg", [
"-v",
"error",
"-ss",
"2",
"-i",
result.file,
"-frames:v",
"1",
"-vf",
"crop=2:2:400:200",
"-f",
"rawvideo",
"-pix_fmt",
"rgb24",
"-",
]);
if (type === "fadeblack")
assert.ok(pixel[0] < 30 && pixel[2] < 30, `black midpoint ${pixel.slice(0, 3)}`);
if (type === "fadewhite")
assert.ok(
pixel[0] > 220 && pixel[1] > 220 && pixel[2] > 220,
`white midpoint ${pixel.slice(0, 3)}`,
);
if (type === "fade")
assert.ok(pixel[0] > 80 && pixel[2] > 80, `crossfade midpoint ${pixel.slice(0, 3)}`);
console.log(
`PASS: centered ${type} ${duration}s preserves 4s edit; midpoint pixel verified`,
);
}
}
// Verify edited geometry, detached audio, gain, fades and black tail in actual encoded media.
const sine = path.join(dir, "steady-tone.mp4");
execFileSync("ffmpeg", [
"-v",
"error",
"-y",
"-f",
"lavfi",
"-i",
"color=c=red:s=320x180:r=30:d=4",
"-f",
"lavfi",
"-i",
"sine=frequency=440:sample_rate=48000:duration=4",
"-c:v",
"libx264",
"-threads",
"2",
"-c:a",
"aac",
sine,
]);
const tone = { ...b, id: "tone", subtitles: [], ...(await probeMedia(sine)) };
const extensionFiles = { tone: sine };
const base = {
...plan,
resolution: "480p",
assets: [tone],
clips: [
{
...plan.clips[0],
assetId: "tone",
sourceOut: 2e6,
},
],
};
const actual = async (name, edited) => {
const out = path.join(dir, name);
fs.mkdirSync(out, { recursive: true });
return renderEditPlan(edited, extensionFiles, undefined, out);
};
const pixelAt = (file, time, x, y) =>
execFileSync("ffmpeg", [
"-v",
"error",
"-ss",
String(time),
"-i",
file,
"-frames:v",
"1",
"-vf",
`crop=2:2:${x}:${y}`,
"-f",
"rawvideo",
"-pix_fmt",
"rgb24",
"-",
]);
const rms = (file, time, duration = 0.2) => {
const data = execFileSync("ffmpeg", [
"-v",
"error",
"-ss",
String(time),
"-i",
file,
"-t",
String(duration),
"-vn",
"-ac",
"1",
"-ar",
"48000",
"-f",
"f32le",
"-",
]);
let sum = 0;
for (let i = 0; i < data.length; i += 4) sum += data.readFloatLE(i) ** 2;
return Math.sqrt(sum / (data.length / 4));
};
const baseline = await actual("audio-baseline", base);
const detached = await actual("audio-detached", {
...base,
clips: [
{ ...base.clips[0], audioDetached: true, transform: { scale: 0.5, x: 0.2, y: -0.1 } },
],
audioClips: [
{
id: "track",
assetId: "tone",
start: 0,
sourceIn: 0,
sourceOut: 2e6,
speed: 1,
gain: 1,
fadeIn: 0,
fadeOut: 0,
},
],
});
const ratio = rms(detached.file, 0.8) / rms(baseline.file, 0.8);
assert.ok(ratio > 0.9 && ratio < 1.1, `detached sound must not double: ${ratio}`);
assert.ok(
pixelAt(detached.file, 0.8, 600, 150)[0] > 220,
"shifted scaled video should be red",
);
assert.ok(
pixelAt(detached.file, 0.8, 150, 150)[0] < 10,
"outside transformed video should be black",
);
const delayedPlan = {
...base,
clips: [{ ...base.clips[0], audioDetached: true }],
audioClips: [
{
id: "delayed",
assetId: "tone",
start: 1e6,
sourceIn: 0,
sourceOut: 4e6,
speed: 2,
gain: 0.5,
fadeIn: 0.5,
fadeOut: 0.5,
},
],
};
const delayed = await actual("audio-delayed-tail", delayedPlan);
const delayedProbe = await probeMedia(delayed.file);
assert.ok(Math.abs(delayedProbe.duration - 3e6) < 150000, JSON.stringify(delayedProbe));
assert.ok(rms(delayed.file, 0.4) < 0.0001, "detached original must stay silent");
const middleRms = rms(delayed.file, 1.7);
assert.ok(
middleRms > rms(baseline.file, 0.8) * 0.4 && middleRms < rms(baseline.file, 0.8) * 0.6,
"independent gain should be applied once",
);
assert.ok(rms(delayed.file, 1.05, 0.1) < middleRms * 0.4, "audio fade in");
assert.ok(rms(delayed.file, 2.8, 0.1) < middleRms * 0.5, "audio fade out");
assert.ok(pixelAt(delayed.file, 2.6, 200, 200)[0] < 10, "audio beyond video uses black tail");
const withCenter = {
...base,
clips: [
base.clips[0],
{
...base.clips[0],
id: "center-audio",
sourceIn: 2e6,
sourceOut: 4e6,
transition: { type: "fadeblack", duration: 2.2, alignment: "center" },
},
],
};
const centerSound = await actual("center-original-audio", withCenter);
const centerDetached = await actual("center-detached-audio", {
...withCenter,
clips: [withCenter.clips[0], { ...withCenter.clips[1], audioDetached: true }],
audioClips: [
{
id: "center-detached",
assetId: "tone",
start: 2e6,
sourceIn: 2e6,
sourceOut: 4e6,
speed: 1,
gain: 1,
fadeIn: 1.1,
fadeOut: 0,
fadeInStart: 0.5,
},
],
});
for (const time of [1.3, 2.1, 2.6, 3.5]) {
const ratio = rms(centerDetached.file, time, 0.1) / rms(centerSound.file, time, 0.1);
assert.ok(
ratio > 0.85 && ratio < 1.15,
`detaching centered-transition audio must preserve its envelope at ${time}s: ${ratio}`,
);
}
console.log("PASS: centered transition audio envelope preserved after detachment");
const captioned = await actual("caption-position", {
...base,
subtitles: true,
captions: [
{
id: "pos",
text: "位置测试",
start: 0,
end: 2e6,
style: {
fontSize: 80,
color: "#ffffff",
outlineColor: "#000000",
outlineWidth: 8,
outlineOpacity: 0,
opacity: 1,
spacing: 0,
x: 0.25,
y: 0.5,
},
},
],
});
const frame = execFileSync(
"ffmpeg",
[
"-v",
"error",
"-ss",
"0.8",
"-i",
captioned.file,
"-frames:v",
"1",
"-f",
"rawvideo",
"-pix_fmt",
"rgb24",
"-",
],
{ maxBuffer: 4 * 1024 * 1024 },
);
let whitePixels = 0,
blackPixels = 0;
for (let y = 170; y < 240; y++)
for (let x = 90; x < 340; x++) {
const offset = (y * 854 + x) * 3;
if (frame[offset] > 200 && frame[offset + 1] > 200 && frame[offset + 2] > 200)
whitePixels++;
if (frame[offset] < 30 && frame[offset + 1] < 30 && frame[offset + 2] < 30) blackPixels++;
}
assert.ok(whitePixels > 150, `caption must render at moved position: ${whitePixels}`);
assert.ok(
blackPixels < 10,
`zero outline opacity must remove only the black outline: ${blackPixels}`,
);
console.log(
"PASS: real transform pixels, caption position, detached audio without doubling, 2x audio, gain, fades, delay and black tail",
);
const boosted = await actual("positive-gain", {
...base,
clips: [{ ...base.clips[0], gain: 2 }],
});
const boostRatio = rms(boosted.file, 0.8) / rms(baseline.file, 0.8);
assert.ok(
boostRatio > 1.85 && boostRatio < 2.15,
`+6 dB should double amplitude: ${boostRatio}`,
);
const muted = await actual("muted-gain", {
...base,
clips: [{ ...base.clips[0], gain: 2, muted: true }],
});
assert.ok(
rms(muted.file, 0.8) < 0.0001,
"mute must silence original sound without overwriting gain",
);
const independentMuted = await actual("muted-independent-gain", {
...delayedPlan,
audioClips: delayedPlan.audioClips.map((clip) => ({ ...clip, muted: true })),
});
assert.ok(rms(independentMuted.file, 1.7) < 0.0001, "mute must silence detached sound");
const fontHashes = new Set();
for (const fontFamily of ["source-han", "noto-sans-sc", "noto-serif-sc", "roboto", "inter", ...(process.env.SMART_EDIT_TEST_CATALOG_FONTS ? ["catalog-0006-font", "catalog-0007-abeezee", "catalog-0014-abril-fatface", "catalog-0365-cjgdgmh", "catalog-1021-molle", "catalog-1685-scjssht"] : [])]) {
const output = await actual("font-" + fontFamily, {
...base,
subtitles: true,
captions: [
{
id: "font",
text: "字幕字体 AaGg 123",
start: 0,
end: 2e6,
style: {
fontFamily,
referenceHeight: 1080,
fontSize: 56,
color: "#ffffff",
outlineColor: "#ffffff",
outlineWidth: 0,
opacity: 1,
spacing: 0,
x: 0.5,
y: 0.5,
},
},
],
});
const pixels = execFileSync(
"ffmpeg",
[
"-v",
"error",
"-ss",
"0.8",
"-i",
output.file,
"-frames:v",
"1",
"-f",
"rawvideo",
"-pix_fmt",
"rgb24",
"-",
],
{ maxBuffer: 4 * 1024 * 1024 },
);
let light = 0;
for (let i = 0; i < pixels.length; i += 3)
if (pixels[i] > 200 && pixels[i + 1] > 200 && pixels[i + 2] > 200) light++;
assert.ok(light > 150, `${fontFamily} must draw glyphs: ${light}`);
fontHashes.add(require("node:crypto").createHash("sha256").update(pixels).digest("hex"));
}
assert.ok(
fontHashes.size >= 4,
"selected fonts must change actual glyph pixels, not all fall back to one face",
);
console.log(
`PASS: positive gain amplitude, original/detached mute, five packaged fonts${process.env.SMART_EDIT_TEST_CATALOG_FONTS ? " and six catalog fonts" : ""}`,
);
if (process.env.SMART_EDIT_PYTHON) { if (process.env.SMART_EDIT_PYTHON) {
const prepared = await prepareAnalysis(files.b, "fixture", dir, true); const prepared = await prepareAnalysis(files.b, "fixture", dir, true);
const analysis = await pythonAnalysis( const analysis = await pythonAnalysis(

View File

@ -28,6 +28,25 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
} }
plan.transition = 0.5; plan.transition = 0.5;
plan.subtitles = true; plan.subtitles = true;
plan.clips[1].gain = 2;
plan.captions = [
{
id: "font-http",
start: 0,
end: 2e6,
text: "API 字体验证",
style: {
fontFamily: "noto-serif-sc",
referenceHeight: 1080,
fontSize: 56,
color: "#ffffff",
outlineColor: "#ffffff",
outlineWidth: 0,
opacity: 1,
spacing: 0,
},
},
];
let child; let child;
const start = async () => { const start = async () => {
child = spawn(process.execPath, ["services/video-render/dist/server.cjs"], { child = spawn(process.execPath, ["services/video-render/dist/server.cjs"], {
@ -45,7 +64,9 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
child.stderr.on("data", (b) => fs.appendFileSync(path.join(data, "log"), b)); child.stderr.on("data", (b) => fs.appendFileSync(path.join(data, "log"), b));
for (let i = 0; i < 60; i++) { for (let i = 0; i < 60; i++) {
try { try {
if ((await fetch(base + "/health", { headers: { authorization: "Bearer " + token } })).ok) if (
(await fetch(base + "/health", { headers: { authorization: "Bearer " + token } })).ok
)
return; return;
} catch {} } catch {}
await sleep(100); await sleep(100);
@ -103,7 +124,15 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const out = path.join(data, "download.mp4"); const out = path.join(data, "download.mp4");
fs.writeFileSync(out, Buffer.from(await result.arrayBuffer())); fs.writeFileSync(out, Buffer.from(await result.arrayBuffer()));
const probe = JSON.parse( const probe = JSON.parse(
execFileSync("ffprobe", ["-v", "error", "-show_format", "-show_streams", "-of", "json", out]), execFileSync("ffprobe", [
"-v",
"error",
"-show_format",
"-show_streams",
"-of",
"json",
out,
]),
); );
assert.ok(Math.abs(Number(probe.format.duration) - 3.5) < 0.15); assert.ok(Math.abs(Number(probe.format.duration) - 3.5) < 0.15);
assert.equal(probe.streams[0].width, 1280); assert.equal(probe.streams[0].width, 1280);

View File

@ -0,0 +1,103 @@
// Real authenticated GPU API smoke test. Inputs must be the documented synthetic one-second clips.
const fs = require("node:fs"),
crypto = require("node:crypto"),
assert = require("node:assert/strict");
const { execFileSync } = require("node:child_process");
const base = process.env.VIDEO_UPSCALE_QA_URL,
token = process.env.VIDEO_UPSCALE_QA_TOKEN,
source = process.env.VIDEO_UPSCALE_QA_SOURCE;
if (!base || !token || !source)
throw Error(
"Set VIDEO_UPSCALE_QA_URL, VIDEO_UPSCALE_QA_TOKEN and VIDEO_UPSCALE_QA_SOURCE"
);
const sha = (s) => crypto.createHash("sha256").update(s).digest("hex"),
headers = {
authorization: "Bearer " + token,
"content-type": "application/json",
};
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
(async () => {
const request = async (route, body) => {
const r = await fetch(base + route, {
method: body ? "POST" : "GET",
headers,
body: body && JSON.stringify(body),
signal: AbortSignal.timeout(15000),
});
return { status: r.status, body: await r.json() };
};
assert.equal((await fetch(base + "/health")).status, 401);
const input = {
id: sha("upscale-qa-" + crypto.randomUUID()),
tenant: sha("isolated-qa"),
plan: { url: source, resolution: "4K" },
};
assert.equal((await request("/v1/upscales", input)).status, 202);
assert.equal((await request("/v1/upscales", input)).status, 200);
assert.equal(
(
await request("/v1/upscales", {
...input,
plan: { ...input.plan, resolution: "2K" },
})
).status,
409
);
const host = process.env.VIDEO_UPSCALE_QA_SSH_HOST,
control = process.env.VIDEO_UPSCALE_QA_SSH_CONTROL;
if (host) {
for (let i = 0; i < 30; i++) {
if ((await request("/v1/upscales/" + input.id)).body.status === "running")
break;
await wait(100);
}
execFileSync(
"ssh",
[
...(control ? ["-S", control] : []),
"-o",
"BatchMode=yes",
host,
"systemctl restart video-upscale",
],
{ timeout: 30000, stdio: "pipe" }
);
await wait(1500);
assert.equal((await request("/v1/upscales", input)).status, 200);
}
let result;
for (let i = 0; i < 240; i++) {
result = (await request("/v1/upscales/" + input.id)).body;
if (["succeeded", "failed"].includes(result.status)) break;
await wait(2000);
}
assert.equal(result.status, "succeeded", JSON.stringify(result));
assert.equal(result.result.width, 3840);
assert.equal(result.result.height, 2160);
assert.equal(result.result.frames, 2);
assert.equal(result.result.audio, true);
if (host) assert.equal(result.attempts, 2);
const response = await fetch(base + "/v1/upscales/" + input.id + "/video", {
headers,
});
assert(response.ok);
fs.mkdirSync(".qa/video-upscale-service", { recursive: true });
fs.writeFileSync(
".qa/video-upscale-service/result.mp4",
Buffer.from(await response.arrayBuffer())
);
assert.equal(
(await fetch(base + "/v1/upscales/" + input.id + "/video")).status,
401
);
fs.writeFileSync(
".qa/video-upscale-service/report.json",
JSON.stringify(result, null, 2)
);
console.log(
"PASS: GPU upscale, authenticated download, idempotency, conflicting parameters, restart recovery, dimensions/frame count/audio"
);
})().catch((e) => {
console.error(e);
process.exitCode = 1;
});

View File

@ -12,7 +12,7 @@
诊断:`$SMART_EDIT_PYTHON services/smart-edit/analyze.py --check`。API `/api/canvas/smart-edit/capabilities` 仅对登录用户返回可用功能与模型列表,不返回密钥或本地路径。ASR 未准备好时页面关闭自动转写选项,仍可导入 SRT 和进行画面混剪。 诊断:`$SMART_EDIT_PYTHON services/smart-edit/analyze.py --check`。API `/api/canvas/smart-edit/capabilities` 仅对登录用户返回可用功能与模型列表,不返回密钥或本地路径。ASR 未准备好时页面关闭自动转写选项,仍可导入 SRT 和进行画面混剪。
资源:整个智能剪辑作业通过 PostgreSQL advisory lock 限制为同一数据库下同时一个重处理任务,租约心跳沿用画布任务队列。Python/FFmpeg 限制线程数,单素材 ≤180 秒、≤125 MiB、至多6份,总素材≤10分钟,成片≤90秒、≤12片段;分析缓存按用户隔离,内容 hash 校验,7天过期、4GiB上限。低清代理仅用于分析,成片使用原素材。数据库锁繁忙时任务保存为可恢复状态,页面轮询或返回画布会继续运行。 资源:整个智能剪辑作业通过 PostgreSQL advisory lock 限制为同一数据库下同时一个重处理任务,租约心跳沿用画布任务队列。Python/FFmpeg 限制线程数,单素材 ≤180 秒、≤125 MiB、至多12份,总原始素材≤10分钟,成片≤300秒、≤12片段;分析缓存按用户隔离,内容 hash 校验,7天过期、4GiB上限。低清代理仅用于分析,成片使用原素材。数据库锁繁忙时任务保存为可恢复状态,页面轮询或返回画布会继续运行。
算法:PySceneDetect 检测镜头,librosa 检测节拍,FunASR 返回语句时间戳(采用 FunClip 的时间戳剪辑思路)。去静音为独立实现的 RMS 阈值算法,参考 Auto-Editor 的粗剪与余量机制;没有复制 Auto-Editor 或 FunClip 源码,也不依赖它们的 UI。来源与许可证记录在项目 docs/video-canvas-smart-edit-research-2026-09-17.md。PySceneDetect 为 BSD-3-Clause,librosa 为 ISC,FunASR 源码和各权重许可应按所安装版本一并保留。 算法:PySceneDetect 检测镜头,librosa 检测节拍,FunASR 返回语句时间戳(采用 FunClip 的时间戳剪辑思路)。去静音为独立实现的 RMS 阈值算法,参考 Auto-Editor 的粗剪与余量机制;没有复制 Auto-Editor 或 FunClip 源码,也不依赖它们的 UI。来源与许可证记录在项目 docs/video-canvas-smart-edit-research-2026-09-17.md。PySceneDetect 为 BSD-3-Clause,librosa 为 ISC,FunASR 源码和各权重许可应按所安装版本一并保留。
@ -22,3 +22,19 @@
远程合成:设置 `SMART_EDIT_RENDER_URL` 与 `SMART_EDIT_RENDER_TOKEN` 后,render操作进入独立API,不占用本地主处理锁。独立服务部署见 `../video-render/README.md`,其千问仅提供可选文字能力。分析操作仍使用本机Python/代理文件。 远程合成:设置 `SMART_EDIT_RENDER_URL` 与 `SMART_EDIT_RENDER_TOKEN` 后,render操作进入独立API,不占用本地主处理锁。独立服务部署见 `../video-render/README.md`,其千问仅提供可选文字能力。分析操作仍使用本机Python/代理文件。
## 人声分离、转写和完整字体库(2026-09-18)
`bash services/smart-edit/setup.sh --with-all` 在独立环境安装全部依赖。也可使用 `--with-separation` 或 `--with-asr`。
- **人声分离**:`prepare_separation.py` 下载并校验官方 HTDemucs 权重。将输出的 `SMART_EDIT_SEPARATION_MODEL_DIR` 加入应用环境。请求阶段离线运行,CPU 2线程、7秒分块、每次最多180秒;支持仅保留人声、仅保留背景音、输出两个音轨,保持裁剪、变速、音量、静音及转场包络。视频原声自动关闭,独立音轨可继续分离;失败保留原内容。
- **自动转写**:字幕列表直接提供入口。FunASR 同时开启 `pred_timestamp` 与 `sentence_timestamp`;时间戳限制在素材范围内,再映射至剪辑时间轴。保留手动字幕,重试只替换此前自动生成的字幕。任务存数据库,可退出后再打开;处理完成前锁住当前编辑,避免覆盖并行改动。
- **字体库**:对应参考站2002项的目录见 `lib/video-canvas/smart-edit/font-catalog.json`(1997项)及 `fonts.ts`(原有5项)。已逐一下载、解析和校验,完整新增字体约757MiB。原始与处理后SHA-256均固定;保留字体内版权/许可字段。浏览器和FFmpeg使用同一处理后文件,独立字体族名避免粗细变体串字,变量字体固定为选定默认实例。两款参考字体的OS/2版本声明与实际长度不一致,按已有字段修复为v4,不改变字形轮廓。
- **安装字体**:默认按选择懒加载,需Python的`requirements-fonts.txt`。部署前推荐执行 `$SMART_EDIT_PYTHON services/smart-edit/prepare_fonts.py` 预热全部目录,或者复制已校验的字体缓存。`SMART_EDIT_FONT_CACHE_DIR` 指定共享缓存目录;`SMART_EDIT_FONT_PYTHON` 可单独指定字体Python。原有5款字体仍随应用/合成服务打包。首次下载失败会明确报错,不静默替换成默认字体。
- **远程合成字体**:独立合成服务同样安装字体依赖,设置同一缓存或预热目录;构建时已带上字体转换脚本及固定目录。`SMART_EDIT_FONT_SCRIPT` 可覆盖脚本位置。ASR/分离仍在主应用配置的处理器运行,合成远程执行。
当前限制:视频素材最多12份,每份180秒;成片最长300秒,最多12视频片段、24独立音频片段。含派生分离轨最多36份资源、累计1800秒。实际模型耗时随硬件和素材变化,合成字体的抗锯齿仍受浏览器与libass渲染器影响。
真实模型回归:提供含“智能剪辑”和“人声分离”的中文WAV,运行 `$SMART_EDIT_PYTHON scripts/qa/smart-edit-models.py input.wav .qa/model-check`。验证实际语音识别、逐句时间戳、真实混音分离及两轨重构误差,无付费模型调用。
本地主站全链路:在隔离数据库与存储环境启动真实Next,准备测试账号JSON和中文视频后,执行 `SMART_EDIT_QA_DIR=.qa/isolated-main-chain node scripts/qa/smart-edit-main-chain.cjs`。脚本只允许本地地址,验证登录、上传、方案、分离三个分支、转写、实际字幕字体成片和SRT。完整记录见 `docs/smart-edit-completion-2026-09-18.md`。

View File

@ -14,9 +14,10 @@ for key in ('OMP_NUM_THREADS', 'OPENBLAS_NUM_THREADS', 'MKL_NUM_THREADS', 'NUMBA
def capabilities(): def capabilities():
import shutil import shutil
return {'scenes': importlib.util.find_spec('scenedetect') is not None, from separate import available
return {'separation': available(), 'scenes': importlib.util.find_spec('scenedetect') is not None,
'beats': importlib.util.find_spec('librosa') is not None, 'beats': importlib.util.find_spec('librosa') is not None,
'asr': importlib.util.find_spec('funasr') is not None and all(Path(os.environ.get(k, '/nonexistent')).is_dir() for k in ('SMART_EDIT_ASR_MODEL','SMART_EDIT_VAD_MODEL','SMART_EDIT_PUNC_MODEL')), 'asr': importlib.util.find_spec('funasr') is not None and all(all((Path(os.environ.get(k, '/nonexistent'))/f).is_file() for f in ('model.pt','config.yaml')) for k in ('SMART_EDIT_ASR_MODEL','SMART_EDIT_VAD_MODEL','SMART_EDIT_PUNC_MODEL')),
'ffmpeg': shutil.which('ffmpeg') is not None, 'ffmpeg': shutil.which('ffmpeg') is not None,
'ffprobe': shutil.which('ffprobe') is not None} 'ffprobe': shutil.which('ffprobe') is not None}
@ -41,6 +42,8 @@ def analyze(data):
result={'scenes':[], 'active':[], 'subtitles':[], 'beats':[], 'warnings':[]} result={'scenes':[], 'active':[], 'subtitles':[], 'beats':[], 'warnings':[]}
duration=data.get('duration',0) duration=data.get('duration',0)
if data.get('video'): if data.get('video'):
import cv2
cv2.setNumThreads(2)
from scenedetect import detect,AdaptiveDetector from scenedetect import detect,AdaptiveDetector
result['scenes']=[[round(a.get_seconds()*1e6),round(b.get_seconds()*1e6)] for a,b in detect(data['video'],AdaptiveDetector(min_scene_len=15),start_in_scene=True)] or [[0,duration]] result['scenes']=[[round(a.get_seconds()*1e6),round(b.get_seconds()*1e6)] for a,b in detect(data['video'],AdaptiveDetector(min_scene_len=15),start_in_scene=True)] or [[0,duration]]
if data.get('audio'): if data.get('audio'):
@ -61,7 +64,7 @@ def analyze(data):
from funasr import AutoModel from funasr import AutoModel
torch.set_num_threads(2) torch.set_num_threads(2)
model=AutoModel(model=os.environ['SMART_EDIT_ASR_MODEL'],vad_model=os.environ['SMART_EDIT_VAD_MODEL'],punc_model=os.environ['SMART_EDIT_PUNC_MODEL'],device='cpu',disable_update=True,trust_remote_code=False) model=AutoModel(model=os.environ['SMART_EDIT_ASR_MODEL'],vad_model=os.environ['SMART_EDIT_VAD_MODEL'],punc_model=os.environ['SMART_EDIT_PUNC_MODEL'],device='cpu',disable_update=True,trust_remote_code=False)
outputs=model.generate(input=data['audio'],sentence_timestamp=True,batch_size_s=30) outputs=model.generate(input=data['audio'],sentence_timestamp=True,pred_timestamp=True,batch_size_s=30)
for output in outputs: for output in outputs:
for sentence in output.get('sentence_info',[]): for sentence in output.get('sentence_info',[]):
start=sentence.get('start');end=sentence.get('end') start=sentence.get('start');end=sentence.get('end')
@ -70,7 +73,10 @@ def analyze(data):
if times:start,end=times[0][0],times[-1][1] if times:start,end=times[0][0],times[-1][1]
text=str(sentence.get('text','')).strip() text=str(sentence.get('text','')).strip()
if text and start is not None and end is not None and end>start: if text and start is not None and end is not None and end>start:
result['subtitles'].append({'start':round(start*1000),'end':round(end*1000),'text':text}) bounded_start=max(0,round(start*1000))
bounded_end=min(round(len(wave)/sr*1e6),round(end*1000))
if bounded_end>bounded_start:
result['subtitles'].append({'start':bounded_start,'end':bounded_end,'text':text})
if not result['subtitles']:result['warnings'].append('语音识别没有返回有效语句,将按画面镜头处理;不会生成虚构字幕。') if not result['subtitles']:result['warnings'].append('语音识别没有返回有效语句,将按画面镜头处理;不会生成虚构字幕。')
elif data.get('transcribe'):result['warnings'].append('素材没有音轨,跳过语音转写。') elif data.get('transcribe'):result['warnings'].append('素材没有音轨,跳过语音转写。')
return result return result

View File

@ -0,0 +1,35 @@
"""Freeze a font's default variation and give it a unique preview/export family name."""
import sys
from fontTools.ttLib import TTFont, newTable
from fontTools.varLib.instancer import instantiateVariableFont
def prepare(source, destination, family):
font = TTFont(source, recalcTimestamp=False)
if 'fvar' in font:
axes = {axis.axisTag: axis.defaultValue for axis in font['fvar'].axes}
for axis in font['fvar'].axes:
if axis.axisTag == 'wght':
axes['wght'] = max(axis.minValue, min(axis.maxValue, 400))
font = instantiateVariableFont(font, axes, inplace=True)
for record in font['name'].names:
if record.nameID in (1, 2, 3, 4, 6, 16, 17):
value = 'Regular' if record.nameID in (2, 17) else family
if record.nameID == 6:
value = family.replace(' ', '-')[:63]
record.string = value.encode(record.getEncoding(), errors='replace')
# Two reference fonts declare OS/2 v5 but only contain the 96-byte v4 table.
# Correct the version tag without inventing missing metrics or changing outlines.
metrics = font.reader['OS/2']
if len(metrics) == 96 and int.from_bytes(metrics[:2], 'big') == 5:
table = newTable('OS/2')
table.decompile(b'\x00\x04' + metrics[2:], font)
font['OS/2'] = table
# A unique family per selected face prevents fontconfig confusing weight variants.
font['OS/2'].fsSelection &= ~((1 << 0) | (1 << 5))
font['OS/2'].fsSelection |= 1 << 6
font['OS/2'].usWeightClass = 400
font['head'].macStyle &= ~3
font.save(destination)
if __name__ == '__main__':
prepare(*sys.argv[1:4])

View File

@ -0,0 +1,23 @@
"""Prewarm the audited font catalogue. Downloads and outputs are checksum verified."""
import concurrent.futures,hashlib,json,os,tempfile,urllib.request
from pathlib import Path
from font import prepare
root=Path(os.environ.get('SMART_EDIT_FONT_CACHE_DIR','.cache/smart-edit-fonts')).resolve();root.mkdir(parents=True,exist_ok=True)
catalog=json.loads(Path('lib/video-canvas/smart-edit/font-catalog.json').read_text())
def digest(file):return hashlib.sha256(file.read_bytes()).hexdigest()
def install(font):
output=root/font['file']
if output.is_file() and digest(output)==font['preparedSha256']:return
with tempfile.TemporaryDirectory(dir=root) as work:
raw=Path(work)/'source';prepared=Path(work)/'prepared.ttf'
with urllib.request.urlopen(font['url'],timeout=90) as response:
data=response.read(40*1024*1024+1)
if len(data)>40*1024*1024:raise ValueError('字体超过大小上限')
raw.write_bytes(data)
if digest(raw)!=font['sha256']:raise ValueError('原始字体校验失败: '+font['name'])
prepare(str(raw),str(prepared),font['family'])
if digest(prepared)!=font['preparedSha256']:raise ValueError('字体处理版本不匹配: '+font['name'])
prepared.replace(output)
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as pool:
list(pool.map(install,catalog))
print(json.dumps({'fonts':len(catalog)+5,'catalogueFonts':len(catalog),'cache':str(root)},ensure_ascii=False))

View File

@ -0,0 +1,18 @@
"""Install only the checksum-pinned official HTDemucs checkpoint, outside request handling."""
import hashlib,json,os,urllib.request
from pathlib import Path
from separate import CHECKSUM
root=Path(os.environ.get('SMART_EDIT_SEPARATION_MODEL_DIR','.smart-edit-models/demucs')).resolve()
root.mkdir(parents=True,exist_ok=True)
file=root/'955717e8-8726e21a.th'
url='https://dl.fbaipublicfiles.com/demucs/hybrid_transformer/955717e8-8726e21a.th'
if not file.exists() or hashlib.sha256(file.read_bytes()).hexdigest()!=CHECKSUM:
temp=file.with_suffix('.part')
try:
urllib.request.urlretrieve(url,temp)
if hashlib.sha256(temp.read_bytes()).hexdigest()!=CHECKSUM:
raise RuntimeError('HTDemucs 权重校验失败')
temp.replace(file)
finally:
temp.unlink(missing_ok=True)
print(json.dumps({'SMART_EDIT_SEPARATION_MODEL_DIR':str(root)},ensure_ascii=False))

View File

@ -3,3 +3,5 @@ funasr==1.4.15
torch==2.6.0 torch==2.6.0
torchaudio==2.6.0 torchaudio==2.6.0
modelscope==1.30.0 modelscope==1.30.0
# Verified with Paraformer sentence/character timestamps.
transformers==5.17.0

View File

@ -0,0 +1,2 @@
fonttools==4.59.1
brotli==1.2.0

View File

@ -0,0 +1,4 @@
-r requirements.txt
torch==2.6.0
torchaudio==2.6.0
demucs==4.0.1

View File

@ -0,0 +1,59 @@
"""Offline, bounded HTDemucs inference. Model installation is a separate operator step."""
import json
import os
import sys
from pathlib import Path
for key in ('OMP_NUM_THREADS', 'OPENBLAS_NUM_THREADS', 'MKL_NUM_THREADS'):
os.environ[key] = '2'
CHECKSUM = "8726e21a993978c7ba086d3872e7608d7d5bfca646ca4aca459ffda844faa8b4"
SIGNATURE = '955717e8'
def available():
import importlib.util
root = Path(os.environ.get('SMART_EDIT_SEPARATION_MODEL_DIR', '/nonexistent'))
return importlib.util.find_spec('demucs') is not None and any(root.glob(SIGNATURE + '-*.th'))
def separate(source, destination):
if not available():
raise RuntimeError('人声分离模型尚未配置,请安装 Demucs 依赖和官方 HTDemucs 权重。')
import numpy as np
import soundfile as sf
import torch
from demucs.states import load_model
import hashlib
from demucs.apply import apply_model
torch.set_num_threads(2)
torch.manual_seed(0)
audio, sr = sf.read(source, dtype='float32', always_2d=True)
if sr != 44100 or audio.shape[1] != 2 or not 0 < len(audio) <= sr * 180:
raise ValueError('分离输入必须为 180 秒以内的 44.1kHz 双声道音频')
if not np.isfinite(audio).all():
raise ValueError('音频包含无效采样')
checkpoint = next(Path(os.environ['SMART_EDIT_SEPARATION_MODEL_DIR']).glob(SIGNATURE + '-*.th'))
if hashlib.sha256(checkpoint.read_bytes()).hexdigest() != CHECKSUM:
raise ValueError('人声分离权重校验失败')
# Official checkpoint verified above; Demucs 4 uses a pickled model class.
model = load_model(torch.load(checkpoint, map_location='cpu', weights_only=False))
model.eval()
wave = torch.from_numpy(audio.T.copy())
reference = wave.mean(0)
mean, std = reference.mean(), reference.std()
if std < 1e-8:
vocals = np.zeros_like(audio)
else:
with torch.inference_mode():
output = apply_model(model, ((wave - mean) / std)[None], device='cpu',
shifts=1, split=True, overlap=.25, segment=7,
progress=False, num_workers=0)[0]
vocals = (output[model.sources.index('vocals')] * std + mean).T.numpy()
# Complementary stems reconstruct the original exactly, with no independent gain normalization.
destination = Path(destination)
destination.mkdir(parents=True, exist_ok=True)
sf.write(destination / 'vocals.wav', vocals, sr, subtype='FLOAT')
sf.write(destination / 'background.wav', audio - vocals, sr, subtype='FLOAT')
if __name__ == '__main__':
if sys.argv[1:] == ['--check']:
print(json.dumps({'separation': available()}))
else:
separate(*sys.argv[1:3])

View File

@ -4,9 +4,13 @@ set -euo pipefail
smart_env="${SMART_EDIT_ENV_DIR:-$PWD/.venv-smart-edit}" smart_env="${SMART_EDIT_ENV_DIR:-$PWD/.venv-smart-edit}"
smart_python="${SMART_EDIT_SETUP_PYTHON:-python3.11}" smart_python="${SMART_EDIT_SETUP_PYTHON:-python3.11}"
"$smart_python" -m venv "$smart_env" "$smart_python" -m venv "$smart_env"
"$smart_env/bin/python" -m pip install -r services/smart-edit/requirements.txt "$smart_env/bin/python" -m pip install -r services/smart-edit/requirements.txt -r services/smart-edit/requirements-fonts.txt
if [[ "${1:-}" == "--with-asr" ]]; then if [[ " $* " == *" --with-asr "* || " $* " == *" --with-all "* ]]; then
"$smart_env/bin/python" -m pip install -r services/smart-edit/requirements-asr.txt "$smart_env/bin/python" -m pip install -r services/smart-edit/requirements-asr.txt
"$smart_env/bin/python" services/smart-edit/prepare_models.py "$smart_env/bin/python" services/smart-edit/prepare_models.py
fi fi
if [[ " $* " == *" --with-separation "* || " $* " == *" --with-all "* ]]; then
"$smart_env/bin/python" -m pip install -r services/smart-edit/requirements-separation.txt
"$smart_env/bin/python" services/smart-edit/prepare_separation.py
fi
SMART_EDIT_PYTHON="$smart_env/bin/python" "$smart_env/bin/python" services/smart-edit/analyze.py --check SMART_EDIT_PYTHON="$smart_env/bin/python" "$smart_env/bin/python" services/smart-edit/analyze.py --check

View File

@ -30,7 +30,7 @@
任务 ID 由主项目持久任务号导出:重复 POST 返回同一记录,参数改变返回 409。写盘采用临时文件、fsync、rename 后再响应。单服务进程拥有此数据目录,不能启动多个副本共用目录。服务重启后运行中任务最多恢复两次;完成记录重启后仍可下载。主项目轮询超时保存编号继续查询,不受浏览器页面生命周期限制。 任务 ID 由主项目持久任务号导出:重复 POST 返回同一记录,参数改变返回 409。写盘采用临时文件、fsync、rename 后再响应。单服务进程拥有此数据目录,不能启动多个副本共用目录。服务重启后运行中任务最多恢复两次;完成记录重启后仍可下载。主项目轮询超时保存编号继续查询,不受浏览器页面生命周期限制。
上限:请求 1 MiB,至多 30 个等待/运行任务,至多 200 个保留任务;完成/失败记录和产物保留 7 天,小时清理;原素材缓存按用户隔离,4 GiB / 7 天清理;素材 ≤125 MiB/份、≤180秒/份、≤6份且总长≤10分钟,成片≤90秒/12片段。磁盘可用不足10GiB拒绝重处理。外部素材禁止内网地址,DNS验证后绑定地址下载,每次重定向重新验证;FFmpeg禁用网络输入协议。 上限:请求 1 MiB,至多 30 个等待/运行任务,至多 200 个保留任务;完成/失败记录和产物保留 7 天,小时清理;原素材缓存按用户隔离,4 GiB / 7 天清理;素材 ≤125 MiB/份、≤180秒/份、≤12份且总长≤10分钟,成片≤300秒/12片段;6种比例(含21:9)、480P/720P/1080P,长边最高2520像素。磁盘可用不足10GiB拒绝重处理。外部素材禁止内网地址,DNS验证后绑定地址下载,每次重定向重新验证;FFmpeg禁用网络输入协议。
## 验证 ## 验证
@ -39,3 +39,7 @@
随后 `node services/video-render/build.cjs && PATH=<ffmpeg目录>:$PATH node scripts/qa/video-render-service.cjs` 验证真实 HTTP、鉴权、FFmpeg、进程强制中断/恢复、幂等、冲突拒绝和完成记录重启保留。用合成色块/正弦音,不调用付费模型。 随后 `node services/video-render/build.cjs && PATH=<ffmpeg目录>:$PATH node scripts/qa/video-render-service.cjs` 验证真实 HTTP、鉴权、FFmpeg、进程强制中断/恢复、幂等、冲突拒绝和完成记录重启保留。用合成色块/正弦音,不调用付费模型。
依赖许可:Zod MIT;FFmpeg 许可取决于本机编译选项,Ubuntu libx264 构建含 GPL 组件。服务只运行本机已安装 FFmpeg,不把二进制混入仓库。代码与发布状态记录在 `docs/video-render-service-2026-09-17.md`。 依赖许可:Zod MIT;FFmpeg 许可取决于本机编译选项,Ubuntu libx264 构建含 GPL 组件。服务只运行本机已安装 FFmpeg,不把二进制混入仓库。代码与发布状态记录在 `docs/video-render-service-2026-09-17.md`。
## 字体包
构建会将 `public/fonts/smart-edit` 的真实字体和许可证复制到 `dist/fonts`,发布时必须与 `server.cjs` 一起携带。服务默认读取同目录 `fonts`,可用 `SMART_EDIT_FONTS_DIR` 指定绝对路径;主项目本地合成默认读取 `public/fonts/smart-edit`。缺失所选字体时返回明确错误,不悄悄使用其他字体。五种字体来自 Adobe/Google 官方仓库,来源、摘要和各自 SIL OFL 许可保存在字体目录。

View File

@ -16,4 +16,8 @@ esbuild
target: "node22", target: "node22",
format: "cjs", format: "cjs",
}) })
.then(() => {
fs.cpSync("public/fonts/smart-edit", "services/video-render/dist/fonts", { recursive: true });
fs.copyFileSync("services/smart-edit/font.py", "services/video-render/dist/font.py");
})
.catch(() => process.exit(1)); .catch(() => process.exit(1));

View File

@ -23,6 +23,8 @@ import {
import { renderJob } from "../../lib/video-canvas/smart-edit/render-job"; import { renderJob } from "../../lib/video-canvas/smart-edit/render-job";
import { pruneSmartEditCache } from "../../lib/video-canvas/smart-edit/local"; import { pruneSmartEditCache } from "../../lib/video-canvas/smart-edit/local";
process.env.SMART_EDIT_FONT_SCRIPT ||= path.join(__dirname, "font.py");
process.env.SMART_EDIT_FONTS_DIR ||= path.join(__dirname, "fonts");
const root = path.resolve(process.env.RENDER_DATA_DIR || "/var/lib/video-render"); const root = path.resolve(process.env.RENDER_DATA_DIR || "/var/lib/video-render");
const token = process.env.RENDER_API_TOKEN || ""; const token = process.env.RENDER_API_TOKEN || "";
if (token.length < 32) throw Error("RENDER_API_TOKEN must have at least 32 characters"); if (token.length < 32) throw Error("RENDER_API_TOKEN must have at least 32 characters");
@ -113,11 +115,17 @@ async function work() {
job.stage = stage; job.stage = stage;
await persist(job); await persist(job);
}); });
job.result = { width: result.width, height: result.height, durationSeconds: result.duration }; job.result = {
width: result.width,
height: result.height,
durationSeconds: result.duration,
};
// Keep only the deliverables. Intermediate files must not accumulate per job. // Keep only the deliverables. Intermediate files must not accumulate per job.
for (const name of await readdir(dir)) for (const name of await readdir(dir))
if (!["result.mp4", "subtitles.srt"].includes(name)) if (!["result.mp4", "subtitles.srt"].includes(name))
await rm(path.join(dir, name), { force: true }); await rm(path.join(dir, name), { recursive: true, force: true }).catch(() => {
console.warn(JSON.stringify({ event: "render-cleanup-deferred", id: job.id }));
});
job.status = "succeeded"; job.status = "succeeded";
job.stage = "合成完成"; job.stage = "合成完成";
await persist(job); await persist(job);
@ -260,7 +268,8 @@ async function main() {
); );
if ( if (
jobs.size >= 200 || jobs.size >= 200 ||
[...jobs.values()].filter((j) => ["queued", "running"].includes(j.status)).length >= 30 [...jobs.values()].filter((j) => ["queued", "running"].includes(j.status)).length >=
30
) )
return json(res, 429, { error: "合成队列已满,请稍后重试" }); return json(res, 429, { error: "合成队列已满,请稍后重试" });
const job: Job = { const job: Job = {
@ -283,7 +292,9 @@ async function main() {
accepting = false; accepting = false;
} }
} }
const match = url.pathname.match(/^\/v1\/renders\/([a-f0-9]{64})(?:\/(video|subtitles))?$/); const match = url.pathname.match(
/^\/v1\/renders\/([a-f0-9]{64})(?:\/(video|subtitles))?$/,
);
if (req.method === "GET" && match) { if (req.method === "GET" && match) {
const job = jobs.get(match[1]); const job = jobs.get(match[1]);
if (!job) return json(res, 404, { error: "任务不存在或已过期" }); if (!job) return json(res, 404, { error: "任务不存在或已过期" });
@ -321,7 +332,10 @@ async function main() {
}); });
server.requestTimeout = 120000; server.requestTimeout = 120000;
server.headersTimeout = 10000; server.headersTimeout = 10000;
server.listen(Number(process.env.RENDER_PORT || 18340), process.env.RENDER_HOST || "127.0.0.1"); server.listen(
Number(process.env.RENDER_PORT || 18340),
process.env.RENDER_HOST || "127.0.0.1",
);
const fatal = () => { const fatal = () => {
console.error("Render queue persistence failure"); console.error("Render queue persistence failure");
process.exit(1); process.exit(1);

View File

@ -0,0 +1,46 @@
# 独立视频高清服务
主项目负责用户/项目鉴权、持久任务、归档;此服务只下载视频并在独立 GPU 上逐帧超分。没有 CPU 回退,不使用千问显卡。当前部署固定 `CUDA_VISIBLE_DEVICES=3`,GPU 2 有硬件故障历史,不分配给本服务。
## 模型和环境
使用 [Real-ESRGAN](https://github.com/xinntao/Real-ESRGAN) 的 `realesr-general-x4v3`,官方 SRVGGNetCompact 架构(64 通道、32 卷积层、PReLU、4 倍输出);源码与 BSD-3-Clause 许可位于 `vendor/`,来源 commit 见 `vendor/SOURCE.md`。不是 Topaz 模型。模型文件不提交仓库。
权重:[官方 v0.2.5.0 发布](https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesr-general-x4v3.pth),SHA-256 `8dc7edb9ac80ccdc30c3a5dca6616509367f05fbc184ad95b731f05bece96292`,推理前强校验。
P40 环境实测:Python 3.12.3、PyTorch 2.5.1+cu121、NumPy 1.26.4、Pillow 10.4.0、系统 FFmpeg/ffprobe(libx264/AAC)。PyTorch 使用 CUDA 12.1 官方 wheel 源;必须固定此版本,不能未经 P40 验证直接升级 CUDA/PyTorch。FP32 推理,192px tile + 40px halo,整帧输出在 CPU 内存,避免全帧网络推理耗尽显存。每个服务同时处理一个任务。
## 部署与 API
构建:`node services/video-upscale/build.cjs`,将 `dist/` 内容放到 `/opt/video-upscale/app/`。环境位于 `/opt/video-upscale/venv/`,权重 `/opt/video-upscale/models/realesr-general-x4v3.pth`。参考 `video-upscale.service` 创建专用 `video-upscale` 用户和其拥有的 `/var/lib/video-upscale`。
`/etc/video-upscale.env` 权限 600,包含 `UPSCALE_API_TOKEN`(至少 32 字符)、`UPSCALE_HOST=127.0.0.1`、`UPSCALE_PORT=18341`、`UPSCALE_DATA_DIR=/var/lib/video-upscale`。禁止将凭据放进 Git 或浏览器。
所有 API 要求 `Authorization: Bearer <token>`:
- `GET /health`:服务、队列和 GPU 配置。
- `POST /v1/upscales`:`{id,tenant,plan:{url,resolution}}`,id/tenant 均为 SHA-256 标识,resolution 为 `1080P`/`2K`/`4K`。
- `GET /v1/upscales/:id`:状态、阶段、帧数/帧率/音轨、尺寸与时长。
- `GET /v1/upscales/:id/video`:鉴权读取结果。
48 服务器使用 `video-upscale-tunnel.service`,复用已有专用隧道账号和固定 known_hosts,只扩展该公钥的 `permitlisten` 到 `127.0.0.1:18341`;原 18340 合成隧道不变。主项目接入变量为 `VIDEO_UPSCALE_URL=http://127.0.0.1:18341` 和 `VIDEO_UPSCALE_TOKEN`,部署时从 root-only `/etc/video-upscale-client.env` 注入,不能依赖本机临时 SSH 转发。
任务落盘 fsync + rename;相同 ID/参数幂等,相同 ID/不同参数 409。服务重启保留 running 记录,最多恢复两次;`KillMode=mixed` 先通知 Node 保存关闭状态,再终止子进程,避免把正常重启误记为推理失败。主项目保存远程 ID/服务指纹,轮询超时、读取/归档失败继续查询同一任务。
## 处理边界
输入 ≤125 MiB、≤120 秒、≤60fps、≤4K 像素数;支持 MP4/MOV、Matroska/WebM、AVI、FLV、MPEG-TS。仅恒定帧率;VFR 明确拒绝,不能静默改变音画同步。手机 90/180/270 度旋转信息会归一化,保留帧数、帧率、音轨。音频重编码 AAC,视频 H.264/yuv420p + faststart。不做补帧。
目标以 1920×1080 / 2560×1440 / 3840×2160 为横屏边界,竖屏交换宽高,等比例缩放、偶数尺寸。目标必须大于原图且至多 4 倍;目标已满足或要求超过 4 倍时拒绝,不拿插值假称模型支持更高倍率。网络输出可能产生细节变化,不能保证真实细节还原。
单任务超时 2 小时;≤30 个在途任务,≤200 个保留任务,完成/失败保留 7 天;可用磁盘 <10 GiB 拒绝开始。输入禁止内网/DNS 重绑定/不安全重定向,FFmpeg 网络协议关闭;输出下载 ≤300 MiB。systemd 内存 8 GiB、CPU 4 核、任务/线程 256,滤镜线程固定 1,避免 128 核宿主机默认线程数导致编码失败。
## 验收
`scripts/qa/video-upscale-service.cjs` 验证真实 API、GPU、音轨/帧数/尺寸、重复提交、参数冲突、未鉴权下载及重启恢复。需设置 `VIDEO_UPSCALE_QA_URL`、`VIDEO_UPSCALE_QA_TOKEN`、`VIDEO_UPSCALE_QA_SOURCE`(960×540、2fps、1 秒、含音轨的合成测试片);可选 `VIDEO_UPSCALE_QA_SSH_HOST` / `VIDEO_UPSCALE_QA_SSH_CONTROL` 执行独立服务重启。只重启视频高清服务,不重启千问或主项目。
测试素材生成示例(应上传到获准访问的测试存储域名):
```sh
ffmpeg -f lavfi -i 'testsrc2=size=960x540:rate=2:duration=1' -f lavfi -i 'sine=frequency=440:sample_rate=48000:duration=1' -c:v libx264 -pix_fmt yuv420p -c:a aac -shortest landscape.mp4
```

View File

@ -0,0 +1,32 @@
const fs = require("node:fs"),
path = require("node:path");
const esbuild = require(
path.resolve(
"node_modules/.pnpm",
fs
.readdirSync("node_modules/.pnpm")
.find((n) => n.startsWith("esbuild@0.25.")),
"node_modules/esbuild"
)
);
esbuild
.build({
entryPoints: ["services/video-upscale/server.ts"],
outfile: "services/video-upscale/dist/server.cjs",
bundle: true,
platform: "node",
target: "node22",
format: "cjs",
})
.then(() => {
fs.copyFileSync(
"services/video-upscale/upscale.py",
"services/video-upscale/dist/upscale.py"
);
fs.cpSync(
"services/video-upscale/vendor",
"services/video-upscale/dist/vendor",
{ recursive: true }
);
})
.catch(() => process.exit(1));

View File

@ -0,0 +1,85 @@
import { spawn } from "node:child_process";
import path from "node:path";
import { downloadCanvasMedia } from "../../lib/video-canvas/download-media";
import {
videoUpscaleSchema,
type VideoUpscaleInput,
} from "../../lib/video-canvas/video-upscale-contract";
export async function upscaleJob(
value: VideoUpscaleInput,
_tenant: string,
dir: string,
progress: (stage: string) => Promise<void>
) {
const input = videoUpscaleSchema.parse(value);
const file = path.join(dir, "input.bin");
await downloadCanvasMedia(input.url, file);
await progress("GPU 正在进行 AI 超分");
return new Promise<any>((resolve, reject) => {
const child = spawn(
process.env.UPSCALE_PYTHON || "/opt/video-upscale/venv/bin/python",
[
process.env.UPSCALE_SCRIPT || path.join(__dirname, "upscale.py"),
"--input",
file,
"--output",
path.join(dir, "result.mp4"),
"--resolution",
input.resolution,
"--model",
process.env.UPSCALE_MODEL ||
"/opt/video-upscale/models/realesr-general-x4v3.pth",
],
{ stdio: ["ignore", "pipe", "pipe"], detached: true }
);
let buffer = "",
result: any,
error = "",
stages = Promise.resolve();
const timer = setTimeout(() => {
try {
process.kill(-child.pid!, "SIGKILL");
} catch {}
}, 2 * 3600000);
child.stdout.on("data", (chunk) => {
buffer += chunk;
const lines = buffer.split("\n");
buffer = lines.pop()!;
for (const line of lines)
try {
const value = JSON.parse(line);
if (value.result) result = value.result;
if (value.stage)
stages = stages
.then(() => progress(value.stage))
.catch(() => {
try {
process.kill(-child.pid!, "SIGKILL");
} catch {}
});
} catch {}
});
child.stderr.on("data", (chunk) => {
error = (error + chunk).slice(-4000);
});
child.on("error", (e) => {
clearTimeout(timer);
reject(e);
});
child.on("close", async (code) => {
clearTimeout(timer);
await stages.catch(() => {});
if (code !== 0 || !result) {
console.error(
"Upscale inference failed",
error.replace(/https?:\/\/\S+/g, "[url]")
);
reject(
Error(
"AI 超分失败,请检查视频是否为恒定帧率、120 秒以内,且放大倍率为 1–4 倍"
)
);
} else resolve({ ...result, duration: result.durationSeconds });
});
});
}

View File

@ -0,0 +1,328 @@
import http from "node:http";
import { createReadStream } from "node:fs";
import {
mkdir,
readFile,
rename,
readdir,
rm,
stat,
statfs,
open,
} from "node:fs/promises";
import path from "node:path";
import { createHash, timingSafeEqual } from "node:crypto";
import { pipeline } from "node:stream/promises";
import { z } from "zod";
import {
videoUpscaleSchema,
type VideoUpscaleInput,
} from "../../lib/video-canvas/video-upscale-contract";
import { upscaleJob } from "./job";
const root = path.resolve(
process.env.UPSCALE_DATA_DIR || "/var/lib/video-upscale"
);
const token = process.env.UPSCALE_API_TOKEN || "";
if (token.length < 32)
throw Error("UPSCALE_API_TOKEN must have at least 32 characters");
const jobs = new Map<string, Job>();
type Job = {
id: string;
digest: string;
tenant: string;
plan: VideoUpscaleInput;
status: "queued" | "running" | "succeeded" | "failed";
stage: string;
createdAt: string;
updatedAt: string;
attempts: number;
error?: string;
result?: Record<string, any>;
};
const inputSchema = z.object({
id: z.string().regex(/^[a-f0-9]{64}$/),
tenant: z.string().regex(/^[a-f0-9]{64}$/),
plan: videoUpscaleSchema,
});
let busy = false,
stopping = false,
accepting = false;
const file = (id: string) => path.join(root, id + ".json");
async function persist(job: Job) {
job.updatedAt = new Date().toISOString();
const temp = file(job.id) + ".tmp";
const handle = await open(temp, "w", 0o600);
try {
await handle.writeFile(JSON.stringify(job));
await handle.sync();
} finally {
await handle.close();
}
await rename(temp, file(job.id));
const directory = await open(root, "r");
try {
await directory.sync();
} finally {
await directory.close();
}
jobs.set(job.id, job);
}
function publicJob(job: Job) {
return {
id: job.id,
status: job.status,
stage: job.stage,
attempts: job.attempts,
createdAt: job.createdAt,
updatedAt: job.updatedAt,
error: job.error,
result: job.result,
};
}
async function prune() {
for (const job of jobs.values())
if (
["succeeded", "failed"].includes(job.status) &&
Date.now() - Date.parse(job.updatedAt) > 7 * 86400000
) {
await rm(path.join(root, job.id), { recursive: true, force: true });
await rm(file(job.id), { force: true });
jobs.delete(job.id);
}
}
async function work() {
if (busy || stopping) return;
const job = [...jobs.values()].find((j) => j.status === "queued");
if (!job) return;
busy = true;
const dir = path.join(root, job.id);
try {
await prune();
const disk = await statfs(root);
if (disk.bavail * disk.bsize < 10 * 1024 ** 3)
throw Error("超分磁盘剩余空间不足 10 GiB");
job.status = "running";
job.attempts++;
job.stage = "准备素材";
await persist(job);
await rm(dir, { recursive: true, force: true });
await mkdir(dir, { mode: 0o700 });
const result = await upscaleJob(
job.plan,
job.tenant,
dir,
async (stage) => {
job.stage = stage;
await persist(job);
}
);
job.result = result;
// Keep only the deliverables. Intermediate files must not accumulate per job.
for (const name of await readdir(dir))
if (name !== "result.mp4")
await rm(path.join(dir, name), { recursive: true, force: true }).catch(
() => {
console.warn(
JSON.stringify({ event: "upscale-cleanup-deferred", id: job.id })
);
}
);
job.status = "succeeded";
job.stage = "超分完成";
await persist(job);
} catch (error) {
if (stopping) return; // Preserve running journal; restart will resume this same job.
job.status = "failed";
job.stage = "超分失败";
// Do not expose ffmpeg arguments, private cache paths or signed source URLs.
job.error =
error instanceof Error &&
!/Command failed|https?:|\/var\/|\/tmp\//.test(error.message)
? error.message.slice(0, 300)
: "素材处理失败,请检查格式、参数或服务日志";
await rm(dir, { recursive: true, force: true }).catch(() => {});
await persist(job);
console.error(
JSON.stringify({ event: "upscale-failed", id: job.id, error: job.error })
);
} finally {
busy = false;
}
setImmediate(
() =>
void work().catch((e) => {
console.error("Render queue persistence failure");
process.exit(1);
})
);
}
function json(res: http.ServerResponse, status: number, value: unknown) {
res.writeHead(status, {
"content-type": "application/json; charset=utf-8",
"cache-control": "no-store",
});
res.end(JSON.stringify(value));
}
async function body(req: http.IncomingMessage) {
let size = 0;
const chunks: Buffer[] = [];
for await (const chunk of req) {
size += chunk.length;
if (size > 1024 * 1024) throw Error("请求超过 1 MiB");
chunks.push(chunk);
}
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
}
function authenticated(req: http.IncomingMessage) {
const expected = Buffer.from("Bearer " + token),
actual = Buffer.from(req.headers.authorization || "");
return expected.length === actual.length && timingSafeEqual(expected, actual);
}
async function main() {
await mkdir(root, { recursive: true, mode: 0o700 });
// This service runs as a single systemd process. A restart requeues local work at most twice.
for (const name of await readdir(root))
if (/^[a-f0-9]{64}\.json$/.test(name)) {
const job: Job = JSON.parse(
await readFile(path.join(root, name), "utf8")
);
if (job.id + ".json" !== name) throw Error("Invalid upscale record");
if (job.status === "running") {
job.status = job.attempts < 3 ? "queued" : "failed";
job.stage =
job.status === "queued"
? "服务重启,等待恢复超分"
: "多次中断,已停止";
if (job.status === "failed") job.error = job.stage;
await persist(job);
}
jobs.set(job.id, job);
}
await prune();
const server = http.createServer(async (req, res) => {
if (!authenticated(req)) return json(res, 401, { error: "Unauthorized" });
const url = new URL(req.url || "/", "http://localhost");
try {
if (req.method === "GET" && url.pathname === "/health")
return json(res, 200, {
status: "ok",
version: 1,
active: busy ? 1 : 0,
queued: [...jobs.values()].filter((j) => j.status === "queued")
.length,
render: "realesr-general-x4v3",
gpu: process.env.CUDA_VISIBLE_DEVICES,
});
if (req.method === "POST" && url.pathname === "/v1/upscales") {
const input = inputSchema.parse(await body(req));
// Serialize admission across the async filesystem write to preserve idempotency.
if (accepting) return json(res, 503, { error: "请重试提交" });
accepting = true;
try {
const digest = createHash("sha256")
.update(JSON.stringify({ tenant: input.tenant, plan: input.plan }))
.digest("hex");
const existing = jobs.get(input.id);
if (existing)
return json(
res,
existing.digest === digest ? 200 : 409,
existing.digest === digest
? publicJob(existing)
: { error: "任务 ID 已用于不同参数" }
);
if (
jobs.size >= 200 ||
[...jobs.values()].filter((j) =>
["queued", "running"].includes(j.status)
).length >= 30
)
return json(res, 429, { error: "超分队列已满,请稍后重试" });
const job: Job = {
...input,
digest,
status: "queued",
stage: "排队中",
attempts: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
await persist(job);
json(res, 202, publicJob(job));
void work().catch(() => {
console.error("Render queue failed");
process.exit(1);
});
return;
} finally {
accepting = false;
}
}
const match = url.pathname.match(
/^\/v1\/upscales\/([a-f0-9]{64})(?:\/(video))?$/
);
if (req.method === "GET" && match) {
const job = jobs.get(match[1]);
if (!job) return json(res, 404, { error: "任务不存在或已过期" });
if (!match[2]) return json(res, 200, publicJob(job));
if (job.status !== "succeeded")
return json(res, 409, { error: "成片尚未就绪" });
const output = path.join(root, job.id, "result.mp4");
const info = await stat(output);
res.writeHead(200, {
"content-type": "video/mp4",
"content-length": info.size,
"cache-control": "no-store",
});
await pipeline(createReadStream(output), res);
return;
}
json(res, 404, { error: "Not found" });
} catch (error) {
if (res.headersSent) {
res.destroy();
return;
}
json(res, 400, {
error:
error instanceof z.ZodError
? "超分参数无效"
: error instanceof Error && !/https?:|\/var\//.test(error.message)
? error.message.slice(0, 300)
: "请求处理失败",
});
}
});
server.requestTimeout = 120000;
server.headersTimeout = 10000;
server.listen(
Number(process.env.UPSCALE_PORT || 18341),
process.env.UPSCALE_HOST || "127.0.0.1"
);
const fatal = () => {
console.error("Render queue persistence failure");
process.exit(1);
};
const timer = setInterval(() => {
void work().catch(fatal);
}, 3000);
const cleanupTimer = setInterval(() => {
if (!busy && !accepting) void prune().catch(fatal);
}, 3600000);
cleanupTimer.unref();
for (const signal of ["SIGTERM", "SIGINT"])
process.on(signal, () => {
stopping = true;
clearInterval(timer);
clearInterval(cleanupTimer);
server.close();
// systemd KillMode=mixed also terminates FFmpeg; persisted running jobs resume on restart.
setTimeout(() => process.exit(0), 1000).unref();
});
console.log("Video upscale service ready");
}
void main().catch(() => {
console.error("Video upscale service startup failed");
process.exitCode = 1;
});

View File

@ -0,0 +1,79 @@
"""Bounded, tiled Real-ESRGAN video inference. No CPU fallback or frame interpolation."""
import argparse, json, subprocess, sys, os, hashlib
from fractions import Fraction
from pathlib import Path
import numpy as np
import torch
from PIL import Image
sys.path.insert(0, str(Path(__file__).parent / 'vendor'))
from srvgg_arch import SRVGGNetCompact
def probe(file):
return json.loads(subprocess.check_output(['ffprobe','-v','error','-protocol_whitelist','file,pipe','-show_streams','-show_format','-of','json',file], timeout=30))
def main():
p=argparse.ArgumentParser()
for name in ['input','output','model','resolution']: p.add_argument('--'+name, required=True)
p.add_argument('--tile', type=int, default=192)
a=p.parse_args()
if not torch.cuda.is_available(): raise RuntimeError('CUDA unavailable; refusing CPU fallback')
torch.set_num_threads(4)
info=probe(a.input); v=next(s for s in info['streams'] if s['codec_type']=='video')
if not any(name in info['format']['format_name'].split(',') for name in ['mov','mp4','matroska','webm','avi','flv','mpegts']): raise ValueError('Unsupported video container')
rotation=next((int(side['rotation'])%360 for side in v.get('side_data_list',[]) if 'rotation' in side),0)
if rotation%90: raise ValueError('Unsupported rotation angle')
w,h=int(v['width']),int(v['height'])
if rotation in [90,270]: w,h=h,w
fps=Fraction(v['avg_frame_rate'])
duration=float(info['format']['duration'])
if not 0<duration<=120 or not 0<float(fps)<=60 or w*h>3840*2160: raise ValueError('Input exceeds 120 s / 60 fps / 4K limits')
# Reject variable frame rate rather than silently altering timing/audio sync.
frame_times=subprocess.check_output(['ffprobe','-v','error','-protocol_whitelist','file,pipe','-select_streams','v:0','-show_entries','frame=best_effort_timestamp_time','-of','csv=p=0',a.input],timeout=60).decode().splitlines()
timestamps=[float(s.split(',')[0]) for s in frame_times if s.split(',')[0].strip()]
if any(abs((b-c)-float(1/fps))>max(.002,float(1/fps)*.05) for c,b in zip(timestamps,timestamps[1:])): raise ValueError('Variable frame rate video must be converted to constant frame rate first')
long,short={'1080P':(1920,1080),'2K':(2560,1440),'4K':(3840,2160)}[a.resolution]
scale=min((long if w>=h else short)/w,(short if w>=h else long)/h)
if not 1<scale<=4: raise ValueError('Target must exceed source resolution by no more than 4x')
ow,oh=int(w*scale)//2*2,int(h*scale)//2*2
model=SRVGGNetCompact(num_in_ch=3,num_out_ch=3,num_feat=64,num_conv=32,upscale=4,act_type='prelu')
if hashlib.sha256(Path(a.model).read_bytes()).hexdigest() != '8dc7edb9ac80ccdc30c3a5dca6616509367f05fbc184ad95b731f05bece96292': raise RuntimeError('Model checksum mismatch')
weights=torch.load(a.model,map_location='cpu',weights_only=True)
model.load_state_dict(weights.get('params_ema',weights.get('params',weights)),strict=True)
model.eval().to('cuda')
temp=a.output+'.silent.mp4'
decode=subprocess.Popen(['ffmpeg','-v','error','-filter_threads','1','-filter_complex_threads','1','-threads','2','-protocol_whitelist','file,pipe','-i',a.input,'-map','0:v:0','-f','rawvideo','-pix_fmt','rgb24','-vsync','0','pipe:1'],stdout=subprocess.PIPE)
encode=subprocess.Popen(['ffmpeg','-v','error','-filter_threads','1','-filter_complex_threads','1','-y','-f','rawvideo','-pix_fmt','rgb24','-s',f'{ow}x{oh}','-r',str(fps),'-i','pipe:0','-an','-c:v','libx264','-threads','4','-preset','fast','-crf','18','-pix_fmt','yuv420p',temp],stdin=subprocess.PIPE)
count=0
try:
with torch.inference_mode():
while True:
raw=decode.stdout.read(w*h*3)
if not raw: break
if len(raw)!=w*h*3: raise RuntimeError('Truncated video frame')
source=np.frombuffer(raw,dtype=np.uint8).reshape(h,w,3)
# CPU output buffer, bounded GPU tiles with enough halo for 35 convolutions.
output=np.empty((h*4,w*4,3),dtype=np.uint8)
for y in range(0,h,a.tile):
for x in range(0,w,a.tile):
x0,y0=max(0,x-40),max(0,y-40); x1,y1=min(w,x+a.tile+40),min(h,y+a.tile+40)
tile=torch.from_numpy(source[y0:y1,x0:x1].copy()).permute(2,0,1).float().unsqueeze(0).to('cuda')/255
enhanced=model(tile).clamp_(0,1).squeeze(0).permute(1,2,0).mul_(255).round_().byte().cpu().numpy()
ex,ey=min(w,x+a.tile),min(h,y+a.tile)
output[y*4:ey*4,x*4:ex*4]=enhanced[(y-y0)*4:(ey-y0)*4,(x-x0)*4:(ex-x0)*4]
frame=Image.fromarray(output)
if (ow,oh)!=(w*4,h*4): frame=frame.resize((ow,oh),Image.Resampling.LANCZOS)
encode.stdin.write(frame.tobytes()); count+=1
if count%5==0: print(json.dumps({'stage':f'AI 超分 {count}/{len(timestamps)} 帧'}),flush=True)
encode.stdin.close()
if decode.wait(timeout=30)!=0 or encode.wait(timeout=120)!=0: raise RuntimeError('Video codec failed')
finally:
for process in [decode,encode]:
if process.poll() is None: process.kill(); process.wait()
if count!=len(timestamps) or count==0: raise RuntimeError('Output frame count mismatch')
subprocess.run(['ffmpeg','-v','error','-filter_threads','1','-filter_complex_threads','1','-y','-protocol_whitelist','file,pipe','-i',temp,'-protocol_whitelist','file,pipe','-i',a.input,'-map','0:v:0','-map','1:a?','-map_metadata','1','-metadata:s:v:0','rotate=0','-c:v','copy','-c:a','aac','-b:a','192k','-movflags','+faststart',a.output],check=True,timeout=120)
actual=probe(a.output); stream=next(s for s in actual['streams'] if s['codec_type']=='video')
if int(stream['nb_frames'])!=count or abs(float(actual['format']['duration'])-duration)>.2: raise RuntimeError('Output timing mismatch')
if any(s['codec_type']=='audio' for s in actual['streams']) != any(s['codec_type']=='audio' for s in info['streams']): raise RuntimeError('Output audio stream missing')
os.unlink(temp)
print(json.dumps({'result':{'width':ow,'height':oh,'durationSeconds':float(actual['format']['duration']),'fps':float(fps),'frames':count,'audio':any(s['codec_type']=='audio' for s in actual['streams']),'engine':'Real-ESRGAN','model':'realesr-general-x4v3'}}),flush=True)
if __name__=='__main__': main()

29
services/video-upscale/vendor/LICENSE vendored Normal file
View File

@ -0,0 +1,29 @@
BSD 3-Clause License
Copyright (c) 2021, Xintao Wang
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

Some files were not shown because too many files have changed in this diff Show More