232 lines
8.0 KiB
TypeScript
232 lines
8.0 KiB
TypeScript
"use client";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import {
|
|
compiledSubtitles,
|
|
defaultCaptionStyle,
|
|
type CaptionStyle,
|
|
planTimeline,
|
|
SECOND,
|
|
type EditPlan,
|
|
} from "@/lib/video-canvas/smart-edit/contract";
|
|
export function EditPreview({
|
|
plan,
|
|
time,
|
|
playing,
|
|
onTime,
|
|
onPlaying,
|
|
onError,
|
|
fit = false,
|
|
}: {
|
|
fit?: boolean;
|
|
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 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.start && visibleTime < 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]);
|
|
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.start + c.overlap);
|
|
const progress = incomingClip ? (visibleTime - incomingClip.start) / incomingClip.overlap : 1;
|
|
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
|
|
? {
|
|
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.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
|
|
? 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} 预览加载失败`);
|
|
}}
|
|
/>
|
|
);
|
|
})}
|
|
{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),
|
|
}}
|
|
/>
|
|
)}
|
|
<div className="pointer-events-none absolute inset-x-4 bottom-[5%] text-center">
|
|
{subs.map((s, i) => {
|
|
const style = (s as { style?: CaptionStyle }).style || defaultCaptionStyle;
|
|
return (
|
|
<div
|
|
key={i}
|
|
style={{
|
|
fontFamily: '"Noto Sans CJK SC", "Source Han Sans CN", sans-serif',
|
|
whiteSpace: "pre-wrap",
|
|
fontSize: style.fontSize * scale,
|
|
letterSpacing: style.spacing * scale,
|
|
color: style.color,
|
|
opacity: style.opacity,
|
|
WebkitTextStroke: `${style.outlineWidth * scale * 2}px ${style.outlineColor}`,
|
|
paintOrder: "stroke fill",
|
|
lineHeight: 1.2,
|
|
}}
|
|
>
|
|
{s.text}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|