Complete smart edit audio processing and font catalogue with main app validation

This commit is contained in:
134 2026-09-18 10:28:09 +08:00
parent 17e17b9ad5
commit c6533e92f3
40 changed files with 31285 additions and 145 deletions

8
.gitignore vendored
View File

@ -112,3 +112,11 @@ 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-*/

View File

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

View File

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

View File

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

View File

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

View File

@ -2226,7 +2226,8 @@ function getVideoNodePreviewUrl(node: CloneNode | null | undefined) {
} }
function getVideoNodeDurationSeconds(node: CloneNode | null | undefined) { function getVideoNodeDurationSeconds(node: CloneNode | null | undefined) {
const recordedDuration = Number(node?.draftValues?.durationSeconds); const meta = node?.draftValues?.videoMeta as Record<string, unknown> | undefined;
const recordedDuration = Number(meta?.durationSeconds || node?.draftValues?.durationSeconds || meta?.duration);
if (Number.isFinite(recordedDuration) && recordedDuration > 0) return recordedDuration; if (Number.isFinite(recordedDuration) && recordedDuration > 0) return recordedDuration;
const params = getNodeParams(node); const params = getNodeParams(node);
const paramsSettings = const paramsSettings =
@ -20688,7 +20689,9 @@ function OfficialVideoResultCard({
const [currentTime, setCurrentTime] = useState(0); const [currentTime, setCurrentTime] = useState(0);
const [frameMenuOpen, setFrameMenuOpen] = useState(false); const [frameMenuOpen, setFrameMenuOpen] = useState(false);
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
const safeDuration = Math.max(0.1, durationSeconds || 5); const [decodedDuration, setDecodedDuration] = useState(0);
useEffect(() => { setDecodedDuration(0); setCurrentTime(0); setPlaying(false); }, [mediaUrl]);
const safeDuration = Math.max(0.1, decodedDuration || durationSeconds || 5);
const progress = Math.max(0, Math.min(currentTime / safeDuration, 1)); const progress = Math.max(0, Math.min(currentTime / safeDuration, 1));
const controlsVisible = hovered || selected || frameMenuOpen; const controlsVisible = hovered || selected || frameMenuOpen;
const frameActions: Array<{ kind: VideoFrameExtractionKind; label: string }> = [ const frameActions: Array<{ kind: VideoFrameExtractionKind; label: string }> = [
@ -20701,8 +20704,7 @@ function OfficialVideoResultCard({
const video = videoRef.current; const video = videoRef.current;
if (!video) return; if (!video) return;
if (video.paused) { if (video.paused) {
void video.play(); void video.play().catch(() => { setPlaying(false); toast.error("视频无法播放,请检查素材地址"); });
setPlaying(true);
return; return;
} }
video.pause(); video.pause();
@ -20730,6 +20732,8 @@ function OfficialVideoResultCard({
<video <video
className="pointer-events-none h-full w-full rounded-[10px] object-cover" className="pointer-events-none h-full w-full rounded-[10px] object-cover"
muted muted
onLoadedMetadata={event => { const value = event.currentTarget.duration; if (Number.isFinite(value) && value > 0) setDecodedDuration(value); }}
onError={() => setPlaying(false)}
onEnded={() => setPlaying(false)} onEnded={() => setPlaying(false)}
onPause={() => setPlaying(false)} onPause={() => setPlaying(false)}
onPlay={() => setPlaying(true)} onPlay={() => setPlaying(true)}

View File

@ -1,5 +1,5 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { Check, ChevronDown, Search } from "lucide-react"; import { Check, ChevronDown, Search } from "lucide-react";
import { captionFonts, captionFont } from "@/lib/video-canvas/smart-edit/fonts"; import { captionFonts, captionFont } from "@/lib/video-canvas/smart-edit/fonts";
import type { CaptionStyle } from "@/lib/video-canvas/smart-edit/contract"; import type { CaptionStyle } from "@/lib/video-canvas/smart-edit/contract";
@ -8,9 +8,13 @@ export function loadCaptionFont(id: string) {
const font = captionFont(id); const font = captionFont(id);
if (!font) return Promise.resolve(); if (!font) return Promise.resolve();
if (!loaded.has(id)) { if (!loaded.has(id)) {
const face = new FontFace(font.family, `url(/fonts/smart-edit/${font.file})`, { const face = new FontFace(
weight: "400", font.family,
}); `url(${font.url ? `/api/canvas/smart-edit/fonts/${font.id}` : `/fonts/smart-edit/${font.file}`})`,
{
weight: "400",
},
);
loaded.set( loaded.set(
id, id,
face face
@ -39,8 +43,39 @@ export function CaptionFontPicker({
const [open, setOpen] = useState(false), const [open, setOpen] = useState(false),
[query, setQuery] = useState(""), [query, setQuery] = useState(""),
[category, setCategory] = useState("全部"), [category, setCategory] = useState("全部"),
[busy, setBusy] = useState(false); [categoriesOpen, setCategoriesOpen] = useState(false),
[busy, setBusy] = useState(false),
[scrollTop, setScrollTop] = useState(0);
const root = useRef<HTMLDivElement>(null); 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(() => { useEffect(() => {
const close = (e: globalThis.PointerEvent) => { const close = (e: globalThis.PointerEvent) => {
if (!root.current?.contains(e.target as Node)) setOpen(false); if (!root.current?.contains(e.target as Node)) setOpen(false);
@ -56,6 +91,31 @@ export function CaptionFontPicker({
(category === "推荐" && font.recommended) || (category === "推荐" && font.recommended) ||
(category === "Google" && font.google)), (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 ( return (
<div className="se-font-picker" ref={root}> <div className="se-font-picker" ref={root}>
<button <button
@ -68,51 +128,94 @@ export function CaptionFontPicker({
<ChevronDown size={14} /> <ChevronDown size={14} />
</button> </button>
{open && ( {open && (
<div className="se-font-menu"> <div
className="se-font-menu"
style={{
position: "fixed",
left: placement.left,
top: placement.top,
width: placement.width,
right: "auto",
}}
>
<div className="se-search"> <div className="se-search">
<Search size={14} />
<input <input
aria-label="搜索字体" aria-label="搜索字体"
placeholder="搜索字体" placeholder="搜索字体"
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
/> />
<Search size={14} />
</div> </div>
<div className="se-font-categories"> <div className="se-font-categories">
{["全部", "常用", "推荐", "Google"].map((name) => ( <button
<button type="button"
key={name} aria-haspopup="menu"
aria-pressed={category === name} aria-expanded={categoriesOpen}
onClick={() => setCategory(name)} onClick={() => setCategoriesOpen(!categoriesOpen)}
> >
{name} {category === "Google" ? "Google 字体" : `${category}字体`}
</button> <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>
<div role="listbox" aria-label="字体库"> <div
{options.map((font) => ( key={`${query}:${category}`}
<button role="listbox"
role="option" style={{ height: placement.height }}
aria-selected={value === font.id} aria-label={`字体库,共 ${options.length} 项`}
key={font.id} onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
disabled={busy} >
onClick={async () => { <div style={{ height: options.length * 40, position: "relative" }}>
setBusy(true); {options.slice(first, first + 18).map((font, index) => (
try { <button
await loadCaptionFont(font.id); style={{
onChange(font.id); position: "absolute",
setOpen(false); top: (first + index) * 40,
} catch { fontFamily: `"${font.family}", "Source Han Sans SC", sans-serif`,
onError("字体加载失败,请检查网络后重试"); }}
} finally { aria-posinset={first + index + 1}
setBusy(false); aria-setsize={options.length}
} role="option"
}} aria-selected={value === font.id}
> key={font.id}
{font.name} disabled={busy}
{value === font.id && <Check size={14} />} onClick={async () => {
</button> 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> </div>
{!options.length && <div className="se-muted">没有匹配的字体</div>} {!options.length && <div className="se-muted">没有匹配的字体</div>}
{busy && <div role="status">正在加载字体…</div>} {busy && <div role="status">正在加载字体…</div>}

View File

@ -1,6 +1,7 @@
"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 { SmartEditEditor, SmartEditEmptyEditor } from "./SmartEditEditor";
import { assertBeatMusic } from "@/lib/video-canvas/smart-edit/intent"; import { assertBeatMusic } from "@/lib/video-canvas/smart-edit/intent";
import { EditPreview } from "./EditPreview"; import { EditPreview } from "./EditPreview";
@ -93,8 +94,14 @@ export function SmartEditDialog({
useEffect(() => { useEffect(() => {
if (planTaskId && planTaskId !== applied.current) { if (planTaskId && planTaskId !== applied.current) {
applied.current = planTaskId; applied.current = planTaskId;
patch({ plans: serverPlans || [], planTaskId, selectedPlanIndex: 0 }); 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); setConfigure(false);
@ -250,6 +257,23 @@ export function SmartEditDialog({
} }
/> />
); );
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) if (plan && timeline && !configure)
return ( return (
<SmartEditEditor <SmartEditEditor
@ -260,6 +284,18 @@ export function SmartEditDialog({
onConfigure={() => setConfigure(true)} onConfigure={() => setConfigure(true)}
onRender={onRender} onRender={onRender}
onCancel={onCancel} 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} tasks={tasks}
canRender={Boolean(cap?.capabilities?.render)} canRender={Boolean(cap?.capabilities?.render)}
exportState={draft.exportState} exportState={draft.exportState}

View File

@ -864,6 +864,9 @@
.root :global(.se-context button:hover) { .root :global(.se-context button:hover) {
background: #ffffff12; background: #ffffff12;
} }
.root :global(.se-separation-group) { position: relative; }
.root :global(.se-separation-group > button) { display: flex; justify-content: space-between; align-items: center; }
.root :global(.se-separation-menu) { position: absolute; right: calc(100% + 6px); bottom: 0; min-width: 150px; padding: 5px; border: 1px solid #444; border-radius: 10px; background: #292929; box-shadow: 0 8px 24px #0008; }
.root :global(.se-progress) { .root :global(.se-progress) {
position: absolute; position: absolute;
inset: 0; inset: 0;
@ -1070,12 +1073,14 @@
.root :global(.se-font-picker) { position: relative; } .root :global(.se-font-picker) { position: relative; }
.root :global(button.se-font) { background: #333; display: flex; align-items: center; justify-content: space-between; } .root :global(button.se-font) { background: #333; display: flex; align-items: center; justify-content: space-between; }
.root :global(.se-font-menu) { position: absolute; z-index: 30; top: 38px; left: 0; right: 0; padding: 12px; background: #242424; border: 1px solid #ffffff1a; border-radius: 8px; box-shadow: 0 8px 24px #0008; } .root :global(.se-font-menu) { position: absolute; z-index: 30; top: 38px; left: -16px; right: -16px; padding: 8px; background: #111; border: 1px solid #ffffff1a; border-radius: 8px; box-shadow: 0 8px 24px #0008; }
.root :global(.se-font-categories) { display: flex; justify-content: space-between; margin-bottom: 8px; } .root :global(.se-font-categories) { position: relative; margin: 8px 0; }
.root :global(.se-font-categories button) { padding: 4px; color: #999; font-size: 12px; } .root :global(.se-font-categories button) { display: flex; align-items: center; gap: 5px; padding: 6px 8px; color: #eee; background: #292929; border-radius: 5px; font-size: 12px; }
.root :global(.se-font-categories button[aria-pressed="true"]) { color: #fff; border-bottom: 1px solid white; } .root :global(.se-font-categories [role="menu"]) { position: absolute; top: 32px; left: 0; width: 140px; padding: 4px; border: 1px solid #ffffff1a; border-radius: 8px; background: #111; z-index: 2; }
.root :global(.se-font-menu [role="listbox"]) { max-height: 230px; overflow-y: auto; } .root :global(.se-font-categories [role="menuitemradio"]) { width: 100%; height: 32px; justify-content: space-between; background: transparent; }
.root :global(.se-font-menu [role="option"]) { display: flex; justify-content: space-between; align-items: center; width: 100%; height: 36px; padding: 6px; text-align: left; } .root :global(.se-font-categories [role="menuitemradio"]:hover) { background: #ffffff14; }
.root :global(.se-font-menu [role="listbox"]) { height: 300px; overflow-y: auto; }
.root :global(.se-font-menu [role="option"]) { display: flex; justify-content: space-between; align-items: center; width: 100%; height: 40px; padding: 6px; text-align: left; }
.root :global(.se-font-menu [role="option"]:hover) { background: #ffffff14; border-radius: 4px; } .root :global(.se-font-menu [role="option"]:hover) { background: #ffffff14; border-radius: 4px; }
.collapsed :global(.se-error), .collapsed :global(.se-export-result) { pointer-events: auto; } .collapsed :global(.se-error), .collapsed :global(.se-export-result) { pointer-events: auto; }

View File

@ -100,6 +100,10 @@ export function SmartEditEditor({
onConfigure, onConfigure,
onRender, onRender,
onCancel, onCancel,
onProcess,
canSeparate = false,
canTranscribe = false,
processError,
tasks, tasks,
canRender, canRender,
exportState, exportState,
@ -111,6 +115,16 @@ export function SmartEditEditor({
onConfigure: () => void; onConfigure: () => void;
onRender: (p: EditPlan) => Promise<unknown>; onRender: (p: EditPlan) => Promise<unknown>;
onCancel?: (taskId: string) => Promise<unknown>; onCancel?: (taskId: string) => Promise<unknown>;
onProcess?: (
operation: "separate" | "transcribe",
plan: EditPlan,
clipId?: string,
track?: "video" | "audio",
keep?: "both" | "vocals" | "background",
) => Promise<unknown>;
canSeparate?: boolean;
canTranscribe?: boolean;
processError?: string;
tasks: CanvasGenerationTask[]; tasks: CanvasGenerationTask[];
canRender: boolean; canRender: boolean;
exportState?: { requestId: string; destination: "local" | "canvas" }; exportState?: { requestId: string; destination: "local" | "canvas" };
@ -208,6 +222,34 @@ export function SmartEditEditor({
...plan, ...plan,
audioClips: (plan.audioClips || []).map((c) => (c.id === id ? { ...c, ...patch } : c)), audioClips: (plan.audioClips || []).map((c) => (c.id === id ? { ...c, ...patch } : c)),
}); });
useEffect(() => {
if (processError) setError(processError);
}, [processError]);
const [separationMenu, setSeparationMenu] = useState(false);
useEffect(() => setSeparationMenu(false), [context]);
const processAudio = async (
operation: "separate" | "transcribe",
keep: "both" | "vocals" | "background" = "both",
) => {
if (!onProcess || busy) return;
setBusy(true);
setPlaying(false);
setContext(undefined);
setError("");
try {
await onProcess(
operation,
plan,
context?.kind === "audio" ? audioClip?.id : clip?.id,
context?.kind === "audio" ? "audio" : "video",
keep,
);
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
};
const detachAudio = () => { const detachAudio = () => {
if ( if (
!clip || !clip ||
@ -1138,6 +1180,18 @@ export function SmartEditEditor({
<Plus size={14} /> <Plus size={14} />
新建字幕 新建字幕
</button> </button>
<button
type="button"
disabled={busy || !canTranscribe || !onProcess}
title={
canTranscribe
? "识别视频人声并添加字幕,保留手动字幕"
: "语音识别模型尚未配置"
}
onClick={() => void processAudio("transcribe")}
>
自动转写
</button>
{!captions.length && <div className="se-empty">暂无字幕</div>} {!captions.length && <div className="se-empty">暂无字幕</div>}
{captions {captions
.filter((s) => s.text.includes(search)) .filter((s) => s.text.includes(search))
@ -1812,6 +1866,57 @@ export function SmartEditEditor({
音视频分离 音视频分离
</button> </button>
)} )}
{(context.kind === "clip" || context.kind === "audio") && (
<div className="se-separation-group">
<button
role="menuitem"
aria-haspopup="menu"
aria-expanded={separationMenu}
disabled={
busy ||
!canSeparate ||
!onProcess ||
(context.kind === "audio"
? !audioClip
: !clip ||
clip.audioDetached ||
!plan.assets.find((a) => a.id === clip.assetId)?.hasAudio)
}
title={
canSeparate ? "生成可单独调节的人声和背景声音轨" : "人声分离模型尚未配置"
}
onClick={() => setSeparationMenu(!separationMenu)}
>
人声分离 <span aria-hidden="true">›</span>
</button>
{separationMenu && (
<div
role="menu"
aria-label="人声分离选项"
className="se-separation-menu"
style={
context.x < 170 ? { left: "calc(100% + 6px)", right: "auto" } : undefined
}
>
<button
role="menuitem"
onClick={() => void processAudio("separate", "vocals")}
>
仅保留人声
</button>
<button
role="menuitem"
onClick={() => void processAudio("separate", "background")}
>
仅保留背景音
</button>
<button role="menuitem" onClick={() => void processAudio("separate", "both")}>
分离为两个音轨
</button>
</div>
)}
</div>
)}
{context.kind !== "timeline" && ( {context.kind !== "timeline" && (
<button role="menuitem" onClick={remove}> <button role="menuitem" onClick={remove}>
删除 删除
@ -2111,7 +2216,10 @@ function CutIcon({ side }: { side?: "left" | "right" }) {
function TransitionIcon() { function TransitionIcon() {
return ( return (
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true"> <svg viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path d="M16.57 0C18.47 0 20 1.54 20 3.43v13.14c0 1.9-1.54 3.43-3.43 3.43H3.25A3.43 3.43 0 0 1 0 16.57V3.43A3.43 3.43 0 0 1 3.25 0zM3.43 1.8c-.9 0-1.63.73-1.63 1.63v13.14c0 .9.73 1.63 1.63 1.63h13.14c.9 0 1.63-.73 1.63-1.63V3.43c0-.9-.73-1.63-1.63-1.63zm3.8 4.7a.86.86 0 0 1 1.29-.75l6.01 3.51a.86.86 0 0 1 0 1.48l-6.01 3.5a.86.86 0 0 1-1.29-.73z" fill="currentColor" /> <path
d="M16.57 0C18.47 0 20 1.54 20 3.43v13.14c0 1.9-1.54 3.43-3.43 3.43H3.25A3.43 3.43 0 0 1 0 16.57V3.43A3.43 3.43 0 0 1 3.25 0zM3.43 1.8c-.9 0-1.63.73-1.63 1.63v13.14c0 .9.73 1.63 1.63 1.63h13.14c.9 0 1.63-.73 1.63-1.63V3.43c0-.9-.73-1.63-1.63-1.63zm3.8 4.7a.86.86 0 0 1 1.29-.75l6.01 3.51a.86.86 0 0 1 0 1.48l-6.01 3.5a.86.86 0 0 1-1.29-.73z"
fill="currentColor"
/>
</svg> </svg>
); );
} }

View File

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

View File

@ -3,12 +3,11 @@
"baseline": "68f62dae", "baseline": "68f62dae",
"local_only": true, "local_only": true,
"counts": { "counts": {
"通过": 35, "通过": 39,
"待验": 7, "待验": 6,
"代码核对": 17, "代码核对": 17,
"已修复": 28, "已修复": 28,
"部分修复": 4, "部分修复": 4,
"差异": 3,
"既有证据": 1 "既有证据": 1
}, },
"items": [ "items": [
@ -288,9 +287,9 @@
"id": "SE-035", "id": "SE-035",
"group": "视频片段", "group": "视频片段",
"item": "右键菜单", "item": "右键菜单",
"status": "差异", "status": "部分修复",
"detail": "本地缺人声分离子菜单,增加撤销/重做/设置;“删除片段”与参考“删除”不同", "detail": "已增加人声分离子菜单及仅保留人声、仅保留背景音、两轨输出;菜单其他文案与参考仍有差异。",
"evidence": "本轮参考及本地UI" "evidence": "本轮主站UI/真实HTDemucs"
}, },
{ {
"id": "SE-036", "id": "SE-036",
@ -352,9 +351,9 @@
"id": "SE-043", "id": "SE-043",
"group": "字幕", "group": "字幕",
"item": "字体搜索和长列表", "item": "字体搜索和长列表",
"status": "部分修复", "status": "通过",
"detail": "字体搜索和选择已接通五个真实字体:Source Han、Noto Sans SC、Noto Serif SC、Roboto、Inter;参考完整长列表尚未齐全。", "detail": "参考目录2002项完整收录,1997新增文件全部下载解析与双SHA校验;搜索、四分类、虚拟滚动、按需加载可用。",
"evidence": "本轮字体UI/五种真实合成" "evidence": "remaining-0918/reference-fonts.json、prepare-catalog.log;主站字体切换"
}, },
{ {
"id": "SE-044", "id": "SE-044",
@ -369,8 +368,8 @@
"group": "字幕", "group": "字幕",
"item": "预览与导出的字形", "item": "预览与导出的字形",
"status": "部分修复", "status": "部分修复",
"detail": "已让预览和导出使用相同静态字体文件,包含中文回退,实际导出验证不同字形;参考完整字体文件/DPR/混排行高仍未逐字形对拍。", "detail": "2002字体目录已齐;预览与FFmpeg使用同一固定文件,5原有+6新增字体实际合成通过;未对2002种字体逐字形、行高、DPR作黄金图对拍。",
"evidence": "本轮FFmpeg 5字体/本地字体切换" "evidence": "catalog-render.log/主站大宝桃桃体切换"
}, },
{ {
"id": "SE-046", "id": "SE-046",
@ -464,9 +463,9 @@
"id": "SE-057", "id": "SE-057",
"group": "字幕", "group": "字幕",
"item": "语音自动转写", "item": "语音自动转写",
"status": "差异", "status": "通过",
"detail": "已有能力探测,但当前本地测试服务asr=false;完整转写尚无可用验收结果", "detail": "真实FunASR转写成功;修复缺少pred_timestamp造成整段无标点时间戳的问题,限制负起点并映射剪辑速度/裁剪。字幕按钮提交主站任务,完成后刷新恢复。",
"evidence": "本地capabilities/已有部署记录" "evidence": "model-regression/report.json;production-chain.log;主站浏览器"
}, },
{ {
"id": "SE-058", "id": "SE-058",
@ -624,9 +623,9 @@
"id": "SE-077", "id": "SE-077",
"group": "音频", "group": "音频",
"item": "人声分离两个分支", "item": "人声分离两个分支",
"status": "差异", "status": "通过",
"detail": "参考“仅保留人声/仅保留背景音”子菜单;本地未接专用分离服务", "detail": "HTDemucs真实离线分离,视频和独立音轨可用;支持仅人声、仅背景及两轨输出,保留速度/增益/静音/包络。混合信号重构最大误差1.49e-8。",
"evidence": "reference-video-menu" "evidence": "smart-edit-models.py;主站API与UI"
}, },
{ {
"id": "SE-078", "id": "SE-078",
@ -696,9 +695,9 @@
"id": "SE-086", "id": "SE-086",
"group": "导出与恢复", "group": "导出与恢复",
"item": "浏览器中返回画布结果", "item": "浏览器中返回画布结果",
"status": "待验", "status": "通过",
"detail": "独立测试页任务回调仍是样例;本轮未跑主站Next API全链路", "detail": "主站Next生产构建:真实登录/隔离数据库/上传/排队/分离/转写/合成/MP4和SRT下载/浏览器回填播放及刷新恢复通过。存储用本地S3兼容服务,未测线上存储或部署。修复新画布时区错位漏回填与成片时长显示。",
"evidence": "ui-entry/onRender" "evidence": "production-chain.log;main-result.mp4;主站浏览器"
}, },
{ {
"id": "SE-087", "id": "SE-087",
@ -774,5 +773,5 @@
} }
], ],
"date": "2026-09-18", "date": "2026-09-18",
"note": "累计检查表;仅修复行更新本轮证据,其他行保留09-17验收边界,不代表全矩阵重测。" "note": "09-18续修:人声分离、自动转写、2002字体目录和本地主站完整智能剪辑链路已接通;仍非全站所有功能验收,未部署或推送;逐字体像素/DPR与休眠断网矩阵未完成。"
} }

View File

@ -19,26 +19,26 @@
本地独立页面仍用样例任务回调,不能冒充主站Next API端到端成功;上面的真实HTTP测试针对独立合成服务。测试目录 `.qa/smart-edit/fixes-0918/` 的日志与截图不提交到Git,复现测试脚本提交。 本地独立页面仍用样例任务回调,不能冒充主站Next API端到端成功;上面的真实HTTP测试针对独立合成服务。测试目录 `.qa/smart-edit/fixes-0918/` 的日志与截图不提交到Git,复现测试脚本提交。
## 仍未完成 ## 09-18续修:本轮指定的四项
1. 人声/背景音分离:尚未接专用分离模型服务。已有“音视频分离”只提取原声,不代替人声分离。 人声分离、自动转写、2002项字体目录、主站智能剪辑本地全链路已接通并使用真实模型/FFmpeg验证。详细环境、数据及边界见 [续修验收记录](smart-edit-completion-2026-09-18.md)。以上早先的“独立测试页”结论保留为历史;本轮新增真实Next生产构建验收。
2. ASR:本地FunASR依赖/模型权重尚未配置,不能宣称已自动转写。
3. 完整字体目录:目前五种真实字体;其余参考字体、逐字形/行高/DPR黄金图对拍未齐全。
4. 运行中远程合成的主动取消,以及主站上传—任务—存储—返回画布全链路、后台/休眠/断网和窄窗口完整矩阵。
5. 五种模式的参考算法效果、广告多创意策略、真实波形及参考未穷尽的边界。
跨域正增益预览会通过现有鉴权下载接口准备同源副本,额外耗时取决于原视频体积;本轮浏览器实测为本地同源素材。字幕包约41MiB,独立服务发布时必须同时带 `dist/fonts`,不能只替换JS。 ## 仍未完成的其他范围
- 全部2002字体逐字形/行高/DPR黄金图对拍;字体目录和实际字体文件已齐全。
- 运行中远程合成主动取消;后台休眠/断网/各窄窗口完整矩阵。
- 五种模式与参考的算法效果逐案对比、广告多创意策略、真实波形及未穷尽边界。
- 线上部署与真实线上对象存储验收。本轮只用隔离本地数据库及S3兼容测试服务。
参考字号证据:预览iframe可见SPAN为56px,父画布1920×1080。缩放证据:slider实际状态19.67871485943775时2.2秒转场宽220px,50时552.2px,100时1100px;采用2+4.98×slider的映射。最小转场覆盖框44px单独约束。 参考字号证据:预览iframe可见SPAN为56px,父画布1920×1080。缩放证据:slider实际状态19.67871485943775时2.2秒转场宽220px,50时552.2px,100时1100px;采用2+4.98×slider的映射。最小转场覆盖框44px单独约束。
## 累计95项 ## 累计95项
- 通过:35 - 通过:39
- 待验:7 - 待验:6
- 代码核对:17 - 代码核对:17
- 已修复:28 - 已修复:28
- 部分修复:4 - 部分修复:4
- 差异:3
- 既有证据:1 - 既有证据:1
### 节点与输入 ### 节点与输入
@ -89,7 +89,7 @@
| SE-032 | 边缘裁剪和排序 | 代码核对 | 已有按source时间/速度换算和撤销;本轮未重新拖遍所有方向 | SmartEditEditor | | SE-032 | 边缘裁剪和排序 | 代码核对 | 已有按source时间/速度换算和撤销;本轮未重新拖遍所有方向 | SmartEditEditor |
| SE-033 | 分割和左右裁剪 | 通过 | 边界禁用、源范围及片长合同回归通过;本轮未对拍参考破坏性编辑 | 本轮helpers/SmartEditEditor | | SE-033 | 分割和左右裁剪 | 通过 | 边界禁用、源范围及片长合同回归通过;本轮未对拍参考破坏性编辑 | 本轮helpers/SmartEditEditor |
| SE-034 | 至少保留一个片段 | 待验 | 本地阻止删最后一段;参考空时间轴行为未删除原素材验证 | SmartEditEditor | | SE-034 | 至少保留一个片段 | 待验 | 本地阻止删最后一段;参考空时间轴行为未删除原素材验证 | SmartEditEditor |
| SE-035 | 右键菜单 | 差异 | 本地缺人声分离子菜单,增加撤销/重做/设置;“删除片段”与参考“删除”不同 | 本轮参考及本地UI | | SE-035 | 右键菜单 | 部分修复 | 已增加人声分离子菜单及仅保留人声、仅保留背景音、两轨输出;菜单其他文案与参考仍有差异。 | 本轮主站UI/真实HTDemucs |
| SE-036 | 转场覆盖区右键 | 已修复 | 转场覆盖区域按指针时间选择底层片段并打开视频右键菜单。 | 本轮浏览器:覆盖框右键选中有声片段并成功分离 | | SE-036 | 转场覆盖区右键 | 已修复 | 转场覆盖区域按指针时间选择底层片段并打开视频右键菜单。 | 本轮浏览器:覆盖框右键选中有声片段并成功分离 |
| SE-037 | 静音后恢复原增益 | 已修复 | 新增可选muted独立于gain保存,原声和独立音轨恢复不会覆盖原增益;分离时继承静音状态。 | 浏览器原声及独立轨+0.5dB静音恢复/真实FFmpeg两类静音 | | SE-037 | 静音后恢复原增益 | 已修复 | 新增可选muted独立于gain保存,原声和独立音轨恢复不会覆盖原增益;分离时继承静音状态。 | 浏览器原声及独立轨+0.5dB静音恢复/真实FFmpeg两类静音 |
@ -102,9 +102,9 @@
| SE-040 | 列表时间格式 | 已修复 | 时间框采用MM:SS.xx,可输入小数秒;失焦校验范围,非法值还原,Esc取消编辑。参考只展示整秒的像素细节尚未作为黄金图验收。 | 本轮UI | | SE-040 | 列表时间格式 | 已修复 | 时间框采用MM:SS.xx,可输入小数秒;失焦校验范围,非法值还原,Esc取消编辑。参考只展示整秒的像素细节尚未作为黄金图验收。 | 本轮UI |
| SE-041 | 选中文本、编辑与位置 | 通过 | 本轮本地样式编辑和实际字幕位置像素测试通过 | 本轮UI/FFmpeg | | SE-041 | 选中文本、编辑与位置 | 通过 | 本轮本地样式编辑和实际字幕位置像素测试通过 | 本轮UI/FFmpeg |
| SE-042 | 显示和隐藏 | 代码核对 | subtitles控制预览和导出;关闭面板仅隐藏编辑轨道 | SmartEditEditor/compiledSubtitles | | SE-042 | 显示和隐藏 | 代码核对 | subtitles控制预览和导出;关闭面板仅隐藏编辑轨道 | SmartEditEditor/compiledSubtitles |
| SE-043 | 字体搜索和长列表 | 部分修复 | 字体搜索和选择已接通五个真实字体:Source Han、Noto Sans SC、Noto Serif SC、Roboto、Inter;参考完整长列表尚未齐全。 | 本轮字体UI/五种真实合成 | | SE-043 | 字体搜索和长列表 | 通过 | 参考目录2002项完整收录,1997新增文件全部下载解析与双SHA校验;搜索、四分类、虚拟滚动、按需加载可用。 | remaining-0918/reference-fonts.json、prepare-catalog.log;主站字体切换 |
| SE-044 | 字体分类 | 已修复 | 新增全部/常用/推荐/Google四类筛选;范围限已打包的实际字体。 | 本轮UI/CaptionFonts | | SE-044 | 字体分类 | 已修复 | 新增全部/常用/推荐/Google四类筛选;范围限已打包的实际字体。 | 本轮UI/CaptionFonts |
| SE-045 | 预览与导出的字形 | 部分修复 | 已让预览和导出使用相同静态字体文件,包含中文回退,实际导出验证不同字形;参考完整字体文件/DPR/混排行高仍未逐字形对拍。 | 本轮FFmpeg 5字体/本地字体切换 | | 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-046 | 字号范围和显示单位 | 已修复 | 从参考可见iframe确认1920×1080、56px;新字幕明确1080基准,默认56/12–72px;旧720基准按比例显示并保留历史超范围值。 | 本轮参考DOM/legacy兼容helpers/本地UI |
| SE-047 | 字间距范围和控件 | 已修复 | 字间距改X数值框0–100;旧工程负值保留,预览与ASS依各自基准缩放。 | StyleNumber/contract | | SE-047 | 字间距范围和控件 | 已修复 | 字间距改X数值框0–100;旧工程负值保留,预览与ASS依各自基准缩放。 | StyleNumber/contract |
| SE-048 | 字体颜色和十六进制 | 代码核对 | 两处均有取色和hex;本地失焦校验非法值;样式烧录已回归 | StyleColor/FFmpeg | | SE-048 | 字体颜色和十六进制 | 代码核对 | 两处均有取色和hex;本地失焦校验非法值;样式烧录已回归 | StyleColor/FFmpeg |
@ -116,7 +116,7 @@
| SE-054 | 应用全部 | 代码核对 | 复制完整style含新outlineOpacity;本轮未覆盖所有字幕数量组合 | SmartEditEditor | | SE-054 | 应用全部 | 代码核对 | 复制完整style含新outlineOpacity;本轮未覆盖所有字幕数量组合 | SmartEditEditor |
| SE-055 | 自动字幕与长转场 | 已修复 | 居中转场不再把后片段字幕向后延迟整个转场时长,避免片头字幕丢失 | 新增合同回归 | | SE-055 | 自动字幕与长转场 | 已修复 | 居中转场不再把后片段字幕向后延迟整个转场时长,避免片头字幕丢失 | 新增合同回归 |
| SE-056 | 手工字幕与SRT | 通过 | 手工字幕保留位置和样式,SRT仅保留文本时间;ASS字符转义回归通过 | 本轮helpers/FFmpeg | | SE-056 | 手工字幕与SRT | 通过 | 手工字幕保留位置和样式,SRT仅保留文本时间;ASS字符转义回归通过 | 本轮helpers/FFmpeg |
| SE-057 | 语音自动转写 | 差异 | 已有能力探测,但当前本地测试服务asr=false;完整转写尚无可用验收结果 | 本地capabilities/已有部署记录 | | SE-057 | 语音自动转写 | 通过 | 真实FunASR转写成功;修复缺少pred_timestamp造成整段无标点时间戳的问题,限制负起点并映射剪辑速度/裁剪。字幕按钮提交主站任务,完成后刷新恢复。 | model-regression/report.json;production-chain.log;主站浏览器 |
### 转场 ### 转场
@ -146,7 +146,7 @@
| SE-074 | 分离后声音不叠加 | 通过 | 实际音频RMS与转场包络回归通过 | 本轮FFmpeg | | SE-074 | 分离后声音不叠加 | 通过 | 实际音频RMS与转场包络回归通过 | 本轮FFmpeg |
| SE-075 | 独立音轨变速、淡入淡出 | 通过 | 实际2倍速、增益、延迟、淡入淡出及长音轨黑尾回归通过 | 本轮FFmpeg | | SE-075 | 独立音轨变速、淡入淡出 | 通过 | 实际2倍速、增益、延迟、淡入淡出及长音轨黑尾回归通过 | 本轮FFmpeg |
| SE-076 | 独立音轨裁剪、分割、移动 | 代码核对 | 有完整source范围与时间轴处理;前批UI通过,本轮未全部重拖 | SmartEditEditor/contract | | SE-076 | 独立音轨裁剪、分割、移动 | 代码核对 | 有完整source范围与时间轴处理;前批UI通过,本轮未全部重拖 | SmartEditEditor/contract |
| SE-077 | 人声分离两个分支 | 差异 | 参考“仅保留人声/仅保留背景音”子菜单;本地未接专用分离服务 | reference-video-menu | | SE-077 | 人声分离两个分支 | 通过 | HTDemucs真实离线分离,视频和独立音轨可用;支持仅人声、仅背景及两轨输出,保留速度/增益/静音/包络。混合信号重构最大误差1.49e-8。 | smart-edit-models.py;主站API与UI |
| SE-078 | 音频参数栏调整宽度 | 已修复 | 音频参数栏新增同一宽度拖拽柄;参考有声参数更多行为仍待验。 | SmartEditEditor | | SE-078 | 音频参数栏调整宽度 | 已修复 | 音频参数栏新增同一宽度拖拽柄;参考有声参数更多行为仍待验。 | SmartEditEditor |
| SE-079 | 真实波形 | 待验 | 本地音频条是纯色色块,没有采样波形;本轮未取得参考有声轨波形证据 | se-wave/se-audio-clip | | SE-079 | 真实波形 | 待验 | 本地音频条是纯色色块,没有采样波形;本轮未取得参考有声轨波形证据 | se-wave/se-audio-clip |
| SE-080 | 音乐与卡点 | 通过 | 节拍与候选剪辑合同回归通过,音乐混音真实输出通过;不是参考算法效果对拍 | 本轮helpers/FFmpeg | | SE-080 | 音乐与卡点 | 通过 | 节拍与候选剪辑合同回归通过,音乐混音真实输出通过;不是参考算法效果对拍 | 本轮helpers/FFmpeg |
@ -160,7 +160,7 @@
| SE-083 | 真实本地合成 | 通过 | FFmpeg实际生成结果,不用模拟成功替代 | 本轮ffmpeg.log | | SE-083 | 真实本地合成 | 通过 | FFmpeg实际生成结果,不用模拟成功替代 | 本轮ffmpeg.log |
| SE-084 | 提交任务ID和幂等 | 通过 | 请求ID回归与远程确定性任务身份测试通过 | 本轮helpers | | SE-084 | 提交任务ID和幂等 | 通过 | 请求ID回归与远程确定性任务身份测试通过 | 本轮helpers |
| SE-085 | 刷新恢复导出任务 | 通过 | 任务检查点/reconcile合同通过;本轮未执行真实Next页面断网恢复 | 本轮helpers | | SE-085 | 刷新恢复导出任务 | 通过 | 任务检查点/reconcile合同通过;本轮未执行真实Next页面断网恢复 | 本轮helpers |
| SE-086 | 浏览器中返回画布结果 | 待验 | 独立测试页任务回调仍是样例;本轮未跑主站Next API全链路 | ui-entry/onRender | | SE-086 | 浏览器中返回画布结果 | 通过 | 主站Next生产构建:真实登录/隔离数据库/上传/排队/分离/转写/合成/MP4和SRT下载/浏览器回填播放及刷新恢复通过。存储用本地S3兼容服务,未测线上存储或部署。修复新画布时区错位漏回填与成片时长显示。 | production-chain.log;main-result.mp4;主站浏览器 |
| SE-087 | 任务错误和服务不可用 | 代码核对 | 导出按钮按能力禁用,失败状态显示错误;本轮未切断实际服务故障注入 | SmartEditEditor/SmartEditDialog | | SE-087 | 任务错误和服务不可用 | 代码核对 | 导出按钮按能力禁用,失败状态显示错误;本轮未切断实际服务故障注入 | SmartEditEditor/SmartEditDialog |
| SE-088 | 取消远程导出 | 部分修复 | 已接排队导出的取消按钮到现有鉴权取消API,取消状态不再等待。运行中远程FFmpeg的主动终止仍未实现,主站取消按钮全链路未验。 | onCancel/现有task-store | | SE-088 | 取消远程导出 | 部分修复 | 已接排队导出的取消按钮到现有鉴权取消API,取消状态不再等待。运行中远程FFmpeg的主动终止仍未实现,主站取消按钮全链路未验。 | onCancel/现有task-store |
| SE-089 | 多版本全导出 | 代码核对 | 高级设置有导出全部版本;主节点默认只一版,参考完整多版结果未生成 | SmartEditDialog | | SE-089 | 多版本全导出 | 代码核对 | 高级设置有导出全部版本;主节点默认只一版,参考完整多版结果未生成 | SmartEditDialog |

View File

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

View File

@ -1,5 +1,5 @@
import { z } from "zod"; import { z } from "zod";
import { captionFontIds, captionFont } from "./fonts"; import { captionFont } from "./fonts";
export const SECOND = 1_000_000; export const SECOND = 1_000_000;
export const MAX_EDIT_SECONDS = 300; 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 editRatios = ["21:9", "16:9", "9:16", "1:1", "4:3", "3:4"] as const;
@ -19,7 +19,11 @@ 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({ export const captionStyleSchema = z.object({
fontFamily: z.enum(captionFontIds).optional(), fontFamily: z
.string()
.max(180)
.refine((id) => Boolean(captionFont(id)), "未知字幕字体")
.optional(),
referenceHeight: z.union([z.literal(720), z.literal(1080)]).optional(), referenceHeight: z.union([z.literal(720), z.literal(1080)]).optional(),
fontSize: z.number().min(8).max(160).default(28), fontSize: z.number().min(8).max(160).default(28),
color: z color: z
@ -71,8 +75,8 @@ 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}$/),
}); });
@ -122,7 +126,7 @@ 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(12), 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(editRatios), ratio: z.enum(editRatios),
resolution: z.enum(editResolutions).default("720p"), resolution: z.enum(editResolutions).default("720p"),
@ -158,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,
@ -210,7 +225,8 @@ export function planTimeline(input: EditPlan) {
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)
throw Error("片段超出原始素材范围");
const duration = durations[index], const duration = durations[index],
overlap = overlaps[index]; overlap = overlaps[index];
if (!centered[index]) cursor -= overlap; if (!centered[index]) cursor -= overlap;

View File

@ -1,4 +1,5 @@
import "server-only"; import "server-only";
import { processEditAudio } from "./audio-process";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { uploadFile } from "@/lib/storage/service"; import { uploadFile } from "@/lib/storage/service";
@ -31,7 +32,7 @@ import { remoteRender, remoteRenderConfig, type RemoteRenderCheckpoint } from ".
import { ProviderTaskUncertainError } from "../video-task-lifecycle"; import { ProviderTaskUncertainError } from "../video-task-lifecycle";
export type SmartEditCheckpoint = { export type SmartEditCheckpoint = {
operation: "smart-edit"; operation: "smart-edit";
action: "plan" | "render"; action: "plan" | "render" | "separate" | "transcribe";
phase: "local" | "analyzed" | "selecting" | "selected" | "remote-render"; phase: "local" | "analyzed" | "selecting" | "selected" | "remote-render";
remote?: RemoteRenderCheckpoint; remote?: RemoteRenderCheckpoint;
stage: string; stage: string;
@ -67,8 +68,9 @@ export async function executeSmartEdit(
const upload = async (file: string, suffix: string) => { const upload = async (file: string, suffix: string) => {
await lifecycle.assertActive(); await lifecycle.assertActive();
const bytes = await readFile(file); const bytes = await readFile(file);
return (await uploadFile(`smart-edit-${randomUUID()}.${suffix}`, Uint8Array.from(bytes).buffer)) return (
.url; await uploadFile(`smart-edit-${randomUUID()}.${suffix}`, Uint8Array.from(bytes).buffer)
).url;
}; };
if (params.operation === "render" && (state.remote || remoteRenderConfig())) { if (params.operation === "render" && (state.remote || remoteRenderConfig())) {
const dir = await mkdtemp(path.join(tmpdir(), "remote-render-")); const dir = await mkdtemp(path.join(tmpdir(), "remote-render-"));
@ -80,7 +82,8 @@ export async function executeSmartEdit(
}); });
const actual = await probeMedia(rendered.file, 300.2); 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
) )
@ -199,7 +202,8 @@ export async function executeSmartEdit(
: Math.min( : Math.min(
range.end, range.end,
start + start +
Math.max(5, Math.min(30, (params.targetDuration || 20) / 12)) * SECOND, Math.max(5, Math.min(30, (params.targetDuration || 20) / 12)) *
SECOND,
); );
if (end - start >= 200000) if (end - start >= 200000)
candidates.push({ candidates.push({
@ -220,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: "检测音乐节拍" });
@ -228,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("未选择音乐,跳过音乐卡点。");
@ -286,6 +298,22 @@ 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 }));

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,5 +1,18 @@
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. */ /** Same packaged font bytes are used in browser previews and FFmpeg exports. */
export const captionFonts = [ const bundledFonts: CaptionFont[] = [
{ {
id: "source-han", id: "source-han",
name: "Source Han Sans CN", name: "Source Han Sans CN",
@ -45,12 +58,7 @@ export const captionFonts = [
recommended: false, recommended: false,
google: true, google: true,
}, },
] as const; ];
export const captionFontIds = [ export const captionFonts: CaptionFont[] = [...bundledFonts, ...catalog];
"source-han", const byId = new Map(captionFonts.map((font) => [font.id, font]));
"noto-sans-sc", export const captionFont = (id?: string) => byId.get(id || "");
"noto-serif-sc",
"roboto",
"inter",
] as const;
export const captionFont = (id?: string) => captionFonts.find((font) => font.id === id);

View File

@ -114,7 +114,10 @@ export async function pythonAnalysis(
data: Record<string, unknown>, data: Record<string, unknown>,
dir: string, dir: string,
): Promise<LocalAnalysis> { ): Promise<LocalAnalysis> {
const key = createHash("sha256").update(JSON.stringify(data)).digest("hex"), const key = createHash("sha256")
.update("analysis-v3:")
.update(JSON.stringify(data))
.digest("hex"),
input = path.join(dir, key + ".analysis-input.json"), input = path.join(dir, key + ".analysis-input.json"),
output = path.join(dir, key + ".analysis.json"); output = path.join(dir, key + ".analysis.json");
try { try {
@ -147,7 +150,12 @@ export async function pythonAnalysis(
.slice(0, 500); .slice(0, 500);
return value; return value;
} }
export async function prepareAnalysis(file: string, hash: string, dir: string, hasAudio: boolean) { export async function prepareAnalysis(
file: string,
hash: string,
dir: string,
hasAudio: boolean,
) {
const proxy = path.join(dir, hash + ".proxy.mp4"), const proxy = path.join(dir, hash + ".proxy.mp4"),
audio = path.join(dir, hash + ".wav"); audio = path.join(dir, hash + ".wav");
try { try {

View File

@ -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,7 +32,8 @@ 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);

View File

@ -1,5 +1,6 @@
import { readFile, writeFile, mkdir, copyFile } from "node:fs/promises"; import { readFile, writeFile, mkdir, copyFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { captionFontFile } from "./font-files";
import { captionFont } from "./fonts"; import { captionFont } from "./fonts";
import { import {
type EditPlan, type EditPlan,
@ -28,8 +29,7 @@ export async function renderEditPlan(
compiledSubtitles(plan) compiledSubtitles(plan)
.map( .map(
(caption) => (caption) =>
captionFont((caption as { style?: { fontFamily?: string } }).style?.fontFamily) captionFont((caption as { style?: { fontFamily?: string } }).style?.fontFamily)?.id,
?.file,
) )
.filter((file): file is NonNullable<typeof file> => Boolean(file)), .filter((file): file is NonNullable<typeof file> => Boolean(file)),
), ),
@ -40,15 +40,17 @@ export async function renderEditPlan(
const source = const source =
process.env.SMART_EDIT_FONTS_DIR || process.env.SMART_EDIT_FONTS_DIR ||
path.resolve(process.cwd(), "public/fonts/smart-edit"); path.resolve(process.cwd(), "public/fonts/smart-edit");
for (const file of selectedFonts) { for (const id of selectedFonts) {
const file = captionFont(id)!.file;
try { try {
await copyFile(path.join(source, file), path.join(fontDir, file)); await progress("准备字幕字体");
await copyFile(await captionFontFile(id), path.join(fontDir, file));
} catch { } catch {
throw Error(`字幕字体文件缺失:${file},请安装剪辑字体包后重新导出`); throw Error(`字幕字体文件缺失:${file},请安装剪辑字体包后重新导出`);
} }
} }
// Latin faces use the same packaged Chinese fallback in both renderers. // Latin faces use the same packaged Chinese fallback in both renderers.
if (!selectedFonts.includes("source-han.otf")) if (!selectedFonts.includes("source-han"))
await copyFile(path.join(source, "source-han.otf"), path.join(fontDir, "source-han.otf")); await copyFile(path.join(source, "source-han.otf"), path.join(fontDir, "source-han.otf"));
} }
const normalized: string[] = []; const normalized: string[] = [];

View File

@ -141,13 +141,28 @@ 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",
@ -164,7 +179,11 @@ export function reconcileCanvasTasks<
audioParams: task.request.params, audioParams: task.request.params,
}); });
if (task.request.kind === "video") if (task.request.kind === "video")
Object.assign(nextDraft, { mediaUrl: data.url, url: [data.url], videoMeta: data.metadata }); Object.assign(nextDraft, {
mediaUrl: data.url,
url: [data.url],
videoMeta: data.metadata,
});
if (task.request.kind === "image" || task.request.kind === "image-operation") { 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)
@ -189,7 +208,10 @@ export function reconcileCanvasTasks<
]; ];
} }
} }
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,
@ -208,7 +230,9 @@ 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 = "任务查询中断,稍后会自动查询原任务,请勿重复生成。";
@ -249,7 +273,7 @@ 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:

View File

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

View File

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

View File

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

View File

@ -544,7 +544,7 @@ const fs = require("node:fs"),
}); });
assert.ok(rms(independentMuted.file, 1.7) < 0.0001, "mute must silence detached sound"); assert.ok(rms(independentMuted.file, 1.7) < 0.0001, "mute must silence detached sound");
const fontHashes = new Set(); const fontHashes = new Set();
for (const fontFamily of ["source-han", "noto-sans-sc", "noto-serif-sc", "roboto", "inter"]) { 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, { const output = await actual("font-" + fontFamily, {
...base, ...base,
subtitles: true, subtitles: true,
@ -599,7 +599,7 @@ const fs = require("node:fs"),
"selected fonts must change actual glyph pixels, not all fall back to one face", "selected fonts must change actual glyph pixels, not all fall back to one face",
); );
console.log( console.log(
"PASS: positive gain amplitude, original/detached mute and five packaged caption fonts", `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);

View File

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

View File

@ -14,9 +14,10 @@ for key in ('OMP_NUM_THREADS', 'OPENBLAS_NUM_THREADS', 'MKL_NUM_THREADS', 'NUMBA
def capabilities(): def capabilities():
import shutil import shutil
return {'scenes': importlib.util.find_spec('scenedetect') is not None, from separate import available
return {'separation': available(), 'scenes': importlib.util.find_spec('scenedetect') is not None,
'beats': importlib.util.find_spec('librosa') is not None, 'beats': importlib.util.find_spec('librosa') is not None,
'asr': importlib.util.find_spec('funasr') is not None and all(Path(os.environ.get(k, '/nonexistent')).is_dir() for k in ('SMART_EDIT_ASR_MODEL','SMART_EDIT_VAD_MODEL','SMART_EDIT_PUNC_MODEL')), 'asr': importlib.util.find_spec('funasr') is not None and all(all((Path(os.environ.get(k, '/nonexistent'))/f).is_file() for f in ('model.pt','config.yaml')) for k in ('SMART_EDIT_ASR_MODEL','SMART_EDIT_VAD_MODEL','SMART_EDIT_PUNC_MODEL')),
'ffmpeg': shutil.which('ffmpeg') is not None, 'ffmpeg': shutil.which('ffmpeg') is not None,
'ffprobe': shutil.which('ffprobe') is not None} 'ffprobe': shutil.which('ffprobe') is not None}
@ -63,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')
@ -72,7 +73,10 @@ def analyze(data):
if times:start,end=times[0][0],times[-1][1] if times:start,end=times[0][0],times[-1][1]
text=str(sentence.get('text','')).strip() text=str(sentence.get('text','')).strip()
if text and start is not None and end is not None and end>start: if text and start is not None and end is not None and end>start:
result['subtitles'].append({'start':round(start*1000),'end':round(end*1000),'text':text}) bounded_start=max(0,round(start*1000))
bounded_end=min(round(len(wave)/sr*1e6),round(end*1000))
if bounded_end>bounded_start:
result['subtitles'].append({'start':bounded_start,'end':bounded_end,'text':text})
if not result['subtitles']:result['warnings'].append('语音识别没有返回有效语句,将按画面镜头处理;不会生成虚构字幕。') if not result['subtitles']:result['warnings'].append('语音识别没有返回有效语句,将按画面镜头处理;不会生成虚构字幕。')
elif data.get('transcribe'):result['warnings'].append('素材没有音轨,跳过语音转写。') elif data.get('transcribe'):result['warnings'].append('素材没有音轨,跳过语音转写。')
return result return result

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -16,5 +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 })) .then(() => {
fs.cpSync("public/fonts/smart-edit", "services/video-render/dist/fonts", { recursive: true });
fs.copyFileSync("services/smart-edit/font.py", "services/video-render/dist/font.py");
})
.catch(() => process.exit(1)); .catch(() => process.exit(1));

View File

@ -23,6 +23,7 @@ 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"); 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 || "";

View File

@ -524,3 +524,56 @@ test("node intent honors explicit beat switches, missing music, and ad variants"
expect(resolveSmartEditIntent({ prompt: "需要卡点", beatSync: false }).beatSync).toBe(false); expect(resolveSmartEditIntent({ prompt: "需要卡点", beatSync: false }).beatSync).toBe(false);
expect(resolveSmartEditIntent({ mode: "ads", variants: 2 }).variants).toBe(2); expect(resolveSmartEditIntent({ mode: "ads", variants: 2 }).variants).toBe(2);
}); });
test("audio processing retains the editor, other variants, and the selected plan", () => {
for (const operation of ["separate", "transcribe"]) {
const first = plan(),
second = { ...plan(), title: "另一个方案" };
const changed = { ...second, title: "音频处理完成" };
const node = {
id: "edit",
kind: "text",
draftValues: {
action: "SMART_EDIT_WORKFLOW",
smartEditDraft: { plans: [first, second], selectedPlanIndex: 1 },
},
};
const task: any = {
id: "processed",
requestId: "req",
status: "succeeded",
createdAt: "2026-09-18T00:01:00Z",
request: { kind: "smart-edit", node, params: { operation, plan: second } },
result: { plans: [changed] },
};
const result = reconcileCanvasTasks([node], [task], "2026-09-18T00:00:00Z")[0];
expect(result.kind).toBe("text");
expect(result.draftValues.smartEditDraft.plans.map((p: any) => p.title)).toEqual([
first.title,
changed.title,
]);
expect(result.draftValues.smartEditDraft.selectedPlanIndex).toBe(1);
expect(reconcileCanvasTasks([result], [task], "2026-09-18T00:00:00Z")).toEqual([result]);
}
});
test("audio-only stem assets can export, but cannot become a video clip", () => {
const p = plan();
p.assets.push({ ...asset, id: "vocals", width: 0, height: 0 });
p.audioClips = [
{
id: "voice",
assetId: "vocals",
start: 0,
sourceIn: 0,
sourceOut: SECOND,
speed: 1,
gain: 1,
fadeIn: 0,
fadeOut: 0,
},
];
expect(planTimeline(p).duration).toBeGreaterThan(0);
p.clips[0].assetId = "vocals";
expect(() => planTimeline(p)).toThrow();
});