Skip to content

perf(cloud): move project bytes once through shared content storage - #5772

Merged
miguel-heygen merged 29 commits into
mainfrom
cloudrender/site-symlinks
Oct 19, 2026
Merged

miguel-heygen merged 29 commits into
mainfrom
cloudrender/site-symlinks

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Oct 17, 2026 •

Copy link
Copy Markdown
Collaborator

Package size after merging current main

@hyperframes/producer packs to 731,628 / 2,674,226 B after main's chunk-sizing change and this PR's URL reads, 2,248 B over its budget. None of the merged code is left unused, so the growth is real. The budget is raised to the measured size x1.05, the repo rule the size check applies. @hyperframes/aws-lambda drops to 60,823 / 209,843, its measured size + 5%.

Review fixes at 2c9a091701f

Branch updated to current main (b46ebedeae9, merged like the earlier main integration) and green on CI.

Finding Commit Fix
New SDK/CLI against a stack from before this PR fails in the plan step 40f7fb5a4dd renderToLambda reads the state machine definition before uploading anything and stops with STACK_TOO_OLD and the redeploy command unless the plan task lists SITE_MANIFEST_INVALID as terminal, which this version's SAM template and CDK construct now do. The README's upgrade section no longer offers the planProtocol: "v1" fallback.
--site-id cannot find sites from earlier versions f7ad850e21e When sites/<id>/files.json is missing, the render uses that site's project.tar.gz, which the plan step still reads. With neither, it fails before starting with SITE_NOT_FOUND, naming both keys and saying to run sites create again.
Docs out of date fbdb38675f9 Deploy guide: the shared cas/sha256/ store and its 30-day rule, the 7-day refresh, the upgrade order, old site ids, the one-time re-upload, the new IAM action for SDK callers, and troubleshooting for STACK_TOO_OLD, SITE_FILES_MISSING, SITE_NOT_FOUND. A test keeps an entry for each pre-submit error.
Nit: no log when a chunk reads by URL 2991d68c42f One reading video by URL line per remote source with the path and the URL minus its query.
CI: a signed URL fragment survived in an ffmpeg error 82425cba7d4 The stderr tail was trimmed before redaction, so a cut inside the URL left ...Signature=... unmatched. ManagedChildProcess (every spawn's stderr) now redacts before trimming, and the redactor also strips a signing parameter cut off from its URL.

Live upgrade path at 2c9a091701f, us-east-2, stack deployed by main's CLI (b46ebedeae9, built from source):

  • Rendering with this head without redeploying: exit 1 with STACK_TOO_OLD before anything was uploaded; 0 executions, bucket unchanged.
  • After lambda deploy from this head, render --site-id <id from main's sites create>: rendered from that site's project.tar.gz, SUCCEEDED, 3600 frames, 120.000 s. A render of the same project as a new site gave identical frames (PSNR inf). CloudWatch: 16 reading video by URL lines, 0 hits for X-Amz-Signature, X-Amz-Credential or Amz-Security-Token. Cost $0.07 per render. Everything torn down.

Release notes

  • Redeploy your stack before upgrading the CLI or SDK. Sites are now a file list (sites/<id>/files.json) over a bucket-wide cas/sha256/ store, which older stacks cannot read. Renders against an older stack stop with STACK_TOO_OLD before uploading.
  • Each project uploads once more after the upgrade: the site id scheme changed. Old site ids keep rendering.
  • Files under cas/sha256/ expire 30 days after their last write; renders refresh files they reuse. A site whose files expired re-uploads them from the project directory, or stops with SITE_FILES_MISSING when the SDK has none.
  • SDK callers with their own IAM role need states:DescribeStateMachine on the state machine.
  • Cloud Run: site ids change once too (each project uploads once more); its sites stay tarballs.

Packaging repair — aa33a0e3eed498955918bb4d6f97881111557c83

The actual hosted failure was duplicated presigner/Smithy code exceeding the published package budget. The four public ESM entries now build together and share internal chunks; all entry paths and the separate Lambda ZIP builder remain unchanged. The newly declared presigner stays external. The reachability pin now reflects the reviewed real remote-MP4 integration test classification.

Measured published package: 53,526 packed / 183,824 unpacked bytes. The gate required lowering the budget to 56,203 / 193,016, and that tighter limit passes. The packed verifier finds no unresolved internal imports; all four published entry imports retain their export counts and external dependency boundaries. AWS suite 167/167 and package/reachability script tests 49/49 pass. Normal commit hooks pass. Local declaration generation still reports the disclosed cached Smithy type mismatch; this is not a full local build success, clean-install or live Lambda acceptance claim. Current-head locked-dependency CI and independent combined review remain required. All earlier reports follow unchanged.

Current-main integrated successor 5ebfa8145a551da195cd0d5cbec6527538660062

Normal signed merge of main deeb8d4 resolves the six actual storage/planning conflicts. It retains remote site manifests, shared digest references and per-chunk range routing alongside main's stable source paths, digest cache, stage timings and audio-mix/upload overlap. Remote and local artifacts use the same bounded32 publication queue; metadata is published last. Remote audio receives the URL map at main's later audio stage; deferred video sources retain stable project-relative identity.

Current combined checks: planV2 65 pass; real ffmpeg remote-MP4 planning1 pass; plan-hash24 pass; plan56 pass; S3 transport/publisher/handler47 pass. Producer typecheck, touched-file format/lint and normal signed commit hooks pass. The separate local AWS-adapter typecheck is non-green because cached S3 Smithy4.14.2 types conflict with the task-only declared presigner's separately resolved Smithy types; no source cast/suppression, manifest change or shared-cache edit was used. Hosted locked-dependency CI must establish the integrated adapter result. No new live AWS benchmark, deployment or all-workspace-build claim. Fresh full-target CI and independent combined review remain required.

Consolidated scope — owner-authorized queue reduction

This survivor preserves #5772, #5774, #5778, #5767, #5776 as original commits; every original head is an ancestor of da6cc6d12b99ef2c255561c745ba1974bd2bdd0b. Superseded PR branches stay intact.

Full preserved source union: shared content storage/remote probing and per-chunk file/range routing. This integrates both original stacks; all reports below keep their historical limitations. Combined tests and normal hooks are recorded in the task report; fresh current-main CI and independent review remain required. No deployment or new live cloud benchmark claim.

All original reports and captures follow, attributed to their original heads. Their limits remain; fresh full-target CI and independent review are required before normal merge. No admin bypass, deployment, paid call or feature activation was performed by this consolidation.

Original report — #5772 at 57df7e8

What changed

Rendering a project through a symlink (ln -s my-project fx; hyperframes lambda render fx) uploaded a site that held a single entry, . -> <local path>, while the site id was a hash of the real files. The broken upload was cached under the correct id, so every later render of that project failed with [plan] entry file not found until the cached site was deleted by hand. Symlinked files or folders inside a project had the same split: the id ignored them, the upload shipped them as links that do not exist on the worker.

The cause was two owners of "which files are the project": the id hash walked real files, and the upload ran tar over .. Now one function decides:

  • listSiteFiles(projectDir) in projectHash.ts lists the files a site ships. It follows symlinks (the root and anything inside, with a guard against folders that link back to a parent), leaves out the same top-level folders the plan stage skips, and throws on a dangling symlink, naming the path.
  • hashProjectDir hashes that list, and both deploySite verbs (AWS Lambda and Cloud Run) pack exactly that list. tarDirectory takes an optional file list and follows symlinks only when given one, so plan and output archives are unchanged.
  • The lambda-local regression harness, which mirrors deploySite, packs the same list.

Site ids change once. The id now includes a scheme tag, so every site uploaded before this change, including broken ones sitting under the right old id, is bypassed. Each project re-uploads once after upgrading; after that the cache works as before. Caller-supplied siteId values are untouched.

What I measured

  • Reproduced on main with the real deploySite, a fake bucket and the worker's own untar: a symlink to the render-symlinked-assets fixture produced a tarball with no index.html and no shared/brand.css, and plan() threw [plan] entry file not found. On this branch the same flow ships index.html and shared/brand.css as a real file, and plan() writes the plan dir.
  • New tests: root-symlinked project (both adapters), symlinked file and folder packed as content and hashed by target bytes, skip-list folders neither hashed nor packed, dangling symlink error, parent-loop guard, and the id differing from the old unversioned scheme. All fail on main and pass here, 3 runs in a row, along with the existing transport tests.
  • One mutation per new test (stat instead of following links, no skip list, no loop guard, no error message, no scheme tag, each adapter ignoring the list, each adapter not following links): each turns its test red.

What I did NOT exercise

  • No real AWS or GCP upload; the bucket was a fake that records the uploaded bytes.
  • The lambda-local regression harness was typechecked, not run (it needs the Docker test image).
  • The plan stage's own copy (plan.ts) still applies the skip list itself; it reads an already-filtered site in the cloud path, so it cannot disagree there. Moving it onto listSiteFiles is a possible follow-up.

Original report — #5774 at 62eb3c8

What changed

A Lambda render used to move a project's video three times before any chunk started: the SDK tarred and uploaded the whole project, the plan step downloaded and untarred it, then re-uploaded the video as a plan artifact under that render's own prefix. Nothing was reused across renders.

  • Shared content store. The render bucket gets one content-addressed area, cas/sha256/<first two hex>/<sha256>, shared by every render. The key comes from the SHA-256 alone (contentStore.ts owns the prefix, the key and the retention numbers).
  • SDK uploads files once. deploySite uploads each project file (the list from listSiteFiles) to the shared area, skipping any whose key already exists (HEAD), then writes a small sites/<siteId>/files.json mapping path to hash. No tarball, no gzip.
  • Plan reads the site, reuses the bytes. The plan step rebuilds the project from files.json, verifying each file's SHA-256 as it downloads. Plan v2 artifacts now live in the shared area too, so the video the SDK already stored is found by HEAD and never re-uploaded. A .tar.gz ProjectS3Uri still works for hand-built uploads (smoke.sh, the README).
  • Integrity unchanged. Every write to the shared area is still create-only (IfNoneMatch: *) with ChecksumSHA256, chunks and assemble still verify each artifact's SHA-256 on download, and an existing object is reused only when its stored digest and size match. files.json paths that escape the project are refused.
  • CLI. --site-id builds its handle through one SDK helper (siteHandleFromId) instead of two hardcoded tarball URIs.

Retention: 30 days, refreshed on reuse

  • A lifecycle rule expires cas/sha256/ after 30 days, added to both the CDK stack and the SAM template. A test pins the SAM rules to the CDK rules and to the constants.
  • S3 counts expiry from an object's last write, not its last read. So whenever the SDK or a plan reuses an object that is 7 days old or older, it rewrites the object in place (server-side CopyObject, same bytes, same SHA-256 metadata and checksum). That restarts its 30-day clock.
  • Every object a plan's manifest references has passed through that reuse check during the plan, so it has at least 23 days left when the chunks run. A render never loses a file mid-run.
  • A site idle for more than 30 days heals itself. Before a render reuses a site (render --site-id, render-batch, or an SDK siteHandle), it HEADs every file in the site's files.json. That also refreshes any file older than 7 days. Any missing file is re-uploaded from the local project directory, after checking it against its recorded SHA-256. The CLI always passes the project directory, so it never fails on expiry. An SDK caller who passes a site handle without projectDir gets SITE_FILES_MISSING, with the missing-file count and the fix (run sites create, or pass projectDir).

IAM

No change needed. The function already has read and write on the whole render bucket (CDK grantReadWrite, SAM S3CrudPolicy). That covers HEAD, GET, PUT and the in-place copy on the new prefix. Nothing was widened.

Not changed

Cloud Run keeps today's tarball path. Chunk range reads (a sibling change) read deferred videos through a presigned URL to <artifact prefix>/<aa>/<sha256> and check S3's stored SHA-256. That key and checksum are exactly what the shared area holds, so it works against these objects unchanged.

Stacked on the symlink fix: this uses its listSiteFiles as the only definition of "the project's files".

What I measured

  • bun test, one file at a time, in packages/aws-lambda. All pass: transport 14, deploySite 9, publisher 7, handler 18, CDK contract 6, CDK snapshot 13, renderToLambda 11, plus the untouched suites.
  • I broke the code once for each new test, and each one failed: refresh disabled, existing keys never skipped, artifacts back under the per-render prefix, tarball-only plan fetch, path check removed, SAM lifecycle drifted, CDK lifecycle drifted.
  • Real AWS, 30-minute 1080p30 benchmark (932 MB project, 902 MB video), with perf(aws-lambda): the plan step probes project videos in place instead of downloading them #5778 on top. The CLI uploaded the project as loose files in 14.8 s; on main the tarball upload took about 46 s, including tar and gzip. Deploying the same tree again uploaded nothing (uploaded: false) in 2.8 s, timed twice. After two renders the bucket held exactly one copy of the video (under cas/sha256/), and the deployed bucket showed both lifecycle rules (renders/ 7 days, cas/sha256/ 30 days). Numbers and the teardown are in perf(aws-lambda): the plan step probes project videos in place instead of downloading them #5778.
  • Two tests cover expired files in a reused site. One re-uploads the missing file from the project directory and renders; the other, without a project directory, refuses with the missing count before starting. Both fail when the check is removed, and each fails under its own one-line mutation.
  • Whole renders stay at about 16 minutes on this change alone (944 s vs 969 s on main), because the chunks take about 13 of them. That changes only with chunk range reads and the Distributed Map changes.
  • Typecheck is clean for aws-lambda, cli and producer. The fallow audit and the comment ratchet pass against this branch's base.

What I did NOT exercise

Original report — #5778 at 6d3ed92

What changed

The plan step used to download every project video in full, only to read its header and its last second. Now a video stays in the shared content store (from the PR below this one), and the plan reads those two parts over HTTP.

  • Lambda plan. When it rebuilds a deployed site, the plan step downloads only the non-video files. For each video (.mp4 .mov .m4v .webm .mkv) it passes a pre-signed GET URL (1 hour) with the file's SHA-256 and size as remoteFiles to planV2WithPublisher. A .tar.gz project and the v1 plan path behave exactly as before.
  • Planner. Each such file is keyed by the local path it would have had. Three resolvers check that map: the compiler's duration probe (header only), the engine's video extractor (metadata, then the deferred last-second tail probe, both straight from the URL with no download) and the audio mixer. meta/videos.json records the project path, never the URL. meta/remote-files.json lists path, SHA-256 and size, so the plan hash covers those files.
  • Publication. Those files become manifest artifacts under compiled/<path>, routed like any compiled asset. A new optional publisher method, referenceStoredBlob, confirms the object is already in the store (stored digest and size must match, and the 7-day age refresh applies) instead of uploading it. A local plan directory, or a publisher without that method, refuses remote files loudly.
  • Chunks are unchanged. They fetch the artifact by digest from the shared store and verify it. Chunk range reads (a sibling change) read the same object through its own pre-signed URL.

Two bugs fixed along the way

  • extractMediaMetadata read the entire video into memory as a possible PNG whenever any of its three color tags was missing, which is common for H.264. It now does that only when ffprobe reports PNG. A regression test fails without the fix.
  • Plan asset hashing used readFileSync, which Node refuses above 2 GiB. It now streams, using one shared hasher (sha256FileHex) for freezePlan and plan v2.

What I measured

  • A real planner run (ffmpeg present) on a 3-second H.264 video known only by URL. The plan defers the video, meta/videos.json points at compiled/assets/clip.mp4 with no URL in it, the publisher references the digest and never receives its bytes, and changing the digest changes the plan hash.
  • The Lambda handler test asserts the plan step makes no GET of the video's key and uploads only the manifest.
  • I broke the code once for each mechanism, and a test failed each time: extractor lookup, source shipper, publication of remote entries, the srcPath mapping, the handler keeping videos remote, a missing object accepted, a referenced digest not counted as durable, and the PNG fix reverted.
  • Existing suites pass, one file at a time: producer planV2 62, plan 46, renderChunk 19, htmlCompiler 142, publicExports 8; engine ffprobe 137, videoFrameExtractor 201, audioMixer 69; aws-lambda handler 18, publisher 8, transport 14, deploySite 9.
  • Real AWS run. I deployed this head (stacked on perf(aws-lambda): upload project files once to a shared content-addressed area #5774), with reserved concurrency 300, 10 GB x86, in us-east-2. The fixture was a 30-minute 1080p30 benchmark: one 902 MB H.264 video, 29 MB of audio, 1800 s. I rendered it twice, once as the first upload and once reusing the site, and compared against the same fixture on main.
main run 1 (first upload) run 2 (reuse)
Plan step (Step Functions state) 103.6 s 70.7 s 66.2 s
Plan reaches "compiled composition" 16.9 s ~5 s
Plan peak memory, cold start 5,138 MB 1,013 MB (warm start, not comparable)
Copies of the video in the bucket one per render 1 still 1
Submit to finished file 968.8 s 944.1 s 937.9 s
Lambda cost per render $2.09 $2.09 $2.09
  • The bytes moved: after both runs the bucket held exactly one object the size of the video, under cas/sha256/, last written at the CLI upload. Neither plan read it whole or re-uploaded it, and no render has its own copy.
  • Both outputs, checked with ffprobe: 54000 video packets, 1800.000 s, an AAC track of 1800 s, mean -27.1 dB.
  • Whole renders stay at about 16 minutes on this lane alone, because the chunks take about 13 of them (787 s Map). That changes only with chunk range reads and the Distributed Map changes.
  • The plan's remaining ~66 s is not timed by stage on this branch. The largest item is probably the audio mix of 1800 s (a separate change).
  • Everything I deployed was deleted afterwards, and a resource listing for the stack's name came back empty.

What I did NOT exercise

  • A video with an audio track (data-has-audio) is still fetched whole by the audio mixer when it needs that audio. That is correct but not faster; the plan's audio work is a separate change.
  • Some header probes are not cached for URLs: about four per remote file.
  • Media referenced only from a sub-composition whose duration needs the browser probe does not see the URL map.
  • Cloud Run keeps today's path.

Original report — #5767 at 1cdc271

Distributed render chunks (plan v2, Lambda and Cloud Run) now download only the project videos they show. Before this, every chunk downloaded every video in the project.

What changed

  • Router (planV2.ts, artifactTargets). Chunks extract their own frames from deferred video sources. A deferred source that the project already ships under compiled/ now goes only to the chunks that show it, as video-sources/ files already did. Everything else under compiled/ (HTML, images, fonts, CSS, videos the plan does not defer) still goes to every chunk. Lambda and Cloud Run both read these per-chunk lists, so neither adapter changed.
  • Chunk (renderChunk.ts). A chunk skips the browser readiness wait for a deferred video it does not show. That is exactly the set the router no longer gives it, taken from the chunk's own frame extraction result. Without this, the chunk logs media_load_failed for a file it was not given on purpose. A video the chunk shows keeps today's readiness wait. A missing source for a shown video still fails the chunk (MISSING_PLAN_ARTIFACT, unchanged).
  • Test contract change. The old test "keeps a deferred source the page itself loads in every chunk" pinned the old routing. Its reason was that a missing <video> src fails the chunk. In practice chunks run best-effort and only log a warning, and this PR removes that warning too. The test is replaced by "ships each compiled/ video source only to the chunks that show it", which uses two clips: one shown in a single chunk, one shown in both.

I tried and rejected giving chunks the size hints that local renders use (collectVideoMetadataHints). They set a height attribute that resizes a video styled with only a CSS width, so chunk 0 changed pixels. The before/after chunk hashes caught it. Local renders have the same behaviour today; that is a separate fix.

What I measured

Linux host, a 60 s 640x360 composition at 30 fps in 4 chunks. It has a 60 MB video shown for 0-60 s and a 1 MB video shown for 0-10 s.

chunk 0 chunks 1-3
bytes downloaded, before 61,240,267 61,240,267 each
bytes downloaded, after 61,240,269 60,243,993 each
chunk mp4 sha256 identical before and after identical before and after
  • All 4 chunk outputs are byte-identical before and after. There were no media_load_failed warnings in either run.
  • On this fixture the saving is the short clip in 3 of 4 chunks. It grows with the number of clips a chunk does not show.
  • New tests fail without the change, one mutation each:
    • Router reverted: the planV2 test gets "all" instead of [1], and the chunk test gets "all" instead of [0].
    • Readiness skip removed: the chunk test fails on media_load_failed.
    • Skipping every deferred video: the chunk test fails, because a shown video must not be skipped.
  • The new chunk test passed 3 runs in a row. planV2, renderChunk, plan, planSizeCap, publicExports, chunkBoundary and renderChunkFallback tests are green, as are the aws-lambda handler and Cloud Run server tests and producer tsc --noEmit.

What I did NOT exercise

  • A real Lambda or Cloud Run deploy. The measurement drives the same listPlanV2ArtifactsForTarget, materializePlanV2Target and renderChunk path locally.
  • Videos the planner extracts up front (clips shown for about a second or less, VFR) are not deferred. Their source under compiled/ still goes to every chunk even though no chunk reads it. Scoping those needs the plan to record their plan-relative path. That is a follow-up.
  • A composition whose script draws a video that is not on screen (for example a WebGL texture of an off-screen clip) in a chunk that does not show it. That chunk no longer has the file.
  • Reading only the byte range of a long video that a chunk shows. That is a separate change.

Original report — #5776 at 2895705

What changed

On the AWS Lambda path (plan protocol v2), a chunk that extracts its own frames from a long project video no longer downloads and hashes the whole video before its browser starts. It reads the video straight from S3 by byte range: only the container index and the part its seek needs.

  • The chunk handler signs a short-lived GET URL (1 hour; a Lambda invocation runs at most 15 minutes) for each deferred video source the manifest gives that chunk, and hands it to the chunk instead of a local file.
  • FFmpeg's own HTTP client does the reading. On an input seek it asks only for the byte ranges it needs.
  • Every other artifact is still downloaded whole and sha256-verified, as before.
  • The chunk's browser cannot load a video it was never given, so for those videos the chunk skips the readiness wait and sizes the <video> from the plan's metadata. This is what a local render already does for every video whose frames it supplies (collectVideoReadinessSkipIds / collectVideoMetadataHints).
  • Cloud Run is out of scope: its chunk worker still downloads every artifact. The producer side (materializePlanV2Target(..., { remoteSources })) is storage-neutral, so the same switch is available to it later.

Why the bytes are still the right bytes

A deferred video is no longer hashed on the chunk, so integrity rests on how plan v2 stores artifacts:

  1. The key is the content hash. Artifacts live at .../artifacts/sha256/<2>/<sha256>, and the manifest that names them is itself pinned by the PlanHash the state machine passes.
  2. Objects are written once, and S3 checks the bytes on write. The publisher puts each blob with If-None-Match: * and ChecksumSHA256, so S3 rejects a body that does not hash to the key, and no publisher can overwrite it.
  3. S3 attests the object before the URL is signed. The handler sends HeadObject with ChecksumMode: ENABLED and refuses (PLAN_ARTIFACT_DIGEST_MISMATCH) unless the stored SHA-256 and size equal the manifest's.
  4. A read that fails partway fails the chunk. FFmpeg can exit cleanly after a refused read. I checked this with a server that refuses the seek: it produced zero frames, and the chunk failed (zero_output). The chunk already fails when any frame it needs is missing.
  5. FFmpeg verifies S3's certificate. For an https input, ffmpeg and ffprobe get -tls_verify 1 -ca_file <bundle> (urlInputArgs). The bundle is the first of /etc/pki/tls/certs/ca-bundle.crt (the Lambda nodejs:22 image, Amazon Linux 2023), /etc/ssl/certs/ca-certificates.crt and /etc/ssl/cert.pem that exists. If none exists, the read fails with a clear error instead of going ahead unverified. Local paths and plain http are unchanged.

The signed URL never shows up in errors

extractVideoFramesRange strips the query string from every http(s) URL in each string field of an error it throws (message, diagnostic, stack). It uses the same redactUrlQueryStrings that telemetry already uses, now exported from core. ffprobe diagnostics were already redacted.

What I measured

  • Same frames, fewer bytes (unit test, real ffmpeg). Extracting 15 frames from the middle of a 2-minute lossless 640x360 file, by URL versus by local path: identical JPEG bytes for every frame, and the server sent about 10-12 MB of a 71 MB file. Most of that is socket buffering past the seek, so the share falls as files grow.
  • Same chunk, byte for byte. A real chunk render (Chrome + ffmpeg) of a plan-v2 chunk that shows a deferred video gives the same chunk sha256 whether the video comes from the plan or from a local HTTP server. Without the plan-metadata sizing it differs (mutation below).
  • Every new test fails without the change. On the base branch with this branch's tests: the handler test sees the video downloaded, the URL extraction finds no source, the end-to-end render finds the video copied into the plan, and the two new helpers do not exist. Each production change also has a mutation that turns exactly its test red: no URL from the handler, no checksum check, materialize copying or accepting any remote path, the extractor ignoring the URL, no stray-URL check, and no <video> sizing.
  • TLS verification is on and works on Lambda. I redeployed this head and rendered the 2-minute fixture. It SUCCEEDED with 16 chunks, 0 failed tasks, 44 s in Step Functions and $0.065. Every chunk read its video by URL with verification on, and no log line contains a signed query. Output: 3600 frames, 120.000 s, audio intact, and the frame at 65 s is right. I listed the bundle inside public.ecr.aws/lambda/nodejs:22 (Amazon Linux 2023): /etc/pki/tls/certs/ca-bundle.crt exists and /etc/ssl/certs/ca-certificates.crt does not. With the bundled ffmpeg and ffprobe builds, -tls_verify 1 -ca_file accepts S3, rejects a self-signed certificate, and fails when the CA file is missing. Without -tls_verify the self-signed site is accepted, which is the gap this closes.
  • Tests for both fixes. A test checks that the URL-input args carry -tls_verify 1 -ca_file. Another checks the refusal when no bundle exists, and a third checks that a failing ffmpeg run leaves no signed query in the error. Breaking each fix turns its test red.
  • Lint, format, typecheck for core, engine, producer and aws-lambda, and the comment and fallow audits are clean. The changed test files pass 3 runs in a row.

One real AWS Lambda render of a 30-minute 1080p30 fixture: a 932 MB H.264 background video that is on screen the whole time, plus a GSAP title and AAC audio. I deployed this branch with reserved concurrency 300 and rendered with --max-parallel-chunks 225, giving 225 chunks of 240 frames.

Before (same fixture, earlier baseline on main) This branch
Seconds from Lambda start to the chunk's first browser launch about 45 s (the whole 932 MB video downloaded and hashed) median 2.9 s, p90 5.2 s, max 5.7 s (all 225 chunks)
Bytes of the video each chunk reads 932 MB the moov box plus the byte ranges for its 8 seconds (locally: 17.5 MB of a 293 MB 1080p file for one 240-frame chunk, socket buffering included)
  • Whole render: Step Functions 302 s (plan 109 s, chunks 107 s, assemble 86 s), with 0 failed tasks and 0 retries. Chunk Lambdas took 19-24 s each. Measured cost was $0.83 for 227 invocations.
  • The chunks did not run as one wave. Peak concurrency was 50, which is the state machine's own Map limit, not something this PR touches.
  • Output, checked with ffprobe -count_packets: h264 1920x1080 at 30/1, 54000 frames, 1800.000 s. AAC audio is 1800.000 s (84376 packets) at a mean of -27.1 dB, the same as the baseline. Frames grabbed at 900 s and 1799 s show the right background timecode and title state.
  • The local Range proof is the same 240-frame chunk extracted from a 1080p 4 Mb/s file once by path and once by HTTP: identical sha256 over all 240 frames.

The baseline was measured at 16 chunks, so the end-to-end times are not comparable. The window before browser launch is: it is spent on the download whatever the chunk length, and it is what this PR removes.

What I did NOT exercise

  • Cloud Run. Its chunk worker still downloads every artifact whole.
  • Short-lived credentials. A pre-signed URL stops working when the Lambda's role session expires, even before its own 1-hour expiry. A chunk that hits that fails loudly and is retried.
  • Plain http inputs, and hosts whose CA bundle is somewhere else. Plain http inputs get no TLS options; only the local test servers use them. A host with its bundle in another path fails with a clear error instead of reading unverified.

A project video under compiled/ that chunks extract their own frames from
now goes only to the chunks that show it, like video-sources/ files. A
chunk skips the readiness wait for a deferred video it does not show, so
it no longer logs media_load_failed for a file it was never given.
@github-actions

github-actions Bot commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 271 (base branch 271), smooth 172 of those

The gate passes.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

Quarantined, measured but not gated (0)

Unstable (1)

  • resize-size-px-r0-nested-z100-on: tracking 0.05, pressJump 0, drop 0, reload 0, render 0.02, renderKey 0.02, undo true, teleport true / tracking 0.05, pressJump 0, drop 0, reload 0, render 0.02, renderKey 0.02, undo true, teleport true / tracking 0.05, pressJump 0, drop 0, reload 0, render 0.02, renderKey 0.02, undo true, teleport true

A chunk that extracts its own frames from a deferred project video now
reads it from S3 by byte range through a short-lived pre-signed URL,
instead of downloading and hashing the whole object first. S3 attests the
object's stored SHA-256 before the URL is signed; every other artifact is
still downloaded and verified as before.
…covers wrapped causes

Signed-URL reads now pass -rw_timeout, -reconnect and -reconnect_delay_max (both Lambda builds accept them) and ffmpeg also -reconnect_on_network_error, so a transient S3 error or a stalled read fails fast or recovers instead of waiting out the process timeout. Query redaction now descends into error.cause.
@miguel-heygen miguel-heygen reopened this Oct 18, 2026
@miguel-heygen miguel-heygen changed the title fix(producer): cloud site uploads pack the files their id hashes, symlinks included perf(cloud): move project bytes once through shared content storage Oct 18, 2026

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes at aa33a0e3. I read the full diff against main (merge base deeb8d43), with a focus on backward compatibility, because this changes the stored site format that adopters' existing stacks and site ids depend on. A live run on Lambda and Cloud Run at this head is in progress on our side. I'll follow up with its results; nothing below depends on it.

Blockers

1. A new SDK or CLI against a stack deployed before this PR fails every render in the plan step.

  • deploySite now writes sites/<id>/files.json and returns it as projectS3Uri, and renderToLambda passes that straight through as ProjectS3Uri.
  • On main, the plan handler downloads ProjectS3Uri to project.tar.gz and untars it (handler.ts at deeb8d43, lines 427-434, and the v1 path at 360-366). So an older stack gets a JSON file it can't untar.
  • Nothing checks the stack's version, and the fallback the README documents for exactly this situation (packages/aws-lambda/README.md:79-83, "pass planProtocol: "v1" explicitly until the redeploy is complete") no longer works. The site format itself changed, so v1 breaks the same way.
  • What's needed: either keep a path that works against an older stack (detect that the stack predates files.json support and upload a tarball site, as before), or at least fail before submitting with a clear "redeploy the stack first" error. Either way, update the README's upgrade section so it no longer offers a fallback that doesn't work.

2. --site-id and siteHandleFromId can't find sites uploaded before this PR.

  • siteHandleFromId always builds s3://<bucket>/sites/<id>/files.json.
  • A site created by lambda sites create on any earlier version has only sites/<id>/project.tar.gz. So ensureSiteStored throws NoSuchKey on the manifest download before anything is submitted, and the handler would do the same.
  • The docs tell adopters to keep and reuse site ids: docs/deploy/aws-lambda.mdx:90-94, docs/packages/cli.mdx:1939,1985,1999, docs/deploy/templates-on-lambda.mdx:86, and render-batch.
  • What's needed: fall back to project.tar.gz when files.json is missing (the handler already accepts tarballs), or give a clear "site was created by an older version; run sites create again" error. Add a test that uses an old-format site.

3. The docs are out of date for this change.

  • docs/deploy/aws-lambda.mdx:94 still describes the tarball HeadObject short-circuit.
  • Nothing documents:
    • the shared cas/sha256/ prefix and its 30-day lifecycle rule;
    • SITE_FILES_MISSING;
    • the one-time site-id change, which means every existing project uploads again on its first render after the upgrade.
  • Users upgrading need all of that in the deploy docs and the release notes.

Interaction with #5813

Both PRs branch from the same deeb8d43 and overlap in 9 files. git merge-tree against #5813 at 98183d65 conflicts only in HyperframesRenderStack.snapshot.test.ts. build.mjs merges cleanly, keeping both @aws-sdk/client-lambda and @aws-sdk/s3-request-presigner external, with splitting: true.

However, this PR lowers the aws-lambda package budget to 56,203 packed / 193,016 unpacked bytes, while #5813 was over the old budget. Whichever PR lands second has to re-measure against the new budget and rerun the upgrade path live. The two PRs are never tested together on CI, because each one runs against main.

Checked and fine

  • Old SDK or CLI against a new stack: tarball ProjectS3Uri values still work (isSiteManifestUri is false, so the handler untars as before).
  • Caller IAM: deploySite now uses Head, Put and in-place Copy calls, which are covered by s3:GetObject/s3:PutObject in policies.ts:142. The function role's bucket read/write covers presigning and copying. No new permissions are needed.
  • files.json path checks reject .., ., empty segments and backslashes, and every downloaded file is verified against its SHA-256.
  • The in-place refresh uses MetadataDirective: REPLACE, which S3 requires for a copy onto itself.
  • urlInputArgs (TLS verification, which throws when no CA bundle is found) is only reached for https inputs. User-authored https sources are still downloaded first in htmlCompiler, extractAllVideoFrames and audioMixer, so local renders, including on Windows where none of the three bundle paths exist, never call ffmpeg with an https input. Only Lambda's presigned remote files do.
  • All 12 commits are signed and verified. Build, Typecheck, Fallow audit and Test reachability pass. Other checks are still running.

Evidence still needed

As disclosed, there is no live run at this head, and gcp-cloud-run/src/sdk/deploySite.ts changed, which also changes Cloud Run site ids. Before I can approve, I need a live Lambda run of the upgrade path (main stack → this head, including a pre-existing site id) and a Cloud Run deploy and render at the head being approved.

— Rames

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Live results at aa33a0e3, run by our hyperframes session in HeyGen's engineering AWS account (us-east-1) and GCP project hyperframes-dev (us-central1). Everything has been torn down. My change request stands: blockers 1 and 2 are now confirmed live.

Lambda

# Check Result
1 Stack deployed from main's CLI, then rendered with this PR's CLI without redeploying FAIL, as predicted. No warning. The CLI uploaded sites/6f8cc4933c10562f/files.json. PlanV2 threw TAR_BAD_ARCHIVE: Unrecognized archive format and was retried 4 more times by States.ALL before the execution failed (hf5772-skew-render).
2 sites create from main (tarball site 68b647887ab30f45), stack upgraded to this head, then render --site-id 68b647887ab30f45 FAIL, as predicted. Exit 1 with only The specified key does not exist., raised client-side in ensureSiteStored before StartExecution. The old project.tar.gz is still in the bucket. The error doesn't name the key or suggest running sites create again.
3 Upgrade from main to this head, using the role from lambda policies role PASS. The CloudFormation update succeeded, and the bucket now has ExpireSharedContent (cas/sha256/, 30 days) next to ExpireIntermediates. Reserved concurrency stayed 8. Running sites create twice gave uploaded:true then uploaded:false. Two sites sharing one clip left exactly one object under cas/sha256/. The render succeeded: 900 frames, 30.000 s.
4 Chunk range reads (6 chunks of a 40 s 720p H.264 clip) PASS. Output is byte-identical to main's render of the same fixture. Per-render S3 dropped from 15 objects / 22.2 MB to 8 / 7.2 MB, with no per-render copy of the clip. CloudWatch has 0 matches for X-Amz-Signature, X-Amz-Credential or Amz-Security-Token.
5 examples/aws-lambda/scripts/smoke.sh PASS, exit 0. v1/v2 at N=2 and N=4 scored 37.11-37.12 dB, and the semantic comparison passes.
6 Merged with #5813 (98183d65) Only the CDK snapshot test conflicts. The aws-lambda tests pass, 134 of 134. Packed size: this PR alone is 53,138 / 183,824 B; merged it's 54,862 / 190,114 B, inside this PR's budget. The merged tree rendered on the upgraded stack: the Map ran 60 of 60 children, peak concurrency was 58, and PSNR against main was 48.04 dB.

Nit from item 4: nothing logs that a chunk took the URL path. It's shown by the artifact layout and the identical output, not by a log line. One log line per remote source, without the query string, would make it observable.

Cloud Run

  • examples/gcp-cloud-run/scripts/smoke.sh --protocols v1,v2: PASS. Both protocols succeeded at 37.11 dB, and the v1/v2 comparison passes.
  • Old site on this PR: main's SDK deploySite created tarball site 68b647887ab30f45. Rendering it with this PR's renderToCloudRun({siteHandle}) succeeded, 900 of 900 frames. Cloud Run sites are still tarballs, so old sites keep working there.
  • Running this PR's deploySite on the same project gave the new id 6f8cc4933c10562f and uploaded once. A render from projectDir succeeded. Old and new sites rendered at 49.9 dB PSNR against each other.
  • Not run: an old Cloud Run service with this PR's SDK. The payload is the same gzip tarball, so it should work.
  • Separate from this PR: the GCP smoke.sh on main fails to build its image with unsupported TARGETARCH '' (Dockerfile:67, from #5576), because gcr.io/cloud-builders/docker doesn't set TARGETARCH. We built the images with BuildKit and passed them with --image.

What's needed to approve

  1. Fix blockers 1 and 2 from my review: keep a path that works against an older stack, or fail before submitting with a clear "redeploy first" error; and fall back to project.tar.gz for old site ids, or name the fix in the error.
  2. Update the docs (blocker 3).
  3. Rerun items 1 and 2 at the new head.

— Rames

A site id from an earlier sites create has only sites/<id>/project.tar.gz.
renderToLambda now falls back to that tarball, which the plan step still
reads, and when neither key exists it fails before starting with both keys
named and the fix (run sites create again).
…d sites

A stack deployed before files.json sites untars the site file in its plan
step and fails after four retries. renderToLambda now reads the state
machine definition first and stops with STACK_TOO_OLD and the redeploy
command when the plan task does not list SITE_MANIFEST_INVALID, which new
SAM and CDK stacks now treat as terminal. The README's upgrade section
drops the planProtocol v1 fallback, which hits the same failure.
The deploy guide still described the tarball HeadObject short-circuit. It
now covers the cas/sha256/ store and its 30-day lifecycle rule, the
STACK_TOO_OLD, SITE_FILES_MISSING and SITE_NOT_FOUND errors, that old site
ids keep working, and that every project uploads once more after the
upgrade. A test keeps a troubleshooting entry for each pre-submit error.
A chunk reading a video straight from the bucket left no trace in its log.
It now logs one line per remote source with the path and the URL minus its
query string, so signatures never reach the log.
@miguel-heygen
miguel-heygen force-pushed the cloudrender/site-symlinks branch from aa33a0e to 2991d68 Compare October 18, 2026 20:33
@mintlify

mintlify Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated
hyperframes 🟢 Ready View Preview Oct 19, 2026, 3:17 PM

💡 Tip: Enable Automations to automatically generate PRs for you.

A process's stderr tail was trimmed before its URLs were redacted, so a
cut inside a signed URL left a query fragment the URL pattern no longer
matched, and the fragment reached the error. The stderr tail is now
redacted before every trim, and the redactor also strips a signing
parameter cut off from its URL.
@miguel-heygen

Copy link
Copy Markdown
Collaborator Author

@jrusso1020 thanks for the review and the live table. All three blockers and the nit are fixed at 2c9a091701f (branch brought up to current main b46ebedeae9; CI green, 74 checks).

1. New SDK/CLI against an older stack (40f7fb5a4dd). I picked the pre-submit refusal over keeping a tarball upload path, so there is one site format and one upload path. Before it uploads anything, renderToLambda calls DescribeStateMachine and needs the plan task to list SITE_MANIFEST_INVALID as terminal. The SAM template and CDK construct now do that for both Plan and PlanV2 (it was being retried 4x before). If the marker is missing, it fails with STACK_TOO_OLD and the redeploy command. A snapshot test pins the marker in SAM and CDK for both plan tasks, and an SDK test checks that an old definition is refused with zero S3 ops and zero executions. Both go red when the code is broken. The README's upgrade section drops the planProtocol: "v1" fallback. Tradeoff: SDK callers that use their own IAM role now need states:DescribeStateMachine. The CLI's lambda policies already grants it, and the deploy guide says so.
Live, your item 1 (main-deployed stack, this head's CLI, no redeploy): exit 1, [renderToLambda] STACK_TOO_OLD: arn:...:stateMachine:cr5772-render was deployed by an earlier @hyperframes/aws-lambda that cannot read this version's sites. Redeploy the stack first (...). list-executions returned 0, and the bucket still held only main's tarball site.

2. --site-id for sites from earlier versions (f7ad850e21e). When sites/<id>/files.json is missing, the render falls back to sites/<id>/project.tar.gz, which the handler still untars. If neither exists, it fails before starting with SITE_NOT_FOUND, naming both keys and saying to run sites create again. Tests cover an old-format site and the neither case, and both fail without the fallback.
Live, your item 2 (sites create from main, then upgrade with this head's lambda deploy, then render --site-id <old id>): the CLI printed Project S3: .../sites/<old id>/project.tar.gz and the render SUCCEEDED with 1920x1080, 30/1, 3600 frames, 120.000 s. A render of the same project as a new site gave identical frames (PSNR inf). Cost was $0.07 per render, and everything is torn down.

3. Docs (fbdb38675f9). docs/deploy/aws-lambda.mdx no longer describes the tarball HeadObject short-circuit. It now documents the cas/sha256/ store, the 30-day ExpireSharedContent rule and the 7-day refresh, the upgrade order, that old site ids keep working, the one-time re-upload, and troubleshooting for STACK_TOO_OLD, SITE_FILES_MISSING and SITE_NOT_FOUND. A test fails if one of those pre-submit errors loses its troubleshooting entry. The CLI and migration pages and the Cloud Run reuse note are updated too, and the PR body now has release notes.

Nit: URL reads are now observable (2991d68c42f). Each chunk logs one [renderChunk] reading video by URL line per remote source, with the path and the URL without its query. Live: 16 lines (8 chunks x 2 renders), and 0 CloudWatch hits for X-Amz-Signature, X-Amz-Credential or Amz-Security-Token (quoted filter patterns, literal matches counted).

Also fixed (CI at the previous head): an ffmpeg error kept ...Signature=<value> because the stderr tail was cut before redaction and the cut landed inside the URL (82425cba7d4). Redaction now runs before every trim in ManagedChildProcess, which every spawn's stderr goes through, and the redactor also strips a signing parameter cut off from its URL. A unit test with the cut inside the query goes red when the order is reversed.

Package size: after the update aws-lambda packs to 53,834 / 187,116 B, within the 56,203 / 193,016 budget. #5813 still has to re-measure if it lands second.

Cloud Run: unchanged, since your run showed old Cloud Run sites still render. I could not run a Cloud Run deploy and render at this head: there is no gcloud access to the GCP project from my environment. That run still needs someone with access, as you did last time.

… spawn uses it

The stderr tail was redacted on each chunk and then cut, so once a cut
severed a long URL's scheme, the rest of its signature in the next chunk
matched neither pattern. The tail is now kept raw with 2x slack and
redacted only when the text is produced, then cut to size. The unused
producer audioExtractor (its own spawn, unredacted stderr) is deleted and
the parity harness's PCM decode goes through ManagedChildProcess; a test
fails on any ffmpeg/ffprobe spawn in engine, producer or aws-lambda that
does not.
The raw tail is kept at twice the cap, but that cut can itself land inside a
signature, and when redaction shrinks the rest below the cap the fragment
survived. After a cut, the severed token is dropped before redaction.
@miguel-heygen

Copy link
Copy Markdown
Collaborator Author

@jrusso1020 two more changes since my last reply, plus main merged in.

Main merged in (48ed893927b). This picks up the single faststart assembly and the account-sized concurrency changes. Conflicts were in the SAM template, the CDK snapshot test, the handler and its test, the plan audio duration and the package size budget. SITE_MANIFEST_INVALID stays terminal on both plan tasks next to the new CHUNK_EXCEEDS_WORKER_TIMEOUT, so the pre-submit STACK_TOO_OLD check still sees it. A chunk now gets both its deferred videos as URLs and its slice of the plan audio.

Stderr redaction, chunk splits (956cb5860bc). The stderr tail used to be redacted on every chunk and then cut. If the cut severed a long URL's scheme, the rest of its signature in the next chunk matched neither pattern. Now ManagedChildProcess keeps the tail raw with 2x slack and redacts only when it produces the stderr text, then cuts to size. Tests split a URL across two chunks three ways: inside the signature value, right after the ?, and after a cut severed the URL's head.

A fragment the slack cut could leave (dfa38288469). Reviewing that change, I found that the 2x cut can itself land inside a signature. When redaction then shrinks the rest below the cap, a hex fragment survived. Now, after a cut, the severed token is dropped before redaction (URLs contain no whitespace). A new test with the cut inside a signature fails on 956cb5860bc and passes now. The earlier test that expected an X-Amz-Credential=… stub after a cut now expects nothing of the cut URL at all (4b26e89dd8d).

One owner for ffmpeg/ffprobe stderr. producer/src/services/audioExtractor.ts had its own unredacted spawn, and nothing imported it, so I deleted it. The parity harness's PCM decode now goes through ManagedChildProcess. A test walks the engine, producer and aws-lambda sources and fails on any ffmpeg/ffprobe spawn in a file that does not use ManagedChildProcess. Three spawnSync calls in local regression harnesses are left as they are: they only read local fixtures, never a URL.

Head 4b26e89dd8d: CI green (73 passed, 9 skipped). Run on a Linux box at this code: managedChildProcess.test.ts 10/10, the CDK snapshot test 21/21, renderToLambda.test.ts 17/17, and the packed-manifest and size checks pass. The aws-lambda budget is lowered to what it packs after the merge (57,926 / 199,850 B).

Your items 1 and 2 were rerun live at 2c9a091701f, and the results are in my previous reply. Since then nothing on the site or stack-version path has changed except the merge.

@somanshreddy somanshreddy left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review at 4b26e89d. COMMENT, not APPROVE: I found no blocker, but Rames' CHANGES_REQUESTED (5561068381) is still open, and this changes public SDK/CLI behavior for existing callers (listed below), which needs the human-of-record's sign-off.

Rames' blockers: fixed

  • B1, old stack: fixed. renderToLambda.ts:96-97,163-173 calls DescribeStateMachine before any upload. A definition without SITE_MANIFEST_INVALID stops with STACK_TOO_OLD and names the redeploy command. Main's CDK construct and SAM template never contain that string; the head adds it to Plan and PlanV2 and pins it with a snapshot test. My probes with an old definition, a missing definition, and an old stack plus an old tarball site all stopped with STACK_TOO_OLD, making 0 S3 calls and 0 executions. README's v1 fallback is replaced with "redeploy first".
  • B2, old site ids: fixed. deploySite.ts:94-99,126-135 falls back to project.tar.gz when files.json returns 404. If neither exists, it stops before starting with SITE_NOT_FOUND, and the message names both keys and sites create. There's an old-format test.
  • B3, docs: fixed in substance (cas prefix, 30-day rule, upgrade order, new id, IAM, troubleshooting). Leftovers:
    • aws-lambda.mdx:37,129 still say "plan tarball(s)".
    • The release note "stop with STACK_TOO_OLD before uploading" isn't true for render-batch, which runs deploySite (render-batch.ts:235) before renderToLambda.
  • Nit, remote-source log line: fixed (renderChunk.ts, query string stripped, test added).

Should-fix

  1. render --site-id with expired cas files gives raw errors instead of SITE_FILES_MISSING (deploySite.ts:102-110, render.ts:138). The CLI always passes projectDir, so the missing list is unreachable from the CLI.
    • Run from a different directory, the error is [s3Transport] upload source missing: …/index.html.
    • If the local file was edited since, the PUT carries the old ChecksumSHA256. Real S3 would answer with a raw BadDigest (inferred; my fake doesn't check digests).
    • Integrity holds either way, but the docs promise a re-upload. Map both cases to SITE_FILES_MISSING and name the directory mismatch.
  2. Per-render overhead: every renderToLambda now makes a DescribeStateMachine call, and a siteHandle render adds a files.json GET plus a HEAD per file (probe: 5 renders made 5 Describe calls). render-batch defaults to 50 concurrent entries, and DescribeStateMachine's quota is far below StartExecution's, so large batches could throttle into failed-to-start (inferred, not measured). Memoize the stack check per ARN and check site storage once per batch.
  3. Remote (in-place) videos skip the STUDIO-5433 text-payload sniff. assetMediaType.ts:129 skips paths absent locally, and htmlCompiler's assertMediaPayload(URL) throws, which counts as "not a document". So an .mp4 that is really HTML surfaces as a retryable, system-owned ffprobe error instead of user-owned NOT_MEDIA_PAYLOAD. Found by reading the code, not run.

Nits

  • files.json returning 403 (caller lacks s3:ListBucket) passes through raw AccessDenied with no tarball fallback. Verified with an injected 403. Worth a doc line.
  • urlInput.ts:40 redaction: a two-error cause cycle overflows the stack (RangeError replaces the original error), and AggregateError.errors aren't redacted. Both verified. The string redaction held on every case I tried (quoted, parenthesized, uppercase scheme, %3F, cut tail, x-amz-security-token).
  • listSiteFiles follows symlinks out of the project root (e.g. assets -> ~/Downloads uploads everything). Intended but undocumented.
  • Shared store: deleting a site never removes its cas/ objects, and uploaded:false reveals that identical content already exists in the bucket. Both are fine under one stack = one trust domain; a doc sentence would help adopters with deletion obligations.
  • Size budget is at +5% (60,823 / 209,843), so little headroom for the next aws-lambda PR.

Public API / CLI caller impact (escalating, not blocking)

  • SiteHandle.projectS3Uri now points at sites/<id>/files.json. Any caller that downloads or untars it breaks.
  • SiteHandle.bytes is now the raw file total, and uploadedAt is always the current time.
  • Site ids change once for every project (site-v2, symlinks included), and the public hashProjectDir output changes.
  • A caller-supplied siteId reused with changed content now throws PLAN_ARTIFACT_DIGEST_MISMATCH (main silently reused the stale tarball).
  • renderToLambda callers need states:DescribeStateMachine and S3 GET/HEAD/PUT/Copy even on siteHandle paths, plus ListBucket for the tarball fallback.
  • New error names: STACK_TOO_OLD, SITE_NOT_FOUND, SITE_FILES_MISSING, SITE_MANIFEST_INVALID.
  • Engine: an https input to the URL-read path now throws on a machine with none of the three CA-bundle paths (e.g. Windows). Only Lambda's presigned inputs reach it.

Checked with no finding

  • Path traversal in files.json is rejected (siteManifest.ts:95-100).
  • SHA-256 is verified on every download.
  • Concurrent deploySite calls are safe (IfNoneMatch:*, then re-inspect).
  • The 7-day refresh runs on every deploy and every siteHandle render.
  • Presign lifetime is 3600 s against Lambda's 900 s cap, and a retry presigns again.
  • TLS/reconnect args are added only for https://, and the ffmpeg/ffprobe arg split matches the bundled binaries.

Tests at head

Suite Result
aws-lambda 195/195
gcp-cloud-run 104/104
CLI lambda 54/54
core redaction 36/36
producer PR-touched 203/203
producer unit lane 889 pass, 1 fail (environmental MPEG-TS case)
engine PR-touched files 420/425

All 5 engine failures fail the same way on main. CI: 73 pass, 9 skipped.

Not verified: no live AWS or Cloud Run run at this head; real-S3 BadDigest and 403 behavior; Step Functions throttling numbers.

— Somu

Main split the chunk worker's frame helpers into chunkFrames.ts; the URL-read sources, readiness skips and size hints move with them.

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review at 09350ee1. All three of my blockers from aa33a0e3 are fixed in the code. My CHANGES_REQUESTED stays in place until there is a live run at this head on Lambda and Cloud Run, which this PR's bar requires. That run is now in progress on our side.

Blockers: fixed in code

  1. Old stack. renderToLambda.ts:96-97 calls assertStackReadsSites before anything is uploaded. It reads the definition with DescribeStateMachine and throws STACK_TOO_OLD with the redeploy command unless SITE_MANIFEST_INVALID is present, and only this PR's definitions contain it. If I make the check always pass, an aws-lambda test fails. The README no longer offers the v1 fallback and says to redeploy first.
  2. Old site ids. In deploySite.ts:94-99,126-135, a 404 on files.json falls back to project.tar.gz. When neither exists, it throws SITE_NOT_FOUND, naming both keys and sites create. If I remove the fallback, 2 tests fail.
  3. Docs. aws-lambda.mdx:100-107 covers the files.json site, the cas/sha256/ 30-day rule, the upgrade order, old site ids and the one-time re-upload. Lines 233-239 add troubleshooting for STACK_TOO_OLD, SITE_FILES_MISSING and SITE_NOT_FOUND.

Tests at this head: aws-lambda 195/195, gcp-cloud-run 104/104. CI: 73 pass, 10 skipped, nothing red. All commits are signed.

Why there's still no approval: no live run of this tree

  • Miguel's live upgrade run is at 2c9a0917. That came before main merged #5813 in (final 96bf7fba), and before the stderr redaction for every spawn, the audioExtractor removal and the size-budget changes. Our Oct 18 combined run used #5813 at 98183d65, not its final head. So the Lambda code at this head has never rendered live, and the run doesn't say which account it was in.
  • The Cloud Run part of this PR is byte-identical to aa33a0e3, which passed live in hyperframes-dev. The shared producer and engine code under it has changed since, though.

What I need: at this head, in the dev accounts, (1) main stack + this CLI → STACK_TOO_OLD with no upload; (2) a main tarball site, then upgrade, then --site-id renders; (3) a fresh Lambda render and smoke with no signed-URL fragments in the logs; (4) Cloud Run smoke v1/v2 plus an old tarball site. I'll approve when those pass.

Non-blocking (agreeing with Somu's review)

  • render-batch runs deploySite (render-batch.ts:235) before renderToLambda. Against an old stack it uploads files first and then stops with STACK_TOO_OLD, so the release note's "before uploading" isn't true for that command.
  • From the CLI, render --site-id always passes projectDir, so an expired cas/ file shows up as a raw upload error instead of SITE_FILES_MISSING.

Need: yes · Size: large, but the consolidation is deliberate · Reuse: the existing content-store and plan-v2 artifact paths · Simpler: no.

No live run at this head yet.

— Rames

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving at 09350ee1. This clears my request for changes (5561068381). My code blockers were already fixed at this head (see 5576018060). The one thing missing was a live run at this head, and it now passes on both clouds. It ran on Lambda in engineering-767398024897 (us-east-1) and on Cloud Run in hyperframes-dev (us-central1), against main 0ce14d64 (#5898). The fixture was mp4-h264-sdr, 640x360, 60 frames.

Back-compat, Lambda

  • Old stack, new SDK: a stack deployed by main's CLI, rendered with the head CLI, fails fast with STACK_TOO_OLD. The bucket stayed at 0 objects and no executions started, so nothing is uploaded before the check.
  • Old site after upgrade: a site created by main (sites/<id>/project.tar.gz) still renders after the stack is upgraded to head. render --site-id <old> SUCCEEDED 60/60, and the execution input pointed at that tarball.
  • Unknown site: an unknown --site-id fails with SITE_NOT_FOUND and starts no execution.

At head, Lambda

  • A fresh files.json site render is byte-identical to the old-tarball render.
  • smoke.sh --plan-protocol both --chunk-counts 2,4 exits 0, where it exited 6 at aa33a0e3. v1 and v2 match at N=2 and N=4, all with PSNR around 37.1 dB.
  • The render log group has 0 hits for X-Amz-Signature, X-Amz-Credential and Amz-Security-Token.

Cloud Run

  • smoke.sh --protocols v1,v2 exits 0, with v1 and v2 matching.
  • A tarball site deployed by main's SDK renders with the head SDK under both v1 and v2, and the two outputs are byte-identical.

Everything was torn down in both accounts and verified.

Notes, not blockers

  • The CLI keeps its stack-state file in the cwd. That's worth a line in the upgrade docs.
  • My earlier nits (render-batch ordering and the unreachable SITE_FILES_MISSING) still stand as follow-ups.

Need: yes · Size: ok · Reuse: existing deploySite/renderToLambda paths · Simpler: no.

Live run: yes, Lambda + Cloud Run at this head, run by the hyperframes session.

— Rames

@miguel-heygen
miguel-heygen added this pull request to the merge queue Oct 19, 2026
Merged via the queue into main with commit 992df8b Oct 19, 2026
111 of 120 checks passed
@miguel-heygen
miguel-heygen deleted the cloudrender/site-symlinks branch October 19, 2026 19:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants