Skip to content

Latest commit

 

History

History
1056 lines (924 loc) · 64.7 KB

HTML_COMPOSITIONS.md

File metadata and controls

1056 lines (924 loc) · 64.7 KB

HTML compositions

Render a local, seekable HTML scene to a text-faithful H.264 MP4 without an AI provider. Start the managed browser and install ffmpeg before submitting a job. Chrome must support CDP hidden targets; unsupported browsers fail the job rather than opening a visible tab.

Put index.html and its assets in a directory such as data/compositions/example/. The page exposes:

<div id="block" style="position:absolute;width:40px;height:40px;background:red"></div>
<script>
globalThis.portosComposition = {
  durationSec: 2, fps: 24, width: 1280, height: 720,
  async seek(t) {
    document.getElementById('block').style.left = `${t * 200}px`;
  }
};
</script>

seek(t) must finish all asynchronous work for that frame before resolving. Drive animation from t, not wall-clock playback, CSS animation timers or random state. The renderer awaits each seek, then captures frame n at n / fps. The in-app live preview is different: the browser paints whenever seek yields, so every await inside it can reach the screen. Load and decode first (await img.decode(); for video, seeked plus a presented frame, as seekVideo in the layered template does), then clear, draw and toggle layer visibility in one synchronous block. A page that fills a canvas or flips visibility before awaiting an image flashes that half-drawn state in the preview (most visibly in iPhone Safari, which decodes slowly) while the render stays clean. Duration is 1–120 seconds, fps is an integer from 12–60, and their product must be a whole number of frames. Dimensions are exactly 1920×1080, 1080×1920, 1080×1080 or 1280×720. Invalid contracts name the field in the job error.

Submit POST /api/html-composition/render with:

{"directory":"compositions/example","musicTrack":"example.wav"}

musicTrack is optional and names an existing Music-library file. It loops if shorter than the video, is trimmed to duration, and fades out over the final half second. A missing requested track fails the job.

Use audioUpload: "example.wav" instead to include a file from data/uploads/ with the same loop, trim and fade behavior. Choose one audio source per render; missing files and unsupported audio formats fail the job.

For procedural music, submit synthesizeMusic: true instead of musicTrack. Define async portosComposition.renderAudio({ sampleRate, durationSec }) to return an ordinary array containing exactly Math.round(sampleRate * durationSec) mono PCM samples. The renderer supplies 24000 Hz and the video duration; every sample must be finite and between -1 and 1, and the soundtrack must not be silent. Use native OfflineAudioContext, deterministic JavaScript synthesis, or locally available Tone.js/Strudel code capable of returning this contract. Convert an AudioBuffer channel with Array.from(buffer.getChannelData(0)). Live browser playback is not captured. The renderer writes temporary PCM WAV audio, muxes it with the same tail fade, and removes the temporary file after completion or failure. No music model, Python runtime, downloaded samples or music-library write is required. Missing or invalid audio fails the job.

Loudness mastering

