Fix smart edit captions, audio gain and timeline parity

This commit is contained in:
134 2026-09-18 09:12:28 +08:00
parent 68f62dae58
commit e18413a4dd
35 changed files with 2931 additions and 316 deletions

View File

@ -1,4 +1,5 @@
"use client";
import { resolveSmartEditIntent, assertBeatMusic } from "@/lib/video-canvas/smart-edit/intent";
import { SmartEditDialog } from "./smart-edit/SmartEditDialog";
import { SmartEditNodeCard, SmartEditNodeComposer } from "./smart-edit/SmartEditNode";
import { smartEditRequestSchema, type EditPlan } from "@/lib/video-canvas/smart-edit/contract";
@ -10379,6 +10380,7 @@ export function LiblibVideoCanvasReplica({
anchor={selectedTargetAnchor}
draft={(selectedNode.draftValues?.smartEditDraft || {}) as Record<string, any>}
sources={selectedTargetSourceNodes.filter(node => node.inputType === "video" && getNodeReferenceMediaUrl(node)).map(node => ({id:node.id,name:node.title,url:getNodeReferenceMediaUrl(node),duration:getVideoNodeDurationSeconds(node)}))}
availableAudios={nodes.filter(node => node.inputType === "audio" && getNodeReferenceMediaUrl(node)).map(node => ({name:node.title,url:getNodeReferenceMediaUrl(node)}))}
availableSources={nodes.filter(node => node.id !== selectedNode.id && node.inputType === "video" && getNodeReferenceMediaUrl(node)).map(node => ({id:node.id,name:node.title,url:getNodeReferenceMediaUrl(node),duration:getVideoNodeDurationSeconds(node)}))}
busy={generationTasks.tasks.some(task => task.request.node.id === selectedNode.id && (task.status === "pending" || task.status === "running"))}
onDraft={patch => setNodes(current => current.map(node => node.id === selectedNode.id ? {...node,draftValues:{...node.draftValues,smartEditDraft:{...(node.draftValues?.smartEditDraft as Record<string,unknown> || {}),...patch}}} : node))}
@ -10390,7 +10392,8 @@ export function LiblibVideoCanvasReplica({
onReference={() => setLineConnectState({sourceId:null,targetId:selectedNode.id,cursorCanvasX:selectedNode.x-80,cursorCanvasY:selectedNode.y+selectedNode.height/2})}
onUpload={files => addUploadedFilesAtPoint(files,{x:selectedNode.x-600,y:selectedNode.y},selectedNode.id)}
onSubmit={async () => {
const draft = (selectedNode.draftValues?.smartEditDraft || {}) as Record<string,any>;
const draft = resolveSmartEditIntent((selectedNode.draftValues?.smartEditDraft || {}) as Record<string,any>);
assertBeatMusic(draft);
const assets = selectedTargetSourceNodes.filter(node => node.inputType === "video" && getNodeReferenceMediaUrl(node)).map(node => ({id:node.id,name:node.title,url:getNodeReferenceMediaUrl(node),subtitles:[]}));
const params = smartEditRequestSchema.parse({ratio:"16:9",resolution:"1080p",targetDuration:20,...draft,mode:["auto","montage","talk","explain","ads"].includes(draft.mode)?draft.mode:"auto",semantic:true,operation:"plan",assets,preferredModelRef:videoCanvasFunctionConfig.defaultMultimodalModel});
const requestId = await generationTasks.submit({...selectedNode,draftValues:{...selectedNode.draftValues,action:"SMART_EDIT_WORKFLOW",nodeType:"smart-edit"}},"smart-edit",params,edges.filter(edge => edge.target === selectedNode.id));
@ -11030,6 +11033,7 @@ export function LiblibVideoCanvasReplica({
onClose={()=>setSmartEditNodeId(null)}
onDraft={smartEditDraft=>setNodes(current=>current.map(node=>node.id===smartEditNode.id?{...node,draftValues:{...node.draftValues,smartEditDraft}}:node))}
onPlan={params=>generationTasks.submit({...smartEditNode,draftValues:{...smartEditNode.draftValues,action:"SMART_EDIT_WORKFLOW",nodeType:"smart-edit"}},"smart-edit",params,edges.filter(edge=>edge.target===smartEditNode.id))}
onCancel={generationTasks.cancel}
onRender={async plan=>{
const result=createTargetModuleNode("video",smartEditNode.x+550,smartEditNode.y+generationTasks.tasks.filter(task=>task.request.params.sourceNodeId===smartEditNode.id).length*80,nodes.length+1);
result.title=`${plan.title} · 成片`;
@ -18651,7 +18655,11 @@ function TargetModuleCanvasNode({
onAction: (actionLabel: TargetModuleActionLabel) => void;
selected: boolean;
}) {
if (kind === "smart-edit") return <SmartEditNodeCard selected={selected} title={node.title} previewUrl={previewUrl} onOpen={() => onAction("打开智能剪辑")} />;
if (kind === "smart-edit") {
const draft = node.draftValues?.smartEditDraft as Record<string, any> | undefined;
const plans = draft?.planTaskId === node.draftValues?.smartEditPlanTaskId ? draft?.plans || node.draftValues?.smartEditPlans : node.draftValues?.smartEditPlans;
return <SmartEditNodeCard plan={(plans as EditPlan[] | undefined)?.[draft?.selectedPlanIndex || 0]} selected={selected} title={node.title} previewUrl={previewUrl} onOpen={() => onAction("打开智能剪辑")} />;
}
const HeaderIcon = kind === "video-clip" ? Scissors : kind === "director" ? Settings2 : kind === "audio" ? Music2 : kind === "video" ? Video : kind === "image" ? ImageIcon : FileText;
const actions: Record<AddModuleKind, Array<{ icon: LucideIcon | "hd"; label: TargetModuleActionLabel }>> = {
"smart-edit": [{icon:Scissors,label:"打开智能剪辑"}],

View File

@ -0,0 +1,123 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Check, ChevronDown, Search } from "lucide-react";
import { captionFonts, captionFont } from "@/lib/video-canvas/smart-edit/fonts";
import type { CaptionStyle } from "@/lib/video-canvas/smart-edit/contract";
const loaded = new Map<string, Promise<FontFace>>();
export function loadCaptionFont(id: string) {
const font = captionFont(id);
if (!font) return Promise.resolve();
if (!loaded.has(id)) {
const face = new FontFace(font.family, `url(/fonts/smart-edit/${font.file})`, {
weight: "400",
});
loaded.set(
id,
face
.load()
.then((loadedFace) => {
document.fonts.add(loadedFace);
return loadedFace;
})
.catch((error) => {
loaded.delete(id);
throw error;
}),
);
}
return loaded.get(id)!;
}
export function CaptionFontPicker({
value,
onChange,
onError,
}: {
value: CaptionStyle["fontFamily"];
onChange: (id: NonNullable<CaptionStyle["fontFamily"]>) => void;
onError: (message: string) => void;
}) {
const [open, setOpen] = useState(false),
[query, setQuery] = useState(""),
[category, setCategory] = useState("全部"),
[busy, setBusy] = useState(false);
const root = useRef<HTMLDivElement>(null);
useEffect(() => {
const close = (e: globalThis.PointerEvent) => {
if (!root.current?.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("pointerdown", close);
return () => document.removeEventListener("pointerdown", close);
}, []);
const options = captionFonts.filter(
(font) =>
font.name.toLowerCase().includes(query.toLowerCase()) &&
(category === "全部" ||
(category === "常用" && font.common) ||
(category === "推荐" && font.recommended) ||
(category === "Google" && font.google)),
);
return (
<div className="se-font-picker" ref={root}>
<button
className="se-font"
aria-label="字幕字体"
aria-expanded={open}
onClick={() => setOpen(!open)}
>
{captionFont(value)?.name || "默认字体"}
<ChevronDown size={14} />
</button>
{open && (
<div className="se-font-menu">
<div className="se-search">
<input
aria-label="搜索字体"
placeholder="搜索字体"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<Search size={14} />
</div>
<div className="se-font-categories">
{["全部", "常用", "推荐", "Google"].map((name) => (
<button
key={name}
aria-pressed={category === name}
onClick={() => setCategory(name)}
>
{name}
</button>
))}
</div>
<div role="listbox" aria-label="字体库">
{options.map((font) => (
<button
role="option"
aria-selected={value === font.id}
key={font.id}
disabled={busy}
onClick={async () => {
setBusy(true);
try {
await loadCaptionFont(font.id);
onChange(font.id);
setOpen(false);
} catch {
onError("字体加载失败,请检查网络后重试");
} finally {
setBusy(false);
}
}}
>
{font.name}
{value === font.id && <Check size={14} />}
</button>
))}
</div>
{!options.length && <div className="se-muted">没有匹配的字体</div>}
{busy && <div role="status">正在加载字体…</div>}
</div>
)}
</div>
);
}

View File

@ -1,5 +1,8 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { captionFont } from "@/lib/video-canvas/smart-edit/fonts";
import { loadCaptionFont } from "./CaptionFonts";
import { usePreviewGain } from "./usePreviewGain";
import {
editableCaptions,
defaultCaptionStyle,
@ -49,6 +52,27 @@ export function EditPreview({
audios = useRef(new Map<string, HTMLAudioElement>()),
latest = useRef({ time, plan, onTime, onPlaying });
latest.current = { time, plan, onTime, onPlaying };
const fontIds = [
...new Set(
editableCaptions(plan)
.map((c) => c.style?.fontFamily)
.filter(Boolean),
),
].join(",");
useEffect(() => {
let canceled = false;
void Promise.all(
[
...new Set([...fontIds.split(",").filter(Boolean), ...(fontIds ? ["source-han"] : [])]),
].map(loadCaptionFont),
).catch(() => {
if (!canceled) onError("字幕字体加载失败,请检查网络后重试");
});
return () => {
canceled = true;
};
}, [fontIds]);
const audioGain = usePreviewGain(plan, onError);
const frameRef = useRef<HTMLDivElement>(null);
const [frameSize, setFrameSize] = useState({ width: 0, height: 0 });
useEffect(() => {
@ -99,6 +123,7 @@ export function EditPreview({
};
}, [playing, externalClock]);
useEffect(() => {
audioGain.retain([...videos.current.values(), ...audios.current.values()]);
for (const clip of timeline.clips) {
const video = videos.current.get(clip.id);
if (!video) continue;
@ -124,8 +149,25 @@ export function EditPreview({
if (clip.overlap && time * SECOND < clip.transitionStart + clip.overlap)
gain = (time * SECOND - clip.transitionStart) / clip.overlap;
if (next?.overlap && time * SECOND > next.transitionStart)
gain = Math.min(gain, (next.transitionStart + next.overlap - time * SECOND) / next.overlap);
video.volume = clip.audioDetached || held ? 0 : Math.min(1, Math.max(0, clip.gain * gain));
gain = Math.min(
gain,
(next.transitionStart + next.overlap - time * SECOND) / next.overlap,
);
const source = plan.assets.find((a) => a.id === clip.assetId)!.url;
if (!audioGain.ready(source)) {
video.pause();
if (playing) {
audioGain.retry();
onPlaying(false);
onError("正在准备音量预览素材,请稍后点击播放");
}
continue;
}
audioGain.volume(
video,
clip.audioDetached || clip.muted || held ? 0 : clip.gain * gain,
playing,
);
if (playing && !held)
void video.play().catch((error) => {
if (error?.name === "AbortError") return;
@ -162,7 +204,17 @@ export function EditPreview({
((1 - (clip.fadeOutEnd || 0)) * (length - relative)) / fadeOut
: 1,
);
audio.volume = Math.max(0, Math.min(1, clip.gain * gain));
const source = plan.assets.find((a) => a.id === clip.assetId)!.url;
if (!audioGain.ready(source)) {
audio.pause();
if (playing) {
audioGain.retry();
onPlaying(false);
onError("正在准备音量预览素材,请稍后点击播放");
}
continue;
}
audioGain.volume(audio, clip.muted ? 0 : clip.gain * gain, playing);
if (playing)
void audio.play().catch((error) => {
if (error?.name === "AbortError") return;
@ -192,7 +244,6 @@ export function EditPreview({
const [ratioWidth, ratioHeight] = plan.ratio.split(":").map(Number);
const ratio = ratioWidth / ratioHeight;
const height = Math.min(frameSize.height || 720, (frameSize.width || 1280) / ratio);
const scale = height / 720;
const incomingClip = current.find(
(c) => c.overlap && visibleTime < c.transitionStart + c.overlap,
);
@ -277,7 +328,10 @@ export function EditPreview({
let value = { x: style.x ?? 0.5, y: style.y ?? 0.95 };
const move = (ev: PointerEvent) => {
value = {
x: Math.max(0, Math.min(1, (style.x ?? 0.5) + (ev.clientX - startX) / (height * ratio))),
x: Math.max(
0,
Math.min(1, (style.x ?? 0.5) + (ev.clientX - startX) / (height * ratio)),
),
y: Math.max(0, Math.min(1, (style.y ?? 0.95) + (ev.clientY - startY) / height)),
};
setGesture({ captionId: id, ...value });
@ -342,13 +396,13 @@ export function EditPreview({
incoming = clip.overlap && visibleTime < clip.transitionStart + clip.overlap;
return (
<video
key={clip.id}
key={`${clip.id}:${audioGain.source(asset.url)}`}
ref={(element) => {
if (element) videos.current.set(clip.id, element);
else videos.current.delete(clip.id);
}}
src={asset.url}
muted={clip.audioDetached || clip.gain === 0}
src={audioGain.source(asset.url)}
muted={clip.audioDetached || clip.muted || clip.gain === 0}
playsInline
preload="metadata"
className={`absolute inset-0 h-full w-full ${cover ? "object-cover" : "object-contain"}`}
@ -384,12 +438,12 @@ export function EditPreview({
})}
{timeline.audioClips.map((c) => (
<audio
key={c.id}
key={`${c.id}:${audioGain.source(plan.assets.find((a) => a.id === c.assetId)!.url)}`}
ref={(el) => {
if (el) audios.current.set(c.id, el);
else audios.current.delete(c.id);
}}
src={plan.assets.find((a) => a.id === c.assetId)?.url}
src={audioGain.source(plan.assets.find((a) => a.id === c.assetId)!.url)}
preload="metadata"
/>
))}
@ -415,11 +469,15 @@ export function EditPreview({
left: `${(selectedTransform.x + 0.5) * 100}%`,
top: `${(selectedTransform.y + 0.5) * 100}%`,
width:
Math.min(height * ratio, (height * selectedAsset.width) / selectedAsset.height) *
selectedTransform.scale,
Math.min(
height * ratio,
(height * selectedAsset.width) / selectedAsset.height,
) * selectedTransform.scale,
height:
Math.min(height, (height * ratio * selectedAsset.height) / selectedAsset.width) *
selectedTransform.scale,
Math.min(
height,
(height * ratio * selectedAsset.height) / selectedAsset.width,
) * selectedTransform.scale,
transform: "translate(-50%,-50%)",
border: "2px solid #87c9ff",
cursor: "move",
@ -477,13 +535,17 @@ export function EditPreview({
selectedCaptionId === (s as TimelineCaption).id
? "1px solid #87c9ff"
: undefined,
fontFamily: '"Noto Sans CJK SC", "Source Han Sans CN", sans-serif',
fontFamily: `"${captionFont(style.fontFamily)?.family || "Noto Sans CJK SC"}", "Source Han Sans SC", sans-serif`,
whiteSpace: "pre-wrap",
fontSize: style.fontSize * scale,
letterSpacing: style.spacing * scale,
fontSize: (style.fontSize * height) / (style.referenceHeight || 720),
letterSpacing: (style.spacing * height) / (style.referenceHeight || 720),
color: style.color,
opacity: style.opacity,
WebkitTextStroke: `${style.outlineWidth * scale * 2}px ${style.outlineColor}${Math.round((style.outlineOpacity ?? 1) * 255).toString(16).padStart(2, "0")}`,
WebkitTextStroke: `${((style.outlineWidth * height) / (style.referenceHeight || 720)) * 2}px ${style.outlineColor}${Math.round(
(style.outlineOpacity ?? 1) * 255,
)
.toString(16)
.padStart(2, "0")}`,
paintOrder: "stroke fill",
lineHeight: 1.2,
}}

View File

@ -2,6 +2,7 @@
import { useEffect, useRef, useState } from "react";
import { X, Upload } from "lucide-react";
import { SmartEditEditor, SmartEditEmptyEditor } from "./SmartEditEditor";
import { assertBeatMusic } from "@/lib/video-canvas/smart-edit/intent";
import { EditPreview } from "./EditPreview";
import {
smartEditRequestSchema,
@ -28,6 +29,7 @@ export function SmartEditDialog({
onDraft,
onPlan,
onRender,
onCancel,
}: {
initial?: Record<string, any>;
plans?: EditPlan[];
@ -41,6 +43,7 @@ export function SmartEditDialog({
onDraft: (draft: Record<string, unknown>) => void;
onPlan: (params: SmartEditRequest) => Promise<unknown>;
onRender: (plan: EditPlan) => Promise<unknown>;
onCancel?: (taskId: string) => Promise<unknown>;
}) {
const [draft, setDraft] = useState<Record<string, any>>(() => ({
assets: [],
@ -58,7 +61,9 @@ export function SmartEditDialog({
preferredModelRef,
...initial,
plans:
initial?.planTaskId === planTaskId ? initial?.plans || serverPlans || [] : serverPlans || [],
initial?.planTaskId === planTaskId
? initial?.plans || serverPlans || []
: serverPlans || [],
planTaskId,
}));
const latest = useRef(draft);
@ -69,7 +74,12 @@ export function SmartEditDialog({
setDraft(next);
onDraft(next);
};
const [selected, setSelected] = useState(0),
const [selected, setSelected] = useState(() =>
Math.max(
0,
Math.min((draft.plans?.length || 1) - 1, Number(initial?.selectedPlanIndex) || 0),
),
),
[clipIndex, setClipIndex] = useState(0),
[time, setTime] = useState(0),
[playing, setPlaying] = useState(false),
@ -83,7 +93,7 @@ export function SmartEditDialog({
useEffect(() => {
if (planTaskId && planTaskId !== applied.current) {
applied.current = planTaskId;
patch({ plans: serverPlans || [], planTaskId });
patch({ plans: serverPlans || [], planTaskId, selectedPlanIndex: 0 });
setSelected(0);
setTime(0);
setPlaying(false);
@ -154,7 +164,10 @@ export function SmartEditDialog({
}, [Boolean(plan), configure, connectedSourceIds.join("|"), waiting]);
const changePlan = (next: EditPlan) => {
setPlaying(false);
patch({ plans: draft.plans.map((p: EditPlan, i: number) => (i === selected ? next : p)) });
patch({
selectedPlanIndex: selected,
plans: draft.plans.map((p: EditPlan, i: number) => (i === selected ? next : p)),
});
};
const saveFile = (name: string, text: string) => {
const url = URL.createObjectURL(new Blob([text], { type: "text/plain;charset=utf-8" }));
@ -210,7 +223,12 @@ export function SmartEditDialog({
right = { ...plan.clips[clipIndex], id: crypto.randomUUID(), sourceIn: point };
changePlan({
...plan,
clips: [...plan.clips.slice(0, clipIndex), left, right, ...plan.clips.slice(clipIndex + 1)],
clips: [
...plan.clips.slice(0, clipIndex),
left,
right,
...plan.clips.slice(clipIndex + 1),
],
});
} else clipEdit(clipIndex, action === "left" ? { sourceIn: point } : { sourceOut: point });
};
@ -241,6 +259,7 @@ export function SmartEditDialog({
onClose={onClose}
onConfigure={() => setConfigure(true)}
onRender={onRender}
onCancel={onCancel}
tasks={tasks}
canRender={Boolean(cap?.capabilities?.render)}
exportState={draft.exportState}
@ -574,6 +593,7 @@ export function SmartEditDialog({
setPlaying(false);
try {
const params = smartEditRequestSchema.parse({ ...draft, operation: "plan" });
if (params.operation === "plan") assertBeatMusic(params);
await onPlan(params);
} catch (e) {
setError((e as Error).message);
@ -603,7 +623,9 @@ export function SmartEditDialog({
</p>
) : null}
{cap && !cap.capabilities.render ? (
<p className="mb-3 text-sm text-amber-200">视频合成服务暂不可用,剪辑方案仍可保存。</p>
<p className="mb-3 text-sm text-amber-200">
视频合成服务暂不可用,剪辑方案仍可保存。
</p>
) : null}
{plan ? (
<>
@ -615,6 +637,7 @@ export function SmartEditDialog({
className={`${button} ${selected === i ? "bg-white/15" : ""}`}
onClick={() => {
setSelected(i);
patch({ selectedPlanIndex: i });
setTime(0);
setPlaying(false);
setClipIndex(0);
@ -671,7 +694,9 @@ export function SmartEditDialog({
<button
type="button"
className={button}
onClick={() => saveFile("字幕.srt", subtitlesToSrt(compiledSubtitles(plan)))}
onClick={() =>
saveFile("字幕.srt", subtitlesToSrt(compiledSubtitles(plan)))
}
>
下载字幕
</button>
@ -779,7 +804,9 @@ export function SmartEditDialog({
aria-label="剪辑转场"
className={input}
value={plan.transition}
onChange={(e) => changePlan({ ...plan, transition: Number(e.target.value) })}
onChange={(e) =>
changePlan({ ...plan, transition: Number(e.target.value) })
}
>
<option value={0}>直接切换</option>
<option value={0.25}>叠化 0.25 秒</option>
@ -858,27 +885,29 @@ export function SmartEditDialog({
{i + 1} · {plan.assets.find((a) => a.id === clip.assetId)?.name}
</button>
</td>
{(["sourceIn", "sourceOut", "speed", "gain"] as const).map((key, j) => (
<td className="px-2 py-2" key={key}>
<input
aria-label={`片段${i + 1}${["入点", "出点", "速度", "音量"][j]}`}
type="number"
className={`${input} w-24`}
min={key === "speed" ? 0.5 : 0}
max={key === "speed" ? 2 : key === "gain" ? 1 : 180}
step={j < 2 ? 0.1 : 0.1}
value={j < 2 ? clip[key] / SECOND : clip[key]}
onChange={(e) =>
clipEdit(i, {
[key]:
j < 2
? Math.round(Number(e.target.value) * SECOND)
: Number(e.target.value),
})
}
/>
</td>
))}
{(["sourceIn", "sourceOut", "speed", "gain"] as const).map(
(key, j) => (
<td className="px-2 py-2" key={key}>
<input
aria-label={`片段${i + 1}${["入点", "出点", "速度", "音量"][j]}`}
type="number"
className={`${input} w-24`}
min={key === "speed" ? 0.5 : 0}
max={key === "speed" ? 2 : key === "gain" ? 1 : 180}
step={j < 2 ? 0.1 : 0.1}
value={j < 2 ? clip[key] / SECOND : clip[key]}
onChange={(e) =>
clipEdit(i, {
[key]:
j < 2
? Math.round(Number(e.target.value) * SECOND)
: Number(e.target.value),
})
}
/>
</td>
),
)}
<td className="whitespace-nowrap px-2 py-2">
{[-1, 1].map((delta) => (
<button

View File

@ -777,8 +777,8 @@
justify-content: center;
}
.root :global(.se-junction.has-transition) {
top: 0;
height: 64px;
top: 4px;
height: 60px;
background: #ffffff12;
border-color: #ddd;
border-radius: 8px;
@ -1058,3 +1058,40 @@
.root :global(.se-track-label:not(.audio):not(.caption)) {
margin-bottom: 8px;
}
.collapsed { background: transparent; justify-content: flex-end; pointer-events: none; }
.collapsed :global(.se-header) { display: none; }
.collapsed :global(.se-timeline), .collapsed :global(.se-context) { pointer-events: auto; }
.root :global(.se-caption-times input) { min-width: 0; width: 70px; font-variant-numeric: tabular-nums; }
.root :global(.se-caption-times button) { padding: 3px; display: flex; align-items: center; }
.root :global(.se-caption-times button:hover) { color: white; background: #ffffff14; border-radius: 4px; }
.root :global(.se-presets button:first-child) { font-size: 12px; }
.root :global(.se-presets button[aria-pressed="true"]) { border-color: #09caf5; }
.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(.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-categories) { display: flex; justify-content: space-between; margin-bottom: 8px; }
.root :global(.se-font-categories button) { padding: 4px; color: #999; font-size: 12px; }
.root :global(.se-font-categories button[aria-pressed="true"]) { color: #fff; border-bottom: 1px solid white; }
.root :global(.se-font-menu [role="listbox"]) { max-height: 230px; overflow-y: auto; }
.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-menu [role="option"]:hover) { background: #ffffff14; border-radius: 4px; }
.collapsed :global(.se-error), .collapsed :global(.se-export-result) { pointer-events: auto; }
.root :global(.se-progress button) { margin-left: 12px; text-decoration: underline; }
.root :global(.se-style-number) { width: 98px; height: 32px; display: flex; gap: 2px; align-items: center; background: #333; border-radius: 7px; padding: 0 6px 0 10px; flex: none; color: #999; }
.root :global(.se-style-number input[type="number"]) { width: 100%; min-width: 0; padding: 0; appearance: textfield; }
.root :global(.se-style-number input::-webkit-inner-spin-button) { appearance: none; }
.root :global(.se-style-number span) { font-size: 12px; }
.root :global(.se-style-number.spacing) { width: 100%; gap: 10px; }
.root :global(.se-style-steppers) { display: flex; flex-direction: column; margin-left: 4px; }
.root :global(.se-style-steppers button) { height: 12px; line-height: 12px; padding: 0; }
.root :global(.se-junction.has-transition[aria-pressed="true"]) { background: #ffffff3d; border-color: #fff; color: white; box-shadow: 0 4px 10px #00000038; }
.root :global(.se-junction.has-transition::before), .root :global(.se-junction.has-transition::after) { content: ""; position: absolute; top: 8px; bottom: 8px; width: 2px; background: currentColor; opacity: .8; border-radius: 999px; pointer-events: none; }
.root :global(.se-junction.has-transition::before) { left: 4px; }
.root :global(.se-junction.has-transition::after) { right: 4px; }
.root :global(.se-junction svg) { width: 16px; height: 16px; }

File diff suppressed because it is too large Load Diff

View File

@ -375,3 +375,12 @@
max-width: calc(100vw - 48px);
}
}
.tracks { max-height: 54px; overflow: hidden; }
.tracks > div { position: relative; height: 12px; }
.tracks i { position: absolute; top: 0; bottom: 0; border-right: 2px solid #262626; }
.settings { max-height: min(600px, calc(100vh - 180px)); overflow-y: auto; }
.settings select { width: 100%; height: 30px; padding: 0 8px; border-radius: 6px; background: #333; color: #eee; border: 0; }
.musicUpload { display: flex; align-items: center; gap: 4px; margin-top: 8px; font-size: 12px; }
.settings label.beatToggle { display: flex; gap: 8px; align-items: center; }

View File

@ -11,7 +11,15 @@ import {
Upload,
X,
} from "lucide-react";
import { editRatios, editResolutions } from "@/lib/video-canvas/smart-edit/contract";
import {
editRatios,
editResolutions,
planTimeline,
editableCaptions,
SECOND,
type EditPlan,
} from "@/lib/video-canvas/smart-edit/contract";
import { resolveSmartEditIntent, assertBeatMusic } from "@/lib/video-canvas/smart-edit/intent";
import styles from "./SmartEditNode.module.css";
export type SmartEditSource = { id: string; name: string; url: string; duration?: number };
const modes = [
@ -24,14 +32,24 @@ const modes = [
export function SmartEditNodeCard({
title,
previewUrl,
plan,
onOpen,
selected = false,
}: {
title: string;
selected?: boolean;
previewUrl?: string;
plan?: EditPlan;
onOpen: () => void;
}) {
let timeline: ReturnType<typeof planTimeline> | undefined;
try {
if (plan) timeline = planTimeline(plan);
} catch {
/* Invalid saved drafts open in the editor for recovery. */
}
const length = timeline?.duration || 0,
tick = Math.max(1, Math.ceil(length / SECOND / 5));
return (
<div className={styles.card} style={{ borderColor: selected ? "#888" : "#454545" }}>
<div className={styles.title}>
@ -47,19 +65,66 @@ export function SmartEditNodeCard({
</button>
</div>
</div>
<div className={styles.ruler}>
{Array.from({ length: 12 }, (_, i) => (
<span key={i}>0:{String(i * 2).padStart(2, "0")}</span>
))}
<div className={styles.ruler} aria-label="剪辑时间刻度">
{timeline ? (
Array.from({ length: Math.floor(length / SECOND / tick) + 1 }, (_, i) => {
const seconds = i * tick;
return (
<span key={i}>
{Math.floor(seconds / 60)}:{String(seconds % 60).padStart(2, "0")}
</span>
);
})
) : (
<span>尚未生成时间轴</span>
)}
</div>
<div className={styles.tracks}>
{[0, 1].map((row) => (
<div key={row}>
{Array.from({ length: 8 }, (_, i) => (
<i key={i} />
))}
</div>
))}
<div className={styles.tracks} aria-label="剪辑时间轴缩略图">
{timeline && (
<>
<div>
{timeline.clips.map((clip) => (
<i
key={clip.id}
title={clip.label}
style={{
left: `${(clip.start / length) * 100}%`,
width: `${(clip.duration / length) * 100}%`,
}}
/>
))}
</div>
{!!timeline.audioClips.length && (
<div>
{timeline.audioClips.map((clip) => (
<i
key={clip.id}
style={{
left: `${(clip.start / length) * 100}%`,
width: `${(clip.duration / length) * 100}%`,
background: "#346752",
}}
/>
))}
</div>
)}
{plan?.subtitles && (
<div>
{editableCaptions(plan).map((caption) => (
<i
key={caption.id}
title={caption.text}
style={{
left: `${(caption.start / length) * 100}%`,
width: `${((caption.end - caption.start) / length) * 100}%`,
background: "#75602e",
}}
/>
))}
</div>
)}
</>
)}
</div>
</div>
);
@ -69,6 +134,7 @@ export function SmartEditNodeComposer({
draft,
sources,
availableSources,
availableAudios = [],
onDraft,
onAddSource,
onRemoveSource,
@ -81,6 +147,7 @@ export function SmartEditNodeComposer({
draft: Record<string, any>;
sources: SmartEditSource[];
availableSources: SmartEditSource[];
availableAudios?: { name: string; url: string }[];
onDraft: (patch: Record<string, unknown>) => void;
onAddSource: (id: string) => void;
onRemoveSource: (id: string) => void;
@ -93,8 +160,10 @@ export function SmartEditNodeComposer({
[expanded, setExpanded] = useState(false),
[error, setError] = useState(""),
[submitting, setSubmitting] = useState(false);
useEffect(() => setError(""), [draft.music?.url, draft.beatSync]);
const root = useRef<HTMLDivElement>(null),
file = useRef<HTMLInputElement>(null);
file = useRef<HTMLInputElement>(null),
musicFile = useRef<HTMLInputElement>(null);
useEffect(() => {
const close = (e: PointerEvent) => {
if (!root.current?.contains(e.target as Node)) setMenu(null);
@ -149,6 +218,40 @@ export function SmartEditNodeComposer({
))}
</div>
)}
<input
ref={musicFile}
hidden
type="file"
accept="audio/*"
onChange={async (e) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
if (file.size > 125 * 1024 * 1024) {
setError("单个素材不得超过 125 MiB");
return;
}
setSubmitting(true);
setError("");
try {
const body = new FormData();
body.append("file", file);
const response = await fetch("/api/canvas/media/upload", {
method: "POST",
body,
signal: AbortSignal.timeout(120000),
});
const data = await response.json(),
url = data.url || data.data?.url;
if (!response.ok || !url) throw Error(data.error || "音乐上传失败");
onDraft({ music: { url, gain: 0.2, start: 0 } });
} catch (e) {
setError(e instanceof Error ? e.message : "音乐上传失败");
} finally {
setSubmitting(false);
}
}}
/>
<textarea
aria-label="智能剪辑要求"
placeholder="描述你想要的剪辑效果"
@ -173,6 +276,7 @@ export function SmartEditNodeComposer({
setError("");
setSubmitting(true);
try {
assertBeatMusic(resolveSmartEditIntent(draft));
await onSubmit();
} catch (e) {
setError(e instanceof Error ? e.message : "提交失败");
@ -212,7 +316,11 @@ export function SmartEditNodeComposer({
{editRatios.map((r) => {
const [w, h] = r.split(":").map(Number);
return (
<button key={r} aria-pressed={r === ratio} onClick={() => onDraft({ ratio: r })}>
<button
key={r}
aria-pressed={r === ratio}
onClick={() => onDraft({ ratio: r })}
>
<i
style={{
width: Math.min(16, (16 * w) / h),
@ -247,6 +355,73 @@ export function SmartEditNodeComposer({
/>
<span>s</span>
</div>
{mode.id === "ads" && (
<>
<label>广告版本</label>
<div className={styles.resolutions}>
{[1, 2, 3].map((count) => (
<button
key={count}
aria-pressed={(draft.variants ?? 3) === count}
onClick={() => onDraft({ variants: count })}
>
{count} 版
</button>
))}
</div>
</>
)}
<label htmlFor="smart-edit-music">背景音乐</label>
<select
id="smart-edit-music"
value={draft.music?.url || ""}
onChange={(e) =>
onDraft({
music: e.target.value
? { url: e.target.value, gain: 0.2, start: 0 }
: undefined,
})
}
>
<option value="">未选择</option>
{draft.music?.url && !availableAudios.some((a) => a.url === draft.music.url) && (
<option value={draft.music.url}>已上传音乐</option>
)}
{availableAudios.map((audio) => (
<option key={audio.url} value={audio.url}>
{audio.name}
</option>
))}
</select>
<button
className={styles.musicUpload}
disabled={submitting}
onClick={() => musicFile.current?.click()}
>
<Upload size={12} />
上传音乐
</button>
<label className={styles.beatToggle}>
<input
type="checkbox"
checked={resolveSmartEditIntent(draft).beatSync}
onChange={(e) => onDraft({ beatSync: e.target.checked })}
/>
音乐卡点
</label>
{resolveSmartEditIntent(draft).beatSync && (
<div className={styles.resolutions}>
{["2", "4"].map((beats) => (
<button
key={beats}
aria-pressed={(draft.beatsPerCut || "4") === beats}
onClick={() => onDraft({ beatsPerCut: beats })}
>
{beats} 拍切换
</button>
))}
</div>
)}
<label>清晰度</label>
<div className={styles.resolutions}>
{editResolutions.map((r) => (

View File

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

View File

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

View File

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

View File

@ -1,5 +1,7 @@
# 智能剪辑全面复核与像素差异(2026-09-17)
> 后续修复已更新至 [2026-09-18记录](smart-edit-fixes-2026-09-18.md)。本文保留09-17历史审计状态。
本轮在基线`431ca03f`之上复核 **95个检查点**,覆盖智能剪辑节点输入、编辑器布局、视频、字幕、转场、音频、导出恢复和边界。结论:**仍有遗漏,不能验收为全部功能齐全或100%像素一致**。这轮继续只在本地修复和验证,没有更新服务器。
该范围承接本次“转场预览图”对话,不扩大为整个视频画布、3D导演台或参考站全站重新验收;它们的旧清单不能被本轮结果覆盖。各检查点区别本轮UI、真实合成、代码核对、既有证据和待验,避免把功能存在误写为全链路成功。

View File

@ -1,4 +1,5 @@
import { z } from "zod";
import { captionFontIds, captionFont } from "./fonts";
export const SECOND = 1_000_000;
export const MAX_EDIT_SECONDS = 300;
export const editRatios = ["21:9", "16:9", "9:16", "1:1", "4:3", "3:4"] as const;
@ -18,6 +19,8 @@ export const subtitleSchema = z
.object({ start: time, end: time, text: z.string().max(500) })
.refine((x) => x.end > x.start);
export const captionStyleSchema = z.object({
fontFamily: z.enum(captionFontIds).optional(),
referenceHeight: z.union([z.literal(720), z.literal(1080)]).optional(),
fontSize: z.number().min(8).max(160).default(28),
color: z
.string()
@ -27,11 +30,11 @@ export const captionStyleSchema = z.object({
.string()
.regex(/^#[0-9a-f]{6}$/i)
.default("#000000"),
outlineWidth: z.number().min(0).max(10).default(0),
outlineWidth: z.number().min(0).max(20).default(0),
opacity: z.number().min(0).max(1).default(1),
// Optional so existing edits keep their whole-caption opacity unchanged.
outlineOpacity: z.number().min(0).max(1).optional(),
spacing: z.number().min(-5).max(30).default(0),
spacing: z.number().min(-5).max(100).default(0),
x: z.number().min(0).max(1).optional(),
y: z.number().min(0).max(1).optional(),
});
@ -47,6 +50,13 @@ export const timelineCaptionSchema = z
export type TimelineCaption = z.infer<typeof timelineCaptionSchema>;
export type CaptionStyle = z.infer<typeof captionStyleSchema>;
export const defaultCaptionStyle: CaptionStyle = captionStyleSchema.parse({});
export const newCaptionStyle: CaptionStyle = {
...defaultCaptionStyle,
fontFamily: "source-han",
referenceHeight: 1080,
fontSize: 56,
outlineColor: "#ffffff",
};
const transitionSchema = z.object({
duration: z.number().min(0).max(4),
// Centered transitions preserve the edit duration; absent keeps saved legacy overlaps.
@ -83,7 +93,8 @@ export const editClipSchema = z
sourceIn: time,
sourceOut: time,
speed: z.number().min(0.5).max(2).default(1),
gain: z.number().min(0).max(1).default(1),
gain: z.number().min(0).max(4).default(1),
muted: z.boolean().optional(),
label: z.string().max(300).default(""),
// The transition belongs to the incoming clip, so it follows that clip when reordered.
transition: transitionSchema.optional(),
@ -99,7 +110,8 @@ export const audioClipSchema = z
sourceIn: time,
sourceOut: time,
speed: z.number().min(0.5).max(2).default(1),
gain: z.number().min(0).max(1).default(1),
gain: z.number().min(0).max(4).default(1),
muted: z.boolean().optional(),
fadeIn: z.number().min(0).max(5).default(0),
fadeOut: z.number().min(0).max(5).default(0),
fadeInStart: z.number().min(0).max(1).optional(),
@ -165,7 +177,9 @@ export function planTimeline(input: EditPlan) {
const plan = editPlanSchema.parse(input),
ids = new Set<string>();
const durations = plan.clips.map((c) =>
Math.round((Math.round(((c.sourceOut - c.sourceIn) / c.speed / SECOND) * 30) * SECOND) / 30),
Math.round(
(Math.round(((c.sourceOut - c.sourceIn) / c.speed / SECOND) * 30) * SECOND) / 30,
),
);
const centered = plan.clips.map((c) => c.transition?.alignment === "center");
const overlaps = plan.clips.map((c, i) =>
@ -226,7 +240,8 @@ export function planTimeline(input: EditPlan) {
throw Error("成片时长需在 0.2–300 秒内");
const audioClips = (plan.audioClips || []).map((c) => {
const asset = plan.assets.find((a) => a.id === c.assetId);
if (!asset?.hasAudio || c.sourceOut > asset.duration) throw Error("独立音轨素材无效或超出范围");
if (!asset?.hasAudio || c.sourceOut > asset.duration)
throw Error("独立音轨素材无效或超出范围");
const duration = Math.round((c.sourceOut - c.sourceIn) / c.speed);
return { ...c, duration, end: c.start + duration };
});
@ -257,7 +272,10 @@ export function compiledSubtitles(plan: EditPlan) {
clip.start + (clip.transition?.alignment === "center" ? 0 : clip.overlap),
clip.start + Math.round((a - clip.sourceIn) / clip.speed),
);
const end = Math.min(clip.end, clip.start + Math.round((b - clip.sourceIn) / clip.speed));
const end = Math.min(
clip.end,
clip.start + Math.round((b - clip.sourceIn) / clip.speed),
);
return end > start ? [{ start, end, text: s.text }] : [];
}),
);
@ -278,17 +296,17 @@ export function subtitlesToAss(plan: EditPlan, width: number, height: number) {
`&H${Math.round((1 - opacity) * 255)
.toString(16)
.padStart(2, "0")}${hex.slice(5, 7)}${hex.slice(3, 5)}${hex.slice(1, 3)}`;
// Style units use a 720px-high composition in both the browser and libass.
// Saved legacy styles use 720px; new reference-aligned styles explicitly use 1080px.
const rows = compiledSubtitles(plan) as Array<{
start: number;
end: number;
text: string;
style?: CaptionStyle;
}>;
const scale = height / 720;
const styles = rows.map((s, i) => {
const style = s.style || defaultCaptionStyle;
return `Style: c${i},Noto Sans CJK SC,${style.fontSize * scale},${color(style.color, style.opacity)},&H00FFFFFF,${color(style.outlineColor, style.opacity * (style.outlineOpacity ?? 1))},&HFF000000,0,0,0,0,100,100,${style.spacing * scale},0,1,${style.outlineWidth * scale},0,2,20,20,${Math.round(height * 0.05)},1`;
const scale = height / (style.referenceHeight || 720);
return `Style: c${i},${captionFont(style.fontFamily)?.family || "Noto Sans CJK SC"},${style.fontSize * scale},${color(style.color, style.opacity)},&H00FFFFFF,${color(style.outlineColor, style.opacity * (style.outlineOpacity ?? 1))},&HFF000000,0,0,0,0,100,100,${style.spacing * scale},0,1,${style.outlineWidth * scale},0,2,20,20,${Math.round(height * 0.05)},1`;
});
const events = rows.map((s, i) => {
const text = s.text
@ -366,7 +384,10 @@ export function buildEditPlans(
if (!ordered.length) throw Error("没有选中可用片段");
return Array.from({ length: params.mode === "ads" ? params.variants : 1 }, (_, variant) => {
const list = variant
? [...ordered.slice(variant % ordered.length), ...ordered.slice(0, variant % ordered.length)]
? [
...ordered.slice(variant % ordered.length),
...ordered.slice(0, variant % ordered.length),
]
: ordered;
const clips: EditPlan["clips"] = [];
let duration = 0;
@ -403,9 +424,12 @@ export function buildEditPlans(
.filter((_, i) => i % stride === 0)
.map((t) => t - params.music!.start)
.filter(
(t) => t - duration >= 0.5 * SECOND && t - duration <= Math.min(available, remaining),
(t) =>
t - duration >= 0.5 * SECOND && t - duration <= Math.min(available, remaining),
)
.sort((a, b) => Math.abs(a - duration - desired) - Math.abs(b - duration - desired))[0];
.sort(
(a, b) => Math.abs(a - duration - desired) - Math.abs(b - duration - desired),
)[0];
if (next !== undefined) length = next - duration;
}
if (length < 200000) continue;
@ -433,7 +457,10 @@ export function buildEditPlans(
warnings: [...warnings],
};
const actual = planTimeline(plan).duration / SECOND;
if (params.mode === "sequence" && assets.reduce((total, a) => total + a.duration, 0) > duration)
if (
params.mode === "sequence" &&
assets.reduce((total, a) => total + a.duration, 0) > duration
)
plan.warnings.push("时间轴已载入前 300 秒,超出的素材未加入;可在设置中另建剪辑方案。");
if (
params.mode !== "sequence" &&

View File

@ -0,0 +1,56 @@
/** Same packaged font bytes are used in browser previews and FFmpeg exports. */
export const captionFonts = [
{
id: "source-han",
name: "Source Han Sans CN",
family: "Source Han Sans SC",
file: "source-han.otf",
common: true,
recommended: true,
google: false,
},
{
id: "noto-sans-sc",
name: "Noto Sans SC",
family: "Noto Sans SC",
file: "noto-sans-sc.ttf",
common: true,
recommended: true,
google: true,
},
{
id: "noto-serif-sc",
name: "Noto Serif SC",
family: "Noto Serif SC",
file: "noto-serif-sc.ttf",
common: true,
recommended: true,
google: true,
},
{
id: "roboto",
name: "Roboto",
family: "Roboto",
file: "roboto.ttf",
common: true,
recommended: false,
google: true,
},
{
id: "inter",
name: "Inter",
family: "Inter",
file: "inter.ttf",
common: true,
recommended: false,
google: true,
},
] as const;
export const captionFontIds = [
"source-han",
"noto-sans-sc",
"noto-serif-sc",
"roboto",
"inter",
] as const;
export const captionFont = (id?: string) => captionFonts.find((font) => font.id === id);

View File

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

View File

@ -1,5 +1,6 @@
import { readFile, writeFile } from "node:fs/promises";
import { readFile, writeFile, mkdir, copyFile } from "node:fs/promises";
import path from "node:path";
import { captionFont } from "./fonts";
import {
type EditPlan,
editOutputSize,
@ -22,6 +23,34 @@ export async function renderEditPlan(
const timeline = planTimeline(plan),
duration = timeline.duration / SECOND;
const { width, height } = editOutputSize(plan);
const selectedFonts = [
...new Set(
compiledSubtitles(plan)
.map(
(caption) =>
captionFont((caption as { style?: { fontFamily?: string } }).style?.fontFamily)
?.file,
)
.filter((file): file is NonNullable<typeof file> => Boolean(file)),
),
];
if (selectedFonts.length) {
const fontDir = path.join(dir, "fonts");
await mkdir(fontDir, { recursive: true });
const source =
process.env.SMART_EDIT_FONTS_DIR ||
path.resolve(process.cwd(), "public/fonts/smart-edit");
for (const file of selectedFonts) {
try {
await copyFile(path.join(source, file), path.join(fontDir, file));
} catch {
throw Error(`字幕字体文件缺失:${file},请安装剪辑字体包后重新导出`);
}
}
// Latin faces use the same packaged Chinese fallback in both renderers.
if (!selectedFonts.includes("source-han.otf"))
await copyFile(path.join(source, "source-han.otf"), path.join(fontDir, "source-han.otf"));
}
const normalized: string[] = [];
for (let i = 0; i < timeline.clips.length; i++) {
await progress(`处理片段 ${i + 1}/${timeline.clips.length}`);
@ -63,7 +92,7 @@ export async function renderEditPlan(
]
: ["-vf", normalize]),
"-af",
`asetpts=PTS-STARTPTS,atempo=${clip.speed},aresample=48000,aformat=channel_layouts=stereo,volume=${clip.audioDetached ? 0 : clip.gain},atrim=duration=${clip.duration / SECOND}${clip.padBefore ? `,adelay=${Math.round(clip.padBefore / 1000)}:all=1` : ""},apad`,
`asetpts=PTS-STARTPTS,atempo=${clip.speed},aresample=48000,aformat=channel_layouts=stereo,volume=${clip.audioDetached || clip.muted ? 0 : clip.gain},atrim=duration=${clip.duration / SECOND}${clip.padBefore ? `,adelay=${Math.round(clip.padBefore / 1000)}:all=1` : ""},apad`,
"-t",
String((clip.duration + clip.padBefore + clip.padAfter) / SECOND),
"-c:v",
@ -198,10 +227,19 @@ export async function renderEditPlan(
const output = path.join(dir, "result.mp4");
const inputs = ["-i", joined];
const filters: string[] = [];
const videoTail = Math.max(0, duration - timeline.clips[timeline.clips.length - 1].end / SECOND);
const videoTail = Math.max(
0,
duration - timeline.clips[timeline.clips.length - 1].end / SECOND,
);
const videoFilters = [
...(videoTail ? [`tpad=stop_mode=add:stop_duration=${videoTail}`] : []),
...(subtitles.length ? ["ass=filename=subtitles.ass"] : []),
...(subtitles.length
? [
selectedFonts.length
? "ass=filename=subtitles.ass:fontsdir=fonts"
: "ass=filename=subtitles.ass",
]
: []),
];
if (videoFilters.length) filters.push(`[0:v]${videoFilters.join(",")}[v]`);
filters.push(`[0:a]apad,atrim=duration=${duration}[baseaudio]`);
@ -233,7 +271,7 @@ export async function renderEditPlan(
? `,volume='${fadeIn ? `min(1,${clip.fadeInStart || 0}+(1-${clip.fadeInStart || 0})*t/${fadeIn})` : "1"}*${fadeOut ? `min(1,${clip.fadeOutEnd || 0}+(1-${clip.fadeOutEnd || 0})*(${length}-t)/${fadeOut})` : "1"}':eval=frame`
: `${fadeIn ? `,afade=t=in:d=${fadeIn}` : ""}${fadeOut ? `,afade=t=out:st=${length - fadeOut}:d=${fadeOut}` : ""}`;
filters.push(
`[${audioInput}:a]asetpts=PTS-STARTPTS,atempo=${clip.speed},aresample=48000,aformat=channel_layouts=stereo,volume=${clip.gain},atrim=duration=${length}${offsetEnvelope},adelay=${Math.round(clip.start / 1000)}:all=1[track${index}]`,
`[${audioInput}:a]asetpts=PTS-STARTPTS,atempo=${clip.speed},aresample=48000,aformat=channel_layouts=stereo,volume=${clip.muted ? 0 : clip.gain},atrim=duration=${length}${offsetEnvelope},adelay=${Math.round(clip.start / 1000)}:all=1[track${index}]`,
);
mixLabels.push(`[track${index}]`);
audioInput++;

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

View File

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

Binary file not shown.

View File

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

Binary file not shown.

View File

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

Binary file not shown.

View File

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

Binary file not shown.

View File

@ -50,7 +50,10 @@ const fs = require("node:fs"),
),
);
await esbuild.build({
entryPoints: ["lib/video-canvas/smart-edit/render.ts", "lib/video-canvas/smart-edit/local.ts"],
entryPoints: [
"lib/video-canvas/smart-edit/render.ts",
"lib/video-canvas/smart-edit/local.ts",
],
outdir: ".qa/smart-edit/build",
bundle: true,
platform: "node",
@ -86,7 +89,15 @@ const fs = require("node:fs"),
subtitles: false,
warnings: [],
clips: [
{ id: "1", assetId: "a", sourceIn: 0, sourceOut: 2000000, speed: 1, gain: 1, label: "red" },
{
id: "1",
assetId: "a",
sourceIn: 0,
sourceOut: 2000000,
speed: 1,
gain: 1,
label: "red",
},
{
id: "2",
assetId: "b",
@ -161,7 +172,10 @@ const fs = require("node:fs"),
const result = await renderEditPlan(mixed, files, undefined, out);
const probe = await probeMedia(result.file);
assert.ok(Math.abs(probe.duration / 1e6 - 3.4) < 0.15, JSON.stringify(probe));
assert.match(fs.readFileSync(path.join(out, "subtitles.ass"), "utf8"), /Noto Sans CJK SC,36/);
assert.match(
fs.readFileSync(path.join(out, "subtitles.ass"), "utf8"),
/Noto Sans CJK SC,36/,
);
assert.match(fs.readFileSync(result.srt, "utf8"), /手动字幕/);
const pixel = execFileSync("ffmpeg", [
"-v",
@ -180,7 +194,8 @@ const fs = require("node:fs"),
"rgb24",
"-",
]);
if (type === "fadeblack") assert.ok(pixel[0] < 70, "black transition must darken the midpoint");
if (type === "fadeblack")
assert.ok(pixel[0] < 70, "black transition must darken the midpoint");
if (type === "fadewhite")
assert.ok(pixel[1] > 180, "white transition must brighten the midpoint");
if (type === "fade")
@ -261,7 +276,9 @@ const fs = require("node:fs"),
);
if (type === "fade")
assert.ok(pixel[0] > 80 && pixel[2] > 80, `crossfade midpoint ${pixel.slice(0, 3)}`);
console.log(`PASS: centered ${type} ${duration}s preserves 4s edit; midpoint pixel verified`);
console.log(
`PASS: centered ${type} ${duration}s preserves 4s edit; midpoint pixel verified`,
);
}
}
// Verify edited geometry, detached audio, gain, fades and black tail in actual encoded media.
@ -349,7 +366,9 @@ const fs = require("node:fs"),
const baseline = await actual("audio-baseline", base);
const detached = await actual("audio-detached", {
...base,
clips: [{ ...base.clips[0], audioDetached: true, transform: { scale: 0.5, x: 0.2, y: -0.1 } }],
clips: [
{ ...base.clips[0], audioDetached: true, transform: { scale: 0.5, x: 0.2, y: -0.1 } },
],
audioClips: [
{
id: "track",
@ -366,7 +385,10 @@ const fs = require("node:fs"),
});
const ratio = rms(detached.file, 0.8) / rms(baseline.file, 0.8);
assert.ok(ratio > 0.9 && ratio < 1.1, `detached sound must not double: ${ratio}`);
assert.ok(pixelAt(detached.file, 0.8, 600, 150)[0] > 220, "shifted scaled video should be red");
assert.ok(
pixelAt(detached.file, 0.8, 600, 150)[0] > 220,
"shifted scaled video should be red",
);
assert.ok(
pixelAt(detached.file, 0.8, 150, 150)[0] < 10,
"outside transformed video should be black",
@ -482,18 +504,103 @@ const fs = require("node:fs"),
],
{ maxBuffer: 4 * 1024 * 1024 },
);
let whitePixels = 0, blackPixels = 0;
let whitePixels = 0,
blackPixels = 0;
for (let y = 170; y < 240; y++)
for (let x = 90; x < 340; x++) {
const offset = (y * 854 + x) * 3;
if (frame[offset] > 200 && frame[offset + 1] > 200 && frame[offset + 2] > 200) whitePixels++;
if (frame[offset] > 200 && frame[offset + 1] > 200 && frame[offset + 2] > 200)
whitePixels++;
if (frame[offset] < 30 && frame[offset + 1] < 30 && frame[offset + 2] < 30) blackPixels++;
}
assert.ok(whitePixels > 150, `caption must render at moved position: ${whitePixels}`);
assert.ok(blackPixels < 10, `zero outline opacity must remove only the black outline: ${blackPixels}`);
assert.ok(
blackPixels < 10,
`zero outline opacity must remove only the black outline: ${blackPixels}`,
);
console.log(
"PASS: real transform pixels, caption position, detached audio without doubling, 2x audio, gain, fades, delay and black tail",
);
const boosted = await actual("positive-gain", {
...base,
clips: [{ ...base.clips[0], gain: 2 }],
});
const boostRatio = rms(boosted.file, 0.8) / rms(baseline.file, 0.8);
assert.ok(
boostRatio > 1.85 && boostRatio < 2.15,
`+6 dB should double amplitude: ${boostRatio}`,
);
const muted = await actual("muted-gain", {
...base,
clips: [{ ...base.clips[0], gain: 2, muted: true }],
});
assert.ok(
rms(muted.file, 0.8) < 0.0001,
"mute must silence original sound without overwriting gain",
);
const independentMuted = await actual("muted-independent-gain", {
...delayedPlan,
audioClips: delayedPlan.audioClips.map((clip) => ({ ...clip, muted: true })),
});
assert.ok(rms(independentMuted.file, 1.7) < 0.0001, "mute must silence detached sound");
const fontHashes = new Set();
for (const fontFamily of ["source-han", "noto-sans-sc", "noto-serif-sc", "roboto", "inter"]) {
const output = await actual("font-" + fontFamily, {
...base,
subtitles: true,
captions: [
{
id: "font",
text: "字幕字体 AaGg 123",
start: 0,
end: 2e6,
style: {
fontFamily,
referenceHeight: 1080,
fontSize: 56,
color: "#ffffff",
outlineColor: "#ffffff",
outlineWidth: 0,
opacity: 1,
spacing: 0,
x: 0.5,
y: 0.5,
},
},
],
});
const pixels = execFileSync(
"ffmpeg",
[
"-v",
"error",
"-ss",
"0.8",
"-i",
output.file,
"-frames:v",
"1",
"-f",
"rawvideo",
"-pix_fmt",
"rgb24",
"-",
],
{ maxBuffer: 4 * 1024 * 1024 },
);
let light = 0;
for (let i = 0; i < pixels.length; i += 3)
if (pixels[i] > 200 && pixels[i + 1] > 200 && pixels[i + 2] > 200) light++;
assert.ok(light > 150, `${fontFamily} must draw glyphs: ${light}`);
fontHashes.add(require("node:crypto").createHash("sha256").update(pixels).digest("hex"));
}
assert.ok(
fontHashes.size >= 4,
"selected fonts must change actual glyph pixels, not all fall back to one face",
);
console.log(
"PASS: positive gain amplitude, original/detached mute and five packaged caption fonts",
);
if (process.env.SMART_EDIT_PYTHON) {
const prepared = await prepareAnalysis(files.b, "fixture", dir, true);
const analysis = await pythonAnalysis(

View File

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

View File

@ -39,3 +39,7 @@
随后 `node services/video-render/build.cjs && PATH=<ffmpeg目录>:$PATH node scripts/qa/video-render-service.cjs` 验证真实 HTTP、鉴权、FFmpeg、进程强制中断/恢复、幂等、冲突拒绝和完成记录重启保留。用合成色块/正弦音,不调用付费模型。
依赖许可:Zod MIT;FFmpeg 许可取决于本机编译选项,Ubuntu libx264 构建含 GPL 组件。服务只运行本机已安装 FFmpeg,不把二进制混入仓库。代码与发布状态记录在 `docs/video-render-service-2026-09-17.md`。
## 字体包
构建会将 `public/fonts/smart-edit` 的真实字体和许可证复制到 `dist/fonts`,发布时必须与 `server.cjs` 一起携带。服务默认读取同目录 `fonts`,可用 `SMART_EDIT_FONTS_DIR` 指定绝对路径;主项目本地合成默认读取 `public/fonts/smart-edit`。缺失所选字体时返回明确错误,不悄悄使用其他字体。五种字体来自 Adobe/Google 官方仓库,来源、摘要和各自 SIL OFL 许可保存在字体目录。

View File

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

View File

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

View File

@ -1,3 +1,4 @@
import { resolveSmartEditIntent, assertBeatMusic } from "@/lib/video-canvas/smart-edit/intent";
import { remoteRender } from "@/lib/video-canvas/smart-edit/remote-render";
import { test, expect } from "@playwright/test";
import {
@ -42,7 +43,15 @@ const plan = (): EditPlan => ({
subtitles: true,
warnings: [],
clips: [
{ id: "c1", assetId: "a", sourceIn: 0, sourceOut: 3 * SECOND, speed: 1, gain: 1, label: "red" },
{
id: "c1",
assetId: "a",
sourceIn: 0,
sourceOut: 3 * SECOND,
speed: 1,
gain: 1,
label: "red",
},
{
id: "c2",
assetId: "a",
@ -125,11 +134,17 @@ test("outline opacity leaves caption fill visible and preserves legacy whole-tex
const p = plan();
const style = { ...defaultCaptionStyle, outlineWidth: 4, outlineOpacity: 0 };
p.captions = [{ id: "alpha", start: 0, end: SECOND, text: "描边", style }];
let fields = subtitlesToAss(p, 1280, 720).split("\n").find((s) => s.startsWith("Style:"))!.split(",");
let fields = subtitlesToAss(p, 1280, 720)
.split("\n")
.find((s) => s.startsWith("Style:"))!
.split(",");
expect(fields[3]).toBe("&H00ffffff");
expect(fields[5]).toBe("&Hff000000");
p.captions[0].style = { ...defaultCaptionStyle, opacity: 0.5 };
fields = subtitlesToAss(p, 1280, 720).split("\n").find((s) => s.startsWith("Style:"))!.split(",");
fields = subtitlesToAss(p, 1280, 720)
.split("\n")
.find((s) => s.startsWith("Style:"))!
.split(",");
expect(fields[3]).toBe("&H80ffffff");
expect(fields[5]).toBe("&H80000000");
});
@ -158,7 +173,9 @@ test("spoken sentences are kept intact and missing footage is reported rather th
const plans = buildEditPlans(params, [asset], candidates, ["one"], [], []);
expect(plans[0].clips[0].sourceOut).toBe(4 * SECOND);
expect(plans[0].warnings.join()).toContain("实际 4.00");
expect(() => buildEditPlans(params, [asset], candidates, ["invented"], [], [])).toThrow("无效");
expect(() => buildEditPlans(params, [asset], candidates, ["invented"], [], [])).toThrow(
"无效",
);
});
test("music cut points use detected beats and batch variants preserve original source ranges", () => {
const params = smartEditRequestSchema.parse({
@ -263,9 +280,9 @@ test("remote render records deterministic identity before network failure and re
checkpoints.push(value);
},
};
await expect(remoteRender(plan(), "user", "stable-task", "/unused", lifecycle)).rejects.toThrow(
"任务已保存",
);
await expect(
remoteRender(plan(), "user", "stable-task", "/unused", lifecycle),
).rejects.toThrow("任务已保存");
await expect(
remoteRender(plan(), "user", "stable-task", "/unused", {
...lifecycle,
@ -456,3 +473,54 @@ test("remote result accepts a five-minute ultrawide export and downloads the sam
await rm(dir, { recursive: true, force: true });
}
});
test("muting preserves gain through validation and independent audio edits", () => {
const p = plan();
p.clips[0] = { ...p.clips[0], gain: 10 ** (6 / 20), muted: true };
let timeline = planTimeline(p);
expect(timeline.clips[0].gain).toBeCloseTo(1.99526);
expect(timeline.clips[0].muted).toBe(true);
p.clips[0].muted = false;
timeline = planTimeline(p);
expect(timeline.clips[0].gain).toBeCloseTo(1.99526);
p.clips[0].gain = 4.01;
expect(() => planTimeline(p)).toThrow();
});
test("1080-based new captions and legacy 720-based captions retain output sizing", async () => {
const { newCaptionStyle } = await import("@/lib/video-canvas/smart-edit/contract");
const p = plan();
p.captions = [
{
id: "old",
start: 0,
end: SECOND,
text: "旧字幕",
style: { ...defaultCaptionStyle, fontSize: 28 },
},
{ id: "new", start: SECOND, end: 2 * SECOND, text: "新字幕", style: newCaptionStyle },
];
let ass = subtitlesToAss(p, 1920, 1080);
expect(ass).toContain("Style: c0,Noto Sans CJK SC,42,");
expect(ass).toContain("Style: c1,Source Han Sans SC,56,");
p.captions[1].style = {
...newCaptionStyle,
fontFamily: "noto-serif-sc",
outlineWidth: 20,
spacing: 100,
};
expect(() => planTimeline(p)).not.toThrow();
ass = subtitlesToAss(p, 1280, 720);
expect(ass).toContain("Style: c1,Noto Serif SC,37.33333333333333,");
});
test("node intent honors explicit beat switches, missing music, and ad variants", async () => {
const beat = resolveSmartEditIntent({ prompt: "卡点并突出产品", mode: "ads" });
expect(beat.beatSync).toBe(true);
expect(beat.variants).toBe(3);
expect(() => assertBeatMusic(beat)).toThrow("背景音乐");
expect(() => assertBeatMusic({ ...beat, music: { url: asset.url } })).not.toThrow();
expect(resolveSmartEditIntent({ prompt: "不用卡点" }).beatSync).toBe(false);
expect(resolveSmartEditIntent({ prompt: "需要卡点", beatSync: false }).beatSync).toBe(false);
expect(resolveSmartEditIntent({ mode: "ads", variants: 2 }).variants).toBe(2);
});