diff --git a/components/canvas/LiblibVideoCanvasReplica.tsx b/components/canvas/LiblibVideoCanvasReplica.tsx
index b81b4f808..a8a95756f 100644
--- a/components/canvas/LiblibVideoCanvasReplica.tsx
+++ b/components/canvas/LiblibVideoCanvasReplica.tsx
@@ -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}
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 || {}),...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;
+ const draft = resolveSmartEditIntent((selectedNode.draftValues?.smartEditDraft || {}) as Record);
+ 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 onAction("打开智能剪辑")} />;
+ if (kind === "smart-edit") {
+ const draft = node.draftValues?.smartEditDraft as Record | undefined;
+ const plans = draft?.planTaskId === node.draftValues?.smartEditPlanTaskId ? draft?.plans || node.draftValues?.smartEditPlans : node.draftValues?.smartEditPlans;
+ return onAction("打开智能剪辑")} />;
+ }
const HeaderIcon = kind === "video-clip" ? Scissors : kind === "director" ? Settings2 : kind === "audio" ? Music2 : kind === "video" ? Video : kind === "image" ? ImageIcon : FileText;
const actions: Record> = {
"smart-edit": [{icon:Scissors,label:"打开智能剪辑"}],
diff --git a/components/canvas/smart-edit/CaptionFonts.tsx b/components/canvas/smart-edit/CaptionFonts.tsx
new file mode 100644
index 000000000..a4288f21b
--- /dev/null
+++ b/components/canvas/smart-edit/CaptionFonts.tsx
@@ -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>();
+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) => void;
+ onError: (message: string) => void;
+}) {
+ const [open, setOpen] = useState(false),
+ [query, setQuery] = useState(""),
+ [category, setCategory] = useState("全部"),
+ [busy, setBusy] = useState(false);
+ const root = useRef(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 (
+
+
+ {open && (
+
+
+ setQuery(e.target.value)}
+ />
+
+
+
+ {["全部", "常用", "推荐", "Google"].map((name) => (
+
+ ))}
+
+
+ {options.map((font) => (
+
+ ))}
+
+ {!options.length &&
没有匹配的字体
}
+ {busy &&
正在加载字体…
}
+
+ )}
+
+ );
+}
diff --git a/components/canvas/smart-edit/EditPreview.tsx b/components/canvas/smart-edit/EditPreview.tsx
index 2ea458f6c..80feb009d 100644
--- a/components/canvas/smart-edit/EditPreview.tsx
+++ b/components/canvas/smart-edit/EditPreview.tsx
@@ -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()),
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(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 (
) : null}
{cap && !cap.capabilities.render ? (
- 视频合成服务暂不可用,剪辑方案仍可保存。
+
+ 视频合成服务暂不可用,剪辑方案仍可保存。
+
) : 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({
@@ -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) })
+ }
>
@@ -858,27 +885,29 @@ export function SmartEditDialog({
{i + 1} · {plan.assets.find((a) => a.id === clip.assetId)?.name}
- {(["sourceIn", "sourceOut", "speed", "gain"] as const).map((key, j) => (
-
-
- clipEdit(i, {
- [key]:
- j < 2
- ? Math.round(Number(e.target.value) * SECOND)
- : Number(e.target.value),
- })
- }
- />
- |
- ))}
+ {(["sourceIn", "sourceOut", "speed", "gain"] as const).map(
+ (key, j) => (
+
+
+ clipEdit(i, {
+ [key]:
+ j < 2
+ ? Math.round(Number(e.target.value) * SECOND)
+ : Number(e.target.value),
+ })
+ }
+ />
+ |
+ ),
+ )}
{[-1, 1].map((delta) => (
|