Commit Graph
991 Commits
Author SHA1 Message Date
Miguel Ángel f6de05efec chore: release v0.8.17 2026-08-28 00:35:58 +00:00
miga-heygenandMiguel Ángel 4d87f8bbae fix(producer): enforce video extraction failures by default (#3372) (#3526)
The extraction failure policy defaulted to "off", silently swallowing
per-source errors. The plumbing to surface them (typed error, retryable
classification, caller throw) was fully built but gated behind an
env-var opt-in. Flip the default to "enforce" so extraction failures
fail the render instead of producing misleading coverage aborts.

Set HF_VIDEO_EXTRACTION_FAILURE_MODE=off to restore the old behavior.

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-08-28 00:31:37 +00:00
Val 9eb84c91b6 fix(core,producer): stamp render ids on empty-src media and pair the snapshot by them (#3513)
Residual of #3340: runtime-assigned src is skipped by the static parse, so
the browser snapshot was still keying clips by author id. Colliding scenes
collapsed onto one window.
2026-08-28 00:26:08 +00:00
miga-heygenandClaude Opus 4.6 e69be30e98 fix(engine): fail render on sub-composition script failures (#3352) (#3528)
When a composition script throws during execution, the GSAP timeline
registration never arrives and pollSubCompositionTimelines times out.
Previously the render continued with a degenerate 2-frame output and
reported success — now it fails loudly.

Two changes:
1. Detect composition script runtime errors in the browser console
   handler and feed them into scriptLoadFailures, triggering the
   existing fail-fast path (same as script load 404s).
2. Make sub_timeline_script_failure a fatal warning in
   applyRenderWarningPolicy, alongside audio_processing_failed.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-08-28 00:21:34 +00:00
miga-heygenandSanthi Prakash 05275c1e8c fix(producer): assert render artifact duration and frame count before commit (#3506)
* fix(producer): assert render artifact duration and frame count before commit

Refuse to publish an artifact that is significantly shorter or has fewer frames

than the capture pipeline just reported. Adds a duration/frame-count gate on top

of the existing readable-non-empty check inside ArtifactTransaction.validate(),

keyed off the values the orchestrator already carries. Closes #3395.

* fix(producer): wire ffprobe frame count into the artifact duration probe

The frame-count gate added in #3395 accepts an expectedFrames value from
the orchestrator, but defaultArtifactDurationProbe was still returning
only durationSeconds - so the wire was half-built and the assertion
short-circuited on undefined for every real render. Forward meta.frames
from ffprobe so the field-packet case the issue names (container duration
correct, stream shorter) is actually caught by the frame-count check,
not just the duration one.

extractMediaMetadata now populates a new frames field from the video
stream's nb_frames tag, returning undefined when the demuxer did not
report one (fragmented MP4, malformed streams, muxes that require
-count_packets). Callers that gate on the count must treat undefined as
no answer; the assertion already does.

The previous CI run (#32589981916) cancelled shard-6 at the 1h job
timeout after bun install failed to extract the aws-cdk-lib tarball
mid-Docker-build - a cache flake, not a code regression. Pushing a
follow-up commit retriggers CI against the now-populated cache layer;
the regression should clear without further code changes.

---------

Co-authored-by: Santhi Prakash <b.santhiprakash@gmail.com>
2026-08-28 00:19:27 +00:00
Miguel Ángel 720ff5ac9c chore: release v0.8.16 2026-08-27 01:32:37 +00:00
ee64c3b116 fix(engine): stop destroying the AAC priming edit list when muxing (#3505)
`muxVideoWithAudio` passed `-avoid_negative_ts make_zero` unless the caller
set `preserveAudioPrimingEditList`. In practice the dominant path is an AAC
sidecar copied into mp4, where that flag is actively harmful: ffmpeg's
default is `auto`, which the mp4/mov muxers (AVFMT_TS_NEGATIVE) already
resolve to `disabled`. Forcing `make_zero` overrides the correct default,
discards the priming edit list the sidecar encode created, shifts the video
start_time forward by one AAC frame and writes an empty video edit at t=0 —
which edit-list-honoring players (QuickTime/Safari) render as a black first
frame.

Verified with ffprobe on a copy mux of a 30fps h264 mp4 and an AAC sidecar:

  with `make_zero`   video start_time 0.066000, elst: [media time -1,
                     dur 5940] + [media time 6000, dur 180000]
                     audio start_time 0.042993, elst: [media time -1, ...]
  without (this fix) video start_time 0.000000, elst: [media time 6000,
                     dur 180000]
                     audio start_time 0.000000, elst: [media time 1024, ...]

The empty leading edit and the offset both disappear, and the audio keeps
its 1024-sample priming edit.

The flag is now never passed for a mux, in any mode. `preserveAudioPrimingEditList`
is part of the exported engine API, so it stays on `MuxVideoWithAudioOptions`
as `@deprecated` and no-op rather than being removed; the two internal callers
that set it (`assembleStage`, distributed `assemble`) drop it.

`buildEncoderArgs` and `streamingEncoder` still pass the flag for video-only
output and are deliberately left alone — those chunks are consumed as
intermediates, not as a delivered mp4/mov.

Fixes #3487

Co-authored-by: Alexandru Mincu <alex@mountsoftware.ro>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 20:38:11 +00:00
Val 97991bbd35 fix(engine,producer,lint): resolve <source> children for media extract and localize (#3238)
Parent src-only scans skipped multi-format <video>/<audio> markup, so those
elements were never extracted, downloaded, or mixed and rendered blank/silent.
Lint now accepts a child <source src> as a resolvable media src.
2026-08-26 20:17:12 +00:00
Miguel Ángel c9f43ebcfb fix(engine): preserve source frame identity above 99,999 (#3503)
* fix(engine): preserve extracted frame identity

* fix(producer): order legacy distributed frames numerically
2026-08-26 12:30:49 -04:00
Val f52ec1c25f fix(producer,core): honor relative data-start id-refs in render media scheduling (#3252)
compileTimingAttrs/injectDurations used parseFloat, so data-start="intro"
wrote a NaN data-end and extract preferred that over duration; parseNumeric
now skips the id-ref (parseVideoElements already resolves it).

collectRenderMedia's resolveHostWindow likewise read host data-start with
parseFloat, so chained sub-composition slots (data-start="hook") stacked at
0-2s and every scene after the first rendered black. It now resolves host
starts through the shared resolveReferencedStart, matching the media parsers.

Fixes #3361.
2026-08-26 05:36:24 +00:00
Miguel Ángel 740f7ead89 chore: release v0.8.15 2026-08-26 03:23:41 +00:00
Miguel Ángel 7c40efbc62 fix(fonts): harden localizer release diagnostics 2026-08-26 03:02:24 +00:00
Miguel Ángel ec68d40cc6 fix(cli): keep font localizer process ownership explicit 2026-08-26 01:11:38 +00:00
Miguel Ángel 744146eb6e fix(fonts): cover rendered case variants in subsets 2026-08-26 01:11:38 +00:00
Miguel Ángel 81069fe47f chore: release v0.8.14 (#3474) 2026-08-24 20:03:19 -04:00
Vance Ingalls 3ed971d018 chore: release v0.8.13 2026-08-24 12:51:02 -07:00
Vance Ingalls 2ca578f945 chore: release v0.8.12 (#3457) 2026-08-23 19:54:55 -07:00
Miguel Ángel 32d58a73e3 chore: release v0.8.11 (#3440) 2026-08-23 14:49:13 -04:00
Miguel Ángel 65b2299db2 fix(engine): preserve static dedup across caption runs (#3438)
* fix(engine): preserve authored clip boundaries after normalization

* perf(engine): bound static verification work across caption runs

* fix(core): preserve explicit nonpositive timeline windows
2026-08-23 13:18:18 -04:00
Miguel Ángel 59a69a145b chore: release v0.8.10 (#3426) 2026-08-22 11:16:32 -04:00
Vance Ingalls f6e8e8ddfd chore: release v0.8.9 (#3422) 2026-08-22 05:57:57 -07:00
Santhi Prakash 718bf5ef32 fix(producer,cli): surface every tried manifest path in the missing-manifest error (#3370) (#3387)
Closes #3370

## What

When `hyperframeRuntimeLoader` could not locate `hyperframe.manifest.json`, the loader reported a single fallback path that was never searched for (`/usr/local/lib/core/dist/hyperframe.manifest.json`). Inside a Docker render the user is then told to look at the wrong directory; the file that was actually missing (`/usr/local/lib/node_modules/hyperframes/dist/hyperframe.manifest.json`) was nowhere in the message.

## Why

`resolveHyperframeManifestPath()` built a 5-element `candidates` array, walked it with `existsSync`, and on total miss returned the last candidate. The error then quoted that candidate verbatim. The reporter even shows the exact reproducing command from a published image.

A second issue rode the same failure path: `packages/cli/src/commands/render.ts:902` keeps attaching the hint `"Try --docker for containerized rendering"` to users who are *already inside* the container. The container sets `ENV CONTAINER=true` and nothing reads it.

A third small thing came along: `CWD_RELATIVE_MANIFEST_PATHS[0]` was a byte-identical duplicate of `SIBLING_MANIFEST_PATH` — same path, two names.

## How

1. Hoist the candidate list to a single `MANIFEST_CANDIDATES` owner in `hyperframeRuntimeLoader.ts` and share it between the resolver and the error reporter. De-duplicate while doing it.
2. Add `triedManifestPaths()` as a tiny export so callers (and tests) can see what was actually searched.
3. Replace the source-text regex test that asserted on string positions inside `const candidates = [...]` with a behaviour test that points `PRODUCER_HYPERFRAME_MANIFEST_PATH` at a missing file and asserts the thrown error names it. Also exercise the no-override branch to confirm the sibling path is the first entry.
4. In `render.ts`, check `process.env.CONTAINER === "true"` before attaching the `--docker` hint. The chrome-launch and macos-old-chrome remediation branches already short-circuit before the hint, so an empty string is a safe value when the user is in the container.

## Test plan

- [x] `bunx vitest run src/services/hyperframeRuntimeLoader.test.ts` — 7/7 pass (`hyperframeRuntimeLoader error path (#3370)` describe covers the missing-manifest message and the tried-paths export).
- [x] `bunx tsc --noEmit` in `packages/producer` and `packages/cli` — clean.
- [x] `bunx oxfmt --check` and `bunx oxlint` on the touched files — clean.
- [x] `bunx fallow audit --base origin/main` — no new findings on the touched files.
- [x] Targeted producer unit lane: `node scripts/run-test-lane.mjs unit` — same 7 pre-existing failures as `origin/main` before the change (htmlCompiler.parity, audioPadTrim.integration); no regressions introduced.

Files touched:
- `packages/producer/src/services/hyperframeRuntimeLoader.ts`
- `packages/producer/src/services/hyperframeRuntimeLoader.test.ts`
- `packages/cli/src/commands/render.ts`
2026-08-22 02:09:11 -04:00
Vance Ingalls 6f82acf50c chore: release v0.8.8 (#3411) 2026-08-21 19:04:29 -07:00
Miguel Ángel e1191edba6 fix(producer): anchor local-font embedding to its url() occurrence (#3405)
The embed step rewrote the compiled document with
result.replaceAll(localPath, dataUri) — a bare substring replace with no
surrounding syntax. That also rewrites the path anywhere else it appears,
including inside a LONGER url whose tail happens to match, producing a
corrupted value like url("file:///abs/data:font/woff2;base64,...").

Any two paths where one is a suffix of the other collide the same way;
img/logo.ttf and assets/img/logo.ttf are enough. Every sibling rewrite in
this file already anchors on url(...), so this one was the outlier.

Also add file: to LOCAL_FONTFACE_URL_RE's exclusion list. Without it an
absolute file:// src was classified as a project-relative path and
resolved to <projectDir>/file:/abs/..., and the failed read was swallowed
by an empty catch. That catch now logs, since a silently skipped font
means the composition renders in a fallback typeface with nothing saying
why.

Closes #3369
2026-08-21 18:58:21 -04:00
Miguel Ángel 41af866bcb chore: release v0.8.7 (#3402) 2026-08-21 15:21:20 -04:00
Miguel Ángel 315a7b758c fix(engine): hide ffmpeg console windows on Windows (#3381)
ffmpeg and ffprobe are console-subsystem binaries and Node defaults
windowsHide to false, so every spawn opened a visible console window on
Windows. A render shells out dozens of times across parallel workers,
which flashed a burst of windows across the user's desktop.

Applied at every production spawn site rather than only the two named in
the report, since they all share the cause: runFfmpeg, both gpuEncoder
probes, ffprobe, streamingEncoder, audioExtractor and the distributed
version check. windowsHide is a no-op on macOS and Linux.

The dev-only parity and regression harnesses are left alone; they never
run on a user's desktop.

Closes #3379
2026-08-21 11:37:21 -04:00
Vance IngallsandClaude Opus 4.7 00f08e8de4 fix(producer): attach src URL to ffprobe failures for compile-phase attribution (STUDIO-5433) (#3033)
* fix(producer): attach src URL to ffprobe failures for compile-phase attribution (STUDIO-5433)

Wrap the video-branch `extractMediaMetadata` and `probeMediaProfile` calls in
`resolveMediaDuration` (`packages/producer/src/services/htmlCompiler.ts`) with a
`withSrcContext` helper that re-throws with the remote `src` appended as
`[src=<url>]`. The URL is passed through `redactTelemetryString` first so
pre-signed URL signatures never reach telemetry.

STUDIO-5433 — enterprise customer `mdave@manh.com` was blocked from generating
AI Studio videos, surfacing in Datadog as `[FFmpeg] ffprobe exit with code 1:
[mov,mp4,m4a,3gp,3g2,mj2 @ 0x...] moov atom not found\n[input]: Invalid data
found when processing input`. `runFfprobe` at `engine/utils/ffprobe.ts:74-79`
intentionally redacts the local `filePath` from the error (see
`redactFfprobeInput` — same file, lines 13-35), so the failure carries no
attribution and identifying the offending source requires dumping the Temporal
activity history for the workflow.

That dump is expensive-per-occurrence and blocks debugging on operator
availability. The demuxer signature (`mov,mp4,m4a,3gp,3g2,mj2`) tells us the
file is MOV/MP4-family, and the workflow_id tells us which HyperFrames
composition element failed — but the *actual URL* that ffprobe was handed is
lost. This change surfaces the URL so the next occurrence is diagnosable
directly from the render error in Datadog, without a Temporal history dump.

Preserves fail-fast semantics: the video branch still throws (aborts the
compile), unlike the audio branch's deliberate graceful-degrade to
`duration=0`. Only the error *message* is enriched; the control flow is
unchanged.

1. `packages/producer/src/services/htmlCompiler.ts`
   - New `withSrcContext(error)` helper inside `resolveMediaDuration` that
     wraps `error.message` with `[src=<redactTelemetryString(src)>]` and
     preserves the original stack.
   - Video-branch `probeMediaProfile` catch re-throws via `withSrcContext`
     (was: bare `throw error`).
   - Video-branch `extractMediaMetadata` newly wrapped in try/catch that
     re-throws via `withSrcContext` (was: uncaught, so the caller saw the
     bare `[input]`-redacted ffprobe message).
   - Adds `redactTelemetryString` import from `@hyperframes/core` (already
     re-exported at `packages/core/src/index.ts:255`).
2. `packages/producer/src/services/htmlCompiler.test.ts`
   - New `describe("STUDIO-5433 — ffprobe failure includes src URL for
     attribution")` block with a `compileForRender` integration test:
     writes a 0-byte `assets/clip.mp4`, references it from an `<video src>`
     tag, asserts the thrown error message contains `[src=assets/clip.mp4]`
     AND still carries the original ffprobe diagnostic so downstream
     failure classifiers continue to match.

- [x] Repro locally: 0-byte mp4 → `compileForRender` → error message contains
      `[src=assets/clip.mp4]` (test above).
- [x] Preserves fail-fast semantics — video branch still throws (assertion on
      thrown error).
- [ ] Focused CI must pass; hosted CI to follow.
- [ ] Follow-up (separate PR pending URL recovery): identify the writer that
      produces the actual failing derivative and add `_probe_section_integrity`
      fail-closed at the write site (the durable fix — this PR is
      diagnosability defense-in-depth).

<!-- pr-check:enterprise-ff:start -->
- [x] This change is not behind a feature flag (small diagnostic improvement
      on an existing error path; preserves failure semantics unchanged).
- [ ] This change is behind a feature flag
<!-- pr-check:enterprise-ff:end -->

<!-- pr-check:ui-impact -->
- [x] <!-- pr-opt:no-ui-impact --> No UI impact — enriches a producer-worker
      error message read only in Datadog.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(producer): pass typed routing errors through the src-context wrapper

`withSrcContext` rebuilt every error as a bare `new Error(...)`, which
dropped `NotMediaPayloadError`'s `.code = "NOT_MEDIA_PAYLOAD"`, `.owner =
"user"`, `.retryable = false` and `.elementFingerprints`. `SAFE_RENDER_ERROR_CODES`
and the distributed retry set both key on those, so a `<video>` src pointing
at an HTML payload — the STUDIO-5433 root case — flipped from
NOT_MEDIA_PAYLOAD/user/no-retry to generic/system/retryable: it paged ops and
re-ran the render on a user-input bug. The existing sniff regression
("aborts with NotMediaPayloadError before ffprobe…") is the pin; it fails on
the removal of this one line.

The PR's own new test also asserted `[src=assets/clip.mp4]`, but a bare
relative path matches `telemetryRedaction`'s BARE_RELATIVE_PATH shape and
redacts to `[path]`. Assert what the redactor actually produces for a local
src, and pin the case the ticket is about — a remote URL, where host and path
survive and only the pre-signed query is dropped — directly on the redactor.

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-20 23:39:27 -07:00
Vance IngallsandClaude Opus 5 13c867267e fix(producer): decode percent-encoded video src in HDR pre-extract (#2759)
* fix(producer): decode percent-encoded video src in HDR pre-extract (PRINFRA-349)

* fix(producer): decode percent-encoded src in HDR image probe

The HDR image probe still hand-rolled the path join the video probe had
already delegated to resolveProjectRelativeSrc, so a percent-encoded
non-ASCII `<img src>` (`图1.png` -> `%E5%9B%BE1.png`) never resolved: the
image never entered nativeHdrImageIds, resolveEffectiveHdrMode saw no HDR
sources, and the composition rendered through the SDR fallback with wrong
color -- silently, unlike the video path which errored at ffmpeg.

Both probes now call resolveProjectRelativeSrc directly, with no
isAbsolute() pre-check. The resolver already returns an absolute path that
exists and otherwise treats a leading slash as a browser origin-root URL,
so a pre-check would hand back `/assets/%E5%9B%BE1.png` undecoded and
re-open the same bug for root-relative srcs. This matches planHdrResources,
so the two halves of the fix can no longer disagree.

Widening resolution also makes previously-unresolvable files reachable for
the first time, including truncated or 0-byte assets on which ffprobe exits
non-zero. These probes run inside a bare Promise.all, so an unguarded throw
aborted the whole render over one unreadable image; probeColorSpaceSafely
now logs and treats such a source as SDR.

Tests cover percent-encoded CJK, origin-root percent-encoded CJK,
compiledDir-over-projectDir precedence, and existing-absolute passthrough,
with distinct projectDir/compiledDir so the precedence is actually pinned.
Fault-injection verified: reintroducing the isAbsolute short-circuit fails
the origin-root test.

Refs PRINFRA-349

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(producer): restore the vitest runner import in extractVideosStage tests

The rebase merged the new `node:fs` / `node:os` / `node:path` imports into
line 1 and took the incoming side, so `import { describe, expect, it } from
"vitest"` was replaced rather than kept alongside. The file still uses all
three, and `bun run test:classification` regex-matches
`/\bfrom\s+["']vitest["']/` to route each test file to a runner — so the
file matched neither and hard-failed the gate, taking Producer unit +
integration and the required Test check with it.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 23:39:11 -07:00
Vance IngallsandClaude Opus 4.8 7e3bcd9ef1 feat(producer): host/render telemetry in RenderPerfSummary (#1551)
Adds a `host` block (platform, arch, cpuCount, totalMemMb, nodeVersion,
gpuDisabled) to RenderPerfSummary so fleet-wide telemetry can correlate render
performance with the machine it ran on — chiefly cpuCount vs the existing
`workers` field (core over/under-subscription) and totalMemMb vs
lowMemoryMode / single-worker collapse. Capture mode + GPU mode already surface
via `observability`; this fills in the missing host facts.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-20 23:37:51 -07:00
James Russo 36c7dffe5c chore: release v0.8.6 (#3386) 2026-08-20 21:47:29 -07:00
Miguel Ángel c056289d83 fix(audio): renumber timestamps between apad and atrim in mixed branches (#3380)
On FFmpeg 5.x through 8.0.x the samples `apad` appends carry timestamps
the following `atrim` misreads. A delayed branch then sounds at t=0
instead of its offset and, once four or more branches are mixed, the last
one disappears from the output entirely. No error is raised; the render
succeeds with wrong audio.

Reverting to `apad=whole_dur=` is not an option: #2769 moved off that
form because some builds reject the option outright ("Error applying
option 'whole_dur': Option not found"). Inserting `asetpts=N/SR/TB`
between the pad and the trim rebuilds the timestamps from the sample
count using only filters every build ships, so it fixes the misplacement
without giving up the portability that change bought.

Verified on FFmpeg 4.2.7, 7.0.2, an 8.x nightly and 8.1.1: the current
form is wrong on the middle two, the new form is correct on all four.

audioPadTrim.ts also pads with apad+atrim but has no adelay and is
correct on every version tested, so it is left alone.

Closes #3344
2026-08-20 21:17:50 -07:00
Miguel Ángel 7a8f8a0b45 chore: release v0.8.5 (#3375) 2026-08-20 19:03:09 -04:00
Val d09145faab fix(producer): seek once per step when discovering video visibility (#3233)
Seek the GSAP timeline once per timestep and sample every auto-start
video, instead of re-walking per media element. Keeps probe cost
proportional to duration, not video count.
2026-08-19 23:49:01 -07:00
Miguel Ángel 42b94fd5db chore: release v0.8.4 (#3359) 2026-08-19 19:28:13 -04:00
Miguel Ángel 634df5a5af fix(producer): give inlined media a document-unique render id (#3342)
* fix(producer): give inlined media a document-unique render id

Element ids are unique per composition file, but the render document is
the inlined union of every file. The producer merged the per-file media
lists and deduplicated by id, so clips that shared an id collapsed into a
single entry, and every id-keyed stage (extract, inject, visibility,
bounds) resolved to whichever element came first in the document. The
surviving clip's frames landed on the wrong element and the visible scene
rendered without footage.

Two shapes hit this, and neither is author error:

  - Two scenes that each declare `<video id="clip">`. Legal per file, and
    unavoidable when a scene is duplicated into a copy with inner ids
    kept, or when one file is mounted twice.
  - Two scenes that each declare a bare `<video>`. The timing compiler
    numbers auto-ids per file, so both arrive as `hf-video-0` with no
    authored id involved at all.

Stamp a document-unique `data-hf-render-id` while inlining, and read the
media list off the inlined document instead of merging per-file lists.
The render id equals the element id whenever that id is already unique,
so documents without a collision keep identical pipeline keys.

Author `id` attributes are left alone: 158 of the 161 registry blocks
reference their own ids from `#id` CSS or getElementById, so renaming
would trade broken footage for broken styling. The engine resolves media
elements through the render id instead, falling back to getElementById
for documents the producer never compiled.

Collecting from the inlined document also retires the per-file media
extraction in parseSubCompositions along with its offset bookkeeping;
host offsets are recovered from the composition hosts the clip sits in.

* fix(core): resolve render-frame siblings by render id in the runtime

The injector creates each `__render_frame_<id>__` sibling from the media
element's render id, but four runtime readers still built that id from the
plain `el.id`. On a document where two compositions share a media id, all
of them resolved the first collider's frame.

colorGrading is the one that changes pixels: findRenderFrameImage returns
the image the grading pass samples, with no class check to catch the
mismatch, so the second video was graded from the first one's frame.
media, mediaProxy and video-texture-compat use it as a render-mode or
substitute-source signal, where both colliders happen to agree during
render, but none of them should rest on that.

Add renderFrameSibling as the single owner of "which frame belongs to
this element" and route all four through it. It reads the stamped render
id and falls back to the author id, so a collision-free document resolves
exactly as before and an uncompiled one (preview, snapshot, check) is
unchanged.

The engine's in-page bridge keeps its own copy of the rule because code
serialized into page.evaluate cannot import; it now names core as the
definition, and a test pins the sibling-id format both sides build so
they cannot drift apart silently.

* refactor(engine): build render-frame sibling ids from core's definition

The drift guard named both sides but pinned one. renderFrameSibling.test
asserts core's format, while the engine rebuilt the same id from a literal
template at six independent sites. Changing the format on either side left
the test green and every runtime reader silently unable to find its frame —
this PR's own failure mode, one level up.

Export the affixes and renderFrameIdForRenderId from core, and take the id
from there at all six. Four sites resolve it on the Node side, where the
engine can import; the two that iterate the DOM in-page receive the affixes
as evaluate arguments, which avoids depending on bridge install order.

Also switch two `__hfMediaId?.(el) ?? el.id` reads to `||`. The bridge
returns "" for an element with neither id, so `??` kept the empty string
and built `__render_frame___`, which no reader looks for. Inert today
because the compiler assigns positional ids to id-less timed media, but it
made the two sides disagree in the one case they could.
2026-08-19 00:24:07 -04:00
Miguel Ángel e282ff15cc fix(audio): raise the authoring gain ceiling and carry it through the probes (#3333)
* fix(audio): raise the authoring gain ceiling and carry it through the probes

Builds on #3328, which made the preview graph apply author gain and user volume
exactly once each. That ownership is now correct but everything is still clamped
to 1.0, so a clip authored above unity cannot be heard or rendered.

`HTMLMediaElement.volume` is spec-clamped to [0,1], so both timeline probes lost
a clip's authored gain the moment it also carried a fade: the probe seeded the
element at the clamped value and every sample read back at or below 0 dB, and
the mixer prefers probed keyframes over the static volume. Both probes now
shadow the accessor for their own duration and forward the clamped value to the
native setter, so the authored gain survives while nothing outside the probe
ever sees an illegal volume.

Measured on one 6 s composition, first 4 s: unity -32.8 LUFS, boosted-with-fade
-32.8 before and -27.0 after — +5.8 dB, exactly the gain the clip was authored
at.

One ceiling, defined once in `audioGain.ts` and reachable from both sides: the
render mixer imports it, and the page-serialized probe takes it as a parameter
rather than re-literalling it. User volume stays spec-clamped — it is a fader,
not a gain.

Also holds the percent volume slider above unity in both property panels. That
control tops out at 100%, so one touch would cap a boosted clip and drop up to
12 dB that now genuinely renders; the dB fader that can represent these levels
replaces it in the next PR.

* fix(audio): carry a static above-unity gain onto the preview gain node

Review follow-up.

`setElementVolume` receives the clip's author gain and clamped it to [0,1],
so a static `data-volume` above unity was capped on the WebAudio preview path
while the render honoured it — the exact preview/render divergence this
ceiling exists to close. Automation lanes hid it: they schedule ramps onto the
param directly and never pass through here. The master volume beside it stays
spec-clamped, because a user fader is not a gain.

Verified by mutation: restoring the [0,1] clamp reds the new case.

Also scope the leveller's rationale to this rung — `VOLUME_RANGE` still stops
at unity until the dB fader lands, so "both now span the same range" was
premature — and say why the GSAP-tracking fallback is unity-capped: it reads
back through `el.volume`, which the spec pins to [0,1], so it cannot observe an
above-unity value however wide the clamp gets.

* fix(audio): restore the live test files this branch overwrote, and uncap preview

Review blocker: three files were wholesale copies from the abandoned #3304
branch laid over a two-day-newer base, so they silently reverted work that had
landed in between. CI could not see it — deleted tests do not fail.

- `audioMixer.test.ts` was byte-identical to #3304's head: 1186 lines against a
  base of 1353. Gone with it were the `data-playback-start` fallthrough cases
  from #3322 — merged 54 minutes before this branch's own merge base — and all
  retiming coverage (`playbackRate` 7 to 0, `atempo` 5 to 0), the strict
  literal-timing table, and the zero-window cases.
- `mediaVolumeEnvelope.test.ts` dropped the trailing-garbage duration case and
  the plateau-retention case.
- `packages/core/package.json` rolled the package version back 0.8.3 to 0.7.109.

All three are restored from `main` with only this PR's additions re-applied on
top, and the subpath export is regenerated by the repo's own script rather than
hand-edited.

Also closes the preview/render split the same review raised. Two clamps had to
go, not one: `setElementVolume` capped the author gain at the transport, and
the first-tick branch in `syncRuntimeMedia` trusted `el.volume` — which is
spec-bound to [0,1] and so cannot represent a boost, opening a boosted clip at
0 dB for one tick before the steady-state branch took over. Both pinned by
tests, both verified by mutation.
2026-08-18 20:07:42 -04:00
Val b31dde35b1 fix(producer): clamp embedded video/audio windows to the scene (#3332)
Browser probe end reflects full source duration, not the scene slot.
Never extend a compile-time end — only fill missing or shrink — so long
recordings sliced across short scenes don't inflate extraction windows
and time out black on Cloud Run (ARC-13403).
2026-08-18 17:45:12 -04:00
Miguel Ángel 3e4b08cdc1 chore: release v0.8.3 (#3327) 2026-08-18 11:11:46 -04:00
Miguel Ángel afafca4b96 feat: make creator media edits render-safe (#3322)
* feat: make creator media edits render-safe

* fix: align media playback timing

* docs: add creator editing recipes

* docs: expand creator editing guidance

* fix: unify media source offsets

* fix: scale natural media duration

* fix: preserve natural media zero spans

* fix: align compiled natural media timing

* test: classify compiler media test as integration

* fix: drop inactive media windows

* fix: unify literal timing parsing

* fix: keep browser media parsing serializable

* fix: keep page timing readers strict

* fix: close remaining preview timing gaps

* fix(core): preserve Studio voice pitch at playback speed

* chore: keep creator contract source-neutral
2026-08-18 10:17:02 -04:00
Miguel Ángel 049f5618d7 chore: release v0.8.2 (#3324) 2026-08-18 01:57:54 -04:00
Miguel Ángel ad84b00c90 chore: release v0.8.1 (#3319) 2026-08-17 21:16:00 -04:00
James Russo 232686f7e0 chore: release v0.8.0 (#3318) 2026-08-17 17:06:28 -07:00
Miguel Ángel 4403b8beef chore: release v0.7.111 (#3315) 2026-08-17 17:34:58 -04:00
Miguel Ángel 5e36f7ac54 chore: release v0.7.110 (#3303) 2026-08-17 15:11:38 -04:00
Vance Ingalls de4062a933 fix: create temp dirs with mkdtemp, not a name built from Date.now() (#3241)
* fix: create temp dirs with mkdtemp, not a name built from Date.now()

Closes nine open `js/insecure-temporary-file` alerts — the technically
correct ones. An audit of all 29 open alerts for that rule split them
three ways:

- 19 false positives: the write lands inside a directory the caller
  already made with `mkdtempSync`, and CodeQL's dataflow reaches
  `tmpdir()` without seeing the mkdtemp in between.
- 1 mitigated: `fontCompression.ts` writes with `flag: "wx"` and only
  takes the tmpdir branch inside Lambda, where /tmp is single-tenant.
- 9 real, and these are them. A name built from `Date.now()` under the
  shared temp dir, followed by `mkdirSync`, is guessable to the
  millisecond AND leaves a window between choosing the name and creating
  it, so on a shared machine another user can pre-create or symlink the
  path first.

`mkdtempSync` closes both halves: it picks the random suffix and creates
the directory 0700 in one syscall. Same shape, one line shorter, and the
alerts go away rather than being dismissed.

Six sites in `normalize.test.ts` (its `mkdirSync` import goes with them),
one in `generate-catalog-previews.ts` — that single construction accounted
for three alerts, since the other two were writes into the directory it
made.

No shared helper. `mkdtempSync` is already the stdlib primitive for
exactly this, and the two callers live in different packages, so a wrapper
would need a home in core to serve one CLI test and one build script —
more indirection than the line it saves.

Deliberately not touching the other 20: excluding the rule repo-wide would
hide this class of bug from future code, which is the reason these are
fixed rather than silenced.

* fix: track the wav temp dir for cleanup and finish the mkdtemp sweep

The wav helper pushed the file path into `dirs`, so `afterEach` removed
`tone.wav` and left the directory it had just made — four per suite run.
Push the directory and derive the file path from it. Measured: the old code
leaks 4 directories per run, the new code leaks 0.

Three sites still built a predictable name and then created it. CodeQL never
flagged them — its dataflow reaches the template preview writes through a
`readdir` walk and does not connect them back to the `tmpdir()` root — so the
alert list was narrower than the pattern, and closing only the alerts would
turn the rule green while the shape survived where nothing would re-flag it.
`generate-template-previews.ts` is the near-twin of the file this change
started from, and the other two are producer dev entry points. All three use
the path only through the variable, so the random suffix changes nothing.

Catalog previews now call the existing `createCatalogPreviewTempDir` instead
of repeating its body. That test was in no runner, so it pinned uniqueness and
mode 0700 on a function nothing called; adding it to `test:scripts` alongside
a real caller makes it load-bearing. The rationale for the primitive moves to
the helper, which is now the only place it lives.

* ci: re-run catalog previews when the temp-dir module changes

Routing the renderer through `createCatalogPreviewTempDir` made that module
part of its runtime path, and the workflow already states the rule for the
sibling case: a module the renderer imports has to appear in the trigger, or a
change to it alone never re-runs the job that exercises it. Add it to the
`paths:` filter and to the renderer canary, so a PR touching only the temp-dir
allocation still renders both shape canaries.

Verified against this branch's own range: the previous argument list does not
report the file, so a helper-only PR was invisible to both checks.
2026-08-14 11:20:37 -07:00
Miguel Ángel 12fd6d9087 chore: release v0.7.109 (#3273) 2026-08-14 10:21:26 -04:00
Miguel Ángel c32b8041db fix(producer): propagate audio mixer config (#3239)
* fix(producer): forward ffmpeg timeout to audio mixer

* fix(producer): propagate audio gain with timeout
2026-08-14 10:01:52 -04:00
James Russo f7d2260f9d feat(engine): stamp rendered files with hidden renderer provenance (#3264)
* feat(engine): stamp rendered files with hidden renderer provenance

* fix(engine,producer): re-assert provenance at every container writer

Review found that a no-audio MOV render still shipped untagged. The concat
step is the last container write on that path (mux is skipped without audio,
and applyFaststart only copies mov/webm), and the concat demuxer does not
carry the chunks' container metadata through.

The same hole applies to no-audio WebM, and to the in-process chunked encode
in chunkEncoder, not just the distributed assemble path. mp4 was masked
throughout because applyFaststart re-runs ffmpeg for that format and re-tagged
the output.

Tags the four remaining writers: the chunked-encode concat, and assemble's
single-chunk remux, concat and cfr re-encode.

Also corrects the trust claim. These are unsigned, freely writable keys, so a
present tag means the file claims to be HyperFrames output, not that
HyperFrames wrote it. Documented as an unauthenticated diagnostic hint rather
than an authenticity or attribution boundary.

Tests assert on the assembled file through the real assemble() path for both
mov and webm; both fail without the concat fix.

* test(engine): pin provenance through the in-process chunked concat

Review noted the distributed writers are mutation-pinned but the
encodeFramesChunkedConcat fix had no real-file regression of its own.

Encodes 70 frames at a 30-frame chunk size so the concat step actually runs,
then asserts the tags on the resulting no-audio mov. Fails without the concat
fix, passes with it.
2026-08-13 16:32:09 -07:00
Vance Ingalls 9ba528914d chore: release v0.7.108 (#3265) 2026-08-13 15:11:20 -07:00
Vance IngallsandClaude Opus 5 f6cf3b242b fix(core): retire worklet processors, retry failed registration, reuse FFT scratch (#3175)
* fix(core): remove the build-audio-fx-runtime.ts stray resurrected by a main merge

An earlier merge with main brought this deleted file back (git's merge/delete
handling on an unchanged-on-one-side file); package.json already points at
build-inline-artifact.ts, so it sat unreachable and duplicating that file's
config, both of which fallow flagged.

* fix(studio): pull TimelineLanes under the 600-line cap

TimelineLanes.tsx hit 620 lines. Extracted the three per-clip pointer
gestures (resize-start, pointer-down move-arm, click/razor-split) into
createClipGestureHandlers — one factory call per rendered clip instead of
~120 lines of inline handler bodies in the render loop. 529 lines now.

* fix(studio): split the extracted pointerdown handler under the CRAP threshold

Moving the ~120-line gesture logic into timelineClipGestureHandlers.ts
concentrated it into two functions fallow flagged (onPointerDown at CRAP
63.6, onResizeStart at 31.6). Split the decision logic (which gesture a
pointerdown implies) into a pure resolvePointerDownAction, then split
its own intent-blocking check into isIntentBlocked. onResizeStart's guard
moved into canStartResize. Every function now scores under 30.

* fix(studio): drop the unused DomEditSelection import in PropertyPanelFlat

CI caught it on PR #3026 (wa-12-panel-params); a later refactor in the
stack removed the last use of the type here without removing the import.

* fix(studio): close the typecheck and fallow gaps wa-18b-reschedule opened

useAutomationLanes.ts's write() assumed gesture-scoped coalescing and a
preview-only commit that useDomEditAttributeCommits.ts never grew — backported
that option support from its own later commit so the two sides of the API
agree. The paste path and its tests were missing the box selection's v0/v1
bounds a sibling commit added to AutomationSelection. The FX panel's carve
controls still edited the six mechanism numbers (maxCutDb, bands,
intelligibilityBias) after carveProfile() collapsed authoring to one Strength
knob, so those fields no longer existed on HfCarveSettings; UI now edits
strength, and analyseCarveBands is called with carveProfile(strength).

Also closes fallow's complexity, dead-code and duplication findings on this
PR's diff: extracted automationLaneDragMath.ts (pure group/point-move math)
and useAutomationRangeDrag.ts (the marquee-select gesture) out of
useAutomationLaneGestures.ts, pulled a couple of render-loop ternaries and a
resolver into named functions, dropped an export nothing outside its file
used, and shared a step-simplifier between audioCarve's two envelope
builders.

The edge-stretch vs. box-select priority test in TimelineAutomationLane.test
was still pinning the pre-box-select rule (edge wins over a point sitting on
it) that a sibling commit deliberately reversed — a point inside the box is
now selected content, so grabbing it drags the group instead. Updated the
test to the shipped rule instead of the old one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(core): cap the via conic's weight so an edge-clamped via point can't NaN

A via point pulled out past the segment (viaX: 5, viaY: -3) clamps to
(0.999, 0.001) — exactly on the steady region's edge, where edge - viaX is 0.
viaConic divided by that zero to get an infinite weight, and shapeVia turned
Infinity into NaN a few steps later (Infinity - Infinity in the quadratic
coefficient). NaN reaching setValueCurveAtTime silences the automated
parameter for the rest of the render.

Capped the weight at 1e6 instead of leaving it unbounded — past that point
the arc already reads as touching the via point, so nothing visible is lost.
Also hardened shapeVia's existing denominator guard (`<= 0`) to `!(> 0)`,
since NaN fails the original comparison and fell through it.

Review by Miga (PR #3208).

* fix(studio-server): fingerprint the proactive waveform cache key too

The route already keys the waveform cache on the asset's size and mtime as
well as its path, so a rebuilt-in-place file gets fresh peaks instead of
stale ones. generateWaveformCache — the proactive path that runs on upload —
still called buildWaveformCacheKey with the path alone, so it wrote to a
different key than the route reads from (making the pre-generated cache
never found) and kept the exact collision bug this fingerprint exists to fix
on its own path.

Review by Miga (PR #3211).

* style(docs): run oxfmt on the /hyperframes-audio skill docs

Table column widths had drifted out of alignment with oxfmt's own rules,
failing format:check and blocking the Preflight gate every downstream
branch inherits. Whitespace only, no content change.

* fix(core): stop \b from missing underscore-separated names, guard clipsOverlap's negative duration

\b treats `_` as a word character, so \bbed\b never matched bed_01,
music_bed_loop, or theme_song, and \bvo\b/\bvox\b/\btts\b had the same gap —
an underscore-separated bed classified as "unknown" and could end up offered
as its own carve source. Replaced the short hints with a boundary that
actually excludes letters and digits on both sides.

clipsOverlap computed end = start + duration without guarding sign, so a
negative duration put end before start — an interval that does not describe
anything, and one specific case showed it silently dropping a real overlap
(a shorter, earlier broken end rejected a clip that genuinely contained the
point). Duration clamps to zero instead: a clip cannot un-play time, and a
zero-length clip at its start is the sane reading of "duration nobody wrote
down as positive."

Review by Miga (PR #3212).

* fix(studio): widen PropertyPanel's resetModules render timeout again

The 20s margin (already once widened for the same reason) is timing out in
CI's full-monorepo Test run — the resetModules()+fresh-import render this
test needs is uncached and competes with every other package's test suite
for the same worker pool, and the same test passes in well under 2s
standalone. Went to 45s rather than re-tuning to whatever number happens to
clear the current CI load, since that number moves every time CI gains a
package.

* fix(studio): stop the single-candidate auto-apply carve firing twice

Two auto-apply effects both fire when sourceOptions.length === 1: the
multi-candidate effect only guards length === 0, so a single candidate
passes it too, and the single-candidate effect passes its own guard right
after — both compute the same sources list and both call setCarve, so the
common case (one narrator, one bed) triggered two decodes, two FFT runs, and
two concurrent attribute writes for one decision.

The multi-candidate effect now defers to its sibling for exactly one
candidate, which already has its own detailed handling for that case.

Review by Miga (PR #3213).

* feat(core): carve against every voice over a bed, always (#3212)

* feat(core): carve against every voice over a bed, always dynamically

A bed usually runs under a whole sequence — a narrator, an interview answer, a
second presenter — and carving against one of them left the others fighting it.
`source` becomes `sources`, and `mixCarveSources` sums every voice onto the BED's
clock before anything is measured. That is what keeps one analysis sufficient: the
chain is fixed, so there is no per-voice filter to switch between, and bands drawn
from all the speech there is with envelopes that rise wherever any of it happens
answer the actual question — where and when is speech masking this bed.

Summed rather than averaged: two people talking at once mask more than either
alone. Audio before the bed starts is dropped rather than folded in at zero, since
it plays over nothing and shifting it would put a cut where there is no voice.

`dynamic` is gone. A fixed depth thins the bed through every pause, and once both
have been heard there is no reason to want it, so every carve follows the speech.

Two helpers the panel and the headless script now share instead of each carrying a
copy — two definitions of "what does this name suggest" drift, and then the two
disagree about which track is the voice:

- `classifyAudioName` reads a track's kind from its id and filename together.
  `unknown` is deliberately common: treating an unrecognised name as "not a voice"
  would hide the one track somebody needs to pick.
- `clipsOverlap` keeps out a voice that never plays while the bed does. An unwritten
  duration counts as unbounded, not zero — refusing a clip whose length the
  composition leaves to the media would drop the commonest case there is.

Files written before this still load: a single `source` reads as a one-voice list,
a stored `dynamic` is ignored, and an absent attribute means the defaults whole.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(core): stop \b from missing underscore-separated names, guard clipsOverlap's negative duration

\b treats `_` as a word character, so \bbed\b never matched bed_01,
music_bed_loop, or theme_song, and \bvo\b/\bvox\b/\btts\b had the same gap —
an underscore-separated bed classified as "unknown" and could end up offered
as its own carve source. Replaced the short hints with a boundary that
actually excludes letters and digits on both sides.

clipsOverlap computed end = start + duration without guarding sign, so a
negative duration put end before start — an interval that does not describe
anything, and one specific case showed it silently dropping a real overlap
(a shorter, earlier broken end rejected a clip that genuinely contained the
point). Duration clamps to zero instead: a clip cannot un-play time, and a
zero-length clip at its start is the sane reading of "duration nobody wrote
down as positive."

Review by Miga (PR #3212).

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(studio): port the carve UI off the removed source/dynamic fields

#3212 (accidentally squash-merged into this branch instead of main) changed
HfCarveSettings from a single `source` + `dynamic` toggle to a `sources`
list with dynamic mode removed outright — the multi-voice UI consumer that
goes with that shape lands in the very next PR, so this branch was left with
a type that no longer matched its own code.

Minimal port, not the multi-voice redesign that PR does properly: the
"Listen to" picker and analyse() treat sources[0] as the one voice this UI
still understands, and every dynamic-mode branch (the automated envelope
lanes, the toggle, the checkbox) is gone along with the field — a carve is
now always the static value the analysis computes, matching what the type
change made permanent. Test suite trimmed the same way: the automation-lane
and toggle tests covered behavior that no longer exists.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 05:43:34 -07:00