mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-05 05:37:09 +00:00
sync/hyperframes-codegen-9fe36934
5139
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
68a605c826 |
feat(engine): add streamingEncodeDurationCapEnabled config flag, default off
The 240 s streaming cap guarded a total-render ffmpeg timeout that became an
inactivity timeout in
|
||
|
|
82f49262ec |
feat(registry): blur every transform driver, and ship the reference case as shutter-slam (#4038)
The motion-blur snippet integrated translation only. Its output stage was one SVG filter per target whose copies were feOffset, and feOffset only translates, so a beat that only scaled or only turned sampled a displacement of zero and rendered sharp. No SVG filter primitive can give a copy its own affine transform, so the sampling loop was never the problem: the output stage was. Copies are now DOM clones of the element in an isolation:isolate group, each at 1/N opacity with mix-blend-mode plus-lighter, each carrying the element's resolved transform at its sample time. plus-lighter adds premultiplied colour, so N + 1 copies at 1/N sum to the average the shutter integral asks for; the isolation is load-bearing rather than tidy, because without a transparent backdrop of its own the first copy adds onto the page and blows a light background out to white. Reading the resolved matrix instead of a list of named GSAP properties is what makes every driver work at once: translation, scale, rotation, 3D rotation and skew arrive in the same one value. Measured against the After Effects export the shutter model was fitted to, per beat, previous stage to this one: scale +0.62 dB, rotate X +0.43, rotate Y +0.84, rotate Z +1.15, translate X -0.18, translate Y -0.04. The two small translate regressions are inherent and not tunable: the filter offset one rasterisation so every copy was pixel-identical, where each copy is now rasterised independently at its own fractional transform with its own subpixel antialiasing. Three consequences of the new stage, each carrying its own fix: - mix-blend-mode flattens preserve-3d, so a copy cannot inherit the parent's 3D context. Each copy carries the parent's perspective as its own first transform function instead. - A copy is styled by nothing that selected the original, since dropping the id also drops every #id rule that gave it size, colour and font. Each copy carries its own resolved style inline, read once and replayed. Those values are px, so a container-relative element needs them again when its box changes: a ResizeObserver re-snapshots rather than paying a subtree walk every frame. - A beat that moved and faded at once kept a full-strength smear behind a vanishing element. The element's opacity now rides the group; the 1/N weight stays on the copies. The axis option is gone. It selected which of x and y to sample, a question the resolved matrix does not have. shutter-slam is the new component: one word through six single-property beats, two translate slams, a scale punch, then a full turn about each axis, which exercises every driver the model covers in one mount. Easing is power2.inOut because it measured best, not because it was the default. A bezier fitted to the reference's own bbox centres looks like the better curve and renders worse: a blurred bbox centre is the shutter window's average rather than the instant, so on an accelerating segment the fit measures its own blur. Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com> |
||
|
|
a8a9fdb0fe |
chore: prepare v0.8.46 (#4033)
* chore: prepare v0.8.46 Draft only, not tagged: bun run release:prepare 0.8.46 --no-tag. Changelog rewritten in plain words for the one change since v0.8.45 (#4031, adaptive per-frame motion-blur sample count). Cutting the release is a separate step on Miguel's word. * docs(release): fix false render-time savings claim in v0.8.46 notes Adaptive sampling never drops below the old fixed-16 default; it only adds cost on frames that measure real spatial motion. Per Miga's review on #4033. |
||
|
|
2ab7b86868 |
feat(engine): adaptive per-frame sample count for motion blur (#4031)
* feat(engine): adaptive per-frame sample count for motion blur Measured against the accuracy bar set in #4010 (72.4dB mean PSNR vs the 960fps shutter integral), the fixed default of 16 samples misses it for hard-edge content while paying nothing extra for static/slow content is still important. This picks a sample count per frame from measured motion, floor 16 ceiling 64 (issue #4029). Two probe captures at the true shutter-window edges (never a sample that enters the average) measure motion magnitude, mapped through a monotone step table to a sample count. Frames confirmed to move only non-spatial properties (opacity, colour) skip the probe and use the floor directly, since a fade's shutter integral is a smooth blend that does not need many samples. MotionBlurPlan's window (from shutter angle/phase alone) is now independent of how many samples fill it, which is what lets the count vary per frame without moving the window; motionBlurWindowIsStatic is bounded by the widest sample inset any chosen K could produce so the static-dedup safety guarantee holds for every K in [16, 64]. * refactor(engine): simplify the adaptive motion-blur sample selection Two findings from an independent code-reuse/quality/efficiency review of the previous commit: - armStaticDedup ran the GSAP-timeline walk (computeStaticFrameSet) twice on the same page when adaptive motion blur and static dedup both apply, once for each purpose. Shares the one result instead. - The non-spatial floor path read the sample count from adaptiveSampleCount(0), which only worked because the step table happens to be sorted with the first bucket covering zero. Reads the now-exported DEFAULT_SAMPLES_PER_FRAME directly instead. * fix(engine): read raw options for the adaptive motion-blur gate Independent review caught it: every armStaticDedup call site runs before finalizeSessionInit resolves session.motionBlur, so the adaptive-classification gate's `session.motionBlur && session.motionBlur.fixedSamplesPerFrame === null` was always false at runtime. The non-spatial probe-skip described in the PR never engaged outside tests that hand-set motionBlurNonSpatialFrames directly, bypassing real init order. Reads session.options.motionBlur (set unconditionally at session creation) instead, which carries the same "adaptive requested" information the resolved plan would have. Adds a source-order lock test plus direct coverage of computeStaticFrameSet's nonSpatialOnlyFrameSet, mutation-proved against the exact regression. * fix(engine): add xPercent/yPercent to the spatial-property list Round 2 of the independent review: the spatial-property list deciding whether a frame's motion needs the adaptive probe omitted GSAP's percentage-based position properties (xPercent/yPercent) and two 3D transform properties (perspective/transformPerspective) that two other lists already in this repo (packages/parsers/src/gsapConstants.ts, packages/studio/.../gsapAnimatesProperty.ts) both carry. A composition positioned via xPercent/yPercent would have been silently floored to K=16 with no probe and no log line, the same failure shape as the round-1 bug through a data gap instead of a dead gate. Moves the list to an exported SPATIAL_TWEEN_PROPERTIES array in motionBlur.ts, passed into computeStaticFrameSet's page.evaluate as data rather than duplicated inline, so it stays a single list and is directly testable. Adds tests that execute the real page-side closure (the existing tests all mocked the whole result and never exercised the matching code), mutation-proved against the exact gap found. * fix(engine): add rotate to the spatial-property list Round 3 of the independent review, comparing SPATIAL_TWEEN_PROPERTIES side by side against the two sibling lists rather than trusting the prior commit's summary: GSAP's individual "rotate" transform property (distinct from "rotation", carried in packages/studio/.../gsapAnimatesProperty.ts's GSAP_TRANSFORM_PROPS) was still missing, same failure shape as the two prior rounds. The same pass also raised transformOrigin, which is deliberately NOT added: gsapConstants.ts's own classifyTweenPropertyGroup excludes it as a pivot-point modifier with no independent visual effect, and a test now locks that reasoning in rather than leaving it to be "fixed" again by a future pass that does not read that far. |
||
|
|
484a06fb65 |
chore: release v0.8.45 (#4027)
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com> |
||
|
|
77a45f6080 |
fix(producer): fail loudly when a capture route cannot blur (#4026)
A render on the layered HDR route accepted `motionBlur` and returned frames with no blur in them. The HDR compositor runs its own capture loop, seeking and blitting each layer itself, so a frame never reaches the sub-frame accumulation branch. The engine's own guard cannot catch it, because from inside the session everything looks supported: screenshot capture mode, PNG frames, and no video injector on a composition with no video. This is the third time the question "does this route support motion blur" has been answered implicitly by whichever code path happened to run, and the third time it came out wrong and silent. `assertMotionBlurSupported` gives that question one owner and one answer. The route it takes is the capture plan's own `kind`, already the single value the capture stages consume, so it cannot drift from the route that actually runs, and a new plan kind with no entry in its table fails the typecheck rather than inheriting silence. It sits inside `syncCapturePlan`, which runs on the initial plan and after every replan, so a route a render falls back into is checked too. Distributed renders are out of scope on purpose and recorded as such: `DistributedRenderConfig` has no `motionBlur` field and is not built from `RenderConfig`, so motion blur cannot be requested on one. There is nothing to reject and no dead call site. The explicit unsupported-mode error for layered HDR is the design in #4013 by Dante-dan. Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com> |
||
|
|
71bb88719e |
feat(telemetry): render output-shape, adapter, count, and environment properties (#4017)
* feat(telemetry): add output-shape request facts to render events output_resolution_preset, output_format, hdr_mode, video_frame_format, and gif_fps_capped join render_complete/render_error, all threaded from already-resolved RenderOptions/RenderPlan fields rather than recomputed: createRenderPlan already computes gifFpsCapped (clamping a requested --fps above 30 to 30 for --format gif) but only used it for a console warning; this threads that same decision into RenderOptions and telemetry. All five are known before the pipeline starts, so both events carry them even when a render fails before a perfSummary exists. Also wires output_format/output_resolution_preset into Studio's separate emitStudioRenderComplete/emitStudioRenderError path, since Studio's request genuinely varies those two per-request. hdr_mode, video_frame_format, and gif_fps_capped stay CLI-only: Studio exposes no HDR-mode, video-frame-format, or GIF output control to users at all, so there is no per-request value to report for those three. Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com> * feat(telemetry): add adapters_used to render events adapters_used lists the runtime deterministic adapters (packages/core/src/runtime/adapters/) a composition exercises. Investigated the actual codebase rather than the ticket's guessed 7-adapter list and found 12 real registered adapters (gsap, css, waapi, animejs, lottie, three, typegpu, d3, leaflet, mapbox, maplibre, google-maps) — the map/data-source adapters share the exact same window.__hf<Name> registration mechanism and are equally real "adapters used" facts, so all 12 are included. Detection unions a live page.evaluate probe (piggybacked on the same probeSession resolveCompositionElementCount already uses) with a static regex fallback over the compiled HTML, since this is purely observational with no gating role (unlike compositionElementCount) — union strictly increases recall rather than needing one source of truth. Threaded through the same plumbing chain as the already-merged composition_element_tags/aroll_video_count/heygen_video_count. Studio's emitStudioRenderComplete/emitStudioRenderError get this field automatically, with no Studio-side code change, via the same shared capture-observability spread heygen_video_count already uses. Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com> * feat(telemetry): add audio/image/sub-composition/color-grading counts audio_count, image_count, sub_composition_count, audio_group_count, color_grading_count, and has_lut join composition_element_tags in the same single scanElementTags pass — audio/image/audio-group counts read from the scan's uncapped tag map so a 50+-distinct-tag composition can't push a named count into the "other" bucket. sub_composition_count counts data-composition-src mounts directly rather than reusing collectSubCompositionSrcs, which dedupes by src and drops placeholder/remote mounts (a different, smaller fact than the mount count this field reports). has_lut decodes the HTML-entity-escaped form of data-color-grading before parsing, since linkedom's serializer re-emits it "-escaped on every compile-pipeline round-trip — without the decode this silently reported false for every LUT-graded composition. The LUT-presence check now mirrors normalizeLut's own definition of "has a LUT" exactly, closing a gap where a blank or empty lut value would have reported true. Threaded through the same plumbing chain as the already-merged per-tag/provenance/adapter fields; Studio's emitter picks these up automatically via the shared capture-observability spread. Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com> * feat(telemetry): add browser_version_major and ffmpeg_version_major Both are already-run subprocess calls whose output was discarded: readToolVersion (checkFFmpeg's preflight) already runs `ffmpeg -version`, and chromeLaunchOutcome already runs `--version` as a Chrome launch sanity probe on every render. A shared extractMajorVersion helper pulls the first X.Y-shaped number from either banner, threaded onto EnvironmentCheckOutcome/ EnvironmentCheckResult, then RenderOptions right after runEnvironmentChecks resolves in the local render path only. Docker renders never populate these two RenderOptions fields (the containerized producer runs its own preflight the host CLI never sees), so both properties come back absent there rather than reporting the host's own toolchain versions for a render that didn't use them. Studio does not call runEnvironmentChecks or this ffmpeg/Chrome preflight at all before creating a render job, so its render_complete/ render_error events will not carry these two properties unless Studio's own startup path is separately instrumented — a genuine parity gap, not an oversight in this change. Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com> * feat(telemetry): add authoring_skill_source and authoring_skill_invalid Both derive from resolution logic createRenderPlan already runs: authoring_skill_source names whichever step actually won attribution (an explicit --skill flag, or the skill persisted in hyperframes.json) using the same flagSkill/projectConfigSkill values already computed for authoringSkill itself, not a second lookup. authoring_skill_invalid surfaces the raw --skill value when it fails slug-shape validation (normalizeSkillSlug rejects malformed input, not unregistered skill names) — this value already exists as RenderPlan.invalidAuthoringSkill and already drives a CLI warning in present.ts; this change threads the same field into telemetry rather than adding a parallel one. Threaded through both RenderOptions builders in execute.ts (single and batch render) and all three telemetry call sites in render.ts, including the Docker path — skill attribution is a request-level fact independent of which backend executes the render. Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com> * feat(telemetry): add root_body_mismatch and root_body_delta_px_bucket Measures the composition root's authored data-width/data-height against the scaffold's own html/body CSS size, straight from the compiled document string scanElementTags already scans — the source-level fact, not runtime DOM state. init.ts's applyCompositionSizing (the #4001 fix) only patches the live DOM after the browser loads this same HTML, so this measurement stays meaningful whether or not that fix ran for a given render. Reuses @hyperframes/parsers' canvasScaffoldPatterns regexes (the same ones packages/lint's root_dimensions_mismatch rule reads) for the CSS side. Root-tag detection here is a lighter first-data-composition-id-match heuristic, not lint's more thorough findRootTag (which also handles a leading decorative <svg> defs block) — that helper isn't part of @hyperframes/lint's public API, and expanding it is out of scope here. The delta is bucketed (0 / 1-10 / 11-50 / 51+ px) rather than reported raw, and both fields come back absent — not a false/"0" default — when the root or the CSS block can't be read at all. Threaded through the same plumbing as every other static-scan field this week, including the live-capture-observability fallback used on a crash that never reaches perfSummary. Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com> * feat(telemetry): add hf_env_overrides (names only, capped at 20) Reports the sorted names of HF_*/HYPERFRAMES_* env vars present when createRenderPlan resolves — never their values, since several of these carry filesystem paths. Snapshotting at plan-resolution time (the very first line of the render command, before any pipeline code runs) matters: this render's own preflight later injects HYPERFRAMES_FFMPEG_PATH/HYPERFRAMES_FFPROBE_PATH into its own process.env, which would otherwise make those two always read back as "operator overrides" whether or not the operator ever set them. Reports an empty array, never an absent key, when nothing is set, matching every other capped/bucketed field added this week. Capped at 20 names, documented alongside the cap constant. Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com> * fix(cli): stop clobbering studio render_complete fields, fix an env leak trackRenderComplete's explicit audio_count/image_count/root_body_mismatch/ adapters_used/etc. keys, sourced from perfSummary.drawElement and only ever populated on the CLI's own render.ts path, always won the object spread over the observability-capture fallback those same keys share with trackRenderError, even when the direct value was undefined. studioRenderTelemetry.ts's emitStudioRenderComplete never populates drawElement, only the capture fallback, so 24 fields silently came back missing from every studio-triggered render_complete event despite Studio having computed and sent the capture value. Each field now falls back to its captureXxx counterpart via a new directOrCapture() helper when the direct field is absent, with regression tests proving both the fallback and that the direct value still wins when both are present. Also excludes HF_SHADER_WORKER_ENTRY from hf_env_overrides: cli.ts sets this on every invocation to point the bundled shader worker pool at its own file, sharing the HF_ prefix by coincidence rather than because an operator set it. Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com> * style: remove em and en dashes from added code comments No behavior change. Rewrites 22 comment lines this PR's own diff introduced, using a comma or a period where the dash separated two clauses. Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com> * test(producer): add a temporary diagnostic for a CI-only rootBodyMismatch failure Not a real fix. The rootBodyMismatch test group fails deterministically in CI (confirmed on two runs of the same head) but will not reproduce locally by any method tried, including an isolated file run, the exact combined producer:test:unit command, a worker-count-constrained run, and a fresh clone with a clean install and build matching CI's own steps. This dumps the intermediate regex-match state for the simplest failing fixture to stderr so the next CI run's log says what actually differs. To be removed once that answer lands. Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com> * test(producer): widen the rootBodyMismatch diagnostic to compare import paths Still not a real fix. The previous diagnostic ( |
||
|
|
9117b36471 |
fix(engine): skip motion-blur dedup when the shutter window moves (#4025)
The static-frame dedup reuses the previous frame's buffer when a frame is byte-identical to its predecessor. With motion blur on that test is not enough: the shutter window reads content either side of the frame instant, so a still frame next to a moving one reused a buffer that carries none of that motion. At 30 fps and 16 samples frame 11's window runs 10.75 to 11.25 at a 180 degree shutter and 10.06 to 11.94 at 720, so both reach past the frame instant and the default is affected too. motionBlurWindowIsStatic now owns whether a blurred frame may reuse its predecessor's buffer. It requires every frame the window reads from to be static, and covers the previous frame's window as well, because reuse claims the two blurred outputs are equal. The range is derived from the plan's own sample tick offsets, so it cannot drift from where the samples land. The guard's contract comes from #4013 by Dante-dan, which carried it before we did. One existing test fixture is widened: it asserted reuse with only the frame itself marked static, which the guard now correctly rejects. Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com> |
||
|
|
b64a7a3a67 |
feat(producer): make sub-frame motion blur reachable from a render (#4020)
* feat(producer): make sub-frame motion blur reachable from a render Second deliverable for issue #4010. The engine gained the accumulation path, but nothing set CaptureOptions.motionBlur, so the feature could not be switched on from a real render. RenderConfig now carries it and the producer threads it into the capture options. Doing that surfaced the defect that would have made it fail silently. "Does this render capture PNG frames" was spelled four times, every one of them re-derived from needsAlpha, which describes the OUTPUT container rather than the capture: probeStage picked the probe capture format renderOrchestrator picked the render capture format encodeStage built the encoder's frame_%06d input pattern, twice captureStage built the disk worker's frame extension Motion blur is a second, independent reason to capture PNG: the sub-frame samples are averaged pixel by pixel, so JPEG samples would be averaged after a lossy quantization. Add that reason to only one of those four and the encoder goes looking for frame_%06d.jpg against files written as .png, and the render finds no frames at all rather than reporting an error. So the rule now has one owner, resolveCaptureImageFormat, which names both reasons. Everyone downstream reads the resolved capture format instead of re-deriving it: the encoder and the disk worker share frameFileExtension with writeCapturedFrame, which is what actually names the files. needsAlpha keeps only the question it really answers, whether the output preserves alpha. captureStage no longer needs needsAlpha at all, which is the tell that the re-derivation was its only reason to know. No JPEG encoder is involved. The PNG capture path is already load-bearing, since every transparent-output render uses it and the streaming encoder already selects png as its input codec. Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com> * fix(engine): resolve the motion-blur plan on every initialization exit A real render produced no blur at all. The plan was correct, the producer wiring was correct, and the sub-frame sample times were genuinely distinct, but K=16 and K=64 rendered identically to no blur because neither ever ran. initializeSession has two exits: screenshot mode finishes its own block and returns, every other capture mode falls through to the end. The plan was resolved at the fall-through exit only, and screenshot is the one capture mode motion blur supports, so on exactly the path the feature runs on session.motionBlur was never assigned. captureFrameCore then read it as undefined and took the single-capture branch: one capture per output frame, no accumulation, no error. finalizeSessionInit is now the single owner of what finishing initialization means. It sets the plan and isInitialized together and is called at both exits, so a third exit cannot mark a session ready while forgetting the plan. Why the existing tests passed, which is the part worth keeping: the accumulation test set session.motionBlur directly on a stub session. It exercised the mechanism while bypassing the only code that decides whether the mechanism runs at all. A test that proves something works and never asks whether it is reachable is not end to end, however much behaviour it drives. Two tests. A structural guard that session.isInitialized = true appears exactly once and that the same place resolves the plan, which fails if the defect is reintroduced at either exit. And a sample-distinctness assertion that hashes the K buffers captured within one output frame and requires K distinct ones, which covers the different failure of every sub-frame seek landing on one instant. Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com> --------- Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com> |
||
|
|
e4b1832fad | feat(cli): add hls output format (#4022) | ||
|
|
9aa60a844f |
fix(registry): make motion-blur match After Effects shutter-based blur (#4008)
* fix(registry): make motion-blur match After Effects shutter-based blur The motion-blur catalog snippet estimated speed from the x/y delta between consecutive timeline updates and drew three one-sided ghost copies with decaying alpha plus a Gaussian on top. After Effects does temporal supersampling: each frame integrates the layer over a shutter window (shutter angle, shutter phase, samples per frame), producing a symmetric box smear of length speed x shutter time. The snippet now does the same inside the page. After every timeline update a deferred pass seeks the GSAP timeline to N sub-frame times across the shutter window (events suppressed), reads each target's x/y, restores the frame time, and drives an SVG filter that averages N feOffset copies with equal weight (feComposite arithmetic, 1/N, sRGB). Defaults follow AE: shutterAngle 180, shutterPhase -90, samplesPerFrame 16; fps comes from the render config, then the root data-fps, then 30. Displacements are mapped through the inverse of the element's own scale/rotation; percent x/y values are resolved to px; SVG targets use their bbox; an existing inline filter is kept and the blur appended to it. The pass runs in a microtask rather than inside the tracker tween's onUpdate, because seeking GSAP from within its own render reads stale tween state and suppresses the composition's callbacks on forward seeks. The heuristic options blurScale/blurMax/stretchScale/stretchMax are removed. Reference: an AE-algorithm reconstruction of the same composition rendered without the component at an effective 960 fps and averaged over the 16 sub-frames of the 180 degree window per output frame. Against it (1920x1080, 30 fps, solid + text translating linear then power2.inOut): old mean PSNR 53.2 dB (min 26.7), SSIM 0.9930; new mean PSNR 72.4 dB (min 52.6), SSIM 0.9998. The 10-90 edge ramp at 66.7 px/frame is 26.8 px in the reference, 27.5 px new, 14.5/6.3 px (one-sided) old; at the ease peak 79.3 vs 79.5 vs 7.9/17.7 px, with smear centres matching to 0.1 px. Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com> * fix(registry): drop render-config fps probe from motion-blur and refresh its docs The snippet resolved fps by reconstructing the renderer's private seek config key so the compiler's inline-script guard would not strip it. A public catalog snippet must not depend on that key, so fps is now the explicit option, then the root data-fps, then 30; renders with an fps override pass fps explicitly. The catalog page's Usage, How it works and Options sections still described the old velocity heuristic and are rewritten for the shutter model. The 1/N weight keeps full precision. Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com> * fix(registry): match the After Effects reference export's shutter window The shutter model landed on this branch was built against a reconstruction of After Effects' algorithm, not against After Effects. A real 1920x1080 / 30 fps export of translating text disagrees with it on three points, all measurable off the export itself. The window is two frames wide, not half a frame. On the measured frame the outermost duplicate on the trailing side sits 128 px back while the previous frame is 125.8 px back, and the outermost leading duplicate sits 189 px ahead while the next frame is 184.9 px ahead: the window ends land on the neighbouring frames. Defaults become shutterAngle 720, shutterPhase -360. Duplicates sit on the sub-interval boundaries, both ends included, not at sub-interval centres. The staircase across a stroke steps every 15.6 px on a 125.8 px displacement and every 23.0 px on a 184.9 px one, i.e. an eighth of a frame, with the run closing exactly on the neighbouring frame's position. samplesPerFrame now means sub-intervals of the window, so there are N+1 copies at k/N rather than N copies at (k+0.5)/N. The frame-time instance is drawn sharp over the smear. At full ink the core bounding box is the whole word at every frame and total ink rises to 1.44x the unblurred frame's, neither of which an average can produce. A final feComposite over of SourceGraphic reproduces it. Each duplicate stays at 1/N: the staircase plateaus step by 0.063 +- 0.002 of the sharp text intensity, flat, with no taper toward the window edges, and triangle weighting scores 1.6 dB worse against the export. Verified by replaying the export's translating section as a composition whose sprite is the export's own rest-frame ink and whose eight legs follow its own measured per-frame positions, rendered at 1920x1080 / 30 fps and compared frame by frame over the text band. Mean PSNR against the export rises from 24.19 dB (min 17.07) to 32.51 dB (min 27.57) across 72 frames, and from 19.83 dB (min 17.07) to 32.03 dB (min 27.57) across the 26 frames that actually move. Ghost extents match to a few px and the rendered staircase plateaus match the export's to two decimal places. The export's later scale, skew, flip and 3D rotation sections are out of reach here: integrating anything but translation inside the shutter is engine work tracked in issue #4010. The filter region now clamps its bounds through zero. It was derived from the duplicate offsets alone, which was correct while the output was nothing but duplicates; the sharp instance draws at offset zero, so a window sitting entirely on one side of the frame time - any shutterPhase above 0 or below -shutterAngle - produced a region starting inside the element's own box and cut the instance off. The defaults straddle the frame time and were never affected, so the measured numbers above are unchanged. Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com> --------- Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com> |
||
|
|
462f9db73d |
feat(producer): add hls output format (#4014)
* feat(engine): add packageHls for HLS VOD packaging Adds `packageHls` next to `muxVideoWithAudio`: stream-copies an already-encoded H.264 video and optional AAC sidecar into an HLS VOD directory (`master.m3u8` + split video/audio renditions, MPEG-TS segments) with `-c copy`, so no re-encode happens at package time. Depends on the locked GOP from the previous commit. `-hls_time` can only cut at existing keyframes, so `gopSize = segmentSeconds x fps` is what makes every segment exactly `segmentSeconds` long and IDR-aligned. A test packages an unlocked encode to pin that: the same call yields one 4s segment instead of four 1s ones. Deliberately omits the mp4 mux's provenance tags, which MPEG-TS drops and whose `-movflags` is invalid for `-f hls`, and `-avoid_negative_ts`, which would discard the AAC priming and shift audio against video (see issue 3487). A real-ffmpeg test asserts the first presentable audio sample lands on the first video frame. No caller yet; `format: "hls"` in the producer follows. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(engine): zero HLS mux base and reject % in packageHls outputDir - Pass -muxdelay 0 -muxpreload 0 so the first segment starts at PTS 0 instead of the mpegts muxer's 1.4 s base; A/V alignment through the AAC priming packet is unchanged - Throw on an outputDir containing %, which ffmpeg expands as a printf conversion in -hls_segment_filename and fails with a misleading "No such file or directory" - Assert alignment on relative spacing rather than on how one ffmpeg build clamps the priming packet's negative PTS - Encode the test fixture with the full software GOP lock (lockedGopCodecParams), not just appendLockedGopArgs - Trim comments to the non-obvious rationale Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(engine): satisfy the ffprobe argv contract in packageHls test `producer/src/utils/ffprobeArgvContract.test.ts` sweeps every package at the source level and requires `--` to be the argv entry immediately before an ffprobe input path. The `probe()` helper here passed `join(outputDir, HLS_VIDEO_PLAYLIST)` in that slot, which the contract's comma-splitting parser reads as two entries, so the producer suite was red on this branch while the engine suite stayed green. `probe()` now takes the input path as a parameter and appends the terminator itself. The sibling ffmpeg `run()` helper switches from a spread-in-array-literal to `.concat`, because a verbosity-flag-then-spread literal is indistinguishable from the ffprobe wrapper shape the contract demands a terminator on. The matcher reads raw source including comments. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(engine): drop the audio-only variant from the HLS master playlist With `a:0,agroup:aud` in `-var_stream_map`, ffmpeg lists the audio rendition twice in `master.m3u8`: as the `#EXT-X-MEDIA:TYPE=AUDIO` group the video variant references, and again as a standalone `#EXT-X-STREAM-INF` variant with no `RESOLUTION`. That is valid HLS, but a player choosing by bandwidth can select it and play sound with no picture, and the VOD consumer asked for a single rendition. `stripAudioOnlyVariants` removes any variant tag without a `RESOLUTION` attribute together with its URI line; `packageHls` applies it to the master after a successful audio+video run. The rendition group and the audio playlist are untouched, so `audio.m3u8` is still referenced from the master. Pinned by a unit test on the playlist text and by the real-ffmpeg test, which now asserts exactly one `#EXT-X-STREAM-INF`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor(producer): extract hdrMode warning and decision-log helpers `resolveEffectiveHdrMode` was already over the fallow complexity gate on `main` (21 cyclomatic / 25 cognitive / CRAP 462) and only surfaces now because the HLS format work needs to touch its downgrade warning. Pull the downgrade message into `formatDowngradeWarning` and the "which mode and why" info line into `logHdrDecision`, which puts the function under the threshold. No behavior change: every log string is byte-identical and `hdrMode.test.ts` asserts them unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(producer): add hls output format Adds `format: "hls"` to `@hyperframes/producer`. An HLS render is the same opaque H.264 + AAC encode as `mp4`, stream-copied at assemble time (no re-encode) into a directory: `master.m3u8`, `video.m3u8` + `video_%05d.ts`, and `audio.m3u8` + `audio_%05d.ts` when the composition has audio. The load-bearing part is that `-c copy -f hls` can only cut a segment at an existing keyframe, so the encoder's GOP is locked to `hlsSegmentSeconds x fps` frames. `resolveHlsEncoderGopLock` returns that pair as one value spread into both encoder inputs, so the streaming and disk/chunked paths cannot disagree — a render reaching only one of them still succeeds, it just emits variable-length segments silently. - `renderFormat.ts`: `hls` is opaque (`outputNeedsAlpha` is now `!== "mp4" && !== "hls"` — missing this flips HLS to RGBA screenshot capture with nothing failing), supports page-side compositing, and `outputUsesH264Pipeline` replaces the three `outputFormat === "mp4"` router gates. - `hlsConfig.ts`: `hlsSegmentSeconds` (default 4, positive integer), the GOP derivation, and the fail-fast gates. `force-hdr` is rejected (v1 is SDR-only; HDR10 needs HEVC in fMP4), and so is `useGpu` — the GOP lock is software-only, so a GPU HLS render segments correctly by accident on videotoolbox and wrong on nvenc. - `assembleStage.ts`: `hls` branches to `packageHls` instead of mux/faststart, after the same `padOrTrimAudioToVideoFrameCount` the mp4 mux uses. - `artifactTransaction.ts`: a directory artifact can now carry a `probeTarget` + `requiredFiles`, so the #3395 truncation gate still runs for HLS against `video.m3u8`. `expectedFrames` is inert there (ffprobe reports no frame count through the hls demuxer); duration carries it. - `renderRequest.ts`: accepts `hls` + `hlsSegmentSeconds`; distributed conversion rejects it alongside `gif`. `DistributedFormat` is unchanged. `RENDER_REQUEST_VERSION` stays at 1: the new field is optional, the parser ignores unknown option keys, and an older worker already rejects `format: "hls"` with "Render request format is invalid". - `hdrMode.ts`: the non-mp4 SDR downgrade warning no longer claims "HDR + alpha" for `hls`; the helper extraction landed in the preceding refactor commit. Verified: `hlsRender.integration.test.ts` renders a real 10 s composition and asserts 3 segments, the first two exactly 4.000 s, every segment starting on an IDR frame, and PTS 0. Removing the GOP wiring collapses it to 2 segments, so the test is load-bearing. Both producer lanes (unit and integration) run on every PR in `ci.yml`, so that wiring check is on the PR gate. CLI flags and docs are deliberately left to the next PR in the series. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(engine): close the HLS master playlist rewrite race CodeQL flagged two high-severity alerts on the `stripAudioOnlyVariants` rewrite: a check-then-use race (`existsSync` then a separate path-based read and write) and insecure creation of a file at a predictable path in the OS temp dir. Both came from resolving `master.m3u8` three times. Open it once with `r+` and owner-only mode — which neither creates the playlist nor widens its permissions, ffmpeg owns that — then read, truncate and write through that one descriptor. Absence is still tolerated for the argument-level tests that stub ffmpeg, now via ENOENT rather than a prior existence check. No behavior change: `hlsRender.integration.test.ts` still renders a real composition with audio and asserts the stripped master playlist. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0a1c6f5a45 |
feat(engine): sub-frame multi-sample motion blur, capture-level accumulation (#4019)
Deliverable one of issue #4010: average K sub-frame captures into each output frame so rotation, scale and opacity blur the way After Effects does, which the translation-only catalog component (#4008) cannot do. Opt in per render with CaptureOptions.motionBlur; absent means the capture path is unchanged. The blocker was quantizeTimeToFrame flooring every seek onto the output frame grid, which collapsed all K samples onto one instant and made the averaged frame byte-identical to the unblurred one. quantizeSeekTime now owns that decision at all three quantize sites (runtime player, its no-timeline fallback, and the init transport) and refines the grid by an integer subdivision count, which can only contain the output grid, never move a frame-time instant off it. The finer grid rounds rather than floors: a sample time round-trips through the page bridge as a float, and past a few million ticks that error exceeds the floor epsilon. Accumulation sits inside captureFrameCore AFTER the static-dedup early return, so a static frame still costs one reused buffer and only moving frames pay K. It is not a before-capture hook, which would have disabled dedup outright. Callback invariant: one eventful seek per output frame at the frame time, then K suppressed sample seeks, then a suppressed restore to the frame time. The restore matters because a suppressed seek still moves the playhead, so without it the next frame's eventful seek would swallow the callbacks inside the shutter window. Scope of this deliverable: screenshot capture mode and PNG output. beginframe, drawelement and JPEG encoding are rejected with a named error rather than silently rendering unblurred. Video content is rejected for the same reason it is a documented limitation: injected frames are extracted per output frame and cannot follow a sub-frame seek. Blend defaults to sRGB, matching AE's non-linearised 8-bpc working space and the catalog component. Linear light is an explicit opt-in. Samples are folded into a running average as they are captured rather than held until the end, so peak memory is one accumulator plus one decoded sample instead of all K: at 1080p that is a constant 41 MB rather than 141 MB at 16 samples and 540 MB at the 64 maximum, on every frame of the render. PNG chunk CRC32 now has one owner in utils/crc32.ts, shared by the new encoder and ffprobe's metadata reader. The encoder previously imported zlib's crc32 by name, which throws at module evaluation on Node 22.0 and 22.1; ffprobe already carried the guarded version and the comment explaining why. The sRGB transfer function is likewise exported once and shared. Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com> |
||
|
|
3c9462feea |
chore: release v0.8.44 (#4016)
- Lint no longer reports root_dimensions_mismatch for sub-compositions, the false positive v0.8.43 shipped with; a top-level mismatch is still reported and a viewport-meta-only mismatch gets its own wording (#4012) - The streaming encoder accepts lockGopForChunkConcat and gopSize, emitting the same arguments as the disk encoder. Off by default, so an existing render produces an unchanged FFmpeg arg list (#3973) Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com> |
||
|
|
b4f1929cf0 |
feat(engine): lock GOP on every encoder path (#3973)
## What this does Video encoders normally choose their own keyframe positions. A keyframe is a frame that can be decoded on its own, and you can only cut a video cleanly at one. The engine already has a flag, `lockGopForChunkConcat`, that forces a keyframe every `gopSize` frames so chunk files can be joined with `ffmpeg -c copy`. Today that logic is copy-pasted in two places inside `buildEncoderArgs` and does not exist at all in the streaming encoder. This PR: 1. **Extracts the locked-GOP logic into three small shared helpers** in `chunkEncoder.ts`: `resolveLockedGopSize` (validation), `appendLockedGopArgs` (the `-g` / `-keyint_min` / `-sc_threshold` / `-force_key_frames` args), and `lockedGopCodecParams` (the x264/x265 param fragment). `buildEncoderArgs` now calls them instead of inlining the same block twice. 2. **Adds the same flag to the streaming encoder** (`StreamingEncoderOptions.lockGopForChunkConcat` + `gopSize`), which is the path most renders under four minutes actually use. It emits the identical args as the disk encoder, including `-bf 0` for h265 under a lock so B-frame reordering cannot reintroduce negative DTS at a boundary. ## What does not change - Any render that does not set the flag produces a byte-identical ffmpeg arg list. The flag defaults to `false` everywhere. - Nothing in the tree sets the flag yet except the distributed chunk worker, which already used the disk encoder. No user-facing behavior changes. - GPU encoders, VP9 and ProRes streaming encodes continue to ignore the flag, exactly as `EncoderOptions` documents. An earlier revision of this PR made GPU encoders honor it; that was dropped because the path is unreachable until HLS lands and was verified only on videotoolbox. ## Why Groundwork for `format: "hls"` in `@hyperframes/producer`. HLS packaging stream-copies the encoded video into fixed-length segments with `-f hls -c copy`, and the segmenter can only cut on keyframes the encoder already produced. Locking the GOP at encode time is what makes every segment land exactly on a 4-second boundary without re-encoding. The follow-up PR adds the format itself and should reject `--gpu` for `hls` in v1. ## Testing - `chunkEncoder.test.ts`: the three helpers tested directly; existing locked-GOP and GPU no-op tests unchanged. - `streamingEncoder.test.ts`: new `lockGopForChunkConcat` block covering default off, libx264, libx265, and invalid `gopSize`. - Engine typecheck, oxlint, oxfmt clean. Full engine suite matches main's baseline (the 4 `ffprobe.test.ts` HDR fixture failures are pre-existing on `main`). 🤖 Generated with [Claude Code](https://claude.com/claude-code) |
||
|
|
2229d0d6a6 |
fix(lint): root_dimensions_mismatch sub-composition false positive + regex SSOT consolidation (#4012)
* fix(lint): guard root_dimensions_mismatch against sub-compositions Sub-compositions mounted via data-composition-src are fetched with DOMParser and only their matched <template>/<body> subtree is imported into the live document. A sub-composition authored as a full standalone HTML document still carries its own <html>/<head>/<meta viewport>, but none of that ever reaches the rendering document, so a mismatch there was always a false positive. Guard the rule with options.isSubComposition, matching the sibling missing_timeline_registry rule's existing pattern. Split the warning wording: html/body CSS actually sizes the element that clips the root, but the CDP viewport is set from the root's own data-width/data-height (frameCapture.ts), never from <meta viewport>. A viewport-only mismatch is real drift but not a clipping risk. Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com> * fix(cli,parsers): consolidate scaffold resolution regexes into one definition applyResolutionPreset kept its own prefix-capturing regex literals for the html/body CSS size and meta viewport content, duplicating the same three patterns already defined in @hyperframes/parsers for lint's read-only root_dimensions_mismatch comparison. Merge to one definition per pattern that captures both the prefix (for in-place replace) and the digits (for comparison), so the two callers can no longer drift on where a scaffold's resolution lives. Existing applyResolutionPreset tests stay green. Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com> --------- Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com> |
||
|
|
d6fe66707f |
chore: release v0.8.43 (#4007)
- Portrait compositions render the full frame when the project was scaffolded at a different size: html/body are sized to the composition root at runtime; new lint warning root_dimensions_mismatch (#4005, fixes #4001) - render_complete telemetry adds composition_element_tags, aroll_video_count, heygen_video_count; render_error adds error_name and failed_stage_code (#4004) - Catalog docs "Source" accordion titles are plain strings, so docs search no longer shows [object Object] (#4006) Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com> |
||
|
|
3704863d96 |
feat(telemetry): per-tag, provenance, and failure-taxonomy properties on render events (#4004)
* feat(telemetry): add per-tag element and a-roll counts to render_complete
countElementTags's single regex scan (the ~83% of renders that get no
live probe session) is extended into scanElementTags, which returns a
per-tag breakdown and an a-roll video count alongside the same total,
all from one pass over the same script/style-stripped markup — the
total is the sum of the per-tag map by construction, so the two
properties can't drift apart. countElementTags itself is removed; it
had zero production callers after this change.
New render_complete properties: composition_element_tags ({tag: count},
raw lowercase tag names, capped at the top 50 by count with the rest
folded into an "other" key) and aroll_video_count (<video
data-aroll="true"> elements from the same scan). Both are static-scan-
only, mirroring the existing composition_element_count_source split —
the live probe-DOM path never runs this scan.
Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
* feat(telemetry): count HeyGen-provenance video from the same tag scan
heygenVideoCount joins arollVideoCount/byTag as a third property
derived from scanElementTags's single pass: <video
data-media-source="heygen"> elements. media-use's resolve.md now
documents that the skill stamps this attribute on a mounted video only
when the ledger record's provider is "heygen.video" (the mount itself
is always the agent's own composition edit, never written by resolve
scripts) — the ledger stays the single source of truth for provenance,
the HTML attribute is a projection of it, and no other provider value
is invented beyond this one signal.
Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
* feat(telemetry): add a bucketable failure taxonomy to render_error
error_name (error.name, e.g. one of the ~20 typed producer error
classes) and failed_stage_code (job.currentStage/failedStage
normalized to a stable snake_case code via normalizeStageCode)
join the existing free-text error_message/failed_stage, so a fleet
query can bucket failures without parsing prose. Error-conditional by
nature — render_complete has no equivalent, since a successful render
has no error to name or stage to blame.
Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
---------
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
|
||
|
|
eb0957a497 |
fix(core,lint): keep html/body sized to the composition root, warn when a scaffold's copies drift (#4005)
A composition whose root declares portrait data-width/data-height renders at the correct output size, but a project scaffolded landscape (init without --resolution) still ships html/body CSS and a viewport meta at the old landscape size. The stale body's overflow:hidden then clips the correctly-sized root at the old height, so everything past it renders as page background instead of real content. applyCompositionSizing now mirrors the SAME forced width/height it already computes for the root onto documentElement/body, reusing rather than recomputing them. overflow:hidden is left untouched (a deliberate anti-white-bar guard set earlier in the same init pass): once body's own size agrees with the root it contains, it clips nothing that matters. Runs in both preview and render, since both load the one bundled runtime artifact this file builds into. Adds a warning-level root_dimensions_mismatch lint rule alongside root_missing_dimensions, comparing the root's dims against the html/body CSS block and the viewport meta - the two other places a scaffold's resolution lives besides the root itself. The read-only regexes live in @hyperframes/parsers (packages/lint cannot depend on packages/cli, which owns the closest existing pattern in applyResolutionPreset) so a future caller has one place to update if the scaffold's shape changes, rather than a rule silently comparing against a guess. Closes #4001. Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com> |
||
|
|
71b274bbfd |
chore: release v0.8.42 (#3988)
* chore: release v0.8.42 - Studio preview works for projects whose path has non-Latin-1 characters (#3982) - Studio render downloads use RFC 6266 headers for non-Latin-1 filenames (#3984) - check --json reports HDR auto-promotion; render prints one warning (#3985) - Forced --sdr renders tone-map HDR sources before extraction (#3981) - Render/preview workers no longer survive as orphans after Ctrl-C or parent exit; cancellation reaches final artifact promotion; HYPERFRAMES_RENDER_DETACHED=1 for intentionally detached renders (#3974) - HDR preflight test coverage for held tails (#3963) Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com> * chore: release v0.8.42 notes — name the #3982 symptom and scope - #3982 entry: a Studio preview server crash for compositions whose file path has non-Latin-1 characters (the project directory is not involved) Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com> * chore: release v0.8.42 notes — name the #3982 symptom - #3982 entry: the uncaught ETag TypeError took the preview server down, so call it a Studio preview server crash rather than a preview failure Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com> --------- Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com> |
||
|
|
08d48ab753 |
fix(cli): guard process snapshot capture (#3974)
Co-authored-by: heygengenesis[bot] <262951085+heygengenesis[bot]@users.noreply.github.com> Co-authored-by: miguel.sierra <229591595+miguel-heygen@users.noreply.github.com> |
||
|
|
ef48485b91 |
fix(engine): tone-map HDR frames for forced SDR (#3981)
Co-authored-by: heygengenesis[bot] <262951085+heygengenesis[bot]@users.noreply.github.com> Co-authored-by: miguel.sierra <229591595+miguel-heygen@users.noreply.github.com> |
||
|
|
5d2478c6fa |
fix: report HDR auto-promotion (#3985)
Report HDR auto-promotion consistently from the render media set, including remote media, and make its regression coverage portable across supported paths. Co-authored-by: heygengenesis[bot] <262951085+heygengenesis[bot]@users.noreply.github.com> Co-authored-by: miguel.sierra <229591595+miguel-heygen@users.noreply.github.com> |
||
|
|
b599f031a9 |
fix(studio-server): make render download headers ByteString-safe (#3984)
Build RFC 6266 Content-Disposition values with an ASCII filename fallback and UTF-8 filename* parameter for each render file response. This preserves inline and attachment disposition semantics while preventing non-Latin-1 filenames from crossing the HTTP ByteString boundary. Co-authored-by: heygengenesis[bot] <262951085+heygengenesis[bot]@users.noreply.github.com> Co-authored-by: miguel.sierra <229591595+miguel-heygen@users.noreply.github.com> |
||
|
|
edb6e87ba7 |
fix(studio-server): make sub-composition ETags ByteString-safe (#3982)
Hash the decoded sub-composition path before including it in the preview ETag. This keeps ETags deterministic and path-specific while ensuring Hono receives an ASCII-safe header value for Unicode composition filenames. Add regression coverage for percent-encoded CJK sub-composition paths, including stability and distinctness assertions. Co-authored-by: heygengenesis[bot] <262951085+heygengenesis[bot]@users.noreply.github.com> Co-authored-by: miguel.sierra <229591595+miguel-heygen@users.noreply.github.com> |
||
|
|
ae18e3a327 |
test(engine): cover held tails in HDR preflight (#3963)
Add regression coverage for finite SDR video slots that start past EOF in mixed HDR timelines, and verify lint exclusions with successful local duration probes. Co-authored-by: heygengenesis[bot] <262951085+heygengenesis[bot]@users.noreply.github.com> Co-authored-by: miguel.sierra <229591595+miguel-heygen@users.noreply.github.com> |
||
|
|
edf6e4b373 | chore: release v0.8.41 (#3966) | ||
|
|
f3bd4234f5 |
fix(engine): hold finite video slots past EOF (#3962)
Allow explicit finite non-looping video slots to use the existing held-final-frame extraction path when data-media-start is at or beyond source duration. Warn during project lint for local static slots that will use this behavior. Co-authored-by: heygengenesis[bot] <262951085+heygengenesis[bot]@users.noreply.github.com> Co-authored-by: miguel.sierra <229591595+miguel-heygen@users.noreply.github.com> |
||
|
|
cccc78c65e |
fix(studio): fail closed on backup failures (#3961)
Fail closed Studio-server mutations when a backup snapshot reports an error. Return the existing backup-failed HTTP 500 response before any raw or structured mutation can change the original. Co-authored-by: heygengenesis[bot] <262951085+heygengenesis[bot]@users.noreply.github.com> Co-authored-by: miguel.sierra <229591595+miguel-heygen@users.noreply.github.com> |
||
|
|
e2d60cf27c |
fix(engine): keep a render's dependent extraction-cache entry alive during long captures (#3913)
The shared, size-capped video-frame extraction cache evicts entries whose LRU-clock sentinel hasn't been touched in over an hour. That clock was set once, at cache-hit lookup time, and never again — so a render holding a compiled-dir symlink (or a still-open partial writer dir) into an entry for longer than an hour could lose it to a concurrent render's GC sweep on the same shared machine, leaving its frame reads a dangling ENOENT with no retry. Every active video's captured frame now renews its cache entry's clock, throttled to once per 5 minutes per directory. touchCacheDir renews both signals gcExtractionCache actually reads: the directory's own mtime (which gates a still-open partial writer dir) and the completion sentinel's mtime (which gates a published entry). Co-authored-by: Miguel Angel <miguel.sierra@heygen.com> |
||
|
|
f98b8ead37 |
fix(studio): guard import.meta.env reads so the player survives non-Vite ESM hosts (#3948)
In non-Vite ESM hosts, import.meta.env is undefined and direct reads crash at module load. Guard both player reads with optional chaining. Existing Vite defaults are unchanged. Reject future unguarded import.meta.env property access in either module. — tai Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
8886743f44 |
fix(core): tell an opaque document how to actually get its Web Audio back (#3940)
The opaque-origin fix in #3937 routed correctly but sent the diagnostic down a dead end. An opaque document reached the same `cross_origin_no_cors` verdict a genuinely foreign asset gets, so the message told the author to "proxy or download the asset to a same-origin URL". That is impossible advice, because nothing is same-origin with an opaque origin and the asset is usually same-origin by URL already. Split the verdict into two reasons and give each its own note and remedy. The opaque case now names the two fixes that work: `allow-same-origin` on the sandbox, or `crossorigin` plus an `Access-Control-Allow-Origin` of `null` or `*`. Opacity wins when both conditions hold, since it is the blocking one and the one an author cannot fix by moving the asset. The CLI scraper matches on the code prefix only (checkBrowser.ts:309), so the new reason value does not touch that contract. - Rames |
||
|
|
a0a6244be7 |
fix(producer): retry capture on a CDP Page.captureScreenshot refusal (#3892)
Chromium's CDP layer can refuse a screenshot capture outright ("Protocol
error (Page.captureScreenshot): Unable to capture screenshot") with no
timeout wording at all. classifyCaptureFailure() didn't recognize the
message, so it fell through to the fatal "authoring" bucket and neither
capture-retry path (the disk-capture retry loop, or the streaming-capture
pinned-fallback retry) ever attempted a retry — including under
--low-memory-mode, whose single-worker plain-screenshot capture never
engages either path's other retry conditions.
Adds the literal error text to classifyCaptureFailure()'s transient_browser
bucket, and threads the resulting classification into
shouldRetryViaPinnedFallback() as a routing-independent retry condition, so
the streaming path recovers on a fresh session the same way the disk path
already does for other transient browser failures.
Co-authored-by: Miguel Angel <miguel.sierra@heygen.com>
|
||
|
|
cfe5dcfad3 | chore: release v0.8.40 (#3938) | ||
|
|
293829a2c1 |
fix(core): route media through Web Audio only when the document can actually read it (#3937)
isCorsSilenced() compared a media URL's origin against window.location.origin, which stays URL-shaped even inside a sandboxed iframe without allow-same-origin. Inside such a frame the document's actual security origin (window.origin) is the string "null", so same-URL-origin media without a crossorigin attribute was waved through createMediaElementSource, and per the Web Audio spec's MediaElementAudioSourceNode security section that node outputs silence without throwing. The composition kept playing with the timeline advancing and no audio at all. Compare against window.origin instead, treating "null" as never same-origin, so an opaque document falls back to the existing decode-only path unless the media element opts in with crossorigin. |
||
|
|
d13a89b670 | chore: release v0.8.39 (#3936) | ||
|
|
f6041d7597 |
fix(lint): preserve opening-tag source bounds after multiline closes (#3917)
Capture explicit source name bounds before attributes are parsed. Preserve malformed names, Unicode lowercase expansion, and existing implied-tag ranges while retaining malformed-attribute lint failures. |
||
|
|
dc6d78b462 |
fix(cli): assign w{index} ids when loading JSON transcripts (#3770)
loadTranscript assigns id: w{index} on the srt/vtt branches but never on
the JSON branches: parseWhisperCpp and parseOpenAI drop the field and the
words-json branch defaults it to "". Every engine funnels through
loadTranscript, and transcribeAudio rewrites transcript.json from its
output, so CLI-produced transcripts ship without the stable word ids that
transcribe.md documents for caption overrides — per-word overrides have
nothing to key on.
Assign id: w.id || `w{index}` across the JSON branches, matching the
srt/vtt behavior. || also repairs the empty-string ids older CLIs wrote
to words-json files, which otherwise collapse every word onto one key.
Signed-off-by: Santhi Prakash <b.santhiprakash@gmail.com>
|
||
|
|
10e8447ac0 | chore: release v0.8.38 (#3929) | ||
|
|
f059a6eb53 |
feat(cli): default BeginFrame, agent blank, and looks quality (#3927)
* feat(cli): render summary names the capture path, gpu mode and stage timings * fix(lint): drop gsap_exit_missing_hard_kill, its hazard does not reproduce on cold workers * fix(cli): render slow-path hint fires only when the gpu was auto-probed * test(cli): isolate agent hint suite from ambient agent env keys The suite runs inside agent sessions. Ambient env keys fill the 16-key hint cap and fail assertions that a plain shell would pass. * docs(music-to-video): drop leftover hard-kill mandate after lint rule removal Frame workers still required a tl.set hard-kill that the dropped lint rule no longer enforces. Align the self-check with montage.md. * refactor(cli): share hint-key pattern and collapse render summary args Export HINT_KEY_PATTERN for the test isolation strip. Read duration and frame count from the perf summary instead of passing them beside it. * test(cli): lock capture-mode preference and 16-key hint isolation Session mode, including drawelement, must print over observability. The hint suite now fails if ambient keys already fill the 16-key cap. * feat(cli): default BeginFrame, agent blank, and looks quality Local auto render opts out of the software-GPU screenshot clamp so BeginFrame can run. init --agent scaffolds a centered Inter blank with no prompts. --quality looks (now the default) is CRF 16; delivery is high. * fix(cli): honor screenshot env and teach agent pitfalls on the happy path Local auto still requests BeginFrame unless PRODUCER_FORCE_SCREENSHOT=true. The agent blank fills its canvas so --resolution can resize it. Skills now name the four pitfalls and tell agents to read the render summary line. * feat(cli): default init to the centered blank Bare init scaffolds the centered Inter stage. --agent is a hidden alias. * fix(cli): pack from-file and use it for video init TTY --video with no --example selects from-file. Pack copies that template. * fix(cli): wire --video onto from-file even with --example blank One resolver picks the scaffold. Spawned init --video asserts a-roll src. * fix(cli): narrow looks CRF on the quality alias Only looks carries crf. Typecheck failed on the union without a guard. * style(cli): shorten render-pipeline summary comment |
||
|
|
95bea1631f |
refactor(skills): keep hyperframes-core as the HTML contract (#3928)
* refactor(skills): keep hyperframes-core as the HTML contract Move brief, storyboard, review, production, dispatch, and frame-worker docs to hyperframes. Slim remaining core references. * fix(skills): restore camera recipes and root sizing Restore Zoom, Ken Burns, crop, and clip-path recipes. #root is 100 percent. * fix(skills): stamp size on the composition root Runtime sizes the composition root, not html/body. Overlap pin requires is valid. |
||
|
|
7a5d2bf769 | chore: release v0.8.37 | ||
|
|
cd975d8c28 |
fix(core/bundler): inline fonts and images so a lone bundle renders (#3590)
`bundleToSingleHtml` documented itself as producing "a single self-contained HTML file", but `INLINE_MIME` covered only `.svg`, `.json`, `.txt`, `.cube` and `.xml`. Every font and raster image stayed a live project-relative reference. That is invisible to every consumer in this repo, because each one serves the bundled string from a server rooted at the project directory, so the relative paths resolve. It breaks the moment the bundle is stored on its own, with no sibling asset directory: the font 404s and the page silently reflows in a fallback face, which is worse than a visible failure. Widen the inline set to fonts (woff2/woff/ttf/otf) and raster images (png/jpg/jpeg/gif/webp/avif), behind a 2 MiB per-asset cap. The cap is measured against this repo's own assets rather than guessed: the largest of 164 tracked `.woff2` files is 105 KB, and the largest of 284 tracked raster images is 2.00 MB, so everything in-tree inlines while a video-sized file cannot. Oversized assets keep their relative URL and warn, reusing the existing "may not be self-contained" wording. Audio and video stay external on purpose: they are large, streamed rather than laid out, and their absence is obvious rather than silent. Scripts already had a better path (`script[src]` is folded in as source), so they are deliberately not added to the MIME table. The five rebasing tests that asserted a relative path survived now assert the data URL's decoded content instead. That is a stronger check: resolving from the wrong base directory finds no file, so nothing inlines and the assertion fails. |
||
|
|
4c26ffe8ce |
fix(render): keep verification fallback storage-aware (#3703)
* fix(render): keep verification fallback storage-aware * fix(render): honour storage-aware verification fallback for every routing kind The disk-headroom flag was computed by the orchestrator for both worker_inversion and parallel_router routings but only honoured by replanAfterFailure under worker_inversion, so the default-on parallel_router path still reverted to an sdr_disk plan that the 90% gate would reject and assertDiskCaptureHeadroom aborted the render. Give the predicate a single owner: drawElementVerificationFailure decides when headroom can change the fallback (non-default routing, sdr_disk preferred fallback, off-disk memory-exhaustion fallback) and consults the shared inspector only then. replanAfterFailure now honours the resulting flag for any routing kind, and the orchestrator merges the two revert log branches keyed on that flag instead of on plan kind, which also stops OOM retries from being logged as headroom diversions. inspectDiskCaptureHeadroom returns a discriminated union so the assert no longer needs a redundant null guard to narrow freeBytes. Tests: parallel_router headroom diversion and restoration, the failure constructor across default / streaming-only / disk-only routings, and an exact-once statfs count. Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com> * test(render): prove non-verification capture failures skip the disk inspector Extract the streaming drain's failure construction into `streamingCaptureFailure` so the retry classification is unit-testable, and assert that a drawElement capture failure or OOM on a routed plan never calls the disk headroom inspector — only a self-verification failure may, via `drawElementVerificationFailure`. Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com> --------- Co-authored-by: miga-heygen <miguel.sierra_miga@heygen.com> |
||
|
|
fdf9ffac95 | fix(engine): preserve nested sibling video layout (#3639) | ||
|
|
1acc65dbf8 |
fix(core): un-hide a later root-level clip instead of leaving it display:none forever (#3893)
A root-level `[data-start]` clip with no authored `position` starts out `position: static` until the runtime forces it to `position: absolute`. A visibility pass over the clip while it's still inactive can observe the pre-forcing `static` value and cache it as in-flow, which correctly hides the clip with `display: none`. But by the time the clip later becomes active, that same cache has been invalidated and recomputed against the now-forced `absolute` position — so the un-hide check, which re-derived the same cached fact instead of tracking what it had actually done, saw "not in-flow" and skipped the removal. The clip stayed `display: none` (and therefore zero-sized) for the rest of the render. Tracks whether this code applied `display: none` to a given element in its own WeakSet instead of re-deriving the in-flow cache, so the un-hide check can no longer disagree with the hide check that set it. Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com> |
||
|
|
58707afaa0 | fix(producer): reserve AAC correction headroom (#3693) | ||
|
|
f86aae655a | chore: release v0.8.36 (#3911) | ||
|
|
4c6fa9a790 |
fix(studio): the preview stays paused, survives typing, and opens without a re-encode (#3910)
* chore(studio): remove the write-only self-write timestamp ref
`domEditSaveTimestampRef` was assigned at 26 sites across 54 files and read
nowhere. It used to feed a 2 s "suppress the watcher reload after our own
write" window; that mechanism was replaced by content-hash identity in
sdkSelfWriteRegistry, whose header still says why (a clock cannot tell an
SDK self-write echo from an undo landing in the same window). The reader
went with that change, the writers did not, and three comments kept
describing the timestamp as protection that no longer existed.
Pure deletion: the ref, every prop and parameter that threaded it, every
assignment, the five comments citing it, and the orphaned test helpers.
No behaviour change; the studio suite, typecheck, lint and format are
green.
Not removed: the __hfSuppressSceneMutations wrapper in gsapSoftReload.ts.
It looked undefined from inside the studio package, but shader-transitions
installs it on the preview window (hyper-shader.ts) so soft reloads do not
invalidate cached transitions. It is live.
* docs(studio): plan for loading the preview during the shell's first layout
* fix(runtime): a paused preview stays still after any seek
Sub-compositions kept animating while the transport was paused and the
Studio button showed play. A render-seek unpauses every sibling timeline so
GSAP propagates the root's totalTime into them, and nothing paused them
again; parented to the global ticker, they free-ran at 1x while the master
timeline and the clock stayed stopped. The Studio reaches that seek path
through its seek-driven fallback adapter right after a preview reload,
which is why editing text was what set it off. Captured live in the user's
preview: seven child timelines advancing 0.210s per 200ms sample, all
gsapPaused=false, transport not playing.
Three fixes under one invariant: while the clock is paused nothing runs,
and the button never disagrees with the runtime.
- The sibling rearm is a lease, not a resting state: seekTimelineAndAdapters
now returns every timeline it unpaused to paused in a finally. The second
rearm after the child re-seek only ever changed the leaked state and is
gone, with the comment that justified the leak by describing one caller.
- The paused side of the transport tick policed nothing; it now stops any
timed media element found running. A parked transport runs no ticks, so a
capture-phase play listener wakes it.
- Studio's canvas click resumed by setting the store flag alone; it now
requests playback through the player so adapter, rAF loop and flag agree.
Regression tests fail without each of the three changes.
* perf(studio-server): stop re-encoding videos the browser already plays on open
Opening a project kicked off a background transcode for every asset whose
codec some browser might not decode. VP9 is on that list for Safari's sake,
so a Chrome user opening a project with a VP9 avatar paid a ~10s, ~60
CPU-second re-encode across six cores on every open, concurrently with the
browser's first layout, for an output nothing ever requested: across four
recorded sessions the browser asked for the proxy zero times.
One predicate was doing two jobs. "Could some browser fail on this" is a
property of the asset and decides what gets injected into the page;
"will this client request the substitute" decides whether to spend CPU
before being asked. The codec table now carries an explicit prewarm flag:
HEVC and ProRes (no browser decodes them) still warm; VP9 and AV1 are
injected but transcoded lazily by the existing ?hf-proxy= request, which
already reports failure as a 502.
Pre-warms requested and proxies served are now counted on the existing
structured-stderr telemetry channel, because a speculative job with a 0%
hit rate emits no error and had been invisible. A single lookup helper
also closes a prototype-key gap where one path used Object.hasOwn and the
other a bare index.
Regression test: a VP9 and an HEVC asset in one composition yield exactly
one pre-warm, for the HEVC. Fails on the previous gate.
* perf(cli): serve the studio bundle compressed and cache hashed assets
The 4.1MB studio bundle was served uncompressed with Cache-Control:
no-store, so every open of the studio re-downloaded and re-parsed it. Vite
names built assets with an 8-character content hash, so their bytes can
never change: those now ship gzip-compressed (1.25MB on the wire,
byte-identical after inflation) with a one-year immutable cache policy.
Unhashed files under public/ keep no-store, and the HTML shell, which
previously sent no Cache-Control at all, now sends no-store explicitly:
it is the only thing that names the current hashed bundle, and it must
keep revalidating for the immutable policy to be safe.
The hash test only accepts the segment after the last hyphen and requires
a digit, underscore or capital, so an ordinary hyphenated name like
user-Guide-v2.js is not mistaken for a hash and served forever.
* perf(cli): warm the preview route before the browser opens
The browser's first request for a project's preview paid the server's cold
compiler import and first bundle in the request path, after the studio
shell's own first-layout stall, so both costs landed on the user's
time-to-first-frame in series. The CLI now issues one fire-and-forget
request for the preview route from openStudioBrowser, the single funnel
for every launch path, before the --no-open early return so pasted URLs
benefit too. Measured on the demo project: first client request 231ms
before, 101ms after; the ETag 304 path afterwards is under 1ms.
The fetch carries a 10s abort so an unsettled connection cannot keep an
otherwise-finished CLI process alive, matching the package's existing
convention. The shader query params the player appends are not part of
the route's cache key, so the plain route URL warms the same entry.
* perf(studio): request the player chunk before the shell's first layout
The dynamic import of @hyperframes/player ran inside the preview's mount
effect, which React schedules after the shell's first layout. On a cold
open that layout stalls the main thread for seconds, so the chunk request
waited behind it for no reason. The import is now kicked at module scope,
behind a typeof window guard that preserves the documented SSR contract
(the module registers a custom element at load), and the mount effect
awaits the already-in-flight promise. Verified in the built bundle: the
preload call sits at module top level, so the request goes out on bundle
evaluation.
* fix(studio): editing a text property no longer reloads the preview
Every keystroke in the Design panel writes the composition file, the file
watcher announces the change to the studio, and the studio decides whether
the change was its own. Over the CLI's event stream that decision has
never worked: the browser hands the handler a MessageEvent whose data is
a JSON string, and three of the four payload readers (version, write
token, content) only understood an already-parsed object, so they read
every field as absent. An absent token means "someone else edited the
file", and the studio hard-reloaded the preview iframe on its own edit,
blanking it for seconds. The path reader alone knew how to unwrap the
string, which is why the event was recognised well enough to reload and
never well enough to suppress.
The envelope is now decoded once, at the boundary, by one function that
all three transports feed; the readers share one field accessor so they
cannot diverge again. The production event-stream rung is extracted into
an exported channel so a test can drive a real MessageEvent through the
listener it registers, which was impossible before because vitest defines
import.meta.hot and the selection never reached that rung under test.
A second, smaller cause: the server attached the write receipt to the
first subscriber only and deleted it on read, so any other listener saw
an unlabelled change. Reads are now non-destructive with the TTL as the
only eviction, scanning newest-first because identical bytes written
twice inside the TTL (undo, retyping a value) share a version and the
older token was already spent. The file version now ships with every
event, receipt or not, so duplicate deliveries of one change dedupe
instead of reloading once each.
shouldReloadSdkSession had no production callers and a signature that
invited an undecoded delivery straight back into this bug; it is removed.
consumeFileWriteReceipt stays as a deprecated alias for one release.
Regression tests: a Studio write delivered as a real SSE MessageEvent is
suppressed; two subscribers of one watcher event reload once; a genuinely
external write still reloads; a repeat of earlier bytes gets the newest
token; a receipt past the TTL is not recognised. Each fails on the code
before it.
* fix(runtime): paused-time media playback is borrowed, not banned
The paused-side enforcement added in the previous commit had no notion of
provenance, so it stopped two features that legitimately play media while the
transport clock is paused. Both were deterministic, not racy: the capture-phase
`play` listener means the very play() that starts them wakes the transport that
stops them.
- The colour-grading preview (colorGrading.ts startPreviewPlayback) plays a
video while paused to render grading previews. It went dark on the first
paused tick.
- The Studio's scrub audition (timelineIframeHelpers.ts applyScrub) plays the
music track for ~140 ms while paused so a playhead drag is audible. Same path
killed it.
A runtime-owned lease fixes both without weakening the enforcement. One owner: a
WeakSet in the runtime closure, with lease/release published on the existing
window.__hf surface for the Studio, which reaches the element across the iframe
boundary and cannot call into the closure. The grading runtime is constructed
with the pair directly. Both the cheap probe and the sync path skip leased
elements while the clock is paused; during playback the transport owns everything
again. Anything that plays while paused without a lease is, by definition, the
defect the enforcement exists for, and is still stopped.
Two corrections to the previous commit's reasoning:
The old leak broke the render path too, deterministically, not only the preview.
packages/producer/src/services/fileServer.ts:375-379 seekToTime flushes the
virtualized rAF queue, and GSAP's global ticker with it, after renderSeek and
before the frame screenshot (fileServer.ts:657-664, hf.seek). A sibling left
unpaused therefore advanced by the full inter-frame delta into the captured
frame. The finally added in the previous commit fixes that as well.
Deleting the second activateSiblingTimelines is safe because nothing between
frames reads a sibling's paused(), verified by grep over the deterministic
adapters, syncTimedElementVisibility, the hf-timelines-built handler and
__hfReseekGpu. Not merely because seekStandaloneRegisteredTimelines pauses each
child. The code comment now gives that reason.
Tests, each proven non-vacuous by reverting the piece it guards:
- a leased element survives repeated paused ticks and is stopped once released
- the colour-grading preview survives, through the real init wiring
- the scrub borrows the element and gives it back on stop
- the existing test that an unleased element is still stopped keeps passing
* test(runtime): a lease ends when the transport plays or the borrower stops
Two assertions the review found not load-bearing. Dropping the isPlaying
branch so leased media stayed exempt during playback left every test
green; a leased out-of-window clip is now asserted stopped the moment the
transport plays. Deleting the release in the grading stop closure also
left the suite green, because the pause on the next line satisfied the
assertion; a restart after stop is now asserted stopped, which only the
release makes true.
* test(studio): the event-stream channel must open /api/events
* fix(studio-server): stop printing a proxy diagnostic line per clip on every render
Review of the pre-warm commit found the diagnostic louder than the thing it
diagnoses, and the two counters measuring different things under one name.
The per-asset `prewarm_requested` line is now behind
HYPERFRAMES_DEBUG_MEDIA_PROXY, matching isGpuProbeDebugEnabled in
packages/engine/src/utils/gpuEncoder.ts. A composition with fifty hostile
clips printed fifty JSON lines into a clack-formatted terminal on every
re-render. One summary line is written at process exit instead, using the
same process.on("exit") shutdown hook as packages/cli/src/cli.ts.
The counters now share a unit. prewarmsRequested counts per asset per render;
proxyRequests counted every HTTP request, including 304s. It now increments
once per resolveProxy call, after the ETag shortcut, so a revalidated repeat
no longer reads as fresh demand. An unconditional Range refill still counts,
and the docstring says so rather than claiming otherwise.
The HEVC justification was false on macOS Chrome, which answers canPlayType
for hvc1 with "probably" and keeps the source, so the pre-warm is redeemed
there only through the reactive zero-videoWidth path. prewarm stays true
because Chrome on Windows/Linux and Firefox do need the substitute; the
comment and test names now say "no cross-platform decode" instead of
"browsers never decode it".
Two test gaps closed. Nulling vp9's representativeMime left the suite green
while making the client skip canPlayType and proxy on every browser, which
would reinstate exactly the transcodes this work removed; the mimes are now
pinned. The Object.prototype test passed with the hasOwn guard deleted, so it
now goes through probeAssetCodec, the input that actually misbehaves without
it. Both fail when the change is reverted.
* perf(cli): stop gzipping the studio bundle on loopback
Compression made the cold open slower on the only transport this server has.
It binds 127.0.0.1 with no --host, and measured there the six bundle assets
took 69.8 ms with gzip against 7.9 ms raw; the 4.1 MB chunk alone was 49.4 ms
against 3.1 ms. hono/compress is removed, which also retires the Vary header
question it raised. If remote serving ever matters, compress at build time
rather than per request.
The cache policy is now decided by route instead of by filename. Reading a
content hash out of a name cannot work: rollup's alphabet is base64url and
includes a hyphen, so roughly one hashed file in ten was misread as unhashed,
and the immutable header also leaked onto hand-authored public/ files served
by the same handler. packages/studio/vite.config.ts sets neither
build.assetsDir nor publicDir, so dist/assets holds only rollup's hashed
emits and every public/ file lands at the dist root. /assets/* is therefore
immutable and /icons/* and /favicon.svg keep revalidating, with no heuristic
in between.
The shell is no-cache rather than no-store. It carries no ETag, so both force
the same full refetch, but no-store puts the document on Chrome's bfcache
blocklist: leaving Studio and pressing Back would cold-boot the app instead
of restoring it.
* docs(runtime): say what the paused-media probe actually filters
* revert(cli): drop the preview prewarm that held the CLI event loop
The fire-and-forget warm added in
|
||
|
|
4ba8396c27 |
chore: release v0.8.35
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |