Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NLSCTnhge4umMnemGXWr1w
211 lines
6.3 KiB
TypeScript
211 lines
6.3 KiB
TypeScript
import type { Edl } from "@rakazo/contracts";
|
|
import { cn } from "@rakazo/ui-web";
|
|
import { useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
|
|
import { assetFileUrl, useMediaObjectUrl } from "./media-files";
|
|
import { clipLengthMs, clipOutputStartMs, edlDurationMs, locateOutputTime } from "./timeline";
|
|
|
|
export interface PlayerHandle {
|
|
/** Jump to an output timestamp in ms. */
|
|
seek(ms: number): void;
|
|
}
|
|
|
|
/** What the source player currently shows, in source coordinates. */
|
|
export interface SourceFrame {
|
|
outputMs: number;
|
|
clipId: string;
|
|
assetId: string;
|
|
sourceMs: number;
|
|
}
|
|
|
|
/** Plays a finished render. Reports the playhead in output ms. */
|
|
export function RenderVideo({
|
|
src,
|
|
poster,
|
|
handleRef,
|
|
onTime,
|
|
className,
|
|
label,
|
|
}: {
|
|
src: string | null;
|
|
poster?: string | null;
|
|
handleRef?: React.Ref<PlayerHandle>;
|
|
onTime?: (ms: number) => void;
|
|
className?: string;
|
|
label: string;
|
|
}) {
|
|
const video = useRef<HTMLVideoElement>(null);
|
|
const pending = useRef<number | null>(null);
|
|
useImperativeHandle(handleRef, () => ({
|
|
seek(ms) {
|
|
const element = video.current;
|
|
if (!element || element.readyState < 1) {
|
|
pending.current = ms;
|
|
return;
|
|
}
|
|
element.currentTime = ms / 1000;
|
|
onTime?.(ms);
|
|
},
|
|
}));
|
|
return (
|
|
// biome-ignore lint/a11y/useMediaCaption: renders carry burned-in or sidecar captions.
|
|
<video
|
|
ref={video}
|
|
aria-label={label}
|
|
className={cn("aspect-video w-full rounded-lg bg-muted object-contain", className)}
|
|
controls
|
|
playsInline
|
|
preload="metadata"
|
|
poster={poster ?? undefined}
|
|
src={src ?? undefined}
|
|
onLoadedMetadata={(event) => {
|
|
if (pending.current === null) return;
|
|
event.currentTarget.currentTime = pending.current / 1000;
|
|
pending.current = null;
|
|
}}
|
|
onTimeUpdate={(event) => onTime?.(Math.round(event.currentTarget.currentTime * 1000))}
|
|
/>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Plays a plan's timeline from the source proxies (the unredacted "before"
|
|
* view, and the only view of a render the gate blocked). Clips play in order;
|
|
* gaps and the intro show nothing.
|
|
*/
|
|
export function SourcePlayer({
|
|
edl,
|
|
handleRef,
|
|
onFrame,
|
|
label,
|
|
children,
|
|
}: {
|
|
edl: Edl;
|
|
handleRef?: React.Ref<PlayerHandle>;
|
|
onFrame?: (frame: SourceFrame | null) => void;
|
|
label: string;
|
|
/** Overlays positioned over the video frame (masks, mark prompts). */
|
|
children?: React.ReactNode;
|
|
}) {
|
|
const [outputMs, setOutputMs] = useState(() => clipOutputStartMs(edl, firstClip(edl)));
|
|
const located = locateOutputTime(edl, outputMs);
|
|
const assetId = located?.clip.assetId ?? null;
|
|
const src = useMediaObjectUrl(assetId ? assetFileUrl(assetId, "proxy") : null);
|
|
const video = useRef<HTMLVideoElement>(null);
|
|
const playing = useRef(false);
|
|
const [ratio, setRatio] = useState(16 / 9);
|
|
|
|
const report = useCallback(
|
|
(ms: number) => {
|
|
const at = locateOutputTime(edl, ms);
|
|
onFrame?.(
|
|
at
|
|
? { outputMs: ms, clipId: at.clip.id, assetId: at.clip.assetId, sourceMs: at.sourceMs }
|
|
: null,
|
|
);
|
|
},
|
|
[edl, onFrame],
|
|
);
|
|
|
|
const seek = useCallback(
|
|
(ms: number) => {
|
|
const clamped = Math.max(0, Math.min(ms, edlDurationMs(edl)));
|
|
setOutputMs(clamped);
|
|
const at = locateOutputTime(edl, clamped);
|
|
const element = video.current;
|
|
if (at && element && at.clip.assetId === assetId && element.readyState >= 1) {
|
|
element.currentTime = at.sourceMs / 1000;
|
|
}
|
|
report(clamped);
|
|
},
|
|
[edl, assetId, report],
|
|
);
|
|
useImperativeHandle(handleRef, () => ({ seek }), [seek]);
|
|
|
|
// A new clip's proxy loaded: start at the mapped source time.
|
|
const onLoaded = (element: HTMLVideoElement) => {
|
|
if (element.videoWidth > 0 && element.videoHeight > 0) {
|
|
setRatio(element.videoWidth / element.videoHeight);
|
|
}
|
|
if (located) element.currentTime = located.sourceMs / 1000;
|
|
if (playing.current) void element.play().catch(() => undefined);
|
|
};
|
|
|
|
const onTimeUpdate = (element: HTMLVideoElement) => {
|
|
if (!located) return;
|
|
const { clip } = located;
|
|
const sourceMs = element.currentTime * 1000;
|
|
const clipEnd = clipOutputStartMs(edl, clip) + clipLengthMs(clip);
|
|
if (sourceMs >= clip.outMs) {
|
|
const next = nextClipStart(edl, clipEnd);
|
|
if (next === null) {
|
|
element.pause();
|
|
playing.current = false;
|
|
return;
|
|
}
|
|
setOutputMs(next);
|
|
report(next);
|
|
return;
|
|
}
|
|
const ms = clipOutputStartMs(edl, clip) + Math.round((sourceMs - clip.inMs) / clip.speed);
|
|
setOutputMs(ms);
|
|
report(ms);
|
|
};
|
|
|
|
// Report once per plan; playback reports through onTimeUpdate.
|
|
useEffect(() => {
|
|
report(outputMs);
|
|
}, [edl]);
|
|
|
|
return (
|
|
<div className="relative aspect-video w-full overflow-hidden rounded-lg bg-muted">
|
|
{/* biome-ignore lint/a11y/useMediaCaption: source footage has no caption track. */}
|
|
<video
|
|
ref={video}
|
|
aria-label={label}
|
|
className="absolute inset-0 h-full w-full object-contain"
|
|
controls
|
|
playsInline
|
|
preload="auto"
|
|
src={src ?? undefined}
|
|
onLoadedMetadata={(event) => onLoaded(event.currentTarget)}
|
|
onPlay={() => {
|
|
playing.current = true;
|
|
}}
|
|
onPause={() => {
|
|
playing.current = false;
|
|
}}
|
|
onTimeUpdate={(event) => onTimeUpdate(event.currentTarget)}
|
|
/>
|
|
{children ? (
|
|
// Children get the displayed frame, so normalized boxes map onto the picture.
|
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
|
<div
|
|
data-frame
|
|
className="relative"
|
|
style={{
|
|
aspectRatio: ratio,
|
|
...(ratio >= 16 / 9 ? { width: "100%" } : { height: "100%" }),
|
|
}}
|
|
>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function firstClip(edl: Edl) {
|
|
return [...edl.tracks.video].sort((a, b) => a.atMs - b.atMs)[0] ?? edl.tracks.video[0]!;
|
|
}
|
|
|
|
/** Output start of the first clip beginning at or after `ms`, or null. */
|
|
function nextClipStart(edl: Edl, ms: number): number | null {
|
|
let best: number | null = null;
|
|
for (const clip of edl.tracks.video) {
|
|
const start = clipOutputStartMs(edl, clip);
|
|
if (start >= ms && (best === null || start < best)) best = start;
|
|
}
|
|
return best;
|
|
}
|