Addresses R2 CHANGES_REQUESTED from Miga + Rames on PR #2529:
1. Sibling-surface gap (blocker): `hyperframes cloudrun render{,-batch}`,
`hyperframes lambda render{,-batch}` all advertised the same tier-only
aliases (`1080p` / `hd` / `4k` / `uhd`) but normalized them to `landscape`
and never set `outputResolutionAspectAgnostic`. The distributed plumbing
PR #2529 added received `undefined` from those callers, so portrait `1080p`
still hit the original aspect-mismatch on Cloud Run / Lambda.
Fix: introduce `resolveResolutionFlagPair` in `@hyperframes/parsers` (the
single source of truth for the two-step normalize + aspect-agnostic
detect) and route every distributed entrypoint through a shared
`parseOutputResolutionFlag` CLI util so the alias signal now reaches
`SerializableDistributedRenderConfig`. Studio Server keeps its
canonical-only HTTP contract; that intent is now pinned in tests.
2. Preflight recompute (hardening): the earlier "downgrade aspect-mismatch"
preflight cleared un-remapped mismatches, so IG 4:5 (non-preset aspect,
no sibling) and portrait-4K comp + `--resolution 1080p` (remap +
downsample) both slipped through to fail late in `resolveDeviceScaleFactor`.
Now `checkRenderResolutionPreflight` computes the effective preset via
`suggestMatchingPreset` (mirroring the compile stage's
`adaptAspectAgnosticResolution`) and re-checks against that — only
genuinely-fixable mismatches clear early. New tests pin both regressed
input classes.
3. Docker forwarding boundary test (Miga's important #2): pinned
`1080p` survives verbatim as `--resolution 1080p` in the Docker args
so the in-container CLI can re-run `isAspectAgnosticResolutionAlias`.
4. Doc-nit (Miga): parsers/src/types.ts no longer references the
nonexistent `resolveResolutionForComposition` — points at the actual
remap helpers.
Fallow: cloudrun.ts / lambda.ts share 390 lines of pre-existing structural
symmetry (parallel AWS + GCP dispatchers), and lambda/render.ts +
render-batch.ts declare parallel RenderArgs interfaces. Both re-flagged
after threading the aspect-agnostic field through each surface; ignored
with justification in .fallowrc.jsonc. lambda.ts's `run` and
lambda/render.ts's `waitForCompletion` are pre-existing CRAP-score
hotspots untouched by this PR — added under health.ignore.
Co-Authored-By: Claude <noreply@anthropic.com>
— Via
FlatRow lays out label…gap…value across a `justify-between` row, but the
shared CommitField input it wraps had no text-align, so its text hugged
the LEFT edge of the value's own (often much wider) right-hand box —
looking left-aligned relative to the row, out of step with FlatSelectRow
and FlatSlider, which already right-align.
Added an optional `align` prop to CommitField (default "left", preserving
the legacy panel's MetricField/DetailField layouts where label-then-value
sits inline and left reads naturally) and pass `align="right"` from
FlatRow. Left the Motion Timing row's Start/End/Duration cells alone —
those stack label-above-value in a grid, a different pattern from the
inline label…value row this fix targets.
New tests: FlatRow's input has `text-right` (not `text-left`); the legacy
MetricField's input keeps `text-left` (not `text-right`), pinning
CommitField's default so the shared component doesn't drift for the
panel that didn't ask for this.
Full studio suite (2645 tests) green; typecheck/oxlint/oxfmt clean.
Reported as "template variables are broken": binding an element's field to a
variable via the flat inspector's "◇ var" promote chip (or editing an
already-bound field's value) wrote the correct bytes to disk, but the
Variables tab kept showing the pre-edit value until the whole Studio page
was hard-reloaded.
Root cause: DesignPanelPromoteProvider deliberately opens its OWN SDK
session (`useSdkSession(projectId, selection.sourceFile ?? activeCompPath)`)
so that promoting inside a sub-composition binds the variable in the
sub-comp's own file, not the host's. For the common case — a top-level
element, same file as `activeCompPath` — this session is a SEPARATE
in-memory `Composition` instance from the shared one `VariablesPanel`
(Variables tab, Slideshow, etc.) reads. A persist through the promote
provider's session never fires the shared session's own "change" event.
Worse, the shared session's file-change listener runs
`isSelfWriteEcho(path, content)` to decide whether to reload — but
`sdkSelfWriteRegistry` is keyed by file path only, not by session instance
(its own doc comment assumes "the studio process has a single SDK session
lifecycle at a time"). It sees the promote provider's write registered
under the same path and concludes it's its own echo, permanently
suppressing the reload it actually needs.
Threaded `forceReloadSdkSession` (the same mechanism every other
server-side-write path in Studio already uses for exactly this "resync
after a write I didn't make myself" case) from App.tsx through
StudioRightPanel into DesignPanelPromoteProvider, and call it after every
successful promote/setDefault persist — unconditionally, not gated on the
promote target matching activeCompPath, since re-opening a file that
didn't change is a harmless no-op re-parse and a path-equality guard here
already produced one subtly wrong comparison (activeCompPath can be null
while the shared session still defaults to "index.html") before landing on
this simpler version. Verified live: editing a variable-bound field's
value now updates the Variables tab immediately, no reload required.
App.tsx crossed the 600-line file-size gate after threading the new prop;
extracted the tiny handleAddAssetAtPlayhead wrapper into its own
useAddAssetAtPlayhead hook (with a regression test) to bring it back under.
Full studio suite (2639 tests) green against a fresh main; typecheck/
oxlint/oxfmt clean.
Fallow flagged the `uncaughtException` arrow at CRAP 30.0 (right at
threshold). Extracting the three exit paths — emitCliErrorEvent,
reportPostRenderTerminationEvent, exitAfterPostRenderTermination,
exitAfterCliFailure — pulls the two `_flushSync?.()` optional-chain
branches out of the arrow body and drops cyclomatic to 3. Same shape
already used on compileStage in a sibling PR. Behavior preserved:
EPIPE → exit(0), renderSucceeded → report + flush + exit(0),
default → commandFailed + track + flush + exit(1) for uncaught;
renderSucceeded → report + return, default → commandFailed + track
for rejection.
Windows contactSheet flake: `createContactSheet > writes PNG output`
timed out at 20025ms (default ceiling 20000ms) on Windows. Test does
milliseconds of real work — the wall-clock overhead is sharp's native
binary fork under runner I/O jitter. PR #2492 already tried lightening
the test; the flake persists at the default ceiling. Bumping the
per-test timeout to 60s absorbs runner jitter without hiding real
slowdowns (a genuine sharp regression would blow through 60s just as
readily as 20s). Targeted per-test bump, not a global suite bump.
Co-Authored-By: Claude <noreply@anthropic.com>
— Via
The prior fix (dc410ca) dropped `pathToFileURL` from the pad-concat step
to make FFmpeg 8.x on Windows stop rejecting `file:///C:/…` URLs — but
kept feeding the concat script via `pipe:0` stdin. That combination
broke Linux CI: FFmpeg's concat demuxer resolves bare paths in the
script against the base URL of the script's own source, and when the
script is fed via `pipe:0` the base URL is `pipe:`. Absolute POSIX
paths (`/tmp/foo.aac`) then join to `pipe:/tmp/foo.aac`, which the
demuxer tries to open as a pipe and fails with:
[concat @ 0x…] Impossible to open 'pipe:/tmp/…/audio.aac'
pipe:0: End of file
Manually reproduced with `ffmpeg-static@7.0.2` on this repo's binary.
Fix: write the concat script to a real temp file (`<outputPath>.concat-
list.txt`) and pass `-i concatListPath` — matching the sibling concat
in `distributed/assemble.ts:180-186` exactly. A real file's directory
becomes the base URL, so absolute paths in the script resolve as-is on
both Linux and Windows. The `file://` scheme prefix stays out of the
script (Windows FFmpeg 8.x fix preserved) and no `pipe:` prefix gets
prepended (Linux regression fixed). Cleanup path list now covers both
the silence tail and the concat list script.
Also drops the now-unused `runFfmpegWithStdin` helper — no consumer
needs stdin plumbing anymore.
Regression pins in `audioPadTrim.test.ts`:
- `does not emit file:// URLs …` — Windows arg-shape pin (unchanged
intent, moved from `stdin` to `concatListContent` field).
- `materializes the pad-concat script to a real file …` — new pin
that asserts `-i` is not `pipe:0` and points at the concat list
path, so the Linux failure mode can't regress.
CI failures fixed:
- CI / Producer: integration tests (assemble.test.ts pad case)
- regression / regression-shards shard-1 (style-3-prod field-signal
end-to-end render exercising the assemble pad path)
Co-Authored-By: Claude <noreply@anthropic.com>
— Via
Three findings, all resolved:
- packages/producer/src/server.ts `render` (CRAP 31.6, cyclo 10 — minor):
pre-existing complexity; the PR only threads
`outputResolutionAspectAgnostic` through parseRenderOverrides /
RenderInput and does not touch `render`. Line-shift fingerprint —
exempted via health.ignore with justification comment.
- packages/producer/src/services/distributed/plan.ts `plan` (CRAP 36.7,
cyclo 33 — major): pre-existing complexity; the PR only adds one
optional field spread inside `plan` and does not add branches.
Line-shift fingerprint — exempted via health.ignore with justification.
- packages/producer/src/services/render/stages/compileStage.ts
`runCompileStage` (cyclo 23, cognitive 19 — minor): this one is a
real complexity bump from the two-branch aspect-agnostic re-target
block added in the fix. Extracted the block into a local helper
`adaptAspectAgnosticResolution` so `runCompileStage` stays under both
the cyclomatic (20) and cognitive (15) thresholds.
Verified locally with `fallow audit --base origin/main --fail-on-issues`
(exit 0, "No GitHub PR/MR findings") and `tsc --noEmit` on the producer
package.
Co-Authored-By: Claude <noreply@anthropic.com>
— Via
The aspect-agnostic resolution aliases (`--resolution 1080p` / `hd` / `4k` / `uhd`) previously all normalized to a landscape preset, which rejected portrait 1080x1920 compositions with 'Output resolution incompatible'. Users had to specify the orientation-bearing alias (`1080p-portrait`) or render at native.
This threads two new fields (`outputResolutionAspectAgnostic` + `outputResolutionRaw`) through the render pipeline. At the CLI layer we detect whether the user's flag was an aspect-agnostic alias; at the compile stage we re-map the preset to the composition's orientation via the existing `suggestMatchingPreset` sibling-lookup (formerly private). Explicit orientation-bearing aliases and canonical presets stay strict.
Field signal: ts=1784176662 (darwin/arm64, CLI 0.7.59, `--resolution 1080p` on a 1080x1920 portrait comp).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
— Via
The render command's post-artifact-validated cleanup (telemetry flush,
feedback prompt, worker/browser teardown, stray promise rejections) can
throw AFTER the producer has committed a valid MP4 to disk. Field signal
ts=1784169760, ts=1784171150, ts=1784172467 (all win32/x64, CLI 0.7.58,
ffmpeg=no, 1080x1920): ffprobe + visual QA confirmed the outputs are
valid, but the CLI exited 1 after the terminal "artifact validated" log
with no final error message.
Introduce a `renderSucceeded` sentinel that flips after `executeRenderJob`
(or the Docker child render) resolves cleanly. From that point on:
- Post-render steps in the render command (trackRenderMetrics,
printRenderComplete, warnIfWebmAlphaDropped, maybePromptRenderFeedback)
run through `runPostRenderStep`/`runPostRenderStepAsync` guards that
swallow throws, log a compact warning to stderr, and sanitize a stray
`process.exitCode` back to 0.
- The CLI's top-level `uncaughtException` handler logs the throw for
diagnosis but exits 0 instead of 1 when the render already succeeded.
- The CLI's `unhandledRejection` handler stops flipping `commandFailed`
(which drove the success:false telemetry field) when the render
already succeeded.
Co-Authored-By: Claude <noreply@anthropic.com>
🤖 Generated with [Claude Code](https://claude.com/claude-code)
— Via
Adds a `--timeout <ms>` CLI flag (and `HYPERFRAMES_TRANSCRIBE_TIMEOUT_MS`
env var) plus a model-slowdown factor in the auto-scaled default so
`hyperframes transcribe` doesn't hard-fail with `spawnSync ETIMEDOUT`
on slow CPUs running heavier whisper models.
Field-signal ts=1784165471 (win32/arm64 emulating x64 on Snapdragon,
CLI 0.7.59) reported the failure on a 63s wav with `-m medium` at ~13x
realtime — the historical 10x-realtime scale (PR #2463) gave 10.5 min
while the machine needed ~13.7 min. Splitting audio and merging offsets
was the manual workaround.
- Add `--timeout <ms>` and `HYPERFRAMES_TRANSCRIBE_TIMEOUT_MS` (min 5000).
Explicit override bypasses auto-scaling; still capped at 12h.
- Add per-model slowdown factor (tiny 0.5, base 0.7, small 1, medium 2,
large 4, large-v3-turbo 2). Multiplied into the 10s/audio-second
baseline so medium/large get proportional headroom while `small.en`
(the default) preserves the historical safety window.
- Wrap whisper's spawn error with a discoverability hint naming
`--timeout`, the env var, and the effective timeout when the child
was killed by SIGTERM/ETIMEDOUT (mirrors PR #2504 protocol-timeout).
- Docs: new `--timeout` row in `docs/packages/cli.mdx` Flags table.
Regression coverage in `packages/cli/src/whisper/transcribe.test.ts`
(56 tests) and `packages/cli/src/commands/transcribe.test.ts` (5 tests):
- Model factor per known name + case-insensitive + safe unknown fallback.
- 63s field-signal case on medium.en → 1_260_000ms (was 630_000ms).
- Explicit override honored below the auto floor + capped at 12h.
- Model factor ignored when overrideMs is set.
- SIGTERM/ETIMEDOUT detection + augmented message contract.
- CLI rejects below-minimum `--timeout` with error naming both the flag
and the 5000ms floor.
— Via
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
🤖 Generated with [Claude Code](https://claude.com/claude-code)
The `audioPadTrim` module's pad-concat step generates a concat script
whose file directives use `file://` URLs (built via Node's
`pathToFileURL`). FFmpeg 8.x on Windows rejects these with
"Impossible to open file:///C:/…" — its `file:` protocol handler strips
the scheme leaving `///C:/…`, which Windows path parsing then rejects.
Field-signal (4 reports over ~24h, all win32/x64, CLI 0.7.59):
- ts=1784169914 (Baoyu, 60s render, native audio assembly failed)
- ts=1784177061 (andre 22cores, 345.87s composition, 9 WAV audio elements)
- ts=1784177375 (KEY DIAGNOSTIC: 13 mono 44.1kHz mp3 tracks, ffmpeg
8.1.1-full_build gyan.dev, "same project rendered fine in July with
an older ffmpeg"; manual `ffmpeg -i track.mp3 -af apad=whole_dur=16
-t 16 -c:a aac out.aac` works with the same binary, so the tool's
audioPadTrim invocation is the incompatible part)
- ts=1784177375 (duplicate reporter follow-up)
The concat approach itself is fine — the sibling concat scripts in
`assemble.ts` and `chunkEncoder.ts` pass raw paths (no `pathToFileURL`)
and work on Windows. `audioPadTrim.ts` was the outlier introduced in
PR #1615 (2026-06-20). Aligns with the codebase convention.
Regression pin: unit test asserts the pad-concat stdin never contains
the `file://` scheme, including for a Windows-shaped input path.
End-to-end verification requires a Windows + FFmpeg 8.x reviewer; the
unit test snapshots the arg shape.
Co-Authored-By: Claude <noreply@anthropic.com>
🤖 Generated with [Claude Code](https://claude.com/claude-code)
— Via
Every editable value in the flat inspector (FlatRow's CommitField, the
Motion Timing row's Start/End/Duration cells, and every raw <select> —
Style/Text dropdowns, Grade's Custom LUT and Copy-grade-to scope) rendered
its underline/border only on hover (`border-transparent
group-hover:border-...` or no border at all). At rest a value looked like
plain static text, with nothing distinguishing it from a label — testers
reported not being able to tell which fields were editable.
Give each a dim-but-visible resting border (`border-panel-border-input/50`,
or `border-panel-accent/30` for the explicitCustom tier) that brightens on
hover/focus, instead of a fully transparent one. Purely visual — no
behavior change.
Full studio suite (2641 tests) green; typecheck/oxlint/oxfmt clean.
Review feedback on #2497 (Rames D Jusso) found a real gap: the exclusivity
this PR introduced only applied to the direct in-panel tab click, which
calls setExclusiveRightInspectorPane. Every OTHER caller that reaches
setRightPanelTab("design"|"layers") — element select (useDomSelection.ts),
closing block-params (App.tsx), the header Inspector button
(StudioHeader.tsx), and even this PR's own "!inspectorTabActive" entry
branch in handleInspectorPaneButtonClick — went through
trackedSetRightPanelTab's old unconditional additive
`{...panes, [tab]: true}`, reproducing the exact "both tabs highlight, only
one renders" bug this PR claims to fix. Confirmed via the reviewer's traced
repro: fresh boot, click Layers tab while no inspector tab is yet active →
rightInspectorPanes ends up {design:true, layers:true}.
Fixed at the reviewer's preferred choke point: trackedSetRightPanelTab
itself is now flat-aware, applying the same exclusive-radio update
setExclusiveRightInspectorPane does whenever STUDIO_FLAT_INSPECTOR_ENABLED
is on, falling back to the legacy additive update otherwise. This closes
the gap for every current and future caller of setRightPanelTab, not just
the one call site this PR touched.
New usePanelLayout.test.ts cases pin both directions: setRightPanelTab
stays additive under flat=off (legacy split-view behavior unchanged), and
enforces exclusivity under flat=on even when called directly (not through
the tab-click handler) — using the vi.doMock(manualEditingAvailability)
pattern already established in PropertyPanel.test.tsx for flag-dependent
module state.
Full studio suite (2643 tests) green; typecheck/oxlint/oxfmt clean.
The flat inspector split Layers and Design into a vertically-resizable
stacked pair whenever both panes were toggled on, mirroring the legacy
panel's layout. For the flat redesign this reads as two competing panels
crammed into one column; Layers should always render full-height by
itself there instead.
Gate the split-view branch behind !STUDIO_FLAT_INSPECTOR_ENABLED so it
still applies to the legacy panel, and fall through to Layers rendering
alone (the existing `layersPaneOpen` branch already does this — it just
never got reached previously because the split check ran first).
Also added setExclusiveRightInspectorPane (radio-style: selecting one pane
turns the other off) and use it for the Design/Layers tab clicks under the
flat flag, since leaving both panes independently toggleable would highlight
both tabs as "active" while only one actually renders.
New usePanelLayout.test.ts covers both the existing toggle behavior and the
new exclusive variant. Full studio suite (2634 tests) green; typecheck/
oxlint/oxfmt clean.
tsc (noUncheckedIndexedAccess) types marked[i]/orphaned[i] as
Element | undefined; vitest passed but bun run build failed. Narrow
before the isStylable predicate and regenerate the render-inline IIFE.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Follow-up on Miga's review of #2512. The regression fixture
`escape-hatch-fatal-fallback` is tagged `field-signal-reproducer` and
`known-broken` so it's skipped from the default sweep via
`--exclude-tags transparency,field-signal-reproducer` in the
`test:regression*` scripts in packages/producer/package.json. But
`Dockerfile.test`'s ENTRYPOINT invoked the harness directly (`bunx tsx
src/regression-harness.ts -- --sequential`), bypassing those scripts —
so `bun run docker:test*` and the aws-lambda smoke tests would still
try to run the known-broken fixture and fail. CI's own regression sweep
was insulated only because it hardcodes per-shard positional test names
that don't include this fixture, but that's incidental, not by design.
Bake the exclude-tags into the Dockerfile.test ENTRYPOINT itself so
every user of the image (local `docker:test*`, aws-lambda smoke, any
adopter running the reference image) picks up the same skip contract.
Docker CMD args appended after the entrypoint (e.g. matrix shard
positional test names in .github/workflows/regression.yml, or
`--mode=distributed-simulated`) still parse correctly — the harness
applies excludeTags after testNames-filtering (see discoverTestSuites
in regression-harness.ts).
Also exports `parseArgs()` from regression-harness.ts and adds
regression-harness-parse.test.ts to pin the `--exclude-tags` comma-parse
contract, so any future change to the parser or the values baked into
the Dockerfile / package.json will trip a red test rather than silently
diverging.
Verification A (harness comma-parses `--exclude-tags transparency,
field-signal-reproducer`) already worked pre-fix; the new test file
codifies it. Verification B (Docker ENTRYPOINT propagates the same
skip) is what this commit fixes.
Signed-off-by: Via
Field-signal baseline: >=2 fallbacks/hr on darwin/arm64 from filter:blur
and filter:drop-shadow triggers. Fallback path perf is currently untimed,
so we can't know if the overhead is 10% or 10x. This PR adds opt-in
per-frame timing (HF_PROFILE_FALLBACK_CAPTURE=true) that emits p50/p95/p99
+ trigger reason via the observeRenderStage telemetry channel extended in
#2510. Diagnostic surface only -- no perf fix, no behavior change on
healthy paths.
Stack: PR #9 (final) of 9 (base via/escape-hatch-fallback-reproducer).
Signed-off-by: Via
Field signal ts=1784039841 (win32/x64, CLI 0.7.57): shifted-DOM-image-
layer bug at frame 120 reproduces with BOTH PRODUCER_FORCE_SCREENSHOT=true
AND HF_DE_PARALLEL_ROUTER=false set. First case where both known escape
hatches fail simultaneously. Standalone 1920x1080 GSAP paused timeline,
absolute PNG layers, 4 independent scenes.
Ships a skipped regression fixture to codify the shape. NOT a fix — no
root cause identified. The composition is preserved so a future
diagnostic pass has a real, checked-in repro and a proposed fix can be
validated against the same shape the field reported.
Skip mechanism (belt-and-suspenders):
1. meta.json tagged `field-signal-reproducer` and `known-broken`;
producer/package.json test:regression* scripts add the tag to
--exclude-tags alongside the existing `transparency` skip.
2. Not registered in any .github/workflows/regression.yml shard's
args, so the CI regression sweep won't pick it up either.
Un-skip when a fix lands: drop the tags from meta.json AND add the
fixture id to a shard's args in the workflow. See src/README.md for
the field-signal envelope and diagnostic starting points.
Stack: PR #8 of 9 (base via/gpu-parity-gate).
Signed-off-by: Via