562 lines
20 KiB
TypeScript

"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,
type CaptionStyle,
planTimeline,
SECOND,
type EditPlan,
defaultVideoTransform,
type VideoTransform,
type TimelineCaption,
} from "@/lib/video-canvas/smart-edit/contract";
export function EditPreview({
plan,
time,
playing,
onTime,
onPlaying,
onError,
fit = false,
cover = false,
externalClock = false,
selectedClipId,
selectedCaptionId,
onSelectClip,
onSelectCaption,
onTransform,
onCaptionPosition,
}: {
fit?: boolean;
cover?: boolean;
externalClock?: 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;
onTime: (t: number) => void;
onPlaying: (v: boolean) => void;
onError: (s: string) => void;
}) {
const videos = useRef(new Map<string, HTMLVideoElement>()),
music = useRef<HTMLAudioElement | null>(null),
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(() => {
const el = frameRef.current;
if (!el) return;
const observer = new ResizeObserver(([entry]) =>
setFrameSize({ width: entry.contentRect.width, height: entry.contentRect.height }),
);
observer.observe(el);
return () => observer.disconnect();
}, []);
const timeline = planTimeline(plan),
visibleTime = Math.min(time * SECOND, timeline.duration - 1),
current = timeline.clips.filter(
(c) => visibleTime >= c.renderStart && visibleTime < c.renderEnd,
),
subs = plan.subtitles
? editableCaptions(plan).filter((s) => time * SECOND >= s.start && time * SECOND < s.end)
: [];
useEffect(() => {
if (!playing || externalClock) return;
let frame = 0,
last = 0;
const tick = (now: number) => {
const state = latest.current;
if (last) {
const next = Math.min(
planTimeline(state.plan).duration / SECOND,
state.time + (now - last) / 1000,
);
state.onTime(next);
if (next >= planTimeline(state.plan).duration / SECOND) {
state.onPlaying(false);
return;
}
}
last = now;
frame = requestAnimationFrame(tick);
};
const hide = () => {
if (document.hidden) latest.current.onPlaying(false);
};
frame = requestAnimationFrame(tick);
document.addEventListener("visibilitychange", hide);
return () => {
cancelAnimationFrame(frame);
document.removeEventListener("visibilitychange", hide);
};
}, [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;
const active = current.some((c) => c.id === clip.id);
if (!active) {
video.pause();
continue;
}
const target = Math.max(
clip.sourceIn / SECOND,
Math.min(
clip.sourceOut / SECOND - 1 / 30,
clip.sourceIn / SECOND + (time - clip.start / SECOND) * clip.speed,
),
);
const held = time * SECOND < clip.start || time * SECOND >= clip.end;
if (Number.isFinite(target) && Math.abs(video.currentTime - target) > 0.1)
video.currentTime = target;
video.playbackRate = clip.speed;
const index = timeline.clips.findIndex((c) => c.id === clip.id),
next = timeline.clips[index + 1];
let gain = 1;
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,
);
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;
onPlaying(false);
onError("预览播放失败,请检查素材链接或点击播放重试");
});
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
? (clip.fadeInStart || 0) + ((1 - (clip.fadeInStart || 0)) * relative) / fadeIn
: 1,
) *
Math.min(
1,
fadeOut
? (clip.fadeOutEnd || 0) +
((1 - (clip.fadeOutEnd || 0)) * (length - relative)) / fadeOut
: 1,
);
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;
onPlaying(false);
onError("独立音轨无法播放");
});
else audio.pause();
}
const audio = music.current;
if (audio && plan.music) {
const target = time + plan.music.start / SECOND;
if (Number.isFinite(audio.duration) && target >= audio.duration) {
audio.pause();
return;
}
if (Math.abs(audio.currentTime - target) > 0.15) audio.currentTime = target;
audio.volume = Math.min(1, plan.music.gain);
if (playing)
void audio.play().catch((error) => {
if (error?.name === "AbortError") return;
onPlaying(false);
onError("背景音乐无法播放");
});
else audio.pause();
}
}, [time, playing, plan]);
const [ratioWidth, ratioHeight] = plan.ratio.split(":").map(Number);
const ratio = ratioWidth / ratioHeight;
const height = Math.min(frameSize.height || 720, (frameSize.width || 1280) / ratio);
const incomingClip = current.find(
(c) => c.overlap && visibleTime < c.transitionStart + c.overlap,
);
const progress = incomingClip
? (visibleTime - incomingClip.transitionStart) / 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 (
<div
ref={frameRef}
className={
fit
? "relative h-full w-full overflow-hidden"
: "relative mx-auto w-full max-w-4xl overflow-hidden bg-black"
}
style={fit ? undefined : { aspectRatio: plan.ratio.replace(":", "/") }}
aria-label="剪辑实时预览"
>
<div
style={
fit && !cover
? {
position: "absolute",
width: height * ratio,
height,
left: "50%",
top: "50%",
transform: "translate(-50%,-50%)",
overflow: "hidden",
background: "black",
}
: { position: "absolute", inset: 0 }
}
>
{timeline.clips.map((clip) => {
const asset = plan.assets.find((a) => a.id === clip.assetId)!;
const active = current.some((c) => c.id === clip.id),
incoming = clip.overlap && visibleTime < clip.transitionStart + clip.overlap;
return (
<video
key={`${clip.id}:${audioGain.source(asset.url)}`}
ref={(element) => {
if (element) videos.current.set(clip.id, element);
else videos.current.delete(clip.id);
}}
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"}`}
style={{
visibility: active ? "visible" : "hidden",
transform: (() => {
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
: progress < 0.5
? 0
: 1
: 1,
}}
onLoadedMetadata={(e) => {
const target =
clip.sourceIn / SECOND +
(Math.max(0, visibleTime - clip.start) / SECOND) * clip.speed;
e.currentTarget.currentTime = Math.min(clip.sourceOut / SECOND - 0.001, target);
}}
onError={() => {
onPlaying(false);
onError(`${asset.name} 预览加载失败`);
}}
/>
);
})}
{timeline.audioClips.map((c) => (
<audio
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={audioGain.source(plan.assets.find((a) => a.id === c.assetId)!.url)}
preload="metadata"
/>
))}
{plan.music ? <audio ref={music} src={plan.music.url} preload="metadata" /> : null}
{incomingClip && incomingClip.transitionType !== "fade" && (
<div
style={{
position: "absolute",
inset: 0,
pointerEvents: "none",
background: incomingClip.transitionType === "fadeblack" ? "black" : "white",
opacity: 1 - Math.abs(2 * progress - 1),
}}
/>
)}
{selectedClip && selectedAsset && selectedTransform && onTransform && (
<div
aria-label="调整画面位置"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => moveTransform(e, selectedClip.id)}
style={{
position: "absolute",
left: `${(selectedTransform.x + 0.5) * 100}%`,
top: `${(selectedTransform.y + 0.5) * 100}%`,
width:
Math.min(
height * ratio,
(height * selectedAsset.width) / selectedAsset.height,
) * selectedTransform.scale,
height:
Math.min(
height,
(height * ratio * selectedAsset.height) / selectedAsset.width,
) * selectedTransform.scale,
transform: "translate(-50%,-50%)",
border: "2px solid #87c9ff",
cursor: "move",
zIndex: 3,
}}
>
{(
[
[-1, -1],
[1, -1],
[1, 1],
[-1, 1],
] as [number, number][]
).map(([x, y]) => (
<button
key={`${x}:${y}`}
type="button"
aria-label="调整视频尺寸"
onPointerDown={(e) => moveTransform(e, selectedClip.id, [x, y])}
style={{
position: "absolute",
width: 12,
height: 12,
left: x < 0 ? 0 : "100%",
top: y < 0 ? 0 : "100%",
transform: "translate(-50%,-50%)",
border: "1px solid white",
background: "#87c9ff",
borderRadius: 2,
cursor: x === y ? "nwse-resize" : "nesw-resize",
}}
/>
))}
</div>
)}
<div className="pointer-events-none absolute inset-0 text-center" style={{ zIndex: 4 }}>
{subs.map((s, i) => {
const style = (s as { style?: CaptionStyle }).style || defaultCaptionStyle;
return (
<div
key={i}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => {
if (onCaptionPosition && "id" in s) moveCaption(e, s.id as string, style);
}}
style={{
position: "absolute",
left: `${((gesture && gesture.captionId === (s as TimelineCaption).id ? gesture.x : style.x) ?? 0.5) * 100}%`,
bottom: `${(1 - ((gesture && gesture.captionId === (s as TimelineCaption).id ? gesture.y : style.y) ?? 0.95)) * 100}%`,
transform: "translateX(-50%)",
maxWidth: "95%",
pointerEvents: onCaptionPosition ? "auto" : "none",
cursor: onCaptionPosition ? "move" : undefined,
outline:
selectedCaptionId === (s as TimelineCaption).id
? "1px solid #87c9ff"
: undefined,
fontFamily: `"${captionFont(style.fontFamily)?.family || "Noto Sans CJK SC"}", "Source Han Sans SC", sans-serif`,
whiteSpace: "pre-wrap",
fontSize: (style.fontSize * height) / (style.referenceHeight || 720),
letterSpacing: (style.spacing * height) / (style.referenceHeight || 720),
color: style.color,
opacity: style.opacity,
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,
}}
>
{s.text}
</div>
);
})}
</div>
</div>
</div>
);
}