Fix smart edit captions, audio gain and timeline parity
This commit is contained in:
parent
689fa615e8
commit
17e17b9ad5
@ -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:"打开智能剪辑"}],
|
||||
|
||||
123
components/canvas/smart-edit/CaptionFonts.tsx
Normal file
123
components/canvas/smart-edit/CaptionFonts.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
}}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
@ -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; }
|
||||
|
||||
@ -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) => (
|
||||
|
||||
111
components/canvas/smart-edit/usePreviewGain.ts
Normal file
111
components/canvas/smart-edit/usePreviewGain.ts
Normal file
@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { EditPlan } from "@/lib/video-canvas/smart-edit/contract";
|
||||
|
||||
/** Only amplified cross-origin sources need a same-origin copy for Web Audio. */
|
||||
export function usePreviewGain(plan: EditPlan, onError: (message: string) => void) {
|
||||
const remote = [
|
||||
...new Set(
|
||||
[...plan.clips, ...(plan.audioClips || [])]
|
||||
.filter((clip) => clip.gain > 1)
|
||||
.map((clip) => plan.assets.find((asset) => asset.id === clip.assetId)!.url)
|
||||
.filter(
|
||||
(url) =>
|
||||
typeof window !== "undefined" &&
|
||||
new URL(url, window.location.href).origin !== window.location.origin,
|
||||
),
|
||||
),
|
||||
].sort();
|
||||
const key = JSON.stringify(remote);
|
||||
const [copies, setCopies] = useState<{ key: string; urls: Record<string, string> }>({
|
||||
key: "",
|
||||
urls: {},
|
||||
});
|
||||
const [attempt, setAttempt] = useState(0),
|
||||
[failed, setFailed] = useState(false);
|
||||
const error = useRef(onError);
|
||||
error.current = onError;
|
||||
const graph = useRef<{
|
||||
context: AudioContext;
|
||||
nodes: Map<HTMLMediaElement, { source: MediaElementAudioSourceNode; gain: GainNode }>;
|
||||
}>();
|
||||
useEffect(() => {
|
||||
setFailed(false);
|
||||
const controller = new AbortController(),
|
||||
urls: Record<string, string> = {};
|
||||
void Promise.all(
|
||||
(JSON.parse(key) as string[]).map(async (url) => {
|
||||
const response = await fetch("/api/canvas/download-image", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url, name: "剪辑音量预览" }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) throw Error("无法读取音量预览素材,请检查链接后重试");
|
||||
const blob = await response.blob();
|
||||
if (!blob.size) throw Error("音量预览素材为空");
|
||||
if (!controller.signal.aborted) urls[url] = URL.createObjectURL(blob);
|
||||
}),
|
||||
)
|
||||
.then(() => {
|
||||
if (!controller.signal.aborted) setCopies({ key, urls });
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setFailed(true);
|
||||
error.current(`${e.message},可点击播放重试`);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
controller.abort();
|
||||
Object.values(urls).forEach(URL.revokeObjectURL);
|
||||
};
|
||||
}, [key, attempt]);
|
||||
useEffect(
|
||||
() => () => {
|
||||
void graph.current?.context.close();
|
||||
graph.current = undefined;
|
||||
},
|
||||
[],
|
||||
);
|
||||
return {
|
||||
retry() {
|
||||
if (failed) setAttempt((value) => value + 1);
|
||||
},
|
||||
source(url: string) {
|
||||
return copies.key === key ? copies.urls[url] || url : url;
|
||||
},
|
||||
ready(url: string) {
|
||||
return !remote.includes(url) || (copies.key === key && Boolean(copies.urls[url]));
|
||||
},
|
||||
retain(elements: HTMLMediaElement[]) {
|
||||
for (const [element, node] of graph.current?.nodes || [])
|
||||
if (!elements.includes(element)) {
|
||||
element.pause();
|
||||
node.source.disconnect();
|
||||
node.gain.disconnect();
|
||||
graph.current!.nodes.delete(element);
|
||||
}
|
||||
},
|
||||
volume(element: HTMLMediaElement, gain: number, playing: boolean) {
|
||||
let node = graph.current?.nodes.get(element);
|
||||
if (gain > 1 && !node) {
|
||||
graph.current ||= { context: new AudioContext(), nodes: new Map() };
|
||||
const source = graph.current.context.createMediaElementSource(element),
|
||||
amplifier = graph.current.context.createGain();
|
||||
source.connect(amplifier);
|
||||
amplifier.connect(graph.current.context.destination);
|
||||
node = { source, gain: amplifier };
|
||||
graph.current.nodes.set(element, node);
|
||||
}
|
||||
if (node) {
|
||||
element.volume = 1;
|
||||
node.gain.gain.value = Math.max(0, gain);
|
||||
if (playing && graph.current?.context.state === "suspended")
|
||||
void graph.current.context
|
||||
.resume()
|
||||
.catch(() => error.current("请再次点击播放以启用音频预览"));
|
||||
} else element.volume = Math.min(1, Math.max(0, gain));
|
||||
},
|
||||
};
|
||||
}
|
||||
778
docs/smart-edit-fixes-2026-09-18.json
Normal file
778
docs/smart-edit-fixes-2026-09-18.json
Normal 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验收边界,不代表全矩阵重测。"
|
||||
}
|
||||
177
docs/smart-edit-fixes-2026-09-18.md
Normal file
177
docs/smart-edit-fixes-2026-09-18.md
Normal file
@ -0,0 +1,177 @@
|
||||
# 智能剪辑修复进度(2026-09-18)
|
||||
|
||||
基线 `68f62dae`,承接09-17的95项清单;**仅本地修改和验证,未部署、未推送**。累计状态不是全项本轮复测,保留每行的证据边界。尚不能认定为全部功能齐全或像素级完全一致。
|
||||
|
||||
## 本轮结果
|
||||
|
||||
已处理字幕行操作、字体搜索/分类、五种实际字体与成片一致性、新字幕1080坐标基准、字间距/描边/单位、正音量增益和静音恢复、收起预览与Esc、真实节点时间轴、音乐卡点与广告版本设置、音频面板拖宽、转场覆盖框与主画面预览时机。
|
||||
|
||||
增加排队导出的取消入口。独立合成服务构建携带字体包;真实测试发现临时字体目录清理会误报合成失败,已修正为递归清理,非关键清理失败不删除已验证成片。
|
||||
|
||||
## 验证
|
||||
|
||||
- TypeScript:通过。
|
||||
- 回归:496通过、1跳过;最后定向剪辑回归21通过。
|
||||
- 真实FFmpeg:六种比例、三种转场、居中/旧重叠时序、字幕位置/透明度;正增益振幅、原声及独立音轨静音;五种字体实际字形输出。
|
||||
- 真实分析:PySceneDetect 3镜头、RMS 2有效区段、librosa 22个节拍。
|
||||
- 真实本地HTTP合成服务:鉴权、真实字体成片、进程强制中断后恢复、幂等、冲突拒绝、完成记录重启保留。
|
||||
- 浏览器独立测试:字幕新增和字体切换、新字幕56px、+0.5dB静音后恢复、折叠播放到片尾、Esc退出、卡点缺音乐拦截、选择音乐、广告默认3版。
|
||||
|
||||
本地独立页面仍用样例任务回调,不能冒充主站Next API端到端成功;上面的真实HTTP测试针对独立合成服务。测试目录 `.qa/smart-edit/fixes-0918/` 的日志与截图不提交到Git,复现测试脚本提交。
|
||||
|
||||
## 仍未完成
|
||||
|
||||
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/操作记录 |
|
||||
@ -1,5 +1,7 @@
|
||||
# 智能剪辑全面复核与像素差异(2026-09-17)
|
||||
|
||||
> 后续修复已更新至 [2026-09-18记录](smart-edit-fixes-2026-09-18.md)。本文保留09-17历史审计状态。
|
||||
|
||||
本轮在基线`431ca03f`之上复核 **95个检查点**,覆盖智能剪辑节点输入、编辑器布局、视频、字幕、转场、音频、导出恢复和边界。结论:**仍有遗漏,不能验收为全部功能齐全或100%像素一致**。这轮继续只在本地修复和验证,没有更新服务器。
|
||||
|
||||
该范围承接本次“转场预览图”对话,不扩大为整个视频画布、3D导演台或参考站全站重新验收;它们的旧清单不能被本轮结果覆盖。各检查点区别本轮UI、真实合成、代码核对、既有证据和待验,避免把功能存在误写为全链路成功。
|
||||
|
||||
@ -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" &&
|
||||
|
||||
56
lib/video-canvas/smart-edit/fonts.ts
Normal file
56
lib/video-canvas/smart-edit/fonts.ts
Normal 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);
|
||||
20
lib/video-canvas/smart-edit/intent.ts
Normal file
20
lib/video-canvas/smart-edit/intent.ts
Normal file
@ -0,0 +1,20 @@
|
||||
/** Explicit switches take precedence over the common beat-sync phrase shortcut. */
|
||||
export function resolveSmartEditIntent<
|
||||
T extends {
|
||||
mode?: string;
|
||||
prompt?: string;
|
||||
beatSync?: boolean;
|
||||
variants?: number;
|
||||
music?: { url: string };
|
||||
},
|
||||
>(draft: T) {
|
||||
const prompt = draft.prompt || "";
|
||||
const beatSync =
|
||||
draft.beatSync ??
|
||||
(/卡点|beat[ -]?sync/i.test(prompt) &&
|
||||
!/(?:不|不要|无需|不用|取消|关闭).{0,5}卡点|no beat[ -]?sync/i.test(prompt));
|
||||
return { ...draft, beatSync, variants: draft.variants ?? (draft.mode === "ads" ? 3 : 1) };
|
||||
}
|
||||
export function assertBeatMusic(draft: { beatSync?: boolean; music?: { url: string } }) {
|
||||
if (draft.beatSync && !draft.music?.url) throw Error("音乐卡点需要先选择或上传背景音乐");
|
||||
}
|
||||
@ -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++;
|
||||
|
||||
36
public/fonts/smart-edit/MANIFEST.json
Normal file
36
public/fonts/smart-edit/MANIFEST.json
Normal file
@ -0,0 +1,36 @@
|
||||
[
|
||||
{
|
||||
"file": "roboto.ttf",
|
||||
"upstreamSha256": "d7598e12c5dbef095ff8272cfc55da0250bd07fbdecbac8a530b9b277872a134",
|
||||
"sha256": "b55c3f5427be8b29e743ebc6a64128af5824c0cffd198fcfc643e4c5eafde0c8",
|
||||
"family": "Roboto",
|
||||
"weight": 400
|
||||
},
|
||||
{
|
||||
"file": "noto-serif-sc.ttf",
|
||||
"upstreamSha256": "050080d9255a86808f2945bffac582b31ef32bc36411ce29563b4961670c66f9",
|
||||
"sha256": "08ac21d3d82d929cb493f2523532dd32dce1d797964de493071b64df87afa6ed",
|
||||
"family": "Noto Serif SC ExtraLight",
|
||||
"weight": 400
|
||||
},
|
||||
{
|
||||
"file": "noto-sans-sc.ttf",
|
||||
"upstreamSha256": "a3041811a78c361b1de50f953c805e0244951c21c5bd412f7232ef0d899af0da",
|
||||
"sha256": "36716fbd3b964480d238030cab171b0ea756e959adc3d323995e2433a06c61e5",
|
||||
"family": "Noto Sans SC Thin",
|
||||
"weight": 400
|
||||
},
|
||||
{
|
||||
"file": "inter.ttf",
|
||||
"upstreamSha256": "29160a80ff49ddcab2c97711247e08b1fab27a484a329ce8b813d820dc559031",
|
||||
"sha256": "54d85e725cf1f763acda27f27bda373f0d0b9117fbf6f1d9015f04004d1ebd3d",
|
||||
"family": "Inter",
|
||||
"weight": 400
|
||||
},
|
||||
{
|
||||
"file": "source-han.otf",
|
||||
"sha256": "f1d8611151880c6c336aabeac4640ef434fa13cbfbf1ffe82d0a71b2a5637256",
|
||||
"family": "Source Han Sans SC",
|
||||
"weight": 400
|
||||
}
|
||||
]
|
||||
9
public/fonts/smart-edit/README.md
Normal file
9
public/fonts/smart-edit/README.md
Normal file
@ -0,0 +1,9 @@
|
||||
# 智能剪辑字幕字体
|
||||
|
||||
来源:Adobe 官方 [Source Han Sans](https://github.com/adobe-fonts/source-han-sans/tree/release/OTF/SimplifiedChinese) 与 [Google Fonts](https://github.com/google/fonts/tree/main/ofl)。各字体的完整 SIL Open Font License 文本随文件保存。`SOURCES.json` 保存下载来源;`MANIFEST.json` 保存文件 SHA-256 和上游下载摘要。
|
||||
|
||||
Google 的可变 TTF 使用 fontTools 4.59.1 实例化为 400 字重,其他轴保持上游默认值,以让浏览器与 libass 使用相同静态轮廓。Adobe 文件为未修改的 Regular OTF。Source Han 在选单保留参考名称 Source Han Sans CN,实际文件的内置家族名为 Source Han Sans SC;Google CJK 文件保留上游 name 表,其排版家族名由 `nameID=16` 定义,旧版家族名可能包含 Thin/ExtraLight,不代表当前实例的字重。
|
||||
|
||||
浏览器字体按需加载,使用所选 TTF/OTF 原文件;导出把同一文件和中文回退 Source Han 复制到任务的 `fonts/` 目录,并通过 libass `fontsdir` 引用。构建独立合成服务时会把整个字体包复制到 `dist/fonts`。
|
||||
|
||||
这五个实际可用字体不等于参考站完整目录;未添加只有名称、没有字体文件的假选项。
|
||||
37
public/fonts/smart-edit/SOURCES.json
Normal file
37
public/fonts/smart-edit/SOURCES.json
Normal file
@ -0,0 +1,37 @@
|
||||
[
|
||||
[
|
||||
"source-han",
|
||||
"adobe-fonts/source-han-sans",
|
||||
"release",
|
||||
"OTF/SimplifiedChinese/SourceHanSansSC-Regular.otf",
|
||||
"LICENSE.txt"
|
||||
],
|
||||
[
|
||||
"noto-sans-sc",
|
||||
"google/fonts",
|
||||
"main",
|
||||
"ofl/notosanssc/NotoSansSC[wght].ttf",
|
||||
"ofl/notosanssc/OFL.txt"
|
||||
],
|
||||
[
|
||||
"noto-serif-sc",
|
||||
"google/fonts",
|
||||
"main",
|
||||
"ofl/notoserifsc/NotoSerifSC[wght].ttf",
|
||||
"ofl/notoserifsc/OFL.txt"
|
||||
],
|
||||
[
|
||||
"roboto",
|
||||
"google/fonts",
|
||||
"main",
|
||||
"ofl/roboto/Roboto[wdth,wght].ttf",
|
||||
"ofl/roboto/OFL.txt"
|
||||
],
|
||||
[
|
||||
"inter",
|
||||
"google/fonts",
|
||||
"main",
|
||||
"ofl/inter/Inter[opsz,wght].ttf",
|
||||
"ofl/inter/OFL.txt"
|
||||
]
|
||||
]
|
||||
93
public/fonts/smart-edit/inter-LICENSE.txt
Normal file
93
public/fonts/smart-edit/inter-LICENSE.txt
Normal file
@ -0,0 +1,93 @@
|
||||
Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
BIN
public/fonts/smart-edit/inter.ttf
Normal file
BIN
public/fonts/smart-edit/inter.ttf
Normal file
Binary file not shown.
93
public/fonts/smart-edit/noto-sans-sc-LICENSE.txt
Normal file
93
public/fonts/smart-edit/noto-sans-sc-LICENSE.txt
Normal file
@ -0,0 +1,93 @@
|
||||
Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
BIN
public/fonts/smart-edit/noto-sans-sc.ttf
Normal file
BIN
public/fonts/smart-edit/noto-sans-sc.ttf
Normal file
Binary file not shown.
94
public/fonts/smart-edit/noto-serif-sc-LICENSE.txt
Normal file
94
public/fonts/smart-edit/noto-serif-sc-LICENSE.txt
Normal file
@ -0,0 +1,94 @@
|
||||
Copyright 2012 Google Inc. All Rights Reserved.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License,
|
||||
Version 1.1.
|
||||
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font
|
||||
creation efforts of academic and linguistic communities, and to
|
||||
provide a free and open framework in which fonts may be shared and
|
||||
improved in partnership with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply to
|
||||
any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software
|
||||
components as distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to,
|
||||
deleting, or substituting -- in part or in whole -- any of the
|
||||
components of the Original Version, by changing formats or by porting
|
||||
the Font Software to a new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed,
|
||||
modify, redistribute, and sell modified and unmodified copies of the
|
||||
Font Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components, in
|
||||
Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the
|
||||
corresponding Copyright Holder. This restriction only applies to the
|
||||
primary font name as presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created using
|
||||
the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
BIN
public/fonts/smart-edit/noto-serif-sc.ttf
Normal file
BIN
public/fonts/smart-edit/noto-serif-sc.ttf
Normal file
Binary file not shown.
93
public/fonts/smart-edit/roboto-LICENSE.txt
Normal file
93
public/fonts/smart-edit/roboto-LICENSE.txt
Normal file
@ -0,0 +1,93 @@
|
||||
Copyright 2011 The Roboto Project Authors (https://github.com/googlefonts/roboto-classic)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
BIN
public/fonts/smart-edit/roboto.ttf
Normal file
BIN
public/fonts/smart-edit/roboto.ttf
Normal file
Binary file not shown.
96
public/fonts/smart-edit/source-han-LICENSE.txt
Normal file
96
public/fonts/smart-edit/source-han-LICENSE.txt
Normal file
@ -0,0 +1,96 @@
|
||||
Copyright 2014-2025 Adobe (http://www.adobe.com/), with Reserved Font
|
||||
Name 'Source'. Source is a trademark of Adobe in the United States
|
||||
and/or other countries.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License,
|
||||
Version 1.1.
|
||||
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font
|
||||
creation efforts of academic and linguistic communities, and to
|
||||
provide a free and open framework in which fonts may be shared and
|
||||
improved in partnership with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply to
|
||||
any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software
|
||||
components as distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to,
|
||||
deleting, or substituting -- in part or in whole -- any of the
|
||||
components of the Original Version, by changing formats or by porting
|
||||
the Font Software to a new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed,
|
||||
modify, redistribute, and sell modified and unmodified copies of the
|
||||
Font Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components, in
|
||||
Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the
|
||||
corresponding Copyright Holder. This restriction only applies to the
|
||||
primary font name as presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created using
|
||||
the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
BIN
public/fonts/smart-edit/source-han.otf
Normal file
BIN
public/fonts/smart-edit/source-han.otf
Normal file
Binary file not shown.
@ -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(
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 许可保存在字体目录。
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user