Skip to content

Make the first run legible, and join the two halves - #7

Open
pallaoro wants to merge 8 commits into
mainfrom
first-frame-poster
Open

Make the first run legible, and join the two halves#7
pallaoro wants to merge 8 commits into
mainfrom
first-frame-poster

Conversation

@pallaoro

@pallaoro pallaoro commented Sep 9, 2026

Copy link
Copy Markdown
Member

Follows the design-system pass (#6). That pass made the app look right; opening it still told you nothing about what to do, and the two halves were unconnected.

The first run

A new video showed a black rectangle. Compositions animate in with gsap.from({opacity: 0}), so at t=0 every element is invisible. Both the gallery thumbnails and the editor sat at t=0, so the starter composition that is meant to teach you what this app is was showing nothing, and the app read as broken on open. Both now seek to the halfway frame, which is what a video tool picks for a poster. The preview harness already accepted ?seek=, so this is a parameter, not new machinery.

The editor opens with the headline 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.

That exposed a defect: the preview iframe renders the saved composition, because the harness is served by /api/compositions/:id/preview. Reloading it after an inspector edit without saving first just re-showed the old frame, so you typed your title and the canvas never changed — and the save button lives only in the Compose tab, so from the default Timeline tab there was no way to make your own edit appear at all. The debounced refresh now persists, then reloads.

Copy: a new composition is named after what is on screen ("Product launch title card") rather than "Untitled", and each section says which one you want — "Graphics you make from scratch" against "Video you already shot". When a section is empty its empty state carries the action, so the page keeps one solid button.

The seam

The composition side and the footage side were two unconnected documents. A render only ever produced a row in render_jobs and an MP4 you could download; you could not put your own title card into your own cut, which is most of what a launch video is.

A finished composition is footage, so it now becomes a media-library asset: render_jobs.asset_id records what the render produced, the render handler inserts the assets row with the duration computed from the composition's own clip timings, and a completed render offers Use in an edit, which starts a cut with that clip at the head of the main track. Export staging already streams from this app's storage, so the export path is unchanged.

That exposed a latent bug it now trips reliably: useSourceDurations gave srcDur an empty dep array, so when a duration landed in the cache the segments memo never recomputed. It only bit when the EDL already referenced an asset at mount, which is exactly what "Use in an edit" creates: every clip rendered at zero length.

The player

The footage stage was sized by aspect-ratio off a full-width box with max-height: 100%. The parent's height is indefinite, so max-height never applied: at 1920x936 the stage computed to 1392x783 inside a 560px-tall pane and the bottom of the frame was clipped, taking the caption safe area with it. The ratio was right; the size was not. It now measures the pane and takes the limiting dimension, which is what the composition preview's harness already does.

Verification

Typecheck, scripts/apps-design-lint.py and build all pass. Driven in a browser: a new video from the empty state through to a first edit landing on the canvas; "Use in an edit" producing a cut that opens at 0:04.2 with the filmstrip on the main track; the player at 1920x936, 1600x1000 and 1280x620 with an exact 16:9 inside the padding on all four sides.

Not verified: the render service call itself. It needs CLAWNIFY_TOKEN, so POST /api/renders returns 503 locally. The seam was exercised with a real uploaded clip standing in for a render. The assets insert inside the render handler has never executed.

Note on scope

This makes a launch video assemblable; it does not make the composition side a builder. You still cannot add an element to a composition without writing HTML. That is tracked separately.

The token layer had drifted from the current design system: a slate-gray
palette where the neutrals are meant to be warm, coral as the primary button
where the primary is ink, 6/8/12px corners where the scale is 8/12/16/24, and
no accent, info or category families at all. Everything downstream inherited
that, so this replaces the tokens with the generated values and then follows
them through both routes.

- Tokens: the full generated palette (neutrals, accent, four statuses, four
  track categories) in light and dark, the type scale as `--text-*` so a
  component names a step instead of a pixel size, and the three elevation
  recipes as utilities. The edge is an inset ring everywhere, never a painted
  border; in dark mode the drops disappear and the ring is the whole treatment.
- One ink action per screen. Coral goes back to being the accent, which now
  appears only on the app icon and the focus ring. Auto-cut, Clean up clip and
  New edit become secondary; Export and New video keep the solid.
- Both timelines share one category palette, so a video clip is the same hue
  in the composition timeline and the footage timeline. Clips are a tint fill
  with a solid bar at the left edge rather than a solid block: a timeline is
  mostly one kind of clip, and a wall of the solid role reads as a paint chart.
- window.confirm and alert are gone. Deleting a composition or a library asset
  opens a dialog that names the object and says what is lost; a failed render
  reports in place. An agent driving the browser could not answer a native
  dialog at all.
- Uppercase tracked labels are gone from the inspectors. That style is the
  micro label above a KPI number; section titles and field labels are sentence
  case in `label`.
- Empty states say what the thing is and offer the way forward, and sit on the
  pane rather than inside a card. Loading renders the shape of the answer.
- Phones and tablets get one pane at a time. Below lg the footage editor shows
  Library, Edit or Options, chosen from a second toolbar row; the four-region
  grid stays a desktop layout. Inputs hold 16px so iOS does not zoom.
- The starter composition moves to its own module: those colours are pixels
  inside the user's video, not app chrome, so the design tokens do not apply
  and the lint exempts that one file.
- Inter is actually loaded now, and the app icon carries the accent hue.

scripts/apps-design-lint.py goes from 33 findings to clean. Typecheck and
build pass, and both routes plus the delete dialog were driven in a browser at
390, 1024 and 1920.
`.field` was unlayered, and unlayered CSS outranks everything Tailwind emits.
Its `width: 100%` therefore beat `w-8` and blew the inspector's 32px colour
swatch up to the full 466px panel width, squeezing the hex input beside it to
24px and pushing a field past the right edge of the viewport.

Moving the rules into @layer base makes the recipe a default that any utility
on the element overrides, which is the precedence a component author expects.
Also drops a local `.tabular-nums` that duplicated Tailwind's own utility.

Measured in the browser: swatch 32×32, its value input 426px, nothing
overflowing.
Compositions animate in with gsap.from({opacity: 0}), so at t=0 every element
is still invisible. Both the gallery thumbnails and the editor sat at t=0, so a
new video showed a black rectangle: the starter composition that is meant to
teach you what this app is was showing nothing, and the app read as broken on
first open.

Seek to the halfway frame instead, which is what a video tool would pick for a
poster: past the entrances, before any outro. The preview harness already
accepted ?seek=, so this is a parameter, not new machinery.
Three changes to the first run, plus the defect they exposed.

- Open a composition with the headline clip 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.
- Name a new composition after what is on screen ("Product launch title card").
  Three things called "Untitled" tell you nothing.
- Say which section is which, in the same shape for both: "Graphics you make
  from scratch" against "Video you already shot". When a section is empty its
  empty state carries the action, so the header does not duplicate the button
  and the page keeps one solid action.

The defect: the preview iframe renders the SAVED composition, because the
harness is served by /api/compositions/:id/preview. Reloading it after an
inspector edit without saving first just re-showed the old frame, so you typed
your title and the canvas never changed. The save button lives only in the
Compose tab, so from the default Timeline tab there was no way to make your own
edit appear at all. The debounced refresh now persists first, then reloads.
The composition side and the footage side were two unconnected documents: a
render only ever produced a row in render_jobs and an MP4 you could download.
You could not put your own title card into your own cut, which is most of what
a launch video is.

A finished composition IS footage, so it now becomes a media-library asset:
- render_jobs.asset_id records what the render produced
- the render handler inserts the assets row, with the duration computed from
  the composition's own clip timings (the renderer produces exactly that, and
  the footage timeline needs a length synchronously to lay the clip out)
