150 lines
5.0 KiB
TypeScript

"use client";
import { useEffect, useRef } from "react";
import {
compiledSubtitles,
planTimeline,
SECOND,
type EditPlan,
} from "@/lib/video-canvas/smart-edit/contract";
export function EditPreview({
plan,
time,
playing,
onTime,
onPlaying,
onError,
}: {
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),
latest = useRef({ time, plan, onTime, onPlaying });
latest.current = { time, plan, onTime, onPlaying };
const timeline = planTimeline(plan),
current = timeline.clips.filter((c) => time * SECOND >= c.start && time * SECOND < c.end),
subs = compiledSubtitles(plan).filter((s) => time * SECOND >= s.start && time * SECOND < s.end);
useEffect(() => {
if (!playing) 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]);
useEffect(() => {
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 = clip.sourceIn / SECOND + (time - clip.start / SECOND) * clip.speed;
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.start + clip.overlap)
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));
if (playing)
void video.play().catch(() => {
onPlaying(false);
onError("预览播放失败,请检查素材链接或点击播放重试");
});
else video.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(() => {
onPlaying(false);
onError("背景音乐无法播放");
});
else audio.pause();
}
}, [time, playing, plan]);
return (
<div
className="relative mx-auto w-full max-w-4xl overflow-hidden bg-black"
style={{ aspectRatio: plan.ratio.replace(":", "/") }}
aria-label="剪辑实时预览"
>
{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 && time * SECOND < clip.start + clip.overlap;
return (
<video
key={clip.id}
ref={(element) => {
if (element) videos.current.set(clip.id, element);
else videos.current.delete(clip.id);
}}
src={asset.url}
playsInline
preload="metadata"
className="absolute inset-0 h-full w-full object-contain"
style={{
visibility: active ? "visible" : "hidden",
opacity: incoming ? Math.max(0, (time * SECOND - clip.start) / clip.overlap) : 1,
}}
onError={() => {
onPlaying(false);
onError(`${asset.name} 预览加载失败`);
}}
/>
);
})}
{plan.music ? <audio ref={music} src={plan.music.url} preload="metadata" /> : null}
<div
className="pointer-events-none absolute inset-x-4 bottom-[5%] text-center text-[clamp(12px,2vw,22px)] text-white"
style={{ textShadow: "-1px -1px 2px black,1px 1px 2px black" }}
>
{subs.map((s, i) => (
<div key={i}>{s.text}</div>
))}
</div>
</div>
);
}