From 84bfd6b9f4e112a381ca63601903af931df1ef62 Mon Sep 17 00:00:00 2001 From: 134 Date: Thu, 17 Sep 2026 18:32:49 +0800 Subject: [PATCH] Support local preview transforms and detached smart edit audio tracks --- components/canvas/smart-edit/EditPreview.tsx | 244 ++++++++++- .../smart-edit/SmartEditEditor.module.css | 41 ++ .../canvas/smart-edit/SmartEditEditor.tsx | 387 +++++++++++++++++- docs/smart-edit-ui-parity-2026-09-17.md | 16 + docs/video-render-service-2026-09-17.md | 6 + lib/video-canvas/smart-edit/contract.ts | 45 +- lib/video-canvas/smart-edit/render.ts | 66 ++- scripts/qa/video-canvas-smart-edit.cjs | 187 +++++++++ tests/routes/video-canvas-smart-edit.test.ts | 47 +++ 9 files changed, 1003 insertions(+), 36 deletions(-) diff --git a/components/canvas/smart-edit/EditPreview.tsx b/components/canvas/smart-edit/EditPreview.tsx index 3f07b72b0..e1a405e1f 100644 --- a/components/canvas/smart-edit/EditPreview.tsx +++ b/components/canvas/smart-edit/EditPreview.tsx @@ -1,12 +1,15 @@ "use client"; import { useEffect, useRef, useState } from "react"; import { - compiledSubtitles, + editableCaptions, defaultCaptionStyle, type CaptionStyle, planTimeline, SECOND, type EditPlan, + defaultVideoTransform, + type VideoTransform, + type TimelineCaption, } from "@/lib/video-canvas/smart-edit/contract"; export function EditPreview({ plan, @@ -16,8 +19,20 @@ export function EditPreview({ onPlaying, onError, fit = false, + selectedClipId, + selectedCaptionId, + onSelectClip, + onSelectCaption, + onTransform, + onCaptionPosition, }: { fit?: boolean; + selectedClipId?: string; + selectedCaptionId?: string; + onSelectClip?: (id: string) => void; + onSelectCaption?: (id: string) => void; + onTransform?: (id: string, value: VideoTransform) => void; + onCaptionPosition?: (id: string, value: { x: number; y: number }) => void; plan: EditPlan; time: number; playing: boolean; @@ -27,6 +42,7 @@ export function EditPreview({ }) { const videos = useRef(new Map()), music = useRef(null), + audios = useRef(new Map()), latest = useRef({ time, plan, onTime, onPlaying }); latest.current = { time, plan, onTime, onPlaying }; const frameRef = useRef(null); @@ -43,7 +59,9 @@ export function EditPreview({ const timeline = planTimeline(plan), visibleTime = Math.min(time * SECOND, timeline.duration - 1), current = timeline.clips.filter((c) => visibleTime >= c.start && visibleTime < c.end), - subs = compiledSubtitles(plan).filter((s) => time * SECOND >= s.start && time * SECOND < s.end); + subs = plan.subtitles + ? editableCaptions(plan).filter((s) => time * SECOND >= s.start && time * SECOND < s.end) + : []; useEffect(() => { if (!playing) return; let frame = 0, @@ -94,7 +112,7 @@ export function EditPreview({ gain = (time * SECOND - clip.start) / clip.overlap; if (next?.overlap && time * SECOND > next.start) gain = Math.min(gain, (clip.end - time * SECOND) / next.overlap); - video.volume = Math.min(1, Math.max(0, clip.gain * gain)); + video.volume = clip.audioDetached ? 0 : Math.min(1, Math.max(0, clip.gain * gain)); if (playing) void video.play().catch(() => { onPlaying(false); @@ -102,6 +120,31 @@ export function EditPreview({ }); else video.pause(); } + for (const clip of timeline.audioClips) { + const audio = audios.current.get(clip.id); + if (!audio) continue; + const relative = time - clip.start / SECOND; + if (relative < 0 || time * SECOND >= clip.end) { + audio.pause(); + continue; + } + const target = clip.sourceIn / SECOND + relative * clip.speed; + if (Math.abs(audio.currentTime - target) > 0.1) audio.currentTime = target; + audio.playbackRate = clip.speed; + const length = clip.duration / SECOND, + fadeIn = Math.min(clip.fadeIn, length), + fadeOut = Math.min(clip.fadeOut, length); + const gain = + Math.min(1, fadeIn ? relative / fadeIn : 1) * + Math.min(1, fadeOut ? (length - relative) / fadeOut : 1); + audio.volume = Math.max(0, Math.min(1, clip.gain * gain)); + if (playing) + void audio.play().catch(() => { + onPlaying(false); + onError("独立音轨无法播放"); + }); + else audio.pause(); + } const audio = music.current; if (audio && plan.music) { const target = time + plan.music.start / SECOND; @@ -125,6 +168,116 @@ export function EditPreview({ const scale = height / 720; const incomingClip = current.find((c) => c.overlap && visibleTime < c.start + c.overlap); const progress = incomingClip ? (visibleTime - incomingClip.start) / incomingClip.overlap : 1; + const [gesture, setGesture] = useState<{ + clipId?: string; + captionId?: string; + transform?: VideoTransform; + x?: number; + y?: number; + }>(); + const gestureCleanup = useRef<() => void>(); + useEffect(() => () => gestureCleanup.current?.(), []); + const moveTransform = (e: React.PointerEvent, id: string, corner?: [number, number]) => { + e.preventDefault(); + e.stopPropagation(); + onPlaying(false); + onSelectClip?.(id); + const c = plan.clips.find((c) => c.id === id)!, + a = plan.assets.find((a) => a.id === c.assetId)!, + original = c.transform || defaultVideoTransform; + const frameWidth = height * ratio, + baseWidth = Math.min(frameWidth, (height * a.width) / a.height), + baseHeight = (baseWidth * a.height) / a.width, + startX = e.clientX, + startY = e.clientY; + let value = original; + const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v)); + const move = (ev: PointerEvent) => { + const dx = ev.clientX - startX, + dy = ev.clientY - startY; + if (corner) { + const nextScale = clamp( + original.scale + ((dx * corner[0]) / baseWidth + (dy * corner[1]) / baseHeight) / 2, + 0.1, + 3, + ), + delta = nextScale - original.scale; + value = { + scale: nextScale, + x: clamp(original.x + (delta * baseWidth * corner[0]) / 2 / frameWidth, -1, 1), + y: clamp(original.y + (delta * baseHeight * corner[1]) / 2 / height, -1, 1), + }; + } else + value = { + ...original, + x: clamp(original.x + dx / frameWidth, -1, 1), + y: clamp(original.y + dy / height, -1, 1), + }; + setGesture({ clipId: id, transform: value }); + }; + const cleanup = () => { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", end); + window.removeEventListener("pointercancel", cancel); + gestureCleanup.current = undefined; + }; + const end = () => { + cleanup(); + setGesture(undefined); + onTransform?.(id, value); + }, + cancel = () => { + cleanup(); + setGesture(undefined); + }; + gestureCleanup.current?.(); + gestureCleanup.current = cleanup; + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", end); + window.addEventListener("pointercancel", cancel); + }; + const moveCaption = (e: React.PointerEvent, id: string, style: CaptionStyle) => { + e.preventDefault(); + e.stopPropagation(); + onPlaying(false); + onSelectCaption?.(id); + const startX = e.clientX, + startY = e.clientY; + 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))), + y: Math.max(0, Math.min(1, (style.y ?? 0.95) + (ev.clientY - startY) / height)), + }; + setGesture({ captionId: id, ...value }); + }; + const cleanup = () => { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", end); + window.removeEventListener("pointercancel", cancel); + gestureCleanup.current = undefined; + }; + const end = () => { + cleanup(); + setGesture(undefined); + onCaptionPosition?.(id, value); + }, + cancel = () => { + cleanup(); + setGesture(undefined); + }; + gestureCleanup.current?.(); + gestureCleanup.current = cleanup; + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", end); + window.addEventListener("pointercancel", cancel); + }; + const selectedClip = current.find((c) => c.id === selectedClipId); + const selectedAsset = selectedClip && plan.assets.find((a) => a.id === selectedClip.assetId); + const selectedTransform = + gesture && gesture.clipId === selectedClipId + ? gesture.transform + : selectedClip?.transform || defaultVideoTransform; return (
{ + const t = + gesture?.clipId === clip.id + ? gesture.transform! + : clip.transform || defaultVideoTransform; + return `translate(${t.x * 100}%,${t.y * 100}%) scale(${t.scale})`; + })(), opacity: incoming ? clip.transitionType === "fade" ? progress @@ -190,6 +350,17 @@ export function EditPreview({ /> ); })} + {timeline.audioClips.map((c) => ( +