Miraiclip SDK
Reference

Changelog

Release notes for every @miraiclip package.

Generated from the engine repo's CHANGELOG.md.

All notable changes to Miraiclip are documented here. The format follows Keep a Changelog, and versions follow SemVer.

Unreleased

Fixed

  • @miraiclip/renderer exports use the same frame rule: exportComposition / exportProject compute each frame's start, duration and the frame count with core's frameToUs / frameCount on the exact rate (fps takes a number or { num, den }; default projectFrameRate), so the main-thread, worker and headless exports match core's frames. StepClock.step derives the next frame from its index instead of adding a rounded frame length (it drifted about one frame per hour at 30 fps). New exportFrameWindow exposes an output frame's span and sample time. Tests: an hour at 29.97 with frame-boundary cuts, saved and reloaded, where preview seeks, clock steps and the export walk agree on every frame's time and clip; and a headless Chromium export whose WebM holds exactly the predicted 1,799 frames at the predicted times. Requires core 0.5.8.

core-0.5.8 — 2026-10-04

Added

  • @miraiclip/core exact frame rates — ProjectSettings.frameRate holds the rate as a ratio ({ num: 30000, den: 1001 } for 29.97). project/set-settings and createProject store decimal NTSC rates (29.97, 23.976, 59.94…) exactly and keep fps in sync; project/set-settings also takes frameRate directly. New helpers: toFrameRate, projectFrameRate, formatFrameRate, frameCount. usToTimecode takes { dropFrame: true } for SMPTE drop-frame timecode at 29.97 / 59.94. See Timeline precision.

Fixed

  • @miraiclip/core frame ↔ µs conversion round-trips: frameToUs rounded and usToFrame floored, so at 30, 29.97, 23.976 and 59.94 fps a third of frame starts mapped back to the previous frame (frame 1 at 30 fps became 33,333 µs → frame 0), and timecode could read one frame early. One rule now: frame n starts at ⌈n · 1e6 · den / num⌉ and usToFrame is ⌊us · num / (1e6 · den)⌋, in exact integer arithmetic. frameToUs results move by at most 1 µs. Thanks to Launch Gate on dev.to for the report.

core-0.5.7 + renderer-0.7.11 + templates-0.1.3 — 2026-10-03

Added

  • @miraiclip/core editable html clip code — clip/set-property takes template (replace the markup), unsetParams (drop params the new markup no longer uses) and widthPx / heightPx (null falls back to the composition size). The clip keeps its id, keyframes, effects and transitions; the change is one undo step. The groundwork for in-app code editors. See HTML clips → Params.

Fixed

  • @miraiclip/renderer html clips no longer render blank at some output sizes (a fractional texture resolution made Pixi resize, and so clear, the raster canvas — e.g. a 2720 px preview or export). Html clips also recover from a failed raster (a font fetch during a dev-server restart, say), retrying on a backoff instead of keeping a stale or empty frame.
  • @miraiclip/templates html clips that draw media (asset:<id>) survive insertion: insertCommands / insertDocument rewrite those references when an asset is renamed or reused, and suggestTemplateFields offers media used only that way as a slot.

core-0.5.6 + renderer-0.7.10 — 2026-10-02

Added

  • @miraiclip/core + @miraiclip/renderer animated html clips — animated: true re-rasterizes an html clip every frame, so CSS animations inside the template play, seek and export frame-exactly. The renderer pauses the animations and seeks them to the clip's time (--t / --T on the root, --d for per-element delays); preview coalesces rasters, exports and stills await each frame (compositor.renderExactAt), worker exports request them from the main thread. clip/split sets animationOffsetUs so the animation continues across the cut. New renderer exports: htmlAnimationTiming, htmlRasterKey, setHtmlRasterSource. See HTML clips → Animated templates.

assistant-0.1.1 + templates-0.1.2 + audio-sources-0.2.1 — 2026-10-02

