Commit Graph
862 Commits
Author SHA1 Message Date
xiayewang-heygenandClaude Opus 4.6 b021cb77be fix(engine): reconcile metadata duration after extraction delivers fewer frames (#4371)
When the probed video stream duration is inflated (container duration
fallback includes a longer audio track), FFmpeg delivers far fewer
frames than expected — typically exactly 50% for stock clips whose
audio is 2x the video length. The coverage gate then aborts the
render because it sees 50% coverage against the inflated expectation.

After extraction, if FFmpeg delivered <=60% of the predicted frames,
correct `metadata.videoStreamDurationSeconds` to the actual video
extent. This reconciliation feeds through to the coverage check via
the same `metadata` reference, so `expectedFramesForVideo` sees the
true duration without any interface changes.

This is a corrective complement to the preventive nb_frames
cross-check in extractMediaMetadata — it catches cases where
nb_frames is unavailable (MKV, live streams, fragmented MP4).

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-23 23:06:37 +00:00
Miguel Ángel 86c5c53d25 chore: release v0.8.68 (#4361) 2026-09-23 17:16:00 -04:00
Miguel Ángel 9cee2bd345 chore: release v0.8.67 (#4354) 2026-09-23 16:14:31 -04:00
Miguel Ángel 4efe5f206d chore: release v0.8.66 (#4344) 2026-09-23 13:29:38 -04:00
Val 30f548f62b fix: a stalled remote asset download no longer hangs a Cloud Run render (#4343)
* fix(gcp-cloud-run): run the Cloud Run image on Bun 1.4.2 so remote downloads cannot hang

* fix(engine): make the download deadline abort the body pipeline
2026-09-23 16:38:58 +00:00
Miguel Ángel aabfba5445 chore: release v0.8.65 (#4336) 2026-09-23 11:03:11 -04:00
Vance Ingalls ed75203cb6 chore: release v0.8.64 (#4326) 2026-09-23 04:03:02 -07:00
miga-heygenandMiguel Angel Simon Sierra 99221c50a5 chore: release v0.8.63 (#4312)
Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
2026-09-23 05:49:08 -04:00
miga-heygenandMiguel Ángel 29378b6c7f fix(engine): classify a single-keyframe video as problematic, not healthy (#3998)
* fix(engine): classify a single-keyframe video as problematic, not healthy

analyzeKeyframeIntervals short-circuited to isProblematic:false whenever
it found fewer than 2 keyframes, but exactly one keyframe is the worst
case for seek performance, not a healthy result: every seek past frame 0
must decode the entire file.

The single-keyframe branch treats the rest of the video stream's own
duration (not a container duration, which a longer audio track can
inflate) as one uninterrupted GOP. ffprobe's frame timestamps are
absolute presentation timestamps rather than relative to stream start,
so a re-muxed or trimmed clip with a nonzero start time is normalized
the same way extractFinalVideoFrameTimestamp already does for the same
quirk. The cache key now includes both values, matching that sibling
function's convention, since a stale cache entry from a different
duration/start pair would otherwise win silently.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* refactor(engine): share stream-window normalization between keyframe check and final-frame lookup

extractFinalVideoFrameTimestamp and analyzeKeyframeIntervals each
independently computed the same stream-start fallback, finite guard, and
cache-key format from the same metadata shape. Extracted resolveStreamWindow
so the two can't drift out of sync with each other.

Also: match ffprobe's real trailing-comma csv output in one keyframe mock,
add a test at the exact 2s threshold boundary, and comment the
noUncheckedIndexedAccess-only fallback at the single-keyframe branch.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* style(engine): trim long comments to one-to-three lines

---------

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-09-23 02:05:51 -04:00
Miguel Ángel 1b8f8a43d4 fix(cli): stopping the preview server no longer leaves a Chrome running (#4183)
* fix(cli): stop the preview server's browser when the server exits

Cancel in-flight renders and thumbnail launches before draining the
browser pool on shutdown, instead of only closing whatever browser was
already registered. A render whose Chrome died from the shutdown signal
itself was being misclassified as a transient failure and retried with a
fresh, untracked browser that outlived the process. Reject new render and
thumbnail requests once shutdown has begun, and await an in-flight
thumbnail launch before closing it.

* fix(cli): close preview browsers before a hung render, keep SIGINT armed

shutdown() awaited renders before closing browsers, so a render slower
than preview.ts 3s exit watchdog left Chrome running when it fired.
Close the thumbnail browser and drain the pool concurrently with, not
after, the render wait, and bound the wait under that watchdog.

A second Ctrl+C/SIGTERM during shutdown removed the one-shot signal
handlers, so it hit the OS default and killed the process before
cleanup ran. Use persistent handlers guarded by the existing
shuttingDown flag instead.

Also: getThumbnailBrowser could still hand a live lease to a request
that lands after shuttingDown flips true; trim a comment over budget;
replace a fixed-sleep test race with a drain-signal barrier.

* fix(engine): make browser pool shutdown terminal, not just draining

drain() resets its drainPromise to null once it settles, so acquire()
only waits for an in-flight drain -- a render still unwinding after
shutdown could relaunch Chrome the instant that drain resolved
(probeStage.ts:449-465 has exactly this gap between an abort check
and a later acquireBrowser call). No non-shutdown caller reuses the
pool after draining it (checked every drainBrowserPool()/drain()
call site), but added a separate terminal close() rather than
changing drain()'s own semantics, so a future reuse caller stays
safe by default.

BrowserLeasePool.close() sets a permanent closed flag before
draining, and acquire() checks it both before and after its one
await point, so a request already mid-await when close() lands still
sees it once that await resolves. studioServer's shutdown() now
calls the new closeBrowserPool() instead of drainBrowserPool().

Also bounds drain()'s own wait: a close() that hangs past 1s now
gets escalated to a force-close instead of blocking the caller
indefinitely, keeping total shutdown time under preview.ts's 3s exit
watchdog alongside the existing render-wait bound.

* fix(engine): trim closeBrowserPool JSDoc to house comment length
2026-09-22 22:49:44 -04:00
cc19b8c812 fix(server): contain static file requests within their served roots (#4217)
* fix(server): contain static file requests within their served roots

Static asset handlers joined the raw request path onto their root
directory without checking where it landed. Hono decodes percent
escapes while routing, so an encoded backslash (%5c) arrives as a real
separator on Windows and dot segments arrive already collapsed —
"/assets/..%5c..%5csecret" and "/%2e%2e%2fsecret" could read files
above the studio bundle root (embedded Studio server) and above
compiledDir/projectDir (engine render file server). Browsers never
request those shapes, but anything that can reach the bound port can:
raw local clients, LAN peers when the preview binds 0.0.0.0, and a
shared-machine process hitting the render server.

Both handlers now resolve the request path first and refuse anything
that collapses outside its root, using the same lexical containment
the preview asset route already applies — bundle/project assets behind
symlinked directories keep being served, only dot-segment escapes are
rejected. Route-level pathSafety tests pin the behavior in both
packages, including the Windows backslash shape and the
symlinked-asset cases from the descriptor-pinning regressions.

Validated: new containment suites fail on the prior handlers (200 +
marker file content served from outside the root) and pass with the
guards; cli server (140), engine services (1024+, matching the main
baseline incl. pre-existing browserManager failures), typecheck,
oxlint, oxfmt and the full workspace build all clean.

* fix: close fallow audit findings, prove containment cross-platform

Fallow audit (health/complexity): createFileServer's request handler
exceeded the CRAP-score threshold once the new containment checks were
added. Extracted resolveContainedPaths, a small pure function that
resolves and validates both roots, cutting the handler's own branching
back down.

Fallow audit (duplication): the new pathSafety test files copied their
temp-root/studioDir setup and teardown from the existing staticAssets
test wholesale. Extracted the shared parts into
studioServerTestFixture.ts (makeStudioServerRoot, writeStudioIndexHtml,
cleanupStudioServerRoot); only the per-file vi.mock("node:path", ...)
call stays duplicated, since Vitest requires it declared statically in
each test file and it cannot be moved behind a shared function -- that
one block keeps a narrow, targeted ignore comment instead of a
file-wide one.

Security claim: isWithinProjectRoot's %5c-decoded-backslash escape is
real, but only on Windows -- POSIX never treats a literal backslash as
a separator, and Hono leaves %2f undecoded in c.req.path, so none of
the new tests' encoded-traversal payloads produce an actual escape on
Linux (verified empirically: they pass identically with the fix
reverted). isWithinProjectRoot now takes an optional path-module
override (defaulting to the host's native node:path, so every existing
caller is unchanged) so its win32 and posix branches can be unit
tested directly from one OS, the same pattern already used by
producer/fileServer.ts's isPathInside. New tests cover a backslash
traversal, a mixed backslash/forward-slash traversal, and a
drive-letter path escaping the root under path.win32, plus the
existing posix case -- confirmed each one fails when the check is
removed. The HTTP-level tests stay as-is: real regression coverage for
them comes from the windows-latest CI job, which runs both new
pathSafety suites unfiltered.

---------

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
Co-authored-by: orphan4183 <kingenmaww@gmail.com>
2026-09-22 22:18:56 -04:00
Miguel Ángel d4485b51f1 chore: release v0.8.62 (#4293) 2026-09-22 16:12:35 -04:00
Vance IngallsandMiguel Angel Simon Sierra 8d2b6e7bdc chore: release v0.8.61 (#4283)
Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
2026-09-22 11:42:28 -04:00
Miguel Ángel b12e767e5a chore: release v0.8.60 2026-09-22 03:11:40 -04:00
Miguel Ángel c27bdda7d9 fix(engine): read extensible PCM and float WAV headers (#4267) 2026-09-22 00:15:42 -04:00
Miguel Ángel bf6cc7e32b chore: release v0.8.59 (#4252) 2026-09-21 19:07:41 -04:00
839ae426dd feat(studio): typed audio values, clip fades, audible video drops (#4250)
* feat(studio): typed audio values, clip fades, audible video drops

Audio rows in the media inspector (Volume, Speed, Media start) take a
typed value: click the readout, type "-6 dB", "1.5x" or "2.5s", Enter.
Speed presets are hidden on <audio> clips.

Clip-edge fades: data-fade-in / data-fade-out in seconds, honoured by the
preview runtime (per-tick gain), the render mixer (afade after volume) and
Studio (Fade in / Fade out rows, and drag dots at the top corners of audio
clips and audible videos). Linear on both sides so preview and render
agree; fades that outrun the clip scale to meet inside it.

Dropped videos with an audio stream now land with data-has-audio="true"
instead of muted, so the mixer keeps their sound. The media metadata
endpoint reports hasAudio from the ffprobe call it already makes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(audio): one owner for fades, dB, and afade-after-volume

Illegal fade values become no-fade at fadeGain; typed inspector input
still returns null so the readout stays red. Master and group mixes
share one filter chain with volume then afade. Fade-out stays anchored
to the trimmed clip end.

* fix(studio): share audio helpers and bound typed media values

* fix(studio): reserve sibling fade duration when editing

---------

Co-authored-by: Jake Moran <jakemoran2011@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
2026-09-21 18:45:08 -04:00
Vance Ingalls c797f54542 chore: release v0.8.58 (#4242) 2026-09-20 20:14:03 -07:00
Vance Ingalls f09ea7af3b chore: release v0.8.57 (#4231) 2026-09-20 14:13:17 -07:00
Miguel Ángel e45483aa0a chore: release v0.8.56 (#4218) 2026-09-20 10:57:46 -04:00
Vance Ingalls 96369c8acd chore: release v0.8.55 (#4216) 2026-09-20 00:45:18 -07:00
Miguel Ángel c204b3c757 chore: release v0.8.54 (#4212) 2026-09-20 00:22:52 -04:00
Vance Ingalls d11907c325 chore: release v0.8.53 (#4210) 2026-09-19 20:30:29 -07:00
Miguel Ángel e778100b48 Merge branch 'main' into release/v0.8.52 2026-09-19 18:33:29 -04:00
Miguel Ángel 0cde884330 fix(cli): macOS 12 users get Chrome 150 automatically and can render (#4185)
* fix(cli): pick a macOS 12 capable Chrome on macOS 12 instead of the pinned build

* test(engine): stop building subprocess eval scripts from strings
2026-09-19 18:08:29 -04:00
Vance Ingalls 4d62e80921 chore: release v0.8.52 2026-09-19 14:38:56 -07:00
Vance Ingalls 1dce615c56 chore: release v0.8.51 (#4178) 2026-09-19 08:51:27 -07:00
Miguel Ángel 3f3f3b7f10 fix(engine): audio source-not-found names the file and shares one src resolver with lint (#4172)
* fix(engine): audio source-not-found names the file and shares one src resolver with lint

* fix(engine): audio source-not-found prints the resolved path with forward slashes on Windows

* fix(parsers): the shared src resolver ignores a media fragment like the browser
2026-09-19 10:07:22 -04:00
Miguel Ángel bdecae066a feat(engine): render speed ramps with the same source-time mapping as the preview (#4143)
* feat(engine): render speed ramps with the same source-time mapping as the preview

The frame extractor, frame lookup table, coverage check, compile-time duration and audio mixer
read a clip's rate lane through sourceTimeAt, so a rendered frame at t shows the source frame
the preview shows. Audio bakes a ramp as consecutive source slices stretched by their mean
rate, keeping pitch. The compile-time attribute reader now handles single-quoted and
entity-encoded JSON attributes. Adds a speed-ramp regression fixture.

* test(producer): regenerate the speed-ramp golden audio with the current engine

The stored baseline predates the per-slice audio fix, so its audio drifted 1 to 3 percent in
pitch from the expected source-time track and the CI render correlated at 0.61. Rendered in the
CI test image, the new baseline tracks the expected pitch to 0.1 percent median, and the compare
now reports visual 0 failed frames and audio correlation 1.000.

* refactor(core): drop data-preserve-pitch, pitch is preserved by construction

The engine always time-stretches audio with atempo, so an opt-out read only by
the preview made preview and render disagree. Remove the attribute and its
reader so both paths keep pitch.
2026-09-19 08:01:17 -04:00
Miguel Ángel 7f452ad276 feat(core): speed ramps as a rate lane in data-automation (#4129)
* feat(core): speed ramps as a rate lane in data-automation

A clip can carry a rate lane in data-automation. sourceTimeAt integrates it to source time and is the one mapping the runtime uses for media seeking, playback rate, natural duration, child timelines and the audio-master clock. Rate is clamped to 0.1-10 in one place, pitch is preserved unless data-preserve-pitch is false, and ramped clips play through the media element. Lint warns on a rate lane outside video and audio.

* fix(core): format the speed-ramp files and expect the unified rate clamp in the engine tests

* test(engine): read the playback-rate clamp from core instead of a literal
2026-09-19 06:27:42 -04:00
Miguel Ángel 0a5c235173 feat(cli): hyperframes timeline prints a project's tracks and clips (#4138)
* feat(cli): hyperframes timeline prints a project's tracks and clips

* docs(cli): list the timeline command

* feat(cli): timeline command reports unreadable lanes; add skill docs

* fix(cli): timeline stays inside the project, shares one start cache, keeps small volumes

* fix(cli): timeline resolves symlinks before the project containment check

* fix(cli): timeline accepts project folders whose name starts with two dots

* refactor(cli): timeline and compositions share the engine's start resolver

* docs(skills): point agents at the timeline command where they decide how to read a project

* fix(cli): timeline prints absolute main-timeline times and the owning file for nested clips

Nested rows carry absStart/absEnd on the main timeline and their owning
file, alongside existing local start/end. Text prints absolute time first,
local time in parentheses. Adds jq/node one-liners for common queries,
tested against a fixture.

* fix(cli): timeline places nested media at the runtime start and tests its query one-liners

A nested media clip with a negative literal data-start was reported later than it plays,
because the raw start was clamped before the host offset was added. The skill's jq and node
one-liners now run against the fixture in the test suite.

* fix(cli): timeline clamps a negative media start to 0 when its host starts at 0

The runtime skips the host-offset formula for a host at t=0 and clamps the start, so the
timeline command now does the same.

* refactor(core): one media-start rule shared by the runtime and the timeline command

The runtime and the CLI each carried a copy of the rule for when a nested media clip's
start takes the host offset, and the copies had drifted. Both now call
resolveMediaStartSeconds.

* test(cli): timeline fixture for a media start written as an expression

parseNumeric and the runtime's parseStrictFiniteTimingNumber are the same function, so the
two callers cannot disagree on what counts as a literal; this pins the expression case.
2026-09-19 04:27:45 -04:00
Miguel Ángel 607775bb3a fix(engine): make urlDownloader retry tests independent of wall-clock time (#4155) 2026-09-19 03:07:27 -04:00
Vance IngallsandClaude Fable 5.1 439bdbd774 fix(engine): pin the pool's worker-failure contract and survive a throwing hook
The streaming stage's peer-death test replays the contract "hook first, with
the original CaptureFailure, then the peers abort and the pool rejects with
its own summary" inside its mock, and nothing on the engine side pinned it.
The gate now lives in createPoolFailureHandler, tested without a browser:
the hook sees the original failure before the peer signal flips, a hook
that throws can no longer skip the abort (the pool still reports the
classified failure, not the hook's error), failures after the first are
ignored, and a cancellation leaves the peers running. Mutation-checked:
removing the try/finally, swapping the order, or dropping the first-failure
guard each fails one test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 20:53:12 -07:00
Vance IngallsandClaude Fable 5.1 5273554516 fix(engine): a worker death is pool-fatal on the streaming path
On the disk path a transient worker death is deliberately non-fatal: the
orchestrator's adaptive retry re-captures that worker's missing frames. The
streaming path has no per-worker retry and the dead worker's frames are
gone, yet it inherited the same gating — so its peers parked in the ordered
writer waiting for a frame that would never come, and sixty seconds later
the no-progress watchdog relabelled the death as a stall. That is the
`stuck at k/N` field signature.

isPoolFatalWorkerFailure splits the two: every non-cancelled failure ends a
streaming pool, and a new onWorkerFailure hook delivers the ORIGINAL failure
to the caller before peers are aborted, so the ordered writer can be released
with it rather than with a stall. Mutation-checked in both directions,
including that `cancelled` never propagates (the pool is already aborting).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 20:53:10 -07:00
Vance IngallsandClaude Fable 5.1 95cbf56634 fix(engine): classify a broken encoder pipe as io, not authoring
EPIPE (darwin/linux), EOF (win32's UV_EOF for a closed named pipe) and
ECONNRESET all mean the process reading our stdin has gone. Bucketed as
authoring, telemetry blamed the composition for a host event. Matched on
the errno code and on the message, since the code is lost when a worker
pool flattens the error to a string.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 20:53:09 -07:00
Vance IngallsandClaude Fable 5.1 69024e17d1 fix(engine): keep ffmpeg's exit reason when it dies under a parked write
The field "write EPIPE" / "write EOF" render errors are this path:
writeFrame waits on once(stdin, "drain") for back-pressure to clear, ffmpeg
dies first, and Node rejects the drain wait with the stream's own error.
That rejection escaped writeFrame as the render error, so ffmpeg's exit code
and stderr — captured, but read only from the `false`-return path — were
discarded. With N workers feeding one encoder the writer is parked most of
the time, which is why the parallel cohort reads EPIPE where a single-worker
render reads "Streaming encoder exited before frame N: FFmpeg exited with
code …".

Wait for the exit to settle, then report it as the close race does. Both
halves are mutation-checked: rethrowing again fails the test, and returning
"exit" without awaiting settlement fails the synchronous getExitStatus read.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 20:53:09 -07:00
Vance IngallsandClaude Opus 5 46d852cc93 refactor(engine): extract concatVideoFiles from the chunked encoder
Segmented capture needs the same concat-demuxer stream copy the chunked
encoder already does. Behaviour preserved, including the
external_interruption classification the caller maps to a retryable
failure: the helper reports it rather than swallowing it. The concat list
keeps living beside its inputs, because concurrent encodes share an output
directory but not a chunk directory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 20:53:00 -07:00
Vance IngallsandClaude Opus 5 2cf8024a7c fix(engine): read Chrome process info from the browser target, not the page
SystemInfo.getProcessInfo is served only by the browser target; a
page-target session rejects with "is only supported on the browser
target". The sampler swallows probe failures by design, so this shipped as
a permanently empty reading: a 10 s debug render reported chromeMemory
null with capturePath already correct. Against the browser target the same
render reports browser 105 MB / renderer 359 MB over 3 samples.

readChromePids is extracted so the target choice is pinned by a test whose
fake rejects a page session the way Chrome does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 20:52:25 -07:00
Vance IngallsandClaude Opus 5 428922162d feat(engine): sample Chrome process memory per capture session
Start a 2 s unref'd sampler when a session finishes initializing, stop it
in closeCaptureSession, and report peaks on CapturePerfSummary. Pids come
from puppeteer (browser) and CDP SystemInfo.getProcessInfo (renderer, GPU),
so the long-render "Target closed" class can be correlated with Chrome
memory instead of guessed at. Disable with HF_CHROME_MEMORY_SAMPLER=false;
cadence HF_CHROME_MEMORY_SAMPLE_MS.

The start hook is finalizeSessionInit rather than the tail of
initializeSession: that helper is already the single owner of "session is
ready" across both of initializeSession's exits, so the early screenshot
return cannot silently skip sampling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 20:52:03 -07:00
Vance IngallsandClaude Fable 5.1 a35168eddd fix(engine): sampleOnce() joins an in-flight sample instead of skipping it
With a boolean reentrancy flag, the Task 3 close sequence
(`stop(); await sampleOnce()`) returned immediately whenever an interval
tick was still awaiting CDP/ps, so getCapturePerfSummary read stats from up
to one interval earlier and onSample fired once more after the session had
closed. Reproduced with a 350 ms getPids on a 100 ms interval: close
resolved with samples=0 and the callback landed afterwards.

The guard now keeps the in-flight promise and returns it to any caller, so
an interval tick still never stacks a second round-trip (the existing
reentrancy test holds) while a close-time call resolves only after the
running tick has merged. Deviates from the plan's reference body, which
used `let inFlight = false`; the ChromeMemorySampler interface and all
names are unchanged, only the sampleOnce() contract is stronger.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 20:52:02 -07:00
Vance IngallsandClaude Fable 5.1 7cf57fa42e test(engine): pin the unref'd sampler interval via a cast-free timer seam
Three independent reviewers found that deleting timer.unref() in
createChromeMemorySampler left the suite green: the plan's Global Constraint
(fire-and-forget on an unref'd interval) had no failing test. The new test
captures the real NodeJS.Timeout through the setIntervalFn seam and asserts
hasRef() is false after start(); it goes red when the unref() call is
removed.

The seam could not be used without a cast as typed in the plan
(`typeof setInterval`): with the DOM lib in scope that type also carries the
`(handler, timeout) => number` overload, which a capturing wrapper cannot
satisfy. setIntervalFn / clearIntervalFn keep their names but are now typed
to the two-argument shape the sampler actually calls; the global timers are
assignable to those shapes, so the defaults are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 20:52:02 -07:00
Vance IngallsandClaude Fable 5.1 53403a5896 feat(engine): chrome memory sampler with live onMemorySample capture option
Pure service for Phase -1 of the long-form render plan: merges per-pid RSS
rows into browser/renderer peaks, last-sample total and GPU presence on an
unref'd interval with a reentrancy guard, so a CDP or ps failure can never
reach the capture loop. CaptureOptions.onMemorySample is the live route the
producer will wire so a mid-render crash still reports memory state. Nothing
instantiates the sampler yet (Task 3); no behaviour change.

Beyond the plan's tests, pins the double-start, empty-sample and catch
guards directly, since the interval-based error test stays green when the
catch is removed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 20:52:02 -07:00
Vance IngallsandClaude Fable 5.1 1862c34399 test(engine): fix readonly array type in the pid-filter test
`readonly string[][]` parses as `ReadonlyArray<string[]>`, so `calls.push`
was a TS2339 in 5214552cd. It slipped through because the engine tsconfig
excludes `src/**/*.test.ts` and vitest strips types; caught by running tsc
on the test file explicitly. `Array<readonly string[]>` is what the
neighbouring "one ps call" test already uses.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 20:52:01 -07:00
Vance IngallsandClaude Fable 5.1 3cee3b94f7 fix(engine): read /proc/<pid>/status for RSS on linux instead of shelling out to ps
Every render image (packages/cli/src/docker/Dockerfile.render,
packages/gcp-cloud-run/Dockerfile, examples/k8s-jobs/Dockerfile.example,
Dockerfile.test) is node:22-bookworm-slim with no procps, so `ps` is ENOENT
in containers and sampleProcessRss would return [] on every tick: the whole
cloud fleet would report chrome_memory_samples: 0, indistinguishable from
"no Chrome pids". Linux now reads VmRSS from /proc/<pid>/status with a
per-pid try/catch, so one renderer that exited between discovery and
sampling omits only itself (with `ps` a wholly-dead batch fails the call).
No child process per tick on the primary server platform. Same precedent as
the CLI's orphanCleanup.ts / processTree.ts. `ps` stays for darwin and
other POSIX; `tasklist` for win32. The reader is an optional fourth
parameter with a default, so Task 2/3 callers are unaffected.

Scope note: spec section 2.5 records the Target-closed population as
docker=false throughout, so this closes a render_complete completeness gap
on cloud, not a coverage gap on the crash class this phase targets.

Verification: 15/15 vitest; four linux-branch mutants (per-pid catch
rethrows, regex loses /m, kb <= 0 weakened, path /status -> /stat) each
fail at least one test, restored green. Real-process smoke check on darwin:
sampleProcessRss([process.pid, <exited pid>]) returns one sample for the
live pid (BSD ps exits 0 with partial output when a valid-range pid is
gone). The linux path is unit-tested against a /proc/<pid>/status fixture
only; no Linux host or VM was available in this session to execute it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 20:52:01 -07:00
Vance IngallsandClaude Fable 5.1 645cbdce4e test(engine): pin the pid filter, dedup and tasklist memory guard in sampleProcessRss
Review of 76d8e2682 found three surviving mutants: removing the
`Number.isInteger(p) && p > 0` filter, dropping the `new Set` dedup, and
weakening `kb <= 0` to `kb < 0` all passed the plan's 8 tests. The filter is
the guard that matters most: Task 2 feeds `browser.process()?.pid`, and a
single NaN or undefined reaching `ps -p` makes ps exit non-zero, which the
never-rejects catch turns into `[]` for every pid in the sample.

Mutation check: each of the three mutants now fails exactly one test
("drops invalid pids and duplicates before calling ps" for the filter and
dedup, "returns [] when the memory column is not a positive number" for the
tasklist guard); restored source is 10/10 green.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 20:52:01 -07:00
Vance IngallsandClaude Fable 5.1 bc70156f00 feat(engine): zero-dependency per-pid RSS sampler (ps / tasklist)
First piece of the long-form render Phase -1 telemetry: the Chrome memory
sampler needs resident set size per pid without adding pidusage or
systeminformation. `ps -o pid=,rss=` covers POSIX in one call for every pid;
`tasklist /FO CSV` covers win32 one pid at a time. The function never rejects
and omits pids that exited between discovery and sampling, so a failed sample
can never reach the capture loop.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 20:52:00 -07:00
Vance Ingalls 2b2b999e62 Merge pull request #4109 from heygen-com/lfr/02-phase0-duration-cap
feat(producer): stream long single-worker renders by default; duration cap behind a flag (Phase 0)
2026-09-18 20:50:36 -07:00
Vance Ingalls 42f4afa367 chore: release v0.8.50 2026-09-18 19:41:06 -07:00
Miguel Ángel 9981d9afb7 fix(capture): never silently render a webgpu composition blank (#4091)
* fix(capture): never silently render a webgpu composition blank

A composition declaring data-requires-webgpu only got the Chrome WebGPU
flag when hardware GPU auto-detection succeeded. On a software-GPU host
it launched without the flag and rendered blank with no error.

compositionRequiresWebGpu (static declaration check) and
assertWebGpuAdapterAvailable (live per-page adapter probe) now live once
in packages/engine's browserManager, the shared choke point every launch
site already goes through. All six launch sites (frame capture, the four
CLI diagnostic commands, the studio thumbnail server, and the render
pipeline's capture-options builder) route through those two functions;
the old CLI-only static-heuristic guard is deleted, not kept alongside
the new one. A software host that cannot obtain any WebGPU adapter now
fails loudly naming the requirement instead of producing a blank frame.

* fix(capture): thread requiresWebGpu through the distributed chunk-render path

The distributed renderChunk() path built its CaptureOptions without
requiresWebGpu, so it fell through to frameCapture's own fetch-based
detection, whose failure path silently treats "requires webgpu" as
"doesn't" if that fetch ever fails. Every other launch site (frame
capture, all four CLI diagnostic commands, the studio thumbnail server,
and the in-process render pipeline) already reads the value from HTML
already in memory or on disk instead of relying on that fallback.

Reads the compiled composition's index.html the same way probeStage
already does and derives requiresWebGpu from it before building
CaptureOptions. Adds a regression test that pins the exact path
renderChunk() reads at render time, since the composition doesn't
actually exercise WebGPU so an end-to-end render can't distinguish the
two cases on this host.

* test(producer): observe the real CaptureOptions renderChunk() builds

The previous regression test called plan() and compositionRequiresWebGpu()
directly, bypassing renderChunk() entirely, so it could not detect a
regression to the wiring it was meant to protect (confirmed: it stayed
green with the fix reverted).

Exports the existing createCaptureSession dependency seam so a test can
substitute a spy that delegates to the real implementation while
capturing the CaptureOptions object renderChunk() actually constructs.
Verified non-vacuous by reverting the fix and confirming the test fails.

* fix(gcp-cloud-run): resolve puppeteer-core from a real file in the webgpu probe

The image build's WebGPU adapter probe imported puppeteer-core from an
inline `bun -e` eval string. The eval's synthetic /app/[eval] location
can't resolve the workspace-hoisted dependency, so every build of this
stage failed closed with "Cannot find package 'puppeteer-core'" before
the probe itself ever ran.

Moves the probe into a real script file next to probe-beginframe.ts,
which already imports puppeteer-core dynamically the same way and
resolves fine from its real workspace location. Verified locally against
a cached chrome-headless-shell binary.

* fix(engine): bound the webgpu adapter probe so a stuck driver can't hang the caller

requestAdapter() has no native timeout. A broken software GPU driver
that never settles it would hang assertWebGpuAdapterAvailable
indefinitely, including inside the GCP image build's probe RUN step,
producing an opaque build timeout instead of a clean probe failure.

Races the in-page request against a bounded timeout so a stuck adapter
reads as unavailable and fails the same way a genuinely absent one does.

* test(cli): mock the engine's webgpu exports in the browser-fallback test

* test(producer): run the typegpu adapter fixture only on hosts with a hardware WebGPU adapter

The fixture declares data-requires-webgpu, so a software-only host now refuses it
instead of silently rendering the shader blank. The old golden had no shader in it.
Tag it webgpu, skip that tag in the CI image and the test scripts, and re-record the
golden from a Metal render with the shader visible.

* fix(engine): bound the composition fetch that decides the webgpu flag

createCaptureSession fetched the served page with no timeout before launching the
browser, so a hung server stalled a render forever and hung a fake-timer test. Give it a
five second limit, and let that test say it has no WebGPU requirement the way the
producer does.

* fix(capture): refuse a software fallback WebGPU adapter instead of rendering on it

swiftshader reports a WebGPU adapter but cannot render canvas WebGPU, so a
data-requires-webgpu composition on a software-only host crashed on its first frame.
Refuse fallback adapters with the same clear WebGPU-unavailable error. The GCP image
probe now serves its page over http (file:// hides navigator.gpu) and reports a
software-only host instead of failing the image build.

* fix(engine): parse the composition root tag without a backtracking regex

* fix(engine): keep quoted attribute values whole when reading the composition root tag
2026-09-18 18:06:23 -04:00
Miguel Ángel cb9ca4f0af chore: release v0.8.49 (#4122) 2026-09-18 17:44:18 -04:00