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/rendererexports use the same frame rule:exportComposition/exportProjectcompute each frame's start, duration and the frame count with core'sframeToUs/frameCounton the exact rate (fpstakes a number or{ num, den }; defaultprojectFrameRate), so the main-thread, worker and headless exports match core's frames.StepClock.stepderives the next frame from its index instead of adding a rounded frame length (it drifted about one frame per hour at 30 fps). NewexportFrameWindowexposes 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/coreexact frame rates —ProjectSettings.frameRateholds the rate as a ratio ({ num: 30000, den: 1001 }for 29.97).project/set-settingsandcreateProjectstore decimal NTSC rates (29.97, 23.976, 59.94…) exactly and keepfpsin sync;project/set-settingsalso takesframeRatedirectly. New helpers:toFrameRate,projectFrameRate,formatFrameRate,frameCount.usToTimecodetakes{ dropFrame: true }for SMPTE drop-frame timecode at 29.97 / 59.94. See Timeline precision.
Fixed
@miraiclip/coreframe ↔ µs conversion round-trips:frameToUsrounded andusToFramefloored, 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⌉ andusToFrameis ⌊us · num / (1e6 · den)⌋, in exact integer arithmetic.frameToUsresults 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/coreeditable html clip code —clip/set-propertytakestemplate(replace the markup),unsetParams(drop params the new markup no longer uses) andwidthPx/heightPx(nullfalls 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/rendererhtml 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/templateshtml clips that draw media (asset:<id>) survive insertion:insertCommands/insertDocumentrewrite those references when an asset is renamed or reused, andsuggestTemplateFieldsoffers media used only that way as a slot.
core-0.5.6 + renderer-0.7.10 — 2026-10-02
Added
@miraiclip/core+@miraiclip/rendereranimated html clips —animated: truere-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/--Ton the root,--dfor per-element delays); preview coalesces rasters, exports and stills await each frame (compositor.renderExactAt), worker exports request them from the main thread.clip/splitsetsanimationOffsetUsso 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/assistantfixes and tools from live evals — newadd_effect,remove_effects,set_effects_enabled,trim_clip,close_gaps,set_keyframesandset_backgroundtools;apply_commandsrejects 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).openAIChatModelretries rate limits and server errors as long as the server asks (maxRetries), andrateLimitedChatModelkeeps any model under a per-minute budget. See Assistant.@miraiclip/templatestemplates for editors —suggestTemplateFieldslists what in a finished project could stay editable andtemplateFromDocumentturns the picks into fields ("Save as template");insertDocument/insertCommandsadd a hydrated template to an existing project as one undo step (new tracks on top, ids remapped, media and fonts reused);fitClipsToMediafits clips and transitions to shorter swapped-in media. Templates takecategory,tagsandthumbnail; fields take alabel. See Templates → From a finished project.@miraiclip/audio-sources—add_audioandgenerate_audiotakereplaceClipId(the new clip takes the old one's track, start, length and volume, in one transaction).
Fixed
@miraiclip/audio-sources—staticProvidersearch 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-neutralChatModelcontract with an OpenAI adapter (openAIChatModel, Chat Completions with function calling and streaming;baseUrlreaches any OpenAI-compatible server).createAssistantruns an agent loop on a working copy of the project and lands each request as one undo step (or waits forturn.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, plusdefineTool/toolsFromDefinitionsfor your own (e.g. audio).createChatHandler+remoteChatModelkeep API keys on the server. See Assistant.@miraiclip/coreanimation 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)andclipHeadroomUsfor 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/rendererhidden tracks —track/set-property { hidden }(optionalTrack.hidden;falseremoves it). The compositor skips a hidden track's clips in preview, exports and stills, andgetClipBounds/hitTestignore them; sound followsmutedas before.describeProjectlists track flags (muted, solo, locked, hidden). See Tracks.@miraiclip/core+@miraiclip/rendererend-anchored keyframes —keyframe/set/keyframe/removetakeanchor: "end"(time measured back from the clip's end), so exit animations follow trims. NewresolveKeyframes/keyframeTimeUshelpers;evaluateKeyframestakes an optional clip duration.clip/splitkeeps 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-sourcesAI audio generation with a vendor-neutral contract: anAudioGeneratordeclares its kinds (sfx / music / voice), models, voices, limits, a JSON-SchemaparamsSchemafor vendor-specific settings and its outputterms.generate(request, options)returns bytes or a URL;paramspass through untouched, so any service adapts without package changes.startGeneration/library.generaterun 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 offlinetoneGenerator. New LLM toolsgenerate_audioandlist_voices. Breaking: the unused placeholderGenerateAudioRequest/AudioJobtypes andAudioProvider.generateare removed. See Audio Sources → Generation.
audio-sources-0.1.0 — 2026-10-02
Added
@miraiclip/audio-sources— stock and library audio (new package): oneAudioProvidercontract (search, getItem, resolve) with adapters for Openverse, Freesound, a static in-app catalog and any backend (httpProvider).createAudioLibrarygroups providers;importAudioadds a file with its source, license and credit line as one undoable step on a free audio track;audioToolDefinitions/runAudioToolexposesearch_audioandadd_audioto LLMs;parseCreativeCommonsmaps CC codes, names and deed URLs toAssetLicense. See Audio Sources.
core-0.5.3 + renderer-0.7.8 — 2026-10-02
Added
@miraiclip/coreasset provenance and clip fades — assets take optionalname,source({ provider, id, url? }),license({ id, url?, commercial, attributionRequired }) andattribution, editable with the newasset/set-propertycommand (nullclears). Video and audio clips takefadeInUs/fadeOutUs;clip/splitkeeps the fade-in on the left half and the fade-out on the right. New pure helpersusedAssets,creditsForandlicenseReport;describeProjectshows names, licenses, volume and fades. See Clips → Asset provenance and licensing.@miraiclip/rendererfades and waveforms — fades apply in live playback and the export mix through the shared gain math, as exact linear ramps. NewcomputeWaveformPeaks,peaksForRangeandaccumulatePeaksfor drawing waveforms, plusclipFades/fadeGainAtfor fade handles. See Rendering → Audio.
core-0.5.2 + renderer-0.7.7 — 2026-10-01
Added
@miraiclip/core+@miraiclip/renderercaption decorations —display: "word"(word-by-word),textTransform, outline (strokeColor,strokeWidthFrac), drop shadow / glow (shadowColor,shadowBlurFrac,shadowOffsetFrac),activeBackgroundColor(a box behind each emphasized word) and arevealpreset. All optional;clip/set-propertyclears them withnulland can replace captionwords. New helperscaptionsToSrt,captionsToVtt,captionsToTextandretimeWords. Font assets acceptweightRange, loaded as variable faces in captions, text and html clips. See Rendering → Captions.
renderer-0.7.6 — 2026-10-01
Added
@miraiclip/renderergetClipBoundsandhitTeston 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/rendererexports 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/renderereffect library — 79 built-in effect kinds (76 new, alongsidecolorAdjust,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'sEFFECT_CATALOGis the single source of truth — param schemas are derived from it (validation + defaults), theeffect/addAI 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. NewrenderEffectThumbnails()renders each kind's real filter on its own offscreen renderer for effect pickers. Effects are static (no time input); noise looks take aseed. 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/rendererrenders typography — text clips honorfontWeight,fontStyle,lineHeight,letterSpacing, andtextAlign; captions honor weight, style, and spacing on every word, withlineHeightdriving line stacking and letter spacing widening word gaps. Font assets'weight/stylebecomeFontFacedescriptors (each face of a family loads separately;FontEnv.createFacetakes optional descriptors) and carry into html-clip@font-faceinlining. 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/coretypography for text and caption clips — optionalfontWeight(100–900),fontStyle,lineHeight(× font size),letterSpacing(em, so it scales with font size between preview and export), andtextAlign(text clips; lines within the block, independent of the anchor) on text clips and captionstyle(captions stay centered, so notextAlign). Font assets gainweight/styledescriptors — one asset per face.clip/addandclip/set-propertyaccept every field (nullinset-propertyclears back to the default), the JSON Schema catalog carries the constraints to LLM tools and property panels, anddescribeProjectlists typography only where set. No schema defaults: existing documents load, render, and serialize unchanged;TYPOGRAPHY_DEFAULTSexports 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 declaredfields(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), andassetfields swap an asset'ssrc; hydration is pure and content-only, so it cannot produce an invalid document, anddefineTemplate/parseTemplateverify coherence up front (every placeholder declared, every field used, asset ids real).hydrate/tryHydratereturn machine-readable failures agents self-correct from;extractFieldsproposes a schema from a document;describeTemplateandtoFieldToolDefinition(Anthropic/OpenAI shapes) make a template one typed tool call per video. Batch rendering lives at@miraiclip/templates/render—renderTemplateBatchhydrates per row and exports through warm export sessions ({field}output naming,concurrencyparallel browsers, per-row results that never abort the batch) — plus amiraiclip-templatesCLI 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/renderersharp 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/textresolutionkeeps 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, andcreatePlayer({ 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-exportcreateExportSession()— a persistent exporter: one headless Chrome + harness page kept warm across calls, one full export perexportFile(doc, options)call (same options and result asexportProjectFile, streamedoutincluded), 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/corehtmlclip 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_frameshows them). Docs: HTML Clips.@miraiclip/rendererHTML rasterization —rasterizeHtml/substituteParams(exported): HTML → SVGforeignObject→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, andsrc="asset:<id>"inlines image assets the same way; external URLs never load. Worker export renders html clips too:exportProjectInWorker/exportViaWorkerrasterize 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 exportedcollectHtmlRasters(doc)/provideHtmlRasters(rasters).
Fixed
@miraiclip/rendererexports 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).SceneNodegained an optionalwhenReady(), the Compositor aggregates it, andexportProject/renderProjectStillnow 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/rendererpublic 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;updatemutates 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, directionalreveal, pixeloffset, and a full-compositionoverlay— withrendersBothClipsdeclaring whether the kind blends both clips through the window (from source headroom, validated attransition/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 ./mediaserves one project file over stdio:dispatchvalidates 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_commandsapplies a batch as ONE transaction (all-or-nothing, failing index reported, one undo step),undo/redostep history,preview_framerenders 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),exportstreams MP4/WebM to disk, andlist_commands/get_command_schemaserve the catalog. Every successful edit autosaves the project file atomically, so the project survives across agent sessions. Docs: MCP Server.@miraiclip/rendererrenderProjectStill(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-exportcreateRenderSession()— 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'spreview_frame.
Fixed
@miraiclip/rendererexportProject'swidth/heightoutput-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 takesoutputSize(andSceneBackendan optionalsetOutputSize): 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/coreAI 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 (Anthropictool_useor OpenAI function-calling shapes viastyle; one tool per command, ormode: "dispatch"for a singlemiraiclip_dispatchtool 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 bycommandTypeForTool).tryDispatch(project, command)returns a structuredCommandResultinstead of throwing on command failures, with a machine-readableCommandFailurean agent can self-correct from:unknown-commandcarriesvalidTypes,invalid-payloadcarries per-fieldissues(path + message from the Zod error),rejectedcarries the engine's rejectioncode; 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 rawtoJSON(). New docs page: AI Integration.
[renderer-0.4.1] — 2026-09-22
Fixed
-
@miraiclip/rendererunbounded 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 mediabunnyInputcaches up to 64MiB of source ranges, our video and audio adapters'dispose()never calledInput.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 underlyingInput(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/rendererunbounded 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).exportProjectnow routes capture through the CPU mirror whenever the format's codec has no hardware encoder at the output size (new exported probehasHardwareVideoEncoder(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/rendererlistener 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/rendererworker 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), andexportViaWorker(worker, project, options)drives a Worker the app owns (worker entry at the@miraiclip/renderer/export-workersubpath; 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 streamingtarget's chunks RELAY through the main thread with per-write acks (aFileSystemWritableFileStream— 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 ownFontFaceSet, and audio —OfflineAudioContextis window-only — mixes on the main thread and crosses as transferred PCM planes, which the sink encodes via a newAudioSampleSourceroute with running timestamps (PcmAudioChunkaccepted anywhere anAudioBufferchunk is;exportProjectgained the advancedaudioOverrideseam that powers this). Custom clip-kindfactoriescannot 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
showSaveFilePickerexists (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 viastress/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=1pins 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/rendererstreaming export output —exportProject(andcreateMediabunnySink) accepttarget: aWritableStreamreceiving{ type: "write", data, position }chunks as the file is encoded, exactly the shapeFileSystemWritableFileStream.writeaccepts, so ashowSaveFilePicker()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/rendererchunked 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-timelineOfflineAudioContextrender (~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 (mixCompositionAudiogainedsilenceIfEmpty;ExportSink.addAudioaccepts sequential chunks;exportCompositiongainedmixAudioChunkfor the per-chunk contract — the existing whole-rangemixAudiooption 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-exportstreams exports to disk — withoutset, 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 foroutcalls is now{ filePath, bytesWritten }—bytesis no longer returned there (breaking for callers that used both — read the file back if needed; withoutout,{ bytes }is unchanged), and streamed progress events carry a livebytesWritten(file size on disk so far;framesDone/totalFramesremains the true completion fraction since total output size is the encoder's decision).audioChunkSecondspasses 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 incorpus-results.json.
Fixed
@miraiclip/renderersame-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, theMediaManagerLRU 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 (newevictionIdleMsoption, default 500ms; 0 restores the hard cap).@miraiclip/rendererpipeline-eviction blackout (caught by the new stress suite): theMediaManagerreleases least-recently-used decode pipelines over its cap (default 4 — an engine default sized conservatively for hardware decoder instances, configurable viamaxActivePipelines), 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:VideoPipelineexposesisDisposed, 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/rendererunbounded 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).exportProjectnow 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-copyCanvasSourcesnapshot 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/renderercreateMediabunnySinkacceptscpuCapture(route per-frame capture through a CPU 2D mirror instead of snapshotting the WebGL canvas) and exportsisSoftwareWebGL(canvas)—exportProjectwires 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/rendererreplay 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;playingreports 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/renderercustom clip-kind factories on the main facades —createPlayerandexportProjectnow acceptfactories(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 ownvideofactory 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/rendererkaraoke captions rendered (v4 step 5) — thecaptionclip 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: newloadFontAssets(doc)(exported) turnskind: "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 — newCompositor.resync());exportProjectawaits 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/renderertransitions rendered (v4 step 4) —transition/addnow 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:crossDissolverenders the incoming clip on top at alpha p (in-over-out compositing IS the mixout·(1−p)+in·p);wipereveals the incoming clip through a composition-space mask (new optionalSceneNode.setReveal);slideoffsets the incoming clip by the remaining travel;dipToBlack/dipToWhitedraw a lazily-created full-frame overlay quad (new optionalSceneBackend.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 samevolumeAutomationramps 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/renderereffects rendered (v4 step 3) — the clip effect stack now draws: an internal kind→filter registry mapscolorAdjust(Pixi ColorMatrixFilter: brightness/contrast/saturation/hue),blur(strength =amount× composition height — resolution-independent by construction), andchromaKey(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 optionalSceneNode.setEffectson 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 forwardssetEffectsto 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/rendereranimations applied (v4 step 2) — the compositor evaluates keyframes every render (alloc-free scratch objects; thesetPlacementcontract 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 gainsclipVolumeAt/volumeAutomation(window endpoints + keyframe boundaries, hold segments pinned just before their jump), the liveAudioEnginere-issues automation on the output clock every pump (AudioChannel.setGainAutomation, WebAudiolinearRampToValueAtTime), and the offline export mixer schedules the SAME points on its gain nodes — one math, zero preview/export drift.planAudioJobskeeps 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/corev4 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;holdsteps),keyframe/set|remove|clearcommands (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);registerEffectKindfor custom kinds. Transitions: doc-level entities on the adjacent-clips + trim-handles model (centered on the cut) withtransition/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-incaptionclip kind (word-level timing + style presets, font size as a fraction of composition height), font assets (kind: "font"with a requiredfamily), SRT/VTT parsing with even word-split fallback, and ASR word-timestamp import (captionClipsFromAsrWords— true karaoke timing, grouped on silence gaps) — both produce ordinaryclip/addcommands. Extension seam:registerClipKind(validatedprops, per-track-kind gating) opens the formerly closed clip union;CustomClipnarrowing preserved via exported type guards (isVideoClipetc.). Old documents hydrate cleanly (missingtransitionsmap 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 amiraiclip-exportCLI run the browser's ownexportProjectinside 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); assetsrcs resolve via an explicitassetsmap,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 andAbortSignalforward 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 inexamples/server-export.
[renderer-0.2.0] — 2026-09-06
Added
@miraiclip/rendereroffline 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 — passcreateDecoder: createWebCodecsDecoderfor uncapped). Audio is mixed offline withOfflineAudioContextusing the same clip math as live playback (extracted to a sharedaudio/mappingmodule — preview and export cannot disagree). Quality presets (draft/standard/high) or explicit bitrate; codec support probed up front with clearUnsupportedMediaErrors; cancellation viaAbortSignal(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 viarange; 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/rendererpipelined 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 insideaddVideoFrame(now an explicit contract), so frame n renders while frames n−3…n−1 encode. Together with an unclampedMessageChannel-based macrotask yield inVideoPipeline.waitForFrame(nestedsetTimeoutis 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 (fpswas already anexportProjectoption).
[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 ane2ejob.createPixiBackendgainspreserveDrawingBufferfor pixel readback.@miraiclip/rendererproxy preview decode —createWebCodecsDecoderFactory({ maxOutputDimensionPx })caps the longest side of decoded output bitmaps (aspect preserved; the downscale happens on the GPU insidecreateImageBitmap). 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:VideoSceneNodegains optionalsetSourceSizeand 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).VideoPipelineexposes cachedinfo(). The playground previews withmaxOutputDimensionPx: 1920;createWebCodecsDecoderstays full-resolution for export-quality output.@miraiclip/rendereraudio playback + player facade (v2 step 4):AudioEngineschedules streaming-decoded audio windows (mediabunnyAudioBufferSink— long files are never decoded whole) on a WebAudio graph with one gain lane per clip, mixing clipvolume× trackmuted/sololive, mapped through clip start + trim, including video clips' embedded tracks; rate-aware deadlines.createPlayerwires 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 inVideoPipeline. 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/rendereras in development. @miraiclip/renderervideo playback (v2 step 3):createVideoSupportbridges the MediaManager into the compositor — video scene nodes pull cached frames on every tick via the new time-awaretickhook, keep decode-ahead primed with throttled re-priming, and map timeline time through clip start + source trim;renderFrameAtdraws one exact frame (thumbnails/posters/export); Pixi video node rendersVideoFrames 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/renderercompositor (v2 step 2):Compositorrenders the document as a pure function of time behind aSceneBackendabstraction, 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/rendererpackage started (private until its first release) with the v2 media layer:FrameCache(distance-based eviction, byte budget, strictclose()ownership),VideoPipeline(keyframe-aware seeking, decode-ahead, abortable priming via generations),MediaManager(one pipeline per asset, decoder cap with LRU release),StepClock/RealtimeClockbehind theClockinterface, 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/rendererframe-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/rendererseek performance audit — removed real per-seek overhead that had nothing to do with decoding: droppedverifyKeyPackets(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 singlechunksFrom(target)seek (was two). Also simplified the over-built display path back to "show the nearest decoded frame" (removedkeepBehindUs/displayToleranceUshold logic and the playground buffering gate) and removed the now-unusedkeyframeAtOrBefore/anchorUs. Net: fewer moving parts and materially faster timeline navigation.@miraiclip/rendererseek latency reduced: the decoder is now reused across seeks viareset()(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 withoptimizeForLatency.FrameDecodergains areset()method.@miraiclip/rendererseek 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/rendererseek 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 withindisplayToleranceUsof 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/rendererVideoPipelinereworked 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/rendererplayback 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.createPlayernow runs a 500ms interval (injectable viaschedule/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/rendererthe 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,FrameCacheeviction 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/renderer4K 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), andFrameCache.evictnever 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/rendererre-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), whileprime()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.VideoPipelinenow tracksstreamStartUs(what the current stream is guaranteed to produce), a monotonicmaxFedUs(B-frame decode order oscillates), andarrivedThroughUs, 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 hammersprime()against an async decoder — one stream seek, ever.@miraiclip/rendererdecode-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/rendereran unchanged scene no longer re-renders: the Pixi backend tracks a dirty flag (frame swaps, placement/visibility/z changes, node churn, resize) andrender()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-framesetPlayheadevent while the clock hasn't moved (one event is still emitted on play/pause/seek so UIs update).@miraiclip/rendererblack video with a pegged CPU on streams that report zero/absentVideoFrame.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) andprime()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/renderermedia errors are no longer silently swallowed:createPlayerandcreateVideoSupportaccept anonError(error, clipId)callback (default logs loudly viaconsole.error), ImageBitmap conversion failures surface through the decoder'sonError, and the tick path reports pipeline failures instead of discarding them — the silentvoid/bare-catchpattern is what hid the black-video bug.- CI test/typecheck failed on fresh checkouts: workspace packages resolved
@miraiclip/corethroughexportspointing atdist/, which doesn't exist before a build. Both packages now use sourceexportsin development (./src/index.ts) withpublishConfigswapping 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/rendererperiodic 1–2s playback stalls, re-diagnosed as hardware decoder frame-pool exhaustion: the frame cache held up to 120 undestroyedVideoFrames while decoders own only ~8–16 output slots, so the decoder stalled until frames were closed. Every decoded frame is now converted to anImageBitmapand theVideoFrameclosed 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.docand ephemeral session state (playheadUs,selection) beside it; selector subscriptions viaproject.subscribe. - Command engine:
dispatchvalidates 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
sourcetag) 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 throughcreateProject, withschemaVersionchecking. - Timeline utilities: µs ↔ seconds/frames/timecode conversion, frame snapping, range overlap.
- Sync utilities:
applyJsonPatchesandfromJsonPointer— 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
exportsverified 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.