Added

  • @miraiclip/assistant fixes and tools from live evals — new add_effect, remove_effects, set_effects_enabled, trim_clip, close_gaps, set_keyframes and set_background tools; apply_commands rejects fields a command would ignore and reports commands that changed nothing; a richer project summary for the model (transforms, styles, volume, effects, animation, cuts, captions). openAIChatModel retries rate limits and server errors as long as the server asks (maxRetries), and rateLimitedChatModel keeps any model under a per-minute budget. See Assistant.
  • @miraiclip/templates templates for editors — suggestTemplateFields lists what in a finished project could stay editable and templateFromDocument turns the picks into fields ("Save as template"); insertDocument / insertCommands add a hydrated template to an existing project as one undo step (new tracks on top, ids remapped, media and fonts reused); fitClipsToMedia fits clips and transitions to shorter swapped-in media. Templates take category, tags and thumbnail; fields take a label. See Templates → From a finished project.
  • @miraiclip/audio-sources — add_audio and generate_audio take replaceClipId (the new clip takes the old one's track, start, length and volume, in one transaction).

Fixed

  • @miraiclip/audio-sources — staticProvider search ranks by how many query words match instead of requiring all of them, and ignores words that only name the kind ("music", "sound"): "calm ambient music" finds a track tagged calm and ambient.

assistant-0.1.0 + core-0.5.5 — 2026-10-02

Added

  • @miraiclip/assistant (new) AI editing assistant — a vendor-neutral ChatModel contract with an OpenAI adapter (openAIChatModel, Chat Completions with function calling and streaming; baseUrl reaches any OpenAI-compatible server). createAssistant runs an agent loop on a working copy of the project and lands each request as one undo step (or waits for turn.apply() in review mode), with streamed events, a "what changed" list and cancel. Tools: get_state, get_command_schema, apply_commands, add_transition, animate_clip, plus defineTool / toolsFromDefinitions for your own (e.g. audio). createChatHandler + remoteChatModel keep API keys on the server. See Assistant.
  • @miraiclip/core animation presets and cuts — ANIMATION_PRESETS (in / loop / out: fade, slides, zoom, spin, pop; pulse, float, sway, Ken Burns), animationCommands(clip, recipe), readAnimation(clip) (recognizes a recipe from keyframes), describeAnimation, fitAnimation; findCuts(doc) and clipHeadroomUs for where transitions fit and how long they can be. Editor panels and AI tools share them.

core-0.5.4 + renderer-0.7.9 — 2026-10-02

Added

  • @miraiclip/core + @miraiclip/renderer hidden tracks — track/set-property { hidden } (optional Track.hidden; false removes it). The compositor skips a hidden track's clips in preview, exports and stills, and getClipBounds / hitTest ignore them; sound follows muted as before. describeProject lists track flags (muted, solo, locked, hidden). See Tracks.
  • @miraiclip/core + @miraiclip/renderer end-anchored keyframes — keyframe/set / keyframe/remove take anchor: "end" (time measured back from the clip's end), so exit animations follow trims. New resolveKeyframes / keyframeTimeUs helpers; evaluateKeyframes takes an optional clip duration. clip/split keeps end-anchored keyframes on the right half only. Volume automation honors the anchor. See Animation.

audio-sources-0.2.0 — 2026-10-02

Added

  • @miraiclip/audio-sources AI audio generation with a vendor-neutral contract: an AudioGenerator declares its kinds (sfx / music / voice), models, voices, limits, a JSON-Schema paramsSchema for vendor-specific settings and its output terms. generate(request, options) returns bytes or a URL; params pass through untouched, so any service adapts without package changes. startGeneration / library.generate run cancellable jobs with progress and store the output with its source and license; createGeneratorHandler (Fetch-API server handler) + remoteGenerators (browser proxies) keep API keys server-side. Adapters: elevenLabsGenerator (sound effects, music, text-to-speech, voices) and an offline toneGenerator. New LLM tools generate_audio and list_voices. Breaking: the unused placeholder GenerateAudioRequest / AudioJob types and AudioProvider.generate are removed. See Audio Sources → Generation.

audio-sources-0.1.0 — 2026-10-02

Added

  • @miraiclip/audio-sources — stock and library audio (new package): one AudioProvider contract (search, getItem, resolve) with adapters for Openverse, Freesound, a static in-app catalog and any backend (httpProvider). createAudioLibrary groups providers; importAudio adds a file with its source, license and credit line as one undoable step on a free audio track; audioToolDefinitions / runAudioTool expose search_audio and add_audio to LLMs; parseCreativeCommons maps CC codes, names and deed URLs to AssetLicense. See Audio Sources.

core-0.5.3 + renderer-0.7.8 — 2026-10-02

Added

  • @miraiclip/core asset provenance and clip fades — assets take optional name, source ({ provider, id, url? }), license ({ id, url?, commercial, attributionRequired }) and attribution, editable with the new asset/set-property command (null clears). Video and audio clips take fadeInUs / fadeOutUs; clip/split keeps the fade-in on the left half and the fade-out on the right. New pure helpers usedAssets, creditsFor and licenseReport; describeProject shows names, licenses, volume and fades. See Clips → Asset provenance and licensing.
  • @miraiclip/renderer fades and waveforms — fades apply in live playback and the export mix through the shared gain math, as exact linear ramps. New computeWaveformPeaks, peaksForRange and accumulatePeaks for drawing waveforms, plus clipFades / fadeGainAt for fade handles. See Rendering → Audio.

core-0.5.2 + renderer-0.7.7 — 2026-10-01

Added

  • @miraiclip/core + @miraiclip/renderer caption decorations — display: "word" (word-by-word), textTransform, outline (strokeColor, strokeWidthFrac), drop shadow / glow (shadowColor, shadowBlurFrac, shadowOffsetFrac), activeBackgroundColor (a box behind each emphasized word) and a reveal preset. All optional; clip/set-property clears them with null and can replace caption words. New helpers captionsToSrt, captionsToVtt, captionsToText and retimeWords. Font assets accept weightRange, loaded as variable faces in captions, text and html clips. See Rendering → Captions.

renderer-0.7.6 — 2026-10-01

Added

  • @miraiclip/renderer getClipBounds and hitTest on the Compositor and the Player: a clip's drawn box (rotated, composition pixels, keyframes evaluated) and the topmost clip under a point — the geometry for on-canvas selection and move/scale/rotate handles. Html clips report their painted content.

renderer-0.7.5 — 2026-09-30

Fixed

  • @miraiclip/renderer exports no longer stall at cuts between two clips of the same video: the lookahead stopped warming the upcoming clip on the pipeline the on-screen clip was reading (they fought over one decoder's seek position — about 2 s per frame, CPU pegged).

core-0.5.1 + renderer-0.7.4 + server-export-0.4.3 — 2026-09-30

Added

  • @miraiclip/core + @miraiclip/renderer effect library — 79 built-in effect kinds (76 new, alongside colorAdjust, blur, chromaKey) in seven categories: color (warm/cool, hue shift, vibrance, exposure, gamma, duotone, gradient map, color pop, solarize, posterize…), film (sepia, vintage, teal & orange, noir, bleach bypass, cross process, cyberpunk, vaporwave, night vision, grain…), stylize (halftone, LED matrix, crosshatch, neon edges, sketch, emboss, sharpen, cartoon, oil paint, hex mosaic, 8-bit, dither…), glitch & retro (RGB split, scanlines, CRT, VHS, glitch, TV static, lens fringe), blur & light (glow, dreamy, tilt shift, zoom/motion blur, vignettes, light leak), distort (fisheye, pinch, swirl, wave, ripple, mirror, kaleidoscope) and frame & key (letterbox, rounded corners, green screen). Core's EFFECT_CATALOG is the single source of truth — param schemas are derived from it (validation + defaults), the effect/add AI tool names every kind, and editors read labels, categories, ranges, steps and display formats from it (EFFECT_CATEGORIES, getEffectInfo, defaultEffectParams); length params are fractions of composition height. The renderer draws every kind as a built-in (data-driven GLSL generated from the catalog params, Pixi color-matrix looks, seeded grain), so all 79 render in preview, browser and worker export, server export and stills — unlike custom kinds. New renderEffectThumbnails() renders each kind's real filter on its own offscreen renderer for effect pickers. Effects are static (no time input); noise looks take a seed. Docs: Effects → Effect library, plus three new live examples and a browsable Effect library gallery on the Examples page (real thumbnails, tap to stack, the shown snippet is what runs). The playground's Effects tab is now the full catalog-driven library with live param sliders.

[renderer-0.7.3] + [server-export-0.4.2] — 2026-09-27

Added

  • @miraiclip/renderer renders typography — text clips honor fontWeight, fontStyle, lineHeight, letterSpacing, and textAlign; captions honor weight, style, and spacing on every word, with lineHeight driving line stacking and letter spacing widening word gaps. Font assets' weight/style become FontFace descriptors (each face of a family loads separately; FontEnv.createFace takes optional descriptors) and carry into html-clip @font-face inlining. Clips without typography render exactly as before. Preview, browser/worker export, and stills share the mapping; @miraiclip/server-export's harness is rebuilt with it, so server exports, template batches, and MCP previews render typography too. Docs: Command Catalog → Typography.

[core-0.5.0] + [renderer-0.7.2] + [server-export-0.4.1] + [templates-0.1.1] + [mcp-0.1.3] — 2026-09-27

Added

  • @miraiclip/core typography for text and caption clips — optional fontWeight (100–900), fontStyle, lineHeight (× font size), letterSpacing (em, so it scales with font size between preview and export), and textAlign (text clips; lines within the block, independent of the anchor) on text clips and caption style (captions stay centered, so no textAlign). Font assets gain weight/style descriptors — one asset per face. clip/add and clip/set-property accept every field (null in set-property clears back to the default), the JSON Schema catalog carries the constraints to LLM tools and property panels, and describeProject lists typography only where set. No schema defaults: existing documents load, render, and serialize unchanged; TYPOGRAPHY_DEFAULTS exports what an absent field means. Docs: Command Catalog → Typography.

[templates-0.1.0] + [server-export-0.4.0] + [renderer-0.7.1] + [mcp-0.1.2] — 2026-09-23

Added

  • @miraiclip/templates — parameterized videos (new package): a template project + a data payload → a hydrated document → export, all data and no code. A template is an ordinary document plus declared fields (text/number/boolean/color/asset — one declaration drives payload validation, UI forms, and LLM tool schemas): {{field}} placeholders bind into text clips, caption words, and html-clip params (a whole-value placeholder binds TYPED), and asset fields swap an asset's src; hydration is pure and content-only, so it cannot produce an invalid document, and defineTemplate/parseTemplate verify coherence up front (every placeholder declared, every field used, asset ids real). hydrate/tryHydrate return machine-readable failures agents self-correct from; extractFields proposes a schema from a document; describeTemplate and toFieldToolDefinition (Anthropic/OpenAI shapes) make a template one typed tool call per video. Batch rendering lives at @miraiclip/templates/render — renderTemplateBatch hydrates per row and exports through warm export sessions ({field} output naming, concurrency parallel browsers, per-row results that never abort the batch) — plus a miraiclip-templates CLI taking JSON/NDJSON/CSV rows (CSV cells coerce to field types). Verified end to end: a 3-row batch census in real headless Chrome, each output's frame 0 carrying its row's color under an independent decoder (ffmpeg). Docs: Templates.
  • @miraiclip/renderer sharp text and html clips at every output size — rasterized content (html-clip rasters, text and caption glyphs) now generates at the RENDER density: output ÷ composition size in exports and stills, or the preview's devicePixelRatio — instead of composition density, which left upscaled exports and hi-DPI (Retina) previews visibly soft. Layout never changes: html templates supersample inside a scale transform, and Pixi texture/text resolution keeps logical sizes, so outputs at composition size are pixel-identical (the golden-frame e2e suite proves it) while a 2x output carries true 2x detail. Worker exports pre-rasterize at the same density (the raster key includes it). New surface: rasterizeHtml({ density }), collectHtmlRasters(doc, { outputSize }), EffectContext.renderScale, and createPlayer({ outputSize }) — hand it the canvas's CSS size × devicePixelRatio and the whole preview sharpens (the playground and the live examples page now do exactly that, capped at 2x).
  • @miraiclip/server-export createExportSession() — a persistent exporter: one headless Chrome + harness page kept warm across calls, one full export per exportFile(doc, options) call (same options and result as exportProjectFile, streamed out included), so an export costs an export instead of a ~1-2s browser launch — the difference at 100 rows. Calls queue on the one page; a failed export never wedges the session and leaves no half files. The batch engine behind @miraiclip/templates, and the building block for export-on-demand services.

[core-0.4.0] + [renderer-0.7.0] + [server-export-0.3.1] + [mcp-0.1.1] — 2026-09-23

Added

  • @miraiclip/core html clip kind — author overlays as HTML/CSS. clip/add { kind: "html", template, params?, widthPx?, heightPx? }: the template (with {{param}} placeholders — values are HTML-escaped, params are data, never markup) rasterizes at the given size in composition pixels (default: the composition size) and composites like any other clip — keyframes, effect stacks, transitions, and reveal masks all apply to the raster. clip/set-property { params } merges new params (one ~2ms re-raster). Because the payload is plain data, html clips cross every boundary custom code can't: they render in preview, browser export, stills, server export, and through the MCP server (preview_frame shows them). Docs: HTML Clips.
  • @miraiclip/renderer HTML rasterization — rasterizeHtml / substituteParams (exported): HTML → SVG foreignObject → data: URL → laundered canvas texture. The mechanics are hard-won: a foreignObject SVG loaded via blob: URL TAINTS the canvas in Chromium (killing export capture) while the identical markup as a data: URL is clean, and the decoded image needs a 2D-canvas launder before WebGL accepts it. Rasters are cached per (template, params, size) — measured ~25ms for a first 1920px-wide rich raster, ~2ms warm. Since SVG-as-image is an isolated document, font assets inline automatically as @font-face data URIs when the template references their family, and src="asset:<id>" inlines image assets the same way; external URLs never load. Worker export renders html clips too: exportProjectInWorker/exportViaWorker rasterize every html clip on the main thread first (deduplicated — one raster per unique template + params + size) and TRANSFER the bitmaps to the worker with the start message, where the compositor serves them by raster key instead of touching the (nonexistent) worker DOM. No API change — it just works; custom worker pipelines can do the same with the newly exported collectHtmlRasters(doc) / provideHtmlRasters(rasters).

Fixed

  • @miraiclip/renderer exports could race async textures: image-asset textures load fire-and-forget, so an export started immediately after building a document could bake EMPTY sprites into its first frames (html rasters would have hit the same race). SceneNode gained an optional whenReady(), the Compositor aggregates it, and exportProject / renderProjectStill now await all async node content (image textures, html rasters) before the deterministic frame walk. A failed html raster fails the export loudly; a missing image still renders empty, as before — it just can't race anymore.

[renderer-0.6.0] — 2026-09-22

Added

  • @miraiclip/renderer public effect and transition registration — the extensibility story's renderer half. registerEffectRenderer(kind, factory) maps a custom effect kind to a Pixi filter factory (params arrive validated by the kind's core schema; update mutates the live filter in place, so no shader recompiles while dragging a slider). registerTransitionRenderer(kind, renderer) maps a custom transition kind to pure per-frame math over progress, composing through four primitives — opacity, directional reveal, pixel offset, and a full-composition overlay — with rendersBothClips declaring whether the kind blends both clips through the window (from source headroom, validated at transition/add) or covers the hard cut like a dip. The five built-in transitions and three built-in effects are now expressed through these exact contracts (the golden-frame e2e suite verifies the re-expression is pixel-identical), so a registered kind is a first-class citizen: same commands, same undo, same audio crossfade, identical in preview, browser export, and stills. Boundaries: renderers are functions, so server export and worker export remain built-in-kinds-only — the existing factories rule. A kind without a renderer is still valid data (an unknown effect applies no visual; an unknown transition draws as a hard cut). Standard keyframes already applied to custom clip kinds; keyframing effect params stays a designed follow-up. New docs recipe: Custom Effects & Transitions. The live Examples page gained a runnable "Custom kind" variant in the Effects and Transitions carousels — the registration code on the page is the code that runs.

[mcp-0.1.0] + [renderer-0.5.0] + [server-export-0.3.0] — 2026-09-22

Added

  • @miraiclip/mcp — the MCP server (new package): let Claude, Codex, or any MCP client edit a video project. npx @miraiclip/mcp --project ./video.miraiclip.json --assets ./media serves one project file over stdio: dispatch validates every command against the live catalog and returns the same machine-readable failures the AI command interface defines (unknown-command → valid types, invalid-payload → per-field issues, rejected → engine code), apply_commands applies a batch as ONE transaction (all-or-nothing, failing index reported, one undo step), undo/redo step history, preview_frame renders any composition time as a PNG the agent actually sees (through the export pipeline — the preview is what the export will look like; one warm headless Chrome across calls, ~100ms a frame after the first), export streams MP4/WebM to disk, and list_commands/get_command_schema serve the catalog. Every successful edit autosaves the project file atomically, so the project survives across agent sessions. Docs: MCP Server.
  • @miraiclip/renderer renderProjectStill(project, { timeUs, width?, height? }) — render ONE composition frame to an image (PNG by default) through the exact export pipeline (same compositor, decode path, and fonts), so a still is what that frame will look like in the exported file. Thumbnails, poster frames, agent previews.
  • @miraiclip/server-export createRenderSession() — a persistent still-frame renderer: one headless Chrome + harness page kept warm across calls (a frame costs a frame, not a browser launch), documents passed per call so a session outlives edits, assets re-resolved live per render. Powers the MCP server's preview_frame.

Fixed

  • @miraiclip/renderer exportProject's width/height output-size option was silently ignored (shipped bug through 0.4.x, found building still rendering): the Compositor unconditionally resizes its backend to the composition size, so a requested output size was overridden and the file always came out composition-sized. The compositor now takes outputSize (and SceneBackend an optional setOutputSize): the canvas renders at the requested pixel size while the scene scales, keeping composition coordinates — placement, wipe masks, caption layout — meaning what they mean. Verified end to end: a 640×360 composition exported at { width: 320, height: 180 } now produces a 320×180 file (it produced 640×360 before).

[core-0.3.0] — 2026-09-22

Added

  • @miraiclip/core AI command interface — the pieces that turn the command catalog into a working LLM integration, all in core with zero new dependencies. toToolDefinitions(project.commandCatalog(), options?) emits the catalog as ready-to-send tool definitions (Anthropic tool_use or OpenAI function-calling shapes via style; one tool per command, or mode: "dispatch" for a single miraiclip_dispatch tool with a type enum for tool-count-constrained hosts such as MCP servers; command types sanitize to legal tool names — clip/add → clip_add, reversed by commandTypeForTool). tryDispatch(project, command) returns a structured CommandResult instead of throwing on command failures, with a machine-readable CommandFailure an agent can self-correct from: unknown-command carries validTypes, invalid-payload carries per-field issues (path + message from the Zod error), rejected carries the engine's rejection code; non-command errors (bugs) still throw. applyCommands(project, commands, { label? }) applies a batch as ONE transaction — all-or-nothing with rollback on the first failure, one undo step on success, and the failing index reported. describeProject(doc, { maxClipsPerTrack? }) renders a compact, deterministic state summary for prompts (ids, kinds, time ranges in command microseconds; long tracks elide their middle) instead of burning context on raw toJSON(). New docs page: AI Integration.

[renderer-0.4.1] — 2026-09-22

Fixed

  • @miraiclip/renderer unbounded memory growth across long timelines — severe enough to exhaust a machine (field report: a 257s 4K export with captions, text, animations and transitions pegged the CPU and hard-restarted the laptop; reproduced and measured with the new crash-repro harness): finished clips retained their demuxer's fetched-range cache. Every mediabunny Input caches up to 64MiB of source ranges, our video and audio adapters' dispose() never called Input.dispose() ("GC handles it"), and clip nodes keep referencing their disposed pipeline after a clip ends — so every clip that ever played kept its cache reachable: measured ~6.5MB per clip on a 91MB 4K fixture (JS heap 51→262MB over 31 clips for a 7MB output), and up to 64MiB per clip — video lanes AND per-chunk audio jobs — on multi-GB sources, all as ArrayBuffers exempt from the V8 heap cap. Both adapters now dispose the underlying Input (canceling in-flight reads and freeing the cache immediately; the decode pump treats a teardown-canceled read as termination, not a media error). Post-fix, the same run's heap is 79→147MB with the stair-step gone; the small residual is per-clip text/caption textures, bounded by clip count rather than source size.

  • @miraiclip/renderer unbounded GPU memory on REAL GPUs when the video encoder is software (field report: a ~10-minute 1080p60 WebM High export in the playground grew Chrome past 20GB by frame ~2,800 and exhausted the machine; MP4 exports were unaffected): zero-copy canvas capture (CanvasSource) feeds GPU-backed frames into the encoder, and when that encoder is SOFTWARE — VP9/WebM has no hardware encoder on macOS — Chromium retains capture shared-images behind the slow encode, the same failure class as the SwiftShader capture leak (which hardware-encoder runs had masked on real GPUs). exportProject now routes capture through the CPU mirror whenever the format's codec has no hardware encoder at the output size (new exported probe hasHardwareVideoEncoder(format, width, height)), keeping zero-copy capture only where a hardware encoder drains it. Verified on the CPU path at the reported config: a 10,798-frame worker+streamed WebM-High 1080p60 export completes with Chrome flat at ~3GB total (crash-repro harness, which also gained knobs: REPRO_FIXTURE, REPRO_BARE, REPRO_WORKER, REPRO_STREAM, REPRO_FPS).

  • @miraiclip/renderer listener churn in the export frame-wait loop: the unclamped-macrotask yield (waitForFrame's poll) created a NEW MessageChannel — and a new 'message' listener — on every iteration; a running export leaves tens of thousands of dead ports awaiting GC (observed at 35k live listeners in DevTools mid-export; pure allocation/GC churn, though the export's CPU cost itself is decode+encode). All yields now go through ONE shared channel with a resolver queue. Measured on an identical 600-frame export: peak jsEventListeners 2,776 → 61.

Added

  • @miraiclip/renderer worker export — the export pipeline (decode → composite → capture → encode → mux) can now run entirely OFF the main thread: exportProjectInWorker(project, options) spawns the bundled worker entry (new Worker(new URL("./export.worker.js", import.meta.url), { type: "module" }) — the pattern bundlers resolve), and exportViaWorker(worker, project, options) drives a Worker the app owns (worker entry at the @miraiclip/renderer/export-worker subpath; Vite: import ExportWorker from "@miraiclip/renderer/export-worker?worker"). Total CPU is unchanged — the win is a responsive page while exporting (e2e: rAF p50 on the main thread stays at display cadence mid-export, where a main-thread export starves it). Everything crosses the boundary as data: progress and abort become messages, a streaming target's chunks RELAY through the main thread with per-write acks (a FileSystemWritableFileStream — the save-picker stream — is not transferable, so the worker never receives a stream; the ack after each resolved write carries the target's backpressure to the worker's encoders), fonts load in the worker's own FontFaceSet, and audio — OfflineAudioContext is window-only — mixes on the main thread and crosses as transferred PCM planes, which the sink encodes via a new AudioSampleSource route with running timestamps (PcmAudioChunk accepted anywhere an AudioBuffer chunk is; exportProject gained the advanced audioOverride seam that powers this). Custom clip-kind factories cannot cross (functions) — worker exports support built-in kinds only, the same rule as server export. Verified by e2e: frame-exact native-decoder readback, unity-gain audio through the PCM path, and cross-boundary abort.
  • Playground export now runs in the worker and streams to disk — the Export button spawns the export worker and, where showSaveFilePicker exists (Chrome desktop), streams the encoded file straight into the chosen file (nothing accumulates in the page; a failed export discards the partial file); elsewhere it falls back to the buffered download. The page stays interactive while exporting.
  • Crash-repro harness (pnpm --filter miraiclip-playground repro:4k, local 4K fixture via stress/make-4k-fixture.sh) — exports a 4K-source workload exactly the way the playground does (buffered output, source fps, composition size) while sampling what the page cannot see: OS-level per-process Chrome memory (total, GPU process, largest renderer) beside frame progress and the JS heap. Watchdogs ABORT the export before a runaway can take the machine down — REPRO_MAX_GB (default 10) on total Chrome RSS, REPRO_STALL_SEC (default 180) on frame progress — and either abort fails the test with the full memory/progress curve, which is the diagnostic. Headed (real GPU) by default; REPRO_SOFTWARE=1 pins SwiftShader for sandbox/CI runs.

renderer-0.4.0 + server-export-0.2.0 — 2026-09-13

The production-hardening release: export memory independent of timeline length (streaming output + chunked audio, demonstrated with a one-hour export), server exports streamed to disk, the export validation corpus and device benchmark tiers, and the pipeline fixes they caught.

Added

  • @miraiclip/renderer streaming export output — exportProject (and createMediabunnySink) accept target: a WritableStream receiving { type: "write", data, position } chunks as the file is encoded, exactly the shape FileSystemWritableFileStream.write accepts, so a showSaveFilePicker() writable works directly (positions may seek backwards — containers patch their headers; stream backpressure throttles the encoders). With a target set the promise resolves with an empty array — the encoded file never accumulates in memory. Verified by an e2e that reassembles the streamed chunks and frame-checks the result with a native decoder.
  • @miraiclip/renderer chunked offline audio mixing — export audio now mixes in bounded sequential chunks (audioChunkSeconds, default 60 ≈ 23MB of PCM at 48kHz stereo) interleaved with the frame walk, instead of one whole-timeline OfflineAudioContext render (~1.4GB/hour): the first chunk registers the audio track before frame 0, later chunks mix just before frames reach their window, and silent stretches of an audible timeline become real zero-filled buffers so chunk timestamps stay aligned (mixCompositionAudio gained silenceIfEmpty; ExportSink.addAudio accepts sequential chunks; exportComposition gained mixAudioChunk for the per-chunk contract — the existing whole-range mixAudio option is unchanged and runs as a single chunk covering the entire range, so custom orchestrator callers keep working as-is). Whether a composition has an audio track is decided once up front by probing the contributing assets, so a silent first minute is not mistaken for an audio-less timeline — and an export of silent video gains no phantom audio track. An e2e proves the chunked mix is seam-free and matches the whole-timeline mix (per-window RMS, no boundary clicks). Together, streaming + chunked audio make export memory independent of timeline length: the long-export stress scenario (real audio on every clip, streamed output) settles at a heap spread of ~2MB with a ~29MB peak, and a demonstrated full one-hour export (86,400 frames, an hour of audio, 984MB streamed) peaked at 165MB of JS heap at 1.6× realtime on a dev laptop.
  • @miraiclip/server-export streams exports to disk — with out set, encoded chunks now stream from the browser page to the output file as they are produced (positioned writes over the loopback server; a failed export removes the partial file), so server memory stays flat for long outputs. The result for out calls is now { filePath, bytesWritten } — bytes is no longer returned there (breaking for callers that used both — read the file back if needed; without out, { bytes } is unchanged), and streamed progress events carry a live bytesWritten (file size on disk so far; framesDone/totalFrames remains the true completion fraction since total output size is the encoder's decision). audioChunkSeconds passes through.
  • Export validation corpus (pnpm corpus, nightly in CI) — every cell exports a real project through headless Chrome and verifies the file with ffmpeg/ffprobe, an independent decoder: whole-file PTS integrity, every frame's pixel against the fixture oracle (through mid-GOP cuts, a dissolve's blend math, fps resampling, keyframed opacity), audio unity/leading-silence/fade, A/V sync via a tone-burst fixture (measured offset: 0ms at both ends of the file), no-phantom-audio-track, and a streamed-vs-buffered byte-identity check. Verifiers are self-tested against generated fixtures' ground truth; results ledger in corpus-results.json.

Fixed

  • @miraiclip/renderer same-asset concurrency wedged playback (caught by the device benchmark tier): two clips of the SAME asset visible at once — picture-in-picture of one source, echo overlays — shared one decode pipeline and fought over its seek target every frame, each reversal clearing the frame cache; the 1080p Standard-workload bench wedged at ~9.6 presented fps with 34s stalls on real hardware. Concurrent (timeline-overlapping) clips of one asset now each get a dedicated pipeline, exactly as transition overlaps already did — while sequential clips of one asset (splits, cuts) keep sharing, since pipeline continuity is what makes cuts cheap. Alongside it, the MediaManager LRU learned two things the overlap case exposed: pipelines are now touched on every frame they're used (previously LRU order froze at acquisition time, so the eviction victim was often the pipeline longest on screen), and an actively used pipeline is never evicted — the cap is temporarily exceeded instead of entering an acquire/evict livelock (new evictionIdleMs option, default 500ms; 0 restores the hard cap).
  • @miraiclip/renderer pipeline-eviction blackout (caught by the new stress suite): the MediaManager releases least-recently-used decode pipelines over its cap (default 4 — an engine default sized conservatively for hardware decoder instances, configurable via maxActivePipelines), but clip nodes cached their pipeline forever and every clip acquired one eagerly at mount — so a long multi-asset timeline, or any timeline whose transition participants (each holding a dedicated lane) outnumbered the cap, evicted the pipelines actually on screen and rendered permanently black, on real hardware too. Acquisition is now lazy (a clip acquires when it nears visibility, never at mount) and self-healing: VideoPipeline exposes isDisposed, and clip nodes detect eviction on tick/prepare and re-acquire, so eviction costs one frame of catch-up instead of a blackout. Unit-tested by walking four assets through a two-pipeline cap and returning to an evicted clip (exact frame required); the asset-churn stress scenario now tracks playback with a 1.0 hit rate where it previously went black.
  • @miraiclip/renderer unbounded GPU memory in software-GL exports: under SwiftShader/llvmpipe (headless CI, some VMs — real GPUs unaffected), Chromium's GPU process retains one shared-image (~2.7MB at 640×360) per captured frame whenever a WebCodecs decoder and encoder run simultaneously, growing a 5-minute export to ~4GB and OOM-killing longer ones; every frame is closed correctly on our side (isolated via CDP memory-infra dumps — the capture+encode pair is the trigger, not any one feature). exportProject now detects software WebGL after the compositor's context exists and routes capture through a CPU 2D mirror canvas automatically (flat ~300MB for the same export); real GPUs keep the zero-copy CanvasSource snapshot path.

Added

  • Production-readiness stress tier (pnpm stress) — five scenarios with hard gates and recorded metrics (apps/playground/stress-results.json): ~150-clip/10-track live playback with frame-vs-clock tracking and deep-seek recovery; a 5-minute export (7,200 frames) asserting residual heap stays bounded (output accumulation excluded, so a per-frame leak fails while honest growth passes); 20 assets churning through the 4-pipeline decoder cap plus a 30-seek scrub storm with exact (±1 frame) paused probes; a 60-second export throughput benchmark with an fps floor (plus an optional 4K→1080p benchmark behind a locally generated fixture); and 3 parallel server exports gated on cross-run interference, each output independently re-parsed. Env knobs: STRESS_MINUTES, STRESS_MIN_FPS, STRESS_MAX_LAG_FRAMES, STRESS_PARALLEL. A nightly workflow (.github/workflows/stress.yaml) runs the suite and uploads the metrics artifact, and the new Production Readiness docs page quotes the measured numbers and the known ceilings.
  • @miraiclip/renderer createMediabunnySink accepts cpuCapture (route per-frame capture through a CPU 2D mirror instead of snapshotting the WebGL canvas) and exports isSoftwareWebGL(canvas) — exportProject wires them automatically; both are public for custom export sinks running in headless environments.

[core-0.2.0] + [renderer-0.3.0] + [server-export-0.1.1] — 2026-09-12

The coordinated v4 creative features release. @miraiclip/server-export@0.1.1 is a dependency bump: its self-contained harness now bundles renderer 0.3.0, so server exports inherit animation, effects, transitions, and captions with no API change.

Fixed

  • @miraiclip/renderer replay flicker under animated opacity (user-reported; reproduced with a 4K fixture + CPU throttling): after play or seek-while-playing, the clock ran during the decode catch-up window, so the canvas showed black (cold start) or the pre-seek frame (replays) while an opacity ramp was already rising — visible pops that static opacity had always masked. The player now takes a transport hold: play and seek-while-playing keep the clock and audio stopped until the target frame has actually ARRIVED (videos.prepare, the same arrival wait export uses), capped at 400ms so a broken pipeline can't wedge the transport; rapid seeks supersede earlier holds; playing reports true during the hold; audio restarts in sync with the ready frame (better post-seek lip-sync); the loop-wrap restart takes the same path. Verified by a pixel-sampling probe (2 dropouts before, 0 after across 3 runs) and a headless regression test.

Added

  • @miraiclip/renderer custom clip-kind factories on the main facades — createPlayer and exportProject now accept factories (clip kind → NodeFactory), exposing the compositor's extension seam without manual wiring: register a kind in core (registerClipKind), pass the same factories to both, and a custom clip renders identically in preview and export. The player's own video factory is reserved and cannot be clobbered. Server-side export keeps built-in kinds only for now (factories are functions — they can't cross the process boundary). See Rendering · Bring your own clip kind.
  • Docs: live Examples showcase — a new full-width top-nav Examples page runs real examples in the visitor's browser against the actual engine: the displayed code block is exactly what executes. Each feature section (animation, effects, transitions, captions) is a carousel of variants — 18 in all, covering every built-in transition kind, effect preset, and caption style — sharing one live preview canvas; switching a variant re-runs it live, and an export section produces a real downloadable file. Ships as a self-contained bundle (apps/docs-examples → docs/static/examples/), Big Buck Bunny demo footage (CC-BY 3.0, © 2008 Blender Foundation) in VP9 WebM + H.264 MP4 picked by WebCodecs probe, one-playing-example-at-a-time, and a Playwright smoke suite (pnpm --filter miraiclip-docs-examples smoke) that pixel-asserts every example including a real export.
  • @miraiclip/renderer karaoke captions rendered (v4 step 5) — the caption clip kind now draws. A pure layout/timing module (captions/layout.ts) does greedy word wrap (80% of composition width) with per-line centering and computes karaoke progress (the word containing the playhead, plus how many words have started); the Pixi caption node is a thin shell over it: one Text per word, an optional rounded background box, and per-word emphasis that changes only at word boundaries. All four style presets render: plain, highlight (the active word lights), karaoke (words STAY lit once passed), pop (the active word lights and enlarges in place). Font assets load for real: new loadFontAssets(doc) (exported) turns kind: "font" assets into document FontFaces — deduped globally, a failed load logs loudly and falls back. The live player runs it at creation and on every asset change, and re-syncs the scene when a font finishes loading (glyph metrics changed under every text node — new Compositor.resync()); exportProject awaits it before the first frame, so a server export can never silently rasterize fallback glyphs (the harness bundles the same code). SRT/VTT and ASR word-timestamp import (core, step 1) now render end to end: captionClipsFromSubtitles/captionClipsFromAsrWords → one transaction → karaoke on screen. Playground gains a Captions tab (karaoke / highlight / pop / boxed cards that drop a word-timed demo line at the playhead). 8 new unit tests (timing incl. gaps, presets, wrap + centering math, font loading with an injectable environment; 114 renderer total) + 2 e2e (18 total) asserted by color census with colors the frame-index fixture cannot produce: passed words stay lit across a boundary and the caption ends with its clip; the highlight flips sides of the canvas at the word boundary.
  • @miraiclip/renderer transitions rendered (v4 step 4) — transition/add now draws. A new pure timing module (transitions/timing.ts) is the single source of truth for the window (centered on the cut), progress, per-clip roles, and the equal-power audio crossfade — shared by the compositor, the audio mapping, and video preparation. Blending without render-textures: crossDissolve renders the incoming clip on top at alpha p (in-over-out compositing IS the mix out·(1−p)+in·p); wipe reveals the incoming clip through a composition-space mask (new optional SceneNode.setReveal); slide offsets the incoming clip by the remaining travel; dipToBlack/dipToWhite draw a lazily-created full-frame overlay quad (new optional SceneBackend.createSolid) whose alpha peaks at exactly the cut — dips never need out-of-bounds rendering. Blend kinds render BOTH clips through the whole window from source headroom (the outgoing clip keeps drawing past its end, the incoming starts early), which required the one-pipeline-per-asset invariant's scoped exception: MediaManager.acquire(assetId, src, lane?) gives each transition-participating clip a dedicated decode pipeline (two positions of one asset decode at once through the overlap; upgrade is one-way and lazy). Audio crossfades on every kind through the same volumeAutomation ramps as keyframed volume (equal-power cos/sin sampled at quarter points, each side inside its own clip bounds — no pop at the cut; true overlapped audio sourcing is a v4.x follow-up), so live playback and the offline export mix agree by shared math. Removing or editing a participating clip drops the transition (core, step 1) and the compositor resets mid-window state (mask, offset) granularly. Playground Transitions tab enabled: each card splits the video at the playhead, jumps the incoming side 1s ahead in the source so the cut is visible, and bridges it — one transaction, one undo. 19 new unit tests (timing math, audio automation points, lane-scoped pipelines, compositor blending against fakes; 106 renderer total) + 5 golden e2e (16 total): exact pixel mixes at p=0.25 and past the cut, wipe showing two frames at once, slide edge position, dip pure black at the cut, and the dissolve re-decoded out of an exported file.
  • Playground quick-test side panel — a left panel with Effects / Text / Animate / Transitions tabs of one-click preset cards (grayscale, punchy, warm shift, blur, chroma key; title/lower-third/timestamp text drops at the playhead; fade-in, slow zoom, slide-in, fade-out-at-end keyframe presets) plus undo/redo. Every card is an ordinary project.dispatch(...) — the same public command surface an app or AI agent uses — so everything is undoable and lands in the document like any other edit. The Transitions tab landed with step 4 (below): one-click cross dissolve / dip to black / wipe / slide at the playhead.
  • @miraiclip/renderer effects rendered (v4 step 3) — the clip effect stack now draws: an internal kind→filter registry maps colorAdjust (Pixi ColorMatrixFilter: brightness/contrast/saturation/hue), blur (strength = amount × composition height — resolution-independent by construction), and chromaKey (custom GLSL: CbCr chroma-distance keying with smoothstep edges and spill suppression, premultiplied-alpha-correct) onto per-node Pixi filters. The compositor pushes each clip's stack through a new optional SceneNode.setEffects on every sync (add/update/remove/reorder/undo all land granularly); filters update in place on param changes (no shader recompiles mid-slider-drag) and rebuild only on structural change; disabled entries are skipped at the node. The video clip's adapter forwards setEffects to its inner node (the wiring gap the e2e caught). New green-screen fixture (e2e-key.webm, red box on #00ff00) + 3 e2e: chroma key removes the background and keeps the subject (enable/disable round-trip), colorAdjust renders grayscale and black at the extremes, and — parity, not assumption — the keyed export is verified by re-decoding the exported file. 6 new unit tests on the pure param math + compositor wiring (87 renderer total, 11 e2e).
  • @miraiclip/renderer animations applied (v4 step 2) — the compositor evaluates keyframes every render (alloc-free scratch objects; the setPlacement contract now states the placement object may be reused — the Pixi video node copies, since it re-applies placement on source-size changes), so animated transform/opacity plays live and exports inherit it for free (same compositor). Animated volume rides linear gain ramps, not steps: the shared mapping module gains clipVolumeAt/volumeAutomation (window endpoints + keyframe boundaries, hold segments pinned just before their jump), the live AudioEngine re-issues automation on the output clock every pump (AudioChannel.setGainAutomation, WebAudio linearRampToValueAtTime), and the offline export mixer schedules the SAME points on its gain nodes — one math, zero preview/export drift. planAudioJobs keeps clips whose static volume is 0 but whose keyframes raise it. Verified closed-loop: a golden e2e proves an opacity ramp lands on exact pixel values on the canvas, and an export e2e bakes a volume fade and measures the RMS decay in the decoded file. 6 new renderer tests (80) + 2 e2e (8).
  • @miraiclip/core v4 creative-features model (step 1) — the document and command surface for keyframe animation, effects, transitions, and karaoke captions; everything enters the AI command catalog as JSON Schema. Animation: per-property keyframes on clips (x/y/scale/rotation/opacity/volume; clip-relative times anchored at the visible start), easings stored as cubic-bézier control points (presets are input sugar; hold steps), keyframe/set|remove|clear commands (sorted upsert), and a pure, alloc-free evaluator (evaluateClipAt/evaluateClipInto, binary search + Newton-with-bisection bézier solve) shared by preview, export, and server export by construction. Effects: per-clip stack (effect/add|update|remove|reorder) with built-in Zod param schemas in core — colorAdjust, blur, chromaKey — length params normalized to composition units (preview/export parity); registerEffectKind for custom kinds. Transitions: doc-level entities on the adjacent-clips + trim-handles model (centered on the cut) with transition/add|update|remove, validating adjacency, one-per-boundary, and source headroom on both sides; editing or removing a participating clip drops its transitions; built-ins crossDissolve/dipToBlack/dipToWhite/wipe/slide. Captions: new built-in caption clip kind (word-level timing + style presets, font size as a fraction of composition height), font assets (kind: "font" with a required family), SRT/VTT parsing with even word-split fallback, and ASR word-timestamp import (captionClipsFromAsrWords — true karaoke timing, grouped on silence gaps) — both produce ordinary clip/add commands. Extension seam: registerClipKind (validated props, per-track-kind gating) opens the formerly closed clip union; CustomClip narrowing preserved via exported type guards (isVideoClip etc.). Old documents hydrate cleanly (missing transitions map defaults; schemaVersion stays 1). 25 new headless tests (60 total in core).

[server-export-0.1.0] — 2026-09-06

Added

  • @miraiclip/server-export (v3.x) — server-side export from Node: exportProjectFile(doc, options) and a miraiclip-export CLI run the browser's own exportProject inside headless Chrome, so server output is pixel-identical to the browser's by construction. The package serves a self-contained harness bundle (core + renderer baked in at build time — version-locked, consumers install nothing browser-side) plus the project's media on a loopback server with Range support (media decode seeks by byte range); asset srcs resolve via an explicit assets map, assetsDir, or pass-through for http(s)/data URLs, and a missing file fails fast before any browser launches. Browser resolution: browser.executablePath → MIRAICLIP_BROWSER → the machine's installed Chrome (playwright-core, no forced download) — real Chrome recommended since free Chromium has no H.264 encoder (WebM works everywhere). Progress events and AbortSignal forward across the process boundary (ServerExportAbortedError); SwiftShader software GL is the default on Linux servers. Verified by an integration suite driving real headless Chromium: container parsed back by an independent reader (duration, dimensions, no stray audio track), exact range durations, mid-export abort, and a CLI smoke test — plus a CI step in the e2e job. A runnable example (project.json + CLI + Node script) lives in examples/server-export.

[renderer-0.2.0] — 2026-09-06

Added

  • @miraiclip/renderer offline export (v3) — exportProject(project, { format, quality, range, fps, signal, onProgress }) renders the composition frame-by-frame through the same compositor preview uses, encoding via WebCodecs and muxing via mediabunny into MP4 (H.264 + AAC) or WebM (VP9 + Opus). Faster than realtime: the muxer's backpressure paces the loop (memory-bounded), time moves strictly forward (zero re-seeks), and frames are sampled at their temporal midpoint (robust to container timestamp rounding), and every frame waits for its decoded pixels to actually arrive before capture (VideoPipeline.waitForFrame) — prime() resolves at feed time while frames land asynchronously, so a render-once consumer used to capture the previous frame whenever a conversion lagged: duplicated frames/judder in exports of high-fps sources. Decode is capped at 2× the output's longest side by default (a 4K source composited onto 720p at full decode resolution cost ~9× the pixels for no visible gain — pass createDecoder: createWebCodecsDecoder for uncapped). Audio is mixed offline with OfflineAudioContext using the same clip math as live playback (extracted to a shared audio/mapping module — preview and export cannot disagree). Quality presets (draft/standard/high) or explicit bitrate; codec support probed up front with clear UnsupportedMediaErrors; cancellation via AbortSignal (ExportAbortedError); progress events per frame. Playground gains an Export button with an editor-style range picker (In/Out marks set at the playhead) that exports a section via range; a closed-loop e2e verifies range exports rebase timestamps to the range start. The audio phase reports live progress (audioMixedUs/audioTotalUs — mixing a long timeline decodes its full audio track and takes real time) and honors the abort signal between chunks, so a long mix is visible and cancellable rather than a silent stall. Closed-loop e2e: the exported file is verified by an independent decoder (native <video> + decodeAudioData) for frame accuracy, resolution, duration, and unity-gain audio RMS. 17 new headless tests (71 total) + 2 e2e (5 total).
  • @miraiclip/renderer pipelined export encoding — the export loop keeps a bounded window of encoder submissions in flight (encodeAheadFrames, default 4) instead of awaiting each frame: the sink captures the canvas synchronously inside addVideoFrame (now an explicit contract), so frame n renders while frames n−3…n−1 encode. Together with an unclamped MessageChannel-based macrotask yield in VideoPipeline.waitForFrame (nested setTimeout is clamped to ~4ms, which alone capped a poll-per-frame export at ~60fps), a lockstep decode→composite→encode walk that measured ~realtime with an idle CPU now overlaps all three stages. Errors from in-flight submissions surface on the next iteration and the drain; abort and sink-failure paths still cancel the sink exactly once. Playground: an output-fps select (Source/30/24) on export — a lower rate cuts frame count and export time proportionally (fps was already an exportProject option).

[renderer-0.1.0] — 2026-09-06

First release of @miraiclip/renderer — WebCodecs media pipeline, PixiJS compositor, audio-master playback (createPlayer), frame-accurate seeking, proxy preview decode. Everything below shipped in it.

Added

  • @miraiclip/renderer + playground: golden-frame e2e suite (Playwright) — a 5KB deterministic fixture where every frame's color encodes its own index (VP9: Playwright's Chromium has no H.264), so tests read "which frame is on screen" straight off the canvas, robust across GPUs. Covers frame-exact mid-GOP seeks, displayed-frames-track-the-master-clock during playback (the invariant every re-seek storm breaks — asserted against the player's own clock, so headless audio-clock speed doesn't matter), and a zero-console-errors sweep. Runs serially against the built playground (pnpm --filter miraiclip-playground e2e); CI gets an e2e job. createPixiBackend gains preserveDrawingBuffer for pixel readback.
  • @miraiclip/renderer proxy preview decode — createWebCodecsDecoderFactory({ maxOutputDimensionPx }) caps the longest side of decoded output bitmaps (aspect preserved; the downscale happens on the GPU inside createImageBitmap). Full-resolution 4K60 is ~4 GB/s of ImageBitmap copies plus texture uploads to composite onto a preview canvas — past the per-frame budget on most machines, hence dropped frames. Layout is unaffected: VideoSceneNode gains optional setSourceSize and the Pixi node normalizes scale against the source's native size, so a clip renders at the same size whatever the decode resolution; the smaller frames also widen the byte-budgeted decode-ahead window (~15 → ~60 frames at 4K→1080p). VideoPipeline exposes cached info(). The playground previews with maxOutputDimensionPx: 1920; createWebCodecsDecoder stays full-resolution for export-quality output.
  • @miraiclip/renderer audio playback + player facade (v2 step 4): AudioEngine schedules streaming-decoded audio windows (mediabunny AudioBufferSink — long files are never decoded whole) on a WebAudio graph with one gain lane per clip, mixing clip volume × track muted/solo live, mapped through clip start + trim, including video clips' embedded tracks; rate-aware deadlines. createPlayer wires compositor + video pipeline + audio + the audio-master clock into one transport (play/pause/seek/setRate/timeUs/durationUs/destroy), pushes the playhead into the core as ephemeral state, and pauses (or loops) at composition end. Headless: 12 new tests (43 total) via fake audio output/source; the player tests also caught and fixed a dispose-during-decode race in VideoPipeline. Playground now plays sound.
  • Docs: Rendering guide for the in-development renderer (architecture layers, quick start, seek semantics, renderFrameAt, browser support); package tables updated to show @miraiclip/renderer as in development.
  • @miraiclip/renderer video playback (v2 step 3): createVideoSupport bridges the MediaManager into the compositor — video scene nodes pull cached frames on every tick via the new time-aware tick hook, keep decode-ahead primed with throttled re-priming, and map timeline time through clip start + source trim; renderFrameAt draws one exact frame (thumbnails/posters/export); Pixi video node renders VideoFrames through a canvas-backed texture. MediaManager now memoizes in-flight acquisitions so concurrent clips of one asset share a single pipeline (race fixed). Playground app (apps/playground, Vite) plays a real MP4 through the full command → patch → compositor → WebCodecs pipeline with play/pause/seek/loop and a text overlay.
  • @miraiclip/renderer compositor (v2 step 2): Compositor renders the document as a pure function of time behind a SceneBackend abstraction, subscribing to the core's patch events for granular scene updates (undo/redo and remote patches included); pixel placement from normalized transforms; per-track stacking; clip-kind node factory registry (the custom-kind/v4 seam); createPixiBackend — the PixiJS implementation for image and text clips with manual render ticks. Compositor fully tested headless with a fake backend (9 tests).
  • @miraiclip/renderer package started (private until its first release) with the v2 media layer: FrameCache (distance-based eviction, byte budget, strict close() ownership), VideoPipeline (keyframe-aware seeking, decode-ahead, abortable priming via generations), MediaManager (one pipeline per asset, decoder cap with LRU release), StepClock/RealtimeClock behind the Clock interface, and browser adapters for mediabunny demuxing + WebCodecs decoding with per-asset capability errors (UnsupportedMediaError). Fully unit-tested headless (14 tests) behind demuxer/decoder interfaces.

Changed

  • @miraiclip/renderer frame-accurate seeking (the canonical WebCodecs settle pattern): after a hard seek, decode lead-in frames are closed instead of cached, so the display can never rewind to the keyframe or fast-forward through the GOP — it holds the last frame and snaps straight to the target. Verified in-browser against a worst-case 5-second-GOP file: mid-GOP seeks (120+ lead-in frames) resolve frame-accurately within a screenshot's latency, paused and during playback.
  • Playground: DOM writes (status text, slider position) throttled to 5/s and change-gated — they were forcing ~120 layouts and style recalcs per second on the thread shared with decode callbacks and GPU uploads, plus detached-node churn. Also: load a video by URL via /?src=.
  • @miraiclip/renderer seek performance audit — removed real per-seek overhead that had nothing to do with decoding: dropped verifyKeyPackets (it decoded packets just to confirm keyframes, on every seek, twice), cached the decoder config + capability check so they run once per asset instead of per seek, and folded the keyframe lookup into a single chunksFrom(target) seek (was two). Also simplified the over-built display path back to "show the nearest decoded frame" (removed keepBehindUs/displayToleranceUs hold logic and the playground buffering gate) and removed the now-unused keyframeAtOrBefore/anchorUs. Net: fewer moving parts and materially faster timeline navigation.
  • @miraiclip/renderer seek latency reduced: the decoder is now reused across seeks via reset() (no hardware re-init per seek), lead-in frames are kept as a progressive-refinement placeholder (a cold seek paints the keyframe immediately, then refines to the exact frame), and WebCodecs runs with optimizeForLatency. FrameDecoder gains a reset() method.
  • @miraiclip/renderer seek smoothness: a hard seek now clears the frame cache so the display holds the last correct frame instead of flashing stale content from the previous position; the Pixi video node dedupes GPU uploads (uploads a decoded frame once instead of every animation tick); decode-ahead trimmed to 1s so seeks waste less decode work.
  • @miraiclip/renderer seek no longer shows a backward-jump/fast-forward shake: the pipeline drops lead-in frames far behind the target (keepBehindUs) and the video node only swaps to a frame within displayToleranceUs of the playhead, holding the last frame through the decode gap and then cutting cleanly to the target. Playground adds a buffering gate that pauses the clock while seeking and resumes from the exact point.
  • @miraiclip/renderer VideoPipeline reworked to continuous streaming decode: one decoder is kept alive and fed forward with an ahead-window as backpressure, re-seeking only on a backward jump to an evicted frame or a large forward jump. This removes the per-second decoder teardown/keyframe re-seek that caused periodic playback stutter. Decode-ahead defaults widened (2s ahead, 120-frame cache). Playground no longer caps duration at 2 minutes and states video-only preview (audio is step 4).
  • Package homepage now points at the documentation site (https://comaniacs.github.io/miraiclip/); docs linked from the package and repo READMEs; "not yet published" notes removed after the 0.1.0 npm release.

Fixed

  • @miraiclip/renderer playback no longer stalls in a hidden tab: the rAF loop freezes when a tab is hidden, but WebAudio keeps playing — audio died as soon as its ~3s scheduling window ran dry. createPlayer now runs a 500ms interval (injectable via schedule/cancelSchedule) that keeps decode and audio windows rolling and detects end-of-composition without animation frames; rendering stays rAF-only. Regression-tested with no animation frames at all.
  • Playground: a paused seek could leave the timecode/slider stale forever — the player emits one playhead event per discrete change and the UI's 200ms throttle could eat it. The throttle now has a trailing edge.
  • @miraiclip/renderer the last re-seek storm, measured and killed: in-page profiling on a 4K60 file showed ~1 stream re-seek per second, each discarding 100–300 lead-in frames (decode rate 3–6× playback — the residual "minor lag"). Root cause: frame conversions complete slightly out of order, and with decode running at hundreds of fps, one straggling conversion leaves a hole at the playhead while arrivals race far ahead — any "arrived past the target" margin misreads that hole as an eviction and re-seeks, discarding the very frame in flight. The pipeline now tracks a contiguity watermark (the highest timestamp below which every frame has arrived at least once): a missing frame at or below it was provably evicted (re-seek), above it it's still in flight (wait). Exact — no margins, no timing dependence. The watermark anchors at the seek target itself — anchoring at the first arrival re-created the race, since post-seek the first conversion is often the slowest and a later frame lands first. Regression tests (proven failing against the previous logic) cover a straggling mid-stream frame and a slow first conversion after a seek: one stream seek, ever. On top of that, FrameCache eviction is now past-before-future (oldest behind frames first, ahead frames only when nothing behind remains): symmetric farthest-from-playhead eviction was discarding freshly decoded ahead frames whenever the behind-tail was short, burning a hole one cache-capacity past every seek — the measured re-seek every ~1.07s (64 frames). Regression test (proven failing against symmetric eviction) plays across the cache-capacity boundary on one stream.
  • @miraiclip/renderer 4K playback decode storm (frames visibly skipping; decode rate measured at 3–6× playback): streams that report no frame durations fell back to 33ms on a 60fps file, so the time-based decode-ahead window fed ~2× the frames the byte budget holds — eviction reached the frame being displayed, upstream read that as an eviction and re-seeked the stream, over and over. Fixed structurally: frame duration is now measured from consecutive arrival timestamps (smoothed; reported durations are no longer trusted for window sizing), and FrameCache.evict never evicts the frame covering the playhead, whatever the budget pressure. Regression tests: wrong-duration big-frame playback and covering-frame protection. Also: clips now render fit-to-composition (scale 1 = contain), fixing 4K sources displaying as a magnified native-pixel center crop.
  • @miraiclip/renderer re-seek decisions are now deterministic in stream state, never in decode timing — the real root cause behind the black-video/pegged-CPU reports on 4K files. Decoded frames land in the cache asynchronously (decoder callback plus a GPU ImageBitmap copy — a long window at 4K), while prime() runs every animation frame; the old "fed past the target but not cached" check read that normal in-flight state as a miss and re-seeked, and each re-seek reset the decoder, discarding the very in-flight frames that would have ended the loop. VideoPipeline now tracks streamStartUs (what the current stream is guaranteed to produce), a monotonic maxFedUs (B-frame decode order oscillates), and arrivedThroughUs, and re-seeks only on a target before the stream's coverage, a provable eviction (with a margin for out-of-order conversion completions), or a far forward jump. Regression test hammers prime() against an async decoder — one stream seek, ever.
  • @miraiclip/renderer decode-ahead now adapts to the frame-cache byte budget: a fixed 1s window at 4K decodes ~60 frames (~2 GB) into a ~15-frame budget, evicting frames the playhead is about to reach and forcing a re-seek every few hundred ms during playback. The window shrinks to what the budget can hold (big frames → decode less ahead), so linear 4K playback stays on one stream.
  • @miraiclip/renderer an unchanged scene no longer re-renders: the Pixi backend tracks a dirty flag (frame swaps, placement/visibility/z changes, node churn, resize) and render() is a no-op when nothing changed — a paused 4K frame was being pushed through the GPU 60×/s. The player also skips the per-frame setPlayhead event while the clock hasn't moved (one event is still emitted on play/pause/seek so UIs update).
  • @miraiclip/renderer black video with a pegged CPU on streams that report zero/absent VideoFrame.duration (seen with a 4K60 H.264 file, e.g. Big Buck Bunny 2160p60): a zero duration made every "does this frame cover time t" check fail, so the settle filter dropped even the target frame (black) and prime() treated every cached frame as a miss, re-seeking the stream in an infinite loop (pegged CPU while paused). Three-layer fix: the WebCodecs adapter falls back to a conservative 33.3ms duration when the stream reports none; the settle check is strictly-before, so a frame starting AT the seek target is always presentable; and the pipeline's covering check is duration-defensive (max(duration, 33_333)). Regression test added (zero-duration decoder must not cause a reseek storm).
  • @miraiclip/renderer media errors are no longer silently swallowed: createPlayer and createVideoSupport accept an onError(error, clipId) callback (default logs loudly via console.error), ImageBitmap conversion failures surface through the decoder's onError, and the tick path reports pipeline failures instead of discarding them — the silent void/bare-catch pattern is what hid the black-video bug.
  • CI test/typecheck failed on fresh checkouts: workspace packages resolved @miraiclip/core through exports pointing at dist/, which doesn't exist before a build. Both packages now use source exports in development (./src/index.ts) with publishConfig swapping in the dist exports at publish — typecheck, tests, and the playground need no pre-build, the playground's Vite aliases are removed (one mechanism instead of two), and the published tarball is verified unchanged (dist-only, publint clean).
  • @miraiclip/renderer periodic 1–2s playback stalls, re-diagnosed as hardware decoder frame-pool exhaustion: the frame cache held up to 120 undestroyed VideoFrames while decoders own only ~8–16 output slots, so the decoder stalled until frames were closed. Every decoded frame is now converted to an ImageBitmap and the VideoFrame closed immediately (generation-guarded across resets; flush() awaits pending conversions). The Pixi video node uploads ImageBitmaps directly to the GL texture, removing the 2D-canvas hop (one copy per frame instead of two). The planned Web Worker decode is deferred with rationale recorded in PLAN.md.

0.1.0 — 2026-09-05

First release of @miraiclip/core.

Added

  • Project state: Zustand (vanilla) store with the composition document under state.doc and ephemeral session state (playheadUs, selection) beside it; selector subscriptions via project.subscribe.
  • Command engine: dispatch validates payloads with Zod v4 schemas and applies them via Immer; typed errors (CommandValidationError, CommandRejectedError, UnknownCommandError) guarantee state is untouched on failure.
  • Built-in command catalog (15 commands): project/set-settings; asset/add, asset/remove; track/add, track/remove, track/reorder, track/rename, track/set-property; clip/add, clip/remove, clip/move, clip/trim, clip/split, clip/duplicate, clip/set-property — with semantic validation (entity existence, track-kind constraints, asset-in-use protection, split-range checks).
  • History: undo/redo from inverse patches, canUndo/canRedo/clearHistory, configurable history limit; transaction(fn, label?) groups dispatches into one atomic history entry with rollback on failure.
  • Patches & events: every document change emitted as RFC-6902 JSON Patch ops (with inverses and a source tag) on a typed emitter — patches, history, playhead, selection.
  • AI catalog: project.commandCatalog() exports one JSON Schema per command type (built-in and custom) via Zod's native converter, suitable as LLM tool definitions.
  • Custom commands: project.registerCommand({ type, schema, handler }) with full validation/history/patch semantics.
  • Serialization: project.toJSON() round-trips through createProject, with schemaVersion checking.
  • Timeline utilities: µs ↔ seconds/frames/timecode conversion, frame snapping, range overlap.
  • Sync utilities: applyJsonPatches and fromJsonPointer — apply the engine's emitted RFC-6902 patches to a plain document copy, the follower side of the collaboration story.
  • Determinism & collaboration test suite (test/replay.test.ts): identical documents from replayed command scripts, leader→follower patch sync, inverse-patch rollback, full undo/redo round-trips, history-limit eviction, frame-boundary splits, JSON Pointer escaping in entity ids (26 tests total).
  • Tooling: pnpm monorepo, tsup build (ESM + CJS + type declarations, split per-condition exports verified with publint and arethetypeswrong), Vitest, strict TypeScript, CI workflow (typecheck/test/build on push and PR), Changesets release tooling (pnpm changeset, pnpm release).
  • Docs site under docs/: Hugo + Hextra with landing page, quickstart, core-concepts pages, Command Catalog reference (generated from the actual Zod schemas), roadmap, and this changelog.

On this page