Audio PortOS itself produces or mixes, a synthesizeMusic score or a musicTrack or audioUpload bed, is mastered by default (#10249). The encode first runs an ffmpeg loudnorm measurement pass over the trimmed bed, then applies it in linear mode inside the same encode to -14 LUFS integrated, -1.5 dBTP, LRA 11 (MASTER_LOUDNESS in server/lib/ffmpeg.js). A bed that measures below -50 LUFS fails the job with a "Soundtrack is silent" error before any frame is captured. After the encode the finished file is measured again, and the job result and each Media History entry carry loudness: { integratedLufs, truePeakDb, loudnessRange, masteredFrom: { integratedLufs, truePeakDb }, targetLufs }. The launch-video panel and the Code Animation export show it as one line, for example "-14.1 LUFS, peak -1.6 dB (was -27.3 LUFS)".

Submit masterLoudness: false to opt out: the bed is then muxed exactly as before and no loudness is reported. A Music Video's exact master (audio.path) is never mastered; when a sound-design bed is mixed under the song, the mix only ends with an alimiter at the -1.5 dB ceiling, so the song's own level is unchanged.

SuperCollider is a planned optional native code-audio runtime, described in the integration decision. It is not currently installed or enabled by PortOS and cannot run through this JavaScript PCM contract.

Several formats from one timeline

One job can render the same composition at several aspect ratios, so a launch posted to several platforms is one film recomposed per frame rather than one agent run per format. The composition declares the extra sizes it supports and an optional layout hook:

globalThis.portosComposition = {
  durationSec: 20, fps: 24, width: 1920, height: 1080,
  formats: ['1920x1080', '1080x1920', '1080x1080'],
  async layout({ width, height }) { /* reflow type and UI for this frame */ },
  async seek(t) { /* same timeline in every format */ },
};

The render request names formats: "formats": ["landscape", "vertical", "square"] (1920×1080, 1080×1920, 1080×1080). The renderer renders them in that canonical order, in sequence, on the same page and frozen asset snapshot: for each one it resizes the viewport, awaits layout({ width, height }) when defined, then seeks every frame. Duration, fps and timing are shared, and synthesized music is rendered once and muxed into every format. A requested format whose size is neither the composition's own width×height nor listed in formats fails the job before any output, naming the missing size. Without formats in the request, a job renders the composition's own size exactly as before.

Each format is its own Media History entry (<jobId>-<format>), all written in one history update, so a job registers every format or none. The completed job result keeps generationId as the job and names the first format in id/filename/thumbnail, and lists all of them in videos: [{ format, id, filename, thumbnail, path }].

Motion blur

Set motionBlur on portosComposition to blend sub-frames on fast moves.

  • Integer 1–4 (default 1): captures that many sub-frames spread over the whole frame interval and blends them with ffmpeg tmix. 1 is no blur. Fast moves show separated copies rather than a streak.
  • Object { shutter, samples, tolerance }: samples a centred fraction of the frame time, at t = (n + (k/count - ½) · shutter) / fps, and averages the sub-frames in linear light. shutter is 0.05–1 (default 0.5; 0.2 is a short filmic streak, 1 smears the whole interval). samples is a fixed count from 4–64 or 'auto' (the default). tolerance is 1–8 levels out of 255 (default 2).

'auto' starts at the frame's centre and refines 1 → 3 → 9 → 27 → 81 samples, reusing every earlier sample. It stops when the worst 8×8 block of the new average differs from the previous average by less than tolerance. A frame whose centre matches both neighbouring frames' centres is still and costs a single capture. The job result's sampleHistogram maps each sample count to the number of output frames that used it.

Shutter blur relies on two scene rules:

  • Output must be a pure function of t. Sub-frames are rendered out of order, may repeat, and the first frame's shutter clamps negative times to 0.
  • Per-frame flicker (grain, jitter, a random seed per frame) must stay constant across the shutter. Key it to PortosMotion.frameIdx(t, fps), which rounds to the nearest frame, not to Math.floor(t * fps).
motionBlur: { shutter: 0.5, samples: 'auto', tolerance: 2 }

Motion kit

server/services/htmlComposition/kit/portos-motion.js is a deterministic helper script for compositions. Copy it beside index.html, load it with <script src="portos-motion.js"></script>, and read globalThis.PortosMotion:

  • spring(t, stiffness, damping) — closed-form damped spring from 0 to 1, a pure function of the time since the move started. SPRINGS holds snappy, default, heavy and playful presets.
  • track(t, [[time, value], ...]) — a value that retargets several times, as one spring per change, so motion never restarts or pops.
  • indicator(t, stops, width), swapAlpha(t, tIn, tOut), loopT(t, dur) — stretching selection bars, content swaps inside a morphing container, loops.
  • frameIdx(t, fps) — the output frame t belongs to, constant across a motion-blur shutter; key per-frame flicker to it.
  • rng(seed) — seeded noise (mulberry32); never use Math.random.
  • beats(bpm) — a beat grid (at, bar, index, phase, list).
  • song(audioAnalysis) — seekable song lookups from a music-video project's analysis JSON: env('rms'|'low'|'mid'|'high', t) (interpolated 0..1), hit('kick'|'snare'|'hat', t, halfLife) (decaying pulses from past onsets; band heuristics, not stems), beatAt, barAt, beatPhase, sectionAt. ready is false when the analysis has no feature track (re-analyze).
  • renderCues({ sampleRate, durationSec, cues }), mixCues, toPcm — synthesized click, tick, pop, thump, whoosh and riser cues that return the plain PCM array renderAudio needs.

Launch-video runs start with the kit already copied into their composition directory. It is ordinary UTF-8 source, so it passes the launch asset gate.

Proof renders (contact sheets)

Add proof: { everySec: 1 } (0.25–10 seconds) to the render request to get a silent contact sheet instead of a video. proof.format (landscape, vertical or square) checks one declared framing, running layout first; a proof request cannot also carry formats. The renderer applies the same contract and launch-video gates, seeks one frame per interval (at most 60), and tiles them six across at phone size (360px wide, 240px for vertical) into one PNG. A proof is silent, so the request must omit musicTrack, audioUpload and synthesizeMusic. Nothing is registered in Media History and no launch-video artifacts are delivered. The completed job's result carries proof: { file, url, times, columns, width, height } (plus format when one was requested), where file is relative to the data directory (never an absolute host path) and url serves it under /data/; tile n shows times[n]. Launch runs write proofs/contact-<jobId>.png beside the run; other compositions write data/composition-proofs/. Both are excluded from backups. Code Animation exports stage data/code-animation-exports/<job-id>/<export-id>/ (also excluded): the stored HTML with a shim that maps ANIMATION_META/renderFrame(t) onto portosComposition, stops the page's own requestAnimationFrame clock, and hides everything but the film canvas.

Code Animation includes the saved audio automatically: Music-library and generated soundtrack files use musicTrack, while uploaded audio uses audioUpload (a filename in data/uploads/). Both loop and trim to the video length with the same half-second tail fade and loudness mastering. Missing or unsupported audio fails the export. Procedural Web Audio playback is still excluded from frame-exact exports; its export note explains how to attach a soundtrack.

The 202 response contains the media queue's jobId. Subscribe to GET /api/html-composition/:jobId/events for the usual queued, started, progress, complete, error and canceled SSE frames. Cancel through POST /api/html-composition/:jobId/cancel or the existing media-job controls. Jobs use the serialized local lane and are never offered to a federated peer.

The renderer snapshots local assets (up to 256 MiB / 4096 files), rejects symlinks, and fulfills requests from that snapshot at a virtual HTTPS origin. Use relative asset URLs. Nothing is served by a new HTTP listener. A fresh, hidden browser target has no access to the operator's normal browser context. Network URLs, missing assets, frames, workers, popups, WebSockets and WebRTC are refused; a refusal fails the job instead of silently dropping content. Browser disconnects, failed encoding and cancellation remove partial outputs. Cancellation is refused once the final history write has begun.

Successful outputs have BT.709 tags, fast-start MP4 layout, and a thumbnail in Media History. Source compositions are externally editable files, not a new relational store. Outputs and job state use the existing video-history and media queue storage contracts; no new JSON store, migration, seed or sync channel is introduced. Source files under data/ follow the existing backup filters.

The real-browser contract suite in server/services/htmlComposition/index.test.js needs Chrome and ffmpeg. It uses a temporary browser profile and data root, never the live managed browser. Set CHROME_PATH when Chrome is not in a standard location. The suite skips explicitly when either binary is unavailable; route and queue tests still run.

Music-video typography overlays

A Music Video project whose composition manifest is in composed mode (#8984) renders its timed text cues through this same sandbox. PortOS writes a generated overlay page (server/services/musicVideo/composition.js) to data/music-video-compositions/<jobId>/, opens it with the composition browser on a transparent background, and captures only the time ranges where text is on screen into alpha overlay clips. The music-video renderer then lays them over the cut footage in its single ffmpeg pass, so the song remains the only audio. A cue's state at a time is a pure function of that time (cueStateAt), and text is kept inside a 10% title-safe inset at any aspect. The scratch directory is removed when the render ends, swept at boot, and excluded from backups.

With timed lyrics and no Subtitle text cues (Hero cues are titles over the words, not a stand-in for them), a composed render also draws the sung lyrics in the shared lyric type, exactly as the storyboard animatic shows them: it stages the animatic's lyric overlay page (documentTemplates/lyricOverlay) beside the typography page and captures it over each line's window (composedLyricWindows, the first onset to two seconds past the line's end). Each shot's lyric placement, style and size apply; a title card shot already draws its text (else its sung line), so the overlay keeps its lines off it. A Subtitle cue hands the words back to the text cues, so "Copy timed lyrics" swaps the lyric type for plain subtitles.

A composed render also honors each scene's visual layer (#8985): its generated footage (the default), its selected still frame with a deterministic hold, push-in or pan, or a title card — a solid colour whose text is drawn by the same typography overlay for exactly that section. Still and card sections need an authored start/end instead of a clip, and every section is cut to a whole number of frames on the song's timebase, so the edit never drifts from the authored timeline. A plain concat render ignores the layer and plays footage, exactly as before.

A project's pre-production treatment (#8980, server/services/musicVideo/treatment.js) plans for this layer instead of fighting it. Each directed shot names a typography role (none, subtitle, hero) and a reserved region (upper third, center, lower third); applying the treatment adds that region to the scene's frame and motion prompts as clean, low-detail negative space and asks the image/video model for no lettering, so the generated picture leaves room for the composited text rather than baking text into pixels. Apply can also add text cues from the timed lyrics, placed in each shot's reserved region; it never switches the render to composed. Apply also maps direction onto the scene's own render fields, but only while they are still at their defaults: a shot routed to 2D/code motion becomes a title card carrying its first sung line (or a pushed-in still when it has no line), and a performance shot becomes a performance shot mode only when the project's video backend has a source-audio lip-sync lane (fal.ai today). The treatment's proof checklist treats readable text and audio alignment as judgeable only in the final render.

Music-video render grades

In Compose → Render grade, explicitly choose Neutral, Teal night, Golden hour, or Monochrome, with an optional per-section override and bounded grain. The selection is stored in composition.grade; clones remap section overrides to their new scene IDs. Neutral is the default and a true filter bypass. A neutral section override disables the default look for that section; resetting to neutral clears every override. Grades affect composed and document exports, not plain footage or code-rendered mode. The live document preview is ungraded; render an excerpt to judge the selected look.

Both encoders use the same bounded RGB curves and deterministic, song-time frame-addressed grain. Composed footage is graded before typography, and excerpt trimming happens after grading. Document captures receive the same filter before encoding, with the excerpt offset and document frame rate, so section changes and grain stay on the song clock. On a wipe between two sections with different looks, the encoder masks each wipe frame so the part still showing the outgoing scene takes that scene's look, lettering included; a wipe between sections with one look renders as if the mask did not exist. Black/white endpoints are preserved to keep document lettering readable. Source assets are unchanged; selecting a grade does not infer a look from references or invoke a provider. Saving the selection gates rendering until the server has accepted it.

See the synthetic visual validation and remaining acceptance.

Layered-template additions

In Compose → Film look → Template additions, set the beat reaction (pulse and automatic chorus glitch), still zoom/pan, footage push, grain and vignette from zero (off) to full strength. The default motion strengths are 100%, grain is 9%, and vignette is 50%, preserving existing frames. These are separate from chosen shot glitches, cut transitions, and the finishing Film look. A note warns when template texture stacks with Film look texture. Planned still-camera moves remain part of the shot when the template's default motion is reduced or off.

Each shot's Film look editor can override individual additions or Follow video. Sparse templateEffects records on the video and shot carry these choices into both preview and final render; zero is explicit, while null/absent inherits. Use Update layered engine for older documents to adopt the controls in a new version. Existing documents remain unchanged until that explicit upgrade; custom and 3D engines retain their own behavior. When beat reaction can draw, the preview uses the composition renderer for the same pulse and chorus glitch as export; with beat reaction off, the lightweight native player remains available.

Music-video film look

A finishing filter for the analog, imperfect feel image and video models rarely give on their own: soft focus, halation, color bleed, grain, fade, a color cast, split toning, vignette, light leaks, gate weave and flicker, plus the film emulation tools a grading plug-in like Dehancer centers on: a per-channel print response (Film color response, dye crossover toward cyan-green shadows and amber highlights), Halation spread, a sharp-center Lens fall-off, and for tape a sideways Tape softness and a Tracking strip with dropouts. These are version-2 controls (since: 2); a version-1 look draws byte-for-byte as before, and in version 2 a light leak fogs in from the frame edge. One definition in server/lib/filmLook.js (FILM_LOOK_CONTROLS, FILM_LOOK_PRESETS, filmLookFilterMarkup) serves three surfaces, so what is tuned on a still is what the preview shows and what the render bakes:

  • Compose → Film look stores the look on the project (project.filmLook, validated by filmLookValidation.js, normalized by normalizeFilmLook). Every slider move reaches the docked live preview at once; releasing a control saves. null is the bypass.
  • The image viewer (Film look) on any gallery image, Cast & sets plate or mood-board still previews the same filter in the browser and Save filtered copy bakes it server-side (POST /api/image-gen/:filename/film-look, services/imageGen/filmLookBake.js) into a new image beside the untouched original, grouped with it like a cleaned copy. Inside a music video project the editor also offers Use on project.
  • The final render installs the same runtime in every worker browser (documentRenderInitScripts → openComposition({ initScripts })), so the split render (#10365) and the overlay text probe see the filtered page. The code-rendered style takes the same scripts. A footage render (the composed or plain-cut ffmpeg encode, final or excerpt) gets the look as a finishing pass (services/musicVideo/filmLookFinish.js): the finished MP4 plays full-frame in a one-video page in the composition browser, the shared encoder captures it frame by frame on song time, and the render's own audio is copied back untouched. Paid clips are never regenerated to change the feel. The pass takes the second half of the render's progress bar and a cancel aborts it.
  • Composed final renders go shot by shot (services/musicVideo/shotSegments.js). Each section of the cut is a cached segment in two stages: the base (its frames from buildMusicVideoFfmpegArgs' segment mode, which draws the same chain the whole-film graph draws there, cut effects borrowing the next shot, fades, grade, glitches and the typography over it) and the look (the base through the shot's film look on song time). A shot uses its own look, then its camera's (cameras[].filmLook), then the video's (null follows the camera, then the video). A camera may also play in its own frame (4:3 or 3:2 inside the video) and the video may crop a few percent from each edge (overscan) before that matte and the look. A look change redoes only the look stage of the shots it reaches, a shot change only that shot (and a neighbor whose cut effect reads it). The shot card's Final render row renders one shot now and plays it silent; Render final renders whatever is missing, joins the segments by stream copy and lays the song under them. Segments live in data/music-video-segments/<projectId>/, machine-local and excluded from backups; publishing a final render drops ones it no longer uses.
  • The composition document preview carries playback mode with each seek into the OS-isolated preview worker. Playing frames use the same lightweight motion layers as the animatic: CSS focus/tone/saturation and weave on the root, with pointer-transparent overlays above document and lyric layers. Noise tiles are rasterized once into canvases from trusted inline filters, preserving the worker's image restrictions. Each overlay blends directly with the document and preceding layers rather than an isolated overlay group. Pause, scrub and frame stepping restore the exact SVG filter. This mode is preview-only; final renders remain exact and generated HTML stays isolated.
  • The storyboard animatic (the docked preview for footage projects) shows the look live over the whole picture, lyrics included, on the song frame. Paused, that is the exact filter; while the song plays it is the light playback version (filmLookMotionLayers, FilmLookFrame.jsx): a browser re-runs an SVG filter chain over every decoded video frame, and the full look took a phone's animatic from 30 fps to under 1 after #11215. The playback version is compositor-only: CSS filter functions for focus, tone and saturation, the gate weave as a transform, and the cast, grain, tape bands, vignette and leak as blended overlays (the noise rasterized once as tiles and shifted per song frame). The glows, fringing and split tone wait for the pause. The Film look button under the animatic turns the preview look off for that viewer (remembered on the device); the render still applies it.

The filter is an SVG <filter> applied to the document root (or to the <img> for a still). Radii and offsets scale with the filtered element's width, so a 390px phone preview, the 1920-wide render and a 4K still read alike. Every time-varying effect (grain seed, gate weave, flicker, light-leak drift) is a pure function of the song frame, so the four render workers agree and a still is one bake. filmLookRuntimeSource() inlines the markup function by toString() into sandboxed pages, which is why filmLookFilterMarkup must stay self-contained; the runtime wraps portosComposition.seek() to advance the frame before the page paints. A neutral look emits no filter at all.

Continuous film motion vs timed film events. The look's Shake & flicker group holds gate weave (labelled Frame shake) and flicker plus their speeds (weaveRate, 0.25–3, and flickerRate, 0.1–1, both defaulting to 1): a speed multiplies the song frame the drift is computed from, so it never moves a shot or the audio, and a look saved without them draws exactly as before. No preset turns frame shake on: it reads as camera shake, so it is opt-in, and each shot shows it beside its Film look picker. The Home movie preset is the restrained flicker recipe (a breathing flicker). Timed film events are each shot's Effects (scene.glitches, musicVideoTransitions.js): a gate jump, shutter, film burn, light leak or any glitch at a moment, each with an optional strength (0 switches it off, 1 or absent is the style as declared), a repeat (count plays, everySec apart on the song clock, never past the shot), and for a light leak a color and entry edge. glitchFrameParams turns those into the frame both the animatic canvas and the ffmpeg chain paint, so a scrub, an excerpt and the full render meet each repeat on the same song frame. The shot card's Home-movie events adds a half-strength gate jump every 3 s and one soft warm leak.

Each control names its effect in photography terms, and the In a prompt box rewrites the current settings as the words that ask a model for the same qualities (describeFilmLook), so tuning a look also teaches the vocabulary for the next generation prompt. A composed project's render grade (above) still applies inside the ffmpeg encode; the film look follows it.

Music-video opening title card

The MTV-style credit block a video opens with: song title, artist, year and an optional extra line (album, director), fading in over the opener (optionally sliding in from one side as it does) and out again in place. Look → Opening title card (under the docked preview) stores it on the project (project.titleCard, validated by musicVideoTitleCardSchema, normalized by normalizeTitleCard in server/lib/musicVideoTitleCard.js; null = none, enabled: false keeps the text). Turning it on fills the song title (the project name without its version suffix), the artist named for DistroKid under Where you post, this year, and a window that ends half a second before the first sung line. Timing (in, out, fade in, fade out), the slide, position (four corners, bottom centre, centre), font, size, look and colors are all editable. Fonts are the lyric type's Archivo, Helvetica, serif, Plex Mono, and six bundled OFL faces (TITLE_CARD_FONT_FACES: Oswald, Audiowide, Orbitron, Righteous, Pacifico, Playfair Display, files and licences in server/services/musicVideo/titleCardFonts/), picked from swatches drawn in each face. Text is drawn exactly as typed; the panel flags a sung line that starts while the card is up and offers to end the card before it. Also at the end (optional, off by default: outro) shows the same card again in its own window (outroStartSec–outroEndSec, song time); turning it on times that window from the song's length (titleCardOutroWindow: as long as the opening one, ending a second before the song does). The runtime's fades and slide cover both windows, and a composed cut gets one capture cue per window (titleCardWindows). While the card shows, a lyric line in a default zone that would collide with the card's corner steps across the frame (a lower-left card sends it to the upper left); a Text zone set on the line's shot stays as set.

The card is not part of the composition document. titleCardRuntimeSource() is a page script installed beside it, in the live preview's head (live edits arrive as portos-mv:title-card messages) and in every render browser (documentRenderInitScripts). It draws on its own canvas fixed over the page, fitted to the render's aspect, registers the bundled faces from data URLs embedded in the script (services/musicVideo/titleCardFonts.js, so every page draws the same face whatever it declares itself), and wraps portosComposition.seek() (chaining the film look's wrapper) so the card is drawn for each frame after its faces load. It has two looks (style). outline follows the lyric type: cream fill, ink outline, no plate. mtv is the classic lower third: the artist over the title, on a dark translucent plate with a colored bar on its leading edge (accentColor, cyan by default) and the title in titleColor (yellow by default). Both sit inside the same title-safe insets, and a block wider than its zone shrinks rather than running off frame. It also rides on the lyric pages (the lyric-timing playthrough and the lyrics laid over the storyboard animatic, which carry no film look), so it can be checked against the timed words before the composition exists. A composed render (composition.mode: 'composed') and its draft excerpts draw it too: projectTypographyPlan adds a text-less title-card cue, and buildTypographyDocument installs the same runtime on the transparent overlay page captured over the cut.

Fades and slide

fadeSec is the fade in and fadeOutSec the fade out (0–3 s each, each at most half the window it is in). A card saved before the two were separate has no fadeOutSec and fades out over fadeSec, as it always did.

Slide in (slideIn: none, left or right, the side the card comes FROM; off by default) moves the whole block (plate, bar and text) in while it fades in: it starts slidePct percent of the frame width to that side (1–100, default 12) and eases out to its resting place over slideSec (0.1–3 s, default 0.8, at most half the window). Both windows slide in. The card never slides out: it fades out where it rests. titleCardSlide(card, t) is the offset and drawTitleCard applies it, so every surface that draws the card moves it the same way, and every frame is still a pure function of song time. layout() reports resting positions.

Keep steady

A card can sit in the film or over it. Keep steady (steady) draws it over the film look and the shots' timed effects, like a broadcast graphic: gate weave does not move it, and flicker, grain and the rest of the look do not touch it. Off, the card is part of the picture and takes the look like the lyrics do. Picking the mtv look turns Keep steady on and picking outline turns it off; the director can change it afterwards. A stored card without the field follows the same rule, so an existing mtv card becomes steady and an existing outline card is unchanged.

How each surface holds a steady card over the look:

Surface Steady card
Composition document: final render, excerpts, docked live preview, the paused exact frame, generated previews The film look is a filter (and, while a preview plays, a transform and overlay layers) on the page's root element, which no ordinary element inside the page escapes. The runtime puts a steady card's canvas in the browser's top layer (popover="manual"), which the root's filter and transform do not reach. These pages are captured by page screenshot or screencast, which include the top layer.
Blender film: final render, excerpts, cuts See Blender films: the film look pass draws the card instead of the join baking it.
Storyboard animatic, composition and film live previews (in this browser) The card rides the lyric layer, an iframe inside the frame the look filters. A steady card's layer is placed outside that frame, over the same box: the lyric layer itself when it carries only the card (every Blender film preview, or a video with its words switched off), otherwise a second, card-only layer while the words stay in the film. A saved frame lays that layer over the finished picture.
Composed cut (composition.mode: 'composed') Not available. The card is baked into the encode and the film look pass runs over it, so Keep steady is not offered on a composed project and its card stays under the look.

A browser without the Popover API (Chrome before 114) keeps the card an ordinary layer under the look.

Music-video composition documents

A Music Video project can own its whole edit as a composition document: set the render style to Composition document (composition.mode: "document") and start from the shipped layered or cel template, import a .zip, or copy a folder that already sits inside data/. Each import is an immutable version folder under data/music-video/<projectId>/composition/; imports refuse symlinks, special files, traversal names and the reserved names below (server/services/musicVideo/compositionDocument.js). Routes, all under /api/music-video/:id/composition/document: GET (manifest), POST /zip (multipart file), POST /directory ({ directory }), POST /template ({ template: "layered" } or { template: "cel" }; omitted means layered), GET /export (zip), GET /preview, GET /playback ({ native, reason?, data? }: whether the document is the unmodified shipped template), GET /file?path= (one document file, served inert), DELETE (detach).

Every render of the project — the final render, a draft excerpt, its contact sheet and the auto-review excerpt — copies the document into a job-private folder under data/music-video-song-renders/<jobId>/ and adds, before the snapshot freezes (server/services/musicVideo/documentRender.js):

  • portos-mv.js — window.PORTOS_MV = { project: { id, name, aspect }, render: { width, height, fps, frames, durationSec }, song: { durationSec, bpm, beats, downbeats, sections, words }, lyrics, lyricMarkers, scenes: [{ sceneId, label, startSec, endSec, shotMode, visualLayer, stillMove, cardText, cardColor, textZone, lyricRole, lyricSize, lyricText, lyricAnchor?, direction, media: { kind, src, inSec, outSec, fps, width, height } | null }], titleCard: { position, windows: [{ startSec, endSec }] } | null, textCues, composition: { mode, style, posterSec, overlay } }. Load it with <script src="portos-mv.js">; the sandbox refuses fetch. A shot cut on a sung word carries lyricAnchor: { cueId, wordIndex, wordCount, onsetSec, text }: the words it was cut on and where they are sung now (onsetSec is null when they lost their timing), so a composition can key a reveal to that word. Document shot manifests accept the same lyricAnchor: { cueId, wordIndex, wordCount? } on a shot, and the storyboard check names a shot whose start is no longer on its word. A shot's speedFps/offsetFrames are baked into the staged clip, so a render seeks media.inSec + (t - startSec). The live preview bridges the original bytes instead: its media keeps source-time inSec/outSec and adds timing: { rate, leadSec, startSec } (only for a retimed shot), and the page seeks startSec + max(0, (t - scene.startSec) - leadSec) * rate.
  • song.json — the same song block the code-rendered mode reads.
  • media/scene-<sceneId>.<ext> — each scene's selected take, named by scenes[].media.src. A selected still effect or photo sequence uses the still even if the shot retains a clip; otherwise a clip takes precedence.

Selected still effects also carry scenes[].imageEffect and, when an end image exists, scenes[].media.end.src naming its staged file. effectFrames lists the selected cut, end-glitch and in-shot effect parameters by absolute song frame. The layered template blends the end image using imageEffectState(spec, shotFrame / fps, { fps }) from musicVideoImageEffects.js, then paints a matching effect frame with globalThis.__portosMusicVideoGlitch(canvas, params, frame). PortOS installs that painter in the sandbox when the edit has selected glitches.

A wipe on a cut (server/lib/musicVideoWipes.js: shape, pattern and move wipes) arrives on the scene it runs into as scenes[].wipe = { style, frames, lengthSec, cutFrame, fromSceneId, color }, with color as #rrggbb or null. It adds no time: from cutFrame the incoming scene's clock runs as usual, and for frames frames the outgoing scene's last frame, held, gives way to it. When the edit has wipes PortOS installs globalThis.__portosMusicVideoWipe(ctx, { outgoing, incoming, style, index, frames, color, width, height }), which draws wipe frame index from two drawables already fitted to the frame. It is the same definition the final render's ffmpeg filter and the storyboard animatic paint, so a document that calls it shows the chosen wipe with no code of its own. A cut carries a glitch or a wipe, never both (the outgoing shot's choice wins).

Custom documents declare which selected effects they implement with // portos-selected-effects: N in engine.js or <!-- portos-selected-effects: N --> in index.html: level 1 covers cut glitches, end-image effects and in-shot glitches; level 2 adds wipes.

An older document that does not declare this support is refused when selected effects need it, rather than silently omitting them. In Document source, Update layered engine adopts an unmodified shipped layered engine as a new document version; customized engines remain untouched and need their own implementation. Updating invalidates earlier render-review evidence.

Staged audio/video is streamed from disk by byte range rather than held in memory, so a song's worth of footage fits. The document stays on song time: an excerpt seeks the same song times as the full render (startSec + n / fps, snapped to the frame grid). The picture is captured silent and the master song (plus the optional sound-design bed) is muxed under it. The project's aspect ratio picks the frame; a page authored at another aspect must list that size in portosComposition.formats (its layout({ width, height }) hook reframes).

Social cuts (#9280). A draft excerpt can render at another frame than the project's: POST /api/music-video/:id/excerpt takes aspect (16:9, 9:16, 1:1) and fade (fade-in 0.08 s and fade-out 0.6 s on the excerpt's audio). The render re-frames the project for that one job, so PORTOS_MV.render and PORTOS_MV.project.aspect report the cut's frame, and the page must declare that size in formats (or read its size from PORTOS_MV.render). The stored project keeps its own aspect, and the excerpt records aspect, fade, width and height. Footage (ffmpeg) projects refuse a re-framed cut (EXCERPT_ASPECT_UNSUPPORTED), because cropping their 16:9 frame slices through the type layers. GET /api/music-video/:id/social-cuts?count=&minSec=&maxSec= suggests hook windows: sung lines, lip-sync coverage, chorus sections, loudness and the title lyric, returned as non-overlapping { startSec, endSec, score, label, reasons }. The Review stage lists them with a one-click "Render 9:16".

A seek(t) that rejects — for example a <video> that failed to seek — fails the render and names the frame. A page that draws <video> frames must await seeked before painting; the template seeks to the middle of the source frame so a frame boundary can never round to the previous one.

The shipped layered template (server/services/musicVideo/documentTemplates/layered/) draws each scene's take with a gentle camera move and beat punch-ins, film grain and a vignette, a title card for card scenes, kinetic lyric type (the subtitle text cues, else the timed lyrics, plus hook slams for cues flagged hero) through the shared lyric-type module below, and an optional HUD from composition.overlay (title lines, a meter with keyframes, a ticker, timecode). It declares all three frame sizes. Its fonts (IBM Plex Mono, IBM Plex Sans Condensed, Big Shoulders Stencil Display, Archivo) are SIL Open Font License; the licenses ship beside them. Once copied into a project the files are the project's to edit — project-specific cards belong there, not in the template.

Cel template: drawn-character films

In Compose → Document → Document source, choose Cel — drawn characters and Start from template, or send { "template": "cel" } to the template endpoint. This is an editable code-rendered starting scene, not an LLM call: it needs no selected stills or footage to preview or render an excerpt. It uses the same immutable import, sandbox and portosComposition.seek(t) contract.

The template lives in server/services/musicVideo/documentTemplates/cel/:

  • main.js composes an original garden scene with a wide walking shot and a close shot with rack focus. Replace these example shots with the film's own direction; template rendering does not approve a production's art or proof.
  • kit/stage.js caches painted planes and combines layer parallax/blur with one optical finish: bloom, color misregistration, diffusion and seeded grain.
  • kit/cel.js, solid.js and core.js draw true-depth posed forms with flat paint, a shadow tone, cast shadows and silhouette/color/depth-step ink.
  • kit/figure.js, head.js and skeleton.js provide a posable original figure, two-bone IK, a skirt draped over the legs, trailing hair and surface-attached face/fold details. Palette and geometry are editable source, not a fixed character asset.
  • kit/walk.js supplies the default authored beat-locked gait. The pose is held on twos while planted feet remain attached to the ground. kit/mocap.js retargets captured or authored joint paths to the skeleton; the optional captured walk in kit/motion/walk.js retains its CMU Graphics Lab source credit and terms in the adjacent CREDITS.txt.
  • kit/song.js maps absolute song time to beats and bars with a chosen bar anchor: makeSong(mv, { anchorSec, beatsPerBar: 4 }) returns beatAt(t), beatTime(beat), barAt(t) and barTime(bar, beatWithinBar). Bars start at 1; the default anchor is the first downbeat, then the first beat. Edit the anchor and shot schedule rather than integrating time from a previous frame.

Authoring motion with the cel kit

To replace the default authored gait with the bundled captured walk, keep the absolute-time drawing hold but feed the returned skeleton to buildFigure:

import capturedWalk from './kit/motion/walk.js';
import { walkOn } from './kit/mocap.js';
import { buildFigure } from './kit/figure.js';

const beat = song.beatAt(timeSec);
const heldTime = Math.floor(timeSec * fps / 2) * 2 / fps;
const heldBeat = song.beatAt(heldTime);
const { sk } = walkOn(capturedWalk, beat, heldBeat, {
  heading: Math.PI / 2, origin: [-2, 0], beatsPerCycle: 2,
});
const figure = buildFigure(sk);

walkOn uses clip travel and foot-plant weights to keep the held pose's stance foot fixed while its root travels. For a non-walking joint-path clip, sample the rows directly and retarget them. sampleRow interpolates a fractional frame; false for the third (loop) argument holds the first/last frame outside the clip. Looping clips include a duplicate terminal row.

import { sampleRow, retarget } from './kit/mocap.js';
const row = sampleRow(capturedWalk, timeSec * capturedWalk.n / capturedWalk.seconds);
const skeleton = retarget(row, capturedWalk, { yaw: 0.4, at: [0, 0, 0] });

Joint rows use the ordered names in the clip's joints array, XYZ per joint, followed by head-forward, head-up and four hand-axis vectors. Metadata supplies source leg/arm lengths, ankle height and resting foot angle so retargeting fits the example figure instead of stretching its bones.

For an authored reach rather than a captured clip, ik2 provides a two-bone chain. Its target and bend pole are in world coordinates/directions, and lengths are metres. Targets beyond reach are clamped while preserving both bone lengths:

import { ik2 } from './kit/skeleton.js';
const shoulder = [0.15, 1.4, 0];
const target = [0.35, 1.15, 0.25];
const { mid: elbow, end: wrist } = ik2(shoulder, target, 0.262, 0.232, [0, -1, -0.3]);

All imports are static and local. Seek backwards and out of order when testing edits; do not add wall clocks, unseeded randomness, network loads or incremental physics. The shared Three.js toonWorld.js below is a separate geometry-world kit, not a dependency of this painted-stage cel template.

Shared kinetic lyric type (lyricType.js)

Any composition document can draw its sung words with one consistent look instead of writing its own type engine (server/services/musicVideo/documentTemplates/shared/):

import { createLyricType } from './lyricType.js';
const lyricType = createLyricType(window.PORTOS_MV);
await lyricType.ready;
// each frame, after the picture:
lyricType.draw(ctx, t, { width, height });

Link lyricType.css from the page for the bundled faces (Archivo variable widths and IBM Plex Mono, OFL licences in fonts/). When a stored document references lyricType.js or lyricType.css without shipping them, PortOS adds its copies and the faces at the document root; a document's own copy is kept.

Lines come from PORTOS_MV.lyrics with their aligned word times (or options.lines), and every timing is read from the song data. Each line gets one of four roles: line (default sung line: words rise and fade in on their onsets, the line drifts up and fades 0.3s after its last word ends, but never before that word has shown for 0.8s), hook (wide caps centred in the zone, the lower band unless the shot names one, each word slams in on its onset, one outline-only accent word with ink beneath its light outline, cut on the next beat; a chorus phrase slams the first time it is sung and its repeats come back as sung lines unless hookRepeats: 'hook'), stamp (a three-frame stamp with a small tilt, optional strike-through) and data (mono HUD caption whose numbers roll; at most one per shot). A per-line override or the line's own role wins, then the shot's lyricRole, then the lyric sheet: the line's lyricMarkers delivery direction (spoken/shouted lines stamp), else its section header (choruses, hooks, refrains and drops are hooks), else line. Each shot's textZone (the corners upper-left, upper-right, lower-left, lower-right, centred upper / lower, center, or none to keep the shot clear) places the words so they never cover the subject, and its lyricSize (small, large, or null for normal; LYRIC_SIZE_SCALE) scales the type and its share of the frame, never below the floor. A line never shows before its first word onset, stays at least 0.8s, and sung type stays at least 56px at 1080p. Type stays out of the picture's way: one caption covers at most 6% of the frame (hooks 10%, data readouts 4%; LYRIC_FIT) in at most two balanced rows, shrinking to fit but never below that floor. Palette tokens (fill, ink, accent, strike), fonts and an optional ink-boil jitter are options. Upgrading a pre-module layered engine adds the module's two tags in front of the template's engine tag.

The in-app composition, code-video, and code-animation previews do not run generated HTML in the user's browser. A private local Chromium worker executes the page and returns JPEG frames (and, for a recording, the finished WebM). The worker is wrapped in macOS Seatbelt (/usr/bin/sandbox-exec) or Linux bubblewrap (/usr/bin/bwrap --unshare-net). Seatbelt denies IP sockets, then allows the browser's own Unix sockets so Chrome can start, and denies the system resolver socket (/private/var/run/mDNSResponder) plus the DNS mach services. Bubblewrap's empty network namespace blocks direct UDP and TCP, and existing host resolver sockets are covered with /dev/null. Chrome's own process sandbox cannot initialize inside that jail (sandbox_init returns EPERM and the renderer crashes), so the worker starts with --no-sandbox. The OS jail is the egress boundary. Windows, Firefox, and WebKit have no such jail: the preview shows an explicit unavailable state and does not fall back to an iframe. Chrome's HTTP proxy is not the boundary. A fresh srcdoc realm can still open WebRTC; the OS deny is what stops UDP, HTTP, and navigation. The egress test drives a real local STUN, HTTP, and WebSocket collector, including a nested srcdoc, and treats a silent negative control (no jail) as an invalid proof. DNS is not proven by binding port 53. On macOS the same profile must make getaddrinfo fail for a name that resolves outside the jail, because the system resolver would send that query itself.

One document is exempt from running in the worker while it plays: the unmodified shipped layered template. It carries no authored code, which the server proves by matching its engine, entrypoint, helpers and fonts byte for byte (shippedLayeredDocument). The dock plays such a project's selected takes in the page with the storyboard animatic's player and opens the worker only for the exact frame of a paused, scrubbed, stepped or saved moment. The page still never loads the document. The playing picture repeats the template's own drawing with shared camera paths, image effects, beat timing, selected glitches, wipes and lyric type. A native canvas draws the template's vignette, grain, beat punch, high-energy downbeat glitch, card scenes and HUD over the browser's playing media. The words-only page merges hook cues with sung lyrics (or the director's subtitle cues), using the template's palette and frame clock. Video and shot template-effect controls apply to the native drawing too; a response from an older server without drawing data keeps visible beat reactions on the worker. GET /playback supplies the same structured song, scene and composition drawing data as the renderer, without document code or media files. The dock refreshes that data when its drawing inputs change. Every other document keeps the worker for playback too. Design record: native composition preview.

documentTemplateParity.browser.test.js compares the native player's visible picture with the unchanged shipped engine at fixed song frames: finish, footage and still camera moves, beat reactions, cards, HUD and hook/sung/subtitle type. Landscape cases render at 1920×1080 and display at 640×360; square and portrait cases use their render aspect at the same display width. The tolerance is mean RGB error below 0.3 out of 255 and fewer than 0.4% of channel samples differing by more than 12. Frame centres avoid media-element microsecond rounding at 24fps boundaries. These are synthetic headless Chromium comparisons; they do not claim physical-phone playback measurements. Final document renders are unchanged.

Seek, film look, and the title card go over the private render protocol. Project media stays on the server (streamMedia); the client never fetches it into the page. Exact document or job revisions retire the previous worker. Closing the session kills the browser process group and deletes the scratch directory under music-video-song-renders/.

During cooperative playback, the viewer presents valid images in arrival order with at most two pending images and one presenting image. Actual visible-image decode and one animation-frame opportunity precede the next presentation task. Queued images cannot replace that current image early. A 100 ms receipt-to- acknowledgement deadline and queue bound stop the preview clearly when the consumer cannot keep up; intermediate images are never silently discarded. This adds bounded viewer latency without changing the source/audio clock. Pause, scrub, visual controls and session changes retire pending work; exact images keep their generation rules, and Save frame uses the actual known decoded current image. URLs retire after their DOM references. Native cadence, lag and drift still require measured evidence for the particular source.

New Canvas and Three.js candidates generated by PortOS package this complete-frame producer automatically. The host awaits packaged fonts and any selected media, draws with the existing renderer into private surfaces, and commits only the finished frame. Three.js retains its scene, text and lens passes and exact subframe timing outside narrative holds. Section functions must finish synchronously before returning; Promise/deferred drawing and retaining ctx violate the generation contract. The packaged engine-reference.js retains the original renderer for comparison. Existing generated, imported and customized documents keep their existing path.

Optional authored complete-frame playback

A custom music-video document can explicitly supply a cooperative producer without PortOS rewriting its engine. Ship preview-playback.json beside index.html, containing exactly:

{"version":1,"contract":"portos-complete-frame-v1"}

Before document readiness, install an own previewPlayback data property on portosComposition. Its own data properties declare version: 1, fps: 30, presentation: 'atomic-canvas-v1', and mediaTiming: 'staged-output-v1', and provide the methods below. PortOS snapshots and binds the methods with their original receiver once. Replacing the public API later cannot redirect calls.

Method Input Successful result
clock() None Synchronous, finite, nonnegative, nondecreasing performance.now() milliseconds
prepare(input) {epoch, sequence, t} {epoch, sequence, status:'prepared', t} with the requested values
play(input) {epoch, sequence, t, workerTime} {epoch, sequence, status:'playing'}
sync(input) {epoch, sequence, t, workerTime} {epoch, sequence, status:'accepted', driftSec?}; optional drift is finite
pause(input) {epoch} {epoch, status:'paused'} after all owned work retires
dispose() None Release author-owned resources; host closure also destroys the owned page

Replies have no extra fields. play may instead return resync-required, superseded or stale; sync may return resync-required or stale, retaining the same correlation fields. A current operation that reports stale/superseded requests synchronization. The host's bounded resynchronization policy still applies. Operation sequence and captured-frame sequence are separate values.

The producer owns atomic visible commits: finish source/font/image preparation and every asynchronous drawing dependency before publishing the complete frame in one synchronous task. Retained callbacks must not mutate the visible document after publication or pause. Honor the request's epoch/sequence and cancel stale work before it can publish. Use the staged PORTOS_MV media timebase, applying rate/lead transformations once; keep original song time separate from quantized or held drawing time. Advance the film-look and title finishing hooks from song time, preserve lyric stacking and pointer behavior, and keep exact seek(t) consistent with paused/scrubbed frames and the final renderer.

The synthetic reference in server/test/authoredPreviewFixture.js packages its own scheduler and complete-frame bridge, with asynchronous source preparation and a deliberately limited, checked source mapping.

Declaration and handshake are an author's promise of this behavior. They do not prove arbitrary JavaScript purity, isolate same-realm canvas/DOM effects, certify untracked embedded media, or establish content throughput. The host enforces method shape, correlation, clock checks, queue ordering and deadlines: clock, play and sync have 1.5 seconds; prepare and pause have 3 seconds. It gates frames immediately before retirement. An invoked producer that fails, exceeds its deadline or returns an invalid reply loses the owned page; uncertain work never continues into exact rendering on that page. An explicit reopen is a new session.

Missing declarations preserve the existing canonical recognition/exact path. Present malformed, unknown or incomplete contracts retain exact fallback without source rewriting. Eligibility still requires the verified Chromium 155+ capture baseline, native video-frame request/cancel callbacks, and trusted finite selected video cadence above 10 fps. This does not establish 30 fps for every source or document, 1080p/4K decoding, GPU presentation, or audible A/V synchronization.

Previously generated documents with older or customized engines are not automatically opted in. Their hosts may ignore section Promise returns; rewriting them would change historical semantics. Generate and accept a new candidate to use the current generation contract. Custom authors implement the API and re-import; historical engine adoption remains an explicit version change.

Two limits stay explicit. The lyric-overlay animatic still mounts the shipped lyric template in an iframe because the storyboard has to show through it; that template is not given private media. Final MP4 encodes still use the existing owned Chromium with HTTP interception, not this OS deny. Authenticated agents keep the same preview authority as the signed-in user.

Inside the worker the page still seeks with portos-mv:seek. Assign a media src when a frame needs the file. The server stages that file; the user's browser only receives the rendered frame.

Bound your own decoded-image cache the same way. A decoded 2048×1152 frame costs about 9 MB however small its JPEG is, so a cache of 90 atlases can hold most of a gigabyte, and iPhone Safari reloads the tab ("A problem repeatedly occurred") or purges and re-decodes images mid-playback. Keep a few seconds around the playhead (for example the current frame plus the next one or two seconds), release the rest by dropping their Image objects, and decode the next frame inside seek before drawing it.

Launch-video admission (API foundation)

POST /api/html-composition/render accepts launchVideo: { targetDurationSec: 20 }. This option is required for directories rooted at launch-videos/ and can also be applied to other composition directories. An app's Launch Video tab (/apps/<appId>/launch-video, also linked from Overview) queues a user-triggered CoS task with tone, direction, one or more formats (landscape, vertical, square), duration (15–120 seconds), an optional Dynamic motion graphics style, optional generated original music or an existing Music-library track (each listed with its filename and an inline audio preview), and an optional provider/model/effort pin (Auto uses the normal CoS provider selection). Closing its drawer does not cancel the run; use CoS agents to follow or cancel it. Overlapping submissions for the same app are refused until its task settles. No schedule or boot-time provider call is installed. Generated music is explicitly opted into for this run. Music creation offers Agent composition (musicMethod: "agent", the default) and Configured music service (musicMethod: "service"). Agent composition writes an instrumental score using the PCM contract above, so absent music engines do not block it. The service option selects a ready music engine, writes an instrumental prompt, waits for its media job, and passes the resulting library filename to the renderer. An explicit service choice still reports missing setup rather than silently changing methods. Shorter music beds loop to fill the video. A generation failure is reported instead of silently dropping the soundtrack.

Motion style (motionStyle) picks the film's grammar: walkthrough (the default: a paced tour of the key flow), showreel (beat-cut kinetic type, color-field swaps and generative geometry around the key flow) or ui-morph (one container that never cuts, morphing through the product's states on a 120 BPM beat and looping seamlessly). The legacy motionGraphics: true means showreel. Every style carries house rules: springs instead of easing curves, a hook in the first two seconds, something new every two to four seconds, one accent color, and no centered-title-on-gradient, fade-everything, corner-label or frame-border defaults. The same privacy and reading-time gates apply, so every kinetic word is a storyboard line with a readable hold.

Critique rounds (critiqueRounds, 0–4, default 2) make the agent render a proof contact sheet, score its own frames (hook, phone readability, variety, composition, brand accuracy, storyboard fidelity), log the three worst problems in plan.md, fix them and proof again before the final render, stopping early when every score reaches 8.

Consult motion skills (motionSkills: true) is offered once npm run setup:motion -- --skills has installed any skill pack. The server resolves the installed skill names and refuses the option when none are present. Skills are technique references only: the run still produces the portosComposition contract and renders through PortOS.

The task supplies the selected app repository and process ports separately from the PortOS media API origin. Product evidence comes from that repository; PortOS serves only as the orchestration/rendering service unless it is the selected app.

App runs pass appId and runId together in launchVideo, with directory exactly launch-videos/<appId>/<runId>/composition. The run is pinned to this instance. On success, the renderer exclusively creates plan.md, storyboard.json, caption.txt, video.mp4 and poster.jpg beside composition/, using the validated in-memory source snapshot. A run that asked for several formats (formats on the launch-video request; the older single format is a one-item list) gets one composition written against layout, and its render delivers video-<format>.mp4 and poster-<format>.jpg per format with the plan, storyboard and caption once. Every format's history entry carries the same launchVideo.runId, and the privacy and storyboard gates run once per job on the frozen assets. A revision re-renders every format its source run delivered. Media History retains its normal video and thumbnail copies with app/run metadata. The Launch Video tab previews the recent takes (the selected video is the ?video=<id> URL param, newest by default), groups a run's formats into one take with a format switcher and a download per format, and offers caption copy and a new-run action. Existing artifacts are never overwritten. This reuses CoS queues and media history; it adds no record store.

Alongside index.html, put non-empty plan.md, caption.txt, and storyboard.json in the composition directory. A storyboard has this shape:

{
  "posterSec": 5,
  "scenes": [{
    "durationSec": 20,
    "lines": [{
      "text": "Plan your next great project",
      "wordCount": 5,
      "holdSec": 2
    }]
  }]
}

Scene durations must total 15–120 seconds, within two seconds of the requested 15–120 second target. Each line's actual whitespace-separated word count must match wordCount; its hold must be at least max(0.8, 0.3 × words) seconds and fit inside the scene. The composition's runtime duration must equal the total. posterSec must be inside that duration and selects the generated thumbnail.

Admission scans all UTF-8 HTML, CSS, JavaScript, JSON, SVG, Markdown, and text assets, including the plan, storyboard, and caption, for the shared PII patterns and recognizable secret tokens. It refuses detected values rather than silently redacting them; errors identify the pattern and file without quoting the value. Only WOFF/WOFF2 fonts are accepted as binary assets. Raster images, footage, and other uninspectable formats are refused on this path. Existing general HTML composition renders retain their asset support.

The same checks run again on the renderer's frozen in-memory assets before any browser script executes, covering edits while a job waits in the queue. These are text-pattern and declared-storyboard checks, not proof that arbitrary code cannot construct private text or violate its declared timing. Producers must still avoid secrets and live records, use fictional content, and make the composition faithfully implement the storyboard. Nothing is uploaded or posted.

Motion studio setup

npm run setup:motion reports and installs the optional motion toolkit:

  • ffmpeg — installed with Homebrew, winget, apt or dnf after confirmation (--yes skips the prompt; non-interactive runs print the command instead).
  • Skill packs (--skills for all, or --skills=hyperframes,remotion) — HyperFrames (hyperframes-animation, hyperframes-creative, motion-graphics, product-launch-video), Remotion (remotion-best-practices) and Claude Animation (claude-animation), installed user-wide for Claude Code and Codex through the skills CLI.
  • --status [--json] prints what is installed. GET /api/html-composition/toolkit returns the same view to the launch-video form.

The seek(t) renderer, motion kit and proofs need nothing beyond ffmpeg and the managed browser (npm run setup:browser).