Skip to content
153 changes: 129 additions & 24 deletions src/client/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
Type as TypeIcon,
Music,
AlertCircle,
Scissors,
X,
} from "lucide-react";
import {
Expand Down Expand Up @@ -59,6 +60,9 @@ interface RenderJob {
status: "rendering" | "completed" | "failed";
output_url: string | null;
error: string | null;
/** The media-library asset this render produced, so the finished
* composition can be cut into an edit instead of only downloaded. */
asset_id: string | null;
created_at: string;
}

Expand Down Expand Up @@ -162,7 +166,10 @@ function Gallery({ navigate }: { navigate: (to: string) => void }) {
setCreating(true);
try {
const c = await api.send<Composition>("POST", "/api/compositions", {
name: "Untitled",
// Name it after what is actually on screen. Three things called
// "Untitled" tell you nothing; this agrees with the thumbnail and says
// what kind of object you just got.
name: "Product launch title card",
html: STARTER_HTML,
});
navigate(`/${c.id}`);
Expand All @@ -174,18 +181,28 @@ function Gallery({ navigate }: { navigate: (to: string) => void }) {
return (
<main className="flex-1 overflow-y-auto">
<div className="max-w-6xl mx-auto px-6 py-8">
{/* Toolbar grammar: identity left, the one solid action right. */}
<div className="flex items-center justify-between gap-4 mb-6">
<h1 className="text-heading-1">
Your videos
{comps && comps.length > 0 && (
<span className="ml-2 text-data text-muted tabular-nums">{comps.length}</span>
)}
</h1>
<button onClick={newVideo} disabled={creating} className={btnPrimary}>
{creating ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
New video
</button>
{/* Toolbar grammar: identity left, the one solid action right. The
two sections carry the same anatomy and a line each saying which
one you want — "graphics you make" vs "footage you shot". */}
<div className="flex items-start justify-between gap-4 mb-6">
<div>
<h1 className="text-heading-1">
Your videos
{comps && comps.length > 0 && (
<span className="ml-2 text-data text-muted tabular-nums">{comps.length}</span>
)}
</h1>
<p className="text-body-sm text-muted mt-0.5">
Graphics you make from scratch: titles, intros, lower thirds. Built on a timeline,
rendered to MP4.
</p>
</div>
{comps && comps.length > 0 && (
<button onClick={newVideo} disabled={creating} className={`${btnPrimary} shrink-0`}>
{creating ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
New video
</button>
)}
</div>

{comps === null ? (
Expand All @@ -205,7 +222,7 @@ function Gallery({ navigate }: { navigate: (to: string) => void }) {
<EmptyState
icon={<Video className="w-8 h-8" />}
title="No videos yet"
body="A video is motion graphics you author as HTML on a timeline — a title card, a lower third, an intro. Start from a template and edit it live."
body="Start from a working title card and change the words."
action={
<button onClick={newVideo} disabled={creating} className={btnPrimary}>
<Plus className="w-4 h-4" /> New video
Expand All @@ -222,7 +239,7 @@ function Gallery({ navigate }: { navigate: (to: string) => void }) {
>
<div className="aspect-video bg-black overflow-hidden">
<iframe
src={`/api/compositions/${c.id}/preview`}
src={`/api/compositions/${c.id}/preview?seek=${posterTime(c.html)}`}
className="w-full h-full pointer-events-none"
scrolling="no"
tabIndex={-1}
Expand Down Expand Up @@ -315,6 +332,23 @@ function Editor({
// Selected clip (by index) for the right-side inspector.
const [selectedClip, setSelectedClip] = useState<number | null>(null);

// Open with the headline already selected, so the first thing on screen is a
// field holding the text you are about to change. An editor that opens on
// "select something to edit it" spends the user's first move on housekeeping;
// the measurable thing in a first run is time to first EDIT.
const autoSelected = useRef(false);
useEffect(() => {
if (autoSelected.current) return;
const { clips } = parseClips(comp.html);
if (!clips.length) return;
autoSelected.current = true;
const px = (v: string) => parseFloat(v) || 0;
const headline = clips
.filter((c) => c.type === "text")
.sort((a, b) => px(b.fontSize) - px(a.fontSize))[0];
setSelectedClip((headline ?? clips[0]).index);
}, [comp.html]);

// Playhead state, kept in sync with the preview iframe's master clock.
const iframeRef = useRef<HTMLIFrameElement>(null);
const [playing, setPlaying] = useState(false); // default paused
Expand Down Expand Up @@ -370,6 +404,7 @@ function Editor({
// a clip only sets the window — it never moves the playhead (you clicked
// something you can already see) and never auto-plays.
const clips = parseClips(html).clips;
const poster = posterTime(html);
const selClip = selectedClip != null ? clips.find((c) => c.index === selectedClip) ?? null : null;

useEffect(() => {
Expand All @@ -395,11 +430,21 @@ function Editor({

const reloadTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
function updateClip(index: number, patch: ClipPatch) {
setHtml((h) => applyClipPatch(h, index, patch));
// Debounce the preview reload so typing stays smooth; restore the current
// playhead afterwards so an edit doesn't jump the time either.
const next = applyClipPatch(html, index, patch);
setHtml(next);
// The preview iframe renders the SAVED composition (the harness is served
// by /api/compositions/:id/preview), so reloading it without saving first
// just re-showed the old frame: you typed your title and the canvas never
// changed. Persist, THEN reload. Debounced so typing stays smooth, and the
// playhead is restored afterwards so an edit doesn't jump the time either.
clearTimeout(reloadTimer.current);
reloadTimer.current = setTimeout(() => {
reloadTimer.current = setTimeout(async () => {
setSaving(true);
try {
await api.send("PUT", `/api/compositions/${comp.id}`, { name, html: next, fps });
} finally {
setSaving(false);
}
restoreRef.current = timeRef.current;
setPreviewKey((k) => k + 1);
}, 350);
Expand All @@ -410,7 +455,7 @@ function Editor({
try {
await api.send("PUT", `/api/compositions/${comp.id}`, { name, html, fps });
setPreviewKey((k) => k + 1); // reload iframe
setTime(0);
setTime(poster);
} finally {
setSaving(false);
}
Expand Down Expand Up @@ -450,7 +495,7 @@ function Editor({
<iframe
ref={iframeRef}
key={previewKey}
src={`/api/compositions/${comp.id}/preview`}
src={`/api/compositions/${comp.id}/preview?seek=${poster}`}
className="w-full h-full"
title="preview"
/>
Expand Down Expand Up @@ -557,7 +602,7 @@ function Editor({
/>
)}
{tab === "media" && <MediaPanel />}
{tab === "renders" && <RendersPanel comp={comp} />}
{tab === "renders" && <RendersPanel comp={comp} navigate={navigate} />}
</div>
</Panel>
</Group>
Expand Down Expand Up @@ -613,6 +658,24 @@ function parseClips(html: string): { clips: Clip[]; tracks: number } {
return { clips, tracks };
}

/**
* A frame worth showing when nothing is playing.
*
* Compositions animate IN (`gsap.from({opacity: 0})`), so at t=0 every element
* is still invisible and the composition renders as an empty frame. Landing the
* gallery thumbnails and the editor on t=0 therefore showed a black rectangle
* and made the app look broken on first open — the starter composition is meant
* to be the thing that teaches you what this is, and it was showing nothing.
*
* Halfway through is the frame a video tool would pick for a poster: past the
* entrances, before any outro.
*/
export function posterTime(html: string): number {
const { clips } = parseClips(html);
const end = clips.reduce((max, c) => Math.max(max, c.start + c.duration), 0);
return end > 0 ? Math.round((end / 2) * 100) / 100 : 0;
}

export type ClipPatch = Partial<{
text: string;
color: string;
Expand Down Expand Up @@ -1083,10 +1146,37 @@ const RENDER_TONE: Record<RenderJob["status"], string> = {
failed: "danger",
};

function RendersPanel({ comp }: { comp: Composition }) {
function RendersPanel({ comp, navigate }: { comp: Composition; navigate: (to: string) => void }) {
const [jobs, setJobs] = useState<RenderJob[]>([]);
const [rendering, setRendering] = useState(false);
const [err, setErr] = useState("");
const [cutting, setCutting] = useState<number | null>(null);

/** Start a cut with this render at the head of the main track: the title
* card first, then you add the footage after it. This is the seam between
* the two halves of the app — the composition becomes a clip. */
async function useInEdit(job: RenderJob) {
if (!job.asset_id) return;
setCutting(job.id);
setErr("");
try {
const project = await api.send<{ id: string }>("POST", "/api/projects", {
name: `${comp.name} launch`,
edl: {
version: 1,
output: { width: 1280, height: 720, fps: 30, background: "#000000" },
main: { elements: [{ id: `c${job.id}`, type: "video", src: `asset:${job.asset_id}` }] },
overlays: [],
audio: [],
},
});
navigate(`/edits/${project.id}`);
} catch (e) {
setErr(String((e as Error).message || e));
} finally {
setCutting(null);
}
}

async function load() {
const all = await api.get<RenderJob[]>("/api/renders");
Expand Down Expand Up @@ -1143,14 +1233,29 @@ function RendersPanel({ comp }: { comp: Composition }) {
<span>
#{j.id} · {new Date(j.created_at + "Z").toLocaleString()}
</span>
{j.status === "completed" && j.asset_id && (
<button
onClick={() => useInEdit(j)}
disabled={cutting === j.id}
className={`${btnSecondary} ml-auto`}
title="Start a cut with this clip at the front"
>
{cutting === j.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Scissors className="w-4 h-4" />
)}
Use in an edit
</button>
)}
</div>
</div>
))}
{jobs.length === 0 && (
<EmptyState
icon={<Film className="w-8 h-8" />}
title="No renders yet"
body="Rendering runs the composition on the managed render service and hands back an MP4. It takes about a minute."
body="Rendering runs the composition on the managed render service and hands back an MP4. It also lands in your media library, so you can cut it into a footage edit."
/>
)}
</div>
Expand Down
60 changes: 45 additions & 15 deletions src/client/edit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,12 @@ const durCache = new Map<string, number>();
const peaksCache = new Map<string, number[]>();

function useSourceDurations(edl: Edl, assets: Asset[]) {
const [, bump] = useState(0);
// `version` is not cosmetic: it is what gives `srcDur` a new identity when a
// duration lands, which is what invalidates the `segments` memo downstream.
// Without it a project whose EDL already references an asset at mount (the
// "Use in an edit" flow) renders every clip at zero length forever, because
// the cache fills after the memo has already been computed.
const [version, bump] = useState(0);
const byId = useMemo(() => new Map(assets.map((a) => [a.id, a])), [assets]);

const resolve = useCallback(
Expand Down Expand Up @@ -270,7 +275,8 @@ function useSourceDurations(edl: Edl, assets: Asset[]) {
}
}, [edl, resolve]);

const srcDur = useCallback((src: string) => durCache.get(src), []);
// eslint-disable-next-line react-hooks/exhaustive-deps
const srcDur = useCallback((src: string) => durCache.get(src), [version]);
return { srcDur, resolveAsset: resolve };
}

Expand Down Expand Up @@ -384,12 +390,15 @@ export function EditProjectsSection({ navigate }: { navigate: (to: string) => vo
)}
</h2>
<p className="text-body-sm text-muted mt-0.5">
Cut and sequence real clips, overlay text, mix music, export to MP4.
Video you already shot: trim it, put the clips in order, add text and music, export to
MP4.
</p>
</div>
<button onClick={create} disabled={busy} className={`${btnSecondary} shrink-0`}>
{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />} New edit
</button>
{projects && projects.length > 0 && (
<button onClick={create} disabled={busy} className={`${btnSecondary} shrink-0`}>
{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />} New edit
</button>
)}
</div>
{projects === null ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
Expand All @@ -404,7 +413,7 @@ export function EditProjectsSection({ navigate }: { navigate: (to: string) => vo
<EmptyState
icon={<Scissors className="w-8 h-8" />}
title="No edits yet"
body="An edit is your own footage cut down: trim the clips, put them in order, drop text over the top and mix music under it."
body="Upload a clip and cut it down."
action={
<button onClick={create} disabled={busy} className={btnSecondary}>
<Plus className="w-4 h-4" /> New edit
Expand Down Expand Up @@ -1054,18 +1063,36 @@ function Player({
setSel: (s: Sel) => void;
update: (fn: (d: Edl) => void) => void;
}) {
const boxRef = useRef<HTMLDivElement>(null);
const stageRef = useRef<HTMLDivElement>(null);
const [scale, setScale] = useState(0.3);
// Scale to FIT, the way the composition preview's harness does: the limiting
// dimension wins. `aspect-ratio` alone sized the stage from the full width
// and let it run off the bottom of the pane (max-height never applied,
// because the parent's height is indefinite), so the frame was clipped.
const [fit, setFit] = useState({ w: 0, h: 0, scale: 1 });
const scale = fit.scale;
const videoRefs = useRef(new Map<string, HTMLVideoElement>());
const audioRefs = useRef(new Map<string, HTMLAudioElement>());

useEffect(() => {
const el = stageRef.current;
if (!el) return;
const ro = new ResizeObserver(() => setScale(el.clientWidth / edl.output.width));
ro.observe(el);
const box = boxRef.current;
if (!box) return;
const measure = () => {
// Content box, not border box: the pane carries padding, and measuring
// through it puts the stage back over the edge it was meant to clear.
const cs = getComputedStyle(box);
const width = box.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
const height = box.clientHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom);
const s = Math.min(width / edl.output.width, height / edl.output.height);
if (s > 0 && Number.isFinite(s)) {
setFit({ w: edl.output.width * s, h: edl.output.height * s, scale: s });
}
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(box);
return () => ro.disconnect();
}, [edl.output.width]);
}, [edl.output.width, edl.output.height]);

const active = segments.find((s) => playhead >= s.start && playhead < s.start + s.dur) ?? segments[segments.length - 1];

Expand Down Expand Up @@ -1134,8 +1161,11 @@ function Player({
};

return (
<div className={`${pane === "canvas" ? "grid" : "hidden"} lg:grid flex-1 min-w-0 bg-surface-sunken place-items-center p-4 overflow-hidden`}>
<div className="w-full max-w-full" style={{ maxHeight: "100%", aspectRatio: `${edl.output.width}/${edl.output.height}` }}>
<div
ref={boxRef}
className={`${pane === "canvas" ? "grid" : "hidden"} lg:grid flex-1 min-w-0 min-h-0 bg-surface-sunken place-items-center p-4 overflow-hidden`}
>
<div style={{ width: fit.w || undefined, height: fit.h || undefined }}>
<div
ref={stageRef}
className="relative w-full h-full overflow-hidden rounded-md shadow-edge"
Expand Down
Loading