Operator guide · the Shorts editor

Cut a Short.
Style it. Ship it.

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.

The mental model

The plan is the truth. The clip is the build.

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.

editable · source of truth

short_clip_plan

A blueprint asset. payload.clips[clipIndex] holds every operator-editable field:

  • trim.start / trim.end — word-snapped boundaries
  • title · caption · description · tags
  • styling — font, animation, colours, position, size
  • description_links · publish_options
  • render_rev — monotonic render counter
build output · disposable

rendered_short_clip

The 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 disk
  • mirrored title · description · tags · styling
  • render_rev — matches the plan at build time
  • carries dispatch / publish history on a stable asset id
Never edit the rendered clip. Operator edits are never persisted on rendered_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.
Operator edits + clicks Render short_clip_plan clips[i] = source of truth render_rev = N pending_render = true clip_render worker · ffmpeg word-snap · ASS burn-in UPSERT (plan_id, clip_index) rendered_short_clip 1080×1920 MP4 build stable asset id dispatch history bound re-edit → bump render_rev → re-render (same asset id) ↓ only rendered_* assets are dispatchable dispatch → Zernio

*_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.

Pre-rendered on arrival

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."

🔁

Idempotent re-renders

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.

🗂

Backfill existing packages

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.

Anatomy of the editor

Two columns. Everything in reach.

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.

PACKAGE · CLIP 3 Short editor ✓ saved ↺ Render ↗ Publish Preview · 9:16 + live subtitle overlay caption live preview Timeline · drag to trim · snaps to words Transcript · in-trim words highlighted · click to seek the · words · in · the · clip · light · up · here Title & Caption Title — ≤ 70 chars renders best Caption Title — on-screen overlay Subtitles font · 6 animations · colours · X/Y · size Word Hi. Pop Single W. Banner… Description & Tags ✨ auto-generated post body · hashtags inline · CTA tags, comma-separated · Description Links Publish options platforms · privacy · schedule ▶ ♪ ◎ on ⏰ Schedule for…

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.

Word-snap trimming

Drag freely. Land on a word.

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).

Transcript strip — each block is one word with its own start/end prompt engineering is basically dead now raw pointer (mid-word) snaps to word start ±2.0 s snap window — nearest boundary wins, else the raw time is kept

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().

No word timings? Packages processed under 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.
Subtitle animations

Six styles, burned in clean.

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.

word by word

Word Highlight

\k karaoke sweep

The active word fills with the highlight colour as it's spoken — the safe default.

make it pop

Pop new

\t scale 100→120→100%

Each word scales up then settles — a bouncy "pop in" beat per word.

NOW

Single Word

one word per line · \fscx150

Just the current word, big and centred — maximum punch, minimal clutter.

type it out

Typewriter new

\1a alpha fade per word

Words fade in across their duration — a clean reveal that tracks the speech.

with motion

Motion new

\frx rotate + colour pulse

The active word tilts on its X-axis with a colour pulse — energetic, hook-y.

BANNER

Banner

BorderStyle=4 box · BackColour

Words sit on a solid coloured bar — bold, legible on busy footage.

ASS colour quirk. Your #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.
Live preview vs burned-in render

Two subtitle paths. One look.

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.

plan.clips[i].styling font · animation · colours · X/Y Live preview path · instant SubtitleOverlay (React DOM) currentTime + word timings + styling → positioned over the preview video live preview · N words Render path · burned-in serializeAss() → clip_000.ass ffmpeg -vf "subtitles=clip_000.ass" → pixels baked into rendered MP4 rendered_short_clip same styling, two renderers — preview is approximate, the burn-in is canonical

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."

Auto-generated descriptions + links

The post writes its own first draft.

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.

Fires once, never loops

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.

🔗

Source link auto-seeded

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, caption, tags

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.

Publishing a Short

One post. Three networks.

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 modal ✓ ▶ ✓ ♪ ✓ ◎ ⏰ Schedule for… datetime-local · ≥1 min ahead Publish now rendered_short_clip publishAsset(id) Zernio createPost platforms[] filter ▶ YouTube Shorts ♪ TikTok ◎ Instagram Reels

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.

ControlWhat it doesStored as
Platform togglesYouTube Shorts · TikTok · Instagram Reels (default: all on)publish_options.platforms
PublicLive the moment Zernio publishesprivacy: 'public'
UnlistedLink-only visibilityprivacy: 'unlisted'
Draft / PrivateHeld private (the default)privacy: 'private'
Schedule for…Auto-publish at a chosen local timeprivacy: 'schedule' + publish_at
Save as draftPersist options only — no dispatchsetClipPublishOptions
Publish nowSave options, then publishAsset(renderedAssetId)dispatch → Zernio
Render before you publish. "Publish now" is disabled until a 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 lifecycle

Every render is a revision.

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.

Editing auto-save plan pending_render render_rev = N · job queued rendered UPSERT · flag cleared ready_for_review → approve → dispatch ↺ Render worker re-edit → bump render_rev → re-render (worker skips if existing rev ≥ plan rev)

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.

Open the Short

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.

Trim & style

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.

Write the post

Accept or edit the auto-generated description, set the title/caption/tags, and confirm the "Watch the full video" link.

Render

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.

Publish

Choose platforms + privacy/schedule and Publish now — or Save as draft. After review/approval it dispatches through Zernio to all selected networks.

The one rule to remember. Edit the plan, not the clip. The plan is the recipe; 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.
See also

Where this connects.

How it works

The four-layer pipeline that turns a video into the intelligence the clip plan is drafted from.

📖

Handbook

The full operator manual — every screen, asset type, and workflow end to end.

YouTube publishing

Direct long-form upload with per-brand OAuth — the Shorts companion to the publish flow above.

🗄

Storage lifecycle

What the rendered clips and source video cost on disk, and when each becomes deletable.