Every package's highest-retention moments arrive as a clip plan, pre-rendered into vertical MP4s. The Shorts editor is where you retrim them on whole words, layer animated subtitles, write the post, and publish to TikTok, Instagram Reels, and YouTube Shorts — all from one screen, all without leaving your Mac.
One idea makes the whole editor coherent: everything you edit lives on the
short_clip_plan asset. The rendered_short_clip MP4 is a
build output — produced from the plan, never the other way around. Edit the recipe; the
worker rebuilds the dish.
A blueprint asset. payload.clips[clipIndex] holds every operator-editable field:
trim.start / trim.end — word-snapped boundariestitle · caption · description · tagsstyling — font, animation, colours, position, sizedescription_links · publish_optionsrender_rev — monotonic render counterThe burned-in vertical MP4 (1080×1920). clip_render writes it and copies
the plan's editorial fields into its payload so the Studio and dispatch don't re-traverse.
local_path — the rendered file on disktitle · description · tags · stylingrender_rev — matches the plan at build timerendered_short_clip — the next
Render would overwrite them. The plan is the only place edits stick. The worker copies plan
fields into the rendered row; it never reads edits back out.
*_plan assets are never dispatched — they're internal blueprints. Only the
rendered_short_clip carries a real local_path and is eligible
for dispatch. Re-renders update the existing rendered row in place (UPSERT) so the asset id
— and its publish history — survives every re-edit.
When a plan first lands, generate_asset fans out one clip_render job per clip
— so the Shorts tab shows ready-to-preview MP4s instead of "click Render per clip."
Render bumps render_rev and enqueues with key clip_render:<plan>:<i>:rev<N>.
The worker skips when the rendered row already has an equal-or-newer rev — double-click and crash safe.
Older packages with missing renders? Run tsx scripts/render-shorts.ts. It enqueues only
the missing clips by default; the queue dedupes on the idempotency key, so re-runs are no-ops.
Open a Short from the Studio's Shorts tab (the ShortsList — one card per
clip_index) or jump straight to /packages/[id]/shorts/[clipIndex].
The URL keys on the clip index, not the asset id, so it stays stable across re-renders. The left column
is the preview rig; the right column is the metadata rail. Edits auto-save to the plan (~800 ms debounce)
with a ✓ saved indicator.
Left rail: PreviewPlayer + SubtitleOverlay · Timeline ·
TranscriptPanel. Right rail: Title & Caption ·
SubtitleStylePanel · Description & Tags ·
ClipPublishOptions. The same publish control also opens in a modal from the header's
↗ Publish button.
Drag a trim handle anywhere — the ghost preview follows your pointer live. On release it snaps
to the nearest whole-word boundary within a 2-second window, so a clip never
starts or ends mid-word. A blue guide line shows where it will snap before you let go.
The same snapToWordBoundary() math runs twice: client-side on drag-end, and again
server-side in clip_render as a defensive snap before ffmpeg's -ss
(which is itself sub-frame-imprecise).
side='start' returns the chosen word's start; side='end'
returns its end. If no boundary falls inside the window (or there are no word
timings at all), the dragged time is kept unchanged. Word timings come from MLX Whisper's
segments[].words[], flattened by flattenTranscriptWords().
fast_audio_only without word timestamps have no
segments[].words[]. The editor degrades gracefully — the live subtitle overlay
is disabled (with a visible warning), snapping becomes a no-op, and trims commit at the exact
dragged time. Re-run transcribe_audio with word timestamps to light it back up.
Pick a style in the Subtitles panel — font, animation, colours, position, and size.
The plan stores a styling block; at render time clip_render emits an
ASS (Advanced SubStation Alpha) file via src/lib/ass-subtitles.ts,
and ffmpeg burns it into the MP4 through the subtitles= filter. Each animation maps
to per-word ASS override tags.
The active word fills with the highlight colour as it's spoken — the safe default.
Each word scales up then settles — a bouncy "pop in" beat per word.
Just the current word, big and centred — maximum punch, minimal clutter.
Words fade in across their duration — a clean reveal that tracks the speech.
The active word tilts on its X-axis with a colour pulse — energetic, hook-y.
Words sit on a solid coloured bar — bold, legible on busy footage.
#RRGGBB picks are converted to ASS's reversed, alpha-first
&H00BBGGRR form by hexToAss(), which falls back to white on a
parse error rather than breaking the render on a typo. Word rows group ~4 words per line
(Single Word = one), matching the live overlay exactly.
Styling a Short shouldn't cost you a render cycle. The editor draws a DOM
SubtitleOverlay on top of the preview <video> — using the current
playhead time, the word timings, and your styling block — so every colour, position, and
animation change updates instantly. It mirrors what serializeAss() will
emit, close enough for editorial review. The rendered MP4 stays the source of truth.
The overlay needs word-level timings to run; without them it hides itself and the editor shows a "missing word timings" warning instead. The "live preview · N words" tag in the corner disambiguates "overlay mounted, currently between words" from "overlay not mounted at all."
Open a clip whose description has never been written and the editor generates one for you on
mount. generateClipDescription calls the LLM with the clip's title, caption, tags,
and the transcript inside the trim window, using the brand's voice profile. It's a bounded,
text-only call (≤ 280 chars) and a documented Server-Action carve-out — no worker round-trip
needed for this single interactive asset.
It runs only when the description is empty and description_generated_at
is unset. The timestamp is stamped on success or failure, so it never retries on
the next open — you can always type one by hand.
When the plan has no Description Links yet and the source is a YouTube URL, the first link is seeded as "Watch the full video" pointing at the long-form. Add up to 8 links total.
Title (≤ 70 chars for Shorts/TikTok), Caption Title (the on-screen overlay, keep ≤ 50), and comma-separated tags — all on-brand from the v2 clip plan, all editable.
Hit ↗ Publish (from the editor header or a Shorts list row) to open the publish modal. Toggle
platforms — YouTube Shorts · TikTok · Instagram Reels — pick a privacy / schedule
option, then "Publish now" or "Save as draft." In v1 a Short routes exclusively through
Zernio: a single createPost fans out to all selected networks at once.
publish_options.platforms filters which Zernio platforms the post hits.
privacy: 'schedule' + a future publish_at maps to Zernio's
scheduledFor (must be ≥ 1 minute ahead). Other privacy values aren't enforced —
Zernio's current SDK doesn't expose per-platform privacy, so you manage that on the platform side.
| Control | What it does | Stored as |
|---|---|---|
| Platform toggles | YouTube Shorts · TikTok · Instagram Reels (default: all on) | publish_options.platforms |
| Public | Live the moment Zernio publishes | privacy: 'public' |
| Unlisted | Link-only visibility | privacy: 'unlisted' |
| Draft / Private | Held private (the default) | privacy: 'private' |
| Schedule for… | Auto-publish at a chosen local time | privacy: 'schedule' + publish_at |
| Save as draft | Persist options only — no dispatch | setClipPublishOptions |
| Publish now | Save options, then publishAsset(renderedAssetId) | dispatch → Zernio |
rendered_short_clip exists — there's nothing to
post otherwise. Edit the plan, hit Render, wait for the rendered file, then publish. (YouTube
Direct upload for Shorts is deferred to v2; for now the YouTube Shorts toggle ships through Zernio
alongside TikTok and Instagram.)
render_rev is the spine of the whole flow. Editing the plan is free and instant.
Clicking Render bumps the rev, sets pending_render, and enqueues a job; the worker
builds, UPSERTs the rendered row at the new rev, and clears the flag. Re-edit and the cycle
repeats — always on the same rendered asset id, so dispatches stay bound across versions.
Idempotency: the render job key is clip_render:<plan>:<clipIndex>:rev<N>.
The worker skips when the rendered row's render_rev is already ≥ the plan's — safe
under double-clicks and crash recovery. A re-render of an already-shipped clip overwrites the
bytes but keeps its dispatch/published status; an approved clip flips back to
ready_for_review.
From the Studio's Shorts tab or /packages/[id]/shorts/[clipIndex].
A pre-rendered MP4 is usually already waiting from the plan's first landing.
Drag the timeline handles (they snap to words), pick a subtitle animation, tweak colours and position. The live overlay updates instantly; edits auto-save to the plan.
Accept or edit the auto-generated description, set the title/caption/tags, and confirm the "Watch the full video" link.
Click ↺ Render. render_rev bumps, the job runs, and the burned-in MP4 refreshes
in place. The header shows ⏳ Rendering… then the rendered status chip.
Choose platforms + privacy/schedule and Publish now — or Save as draft. After review/approval it dispatches through Zernio to all selected networks.
clip_render is the oven. Every
Render produces a fresh build at a new render_rev on the same stable asset id — so
your trims, styling, and publish history never drift apart.
The four-layer pipeline that turns a video into the intelligence the clip plan is drafted from.
The full operator manual — every screen, asset type, and workflow end to end.
Direct long-form upload with per-brand OAuth — the Shorts companion to the publish flow above.
What the rendered clips and source video cost on disk, and when each becomes deletable.