* fix(fonts): make Google Fonts subsetting CSS text-transform aware
Extends the subset character closure to cover locale/context-sensitive
case transforms and non-case CSS text-transform values:
- Parse lang attributes from authored HTML and apply toLocaleUpperCase/
toLocaleLowerCase for each detected locale (covers Turkish İ/ı,
Azeri, German ẞ, and other locale-dependent casing)
- Map ASCII U+0021–U+007E to fullwidth equivalents U+FF01–U+FF5E when
full-width appears in the source
- Map small hiragana/katakana to full-size equivalents when
full-size-kana appears in the source
- Preserve the existing 1700-char encoded URL budget and full-font
fallback
Closes#3496
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor: extract helpers to reduce complexity and duplication
Split extractGoogleFontsText into addCaseClosure, addFullwidthVariants,
and addFullSizeKanaVariants. Extract subsetTextFor test helper to
eliminate repeated URL→text boilerplate.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: validate lang tags, scope transform gates, deduplicate closure input
- Validate lang attributes with Intl.getCanonicalLocales before passing
to toLocaleUpperCase — malformed tags (en_US, x, 123) no longer throw
RangeError.
- Gate fullwidth/kana expansion on text-transform declarations instead
of raw html.includes — a CSS class named .full-width no longer eats
half the URL budget.
- Deduplicate characters before the closure loop (new Set) to avoid
redundant locale conversions on base64-heavy compositions.
- Benchmark now asserts the transform cost delta, not just that one
small fixture fits under the cap.
- Restore over-approximation comment.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: prevent regex bridging across rules and add case-insensitive matching
Exclude {} from the text-transform regex character class so the match
cannot cross rule boundaries when the trailing semicolon is omitted.
Add /i flag so uppercase declarations (text-transform: FULL-WIDTH)
are not missed. Test now uses a fixture with both a text-transform
declaration and a .full-width class to exercise the bridging case.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: remove redundant \s* to eliminate quadratic backtracking
Drop the \s* between : and [^;{}]* — the character class already
matches whitespace, and the adjacent quantifiers caused quadratic
backtracking on inputs like "text-transform:" + " ".repeat(N).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: replace regex with linear indexOf/slice scan (js/polynomial-redos)
The text-transform regex backtracks O(n²) on input with many
text-transform: runs and no ;{} between them. Replace with a linear
indexOf/slice scan following the repo's existing pattern for this
CodeQL rule (beatFile.ts, utils.ts, parseStoryboard.ts).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(producer): degrade gracefully when font cache directory is unwritable
When the font cache root (~/.cache/hyperframes/fonts/) cannot be created
(EPERM on read-only filesystems, restricted home directories, etc.), the
render aborts with a raw mkdir error. The cache is an optimization, not
a requirement — a missing cache should mean slower first renders, not
broken renders.
Fall back to a temporary directory under os.tmpdir() when the configured
cache root fails, so Google Fonts downloads still proceed. The fallback
cache is per-process and not persistent across renders, but the render
completes.
Fixes#3412.
* fix: use mkdtempSync for font cache fallback, restore warning
Rames Jusso's review caught a regression in the force-push: the
predictable tmpdir path is unsafe (symlink attacks in world-writable
dirs), and the warning log was dropped. Restore the mkdtempSync
pattern matching lambdaFontCacheRoot, add a CLI hint per Miguel's
request, and reuse the ephemeral root across calls.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: emit font cache fallback warning once per run, not per font
Gate the warning on whether this is the first fallback activation.
The ??= already suppresses repeat mkdtempSync, but the warn fired
for every font family in the composition.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(producer): transport safe extraction failure metadata
* refactor(producer): generalize public error metadata
* test(producer): use vendor-neutral media hosts
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>
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.
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>
* 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>
`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>
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.
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.
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`
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