- a completed render offers "Use in an edit", which starts a cut with that clip
  at the head of the main track

Export staging already streams from this app's own storage, so nothing in the
export path changed.

This exposed a latent bug it now trips reliably: useSourceDurations gave srcDur
an empty dep array, so when a duration landed in the cache the segments memo
never recomputed. It only bit when the EDL already referenced an asset at mount,
which is exactly what "Use in an edit" creates: every clip rendered at zero
length. srcDur now takes the version counter, so a resolved duration
invalidates the memo.

Verified in the browser with a real clip: the cut opens at 0:04.2 with the
filmstrip on the main track. NOT verified: the render service call itself,
which needs CLAWNIFY_TOKEN and is unchanged by this commit.
The stage was sized by `aspect-ratio` off a full-width box with
`max-height: 100%`. The parent's height is indefinite, so max-height never
applied: at 1920x936 the stage computed to 1392x783 inside a 560px-tall pane
and the bottom of the frame was clipped, taking the caption safe area with it.
The ratio was right; the size was not.

Measure the pane and take the limiting dimension, which is what the
composition preview's harness already does (`min(innerWidth/w, innerHeight/h)`).
Measured on the CONTENT box, because the pane carries padding and measuring
through it puts the stage back over the edge the padding exists to clear.

Verified at 1920x936, 1600x1000 and 1280x620: exact 16:9, inside the padding on
all four sides, nothing clipped.
# Conflicts:
#	src/client/app.tsx
#	src/client/edit.tsx
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant