Compare commits
12 Commits
main
...
codex/vide
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd9f340a89 | ||
|
|
e8db66c6d5 | ||
|
|
1f3f02e136 | ||
|
|
cb499612f4 | ||
|
|
e18413a4dd | ||
|
|
68f62dae58 | ||
|
|
431ca03fac | ||
|
|
048d50c357 | ||
|
|
f25e78e570 | ||
|
|
41e2db9ca7 | ||
|
|
9b78fbc9f7 | ||
|
|
aa8a3011e4 |
12
.gitignore
vendored
12
.gitignore
vendored
@ -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__/
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
39
app/api/canvas/group-runs/route.ts
Normal file
39
app/api/canvas/group-runs/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/api/canvas/group-templates/route.ts
Normal file
40
app/api/canvas/group-templates/route.ts
Normal 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" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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 },
|
||||||
|
|||||||
22
app/api/canvas/smart-edit/fonts/[id]/route.ts
Normal file
22
app/api/canvas/smart-edit/fonts/[id]/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
39
app/api/canvas/video-upscale/capabilities/route.ts
Normal file
39
app/api/canvas/video-upscale/capabilities/route.ts
Normal 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" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
125
components/canvas/CanvasGroupToolbar.tsx
Normal file
125
components/canvas/CanvasGroupToolbar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
components/canvas/GroupStoryboardPreview.tsx
Normal file
122
components/canvas/GroupStoryboardPreview.tsx
Normal 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
226
components/canvas/smart-edit/CaptionFonts.tsx
Normal file
226
components/canvas/smart-edit/CaptionFonts.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
1102
components/canvas/smart-edit/SmartEditEditor.module.css
Normal file
1102
components/canvas/smart-edit/SmartEditEditor.module.css
Normal file
File diff suppressed because it is too large
Load Diff
2396
components/canvas/smart-edit/SmartEditEditor.tsx
Normal file
2396
components/canvas/smart-edit/SmartEditEditor.tsx
Normal file
File diff suppressed because it is too large
Load Diff
386
components/canvas/smart-edit/SmartEditNode.module.css
Normal file
386
components/canvas/smart-edit/SmartEditNode.module.css
Normal 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; }
|
||||||
476
components/canvas/smart-edit/SmartEditNode.tsx
Normal file
476
components/canvas/smart-edit/SmartEditNode.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
components/canvas/smart-edit/TransitionPreview.tsx
Normal file
75
components/canvas/smart-edit/TransitionPreview.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
components/canvas/smart-edit/usePreviewGain.ts
Normal file
111
components/canvas/smart-edit/usePreviewGain.ts
Normal 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));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
63
docs/smart-edit-completion-2026-09-18.md
Normal file
63
docs/smart-edit-completion-2026-09-18.md
Normal 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、中文混排、字形抗锯齿方面都完成逐像素对拍。后台休眠/断网/所有窄屏矩阵、运行中远程任务主动取消、五种模式算法与参考逐案一致性仍单列待验,不混入本轮完成项。
|
||||||
777
docs/smart-edit-fixes-2026-09-18.json
Normal file
777
docs/smart-edit-fixes-2026-09-18.json
Normal 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与休眠断网矩阵未完成。"
|
||||||
|
}
|
||||||
177
docs/smart-edit-fixes-2026-09-18.md
Normal file
177
docs/smart-edit-fixes-2026-09-18.md
Normal 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/操作记录 |
|
||||||
775
docs/smart-edit-full-recheck-2026-09-17.json
Normal file
775
docs/smart-edit-full-recheck-2026-09-17.json
Normal 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/操作记录"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
208
docs/smart-edit-full-recheck-2026-09-17.md
Normal file
208
docs/smart-edit-full-recheck-2026-09-17.md
Normal 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)。旧报告如有冲突,以本轮明确复核的条目为准。
|
||||||
59
docs/smart-edit-subfeature-audit-2026-09-17.md
Normal file
59
docs/smart-edit-subfeature-audit-2026-09-17.md
Normal 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参数已恢复;临时新建字幕已清理;没有扣费生成或发布。
|
||||||
|
- 字体、人声分离、字幕细节和正增益这些差异仍然存在,**不能标记“全部功能已齐/全站像素级一致”**。服务器更新继续冻结。
|
||||||
56
docs/smart-edit-ui-parity-2026-09-17.md
Normal file
56
docs/smart-edit-ui-parity-2026-09-17.md
Normal 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),不要把此前概括性“已实现”解释为参考站所有子功能均已一致。该批继续保持本地,服务器未更新。
|
||||||
59
docs/video-canvas-base-audit-2026-09-18.md
Normal file
59
docs/video-canvas-base-audit-2026-09-18.md
Normal 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人声分离已完成。
|
||||||
54
docs/video-canvas-connections-2026-09-18.md
Normal file
54
docs/video-canvas-connections-2026-09-18.md
Normal 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;本轮未修改编解码实现)。覆盖循环、端点缺失、类型、模式容量、宫格部分失败、文本更新及首尾帧位置语义。
|
||||||
|
|
||||||
|
## 尚未完成与边界
|
||||||
|
|
||||||
|
- 整组依赖执行器、完整编组成员关系尚未实现。
|
||||||
|
- 剪辑器内部独立上传的素材、编辑好的时间轴与画布输入边是不同层数据,现有成片/时间轴不会被改线自动改写;编辑器内素材增删和画布编组之间还需要完整双向同步验收。
|
||||||
|
- 部分专用节点有自己的提交路径,本轮不宣称所有导演台、标记对象和第三方工具组合已全链路验收。
|
||||||
|
- 模型结果质量、耗时、真实供应商生成没有在本轮重测。本轮验证输入关系和请求边界。
|
||||||
|
- 代码仅本地修复,未推送、未更新主站服务器。
|
||||||
39
docs/video-canvas-groups-upscale-2026-09-18.md
Normal file
39
docs/video-canvas-groups-upscale-2026-09-18.md
Normal 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 账号。
|
||||||
@ -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`。
|
||||||
|
|
||||||
|
用户进一步要求先在本地补齐和测试特效等缺项,再更新服务器。因此此后的画面变换、字幕坐标和独立音轨等增量只在本地开发验证,尚未同步至候选或算力服务;需要将主项目与算力服务的共享合同同步发布后才能在线使用。完成已确认功能不代表全部参考站子功能已核对完毕,继续对照的记录见智能剪辑界面验收文档。
|
||||||
|
|||||||
90
hooks/use-canvas-group-runs.ts
Normal file
90
hooks/use-canvas-group-runs.ts
Normal 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 };
|
||||||
|
}
|
||||||
@ -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],
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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 九宫格、多机位分镜、黑色分割线、重复头像、文字、水印、编号、环境场景或额外人物。",
|
||||||
"以输入角色图为唯一角色参考,生成正面、侧面、背面三视图角色设定图。保持五官、发型、服装、颜色和体型一致,干净白底,三视图水平排布,适合后续视频角色一致性使用。",
|
"以输入角色图为唯一角色参考,生成正面、侧面、背面三视图角色设定图。保持五官、发型、服装、颜色和体型一致,干净白底,三视图水平排布,适合后续视频角色一致性使用。",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
116
lib/video-canvas/connections.ts
Normal file
116
lib/video-canvas/connections.ts
Normal 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;
|
||||||
|
}
|
||||||
333
lib/video-canvas/group-execution.ts
Normal file
333
lib/video-canvas/group-execution.ts
Normal 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
398
lib/video-canvas/groups.ts
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
178
lib/video-canvas/smart-edit/audio-process.ts
Normal file
178
lib/video-canvas/smart-edit/audio-process.ts
Normal 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),
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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)} 秒;保留完整语句或受素材长度限制。`,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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: "转存成片" });
|
||||||
|
|||||||
29957
lib/video-canvas/smart-edit/font-catalog.json
Normal file
29957
lib/video-canvas/smart-edit/font-catalog.json
Normal file
File diff suppressed because it is too large
Load Diff
88
lib/video-canvas/smart-edit/font-files.ts
Normal file
88
lib/video-canvas/smart-edit/font-files.ts
Normal 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;
|
||||||
|
}
|
||||||
64
lib/video-canvas/smart-edit/fonts.ts
Normal file
64
lib/video-canvas/smart-edit/fonts.ts
Normal 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 || "");
|
||||||
20
lib/video-canvas/smart-edit/intent.ts
Normal file
20
lib/video-canvas/smart-edit/intent.ts
Normal 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("音乐卡点需要先选择或上传背景音乐");
|
||||||
|
}
|
||||||
@ -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 {
|
||||||
|
|||||||
@ -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(),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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 };
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
33
lib/video-canvas/video-upscale-contract.ts
Normal file
33
lib/video-canvas/video-upscale-contract.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
181
lib/video-canvas/video-upscale-remote.ts
Normal file
181
lib/video-canvas/video-upscale-remote.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
62
lib/video-canvas/video-upscale.ts
Normal file
62
lib/video-canvas/video-upscale.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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: {
|
||||||
|
|||||||
@ -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/,
|
||||||
|
|||||||
36
public/fonts/smart-edit/MANIFEST.json
Normal file
36
public/fonts/smart-edit/MANIFEST.json
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
9
public/fonts/smart-edit/README.md
Normal file
9
public/fonts/smart-edit/README.md
Normal 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`。
|
||||||
|
|
||||||
|
这五个实际可用字体不等于参考站完整目录;未添加只有名称、没有字体文件的假选项。
|
||||||
37
public/fonts/smart-edit/SOURCES.json
Normal file
37
public/fonts/smart-edit/SOURCES.json
Normal 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"
|
||||||
|
]
|
||||||
|
]
|
||||||
93
public/fonts/smart-edit/inter-LICENSE.txt
Normal file
93
public/fonts/smart-edit/inter-LICENSE.txt
Normal 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.
|
||||||
BIN
public/fonts/smart-edit/inter.ttf
Normal file
BIN
public/fonts/smart-edit/inter.ttf
Normal file
Binary file not shown.
93
public/fonts/smart-edit/noto-sans-sc-LICENSE.txt
Normal file
93
public/fonts/smart-edit/noto-sans-sc-LICENSE.txt
Normal 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.
|
||||||
BIN
public/fonts/smart-edit/noto-sans-sc.ttf
Normal file
BIN
public/fonts/smart-edit/noto-sans-sc.ttf
Normal file
Binary file not shown.
94
public/fonts/smart-edit/noto-serif-sc-LICENSE.txt
Normal file
94
public/fonts/smart-edit/noto-serif-sc-LICENSE.txt
Normal 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.
|
||||||
BIN
public/fonts/smart-edit/noto-serif-sc.ttf
Normal file
BIN
public/fonts/smart-edit/noto-serif-sc.ttf
Normal file
Binary file not shown.
93
public/fonts/smart-edit/roboto-LICENSE.txt
Normal file
93
public/fonts/smart-edit/roboto-LICENSE.txt
Normal 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.
|
||||||
BIN
public/fonts/smart-edit/roboto.ttf
Normal file
BIN
public/fonts/smart-edit/roboto.ttf
Normal file
Binary file not shown.
96
public/fonts/smart-edit/source-han-LICENSE.txt
Normal file
96
public/fonts/smart-edit/source-han-LICENSE.txt
Normal 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.
|
||||||
BIN
public/fonts/smart-edit/source-han.otf
Normal file
BIN
public/fonts/smart-edit/source-han.otf
Normal file
Binary file not shown.
13
public/video-canvas/transitions/README.md
Normal file
13
public/video-canvas/transitions/README.md
Normal 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 |
|
||||||
|
|
||||||
|
卡片图片只用于展示效果类别。右侧预览与成片合成使用用户选中的相邻素材,不把示例图作为用户成片素材。
|
||||||
BIN
public/video-canvas/transitions/fade.webp
Normal file
BIN
public/video-canvas/transitions/fade.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 MiB |
BIN
public/video-canvas/transitions/fadeblack.webp
Normal file
BIN
public/video-canvas/transitions/fadeblack.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
BIN
public/video-canvas/transitions/fadewhite.webp
Normal file
BIN
public/video-canvas/transitions/fadewhite.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
214
scripts/qa/smart-edit-main-chain.cjs
Normal file
214
scripts/qa/smart-edit-main-chain.cjs
Normal 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;
|
||||||
|
});
|
||||||
30
scripts/qa/smart-edit-models.py
Normal file
30
scripts/qa/smart-edit-models.py
Normal 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))
|
||||||
@ -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(
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
103
scripts/qa/video-upscale-service.cjs
Normal file
103
scripts/qa/video-upscale-service.cjs
Normal 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;
|
||||||
|
});
|
||||||
@ -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`。
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
35
services/smart-edit/font.py
Normal file
35
services/smart-edit/font.py
Normal 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])
|
||||||
23
services/smart-edit/prepare_fonts.py
Normal file
23
services/smart-edit/prepare_fonts.py
Normal 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))
|
||||||
18
services/smart-edit/prepare_separation.py
Normal file
18
services/smart-edit/prepare_separation.py
Normal 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))
|
||||||
@ -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
|
||||||
|
|||||||
2
services/smart-edit/requirements-fonts.txt
Normal file
2
services/smart-edit/requirements-fonts.txt
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
fonttools==4.59.1
|
||||||
|
brotli==1.2.0
|
||||||
4
services/smart-edit/requirements-separation.txt
Normal file
4
services/smart-edit/requirements-separation.txt
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
torch==2.6.0
|
||||||
|
torchaudio==2.6.0
|
||||||
|
demucs==4.0.1
|
||||||
59
services/smart-edit/separate.py
Normal file
59
services/smart-edit/separate.py
Normal 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])
|
||||||
@ -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
|
||||||
|
|||||||
@ -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 许可保存在字体目录。
|
||||||
|
|||||||
@ -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));
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
46
services/video-upscale/README.md
Normal file
46
services/video-upscale/README.md
Normal 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
|
||||||
|
```
|
||||||
32
services/video-upscale/build.cjs
Normal file
32
services/video-upscale/build.cjs
Normal 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));
|
||||||
85
services/video-upscale/job.ts
Normal file
85
services/video-upscale/job.ts
Normal 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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
328
services/video-upscale/server.ts
Normal file
328
services/video-upscale/server.ts
Normal 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;
|
||||||
|
});
|
||||||
79
services/video-upscale/upscale.py
Normal file
79
services/video-upscale/upscale.py
Normal 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
29
services/video-upscale/vendor/LICENSE
vendored
Normal 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
Loading…
x
Reference in New Issue
Block a user