Complete smart edit audio processing and font catalogue with main app validation
This commit is contained in:
parent
17e17b9ad5
commit
c6533e92f3
8
.gitignore
vendored
8
.gitignore
vendored
@ -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-*/
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
@ -100,6 +100,8 @@ export async function POST(request: NextRequest) {
|
|||||||
.insert(canvasProject)
|
.insert(canvasProject)
|
||||||
.values({
|
.values({
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
title: title || getDefaultWorkspaceTitle(workspaceType),
|
title: title || getDefaultWorkspaceTitle(workspaceType),
|
||||||
description,
|
description,
|
||||||
workspaceType,
|
workspaceType,
|
||||||
|
|||||||
@ -10,7 +10,14 @@ export async function GET() {
|
|||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||||
try {
|
try {
|
||||||
let capabilities = { scenes: false, beats: false, asr: false, ffmpeg: false, ffprobe: false };
|
let capabilities = {
|
||||||
|
separation: false,
|
||||||
|
scenes: false,
|
||||||
|
beats: false,
|
||||||
|
asr: false,
|
||||||
|
ffmpeg: false,
|
||||||
|
ffprobe: false,
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
const { stdout } = await promisify(execFile)(
|
const { stdout } = await promisify(execFile)(
|
||||||
process.env.SMART_EDIT_PYTHON || "python3",
|
process.env.SMART_EDIT_PYTHON || "python3",
|
||||||
@ -35,7 +42,9 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const models = (await getEnabledAiModelsWithProvider(false))
|
const models = (await getEnabledAiModelsWithProvider(false))
|
||||||
.filter((item) => isCanvasVisionModel({ ...item.model, providerType: item.provider.type }))
|
.filter((item) =>
|
||||||
|
isCanvasVisionModel({ ...item.model, providerType: item.provider.type }),
|
||||||
|
)
|
||||||
.map(({ model }) => ({ id: model.id, name: model.displayName, modelId: model.modelId }));
|
.map(({ model }) => ({ id: model.id, name: model.displayName, modelId: model.modelId }));
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ capabilities: { ...capabilities, render: renderAvailable }, models },
|
{ capabilities: { ...capabilities, render: renderAvailable }, models },
|
||||||
|
|||||||
22
app/api/canvas/smart-edit/fonts/[id]/route.ts
Normal file
22
app/api/canvas/smart-edit/fonts/[id]/route.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { captionFont } from "@/lib/video-canvas/smart-edit/fonts";
|
||||||
|
import { captionFontFile } from "@/lib/video-canvas/smart-edit/font-files";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const maxDuration = 180;
|
||||||
|
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
if (!(await auth())?.user?.id) return Response.json({ error: "未登录" }, { status: 401 });
|
||||||
|
const { id } = await params;
|
||||||
|
if (!captionFont(id)) return Response.json({ error: "字体不存在" }, { status: 404 });
|
||||||
|
try {
|
||||||
|
const data = await readFile(await captionFontFile(id));
|
||||||
|
return new Response(new Uint8Array(data), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "font/ttf",
|
||||||
|
"Cache-Control": "private, max-age=31536000, immutable",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: "字体加载失败,请检查字体服务配置后重试" }, { status: 503 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)}
|
||||||
|
|||||||
@ -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>}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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; }
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
63
docs/smart-edit-completion-2026-09-18.md
Normal file
63
docs/smart-edit-completion-2026-09-18.md
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
# 智能剪辑四项续修与本地验收(2026-09-18)
|
||||||
|
|
||||||
|
本轮承接 `e18413a4`,范围为人声分离、自动转写、完整字体目录、主站智能剪辑链路。仅本地修改和验证,未部署、未推送。并非全部网站功能或所有视觉状态完成像素级验收。
|
||||||
|
|
||||||
|
## 功能结果
|
||||||
|
|
||||||
|
| 项目 | 实现与验证 |
|
||||||
|
| --- | --- |
|
||||||
|
| 人声分离 | 离线 HTDemucs,CPU 2线程、7秒分块、每素材180秒上限。视频和独立音轨均可选择“仅保留人声”“仅保留背景音”“分离为两个音轨”。保留裁剪、变速、增益、静音和转场音量包络,关闭已分离的视频原声,防止音量叠加;处理失败保留原方案。 |
|
||||||
|
| 自动转写 | 本地 FunASR Paraformer/FSMN-VAD/CT-Transformer。字幕面板入口直接创建持久化任务,保留手动字幕、替换此前 ASR 字幕。修复缺失 `pred_timestamp` 导致没有逐句时间戳的问题,负起点归零,按照裁剪及变速映射到时间轴。 |
|
||||||
|
| 完整字体库 | 参考目录共2002项,5项随应用打包、1997项新增目录全部下载、解析及SHA校验通过;198个参考源字体,其他字体来自官方 Google Fonts。原始与处理后字节均固定校验和。浏览器和合成使用相同字体文件;字体内版权信息保留。支持搜索、全部/常用/推荐/Google分类、虚拟滚动。修复小高度窗口弹层被参数栏截断。 |
|
||||||
|
| 主站智能剪辑链路 | 实际 Next 生产构建、登录、隔离数据库、上传、任务队列、模型处理、FFmpeg合成、成片与SRT、画布回填播放、编辑方案保存和刷新恢复均验证。未使用独立演示页的样例任务回调。 |
|
||||||
|
|
||||||
|
模型官方来源:[Demucs](https://github.com/facebookresearch/demucs)、[FunASR](https://github.com/modelscope/FunASR)。部署安装及缓存配置见 [服务说明](../services/smart-edit/README.md)。
|
||||||
|
|
||||||
|
## 真实数据
|
||||||
|
|
||||||
|
中文样例8.3935秒,视频8.9秒。实际识别三句话:
|
||||||
|
|
||||||
|
- 0–1.349秒:“欢迎使用智能剪辑,”
|
||||||
|
- 1.349–4.710秒:“我们正在测试人声分离和自动字幕,”
|
||||||
|
- 4.710–8.205秒:“请保留原始视频的画面和声音。”
|
||||||
|
|
||||||
|
实际混音分离后,人声及背景声相加与原信号的最大误差 `1.49e-8`。这验证音量与时间同步;不等于对任意歌曲的人声提取质量作保证。
|
||||||
|
|
||||||
|
生产构建 API 链路:方案1.6秒、视频分离10.6秒、转写10.6秒、独立音轨分离9.1秒、合成1.6秒。两个单轨保留分支另测11.6/10.2秒,均只新增所选的一轨。此为本机短素材实测,不是线上性能承诺。成片854×480、H.264/AAC、8.900秒。
|
||||||
|
|
||||||
|
新增字体1997项全部处理通过,缓存793,509,828字节(约757MiB)。实际FFmpeg验证原有5种及新增6种(大宝桃桃体、ABeeZee、Abril Fatface、CJGDGMH、Molle、SCJSSHT),检查实际字形像素。主站实际选择大宝桃桃体,应用全部字幕并确认数据库保存为所选字体。两款参考字体OS/2版本字段与长度不匹配,仅修正字段版本,未改变字形轮廓。
|
||||||
|
|
||||||
|
## 发现并修复的主站问题
|
||||||
|
|
||||||
|
- 新画布依赖数据库 `now()` 写入无时区时间时,与应用生成的任务时间产生8小时时差,导致任务回填被判为旧结果。新建画布的两个接口改为显式应用时间;隔离测试库为 Asia/Shanghai,新增项目时间断言通过。未迁移或修改生产历史数据。
|
||||||
|
- 视频卡片原先忽略 `videoMeta.durationSeconds`,显示5秒而实际8.9秒。优先读取成片元数据及浏览器解码时长,现显示9秒。
|
||||||
|
- 音频处理结果不得把编辑器节点变成视频节点;更新当前方案时保留其他备选方案,反复回填保持幂等。
|
||||||
|
- 开发热更新期间曾遇到编辑器样式失效;最终生产构建重新加载、播放和操作正常。未将开发热更新现象误判为已定位的线上缺陷。
|
||||||
|
|
||||||
|
## 验证与复现
|
||||||
|
|
||||||
|
- TypeScript及Next生产构建通过。
|
||||||
|
- Helper回归498通过、1跳过。
|
||||||
|
- 真实FFmpeg:6比例、3转场、居中转场、裁剪/变速/音量/静音/字幕位置与字体;PySceneDetect 3镜头、RMS 2有效区间、librosa 22拍。
|
||||||
|
- 独立合成服务:鉴权、真实成片、进程中断后恢复、幂等、冲突拒绝、完成结果重启保留。
|
||||||
|
- 主站浏览器:实际自动转写、字体搜索/选择/全部应用、两个分离子菜单、仅人声提交、刷新后回填、成片播放和导出到画布。
|
||||||
|
|
||||||
|
可复现脚本:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$SMART_EDIT_PYTHON scripts/qa/smart-edit-models.py input.wav .qa/model-check
|
||||||
|
SMART_EDIT_TEST_CATALOG_FONTS=1 node scripts/qa/video-canvas-smart-edit.cjs
|
||||||
|
SMART_EDIT_QA_DIR=.qa/isolated-main-chain node scripts/qa/smart-edit-main-chain.cjs
|
||||||
|
node services/video-render/build.cjs
|
||||||
|
node scripts/qa/video-render-service.cjs
|
||||||
|
```
|
||||||
|
|
||||||
|
主站脚本仅允许localhost/127.0.0.1,需独立测试库、实际登录账号 `account.json`(id/email/password)和含“智能剪辑”的中文 `speech.mp4`。不得使用生产账号或生产库。数据库、模型权重、字体缓存、测试凭据和成片不提交Git。
|
||||||
|
|
||||||
|
本地证据目录 `.qa/smart-edit/remaining-0918/`:`model-regression/report.json`、`production-chain.log`、`production-chain/main-result.mp4`、`branches-report.json`、`catalog-render.log`、`browser-verification.json`、`main-editor-final.jpg`、`browser-export.mp4`、`build-release.log`、`helpers-final.log`、`render-service.log`。
|
||||||
|
|
||||||
|
## 验收边界
|
||||||
|
|
||||||
|
本地主站使用隔离 PostgreSQL 数据库及本地S3兼容服务。测试进程注入仅限本地下载传输的映射,使真实下载器可访问该存储;没有放宽生产SSRF规则。尚未验证线上对象存储或部署。
|
||||||
|
|
||||||
|
目录及字体文件完整,不代表2002字体在所有DPR、中文混排、字形抗锯齿方面都完成逐像素对拍。后台休眠/断网/所有窄屏矩阵、运行中远程任务主动取消、五种模式算法与参考逐案一致性仍单列待验,不混入本轮完成项。
|
||||||
@ -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与休眠断网矩阵未完成。"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 |
|
||||||
|
|||||||
178
lib/video-canvas/smart-edit/audio-process.ts
Normal file
178
lib/video-canvas/smart-edit/audio-process.ts
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import {
|
||||||
|
SECOND,
|
||||||
|
editPlanSchema,
|
||||||
|
planTimeline,
|
||||||
|
editableCaptions,
|
||||||
|
newCaptionStyle,
|
||||||
|
type EditPlan,
|
||||||
|
type TimelineCaption,
|
||||||
|
} from "./contract";
|
||||||
|
import {
|
||||||
|
materialize,
|
||||||
|
probeMedia,
|
||||||
|
ffmpeg,
|
||||||
|
mediaSafety,
|
||||||
|
prepareAudio,
|
||||||
|
pythonAnalysis,
|
||||||
|
} from "./local";
|
||||||
|
|
||||||
|
export async function processEditAudio(
|
||||||
|
operation: "separate" | "transcribe",
|
||||||
|
plan: EditPlan,
|
||||||
|
clipId: string | undefined,
|
||||||
|
userId: string,
|
||||||
|
dir: string,
|
||||||
|
progress: (stage: string) => Promise<void>,
|
||||||
|
upload: (file: string, suffix: string) => Promise<string>,
|
||||||
|
track: "video" | "audio" = "video",
|
||||||
|
keep: "both" | "vocals" | "background" = "both",
|
||||||
|
): Promise<EditPlan> {
|
||||||
|
const timeline = planTimeline(plan);
|
||||||
|
if (operation === "separate") {
|
||||||
|
const index = plan.clips.findIndex((c) => c.id === clipId);
|
||||||
|
const audio = track === "audio" ? plan.audioClips?.find((c) => c.id === clipId) : undefined;
|
||||||
|
const clip = track === "audio" ? audio : plan.clips[index];
|
||||||
|
if (!clip || (track === "video" && plan.clips[index].audioDetached))
|
||||||
|
throw Error("请选择可分离的音视频片段");
|
||||||
|
if ((plan.audioClips?.length || 0) + (keep === "both" ? 2 : 1) - (audio ? 1 : 0) > 24)
|
||||||
|
throw Error("分离需要空闲音轨,最多支持 24 个音频片段");
|
||||||
|
const asset = plan.assets.find((a) => a.id === clip.assetId)!;
|
||||||
|
if (!asset.hasAudio) throw Error("当前视频没有音轨");
|
||||||
|
await progress("准备人声分离素材");
|
||||||
|
const media = await materialize(asset.url, userId);
|
||||||
|
if (media.hash !== asset.hash) throw Error("原素材内容已变化,请重新解析");
|
||||||
|
const actual = await probeMedia(media.file);
|
||||||
|
if (!actual.hasAudio || Math.abs(actual.duration - asset.duration) > 100000)
|
||||||
|
throw Error("原音轨参数已变化,请重新解析");
|
||||||
|
const inputAudio = path.join(dir, "separation-input.wav");
|
||||||
|
await ffmpeg([
|
||||||
|
...mediaSafety,
|
||||||
|
"-ss",
|
||||||
|
String(clip.sourceIn / SECOND),
|
||||||
|
"-i",
|
||||||
|
media.file,
|
||||||
|
"-t",
|
||||||
|
String((clip.sourceOut - clip.sourceIn) / SECOND),
|
||||||
|
"-vn",
|
||||||
|
"-af",
|
||||||
|
"apad",
|
||||||
|
"-ac",
|
||||||
|
"2",
|
||||||
|
"-ar",
|
||||||
|
"44100",
|
||||||
|
"-c:a",
|
||||||
|
"pcm_f32le",
|
||||||
|
inputAudio,
|
||||||
|
]);
|
||||||
|
await progress("正在分离人声与背景声");
|
||||||
|
try {
|
||||||
|
await promisify(execFile)(
|
||||||
|
process.env.SMART_EDIT_PYTHON || "python3",
|
||||||
|
[path.resolve("services/smart-edit/separate.py"), inputAudio, dir],
|
||||||
|
{ timeout: 600000, maxBuffer: 2 * 1024 * 1024 },
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
const detail = String((e as { stderr?: string }).stderr || "");
|
||||||
|
throw Error(
|
||||||
|
detail.includes("尚未配置")
|
||||||
|
? "人声分离模型尚未配置"
|
||||||
|
: "人声分离失败或超时,原音轨已保留",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await progress("保存人声与背景声音轨");
|
||||||
|
const audioClips = (plan.audioClips || []).filter((c) => !audio || c.id !== audio.id);
|
||||||
|
const used = new Set([...plan.clips, ...audioClips].map((c) => c.assetId));
|
||||||
|
const assets = plan.assets.filter((a) => used.has(a.id));
|
||||||
|
const t = timeline.clips[index],
|
||||||
|
next = timeline.clips[index + 1];
|
||||||
|
for (const [stem, title] of [
|
||||||
|
["vocals", "人声"],
|
||||||
|
["background", "背景声"],
|
||||||
|
]) {
|
||||||
|
if (keep !== "both" && keep !== stem) continue;
|
||||||
|
const file = path.join(dir, stem + ".wav"),
|
||||||
|
metadata = await probeMedia(file);
|
||||||
|
if (Math.abs(metadata.duration - (clip.sourceOut - clip.sourceIn)) > 50000)
|
||||||
|
throw Error("分离音轨时长异常,原音轨已保留");
|
||||||
|
const id = randomUUID(),
|
||||||
|
bytes = await readFile(file);
|
||||||
|
assets.push({
|
||||||
|
id,
|
||||||
|
name: `${asset.name.slice(0, 180)} · ${title}`,
|
||||||
|
url: await upload(file, "wav"),
|
||||||
|
hash: createHash("sha256").update(bytes).digest("hex"),
|
||||||
|
subtitles: [],
|
||||||
|
...metadata,
|
||||||
|
});
|
||||||
|
audioClips.push({
|
||||||
|
id: randomUUID(),
|
||||||
|
assetId: id,
|
||||||
|
start: audio ? audio.start : t.start,
|
||||||
|
sourceIn: 0,
|
||||||
|
sourceOut: clip.sourceOut - clip.sourceIn,
|
||||||
|
speed: clip.speed,
|
||||||
|
gain: clip.gain,
|
||||||
|
muted: clip.muted,
|
||||||
|
fadeIn: audio ? audio.fadeIn : (t.overlap - t.padBefore) / SECOND,
|
||||||
|
fadeInStart: audio ? audio.fadeInStart : t.overlap ? t.padBefore / t.overlap : 0,
|
||||||
|
fadeOut: audio ? audio.fadeOut : next ? (next.overlap - t.padAfter) / SECOND : 0,
|
||||||
|
fadeOutEnd: audio ? audio.fadeOutEnd : next?.overlap ? t.padAfter / next.overlap : 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return editPlanSchema.parse({
|
||||||
|
...plan,
|
||||||
|
assets,
|
||||||
|
audioClips,
|
||||||
|
clips: plan.clips.map((c) =>
|
||||||
|
!audio && c.id === clip.id ? { ...c, audioDetached: true } : c,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const captions: TimelineCaption[] = editableCaptions(plan).filter(
|
||||||
|
(c) => !c.id.startsWith("asr-"),
|
||||||
|
);
|
||||||
|
let recognized = 0;
|
||||||
|
for (const [index, clip] of timeline.clips.entries()) {
|
||||||
|
const asset = plan.assets.find((a) => a.id === clip.assetId)!;
|
||||||
|
if (!asset.hasAudio) continue;
|
||||||
|
await progress(`自动转写 ${index + 1}/${plan.clips.length}`);
|
||||||
|
const media = await materialize(asset.url, userId);
|
||||||
|
if (media.hash !== asset.hash) throw Error("原素材内容已变化,请重新解析");
|
||||||
|
const actual = await probeMedia(media.file);
|
||||||
|
if (!actual.hasAudio || Math.abs(actual.duration - asset.duration) > 100000)
|
||||||
|
throw Error("原音轨参数已变化,请重新解析");
|
||||||
|
const audio = await prepareAudio(media.file, path.join(media.dir, media.hash + ".wav"));
|
||||||
|
const analysis = await pythonAnalysis({ version: 1, audio, transcribe: true }, media.dir);
|
||||||
|
for (const [j, s] of analysis.subtitles.entries()) {
|
||||||
|
const from = Math.max(s.start, clip.sourceIn),
|
||||||
|
to = Math.min(s.end, clip.sourceOut);
|
||||||
|
if (to <= from) continue;
|
||||||
|
const start = Math.round(clip.start + (from - clip.sourceIn) / clip.speed);
|
||||||
|
const end = Math.min(
|
||||||
|
timeline.duration,
|
||||||
|
Math.round(clip.start + (to - clip.sourceIn) / clip.speed),
|
||||||
|
);
|
||||||
|
if (end <= start) continue;
|
||||||
|
captions.push({
|
||||||
|
id: `asr-${clip.id}-${j}`,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
text: s.text,
|
||||||
|
style: { ...newCaptionStyle },
|
||||||
|
});
|
||||||
|
recognized++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!recognized) throw Error("未识别到有效人声,原字幕已保留");
|
||||||
|
if (captions.length > 500) throw Error("转写后字幕超过 500 条,请缩短素材");
|
||||||
|
return editPlanSchema.parse({
|
||||||
|
...plan,
|
||||||
|
subtitles: true,
|
||||||
|
captions: captions.sort((a, b) => a.start - b.start),
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -1,5 +1,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;
|
||||||
|
|||||||
@ -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 }));
|
||||||
|
|||||||
29957
lib/video-canvas/smart-edit/font-catalog.json
Normal file
29957
lib/video-canvas/smart-edit/font-catalog.json
Normal file
File diff suppressed because it is too large
Load Diff
88
lib/video-canvas/smart-edit/font-files.ts
Normal file
88
lib/video-canvas/smart-edit/font-files.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { readFile, writeFile, mkdir, rename, rm } from "node:fs/promises";
|
||||||
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { captionFont } from "./fonts";
|
||||||
|
const pending = new Map<string, Promise<string>>();
|
||||||
|
const sha = (data: Uint8Array) => createHash("sha256").update(data).digest("hex");
|
||||||
|
export function captionFontFile(id: string): Promise<string> {
|
||||||
|
const font = captionFont(id);
|
||||||
|
if (!font) return Promise.reject(Error("未知字幕字体"));
|
||||||
|
if (!font.url)
|
||||||
|
return Promise.resolve(
|
||||||
|
path.join(
|
||||||
|
process.env.SMART_EDIT_FONTS_DIR || path.resolve("public/fonts/smart-edit"),
|
||||||
|
font.file,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (pending.has(id)) return pending.get(id)!;
|
||||||
|
const work = (async () => {
|
||||||
|
const root = path.resolve(
|
||||||
|
process.env.SMART_EDIT_FONT_CACHE_DIR || ".cache/smart-edit-fonts",
|
||||||
|
);
|
||||||
|
await mkdir(root, { recursive: true });
|
||||||
|
const output = path.join(root, font.file);
|
||||||
|
try {
|
||||||
|
const data = await readFile(output);
|
||||||
|
if (font.preparedSha256 && sha(data) === font.preparedSha256) return output;
|
||||||
|
} catch {}
|
||||||
|
if (pending.size > 4) throw Error("字体正在加载,请稍后重试");
|
||||||
|
const source = new URL(font.url!);
|
||||||
|
if (
|
||||||
|
source.protocol !== "https:" ||
|
||||||
|
!["fonts.gstatic.com", "fonts.gstatic.cn", "libtv-res.liblib.art"].includes(
|
||||||
|
source.hostname,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw Error("字体来源无效");
|
||||||
|
const response = await fetch(source, {
|
||||||
|
signal: AbortSignal.timeout(60000),
|
||||||
|
redirect: "error",
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
if (!response.ok || Number(response.headers.get("content-length")) > 40 * 1024 * 1024)
|
||||||
|
throw Error("字体下载失败");
|
||||||
|
const reader = response.body!.getReader(),
|
||||||
|
chunks: Uint8Array[] = [];
|
||||||
|
let bytes = 0;
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
bytes += value.length;
|
||||||
|
if (bytes > 40 * 1024 * 1024) throw Error("字体文件过大");
|
||||||
|
chunks.push(value);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await reader.cancel();
|
||||||
|
}
|
||||||
|
const data = Buffer.concat(chunks);
|
||||||
|
if (!font.sha256 || sha(data) !== font.sha256)
|
||||||
|
throw Error("字体资源校验失败,请更新字体目录");
|
||||||
|
const temp = path.join(root, randomUUID()),
|
||||||
|
prepared = temp + ".ttf";
|
||||||
|
try {
|
||||||
|
await writeFile(temp, data);
|
||||||
|
await promisify(execFile)(
|
||||||
|
process.env.SMART_EDIT_FONT_PYTHON || process.env.SMART_EDIT_PYTHON || "python3",
|
||||||
|
[
|
||||||
|
process.env.SMART_EDIT_FONT_SCRIPT || path.resolve("services/smart-edit/font.py"),
|
||||||
|
temp,
|
||||||
|
prepared,
|
||||||
|
font.family,
|
||||||
|
],
|
||||||
|
{ timeout: 120000, maxBuffer: 512 * 1024 },
|
||||||
|
);
|
||||||
|
if (sha(await readFile(prepared)) !== font.preparedSha256)
|
||||||
|
throw Error("字体处理版本不一致,请安装指定字体依赖");
|
||||||
|
await rename(prepared, output);
|
||||||
|
return output;
|
||||||
|
} finally {
|
||||||
|
await rm(temp, { force: true });
|
||||||
|
await rm(prepared, { force: true });
|
||||||
|
}
|
||||||
|
})().finally(() => pending.delete(id));
|
||||||
|
pending.set(id, work);
|
||||||
|
return work;
|
||||||
|
}
|
||||||
@ -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);
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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[] = [];
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
214
scripts/qa/smart-edit-main-chain.cjs
Normal file
214
scripts/qa/smart-edit-main-chain.cjs
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
// Real local Next/auth/database/storage/task/model/render chain. Requires an isolated account.json and speech.mp4 in SMART_EDIT_QA_DIR. Never target production.
|
||||||
|
const fs = require("fs"),
|
||||||
|
crypto = require("crypto"),
|
||||||
|
path = require("path"),
|
||||||
|
assert = require("assert/strict");
|
||||||
|
const q = path.resolve(process.env.SMART_EDIT_QA_DIR || ".qa/smart-edit/main-chain"),
|
||||||
|
base = process.env.SMART_EDIT_QA_URL || "http://127.0.0.1:3025";
|
||||||
|
assert(
|
||||||
|
["127.0.0.1", "localhost"].includes(new URL(base).hostname),
|
||||||
|
"Use an isolated local server only",
|
||||||
|
);
|
||||||
|
fs.mkdirSync(q, { recursive: true });
|
||||||
|
let cookies = {};
|
||||||
|
let report = [];
|
||||||
|
async function request(p, o = {}) {
|
||||||
|
const r = await fetch(base + p, {
|
||||||
|
...o,
|
||||||
|
headers: {
|
||||||
|
...o.headers,
|
||||||
|
Cookie: Object.entries(cookies)
|
||||||
|
.map(([k, v]) => k + "=" + v)
|
||||||
|
.join("; "),
|
||||||
|
},
|
||||||
|
redirect: "manual",
|
||||||
|
signal: AbortSignal.timeout(180000),
|
||||||
|
});
|
||||||
|
for (const c of r.headers.getSetCookie()) {
|
||||||
|
let t = c.split(";")[0],
|
||||||
|
i = t.indexOf("=");
|
||||||
|
cookies[t.slice(0, i)] = t.slice(i + 1);
|
||||||
|
}
|
||||||
|
let raw = await r.text();
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
data = raw.slice(0, 300);
|
||||||
|
}
|
||||||
|
return { status: r.status, data };
|
||||||
|
}
|
||||||
|
const post = (p, b) =>
|
||||||
|
request(p, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(b),
|
||||||
|
});
|
||||||
|
(async () => {
|
||||||
|
const a = JSON.parse(fs.readFileSync(path.join(q, "account.json")));
|
||||||
|
let csrf = await request("/api/auth/csrf");
|
||||||
|
assert.equal(csrf.status, 200, JSON.stringify(csrf));
|
||||||
|
await request("/api/auth/callback/credentials", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
"X-Auth-Return-Redirect": "1",
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
csrfToken: csrf.data.csrfToken,
|
||||||
|
email: a.email,
|
||||||
|
password: a.password,
|
||||||
|
callbackUrl: base + "/video-canvas",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
let session = await request("/api/auth/session");
|
||||||
|
assert.equal(session.data.user?.id, a.id, "real login");
|
||||||
|
fs.writeFileSync(path.join(q, "cookies.json"), JSON.stringify(cookies), { mode: 0o600 });
|
||||||
|
console.log("LOGIN_OK");
|
||||||
|
let cap = await request("/api/canvas/smart-edit/capabilities");
|
||||||
|
assert.equal(cap.data.capabilities.asr, true);
|
||||||
|
assert.equal(cap.data.capabilities.separation, true);
|
||||||
|
console.log("CAPABILITIES_OK");
|
||||||
|
let project = await post("/api/canvas", {
|
||||||
|
title: "智能剪辑本地全链路验收0918",
|
||||||
|
workspaceType: "video",
|
||||||
|
});
|
||||||
|
assert.equal(project.status, 200, JSON.stringify(project));
|
||||||
|
let id = project.data.data.id;
|
||||||
|
assert(
|
||||||
|
Math.abs(Date.now() - new Date(project.data.data.createdAt).getTime()) < 60000,
|
||||||
|
"new canvas timestamp must agree with task timestamps",
|
||||||
|
);
|
||||||
|
fs.writeFileSync(path.join(q, "project-id.txt"), id);
|
||||||
|
let form = new FormData();
|
||||||
|
form.append(
|
||||||
|
"file",
|
||||||
|
new Blob([fs.readFileSync(path.join(q, "speech.mp4"))], { type: "video/mp4" }),
|
||||||
|
"测试中文视频.mp4",
|
||||||
|
);
|
||||||
|
let uploaded = await request("/api/canvas/media/upload", { method: "POST", body: form });
|
||||||
|
assert.equal(uploaded.status, 200, JSON.stringify(uploaded));
|
||||||
|
let asset = {
|
||||||
|
id: "qa-video",
|
||||||
|
name: "中文实测视频",
|
||||||
|
url: uploaded.data.data.url,
|
||||||
|
subtitles: [],
|
||||||
|
};
|
||||||
|
console.log("UPLOAD_OK");
|
||||||
|
const node = {
|
||||||
|
id: "qa-edit",
|
||||||
|
title: "智能剪辑全链路验收",
|
||||||
|
x: 200,
|
||||||
|
y: 200,
|
||||||
|
width: 440,
|
||||||
|
height: 350,
|
||||||
|
kind: "text",
|
||||||
|
inputType: "text",
|
||||||
|
draftValues: { action: "SMART_EDIT_WORKFLOW", nodeType: "smart-edit" },
|
||||||
|
};
|
||||||
|
async function task(params, target = node) {
|
||||||
|
let req = {
|
||||||
|
requestId: crypto.randomUUID(),
|
||||||
|
projectId: id,
|
||||||
|
kind: "smart-edit",
|
||||||
|
node: target,
|
||||||
|
params,
|
||||||
|
edges: [],
|
||||||
|
};
|
||||||
|
let start = Date.now();
|
||||||
|
let r = await post("/api/canvas/generation-tasks", req);
|
||||||
|
assert.equal(r.status, 202, JSON.stringify(r));
|
||||||
|
let repeat = await post("/api/canvas/generation-tasks", req);
|
||||||
|
assert.equal(repeat.data.task.id, r.data.task.id, "idempotent task");
|
||||||
|
while (Date.now() - start < 900000) {
|
||||||
|
await new Promise((r) => setTimeout(r, 1500));
|
||||||
|
let all = await request("/api/canvas/generation-tasks?projectId=" + id);
|
||||||
|
assert.equal(all.status, 200);
|
||||||
|
let t = all.data.tasks.find((t) => t.requestId === req.requestId);
|
||||||
|
if (t && ["succeeded", "failed", "interrupted"].includes(t.status)) {
|
||||||
|
console.log(
|
||||||
|
params.operation,
|
||||||
|
t.status,
|
||||||
|
t.error || "",
|
||||||
|
((Date.now() - start) / 1000).toFixed(1) + "s",
|
||||||
|
);
|
||||||
|
report.push({
|
||||||
|
operation: params.operation,
|
||||||
|
status: t.status,
|
||||||
|
seconds: (Date.now() - start) / 1000,
|
||||||
|
});
|
||||||
|
assert.equal(t.status, "succeeded", t.error);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(q, params.operation + "-task.json"),
|
||||||
|
JSON.stringify(t, null, 2),
|
||||||
|
);
|
||||||
|
return t.result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw Error("timeout " + params.operation);
|
||||||
|
}
|
||||||
|
let result = await task({
|
||||||
|
operation: "plan",
|
||||||
|
mode: "sequence",
|
||||||
|
assets: [asset],
|
||||||
|
targetDuration: 300,
|
||||||
|
semantic: false,
|
||||||
|
resolution: "480p",
|
||||||
|
});
|
||||||
|
let plan = result.plans[0];
|
||||||
|
result = await task({ operation: "separate", plan, clipId: plan.clips[0].id });
|
||||||
|
plan = result.plans[0];
|
||||||
|
assert.equal(plan.audioClips.length, 2);
|
||||||
|
assert.equal(plan.clips[0].audioDetached, true);
|
||||||
|
assert.equal(plan.assets.length, 3);
|
||||||
|
result = await task({ operation: "transcribe", plan });
|
||||||
|
plan = result.plans[0];
|
||||||
|
assert(plan.captions.some((s) => s.text.includes("智能剪辑")));
|
||||||
|
result = await task({
|
||||||
|
operation: "separate",
|
||||||
|
plan,
|
||||||
|
clipId: plan.audioClips[0].id,
|
||||||
|
track: "audio",
|
||||||
|
});
|
||||||
|
plan = result.plans[0];
|
||||||
|
assert.equal(plan.audioClips.length, 3, "independent audio separation");
|
||||||
|
for (const keep of ["vocals", "background"]) {
|
||||||
|
const processed = await task({
|
||||||
|
operation: "separate",
|
||||||
|
plan,
|
||||||
|
clipId: plan.audioClips[0].id,
|
||||||
|
track: "audio",
|
||||||
|
keep,
|
||||||
|
});
|
||||||
|
const next = processed.plans[0];
|
||||||
|
assert.equal(next.audioClips.length, plan.audioClips.length);
|
||||||
|
const added = next.audioClips.filter(
|
||||||
|
(c) => !plan.audioClips.some((old) => old.id === c.id),
|
||||||
|
);
|
||||||
|
assert.equal(added.length, 1);
|
||||||
|
assert(
|
||||||
|
next.assets
|
||||||
|
.find((a) => a.id === added[0].assetId)
|
||||||
|
.name.endsWith(keep === "vocals" ? "人声" : "背景声"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
plan.captions[0].style = { ...plan.captions[0].style, fontFamily: "catalog-0006-font" };
|
||||||
|
let render = await task(
|
||||||
|
{ operation: "render", plan, sourceNodeId: "qa-edit" },
|
||||||
|
{ ...node, id: "qa-render", title: "本地验收成片", kind: "media", inputType: "video" },
|
||||||
|
);
|
||||||
|
let media = await fetch(render.url);
|
||||||
|
assert.equal(media.status, 200);
|
||||||
|
fs.writeFileSync(path.join(q, "main-result.mp4"), Buffer.from(await media.arrayBuffer()));
|
||||||
|
let srt = await fetch(render.subtitleUrl);
|
||||||
|
assert.equal(srt.status, 200);
|
||||||
|
assert((await srt.text()).includes("智能剪辑"));
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(q, "main-result.json"),
|
||||||
|
JSON.stringify({ projectId: id, plan, render, report }, null, 2),
|
||||||
|
);
|
||||||
|
console.log("MAIN_CHAIN_OK", JSON.stringify(report));
|
||||||
|
})().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
30
scripts/qa/smart-edit-models.py
Normal file
30
scripts/qa/smart-edit-models.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
"""Real local ASR + separation regression. Pass an actual Chinese speech WAV fixture.
|
||||||
|
Usage: SMART_EDIT_*_MODEL=... python scripts/qa/smart-edit-models.py speech.wav output-dir
|
||||||
|
No provider calls; the input and all derived files stay local.
|
||||||
|
"""
|
||||||
|
import json,sys,subprocess,os
|
||||||
|
from pathlib import Path
|
||||||
|
import numpy as np
|
||||||
|
import soundfile as sf
|
||||||
|
sys.path.insert(0,str(Path('services/smart-edit').resolve()))
|
||||||
|
from analyze import analyze
|
||||||
|
from separate import separate
|
||||||
|
source=Path(sys.argv[1]).resolve();root=Path(sys.argv[2]).resolve();root.mkdir(parents=True,exist_ok=True)
|
||||||
|
subprocess.run(['ffmpeg','-v','error','-y','-i',str(source),'-ac','1','-ar','16000',str(root/'speech.wav')],check=True)
|
||||||
|
result=analyze({'audio':str(root/'speech.wav'),'transcribe':True})
|
||||||
|
text=''.join(s['text'] for s in result['subtitles']);assert '智能剪辑' in text and '人声分离' in text,text
|
||||||
|
assert len(result['subtitles'])>=2,'Sentence timestamps must be enabled'
|
||||||
|
assert all(0<=s['start']<s['end'] for s in result['subtitles'])
|
||||||
|
subprocess.run(['ffmpeg','-v','error','-y','-i',str(source),'-ac','2','-ar','44100',str(root/'clean.wav')],check=True)
|
||||||
|
clean,sr=sf.read(root/'clean.wav',dtype='float32',always_2d=True)
|
||||||
|
t=np.arange(len(clean))/sr
|
||||||
|
music=(.06*np.sin(2*np.pi*220*t)+.04*np.sin(2*np.pi*330*t))*(.6+.4*np.sin(2*np.pi*2*t)**2)
|
||||||
|
mix=clean+music[:,None];sf.write(root/'mixed.wav',mix,sr,subtype='FLOAT')
|
||||||
|
separate(str(root/'mixed.wav'),str(root))
|
||||||
|
v,_=sf.read(root/'vocals.wav',dtype='float32',always_2d=True);b,_=sf.read(root/'background.wav',dtype='float32',always_2d=True)
|
||||||
|
assert v.shape==b.shape==mix.shape
|
||||||
|
error=float(np.max(np.abs(v+b-mix)));assert error<1e-6,error
|
||||||
|
assert float(np.sqrt(np.mean(v*v)))>.005 and float(np.sqrt(np.mean(b*b)))>.005
|
||||||
|
assert not np.allclose(v,mix),'Separation must not return the original track'
|
||||||
|
report={'subtitles':result['subtitles'],'duration':len(clean)/sr,'stemReconstructionMaxError':error,'vocalRms':float(np.sqrt(np.mean(v*v))),'backgroundRms':float(np.sqrt(np.mean(b*b)))}
|
||||||
|
(root/'report.json').write_text(json.dumps(report,ensure_ascii=False,indent=2));print(json.dumps(report,ensure_ascii=False))
|
||||||
@ -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);
|
||||||
|
|||||||
@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
诊断:`$SMART_EDIT_PYTHON services/smart-edit/analyze.py --check`。API `/api/canvas/smart-edit/capabilities` 仅对登录用户返回可用功能与模型列表,不返回密钥或本地路径。ASR 未准备好时页面关闭自动转写选项,仍可导入 SRT 和进行画面混剪。
|
诊断:`$SMART_EDIT_PYTHON services/smart-edit/analyze.py --check`。API `/api/canvas/smart-edit/capabilities` 仅对登录用户返回可用功能与模型列表,不返回密钥或本地路径。ASR 未准备好时页面关闭自动转写选项,仍可导入 SRT 和进行画面混剪。
|
||||||
|
|
||||||
资源:整个智能剪辑作业通过 PostgreSQL advisory lock 限制为同一数据库下同时一个重处理任务,租约心跳沿用画布任务队列。Python/FFmpeg 限制线程数,单素材 ≤180 秒、≤125 MiB、至多6份,总素材≤10分钟,成片≤90秒、≤12片段;分析缓存按用户隔离,内容 hash 校验,7天过期、4GiB上限。低清代理仅用于分析,成片使用原素材。数据库锁繁忙时任务保存为可恢复状态,页面轮询或返回画布会继续运行。
|
资源:整个智能剪辑作业通过 PostgreSQL advisory lock 限制为同一数据库下同时一个重处理任务,租约心跳沿用画布任务队列。Python/FFmpeg 限制线程数,单素材 ≤180 秒、≤125 MiB、至多12份,总原始素材≤10分钟,成片≤300秒、≤12片段;分析缓存按用户隔离,内容 hash 校验,7天过期、4GiB上限。低清代理仅用于分析,成片使用原素材。数据库锁繁忙时任务保存为可恢复状态,页面轮询或返回画布会继续运行。
|
||||||
|
|
||||||
算法:PySceneDetect 检测镜头,librosa 检测节拍,FunASR 返回语句时间戳(采用 FunClip 的时间戳剪辑思路)。去静音为独立实现的 RMS 阈值算法,参考 Auto-Editor 的粗剪与余量机制;没有复制 Auto-Editor 或 FunClip 源码,也不依赖它们的 UI。来源与许可证记录在项目 docs/video-canvas-smart-edit-research-2026-09-17.md。PySceneDetect 为 BSD-3-Clause,librosa 为 ISC,FunASR 源码和各权重许可应按所安装版本一并保留。
|
算法:PySceneDetect 检测镜头,librosa 检测节拍,FunASR 返回语句时间戳(采用 FunClip 的时间戳剪辑思路)。去静音为独立实现的 RMS 阈值算法,参考 Auto-Editor 的粗剪与余量机制;没有复制 Auto-Editor 或 FunClip 源码,也不依赖它们的 UI。来源与许可证记录在项目 docs/video-canvas-smart-edit-research-2026-09-17.md。PySceneDetect 为 BSD-3-Clause,librosa 为 ISC,FunASR 源码和各权重许可应按所安装版本一并保留。
|
||||||
|
|
||||||
@ -22,3 +22,19 @@
|
|||||||
|
|
||||||
|
|
||||||
远程合成:设置 `SMART_EDIT_RENDER_URL` 与 `SMART_EDIT_RENDER_TOKEN` 后,render操作进入独立API,不占用本地主处理锁。独立服务部署见 `../video-render/README.md`,其千问仅提供可选文字能力。分析操作仍使用本机Python/代理文件。
|
远程合成:设置 `SMART_EDIT_RENDER_URL` 与 `SMART_EDIT_RENDER_TOKEN` 后,render操作进入独立API,不占用本地主处理锁。独立服务部署见 `../video-render/README.md`,其千问仅提供可选文字能力。分析操作仍使用本机Python/代理文件。
|
||||||
|
|
||||||
|
## 人声分离、转写和完整字体库(2026-09-18)
|
||||||
|
|
||||||
|
`bash services/smart-edit/setup.sh --with-all` 在独立环境安装全部依赖。也可使用 `--with-separation` 或 `--with-asr`。
|
||||||
|
|
||||||
|
- **人声分离**:`prepare_separation.py` 下载并校验官方 HTDemucs 权重。将输出的 `SMART_EDIT_SEPARATION_MODEL_DIR` 加入应用环境。请求阶段离线运行,CPU 2线程、7秒分块、每次最多180秒;支持仅保留人声、仅保留背景音、输出两个音轨,保持裁剪、变速、音量、静音及转场包络。视频原声自动关闭,独立音轨可继续分离;失败保留原内容。
|
||||||
|
- **自动转写**:字幕列表直接提供入口。FunASR 同时开启 `pred_timestamp` 与 `sentence_timestamp`;时间戳限制在素材范围内,再映射至剪辑时间轴。保留手动字幕,重试只替换此前自动生成的字幕。任务存数据库,可退出后再打开;处理完成前锁住当前编辑,避免覆盖并行改动。
|
||||||
|
- **字体库**:对应参考站2002项的目录见 `lib/video-canvas/smart-edit/font-catalog.json`(1997项)及 `fonts.ts`(原有5项)。已逐一下载、解析和校验,完整新增字体约757MiB。原始与处理后SHA-256均固定;保留字体内版权/许可字段。浏览器和FFmpeg使用同一处理后文件,独立字体族名避免粗细变体串字,变量字体固定为选定默认实例。两款参考字体的OS/2版本声明与实际长度不一致,按已有字段修复为v4,不改变字形轮廓。
|
||||||
|
- **安装字体**:默认按选择懒加载,需Python的`requirements-fonts.txt`。部署前推荐执行 `$SMART_EDIT_PYTHON services/smart-edit/prepare_fonts.py` 预热全部目录,或者复制已校验的字体缓存。`SMART_EDIT_FONT_CACHE_DIR` 指定共享缓存目录;`SMART_EDIT_FONT_PYTHON` 可单独指定字体Python。原有5款字体仍随应用/合成服务打包。首次下载失败会明确报错,不静默替换成默认字体。
|
||||||
|
- **远程合成字体**:独立合成服务同样安装字体依赖,设置同一缓存或预热目录;构建时已带上字体转换脚本及固定目录。`SMART_EDIT_FONT_SCRIPT` 可覆盖脚本位置。ASR/分离仍在主应用配置的处理器运行,合成远程执行。
|
||||||
|
|
||||||
|
当前限制:视频素材最多12份,每份180秒;成片最长300秒,最多12视频片段、24独立音频片段。含派生分离轨最多36份资源、累计1800秒。实际模型耗时随硬件和素材变化,合成字体的抗锯齿仍受浏览器与libass渲染器影响。
|
||||||
|
|
||||||
|
真实模型回归:提供含“智能剪辑”和“人声分离”的中文WAV,运行 `$SMART_EDIT_PYTHON scripts/qa/smart-edit-models.py input.wav .qa/model-check`。验证实际语音识别、逐句时间戳、真实混音分离及两轨重构误差,无付费模型调用。
|
||||||
|
|
||||||
|
本地主站全链路:在隔离数据库与存储环境启动真实Next,准备测试账号JSON和中文视频后,执行 `SMART_EDIT_QA_DIR=.qa/isolated-main-chain node scripts/qa/smart-edit-main-chain.cjs`。脚本只允许本地地址,验证登录、上传、方案、分离三个分支、转写、实际字幕字体成片和SRT。完整记录见 `docs/smart-edit-completion-2026-09-18.md`。
|
||||||
|
|||||||
@ -14,9 +14,10 @@ for key in ('OMP_NUM_THREADS', 'OPENBLAS_NUM_THREADS', 'MKL_NUM_THREADS', 'NUMBA
|
|||||||
|
|
||||||
def capabilities():
|
def capabilities():
|
||||||
import shutil
|
import shutil
|
||||||
return {'scenes': importlib.util.find_spec('scenedetect') is not None,
|
from separate import available
|
||||||
|
return {'separation': available(), 'scenes': importlib.util.find_spec('scenedetect') is not None,
|
||||||
'beats': importlib.util.find_spec('librosa') is not None,
|
'beats': importlib.util.find_spec('librosa') is not None,
|
||||||
'asr': importlib.util.find_spec('funasr') is not None and all(Path(os.environ.get(k, '/nonexistent')).is_dir() for k in ('SMART_EDIT_ASR_MODEL','SMART_EDIT_VAD_MODEL','SMART_EDIT_PUNC_MODEL')),
|
'asr': importlib.util.find_spec('funasr') is not None and all(all((Path(os.environ.get(k, '/nonexistent'))/f).is_file() for f in ('model.pt','config.yaml')) for k in ('SMART_EDIT_ASR_MODEL','SMART_EDIT_VAD_MODEL','SMART_EDIT_PUNC_MODEL')),
|
||||||
'ffmpeg': shutil.which('ffmpeg') is not None,
|
'ffmpeg': shutil.which('ffmpeg') is not None,
|
||||||
'ffprobe': shutil.which('ffprobe') is not None}
|
'ffprobe': shutil.which('ffprobe') is not None}
|
||||||
|
|
||||||
@ -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
|
||||||
|
|||||||
35
services/smart-edit/font.py
Normal file
35
services/smart-edit/font.py
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
"""Freeze a font's default variation and give it a unique preview/export family name."""
|
||||||
|
import sys
|
||||||
|
from fontTools.ttLib import TTFont, newTable
|
||||||
|
from fontTools.varLib.instancer import instantiateVariableFont
|
||||||
|
|
||||||
|
def prepare(source, destination, family):
|
||||||
|
font = TTFont(source, recalcTimestamp=False)
|
||||||
|
if 'fvar' in font:
|
||||||
|
axes = {axis.axisTag: axis.defaultValue for axis in font['fvar'].axes}
|
||||||
|
for axis in font['fvar'].axes:
|
||||||
|
if axis.axisTag == 'wght':
|
||||||
|
axes['wght'] = max(axis.minValue, min(axis.maxValue, 400))
|
||||||
|
font = instantiateVariableFont(font, axes, inplace=True)
|
||||||
|
for record in font['name'].names:
|
||||||
|
if record.nameID in (1, 2, 3, 4, 6, 16, 17):
|
||||||
|
value = 'Regular' if record.nameID in (2, 17) else family
|
||||||
|
if record.nameID == 6:
|
||||||
|
value = family.replace(' ', '-')[:63]
|
||||||
|
record.string = value.encode(record.getEncoding(), errors='replace')
|
||||||
|
# Two reference fonts declare OS/2 v5 but only contain the 96-byte v4 table.
|
||||||
|
# Correct the version tag without inventing missing metrics or changing outlines.
|
||||||
|
metrics = font.reader['OS/2']
|
||||||
|
if len(metrics) == 96 and int.from_bytes(metrics[:2], 'big') == 5:
|
||||||
|
table = newTable('OS/2')
|
||||||
|
table.decompile(b'\x00\x04' + metrics[2:], font)
|
||||||
|
font['OS/2'] = table
|
||||||
|
# A unique family per selected face prevents fontconfig confusing weight variants.
|
||||||
|
font['OS/2'].fsSelection &= ~((1 << 0) | (1 << 5))
|
||||||
|
font['OS/2'].fsSelection |= 1 << 6
|
||||||
|
font['OS/2'].usWeightClass = 400
|
||||||
|
font['head'].macStyle &= ~3
|
||||||
|
font.save(destination)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
prepare(*sys.argv[1:4])
|
||||||
23
services/smart-edit/prepare_fonts.py
Normal file
23
services/smart-edit/prepare_fonts.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
"""Prewarm the audited font catalogue. Downloads and outputs are checksum verified."""
|
||||||
|
import concurrent.futures,hashlib,json,os,tempfile,urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
from font import prepare
|
||||||
|
root=Path(os.environ.get('SMART_EDIT_FONT_CACHE_DIR','.cache/smart-edit-fonts')).resolve();root.mkdir(parents=True,exist_ok=True)
|
||||||
|
catalog=json.loads(Path('lib/video-canvas/smart-edit/font-catalog.json').read_text())
|
||||||
|
def digest(file):return hashlib.sha256(file.read_bytes()).hexdigest()
|
||||||
|
def install(font):
|
||||||
|
output=root/font['file']
|
||||||
|
if output.is_file() and digest(output)==font['preparedSha256']:return
|
||||||
|
with tempfile.TemporaryDirectory(dir=root) as work:
|
||||||
|
raw=Path(work)/'source';prepared=Path(work)/'prepared.ttf'
|
||||||
|
with urllib.request.urlopen(font['url'],timeout=90) as response:
|
||||||
|
data=response.read(40*1024*1024+1)
|
||||||
|
if len(data)>40*1024*1024:raise ValueError('字体超过大小上限')
|
||||||
|
raw.write_bytes(data)
|
||||||
|
if digest(raw)!=font['sha256']:raise ValueError('原始字体校验失败: '+font['name'])
|
||||||
|
prepare(str(raw),str(prepared),font['family'])
|
||||||
|
if digest(prepared)!=font['preparedSha256']:raise ValueError('字体处理版本不匹配: '+font['name'])
|
||||||
|
prepared.replace(output)
|
||||||
|
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as pool:
|
||||||
|
list(pool.map(install,catalog))
|
||||||
|
print(json.dumps({'fonts':len(catalog)+5,'catalogueFonts':len(catalog),'cache':str(root)},ensure_ascii=False))
|
||||||
18
services/smart-edit/prepare_separation.py
Normal file
18
services/smart-edit/prepare_separation.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
"""Install only the checksum-pinned official HTDemucs checkpoint, outside request handling."""
|
||||||
|
import hashlib,json,os,urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
from separate import CHECKSUM
|
||||||
|
root=Path(os.environ.get('SMART_EDIT_SEPARATION_MODEL_DIR','.smart-edit-models/demucs')).resolve()
|
||||||
|
root.mkdir(parents=True,exist_ok=True)
|
||||||
|
file=root/'955717e8-8726e21a.th'
|
||||||
|
url='https://dl.fbaipublicfiles.com/demucs/hybrid_transformer/955717e8-8726e21a.th'
|
||||||
|
if not file.exists() or hashlib.sha256(file.read_bytes()).hexdigest()!=CHECKSUM:
|
||||||
|
temp=file.with_suffix('.part')
|
||||||
|
try:
|
||||||
|
urllib.request.urlretrieve(url,temp)
|
||||||
|
if hashlib.sha256(temp.read_bytes()).hexdigest()!=CHECKSUM:
|
||||||
|
raise RuntimeError('HTDemucs 权重校验失败')
|
||||||
|
temp.replace(file)
|
||||||
|
finally:
|
||||||
|
temp.unlink(missing_ok=True)
|
||||||
|
print(json.dumps({'SMART_EDIT_SEPARATION_MODEL_DIR':str(root)},ensure_ascii=False))
|
||||||
@ -3,3 +3,5 @@ funasr==1.4.15
|
|||||||
torch==2.6.0
|
torch==2.6.0
|
||||||
torchaudio==2.6.0
|
torchaudio==2.6.0
|
||||||
modelscope==1.30.0
|
modelscope==1.30.0
|
||||||
|
# Verified with Paraformer sentence/character timestamps.
|
||||||
|
transformers==5.17.0
|
||||||
|
|||||||
2
services/smart-edit/requirements-fonts.txt
Normal file
2
services/smart-edit/requirements-fonts.txt
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
fonttools==4.59.1
|
||||||
|
brotli==1.2.0
|
||||||
4
services/smart-edit/requirements-separation.txt
Normal file
4
services/smart-edit/requirements-separation.txt
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
torch==2.6.0
|
||||||
|
torchaudio==2.6.0
|
||||||
|
demucs==4.0.1
|
||||||
59
services/smart-edit/separate.py
Normal file
59
services/smart-edit/separate.py
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
"""Offline, bounded HTDemucs inference. Model installation is a separate operator step."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
for key in ('OMP_NUM_THREADS', 'OPENBLAS_NUM_THREADS', 'MKL_NUM_THREADS'):
|
||||||
|
os.environ[key] = '2'
|
||||||
|
CHECKSUM = "8726e21a993978c7ba086d3872e7608d7d5bfca646ca4aca459ffda844faa8b4"
|
||||||
|
SIGNATURE = '955717e8'
|
||||||
|
|
||||||
|
def available():
|
||||||
|
import importlib.util
|
||||||
|
root = Path(os.environ.get('SMART_EDIT_SEPARATION_MODEL_DIR', '/nonexistent'))
|
||||||
|
return importlib.util.find_spec('demucs') is not None and any(root.glob(SIGNATURE + '-*.th'))
|
||||||
|
|
||||||
|
def separate(source, destination):
|
||||||
|
if not available():
|
||||||
|
raise RuntimeError('人声分离模型尚未配置,请安装 Demucs 依赖和官方 HTDemucs 权重。')
|
||||||
|
import numpy as np
|
||||||
|
import soundfile as sf
|
||||||
|
import torch
|
||||||
|
from demucs.states import load_model
|
||||||
|
import hashlib
|
||||||
|
from demucs.apply import apply_model
|
||||||
|
torch.set_num_threads(2)
|
||||||
|
torch.manual_seed(0)
|
||||||
|
audio, sr = sf.read(source, dtype='float32', always_2d=True)
|
||||||
|
if sr != 44100 or audio.shape[1] != 2 or not 0 < len(audio) <= sr * 180:
|
||||||
|
raise ValueError('分离输入必须为 180 秒以内的 44.1kHz 双声道音频')
|
||||||
|
if not np.isfinite(audio).all():
|
||||||
|
raise ValueError('音频包含无效采样')
|
||||||
|
checkpoint = next(Path(os.environ['SMART_EDIT_SEPARATION_MODEL_DIR']).glob(SIGNATURE + '-*.th'))
|
||||||
|
if hashlib.sha256(checkpoint.read_bytes()).hexdigest() != CHECKSUM:
|
||||||
|
raise ValueError('人声分离权重校验失败')
|
||||||
|
# Official checkpoint verified above; Demucs 4 uses a pickled model class.
|
||||||
|
model = load_model(torch.load(checkpoint, map_location='cpu', weights_only=False))
|
||||||
|
model.eval()
|
||||||
|
wave = torch.from_numpy(audio.T.copy())
|
||||||
|
reference = wave.mean(0)
|
||||||
|
mean, std = reference.mean(), reference.std()
|
||||||
|
if std < 1e-8:
|
||||||
|
vocals = np.zeros_like(audio)
|
||||||
|
else:
|
||||||
|
with torch.inference_mode():
|
||||||
|
output = apply_model(model, ((wave - mean) / std)[None], device='cpu',
|
||||||
|
shifts=1, split=True, overlap=.25, segment=7,
|
||||||
|
progress=False, num_workers=0)[0]
|
||||||
|
vocals = (output[model.sources.index('vocals')] * std + mean).T.numpy()
|
||||||
|
# Complementary stems reconstruct the original exactly, with no independent gain normalization.
|
||||||
|
destination = Path(destination)
|
||||||
|
destination.mkdir(parents=True, exist_ok=True)
|
||||||
|
sf.write(destination / 'vocals.wav', vocals, sr, subtype='FLOAT')
|
||||||
|
sf.write(destination / 'background.wav', audio - vocals, sr, subtype='FLOAT')
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
if sys.argv[1:] == ['--check']:
|
||||||
|
print(json.dumps({'separation': available()}))
|
||||||
|
else:
|
||||||
|
separate(*sys.argv[1:3])
|
||||||
@ -4,9 +4,13 @@ set -euo pipefail
|
|||||||
smart_env="${SMART_EDIT_ENV_DIR:-$PWD/.venv-smart-edit}"
|
smart_env="${SMART_EDIT_ENV_DIR:-$PWD/.venv-smart-edit}"
|
||||||
smart_python="${SMART_EDIT_SETUP_PYTHON:-python3.11}"
|
smart_python="${SMART_EDIT_SETUP_PYTHON:-python3.11}"
|
||||||
"$smart_python" -m venv "$smart_env"
|
"$smart_python" -m venv "$smart_env"
|
||||||
"$smart_env/bin/python" -m pip install -r services/smart-edit/requirements.txt
|
"$smart_env/bin/python" -m pip install -r services/smart-edit/requirements.txt -r services/smart-edit/requirements-fonts.txt
|
||||||
if [[ "${1:-}" == "--with-asr" ]]; then
|
if [[ " $* " == *" --with-asr "* || " $* " == *" --with-all "* ]]; then
|
||||||
"$smart_env/bin/python" -m pip install -r services/smart-edit/requirements-asr.txt
|
"$smart_env/bin/python" -m pip install -r services/smart-edit/requirements-asr.txt
|
||||||
"$smart_env/bin/python" services/smart-edit/prepare_models.py
|
"$smart_env/bin/python" services/smart-edit/prepare_models.py
|
||||||
fi
|
fi
|
||||||
|
if [[ " $* " == *" --with-separation "* || " $* " == *" --with-all "* ]]; then
|
||||||
|
"$smart_env/bin/python" -m pip install -r services/smart-edit/requirements-separation.txt
|
||||||
|
"$smart_env/bin/python" services/smart-edit/prepare_separation.py
|
||||||
|
fi
|
||||||
SMART_EDIT_PYTHON="$smart_env/bin/python" "$smart_env/bin/python" services/smart-edit/analyze.py --check
|
SMART_EDIT_PYTHON="$smart_env/bin/python" "$smart_env/bin/python" services/smart-edit/analyze.py --check
|
||||||
|
|||||||
@ -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));
|
||||||
|
|||||||
@ -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 || "";
|
||||||
|
|||||||
@ -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();
|
||||||
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user