Claude 844f7fe7c1
feat(media): web project, editor, review, and brand kit views
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLSCTnhge4umMnemGXWr1w
2026-10-03 19:19:45 +00:00

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;
}