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 (