import { Trans, useLingui } from "@lingui/react/macro"; import type { CaptionStylePreset, Edl, EdlCaptions, EdlError, EdlOverlay, MediaEditPlan, } from "@rakazo/contracts"; import { Button, Skeleton } from "@rakazo/ui-web"; import { Plus, Save, Trash2 } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { LoadingState } from "../../components/ai/primitives"; import { ErrorLine, errorMessage, ViewHeader } from "../../components/media/editor/chrome"; import { NumberField, SecondsField, SelectField, TextField, ToggleField, } from "../../components/media/editor/fields"; import type { PlayerHandle } from "../../components/media/editor/Player"; import { SourcePlayer } from "../../components/media/editor/Player"; import type { TimelineSelection } from "../../components/media/editor/Timeline"; import { Timeline } from "../../components/media/editor/Timeline"; import { errorTarget, formatMs, mainProgramMs, nextId, updateOverlay, } from "../../components/media/editor/timeline"; import { rpc } from "../../lib/rpc"; import type { MediaViewProps } from "./nav"; interface Loaded { plan: MediaEditPlan; assetDurations: Map; captionPresets: CaptionStylePreset[]; } /** Timeline editor for one plan version. Saving submits a new version. */ export function EditorView({ navigate, projectId, planId, }: MediaViewProps & { projectId: string; planId: string }) { const { t } = useLingui(); const [loaded, setLoaded] = useState(null); const [edl, setEdl] = useState(null); const [dirty, setDirty] = useState(false); const [selection, setSelection] = useState(null); const [errors, setErrors] = useState([]); const [playheadMs, setPlayheadMs] = useState(0); const [saving, setSaving] = useState(false); const [loadError, setLoadError] = useState(null); const player = useRef(null); useEffect(() => { let live = true; setLoaded(null); setEdl(null); setDirty(false); setErrors([]); setSelection(null); void (async () => { try { const { project, plans } = await rpc.media.projects.get({ projectId }); const plan = plans.find((candidate) => candidate.id === planId); if (!plan) throw new Error(t`This plan no longer exists.`); const assetIds = [...new Set(plan.edl.tracks.video.map((clip) => clip.assetId))]; const [assets, kits] = await Promise.all([ Promise.all( assetIds.map((assetId) => rpc.media.assets .get({ assetId }) .then(({ asset }) => [assetId, asset.durationMs] as const) .catch(() => [assetId, null] as const), ), ), project.brandKitId ? rpc.media.brandKits.list({}) : Promise.resolve([]), ]); if (!live) return; const kit = kits.find((record) => record.id === project.brandKitId); setLoaded({ plan, assetDurations: new Map(assets), captionPresets: kit?.kit.captionPresets ?? [], }); setEdl(plan.edl); } catch (cause) { if (live) setLoadError(errorMessage(cause)); } })(); return () => { live = false; }; }, [projectId, planId]); const change = useCallback((next: Edl) => { setEdl(next); setDirty(true); }, []); const invalid = useMemo(() => { const ids = new Set(); if (!edl) return ids; for (const error of errors) { const target = errorTarget(edl, error); if (target) ids.add(target.id); } return ids; }, [edl, errors]); if (!loaded || !edl) { return (
{loadError ? null : }
); } async function save() { if (!edl) return; setSaving(true); try { const result = await rpc.media.plans.submit({ projectId, edl, rationale: null }); if (result.ok) { setErrors([]); setDirty(false); navigate({ name: "editor", projectId, planId: result.plan.id }); } else { setErrors(result.errors); } } catch (cause) { setErrors([{ code: "schema", path: "", message: errorMessage(cause) }]); } finally { setSaving(false); } } function addOverlay() { if (!edl) return; const id = nextId(edl, "text"); const atMs = Math.max(0, Math.min(playheadMs, mainProgramMs(edl))); const overlay: EdlOverlay = { id, kind: "text", atMs, durationMs: 2000, text: "" }; change({ ...edl, overlays: [...edl.overlays, overlay] }); setSelection({ kind: "overlay", id }); } return (
navigate({ name: "project", projectId })} >
{ if (frame) setPlayheadMs(frame.outputMs); }} />
{formatMs(playheadMs)}
{ setPlayheadMs(ms); player.current?.seek(ms); }} onChange={change} /> {errors.length > 0 ? (
    {errors.map((error) => { const target = errorTarget(edl, error); return (
  • ); })}
) : null} setSelection(null)} />
); } function Inspector({ edl, selection, assetDurations, captionPresets, onChange, onClear, }: { edl: Edl; selection: TimelineSelection; assetDurations: ReadonlyMap; captionPresets: CaptionStylePreset[]; onChange: (edl: Edl) => void; onClear: () => void; }) { const { t } = useLingui(); if (!selection) return null; if (selection.kind === "clip") { const clip = edl.tracks.video.find((candidate) => candidate.id === selection.id); if (!clip) return null; const max = assetDurations.get(clip.assetId) ?? Number.POSITIVE_INFINITY; const patch = (next: Partial) => onChange({ ...edl, tracks: { ...edl.tracks, video: edl.tracks.video.map((candidate) => candidate.id === clip.id ? { ...candidate, ...next } : candidate, ), }, }); return (
patch({ atMs })} /> patch({ inMs: Math.min(inMs, clip.outMs - 100) })} /> patch({ outMs: Math.min(max, Math.max(outMs, clip.inMs + 100)) })} /> patch({ speed })} /> { if (value !== "keyframes") patch({ reframe: value }); }} /> patch({ muted })} /> {edl.tracks.video.length > 1 ? ( ) : null}
); } if (selection.kind === "overlay") { const overlay = edl.overlays.find((candidate) => candidate.id === selection.id); if (!overlay) return null; const patch = (next: Partial) => onChange(updateOverlay(edl, overlay.id, next)); return (
patch({ kind })} /> patch({ text })} /> patch({ atMs })} /> patch({ durationMs })} />
); } const captions: EdlCaptions = edl.captions ?? { mode: "none", maxCharsPerLine: 32, wordHighlight: false, position: "lower", }; const patch = (next: Partial) => onChange({ ...edl, captions: { ...captions, ...next } }); return (
patch({ mode })} /> {captions.mode === "none" ? null : ( <> patch({ position })} /> patch({ maxCharsPerLine })} /> {captionPresets.length > 0 ? ( ({ value: preset.id, label: preset.name })), ]} onChange={(presetId) => patch({ presetId: presetId || undefined })} /> ) : null} patch({ wordHighlight })} /> )}
); }