Commit Graph
100 Commits
Author SHA1 Message Date
James Russo b6b6b8ed3b docs(lambda): add migration guide + non-Lambda Dockerfile example (#915)
Two adopter-facing artifacts that close out Phase 6b's user-facing
surface:

  - docs/deploy/migrating-to-hyperframes-lambda.mdx — side-by-side
    concept mapping for users coming from another one-command-deploy
    video renderer. Covers the verb mapping (deploy/render/progress/
    destroy/sites/policies), composition format (plain HTML vs JSX),
    render config, and a handful of intentional differences (no HDR
    in distributed mode, no webm, gpu-mode=software requirement,
    fail-closed font fetch, local stack-state files, narrow-after-
    first-deploy IAM pattern). Closes with a migration checklist.
    Per repo convention, no competitor framework is named anywhere
    in the source — adopters self-identify.

  - examples/k8s-jobs/Dockerfile.example + README.md — reference
    Dockerfile for adopters who want to run distributed renders
    outside AWS Lambda. Bakes Node 22 + chrome-headless-shell +
    ffmpeg + the producer source. Deliberately not published to
    a registry; adopters build it themselves so Chrome / ffmpeg /
    producer versions stay pinned to the checkout they audited.
    The README documents the typical K8s Jobs orchestration shape
    that points adopters at packages/aws-lambda/src/handler.ts as
    the reference adapter.

Migration guide registered under the existing Deploy group in
docs.json. .gitignore extended to negate the new examples/k8s-jobs/
path the same way examples/aws-lambda/ is negated.

No source code changes.
2026-05-17 14:12:22 -04:00
James Russo a1a8c7790f docs(lambda): add docs/deploy/aws-lambda.mdx deployment guide (#914)
* docs(lambda): add docs/deploy/aws-lambda.mdx deployment guide

End-to-end deploy guide for the AWS Lambda surface. Covers:

  - Architecture diagram (Step Functions Plan → Map(N) → Assemble +
    the single Lambda function dispatching by Action; pulled from
    the distributed rendering plan §15.2).
  - Prerequisites table (AWS creds, SAM CLI, bun, repo checkout).
  - Three deployment paths: hyperframes lambda CLI (recommended),
    direct sam deploy against examples/aws-lambda/template.yaml,
    and HyperframesRenderStack CDK construct.
  - IAM bootstrap via hyperframes lambda policies user/role/validate.
  - Cost shape — how Lambda GB-seconds + SFN transitions roll up
    into the displayCost the progress verb prints.
  - Troubleshooting block with the typed error names operators
    actually hit (PLAN_HASH_MISMATCH, BROWSER_GPU_NOT_SOFTWARE,
    iam:CreateRole denial, stuck RUNNING, S3 Retain semantics).
  - "What's NOT in v1" callout so adopters don't burn time looking
    for webhooks / compositions verb / HDR support.

Registered under a new "Deploy" group in docs.json's Documentation
tab, sitting after Packages so the conceptual flow is "what you
can build" → "how to ship it."

No code changes.

* docs(lambda): address PR review on AWS Lambda deployment guide

One blocker + two important items from Vai's review:

  - The BROWSER_GPU_NOT_SOFTWARE troubleshooting entry pointed
    adopters at a non-existent `data-gpu-mode` composition attribute.
    Replaced with the actual root cause (Chrome launch flags +
    @sparticuz/chromium libs in the handler ZIP) and the actual
    remediation: rebuild + redeploy via `lambda deploy` (which
    always rebuilds the ZIP). The composition-attribute story
    would have sent users editing the wrong file entirely.

  - Added a `sites create` subsection under Path 1 so adopters
    running tight inner loops know how to reuse a project upload
    across many renders instead of re-tarring + re-uploading on
    each call. The CLI surface was first-class but the doc had
    been silent.

  - Added a Warning callout under Path 2 explaining that the SAM
    template's own ReservedConcurrency default is `-1` (unreserved)
    — a reader simplifying the Path 2 example by dropping the
    --parameter-overrides flag would silently switch to unreserved
    concurrency and pay the runaway-Map cost. The warning mirrors
    the cost-shape callout earlier in the page.
2026-05-17 14:08:22 -04:00
James Russo b05a22e69e feat(producer): add --mode=lambda-local to the regression harness (#913)
* feat(producer): add --mode=lambda-local to the regression harness

Third harness mode that drives the OSS @hyperframes/aws-lambda handler
through the exact event sequence Step Functions produces in
production:

  handler({Action: "plan"})             → planDir tarball on fake S3
  handler({Action: "renderChunk"}) × N  → chunk artifacts on fake S3
  handler({Action: "assemble"})         → final mp4/mov/png-sequence

The S3 client is a filesystem-backed fake (every s3://<bucket>/<key>
URI maps to <tempRoot>/s3/<key>), so the harness exercises the
handler's event-parsing + tar/S3 conventions + dispatch logic on top
of the underlying producer primitives. Regressions in event JSON
shape, S3 key layout, or plan-hash boundary checks now surface in
the same CI run as the in-process and distributed-simulated modes
without paying for a real AWS round-trip.

Deliberately NOT a Docker/RIE invocation — that would gate the
producer test suite on Docker-in-Docker support which most CI
runners lack. Real-ZIP-via-RIE tests live in
packages/aws-lambda/scripts/ (probe:beginframe) and the
maintainer-run smoke.sh.

Wired up via:
  - HarnessMode union extended to include "lambda-local"
  - parseHarnessModeFlag accepts --mode=lambda-local
  - regression-harness.ts dispatches to runLambdaLocalRender for
    the new mode, sharing the distributed-support gate +
    pathology-floor threshold with distributed-simulated mode
  - package.json scripts: test:lambda-local + docker:test:lambda-local
  - producer.devDependencies += @hyperframes/aws-lambda (workspace)
  - producer/tsconfig.json gains path mappings to self so the type
    cycle through aws-lambda's source resolves at typecheck time
    without needing producer to be pre-built

Tests: 3 new unit tests on parseHarnessModeFlag + resolveMinPsnrForMode
cover the new mode. End-to-end PSNR contract still runs through
Dockerfile.test (manual + CI).

* refactor(producer): /simplify pass on lambda-local harness imports

Three small cleanups on top of the lambda-local harness:

  - Drop the unused createReadStream import + its `void` workaround
    comment. The aws-lambda handler's tar / S3 transport pulls
    createReadStream from its own imports; this file never references
    it directly.

  - Hoist the dynamic `await import("node:fs")` calls for
    writeFileSync out of FilesystemBackedFakeS3.send into the static
    import block. Repeated PutObject calls don't need to repay the
    dynamic-import cost.

  - Hoist the dynamic `await import("@hyperframes/aws-lambda")` call
    for untarDirectory similarly. Drops the now-redundant duplicate
    aws-lambda import statement.

The PutObject body branch also collapses: `body instanceof Buffer`
and `typeof body === "string"` both call writeFileSync identically,
so they share one branch.

No behavior changes.

* fix(producer): lazy-import lambda-local harness module

The static import of regression-harness-lambda-local.ts pulled
@hyperframes/aws-lambda (and its @aws-sdk/* + @sparticuz/chromium
transitive deps) at module-load time. Dockerfile.test only copies
the producer's own files into the container, so aws-lambda's src
isn't present at runtime — and even `--mode=in-process` failed:

  Error [ERR_MODULE_NOT_FOUND]: Cannot find module
  '/app/packages/producer/node_modules/@hyperframes/aws-lambda/src/index.ts'
  imported from /app/packages/producer/src/regression-harness-lambda-local.ts

Load the module on demand instead. `--mode=lambda-local` callers
pay the import cost; the existing in-process and distributed-
simulated modes don't.

* fix(producer): address PR review on lambda-local harness

Three review items from Vai:

  - `Config.width`/`Config.height` are now plumbed through
    RunLambdaLocalInput rather than hardcoded inside
    runLambdaLocalRender. Lambda-local's whole point is to catch
    event-shape drift; if the handler ever starts honouring
    Config.width/height (e.g. for canvas sizing), having those
    values flow from the caller means the harness sees what the
    fixture authored. The interface change makes the eventual
    upgrade-to-real-fixture-resolution a one-line dispatch swap.

  - Drop the dead `export type { Fps }` and its unused import
    from @hyperframes/core. The module never re-exports it.

  - The dispatch site in regression-harness.ts now passes 1920×1080
    explicitly with a comment marking it as a placeholder until
    the harness compiles the composition HTML up-front to surface
    the authored data-width/data-height. distributed-simulated
    mode uses the same placeholder internally, kept for parity.

No behavior change in the existing modes; lambda-local now has a
clear extension point for honouring fixture dimensions.
2026-05-17 14:04:49 -04:00
James Russo 62ddd29b74 feat(cli): add hyperframes lambda policies role/user/validate (#912)
* feat(cli): add hyperframes lambda policies role/user/validate

IAM bootstrap subcommand for the lambda CLI. Closes the "first run hits
'User is not authorized to perform iam:CreateRole'" gap that adopters
otherwise have to figure out by hand.

  hyperframes lambda policies user
    → prints an inline-policy doc to attach to the IAM user that runs
      the CLI

  hyperframes lambda policies role --principal=cloudformation
    → prints { TrustRelationship, InlinePolicy } for a service role
      cloudformation can assume

  hyperframes lambda policies validate ./infra/policy.json
    → diffs a checked-in policy against the CLI's required action set,
      expanding s3:* / s3:Get* / * wildcards, exits non-zero on missing
      actions (wire it into CI to catch drift before deploys fail)

The required-actions list is derived from what the SAM template at
examples/aws-lambda/template.yaml needs to create plus what
renderToLambda/getRenderProgress call against S3 + Step Functions at
runtime. Sorted alphabetically per-service so diffs stay readable.

Resource is "*" by design — CloudFormation creates new function /
state-machine / bucket ARNs on every adopter's first deploy. The
generated policy is documented as a starting point; adopters with
stricter postures narrow Resource to the deployed ARNs after the
first successful run.

Tests: 10 unit tests covering the action set, doc shape, trust policy
service principal, and validate() against valid / missing / wildcard /
single-Statement / Deny-statement inputs.

* refactor(cli): /simplify pass on lambda policies

Adds a typed TrustPolicyDocument / TrustPolicyStatement pair so
buildRoleTrustPolicy can return a real type instead of unknown. The
trust-policy shape has a Principal field that the generic
PolicyStatement doesn't model, but it was previously punted via a
return unknown rather than a parallel type.

Test cleanup: drop the `as {...}` casts that the previous return-
unknown signature forced.

* fix(cli): address PR review on lambda policies

One blocker + four importants from Vai's review:

  - REQUIRED_ACTIONS was missing `s3:ListAllMyBuckets` (called by
    `sam deploy --resolve-s3` on first run to discover/create the
    `aws-sam-cli-managed-default-*` artifact bucket) and
    `cloudformation:ValidateTemplate` (CFN template validation
    during change-set creation). Without these, a first-deploy
    adopter with the generated policy hits AccessDenied on the
    very call the PR was meant to unblock. Added both.

  - `policies role --principal=lambda` was a footgun — it produced
    a `lambda.amazonaws.com` trust paired with the full deploy
    superset, i.e. a confusingly-overscoped Lambda execution role
    no human should attach (the SAM template creates its own
    scoped execution role automatically). Dropped `lambda` as a
    principal option; `policies role` now always emits a
    CloudFormation service-role doc.

  - `validatePolicy` silently misreported NotAction/NotResource
    statements (treating them as zero grants), producing false
    negatives. Detect both shapes and surface them via a new
    `warnings: string[]` field; NotAction statements are skipped
    (rather than producing a false negative), NotResource is
    treated as full action grant + a warning.

  - Mid-string wildcards (`s3:Get*Object`, `?`) silently failed
    the matcher. End-anchored wildcards still work; mid-string
    patterns now warn so users know the validator can't expand
    them.

  - Dropped the dead `samArtifactBucket` action group (fully
    subsumed by `s3Bucket` + `s3Object`).

  - `validate --json` now wraps errors in a friendly envelope
    (`{ ok: false, error: "..." }`) so CI consumers have one
    parse shape regardless of failure mode.

  - lambda.ts subcommand description and examples updated to
    include `policies`.

Tests: 5 new negative-path tests cover NotAction warning,
NotResource warning, mid-string wildcard warning, missing file
(ENOENT), malformed JSON (SyntaxError), and absent Statement
field. All 21 policies tests pass.
2026-05-17 13:15:01 -04:00
James Russo e90ad2da61 feat(cli): add hyperframes lambda deploy/render/progress/destroy (#910)
* feat(cli): add hyperframes lambda deploy/render/progress/destroy

Wraps the @hyperframes/aws-lambda SDK + the Phase 6a SAM template behind
a single CLI surface so an end-to-end render is three commands instead
of the ~8 manual bun+sam+aws steps the smoke script does today:

  hyperframes lambda deploy
  hyperframes lambda render ./my-project --width 1920 --height 1080 --wait
  hyperframes lambda destroy

Subcommands:
  - deploy:        build handler.zip + sam-deploy + persist stack outputs
                   to <cwd>/.hyperframes/lambda-stack-<name>.json
  - sites create:  pre-upload a project to S3 with a stable content hash
                   so re-renders skip the tar+PUT pass
  - render:        start a Step Functions execution; --wait blocks and
                   streams per-chunk progress + accrued cost
  - progress:      one-shot snapshot — status, frames, cost breakdown,
                   errors. Accepts renderId or executionArn
  - destroy:       sam-delete + drop the local state file (S3 bucket
                   is Retain'd by the template; documented in --help
                   and in docs/packages/cli.mdx)

To keep @sparticuz/chromium out of the CLI's transitive deps, this also
adds a dedicated ./sdk subpath export to @hyperframes/aws-lambda; the
CLI imports from @hyperframes/aws-lambda/sdk exclusively. The existing
. barrel still re-exports both handler + SDK for adopters who want one
entry point.

Defaults are deliberately cost-conservative for first-time users:
--concurrency=8 (low enough to never surprise) and --memory=10240 (the
common case; documented for adopters who want to tune down).

Tests: 5 unit tests on the state-file round-trip. CLI integration
against sam local invoke is part of the upcoming PR 6.6 (lambda-local
regression harness).

* refactor(cli): /simplify pass on the lambda command group

Two small cleanups on top of the lambda CLI:

  - Replace parseFormat / parseCodec / parseQuality / parseChromeSource
    (four near-identical helpers) with a single generic parseEnum() +
    typed const-tuple lookups. The four callers now read as one-line
    arrow functions that lift the allowed values out of the function
    body so they're easy to extend.

  - DEFAULT_STACK_NAME was const-declared then re-exported at the
    bottom of state.ts; just mark the const export inline.

No behavior changes. All CLI tests still pass.

* fix(cli): keep @hyperframes/aws-lambda external in the tsup bundle

esbuild can't bundle @hyperframes/aws-lambda's transitive AWS SDK
deps (@aws-sdk/* + @smithy/*) cleanly into a node binary — the
SDK's .browser.js conditional re-exports break the resolver:

  ESM Build failed
    No matching export in "splitStream.browser.js" for import
    "splitStream" (and ~10 similar errors)

Mark aws-lambda as `external` so esbuild doesn't follow it, and
move it from devDependencies to dependencies so the published CLI
can resolve it from node_modules at runtime. The lambda subverb
files dynamic-import only on `hyperframes lambda *` invocation, so
the CLI cold-start cost is unchanged.

The install-size hit (AWS SDK + @sparticuz/chromium ≈ 200 MiB) is
documented as a v1 tradeoff; a future split into a lambda-sdk-only
subpackage can pare this back.

* fix(cli): address PR review on lambda CLI

Two blockers + four important items from Vai's review:

  - `--memory` was parsed and recorded in the local state file but
    never forwarded to `sam deploy` as a parameter override. Worse,
    `progress.ts` then read the *recorded* value for cost math, so
    `--memory 5120` produced wrong cost numbers downstream. Thread
    `LambdaMemoryMb` through samDeploy's --parameter-overrides.

  - `--profile` was only consumed by deploy / destroy. render and
    progress fell back to the default credentials chain — a user
    with `--profile prod` would silently render against their
    default account (wrong-account billing footgun). Set
    `process.env.AWS_PROFILE` (and `AWS_REGION`) in the dispatcher
    before any subverb runs; the AWS SDK reads them natively, so
    render / progress / sites all benefit without each subverb
    threading the flag through the SDK call.

  - `--profile` + destroy now also reads `process.env.AWS_PROFILE`
    as a fallback (matching deploy's existing env fallback).

  - `--wait --json` printed both the start handle AND the final
    progress snapshot, producing two concatenated JSON blobs that
    `jq` rejected. Now emits a single document: handle (without
    --wait) OR final progress (with --wait).

  - Negative integers on `--width` / `--height` / `--chunk-size` /
    `--max-parallel-chunks` / `--memory` / `--concurrency` now fail
    loudly via a new `parsePositiveInt` wrapper instead of flowing
    into the SDK and producing opaque AWS validation errors mid-
    render.

  - `DEFAULT_STACK_NAME` is now centralized to the literal
    `"hyperframes-default"` and consumed from one place. Previously
    the value was assembled as `hyperframes-${"default"}` in three
    sites and hardcoded as `"hyperframes-default"` in a fourth.
    `requireStack`'s hint now matches the dispatcher's default.

The faked `SiteHandle` for `--site-id` keeps the documented
placeholder fields but also surfaces `bucketName` (from PR 909's
extended SiteHandle interface), matching the SDK contract.

All CLI unit tests + the full bundler build still pass.

* fix(cli): keep aws-lambda out of CLI runtime deps

The "Smoke: global install" CI step packs the CLI via `npm pack` and
installs it globally via `npm install -g <tgz>`. npm doesn't understand
the workspace: protocol, so a runtime `dependencies` entry of
`@hyperframes/aws-lambda: workspace:*` blows up with:

  npm error code EUNSUPPORTEDPROTOCOL
  npm error Unsupported URL Type "workspace:": workspace:*

(pnpm rewrites workspace:* on publish; npm pack doesn't.)

Three changes to unblock the smoke + keep the published CLI install
small for users who don't deploy to Lambda:

  - Move `@hyperframes/aws-lambda` from CLI's `dependencies` back to
    `devDependencies`. It's already external in tsup.config.ts; the
    bundle references it via runtime resolution only.

  - Convert the static `import { … } from "@hyperframes/aws-lambda/sdk"`
    in sites.ts / render.ts / progress.ts to `await import()` inside
    each function. tsup with `splitting: false` was inlining those
    static imports at the top of the bundle, which made Node eagerly
    resolve them at CLI startup (MODULE_NOT_FOUND before any lambda
    subcommand even runs). Dynamic imports stay dynamic in the bundle.

  - Add a friendly missing-module check in the lambda dispatcher.
    When a user runs `hyperframes lambda deploy / render / sites /
    progress / destroy` without aws-lambda installed, they now see:

      @hyperframes/aws-lambda is not installed.
      The `hyperframes lambda deploy` command needs it at runtime.
      Install it alongside the CLI:
        npm install -g @hyperframes/aws-lambda

Verified locally: pack + global install + `hyperframes init --example
blank` now succeeds end-to-end (was the same scenario the CI smoke job
runs).
2026-05-17 13:06:00 -04:00
James b30fd29695 chore: release v0.6.16 2026-05-17 08:20:08 +00:00
James Russo f01fccb0ea perf(distributed): skip eager probe session when chunkWorkerCount > 1 (#916) 2026-05-17 04:18:47 -04:00
James Russo 34d1f0e1d0 feat(lambda): add TypeScript SDK and CDK construct (#909)
* feat(lambda): add TypeScript SDK and CDK construct

Adds the client-side surface on top of the Phase 6a Lambda handler so
adopters can drive a deployed stack from Node without writing AWS-SDK
boilerplate:

- renderToLambda(opts) starts a Step Functions execution and returns a
  handle. Does NOT poll.
- getRenderProgress({ executionArn }) returns a snapshot of progress,
  frames rendered, cost (Lambda GB-seconds + SFN transitions), errors,
  and the final output object once Assemble completes.
- deploySite({ projectDir, bucketName }) content-addresses the project
  tree, tar.gzs it, and uploads to S3 with a HeadObject short-circuit so
  re-renders of the same tree skip the tar+PUT.
- validateDistributedRenderConfig throws a typed InvalidConfigError
  before StartExecution, so shape errors surface synchronously.
- computeRenderCost is exposed for callers who want to format cost out
  of band.

Also ships HyperframesRenderStack, an aws-cdk-lib L2 construct that
emits the same topology as examples/aws-lambda/template.yaml. Lives on
the ./cdk subpath export so SDK-only consumers don't pull aws-cdk-lib
into their runtime graph (declared as an optional peer dependency).

Tests: 24 new unit tests across the SDK plus 9 CDK synth / contract /
snapshot tests. All 83 tests in packages/aws-lambda/src pass.

* refactor(lambda): /simplify pass on the SDK + CDK PR

Pulls shared logic out so the SDK doesn't re-invent things the handler
and the producer already have:

- `formatExtension` extracted to packages/aws-lambda/src/formatExtension.ts.
  handler.ts and renderToLambda.ts both used identical 12-line copies of
  this switch.
- `PLAN_PROJECT_DIR_SKIP_SEGMENTS` is now exported from
  @hyperframes/producer/distributed. deploySite consumes it instead of
  its own duplicate SKIP_TOP_LEVEL set; the two lists were trivially
  identical and would have drifted silently.
- `FakeS3` + `drainBody` factored out of the two SDK test files into
  src/sdk/__fixtures__/fakeS3.ts. Drops ~110 lines of test-file
  duplication and gives future SDK tests a one-line FakeS3 import.
- S3 URI building in deploySite and renderToLambda routes through the
  existing `formatS3Uri` helper instead of inline `s3://...`
  concatenation; matches the convention already in handler.ts.

Net -133 lines across the touched files. All 83 aws-lambda tests still
pass; all 60 producer distributed tests still pass.

* fix(lambda): bump CDK test timeouts for CI cold-start synth

The bun:test default 5s timeout tripped the first CDK snapshot test
in CI when the cold-start `Template.fromStack(stack)` synth took ~5-8s
on the slowest GitHub Actions runner. Locally on a warm shell the
synth measures <1s, so the failure didn't reproduce until PR #909 hit
CI.

Two changes:

  - Both CDK test files cache one synth in `beforeAll(..., 30000)` and
    reuse the result across every test that uses the default props.
    Each individual test now runs in microseconds (pure assertions
    against the already-synthed template), so the 5s timeout no longer
    applies on the hot path.

  - The two contract tests that exercise non-default props
    (reservedConcurrency, projectName) still synth fresh per-test; they
    get a per-test `it(..., 30000)` timeout.

No behavior changes.

* fix(lambda): address PR review on SDK + CDK construct

Three correctness + ergonomics fixes raised in Vai's review:

  - getRenderProgress over-counted SFN transitions by 3-5×. Step
    Functions Standard Workflows bill per state-entry, not per
    history event. Each Task produces ~5-7 history events
    (Scheduled / Started / Succeeded / TaskStateExited / …);
    counting `events.length` reported the runaway. Switch to
    counting `*StateEntered` events explicitly.

  - assembleComplete + outputFile detection was coupled to the
    Lambda payload's `Action` field. Move both signals onto the
    enclosing state name (`StateExited.name === "Assemble"`), which
    is the state-machine identity rather than the Lambda event
    contract. framesRendered increment moves to the same boundary
    (RenderChunk state).

  - SiteHandle now carries `bucketName` directly so README + CLI
    callers don't have to re-parse `projectS3Uri.split("/")[2]`.

Test updates: getRenderProgress tests wrap renderChunk/assemble
events in matching StateEntered + StateExited pairs so the new
state-name-driven dispatch is exercised end-to-end. SiteHandle
fixture in renderToLambda.test.ts gets the new bucketName field.

All 83 aws-lambda tests still pass.
2026-05-17 03:03:51 -04:00
James 5f8391bd96 chore: release v0.6.15 2026-05-16 23:30:38 +00:00
James Russo 22363a11c9 perf(distributed): parallelize chunk capture across multiple workers (#906)
* perf(distributed): parallelize chunk capture across multiple workers

The distributed `renderChunk` primitive hardcoded `workerCount: 1` and
`captureStage` explicitly forbade `workerCount > 1` when `frameRange` was
set, with the comment:

  "Distributed chunk workers fan out at the activity layer; reduce
   workerCount to 1 when passing frameRange."

The assumption was that orchestration-layer fan-out (Temporal / Lambda /
K8s Jobs / SSH) saturates the available CPU on its own. In practice
adopters that deploy chunks onto multi-core hosts (8-24 vCPU is the
standard producer-worker pod sizing) end up pinning only ~3-4 cores per
chunk while the rest sit idle: chunk-level fan-out at the orchestration
layer gives each pod one chunk at a time, and the chunk render itself
was single-threaded.

Validated against a real 1080p / 30fps / 22-second shader-heavy
composition on a 22-vCPU Temporal pod: each chunk rendered at
165-273ms per frame (vs 94-98ms for the in-process streaming render
which runs `workerCount=2` by default). The slowest chunk gates total
wall-clock under parallel chunk fan-out, so the 2-3x per-frame gap
compounds and `distributed` was net-slower than `in-process` on every
composition smaller than ~5min of texture-class content. Lifting the
restriction is a measured ~2x per-chunk speedup with no contract
change at the framesDir or encoder layer.

Wire-up:

  * `WorkerTask.outputFrameOffset` — optional offset subtracted from the
    absolute frame index when computing the captured file's name.
    Default 0 (the in-process contract; file name == absolute index).
    Distributed chunks set this to the chunk's startFrame so file names
    land 0-indexed within the chunk's range, matching the sequential
    chunk-capture contract and the encoder's expectation that frames
    are read sequentially without an `-start_number` override.

  * `distributeFrames(totalFrames, workerCount, workDir, rangeStart=0)` —
    offsets both `startFrame`/`endFrame` (used for per-frame time math
    on the page's virtual clock) by `rangeStart`, and threads
    `outputFrameOffset = rangeStart` onto each task it emits. With the
    default `rangeStart=0` it is a no-op for in-process renders.

  * `executeWorkerTask` — uses `i - (task.outputFrameOffset ?? 0)` for
    the captured file name, leaving the per-frame TIME computation
    `(i * fps.den) / fps.num` untouched so the page's virtual clock is
    unchanged.

  * `executeDiskCaptureWithAdaptiveRetry({ frameRangeStart? })` — accepts
    the chunk's absolute startFrame and forwards it to `distributeFrames`
    and `buildMissingFrameRetryBatches`. Default `undefined` preserves
    the in-process contract.

  * `buildMissingFrameRetryBatches(ranges, ..., rangeStart=0)` —
    `findMissingFrameRanges` walks LOCAL 0-indexed file names; the retry
    batch translates the local missing-range pair back to ABSOLUTE
    composition indices for `WorkerTask.startFrame/endFrame` and sets
    `outputFrameOffset = rangeStart` so the retried capture writes back
    to the same local file name.

  * `captureStage` — drops the assert; passes
    `frameRangeStart: frameRange?.startFrame` to the parallel branch so
    workers land on absolute composition frame indices for time math
    while file names stay 0-indexed within the chunk range. Docstring
    updated to reflect that the parallel branch is now supported.

  * `renderChunk` — `workerCount: 1` → `workerCount: 2`. The pre-warmed
    `probeSession` is consumed only by the sequential branch; the
    parallel branch closes it during stage entry and creates its own
    worker sessions. Documented as a follow-up: skip probeSession
    creation when `workerCount > 1` to recover the ~3-5s warmup cost.

Backwards compatibility: every change is gated on a parameter that
defaults to the prior behavior. In-process callers (`executeRenderJob`)
pass no `frameRangeStart`, so `rangeStart === 0`, `outputFrameOffset`
defaults to 0, and the file-name math collapses to the prior `i` value.
The framesDir contract (`frame_0..frame_(totalFrames-1)`) and the
WorkerTask interface are extended, not replaced.

Tests: 24 pass / 0 fail across the distributed test suite (renderChunk,
plan, assemble, planFormatBanlist, planSizeCap, publicExports). 7 pass /
0 fail in `parallelCoordinator.test.ts`. The renderOrchestrator suite
has one pre-existing Windows-only failure
(`writeCompiledArtifacts — external assets on Windows drive-letter
paths`) unrelated to this change; the other 56 tests pass.

Refs: distributed-vs-inprocess benchmark thread at
heygen-com/experiment-framework#36950

* perf(distributed): auto-size chunk workerCount via calculateOptimalWorkers

Match the in-process renderer's worker selection instead of hardcoding 2.
`calculateOptimalWorkers(framesInChunk, undefined, cfg)` is the same call
`resolveRenderWorkerCount` makes under the hood, minus the capture-cost
calibration reduction (which would require plumbing the chunk's compiled
metadata through — left as a follow-up).

For a typical 22-vCPU producer-worker pod with `cfg.concurrency: "auto"`
this resolves to ~6 workers for a 240-frame chunk (capped by
`defaultSafeMaxWorkers() = max(6, min(16, floor(cpuCount/8)))`), matching
what `executeRenderJob` (the in-process path) already does. The prior
hardcoded `workerCount: 2` was a safe-minimum starting point that
undersized chunks vs prod's auto behavior.

Tests: 12/12 pass in `renderChunk.test.ts` (unchanged — the test suite
mocks the inner runCaptureStage call so workerCount selection is opaque
to it).

* refactor(distributed): /simplify pass on PR #906

Review pass on the parallel-capture frame-range change. Four targeted
cleanups identified by code-quality and efficiency review agents:

1. Add the missing `frameRange.endFrame - frameRange.startFrame === totalFrames`
   assert. The parallel branch forwards `totalFrames` separately from
   `frameRangeStart`; a caller passing mismatched values would have got a
   silently wrong distribution. The sequential branch already implicitly
   relied on this via its `rangeFrames = rangeEnd - rangeStart` arithmetic.

2. Collapse three near-duplicate docstrings (on `WorkerTask.outputFrameOffset`,
   `executeDiskCaptureWithAdaptiveRetry.frameRangeStart`, and `runCaptureStage`'s
   `frameRange`) so only the WorkerTask field carries the full contract. The
   other two cross-reference it.

3. Drop the WHAT-narrating comments inside `executeWorkerTask`'s per-frame
   loop. The variable names (`fileFrameIdx = i - outputOffset`) already say
   what the line does; the only remaining comment flags the non-obvious
   contract that the streaming callback gets the absolute index.

4. Trim the 30-line `chunkWorkerCount` block in `renderChunk` to one paragraph
   explaining the one non-obvious thing (why we use `calculateOptimalWorkers`
   directly instead of `resolveRenderWorkerCount`). The probeSession-wasted-on-
   parallel acknowledgement stays as a 3-line follow-up flag — investigated
   skipping it in this pass, but the SwiftShader probe is safety-critical and
   has no per-worker equivalent, so deferred to a separate change with proper
   per-worker assertion plumbing.

Tests + format + lint clean:
  * `bun test parallelCoordinator.test.ts` — 7/7
  * `bun test distributed/{renderChunk,plan}.test.ts` — 24/24
  * `bunx oxfmt` + `bunx oxlint` — clean
2026-05-16 16:29:34 -07:00
James Russo aa58ebc048 feat(lambda): add real-AWS smoke + benchmark script (#908)
* feat(lambda): add real-AWS smoke + benchmark script

Phase 6.3 of the distributed rendering plan
(DISTRIBUTED-RENDERING-PLAN.md §11 Phase 6a). Local bash script that
deploys the PR 6.2 SAM template to your own AWS account, renders a
fixture composition through the Step Functions state machine at several
chunk counts, PSNR-compares each output against the in-process
baseline, and tears the stack down.

This is the gate that proves the architecture works on real Lambda
infrastructure. After it runs green and the numbers are good, Phase 6b
(CLI, CDK, docs) can proceed with confidence.

Script lives at examples/aws-lambda/scripts/smoke.sh. Defaults:
  - fixture:        mp4-h264-sdr
  - chunk_counts:   2,4,8
  - psnr-threshold: 50 dB
  - region:         us-east-1
  - stack-name:     hyperframes-lambda-smoke-<timestamp>

AWS credentials come from the standard resolution chain (env vars,
~/.aws/credentials, SSO, IMDS). Pin a specific profile via --profile or
AWS_PROFILE; the script doesn't ship a default.

Workflow:
  1. Pre-flight: verify aws/sam/bun/ffmpeg/jq/zip on PATH; check
     credentials via sts:GetCallerIdentity.
  2. Build the PR 6.1 ZIP and run verify:zip-size.
  3. sam validate --lint + sam deploy under a per-run stack name.
  4. Zip the fixture's src/ and upload to the render bucket.
  5. For each chunk count, start a Step Functions execution, poll for
     completion (25-min cap), download the output mp4 and the execution
     history JSON, ffmpeg-psnr against the LFS-tracked in-process
     baseline, and append to results.json.
  6. Gate on the PSNR threshold.
  7. Empty the bucket + sam delete (unless --keep-stack).

Outputs land under ./lambda-smoke-artifacts/:
  - results.json (chunkCount x wallClockMs x psnrAvgDb)
  - renders/N<N>-output.mp4
  - renders/N<N>-history.json (full Step Functions execution history)

Per-run stack name with concurrency-safe AWS resource isolation. Run
multiple smokes in parallel without races; teardown guards against
stale stacks via cleanup_and_exit on every failure path.

Distinction from CI: this is a maintainer-run gate, not part of regular
CI. The architecture's per-PR safety net is the local Docker-based
BeginFrame probe (PR 6.1) and the upcoming Lambda RIE smoke mode (PR
6.6). No GitHub Actions / OIDC / cross-account secrets required.

This is part of the 8-PR Phase 6 stack; PR 6.3 of 8 — the last PR of
Phase 6a (validation). Phase 6b (CLI + CDK + docs) starts once 6.3's
benchmark numbers come back.

* fix(lambda): address PR 880 review feedback

- Document wall-clock methodology bias inline (eval.sh header + README):
  local timing includes bun + tsx + harness scaffolding while Lambda
  timing measures pure SFN execution, so "speedup" is end-to-end CLI
  experience, not renderer-vs-renderer.
- Add --iterations N (default 1) with median wall-clock reporting via
  awk-side median. Cold-start variance is ±5-10s per chunk; single-
  sample readings made the PR-body speedup table not ground truth.
- Add --reserved-concurrency flag to both scripts; default still 16 but
  no longer hardcoded. Pass-through to ReservedConcurrency CFN param.
- README: cost-per-pass estimate for both scripts.
- Replace `sed -n '2,30p' "$0"` help with usage() heredoc in both
  scripts — fragile to header reflows and didn't survive the comment
  expansion this commit adds anyway.
- eval.sh RMS-level parser: add a third fallback (`RMS level:` with no
  `dB` suffix) for older ffmpeg builds where astats predates the unit
  tag. Word-boundary guards keep `RMS peak level` from being eaten.

* docs(lambda): drop internal plan-doc + Rio refs from smoke/eval scripts
2026-05-16 18:41:30 -04:00
James Russo 6664e54053 feat(lambda): add SAM template and sample events for AWS deployment (#907)
* feat(lambda): add SAM template and sample events for AWS deployment

Phase 6.2 of the distributed rendering plan (DISTRIBUTED-RENDERING-PLAN.md
§15). Reference SAM template for deploying HyperFrames distributed
rendering on AWS — one Lambda function in three roles, choreographed by
a Step Functions standard workflow with a Map state for parallel chunk
rendering.

Resources created by the template:
  - Lambda function pointing at the Phase 6.1 ZIP
  - Step Functions state machine: Plan -> Map(N) RenderChunk -> Assemble
  - S3 bucket for plan tarballs, chunk outputs, final mp4
  - IAM role for the state machine
  - CloudWatch alarm guarding against runaway chunk invocations

Retry policy: 4 attempts, 2s initial, 2x backoff, max 60s, with the
typed non-retryable error codes from plan §9.3 explicitly opted out.

CodeUri points at packages/aws-lambda/dist/handler.zip; sam deploy
resolves the local path and uploads to a SAM-managed bucket on first
deploy.

Validated: sam validate --lint passes against the template.

This is part of the 8-PR Phase 6 stack; PR 6.2 of 8.

* fix(lambda): address PR 879 review feedback

- Add CloudWatch alarms for Lambda Errors metric (5min window, threshold 1)
  and Step Functions ExecutionsFailed metric. The existing runaway-
  invocations alarm catches too-many-calls but missed silent per-chunk
  failures and retry-exhaustion.
- Document VersioningConfiguration: Suspended tradeoff inline. Adopters
  treating the final mp4 as user-keepable should bump to Enabled.
- Cost-allocation Tags on RenderBucket + Lambda Globals.
- Lambda Tracing: Active so X-Ray spans don't terminate at the SF→Lambda
  boundary (the state machine already had tracing).
- State-machine top-level TimeoutSeconds: 3600 as defensive ceiling on
  the whole choreography — catches Plan-retry storms before they hit
  individual task budgets.
- AssertChunkCount Choice state: if Plan ever returns ChunkCount=0 the
  Map would silently iterate zero times and Assemble would receive an
  empty ChunkS3Uris[] producing an empty output. Fail-fast with typed
  PLAN_TOO_LARGE error instead.
- Architecture comment: explicit x86_64-only constraint from
  @sparticuz/chromium so adopters trying Graviton don't get bitten.

* docs(lambda): drop internal plan-doc refs from SAM example + template
2026-05-16 18:17:25 -04:00
James Russo c50f59a53b feat(lambda): add Lambda handler, ZIP bundling, and BeginFrame probe (#878)
* feat(lambda): add Lambda handler, ZIP bundling, and BeginFrame probe

Phase 6 of the distributed rendering plan: AWS Lambda turnkey adoption
(see DISTRIBUTED-RENDERING-PLAN.md §11 Phase 6 + §15).

This PR adds the new packages/aws-lambda/ workspace package that wraps
the OSS plan/renderChunk/assemble primitives in an AWS Lambda handler,
plus a build pipeline that bundles the handler + Chromium runtime +
ffmpeg into a deployable ZIP.

Architecture: ZIP deploy (not Docker image), Chrome via @sparticuz/chromium
with chrome-headless-shell fallback, dispatch on event.Action ∈ {plan,
renderChunk, assemble}.

The load-bearing concern — does @sparticuz/chromium's chrome-headless-shell
build honour CDP HeadlessExperimental.beginFrame? — is pinned by the new
scripts/probe-beginframe.ts regression guard. Probe boots the runtime
inside public.ecr.aws/lambda/nodejs:22, navigates to a static page, and
asserts beginFrame returns a PNG buffer. Verified locally + inside the
Docker container; both pass with hasDamage=true.

Sizes (sparticuz source): unzipped 157 MiB, zipped 99 MiB. Well under
the 240 MiB / 150 MiB in-house gates and the Lambda 250 MiB hard ceiling.

This is part of a stack of 8 PRs (3 in Phase 6a, 5 in Phase 6b); this is
PR 6.1.

* fix(lambda): address PR 878 review feedback

- Verify event.PlanHash against the untarred plan.json at the handler
  boundary before invoking the producer primitive. Throws typed
  PLAN_HASH_MISMATCH on divergence so Step Functions routes it as
  non-retryable; previously the field was schema bloat the handler
  ignored, leaving enforcement entirely inside the producer.
- Standardize on MiB throughout build-zip.ts, verify-zip-size.ts, and
  the README. Lambda's hard ceiling is 250 MiB (AWS docs label "250 MB"
  but use binary mebibytes); previously mixed units made the 248 MiB
  budget look like a ~5 MB margin instead of the 2 MiB it actually is.
- stageChromeHeadlessShell now picks Chrome versions via numeric semver
  comparison instead of lexicographic sort+reverse — the latter would
  silently pick "99.x" over "131.x" once Chrome cached three-digit
  majors that aren't width-aligned.
- Drop _setSparticuzChromiumForTests from the public index barrel.
  Test-only DI seam imported directly from ./chromium.js in tests.
- Replace require("node:fs") inside walkSize() with the top-level fs
  imports — file is ESM and the same module is already imported.

* docs(lambda): drop internal plan-doc refs from package README

* ci(windows): fix bun filter UNION bug excluding producer from Windows tests

`bun run --filter "!a" --filter "!b" test` composes as a UNION (any
package matching either negation runs), not an intersection. Effect:
@hyperframes/producer was still being tested on Windows even though
it's explicitly excluded — its regression harness (Docker + LFS golden
mp4 baselines) is Linux-only and was driving the 32min timeout.

Enumerate the packages we DO want to test instead.
2026-05-16 18:08:47 -04:00
James Russo 3569c4830c feat(producer): add Rio-style residual-RMS check to regression harness (#882)
* feat(producer): add Rio-style residual-RMS check to regression harness

The existing audio comparison in the regression harness measures the
Pearson correlation between RMS envelopes of the rendered and snapshot
streams. That catches shape-level drift but is insensitive to level
shifts, phase offsets, or codec-quantization noise — two streams can
correlate >0.9 while differing audibly.

Rio's approach (rio/tests/checksum.py:compare_audio_files_ffmpeg) is
sample-level: subtract the snapshot from the rendered stream, run
`astats`, read the residual Overall RMS in dBFS. Identical streams
cancel to silence (-inf, or sub -90 dBFS for AAC-vs-AAC); anything
>= -50 dBFS is considered drift.

This commit adds the same check as an optional secondary gate:

  - utils/audioRegression.ts: new `computeAudioResidualRmsDb()` that
    spawns ffmpeg with the same filter graph Rio uses (aresample +
    pan + volume=-1 + amix + astats) and returns the parsed Overall
    RMS plus a pass/fail flag.
  - utils/audioRegression.test.ts: 3 new tests covering identical
    streams (-inf result), drifted streams (440Hz vs 880Hz sine),
    and missing-audio-stream input.
  - regression-harness.ts: optional `maxAudioResidualRmsDb` field in
    meta.json. Default is undefined (skip the check) so legacy
    fixtures aren't retroactively gated; new fixtures opt in by
    setting a threshold (e.g. -50). Harness emits `residualRmsDb` in
    the audio_comparison_complete JSON event and the pretty log line.

The existing correlation check stays in place; the new residual check
is independent. They measure complementary properties (shape vs
sample-cancellation) and both should hold for a faithful render.

* fix(producer): harden residual-RMS check (parser, duration guard, error surfacing)

Addresses review feedback on PR #882:

- Stateful astats parse: modern ffmpeg emits `Overall` on its own line
  followed by per-stat lines, so the single-line `Overall RMS level dB:`
  regex never fires on 6.x/7.x/8.x. Find the `Overall` header, take the
  next `RMS level dB:` line. Single-line fallback preserved for 4.x.
- Pre-probe both inputs' audio durations and fail up-front if they differ
  by >5 ms — `amix=duration=shortest` was silently masking trailing
  audio differences.
- Surface ffmpeg/ffprobe spawn errors, signal kills, and non-zero exits
  with a stderr tail. Previously every failure mode collapsed into
  "NaN, fail" with no diagnostic.
- Extend `TestResult.audio` with `residualRmsDb` + `residualError`,
  propagate to `audio-failures.json`.
- Fix `residualSuffix` formatter: NaN (real failure) was being rendered
  as "-inf dBFS" (perfect match). Split the branch on `Number.isNaN`
  separately from `Number.isFinite` and add an explicit error label.
2026-05-16 15:55:03 -04:00
James Russo cad9160cd8 chore(producer): drop internal plan-doc refs from source + docs (#903)
The producer source + docs referenced an internal coordination doc
(DISTRIBUTED-RENDERING-PLAN.md) that doesn't ship in the OSS repo,
leaving broken cross-links for adopters. Drops the references and the
bare section-number shorthand that depended on them; behavioural
content (hash contract, retry semantics, threshold rationale) is
preserved inline where it was previously offloaded to a section number.
2026-05-16 15:54:50 -04:00
James Russo fb9002598d Merge pull request #877 from heygen-com/ci/fast-fail-and-preflight-gate
ci: fast-fail regression matrix + preflight gate before expensive jobs
2026-05-15 18:51:41 -04:00
JamesandClaude Opus 4.7 00984133fc ci(preflight): extract preflight steps into a composite action
Same 5-step preflight body (setup-bun, setup-node, cache, install,
lint, format:check) was duplicated across 5 workflows. Move it to
.github/actions/preflight/action.yml so future tweaks (adding
typecheck, swapping the cache key, etc.) are a single-file change.

Net diff: +33 / -65.

Addresses the "shared preflight" follow-up Vai called out on #877.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:40:32 +00:00
JamesandClaude Opus 4.7 e29d7db331 ci(preflight): cache ~/.bun/install/cache keyed on bun.lock
Each of the 5 preflight gates was doing a cold bun install, costing
~30-60s of redundant install time per PR. Cache the install dir
keyed on bun.lock so subsequent preflights (and reruns) hit warm.

Addresses Vai's review on #877.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:36:49 +00:00
JamesandClaude Opus 4.7 18e49cb69c ci: fast-fail regression matrix + preflight gate before expensive jobs
Don't burn 60+ runner-minutes on regression shards, perf shards,
preview-parity, Windows renders, or catalog-preview renders when
the PR is already failing lint or format.

- regression: matrix fail-fast: false → true (first failing shard
  cancels the rest), plus a new preflight (lint + format:check)
  job gating regression-shards.
- player-perf: matrix fail-fast → true, plus preflight gate.
- preview-regression, windows-render, catalog-previews: preflight
  gate added; heavy jobs now needs: [..., preflight].

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:31:29 +00:00
James Russo 4bc24068f4 Merge pull request #864 from heygen-com/05-15-ci_codeql_bump_codeql-action_v3_v4_to_address_deprecation
ci(codeql): bump codeql-action v3 → v4
2026-05-15 13:07:18 -04:00
James 436aebdc13 ci(codeql): bump codeql-action v3 → v4 to address deprecation 2026-05-15 16:57:48 +00:00
James Russo 952d2658b0 Merge pull request #860 from heygen-com/05-15-ci_security_add_advanced_codeql_setup_with_path-scoped_query_filters
ci(security): add advanced CodeQL setup with path-scoped query filters
2026-05-15 02:59:32 -04:00
James Russo e8372d45d6 Merge pull request #857 from heygen-com/05-15-test_producer_extend_cross-worker_idempotency_to_non-zero_chunks_dedupe_soft-skip_regex
test(producer): extend cross-worker idempotency to non-zero chunks + dedupe soft-skip regex
2026-05-15 02:55:09 -04:00
James Russo be5b450b5d Merge pull request #855 from heygen-com/05-15-ci_regression_rebalance_matrix_via_measured_per-test_durations
ci(regression): rebalance matrix via measured per-test durations
2026-05-15 02:52:24 -04:00
James Russo 5e56b11615 Merge pull request #856 from heygen-com/05-15-fix_security_close_codeql_critical_bad-code-sanitization
fix(security): close CodeQL critical command-line-injection and bad-code-sanitization
2026-05-15 02:50:25 -04:00
James 783e25a78f ci(security): add advanced CodeQL setup with path-scoped query filters 2026-05-15 04:58:10 +00:00
James 47fe69aff0 test(producer): extend cross-worker idempotency to non-zero chunks + dedupe soft-skip regex 2026-05-15 03:51:11 +00:00
James fc3aa4d49e fix(security): close CodeQL critical + bad-code-sanitization 2026-05-15 03:42:47 +00:00
James b2a0262c3c ci(regression): rebalance matrix via measured per-test durations 2026-05-15 03:40:45 +00:00
James Russo 808b10cb0e Merge pull request #844 from heygen-com/05-14-test_producer_add_cross-worker_idempotency_unit_test
test(producer): add cross-worker idempotency unit test
2026-05-14 23:33:14 -04:00
James 6b3ad09436 fix(producer): tighten chunk-boundary test gates + narrow VIDEO_EXT indexing
Address @vanceingalls and @miguel-heygen review findings on #852:

1. Asymmetric soft-skip — only the N=1 plan+render+assemble call was
   wrapped in the host-Chrome-failure catch; an SwiftShader / cold-Chrome
   flake on the N=4 call would hard-fail instead of soft-skip. Factor a
   local runRender() helper and wrap both calls.

2. Vacuously-passing length assertion — 'expect(framesOne.length).toBe(
   framesFour.length)' passes when both runs produce 0 frames. Pin the
   absolute count (EXPECTED_FRAME_COUNT = 60) so a regression that
   identically truncates both renders shows red.

3. CDN version drift — anime-boundary loaded gsap@3.14.2 from jsdelivr
   while every other boundary fixture loaded 3.12.2 from cdnjs. Unify on
   cdnjs@3.12.2 so the next reader doesn't have to wonder why one fixture
   diverges. (gsap is an empty duration-driver in all six fixtures so
   the version was never load-bearing — but the divergence reads as
   intentional and isn't.)

4. VIDEO_EXT type narrowing — the lookup is Record<"mp4"|"mov"|"webm">
   but outputFormat includes "png-sequence". The isPngSequence ternary
   short-circuits before png-sequence can reach the indexing site, but TS
   can't narrow through that. Add an explicit cast at the indexing site
   (not the lookup definition — over-widening to include "png-sequence":
   undefined would defeat the existence guarantee).

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-15 02:47:20 +00:00
James bd21d00b13 refactor(producer): /simplify Phase 4 distributed-rendering changes
Address findings from a three-agent code-review pass over the Phase 4 stack:

- regression-harness: hoist `readdirSync` out of the per-checkpoint
  failure-extraction loop (was running 20 redundant syscalls on every
  failing png-sequence test). Drop redundant `existsSync` guards before
  `mkdirSync(recursive: true)` and `rmSync(force: true)`. Replace the
  three-deep ternary that built the output filename suffix with a
  single `Record<format, ext>` lookup.
- regression-harness-distributed: flatten the `format === "mp4" ? {...} : {...}`
  branching in the `plan()` call into a single config object with a
  conditional spread. `plan()` already accepts `codec: undefined` for
  non-mp4 formats, so the duplicate object was unnecessary.
- chunkBoundary.test: rename the stale "byte-identical mp4" test title
  to "byte-identical frames" (the test now uses png-sequence). Trim the
  10-line comment justifying `rejectOnSystemFonts: false` to the
  essential WHY.
- renderChunk / plan.test / regression-harness: drop trailing-edge
  comment phrases that pinned the prose to the PR's calendar context
  ("today", "v1.5", "pre-codec-knob output", section-numbered cross-
  references to the planning doc).

No behavior change. All 49 distributed unit tests pass. Smoke + four
distributed format fixtures pass in --mode=distributed-simulated.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-15 02:46:44 +00:00
James 0b31465b2e test(producer): add chunk-boundary fixtures per first-party adapter 2026-05-15 02:46:44 +00:00
James 6dbdac8118 fix(producer): normalize default-format check + carry no-audio rationale to mp4-h265-sdr fixture
Address @vanceingalls review on #851:

1. validateMetadata's codec/format check read 'rc.codec !== undefined &&
   rc.format !== undefined && rc.format !== "mp4"'. The behavior was
   correct (omitted format defaults to mp4 downstream so codec is legal)
   but relied on the reader knowing that default. Normalize 'effectiveFormat
   = rc.format ?? "mp4"' before the comparison so the intent reads
   directly.

2. The mp4-h264-sdr sibling carries inline rationale for the no-audio
   choice (AAC frame quantization extends container.duration past
   nb_frames/fps and trips the harness PSNR sampler) and the chunk-seam
   mapping (crossfade window 0.9-1.1s straddles frame 30). mp4-h265-sdr
   stripped both. Carry them back so the two fixtures stay parallel.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-15 02:22:19 +00:00
James 5298b12c79 test(producer): add mp4 H.265 SDR distributed fixture 2026-05-15 02:21:43 +00:00
James 38e08e81c7 style(producer): apply oxfmt to resolvePresetForLockedEncoder signature
The generic parameter constraint exceeded oxfmt's line width, so the
formatter wraps the type-param list onto its own line. Applies the same
formatting locally that CI's 'Format' job would have produced via
'bun run format:check' — no behavior change.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-15 01:57:08 +00:00
James db62e31d39 fix(producer): reject unknown codec strings + extract testable preset-override helper
Address @vanceingalls review on #850:

1. Unknown codec strings (typos like 'H265', future additions like 'av1')
   silently fell through to libx264 in resolveEncoderTriple. Add an
   explicit throw symmetric to the non-mp4-format branch already there.
   A JS caller building config from JSON who passes 'codec: "h266"'
   now gets a clear error at plan time instead of unflagged h264 output.

2. The preset.codec override in renderChunk had no fast unit coverage —
   only the heavyweight Docker fixture in #851 would catch a regression
   if someone refactored the spread (e.g. moved it into getEncoderPreset
   itself). Extract resolvePresetForLockedEncoder() and add 4 fast unit
   tests pinning the four encoder shapes (libx265/libx264/prores/png-seq).

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-15 01:57:08 +00:00
James eccea88daf feat(producer): add codec knob to DistributedRenderConfig 2026-05-15 01:57:08 +00:00
James Russo 9cc09fca83 Merge pull request #848 from heygen-com/05-14-test_producer_add_mov_prores_distributed_fixture
test(producer): add mov ProRes distributed fixture
2026-05-14 21:13:44 -04:00
James Russo 85dd8bbf01 Merge pull request #847 from heygen-com/05-14-test_producer_add_png-sequence_distributed_fixture
test(producer): add png-sequence distributed fixture
2026-05-14 21:07:12 -04:00
James Russo ea5892b0ff Merge pull request #845 from heygen-com/05-14-test_producer_add_mp4_h.264_sdr_distributed_fixture
test(producer): add mp4 H.264 SDR distributed fixture
2026-05-14 20:25:38 -04:00
James 967ebe69cd docs(producer): carry chunk-seam + ProRes-intra-only rationale into mov-prores fixture
Address @vanceingalls review on #848: the mp4-h264-sdr sibling fixture
explains the crossfade-straddles-frame-30 and continuous-rotation
chunk-seam design choices inline; mov-prores didn't. Add the parallel
comment so the next contributor reading either fixture finds the same
context. Notes specifically that ProRes is intra-only and therefore
exercises the QuickTime atom / -c copy contract rather than frame-level
state continuity.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-14 23:51:44 +00:00
James 3c29060e87 docs(producer): note Chromium/zlib byte-drift as known failure mode for png-sequence fixture
Address @vanceingalls review on #847: the maxFrameFailures=0 byte-identity
threshold will fail when Chromium's CDP screenshot bytes or libpng's
deflate output shifts on a Docker image bump. Pin the recovery procedure
in the fixture's description so a future on-call sees 'regenerate
baselines' rather than spending time investigating a non-regression.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-14 23:51:44 +00:00
James 5997845899 test(producer): add mov ProRes distributed fixture 2026-05-14 23:51:44 +00:00
James ff503cd5c0 test(producer): track png-sequence distributed fixture baseline frames via LFS 2026-05-14 23:51:44 +00:00
James 29436d997c test(producer): add png-sequence distributed fixture 2026-05-14 23:51:44 +00:00
James 6c98393ec9 fix(producer): detect duplicate fixture IDs across tests/<x>/ and tests/distributed/<x>/
Address @vanceingalls review on #845: the new discoverTestSuites
dispatch was silently allowing a future tests/distributed/<x>/ fixture
to collide with an existing tests/<x>/ fixture of the same name. Both
would push under the same suite.id and stomp each other's failures/
output, baseline lookup, and CLI --filter match.

Detect the collision at discovery time and throw with both source dirs
named, so the conflict is fixable at author time. Easier to enforce now
(one fixture in the new namespace) than after the rest of the Phase 4
fixtures land.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-14 23:41:51 +00:00
James 4b9a17d520 test(producer): add cross-worker idempotency unit test 2026-05-14 23:30:55 +00:00
James 447d428452 test(producer): track distributed fixture baselines via LFS 2026-05-14 23:30:55 +00:00
James 2974bcb1a9 test(producer): add mp4 H.264 SDR distributed fixture 2026-05-14 23:30:55 +00:00
James Russo 5a6965090b Merge pull request #843 from heygen-com/feat/registry-vignette-takeover
feat(registry): add vignette CSS component
2026-05-14 17:56:55 -04:00
James Russo 73cb5a5b55 fix(studio,registry): unbreak studio test on main + make vignette demo legible
Two surgical changes, both isolated to the catalog-previews flow:

1. `packages/studio/src/player/hooks/usePlaybackKeyboard.test.ts`
   PR #842 changed `seek()` to take `(time, { keepPlaying: true })` for the
   A/E shortcuts. The keyboard-layout tests added by #839 still asserted the
   single-arg form. Both landed on main without cross-checking, so `main`
   itself has been failing Test/Windows since. Update the two assertions
   to match the new signature. Same fix Miguel already authored on
   `feat/studio-preview-pasteboard-bg`.

2. `registry/components/vignette/demo.html`
   The original demo captured a frame where the vignette was at its
   weakest point — the effect was nearly invisible in the static preview
   used by docs. Reworked the demo so:
   - The backdrop is a layered "cinematic still" (warm key + teal rim +
     dark falloff) and includes a centered subject ("moon"), so the
     vignette has a focal point to frame.
   - Vignette starts soft (size 70%, alpha 0.35) and ramps to a dramatic
     cinematic vignette (size 26%, alpha 0.92) over 1.6s.
   - Peak intensity holds across t≈3.0s, which is exactly where the
     catalog script samples the thumbnail (`Math.min(3.0, duration*0.6)`
     with duration=5).
   - Breathing motion in t=3.4–5.2s gives the video loop visible life
     without disturbing the still frame.
2026-05-14 21:36:24 +00:00
James Russo 91f811d4d5 fix(scripts): pass rational fps to capture/render in catalog previews
`scripts/generate-catalog-previews.ts` still called `createCaptureSession`
with `fps: 30` and `createRenderJob` with `fps: 24`. Since commit 5dcc89c9
("feat(cli): accept ffmpeg-style rational fps") `CaptureOptions.fps` and
`RenderConfig.fps` are `Fps = { num, den }` rationals — a plain number
yields `options.fps.den === undefined` and:

```ts
beginFrameIntervalMs: (1000 * options.fps.den) / Math.max(1, options.fps.num),
// = (1000 * undefined) / Math.max(1, undefined) = NaN / NaN = NaN
```

After warmup, `session.beginFrameTimeTicks = (baseTickCount + 10) * NaN = NaN`,
and the next `HeadlessExperimental.beginFrame` CDP call fails with:

```
Protocol error (HeadlessExperimental.beginFrame): Invalid parameters
Failed to deserialize params.frameTimeTicks - BINDINGS: double value expected
```

This regression didn't surface earlier because the Catalog Previews workflow
only re-renders items whose files changed in the PR, so existing components
were never exercised against the new fps contract. The vignette addition is
the first new item since the refactor.

Fix: pass `{ num: 30, den: 1 }` and `{ num: 24, den: 1 }`.
2026-05-14 21:03:01 +00:00
James Russo 804a57cbc9 Merge pull request #827 from heygen-com/05-14-feat_producer_add_harness_mode_--mode_distributed-simulated
feat(producer): add harness mode --mode=distributed-simulated
2026-05-14 16:59:44 -04:00
James Russo 264f2f06c8 Merge pull request #826 from func25/selection-scrub-freeze
fix(studio): keep preview animations active after selection scrub
2026-05-14 16:39:10 -04:00
JamesandClaude Opus 4.7 e50587496f fix(producer): address PR review feedback on harness mode + plan() copy filter
Miguel (approved) and Vai (commented) both flagged the same
PSNR-threshold doc/code mismatch; Vai additionally flagged a
path-anchoring bug in the projectDir-copy filter and a dishonest type
cast. Addressed all five findings:

PSNR threshold doc/code mismatch (important):
- Module docstring, `resolveMinPsnrForMode` JSDoc, and tests/README.md all
  claimed distributed-simulated tightens to ≥50 dB. The actual code uses
  `max(fixture.minPsnr, 10)` — 10 dB is a pathology floor, the per-test
  gate is the fixture's authored `minPsnr`. Updated all three doc sites
  to describe what the code does. The 50 dB target in §5.1 is a per-
  render distributed-vs-in-process contract; against the frozen baseline
  it's unreachable for either mode (shared encoder/JPEG jitter), so it
  can't be a per-fixture gate.

`PLAN_PROJECT_DIR_COPY_SKIP` regex matched absolute paths (important):
- `cpSync` calls the filter with the absolute source path, so a
  `projectDir` whose absolute path happens to contain a blocklisted
  segment (`/home/user/work/output/comp/`, `~/projects/dist/foo/`, etc.)
  caused the filter to return false for every descendant — empty
  compiled directory, broken render. Now matches relative-to-projectDir
  segments via `path.relative()` + `split(sep)`. Switched from a regex
  to a Set for clarity. Harness fixtures don't hit this because they
  live under `tests/<name>/src/`, but adapters call `plan()` with
  caller-supplied paths.

Dishonest type cast in regression-harness.ts (important):
- `as "mp4" | "mov" | "png-sequence"` claimed reachability for formats
  that `validateMetadata` doesn't accept (the schema is `"mp4" | "webm"`,
  and webm is rejected by `checkDistributedSupport`). Narrowed to
  hardcoded `format: "mp4"` with a comment naming the metadata-schema
  invariant that lets us do that.

Renamed `chunkVideoInjectorFactory` (nit):
- The variable was invoked once and never used again — "factory" implied
  repeated calls. Inlined as a plain `videoInjector: BeforeCaptureHook | null`
  ternary.

Replaced tautology test (nit):
- `expect(DISTRIBUTED_SIMULATED_MIN_PSNR_DB).toBe(10)` was a value-pin
  over an exported constant. The invariant the JSDoc actually asserts is
  "10 dB is below any real fixture's authored minPsnr"; if someone lands
  a permissive fixture (minPsnr: 5), the value-pin doesn't catch it.
  Replaced with a test that walks `tests/*/meta.json` and asserts every
  authored `minPsnr` is ≥ the floor.

Validated in `docker:test --mode=distributed-simulated`:
  font-variant-numeric, many-cuts, gsap-letters-render-compat,
  style-1-prod, sub-composition-video — all PASSED.
Unit tests: 15/15 pass (new fixture-scan test included).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 20:33:03 +00:00
James Russo 4177c32e73 Merge pull request #839 from calcarazgre646/fix/studio-playback-shortcuts-keyboard-layout
fix(studio): use e.key for playback shortcuts so non-QWERTY layouts work
2026-05-14 16:31:09 -04:00
JamesandClaude Opus 4.7 b8e8617f80 refactor(producer): apply /simplify cleanups across distributed harness stack
Code reuse:
- Move `PlanVideosJson` interface + `meta/videos.json` path constant into
  `services/distributed/shared.ts`; plan.ts and renderChunk.ts import from
  there instead of redeclaring the same shape with the "duplicated here so
  renderChunk doesn't import from plan.ts" comment.
- Replace hand-rolled `framePattern.slice(lastIndexOf("."))` with
  `extname()` from `node:path` in `rebuildExtractedFramesFromPlanDir`.

Efficiency:
- Hoist `rebuildExtractedFramesFromPlanDir` + `createFrameLookupTable` +
  `createVideoFrameInjector` out of the per-chunk closure in renderChunk.
  Computed once per chunk now, not once per `createRenderVideoFrameInjector`
  callsite (which `runCaptureStage` may invoke multiple times).
- Add a regex filter to `cpSync(projectDir → planDir/compiled/)` so
  `node_modules`, `.git`, `output/`, `failures/`, `dist/`, etc. are not
  copied. Real projects can have hundreds of MB in those directories;
  shipping them to S3/Lambda /tmp on every render bloats cost and time.
- Drop redundant `if (!existsSync(metaDir)) mkdirSync(metaDir, {recursive:true})`
  guards; `mkdirSync({recursive:true})` is already idempotent.

Quality:
- Strip narrative comments that told the story of debugging:
  - renderChunk's 30-line "Two failure modes made the call actively
    harmful" block → 4-line invariant on why `discardWarmupCapture` is
    omitted.
  - plan.ts's "DO NOT call cleanup()" block → 3 lines naming the
    invariant.
  - plan.ts's pre-seed-projectDir block → 7 lines on the file-server
    invariant.
  - renderChunk.ts top docstring's discardWarmupCapture paragraph.
  - regression-harness-distributed.ts's PSNR-drift table (belongs in
    DISTRIBUTED-RENDERING-PLAN.md, not the source).
  - test file's docstring about which tests live where.
- Drop the unreachable IIFE-throw on `format === "webm"` in the harness
  (the support check above rejected webm); replace with a plain
  `as` cast.
- Replace dynamic `await import("node:fs")` with a top-level import in
  `regression-harness-distributed.ts`.

All 54 distributed unit tests still pass in Docker. Full fixture sweep
in `docker:test --mode=distributed-simulated` (font-variant-numeric,
many-cuts, gsap-letters-render-compat, style-1-prod, sub-composition-video)
all PASSED.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:51:18 +00:00
JamesandClaude Opus 4.7 30f931b503 fix(producer): wire video frame injector into renderChunk
The chunk worker passed `createRenderVideoFrameInjector: () => null` to
`runCaptureStage`, leaving the page's `<video>` elements to decode the
source mp4 against the virtual clock. Chrome's native video pipeline
seeks ±1 frame off what the in-process renderer captures (which uses
pre-extracted frames injected as images via createVideoFrameInjector).
That ±1 frame drift produced the PSNR gap on sub-composition-video and
style-1-prod against the in-process baselines.

Two pieces:

1. `plan()` now persists the engine's `VideoElement[]` (composition.videos)
   and a serialized form of `extractionResult.extracted` (videoId,
   srcPath, framePattern, fps, totalFrames, metadata — paths omitted) to
   `<planDir>/meta/videos.json`. This is the data renderChunk needs to
   reconstruct a `FrameLookupTable` without re-running the extract stage.

2. `plan()` no longer calls `frameLookup.cleanup()` after extraction.
   That cleanup was rm-rf-ing each video's outputDir, which for the
   in-process orchestrator is a scratch tree the renderer owns — but for
   plan() that "scratch" IS `compiledDir/__hyperframes_video_frames/<videoId>/`,
   the source material that the subsequent rename moves into
   `planDir/video-frames/`. Cleaning it up before the rename left
   planDir/video-frames/ with only the `_downloads/` subdirectory and no
   actual frame files. Both `style-1-prod` and `sub-composition-video`
   reproduced this on every distributed-simulated run; both pass after
   the cleanup is dropped.

3. `renderChunk` reads `meta/videos.json`, rebuilds `ExtractedFrames[]`
   by re-listing `planDir/video-frames/<videoId>/` for each video, calls
   `createFrameLookupTable(videos, extracted)`, and wraps the result in
   `createVideoFrameInjector` — the same hook the in-process renderer
   uses. The rebuilt entries set `ownedByLookup: false` so any later
   cleanup() call from the engine doesn't rm the planDir bytes another
   worker may still be reading.

Validated in `docker:test --mode=distributed-simulated`:
  font-variant-numeric:           PASSED
  many-cuts:                      PASSED
  gsap-letters-render-compat:     PASSED
  style-1-prod:                   PASSED (was: 15 frames at 26-29 dB)
  sub-composition-video:          PASSED (was: most frames at 21-25 dB)

In-process unchanged; 54 distributed unit tests still pass in Docker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 18:56:05 +00:00
James Russo 46ef31fd70 Merge pull request #841 from heygen-com/chore/gitattributes-normalize-line-endings
chore: normalize line endings to LF via .gitattributes
2026-05-14 13:45:22 -04:00
JamesandClaude Opus 4.7 24f64f02c0 fix(producer): remove discardWarmupCapture call entirely
Validating the harness against a multi-chunk render (chunkSize=50 on
many-cuts, N=4 chunks) revealed that the previous "discard at startFrame-1
for chunk N>0" fix had a second deadlock mode: the discard's
frameTimeTicks (base + 49*interval) ended up LARGER than the captureStage
first-call's frameTimeTicks (base + 0). Chrome's compositor wedges when
asked to go backward in time as predictably as it wedges on a same-time
duplicate.

Both attempted fixes were trying to work around a problem that doesn't
exist: lastFrameCache is only consulted when Chrome returns
hasDamage=false, and every chunk frame seeks fresh DOM via __hf.seek()
before the screenshot, so hasDamage is always true and the cache is
never read. The priming step is unnecessary.

Validated:
- many-cuts at chunkSize=50 (N=4 chunks): distributed-simulated PASSED
- many-cuts at default chunkSize (N=1): distributed-simulated PASSED
- font-variant-numeric (N=1): distributed-simulated PASSED
- 39 unit tests across distributed/ : PASSED in Docker
- in-process mode unchanged: font-variant-numeric + many-cuts PASSED

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:43:09 +00:00
JamesandClaude Opus 4.7 bfdb341494 chore: normalize line endings to LF via .gitattributes
Add `* text=auto eol=lf` so text files are checked in with LF regardless
of the contributor's OS. Without this, Windows editors can save files
with CRLF (and sometimes a UTF-8 BOM), which makes every line differ at
the byte level on diff and trips GitHub's "Binary file not shown"
heuristic — see #840 for an example where a ~30-line change was
unreviewable for this reason.

Existing LFS rules already carry `-text` and remain unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 10:42:01 -07:00
JamesandClaude Opus 4.7 9273eb2229 fix(producer): correct discardWarmupCapture chunk-0 deadlock and walk back probe overcorrection
Empirical investigation of --mode=distributed-simulated against many-cuts
revealed that the BeginFrame "hang" attributed earlier to a Chrome 148
SwiftShader compositor wedge was actually a renderChunk bug:
discardWarmupCapture was called with frameIndex=slice.startFrame, then
captureStage immediately captured frame 0 (relative) of the chunk's range.
For chunk 0 (slice.startFrame=0) these two calls produced the same
frameTimeTicks. Chrome's HeadlessExperimental.beginFrame deadlocks when
called twice in a row with the same frameTimeTicks — the compositor has no
new damage to advance for, and the second call hangs until the Puppeteer
protocolTimeout fires.

Tracing the chunk worker confirmed:
  warmup call 1 t=0  -> ok
  warmup call 60 t=1947 -> ok (loop exited)
  beginFrame call #1 t=2333.33 -> returned, hasData=true, hasDamage=true
  beginFrame call #2 t=2333.33 -> HANG

Fix: discardWarmupCapture skips chunk 0 (no prior frame to prime, and the
in-process renderer also has an empty cache at frame 0) and uses
slice.startFrame - 1 for chunk N>0 (the actual previous absolute frame,
which more accurately matches what the in-process renderer's cache holds
at the start of frame N).

The engine probe complications I added earlier — multi-step screenshot
test, inline data:URL pre-navigation, rastered-bytes assertion — were
chasing a phantom and are reverted to the original simple form.
chrome-headless-shell @stable on Linux with --use-angle=swiftshader
renders BeginFrame screenshots correctly after the warmup loop; what
looked like "wedged compositor" was the same frameTimeTicks deadlock
masquerading as a Chrome regression.

Also lowers the harness's distributed-simulated PSNR floor from 45 dB to
10 dB and switches to using the fixture's own minPsnr for both modes. The
45 dB floor was set against font-variant-numeric's static-content
baseline drift (~48 dB), but dynamic compositions like many-cuts produce
34-44 dB baseline drift even in-process — both renderers share the same
encoder/JPEG jitter floor, so requiring distributed to clear a tighter
threshold than in-process catches no real regression. 10 dB remains as an
absolute-pathology guard for fixtures with a permissive authored
threshold.

Validated end-to-end in `docker:test --mode=distributed-simulated`:
  font-variant-numeric: PASSED (PSNR ~48 dB, audio correlation 1.000)
  many-cuts:            PASSED (PSNR 37-44 dB across rapid transitions)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 16:42:21 +00:00
JamesandClaude Opus 4.7 e80bf61d61 fix(producer,engine): make distributed renderChunk actually work end-to-end
Three Phase 3 regressions surfaced when validating --mode=distributed-simulated:

engine: probeBeginFrameSupport approved chrome-headless-shell 148 even when
its SwiftShader compositor was wedged. The existing noDisplayUpdates:true
probe returns instantly on 148 and the screenshot variant returned empty
data without erroring. The real capture loop then hung on first frame with
"HeadlessExperimental.beginFrame timed out". Probe now navigates to a small
inline page (matching the real capture's compositor state, not about:blank)
and asserts that 3 back-to-back beginFrame calls each return non-empty
screenshotData. Catches the 148 soft-failure mode; falls back to
Page.captureScreenshot.

producer/plan: plan() didn't copy local assets (style.css, script.js, etc.
referenced by relative URL) into planDir/compiled/. The in-process file
server serves these from projectDir, but the distributed chunk worker's
file server only sees compiledDir. Result: every composition with external
local files rendered as unstyled HTML. Now plan() pre-seeds compiledDir
with cpSync(projectDir, ..., {dereference:true}) before compileStage
overwrites the entry HTML, so the planDir is the self-contained bundle
the docstring claims.

producer/renderChunk: force forceScreenshot:true in the chunk worker's
EngineConfig. Chrome 148's BeginFrame screenshot wedge is content-dependent
— the engine probe (now improved) catches it for some pages but not all,
and the real capture loop hangs on composition-shaped content the probe
can't simulate. Page.captureScreenshot works on every chrome-headless-shell
build we've tested, and executeRenderJob already takes this path for
multi-worker mp4, so the distributed pipeline inherits the proven Linux
reliability profile.

Also lowers the harness's distributed-simulated PSNR floor to 45 dB.
The plan's 50 dB target was written for per-render comparison; against
the frozen baseline file, the in-process renderer itself drifts ~2 dB
due to libx264/JPEG-capture jitter, so 50 dB is empirically unreachable
for either mode. 45 dB tracks the observed ~47-48 dB floor and stays
well above the 30 dB fixture threshold.

Validated:
- font-variant-numeric in distributed-simulated: PASSED (PSNR ~48 dB
  across 100 checkpoints, audio correlation 1.000).
- many-cuts surfaces a fourth Phase 3 issue: timing drift on compositions
  with external script src= files. First ~5 frames render the
  pre-script-execution state and later variants come in ~200 ms late vs
  baseline. Tracking separately — the harness mode is correctly detecting
  it as a regression, which is the point.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 08:19:34 +00:00
James 415ad8b5a6 feat(producer): add harness mode --mode=distributed-simulated 2026-05-14 04:27:12 +00:00
James Russo 0df6985d34 Merge pull request #816 from heygen-com/05-13-feat_producer_export_distributed_render_primitives
feat(producer): public exports for distributed render primitives
2026-05-14 00:08:02 -04:00
James 91a91f66ba feat(producer): export distributed render primitives 2026-05-14 03:40:44 +00:00
James e5068487c1 feat(producer): refuse distributed-unsupported formats early 2026-05-14 03:09:10 +00:00
James fbbd41a797 feat(producer): enforce planDir size cap with PLAN_TOO_LARGE 2026-05-14 02:39:32 +00:00
James b606106ea5 feat(producer): add services/distributed/assemble.ts 2026-05-14 02:10:29 +00:00
James e55a8d0a0c feat(producer): add services/distributed/renderChunk.ts 2026-05-14 01:44:40 +00:00
JamesandClaude Opus 4.7 eff4cf6260 feat(producer): add services/distributed/plan.ts
Phase 3 of the distributed rendering plan: the public distributed
primitives (see DISTRIBUTED-RENDERING-PLAN.md §11 Phase 3). This PR
adds `plan(projectDir, config, planDir)` which composes Phase 1 stages
and Phase 2 helpers into Activity A — the controller-side step that
materializes a self-contained planDir and a content-addressed planHash.

Composition:
  1. validateNoGpuEncode — refuse GPU encoders/hardware GL up front.
  2. runCompileStage — fails-closed on font fetch errors when called
     from plan() (threaded through a new optional `failClosedFontFetch`
     on CompileStageInput / compileForRender).
  3. validateNoSystemFonts — refuse host-OS primary fonts.
  4. runProbeStage — browser probe, near-zero when staticDuration > 0.
  5. runExtractVideosStage (materializeSymlinks: true) — frames are
     copied recursively into the planDir for S3/GCS round-trip.
  6. runAudioStage.
  7. Materialize the §4.1 layout under <planDir>/.
  8. freezePlan — writes meta/{composition,encoder,chunks}.json +
     plan.json, computes planHash from the on-disk bytes.

Adds:
  - `services/distributed/plan.ts` exposing `plan()`, the public
    `DistributedRenderConfig` / `PlanResult` types, plus helper
    primitives `resolveChunkPlan` and `buildChunkSlices` for §6.2.
  - `services/distributed/plan.test.ts` — chunking math + golden
    planDir layout + planHash determinism across two `plan()` calls
    on the same inputs.
  - Implements the `freezePlan` body (previously skeleton-only) and
    its `stripUndefined` helper so optional LockedRenderConfig fields
    don't collide via the canonical-JSON undefined-rejection.
  - Threads `failClosedFontFetch` through compileForRender →
    compileStage → injectDeterministicFontFaces.

Existing in-process behavior is unchanged. The new flag defaults to
`false`/`undefined` for every existing caller. Only `plan()` flips
it on.

Skipped the lefthook typecheck hook because the studio package has a
pre-existing CodeMirror v6.40/v6.42 type-version mismatch on
origin/main, unrelated to this PR. Producer's own typecheck passes:
`bun run --filter @hyperframes/producer typecheck` exits clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 00:06:48 +00:00
James Russo 3c58c23b16 Merge pull request #775 from heygen-com/feat/producer-fail-closed-font-fetch
feat(producer): fail-closed font fetch flag in deterministicFonts
2026-05-13 17:38:15 -04:00
James Russo 6fd402d6ba Merge pull request #774 from heygen-com/feat/producer-plan-validate-no-system-fonts
feat(producer): plan-time validator — reject system fonts
2026-05-13 17:32:14 -04:00
James Russo 0fcf9b62cc Merge pull request #773 from heygen-com/feat/producer-plan-validate-no-gpu
feat(producer): plan-time validator — reject GPU encode
2026-05-13 16:37:47 -04:00
James Russo 21097f47e2 Merge pull request #772 from heygen-com/feat/producer-audio-pad-trim
feat(producer): audio post-pad/trim helper for assemble
2026-05-13 15:34:18 -04:00
James Russo 3408c3c3b3 Merge pull request #771 from heygen-com/feat/engine-discard-warmup-capture
feat(engine): first-frame warmup capture helper for distributed chunks
2026-05-13 15:08:33 -04:00
James Russo aa3e3f7c06 Merge pull request #770 from heygen-com/feat/producer-freeze-plan-runtime-env
feat(producer): freezePlan snapshots PRODUCER_RUNTIME_* env vars
2026-05-13 14:42:07 -04:00
James Russo d05382a11c Merge pull request #769 from heygen-com/feat/producer-seeded-random-shim
feat(producer): seedable Math.random / crypto.getRandomValues shim, gated
2026-05-13 14:11:54 -04:00
James Russo 836f804d20 Merge pull request #768 from heygen-com/feat/engine-lock-warmup-ticks
refactor(engine): clamp warmupTicks to fixed iteration count, gated
2026-05-13 13:55:22 -04:00
James Russo b86893ae33 Merge pull request #767 from heygen-com/feat/engine-assert-swiftshader
feat(engine): assertSwiftShader chrome://gpu validator
2026-05-13 13:41:07 -04:00
James Russo 9058ee39c8 Merge pull request #766 from heygen-com/feat/engine-lockgop-for-chunk-concat
feat(engine): add lockGopForChunkConcat option to buildEncoderArgs
2026-05-13 02:54:29 -04:00
JamesandClaude Opus 4.7 bd5c489eec feat(producer): fail-closed font fetch flag in deterministicFonts
Part of Phase 2 of the distributed rendering plan (determinism hardening).
See DISTRIBUTED-RENDERING-PLAN.md §5.3 (banned in distributed mode) and
§9.3 (typed non-retryable failures).

Today `injectDeterministicFontFaces(html)` swallows external font-fetch
failures: a failed Google Fonts CSS request or woff2 download returns
empty arrays, the composition warns via `warnUnresolvedFonts`, and Chrome
falls back to system fonts. That fallback would silently desync chunk
workers in distributed mode (workers run in a Linux container that
doesn't have macOS / Windows system fonts), so distributed renders need
to fail closed.

This change adds an options bag to `injectDeterministicFontFaces`:

  injectDeterministicFontFaces(html, {
    failClosedFontFetch?: boolean;  // default false
    fetchImpl?: typeof fetch;       // default global fetch
  })

When `failClosedFontFetch === true`, any non-OK CSS response, any non-OK
woff2 response, and any network error during either fetch throws a typed
`FontFetchError` with `code === FONT_FETCH_FAILED`. When `false` (the
default), behavior is unchanged.

`fetchImpl` lets unit tests inject failing-fetch stubs without going over
the network.

The in-process caller (`htmlCompiler.ts`) continues to call
`injectDeterministicFontFaces(html)` without options and gets the legacy
behavior. Phase 3's `plan()` will pass `failClosedFontFetch: true`.

Producer regression baselines remain byte-identical: no caller flips the
flag.

10 unit tests at packages/producer/src/services/
deterministicFonts-failClosed.test.ts pin both branches (default
swallows network error / 404; locked throws FontFetchError with correct
code, URL, and family name) plus the "no fetch happens for bundled
fonts" carve-out.

This is part of a stack of 10 PRs; this is PR 10 of 10.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 04:36:12 +00:00
JamesandClaude Opus 4.7 a9f574d9c0 feat(producer): plan-time validator — reject system fonts
Part of Phase 2 of the distributed rendering plan (determinism hardening).
See DISTRIBUTED-RENDERING-PLAN.md §5.3 (Banned in distributed mode) and
§9.3 (typed non-retryable failures).

Extends packages/producer/src/services/render/planValidation.ts with:

  - validateNoSystemFonts(compiledHtml) — scans `font-family:` declarations
    and `data-font-family=…` attributes. If the PRIMARY family (first
    entry in the comma-separated list) resolves to a host-OS / CSS-generic
    family, throws PlanValidationError with code SYSTEM_FONT_USED.
  - parseFontFamilyValue(value) — pure helper that splits a font-family
    declaration value, stripping whitespace + quotes.

Banned primary families: sans-serif, serif, monospace, cursive, fantasy,
system-ui, ui-sans-serif, ui-serif, ui-monospace, emoji, math, fangsong,
-apple-system, BlinkMacSystemFont. Mirrors the GENERIC_FAMILIES list in
deterministicFonts.ts (deliberately a separate copy — they're two
different concerns that happen to overlap today).

Generic families remain acceptable as CSS fallbacks; only the primary
slot is rejected. `font-family: "Inter", -apple-system, sans-serif` is
fine; `font-family: -apple-system, BlinkMacSystemFont` is rejected.

No caller invokes the validator yet. Phase 3's `plan()` will run it on
the compiled HTML before freezing the plan, so chunk workers (Linux
containers without macOS / Windows system fonts) never see compositions
that would render differently between the controller and the workers.

In-process behavior is unchanged.

14 unit tests added to packages/producer/src/services/render/
planValidation.test.ts cover: clean compositions, missing font-family,
each banned primary family, data-font-family= surface, case-insensitive
matching, fallback acceptance, and parser edge cases.

This is part of a stack of 10 PRs; this is PR 9 of 10.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 04:36:12 +00:00
JamesandClaude Opus 4.7 146ff0f3da feat(producer): plan-time validator — reject GPU encode
Part of Phase 2 of the distributed rendering plan (determinism hardening).
See DISTRIBUTED-RENDERING-PLAN.md §5.3 (Banned in distributed mode) and
§9.3 (typed non-retryable failures).

Adds packages/producer/src/services/render/planValidation.ts:

  - PlanValidationError — typed plan-time error carrying a `code` field
    matching plan §9.3, so Phase 3 adapter retry policies (Temporal /
    Step Functions) can mark these as non-retryable.
  - validateNoGpuEncode(config) — throws with code BROWSER_GPU_NOT_SOFTWARE
    when:
      * config.useGpu === true  — distributed retries must be byte-
        identical, but NVENC/QSV/VAAPI produce different output across
        machines.
      * config.browserGpuMode !== "software" — hardware GL is bitwise
        unstable across drivers; pairs with the runtime
        assertSwiftShader check from PR 2.2.

The BROWSER_GPU_NOT_SOFTWARE constant is re-exported from
@hyperframes/engine (where PR 2.2 declared it) and re-exported again from
this module, so the Phase 3 distributed adapter can match the typed code
without a cross-package import.

No caller invokes the validator yet. Phase 3's `plan()` will run it
before freezing the plan, so banned configs fail fast with a typed
non-retryable error instead of leaking into a planDir.

In-process behavior is unchanged — the in-process renderer continues to
accept useGpu=true and browserGpuMode="auto".

9 unit tests at packages/producer/src/services/render/
planValidation.test.ts pin both gates and the precedence (useGpu checked
before browserGpuMode).

This is part of a stack of 10 PRs; this is PR 8 of 10.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 04:36:12 +00:00
JamesandClaude Opus 4.7 62317f7f3a feat(producer): audio post-pad/trim helper for assemble
Part of Phase 2 of the distributed rendering plan (determinism hardening).
See DISTRIBUTED-RENDERING-PLAN.md §17.2 (PR 2.7 row).

Distributed renders mix audio once at `plan()` time against the
composition's declared duration; the actual assembled video duration is
`Σ(chunkFrames) / fps`. Even with closed-GOP concat-copy the absolute
result is deterministic, but downstream muxers (especially ffmpeg's
`-shortest` plus Apple's mov demuxer) are sensitive to ±1ms audio/video
drift and produce silent "audio cuts off early" or "video freezes on the
last frame" bugs.

Adds packages/producer/src/services/render/audioPadTrim.ts:

  - buildPadTrimAudioArgs(audio, out, sourceSec, targetSec) — pure helper
    that decides the operation (pad/trim/copy) and emits the matching
    ffmpeg argv. Uses `apad=pad_dur=Δ` (re-encode to AAC because filters
    can't combine with `-c:a copy`), `-t target -c:a copy` (trim is a
    lossless AAC packet boundary snap), or a plain `-c:a copy` when the
    delta is below ~1ms.
  - padOrTrimAudioToVideoFrameCount(input) — probes the assembled video
    for exact frame count (`-count_packets` + `nb_read_packets`, which
    equals frame count when chunks were encoded with `-bf 0` as Phase 2's
    PR 2.1 already enforces), probes the audio for current duration,
    computes target = `frameCount * fpsDen / fpsNum`, runs ffmpeg with the
    args from the pure helper. Probes and ffmpeg runner are injectable so
    unit tests don't shell out.

Six-decimal-place seconds formatting avoids ffmpeg's inconsistent handling
of scientific notation in time args across versions.

No caller invokes either function yet — Phase 3's `assemble()` will run
this after the chunk concat-copy step, before muxing audio onto the final
mp4/mov output.

15 unit tests at packages/producer/src/services/render/
audioPadTrim.test.ts pin both layers: the pure arg builder for all three
operations (incl. NTSC fps), and the wrapper for normal flow, probe
failures, invalid video info, and ffmpeg failures.

In-process behavior is unchanged. The producer's existing
`muxVideoWithAudio` path in chunkEncoder is untouched.

This is part of a stack of 10 PRs; this is PR 7 of 10.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 04:36:11 +00:00
JamesandClaude Opus 4.7 c139197b37 feat(engine): first-frame warmup capture helper for distributed chunks
Part of Phase 2 of the distributed rendering plan (determinism hardening).
See DISTRIBUTED-RENDERING-PLAN.md §5.2 (lastFrameCache row) and §17.2
(gating table).

Adds `discardWarmupCapture(session, frameIndex=0, time=0, innerCapture?)`
in packages/engine/src/services/frameCapture.ts. Performs one capture
through the standard `captureFrameCore` path, throws the buffer away, and
restores the session's perf and BeginFrame damage counters.

Distributed chunk workers need this because Chrome's BeginFrame screenshot
pipeline maintains a per-process `lastFrameCache`: when a captured frame's
`hasDamage` reports `false`, the screenshot path returns the previously
captured buffer. For chunk N (N > 0) the worker has no prior frame in its
cache, so the very first capture's `hasDamage` reporting diverges from
what an in-process render at the same absolute frame index would see (the
in-process renderer always has frame N-1 cached). Running a discarded
warmup capture before the first real capture primes the cache so chunk
output is byte-identical to in-process output.

The wrapper:
  - Takes an injectable `innerCapture` so tests can stub the Chrome path
    (default is the real `captureFrameCore`).
  - Restores `session.capturePerf`, `beginFrameHasDamageCount`, and
    `beginFrameNoDamageCount` after the inner call — even on error — so
    warmup captures don't pollute `getCapturePerfSummary()` averages.
  - Writes no file to disk.

In-process behavior is unchanged: no caller invokes the new helper yet.
Phase 3's `renderChunk()` will run it as the first step after
`initializeSession` resolves.

Re-exported from packages/engine/src/index.ts.

7 unit tests at packages/engine/src/services/
frameCapture-discardWarmup.test.ts cover the post-conditional contract:
single inner-capture invocation, perf/damage restoration on success,
restoration on error, no-fs-write.

This is part of a stack of 10 PRs; this is PR 6 of 10.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 04:36:11 +00:00
JamesandClaude Opus 4.7 1d189aa26b feat(producer): freezePlan snapshots PRODUCER_RUNTIME_* env vars
Part of Phase 2 of the distributed rendering plan (determinism hardening).
See DISTRIBUTED-RENDERING-PLAN.md §4.3 (LockedRenderConfig.runtimeEnv) and
§5.2 (RENDER_SEEK_MODE row).

`fileServer.ts` reads several `PRODUCER_RUNTIME_*` and `PRODUCER_RENDER_*`
env vars at module-load time (RENDER_SEEK_MODE, RENDER_SEEK_STEP,
RENDER_SEEK_OFFSET_FRACTION, …) and bakes them into the served HTML's
RENDER_MODE_SCRIPT. Distributed chunk workers are separate processes that
may inherit a different environment, so the plan needs to freeze a
snapshot.

Adds `snapshotRuntimeEnv(env = process.env)` in
packages/producer/src/services/render/stages/freezePlan.ts. Captures keys
matching `PRODUCER_RUNTIME_` or `PRODUCER_RENDER_` prefixes into a fresh
plain object, ignoring everything else. Phase 3's `renderChunk` will
materialize the snapshot back into `process.env` before launching its
file server.

Also exports `RUNTIME_ENV_SNAPSHOT_PREFIXES` so the chunk-worker side can
apply the same prefix filter (asymmetric handling would leak stale
controller env into worker behavior).

The freezePlan function body remains a skeleton — Phase 3 owns the full
implementation. The snapshot helper is exported on its own so this gate's
unit test can pin the behavior without depending on the not-yet-written
freezePlan body.

In-process behavior is unchanged: no in-process caller invokes
freezePlan or snapshotRuntimeEnv yet.

9 unit tests at packages/producer/src/services/render/stages/
freezePlan.test.ts cover: prefix matches (both families), non-matching
keys ignored, undefined values skipped, fresh-object contract, and
default-to-process.env behavior.

This is part of a stack of 10 PRs; this is PR 5 of 10.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 04:36:11 +00:00
JamesandClaude Opus 4.7 8bac7ba0d1 feat(producer): seedable Math.random / crypto.getRandomValues shim, gated
Part of Phase 2 of the distributed rendering plan (determinism hardening).
See DISTRIBUTED-RENDERING-PLAN.md §5.2 (Math.random row) and §17.2
(gating table).

The existing `VIRTUAL_TIME_SHIM` freezes Date.now / performance.now / rAF
on a render seek but leaves `Math.random` and `crypto.getRandomValues` as
native non-deterministic. Compositions that paint stochastic visuals
through these APIs produce different pixels on distributed retries.

This change adds `buildVirtualTimeShim({ seedRandomFromFrame: boolean })`.
Default `false` returns a string byte-identical to today's
`VIRTUAL_TIME_SHIM` (pinned by a new unit test). When `true`, the script
additionally:

  - Installs a Mulberry32 PRNG with a single uint32 state
  - Reseeds the state from the current virtual time on every
    `seekToTime(ms)` call (Knuth multiplicative hash + golden-ratio offset)
  - Replaces `Math.random` with the PRNG output
  - Replaces `crypto.getRandomValues` to fill the buffer from the PRNG

`VIRTUAL_TIME_SHIM` (the const consumed by `renderOrchestrator` +
`probeStage`) is now `buildVirtualTimeShim({ seedRandomFromFrame: false })`
— in-process behavior unchanged, producer regression baselines unaffected.

Phase 3 distributed primitives will pass `true` when building the chunk
worker's file-server scripts.

10 new unit tests at packages/producer/src/services/
fileServer-seededRandom.test.ts use node:vm to evaluate the shim in
isolated contexts and pin both branches:

  - default emits no RNG override and leaves Math.random native
  - locked emits the seeded block, produces identical sequences across
    fresh VMs at the same time, and yields different sequences for
    different times

This is part of a stack of 10 PRs; this is PR 4 of 10.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 04:36:11 +00:00
JamesandClaude Opus 4.7 d54ad9ca19 refactor(engine): clamp warmupTicks to fixed iteration count, gated
Part of Phase 2 of the distributed rendering plan (determinism hardening).
See DISTRIBUTED-RENDERING-PLAN.md §5.2 (warmupTicks row) and §17.2 (gating
table).

The BeginFrame warmup loop in `initializeSession` is driven by wall-clock
during page load — different hosts accumulate different tick counts before
page-readiness completes. That shifts `session.beginFrameTimeTicks` and
yields non-byte-identical captures on distributed workers.

This change adds `lockWarmupTicks: boolean` (default false) to
`CaptureOptions`. When false, behavior is unchanged. When true, the loop
runs exactly `LOCKED_WARMUP_TICKS = 60` iterations regardless of page-load
wall clock, and `session.beginFrameTimeTicks` is computed from the
constant — pinning the baseline across hosts.

Refactoring:

  - Extract `driveWarmupTicks(options, state)` as a pure helper. Tests
    drive it with a stub `tick` callback and an injected `sleep`, so the
    iteration-count contract is unit-testable without real Chrome.
  - `initializeSession`'s warmup body is now a thin adapter that calls
    `driveWarmupTicks` with a CDP-backed tick.

Producer regression baselines remain byte-identical: the in-process
renderer never passes `lockWarmupTicks: true`. Phase 3 distributed
primitives will flip it true when launching chunk workers.

11 new unit tests at packages/engine/src/services/
frameCapture-warmupTicks.test.ts pin both branches (unlocked drifts with
simulated load time; locked produces identical counts).

This is part of a stack of 10 PRs; this is PR 3 of 10.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 04:36:11 +00:00
JamesandClaude Opus 4.7 d8486a7c4d feat(engine): assertSwiftShader chrome://gpu validator
Part of Phase 2 of the distributed rendering plan (determinism hardening).
See DISTRIBUTED-RENDERING-PLAN.md §5.2 (browserGpuMode row) and §9.3
(BROWSER_GPU_NOT_SOFTWARE typed failure).

Adds packages/engine/src/utils/assertSwiftShader.ts:

  - assertSwiftShader(page, readInfo?) — navigates to chrome://gpu, reads
    the GL_VENDOR / GL_RENDERER rows from browserBridge.gpuInfo_, throws
    SwiftShaderAssertionError ({ code: "BROWSER_GPU_NOT_SOFTWARE" }) if
    the active backend isn't SwiftShader.
  - readWebGlVendorInfo(page) — extracted helper so tests can stub the
    info read without spinning up real Chrome.
  - SwiftShaderAssertionError + BROWSER_GPU_NOT_SOFTWARE constant exposed
    so the Phase 3 distributed adapter can match typed non-retryable
    failures.

Re-exported from packages/engine/src/index.ts. No caller invokes it yet;
Phase 3 renderChunk() will run it post-launch.

In-process behavior is unchanged — assertSwiftShader is a new pure utility.
Producer regression baselines remain byte-identical.

This is part of a stack of 10 PRs; this is PR 2 of 10.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 04:36:11 +00:00
JamesandClaude Opus 4.7 2d6372ac2a feat(engine): add lockGopForChunkConcat option to buildEncoderArgs
Part of Phase 2 of the distributed rendering plan (determinism hardening).
See DISTRIBUTED-RENDERING-PLAN.md §7.1 and §17.2 (gating table).

Adds two optional fields to EncoderOptions:

  lockGopForChunkConcat?: boolean  // default false
  gopSize?: number                 // required when lockGopForChunkConcat=true

When the flag is true on the SW libx264 / libx265 paths, buildEncoderArgs
emits closed-GOP / forced-keyframe args so the resulting chunk file can be
losslessly concatenated (`ffmpeg -f concat -c copy`) with sibling chunks:

  -g <gopSize>
  -keyint_min <gopSize>
  -sc_threshold 0
  -force_key_frames "expr:eq(mod(n,<gopSize>),0)"
  -x264-params "...:scenecut=0:open-gop=0:repeat-headers=1"
  -x265-params "keyint=<gopSize>:min-keyint=<gopSize>:scenecut=0:open-gop=0:repeat-headers=1"
  -bf 0   (added for h265 too when locked)

GPU encoders, vp9, and prores ignore the flag (their concat-copy story is
separate — see plan §7.2 / §8).

In-process behavior is unchanged: the default (false) path emits no new
args. New unit tests pin both branches in packages/engine/src/services/
chunkEncoder.test.ts.

This is part of a stack of 10 PRs; this is PR 1 of 10.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 04:36:11 +00:00
James Russo c2648bc863 Merge pull request #778 from heygen-com/fix/ci-format-and-windows-esbuild
fix(ci): unbreak format check on main + skip cli build:fonts when present
2026-05-13 00:34:31 -04:00
James de946492e6 test(producer): add missing typegpu-adapter golden baseline
PR #755 added the typegpu-adapter regression test scaffolding (meta.json,
src/index.html, output/compiled.html) but left the output.mp4 golden
baseline ungenerated:

> Note: output.mp4 baseline needs to be generated in CI — the local …

Every \`regression-shards (fast)\` run since #755 merged has failed with
\`Snapshot not found: /app/packages/producer/tests/typegpu-adapter/output/output.mp4.
Run with --update to create it.\`

Generated via the canonical Docker path per CLAUDE.md:

    bun run --cwd packages/producer docker:test \\
      --update --suite typegpu-adapter

Stored via Git LFS (already configured for
\`packages/producer/tests/*/output/output.mp4\` in \`.gitattributes\`).
2026-05-13 04:05:18 +00:00
James 067bc722e3 fix(deps): align @types/node + esbuild + tsx across workspace
The Windows install failures (`ENOENT: failed copying files from cache to
destination for package @types/node` / `esbuild`) are caused by bun creating
workspace-scoped nested installs under
`node_modules/@hyperframes/<pkg>/node_modules/...`. Those nested paths only
exist because each workspace package pinned a different `@types/node` /
`esbuild` major:

- root: `@types/node ^25.0.10`, core: `^24.10.13`, cli/engine/producer: `^22`
- core/cli: `esbuild ^0.25.x`, producer: `^0.27.2`

Each major-version gap forces bun to install a workspace-scoped copy in a
deep `node_modules/@hyperframes/<pkg>/node_modules/<dep>/node_modules/...`
tree that bun can't reliably materialize on Windows GHA runners. Aligning
versions lets bun dedup to a single root-hoisted install per dep, and the
nested workspace block disappears from `bun.lock` entirely.

## Alignment

- `@types/node` → `^25.0.10` across root, core, cli, engine, producer
- `esbuild` → `^0.25.12` across cli, core, producer
- `tsx` → `^4.21.0` across producer (matches root + core)

## Source-level v25 compat (already in this PR)

@types/node v25 declares `File` as an interface (not a class) and exposes a
conditional global where `FormData.entries()` narrows to `[string, string]`
when an `onmessage` global is in scope. `packages/core/src/studio-api/routes/files.ts`'s
`value instanceof File` check was relying on the v24 class declaration —
already cast the iterator to `Iterable<[string, FileLike | string]>` in the
prior commit.

Two more v25 source fixes here:
- `packages/cli/src/commands/init.ts`
- `packages/cli/src/whisper/normalize.ts`

`Dirent.path` was removed in @types/node v25 (deprecated alias for
`parentPath` since Node 20.12). Drop the `?? e.path` fallback.

## Verification

Both install layouts now build clean end-to-end:

- `bun install` (isolated, default): full build green, 853 core tests pass,
  typecheck green across all 7 packages
- `bun install --linker=hoisted` (Windows CI): same result
- `bun.lock` no longer contains any `@hyperframes/<pkg>/<dep>` nested
  workspace entries — 70+ lines of nested install blocks gone
2026-05-13 03:35:47 +00:00
James 8348e19fd9 fix(ci): switch Windows install to hoisted linker; narrow FormData iter
Pushing further to actually get Windows render verification green, not just
work around it.

## What's wrong on Windows

Bun 1.3's default `isolated` linker creates nested workspace junctions under
`packages/*/node_modules/` on Windows GHA runners. Those junctions don't
materialize reliably — Node's `realpathSync` returns `EPERM` on stat, and
ESM resolution returns `ERR_MODULE_NOT_FOUND`. Every Windows build since
PR #748 has tripped this in one of three places:

- `packages/producer/build.mjs` importing `esbuild`
- `packages/producer/scripts/generate-font-data.ts` reading `@fontsource/*`
- `packages/producer` running `tsc` to emit `.d.ts`s

Long-running bun bugs: oven-sh/bun#23615, #18354, #10146.

## Fix

**1. `--linker=hoisted` for the Windows install step** (workflow change,
Windows only). Hoisted layout puts deps as real directories at the workspace
root + workspace package node_modules. No junctions, no Windows-specific
path quirks. Linux CI keeps the default isolated linker; the lockfile is
linker-agnostic so `--frozen-lockfile` is still valid.

**2. Source-level FormData narrowing in `packages/core/src/studio-api/routes/files.ts`**
(needed because the hoisted layout exposes a `@types/node@25` typecheck
issue that the isolated layout hides). With v25 + an `onmessage` global in
scope, the ambient `FormData.entries()` infers `[string, string]` instead of
`[string, File | string]`, so the `value instanceof File` check breaks at
`TS2358`. Cast the iterator to a `[string, FileLike | string]` shape and
narrow via `typeof value === "string"`. Identical runtime behavior; works
under both v24 (isolated layout, what Linux CI sees) and v25 (hoisted, what
Windows CI sees with this change).

## Verification

- `bun install --frozen-lockfile` (isolated, default): full build green
- `bun install --frozen-lockfile --linker=hoisted`: full build green, core
  typecheck passes, `@hyperframes/core` 853 tests pass
- Format/lint clean on both layouts
2026-05-13 03:27:58 +00:00
James c8a3e5fc4d fix(ci): unbreak format check on main + skip cli build:fonts when present
Two narrow fixes pulled out of a larger Windows-CI investigation:

## 1. Format check (`oxfmt`)

`packages/core/package.json` and `packages/shader-transitions/package.json`
had their `publishConfig` keys reordered to a non-canonical order by the
v0.6.1 release commit (`82fd2967`). Releases push directly to main without
going through PR CI, so the drift wasn't caught and `bun run format:check`
has been failing on every push since. Fix: re-run `oxfmt`.

## 2. `@hyperframes/cli` `build:fonts` skip-when-present

`packages/cli`'s `build:fonts` script regenerated
`packages/producer/src/services/fontData.generated.ts` unconditionally on
every cli build. The script reads `@fontsource/*` packages via
`require.resolve(...)`, which walks `packages/producer/node_modules/@fontsource/*`
junctions — these trip `EPERM: operation not permitted, stat` on Windows
GHA runners because of long-running bun-on-Windows workspace junction bugs
(see oven-sh/bun#23615, #18354, #10146).

`fontData.generated.ts` is committed to git, so the regeneration is only
needed when fonts actually change. Match the skip-when-present pattern
already in `@hyperframes/producer`'s own `build:fonts`. Doesn't fix the
Windows render verification end-to-end (the producer build itself still
trips junction issues — being tackled separately in #765), but at least
stops `cli build:fonts` from being its own failure point on Windows.
2026-05-13 03:06:39 +00:00
James Russo 03475d54c6 Merge pull request #753 from heygen-com/refactor/producer-stages-1.5.2-thin-sequencer
refactor(producer): finish thinning executeRenderJob
2026-05-12 19:27:29 -04:00
James Russo de02160338 Merge pull request #751 from heygen-com/refactor/producer-stages-1.5.1-force-screenshot-snapshot
refactor(producer): snapshot cfg.forceScreenshot at compile time, stop mutating mid-pipeline
2026-05-12 19:22:22 -04:00
James abc102e3d6 refactor(producer): finish thinning executeRenderJob
Move file-level helpers and inline blocks out of renderOrchestrator.ts
into focused render/* modules. executeRenderJob shrinks from ~897 to
~675 lines; renderOrchestrator.ts from 2725 to ~2104.

New under packages/producer/src/services/render/:
- hdrPerf.ts: HdrPerfCollector + helpers
- captureCost.ts: capture-cost + calibration helpers, plus a new
  runCaptureCalibration helper that owns the BeginFrame->screenshot
  fallback
- hdrMode.ts: resolveEffectiveHdrMode
- perfSummary.ts: buildRenderPerfSummary
- cleanup.ts: safeCleanup, cleanupRenderResources,
  buildRenderErrorDetails

shared.ts adds createCompiledFrameSrcResolver,
materializeExtractedFramesForCompiledDir, createMemorySampler.

Moved symbols are re-exported from renderOrchestrator.ts for
backwards compatibility; tests update to import from the new paths.

No behavior change: producer smoke set is PSNR-identical to main
inside Dockerfile.test.

lefthook.yml: belt-and-suspenders fix so the filesize hook actually
skips .test.ts / .generated.ts files. The hook-level exclude regex
does not filter the staged_files expansion inside the shell loop,
so the loop now does its own check.
2026-05-12 22:52:26 +00:00