Repository navigation
feat(studio-server): support png-sequence from the Studio render route (#5553) - #5578
ym256-stanford wants to merge 12 commits into
Conversation
jrusso1020
left a comment
There was a problem hiding this comment.
Thanks for writing the questions down before the code. That's what #5553 asked for. Here are my answers, checked against main @ 9b996059. This is a COMMENT, since there's no code to approve yet.
Short version: Q1 A, Q2 A with a fallback, Q3 on-demand archive (see the format note), Q4 A, Q5 A. Two things in the adapters will break with a directory output whatever you choose, so please put them in the implementation plan (first section below).
Two things that break with a directory output
- The metadata file path comes from the file extension. Both adapters build it with
opts.outputPath.replace(/\.(mp4|webm|mov)$/, ".meta.json"):studio/vite.adapter.ts:328,380,397andcli/src/server/studioServer.ts:602,657,684. A png-sequenceoutputPathhas no extension, so the replace does nothing andmetaPath === outputPath.- On success,
writeFileSynctargets the frames directory and throwsEISDIRafter the render has already published. - On failure,
{status:"failed"}is written to the place the directory would have gone. - Fix: derive the sidecar from the job id (
join(rendersDir, \${jobId}.meta.json`)`), not from the output path.
- On success,
- Cancel cleanup does not "already handle" directories.
removeCancelledOutputcallsunlinkSynconoutputPath, and the error is swallowed.unlinkSyncon a directory fails, so a cancelled png-sequence render leaves its frames behind. That breaks #5553's "cancelling it leaves nothing behind".- It needs an
lstat().isDirectory()branch that usesrmSync(path, { recursive: true, force: true }). - Add a test that cancels a directory render after commit.
- It needs an
Related: the producer stages into a hidden sibling, .<jobId>.hf-transaction-XXXXXX/, created inside renders/ (artifactTransaction.ts:140-148). Today the listing ignores it only because of the extension filter. When the listing starts accepting directories, skip dot-prefixed entries explicitly.
Q1, on-disk shape: A, a directory named by the job id
Agreed. The producer already publishes renders/<jobId>/ atomically (ArtifactTransaction with kind: "directory", renderOrchestrator.ts:2882-2885), and the CLI writes the same shape.
- Branch on "is a directory output", not on
format === "png-sequence".hlsis also a directory output (renderOrchestrator.ts:2882-2885passesisPngSequence || isHls). If the route's directory branch keys on the artifact kind, HLS doesn't need a second widening later.
Q2, listing: A, with a fallback for a missing sidecar
- Write
frameCountand the summed size into the sidecar when the render completes, and read them in the listing. - Keep the existing field name
size, don't add a newsizeBytes, so current clients keep working. Addkind: "directory"to directory entries only. - Discover entries from the filesystem, not from the sidecar. A directory is a render entry when it isn't dot-prefixed,
lstatsays it's a real directory (not a symlink), and it resolves throughresolveWithinProject. - If the sidecar is missing or has no
frameCount(for example a directory copied in from the CLI), fall back to onereaddirSynccount plus a size sum. The CLI'sreadOutputFootprint(cli/src/commands/render.ts:1913-1930) is the helper to share, not the block at1892-1906, which is telemetry.
Q3, download: on demand, no zip written at completion
Agreed on streaming per request. Some notes on the format:
- Precedent: cloud rendering already packages png-sequence as a single archive. Cloud Run and Lambda
tarDirectory(...)the frames to.tar.gz(gcp-cloud-run/src/server.ts:594-597,aws-lambda/src/s3Transport.ts, which uses thetarpackage). So a single-file archive is the existing pattern; the open question is zip vs tar. - If zip: use STORE, and it must support ZIP64. A 4K RGBA sequence passes 4 GB quickly (around 900 frames at 5-15 MB each). A writer without ZIP64 will produce a corrupt archive past that size, with no error.
- Content-Length is possible. With STORE, every entry's size is known from
statbefore streaming, so the total length is deterministic. That gives browsers a progress bar, contrary to the "cons" list. CRC-32 can go in the data descriptor. - There is already a zip dependency.
adm-zipis inpackages/cli. It builds archives in memory, though, so it's the wrong tool for frames. Don't reuse it here. audioMixer.tsdoesn't usezlib.crc32/deflateRawas the plan says.zlib.crc32itself exists on Node ≥22.2, which is fine againstengines: >=22.- My preference: a streamed STORE zip with ZIP64, since macOS and Windows open zip natively. A plain streamed
.tar(no gzip, since PNGs are already compressed) is the simpler alternative and matches cloud output. Either is fine; just not a zip without ZIP64. GET /render/:jobId/viewhas nothing to play for a directory. Return a 404 for directory jobs and hide the action in the UI.- If
/projects/:id/renders/file/*should serve individual frames (a first-frame thumbnail, say), add.png → image/pngtoRENDER_MIME. Today an unknown extension falls back tovideo/mp4.
Q4, DELETE while rendering: A, 409
Agreed, but the reason is different from the one in the plan.
- The destination doesn't exist during a render, because staging lives in the hidden sibling. So a DELETE mid-render deletes nothing.
- The render then commits, and the deleted job reappears in the listing. A 409 while
status === "rendering"closes that, and it fixes the same gap the file formats already have. - For the directory delete itself:
- Extend
resolveRenderArtifactswith the bare${jobId}candidate. - Keep the existing
/,\and NUL rejection. lstatthe resolved path and remove it recursively only when it's a real directory.- The traversal test #5553 asks for should include a symlink planted at
renders/<jobId>pointing outside the project.
- Extend
Q5, sidecar schema: A, additive
Agreed. The listing reader already ignores unknown keys. No version field for now.
The two decisions "recorded at the end of the body"
I can't find them. Both the PR body and plans/issue-5553-pr-body.md end at Q5. The only one I can infer is RenderJobState gaining kind?: "file" | "directory", which I agree with (set it in startRender from the format). Please add the second one, or point me to it.
Smaller corrections to the doc
artifactTransaction.ts:2882-2886should berenderOrchestrator.ts:2882-2885;artifactTransaction.tsis 450 lines long.- The current DELETE has no
renderingcheck. The:238-259lines cited as "the existing 404" are the view route.
Not a run: I read the code on main and did not render a png-sequence through Studio.
— Rames
0a5ca03 to
a8c69e7
Compare
miguel-heygen
left a comment
There was a problem hiding this comment.
Thanks for turning the design discussion into an implementation that follows it closely. I checked each point from the earlier review against this head:
- The metadata file is now named by job id in both adapters (
renderMetadataPath). - Cancel removes a directory output recursively without following symlinks, and there is a test for cancel racing completion.
- The listing skips dot-prefixed staging folders and symlinked folders.
- DELETE returns 409 while a render is running, and the symlink cases are covered by tests.
/viewreturns 404 for a directory, and frames are served asimage/png.
I also went through renderZipStream.ts byte by byte against the ZIP spec. The ZIP64 extra fields, data descriptors, end records and the precomputed Content-Length all line up.
A few things before this can go in:
- CI hasn't run on the implementation. Workflows from forks wait for a maintainer, and the branch is well behind
main(it merges cleanly). A rebase and a full run are needed. - Please open a downloaded ZIP in macOS Finder and Windows Explorer. The archive stores entries uncompressed with a trailing data descriptor. The spec allows that, but some readers handle it badly, and opening natively on both systems was the reason for choosing zip.
unzip -talone doesn't show that. - Deleting a file render can now remove a folder with the same name.
resolveRenderArtifactsadds the bare<jobId>directory candidate for every job. Ifrenders/intro.mp4and a CLI PNG sequencerenders/intro/both exist, deleting the MP4 row also deletes the frames folder. I'd only include the directory candidate when the job is a directory render. - Scope. The PR also commits the planning material (
plans/issue-5553-*,plans/feedback/, and a new top-levelresearch/folder) and some unrelated engine test changes (skipping the extractor suites when ffmpeg is missing,-vsyncto-fps_mode, whichmainalready uses). Those would be easier to review as a separate PR, or left out. Whether the planning docs belong in the repo is up to the maintainers.
I read the route, the helpers, both adapters and the Studio panel changes end to end. I only skimmed the tests, and I didn't run the suites or a real Studio render.
Verdict: COMMENT
Reasoning: The implementation follows the design review and I found no defect in what I read, but CI has never run on it, the zip needs a check in the system archive tools, and unrelated files should come out.
|
Still want this. Before merge: rebase on main, reject |
… kind Review feedback on heygen-com#5578: resolveRenderArtifacts always added the bare <jobId> directory candidate, so deleting a file render whose stem matched a directory render (`renders/intro.mp4` beside a CLI `renders/intro/`) recursively deleted the other render's frames. - resolveRenderArtifacts now takes the job's kind: the bare job-id directory is a candidate only for a directory render, and suffixed outputs only for a file render. - The delete route resolves the kind from the job map; an id the server has never seen defaults to a file render, so an unknown id can never trigger a recursive removal. - The symlink tests now swap a posted directory render's directory for the symlink, since an unlisted id no longer resolves a directory candidate, and two same-stem tests cover both directions of the collision. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Review feedback on heygen-com#5578: the delete route must never act on a job id of `.`, `..`, or empty — those name the renders directory itself (or its parent) as the directory candidate — and it must never remove `renders/` wholesale. - The DELETE handler rejects empty, `.`, and `..` up front, next to the existing separator checks. The router normalizes the raw forms away, but the handler does not trust that. - resolveRenderArtifacts refuses to build any candidate whose entry name is empty, `.`, or `..`, so no caller can construct an artifact path that is the directory itself or its parent. - A regression test drives the raw, normalized, and decoded forms and asserts the renders directory and its contents survive them all. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
a8c69e7 to
907b7f3
Compare
jrusso1020
left a comment
There was a problem hiding this comment.
Reviewed at 907b7f3ab0d89379d697cefd5c178ab3392b9f6c.
Directory output now follows the producer’s existing staging model, and the shared cleanup/metadata helpers cover both adapter hosts. The earlier dot-id and same-stem deletion concerns are addressed.
Important — missed in earlier rounds: directory deletion loses its artifact kind after progress expires (packages/studio-server/src/routes/render.ts:463). cleanupFinishedJobs removes completed entries after five minutes, including the only record that this output is a directory. DELETE then defaults to "file", removes file candidates, and returns { deleted: true } while the PNG directory remains. Studio removes the row, which reappears on reload.
I reproduced this through the actual Hono routes: create a completed PNG sequence, advance timers 360,001 ms, confirm progress returns 404, then DELETE returns 200 but existsSync(outputDirectory) is still true. The added TTL regression fails; all 31 existing directory-route, archive and artifact-helper tests pass. An artificial control retaining directory identity across cleanup makes the regression pass.
Please retain the validated artifact kind/path independently of expiring progress state, or rediscover that identity safely before deletion. Keep the existing same-stem file/directory and symlink protections; add this TTL case to renderDirectory.test.ts.
Audited the render route, directory/archive helpers and Studio directory-job/action wiring; inspected both adapter integration changes. The larger adapter and CLI interiors were read at the integration points. This was a real filesystem/Hono probe, not a producer render or native Windows archive check.
Need: yes · Size: ok · Reuse: existing producer directory output and shared artifact helpers · Simpler: keep artifact identity independent of transient progress · Structure: fine
Risk: medium — new directory lifecycle and recursive deletion.
— hf-review
Verdict: REQUEST CHANGES
Reasoning: The directory feature is useful, but an ordinary delayed Delete currently reports success without deleting the render.
Preview approval requiredThis pull request comes from a fork, so Mintlify won't build a preview until someone with write access approves commit |
|
@jrusso1020 Thanks for catching this. Fixed in Added TTL regressions for posted and listed directory and file renders after advancing 360,001 ms. They verify that deleting a directory render preserves a same-stem MP4, deleting a file render preserves a same-stem directory, and symlink protections still hold both before and after expiry. I also fixed the Vite adapter render-test fixtures to expect the existing GIF metadata filename and configured I need to correct my earlier test report, and I’m sorry I said the full suite passed before it was green. With local FFmpeg 9.0.2, the full suite had failures. With FFmpeg 8.0.1, the version used by CI, the full studio-server suite passes: 88/88 files, 1,588 passed, 3 skipped, 0 failed. Focused route/helper tests pass (133/133), Studio render and Vite adapter tests pass (32/32), CLI studioServer tests pass (97 passed, 3 skipped), and typechecks, oxlint, oxfmt, and fallow are clean. Mint docs checks were unavailable locally. Could you approve the fork CI runs and take another look? |
heygen-com#5553) Studio's render route, its two adapter hosts and the render panel now accept `format: "png-sequence"`, which renders to `renders/<jobId>/` rather than a single file. - Route: format validation and a `FORMAT_KIND` map; kind-aware artifact resolution that also considers the bare job-id directory candidate; an `lstat`-guarded recursive removal; a 409 while a render is still active; listing rows that add `kind: "directory"`, `frameCount` and `audioSidecar`. - Helpers: artifact filesystem, directory stats, an on-demand STORE ZIP64 stream, and the content-disposition header the two responses share. - Adapters: the CLI host and the Vite host accept the format, widen cancel cleanup to a directory plus its sidecar, and record the directory facts. - Studio: the format selector gains PNG sequence, and a directory row shows a PNG badge, a frame count and a ZIP download instead of a video preview. Fixes heygen-com#5553 Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
… kind Review feedback on heygen-com#5578: resolveRenderArtifacts always added the bare <jobId> directory candidate, so deleting a file render whose stem matched a directory render (`renders/intro.mp4` beside a CLI `renders/intro/`) recursively deleted the other render's frames. - resolveRenderArtifacts now takes the job's kind: the bare job-id directory is a candidate only for a directory render, and suffixed outputs only for a file render. - The delete route resolves the kind from the job map; an id the server has never seen defaults to a file render, so an unknown id can never trigger a recursive removal. - The symlink tests now swap a posted directory render's directory for the symlink, since an unlisted id no longer resolves a directory candidate, and two same-stem tests cover both directions of the collision. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Review feedback on heygen-com#5578: the delete route must never act on a job id of `.`, `..`, or empty — those name the renders directory itself (or its parent) as the directory candidate — and it must never remove `renders/` wholesale. - The DELETE handler rejects empty, `.`, and `..` up front, next to the existing separator checks. The router normalizes the raw forms away, but the handler does not trust that. - resolveRenderArtifacts refuses to build any candidate whose entry name is empty, `.`, or `..`, so no caller can construct an artifact path that is the directory itself or its parent. - A regression test drives the raw, normalized, and decoded forms and asserts the renders directory and its contents survive them all. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
…y expires
The five-minute progress cleanup dropped the only record that a job's
output was a directory, so a later DELETE fell back to file candidates,
returned { deleted: true } and left the PNG frames on disk.
Artifact kind and validated output path now live in their own store,
populated by a validated render request or a disk listing and removed
only by DELETE. Same-stem file/directory scoping and symlink guards are
unchanged and now also covered after expiry.
Also aligns the Vite adapter GIF fixtures with the job-id metadata path.
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
…d of Artifact kind lived only in process memory, so after a restart (or for a client that deletes before it lists) DELETE defaulted to "file", removed the sidecar, returned deleted: true and left the PNG frames on disk, where the next listing showed the render again. When there is no record, the kind is now read from disk the way the listing decides it: a real directory at renders/<jobId> that holds frames or has a sidecar. A file render of the same stem still wins, a folder that is not render-shaped is left alone, and a symlink at the job id is never followed. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
The Format and Quality rows now list PNG sequence (and the GIF option the table had missed), and the render-queue paragraph says what a finished sequence is, what its row shows, and how it downloads, in place of a note about progress tracking expiring. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
…er the Vite host ZIP entries carried a 1980-01-01 placeholder, so every extracted frame showed that date in Finder and Explorer; they now carry the file's mtime at DOS resolution. A failure while the body streams logs its cause, since the client only sees a short download. Frame names sort by code point, which is frame order for fixed-width names. The Vite adapter gains the png-sequence cases the CLI host already had: the job is marked a directory render, the sidecar sits beside the frames with their count and footprint, and cancel removes the directory and the sidecar. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
23198da to
f8313b8
Compare
|
@jrusso1020 @miguel-heygen this is rebased onto current |
…e package descriptor `scripts/package-subpaths.mjs` regenerates each public package's export map from `package-subpaths.json`, and the check fails when the manifest carries an entry the descriptor does not know. The `./render-directory-stats` export was added to `package.json` by hand before the descriptor existed; this records it there too, so the generated manifest is unchanged and `check:package-subpaths` passes. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
… list PNG sequence hides the Quality field, like MOV. Listed last, it was what the render-selects e2e picked for Format before walking on to Quality, which had then disappeared. It now sits beside MOV, the other fixed-quality format, and GIF stays last as before. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
…nce route The ZIP64 stream, the artifact helpers and the directory branches of the render route add about 2% to the packed tarball, and the previous budget had under 1% of headroom. The new figures are the measured size times the verifier's own 1.05 headroom (173.6 KB packed, 670 KB unpacked with pnpm pack at this head). Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
…share The comment ratchet allows a changed file's comment share only to fall and a new file to open at or under its package's share. The long blocks now state the invariant in one line (kind ownership, renders/ containment, no symlink removals) and the ones that restated the code are gone. No behavior change. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
|
@jrusso1020 @miguel-heygen The four CI gates that failed after the rebase are fixed in
The branch is 24 commits behind |
What
Studio's render route now accepts
format: "png-sequence"and hands the producer a directoryrather than a single file, the way the producer and the CLI have always treated that format. A
finished PNG-sequence render shows up as a directory row in the render panel: a PNG badge and a
frame count instead of a video thumbnail, "Download as ZIP" instead of "Download", and no
"open in a new tab" control, because there is no single playable file. Downloading streams the
directory as an archive, so nothing is written twice.
Why
Before this change every part of the route was single-file-shaped, while the producer's
directory writer was already the source of truth:
mp4,webmandmov, and the output path was built asrenders/<jobId>.mp4with the extension swapped per format.*.mp4 | *.webm | *.mov | *.meta.json, and both adapter hostsderived the meta sidecar by replacing that extension.
readdirSyncto those three extensions, so a directory render hadno row at all.
StudioApiAdapter.startRender's format union inpackages/studio-server/src/types.tswas thesame three-member shape, so an adapter could not be asked for the format in the first place.
Asking for
format: "png-sequence"therefore produced an invisible render, a download endpointthat 404s, and a DELETE that tried to unlink a path that never existed.
Related work
plans/issue-5553-png-sequence-route.mdplans/feedback/2026-10-14-issue-5553-png-sequence-route.md,checked against
mainat9b996059. The comment did not include a Studio PNG-sequence render run.packages/producer/src/services/renderOrchestrator.tsselects thedirectory transaction, and
packages/producer/src/services/render/stages/encodeStage.tswritesframe_NNNNNN.pngplus the optionalaudio.m4a.packages/cli/src/commands/render.ts.resolveWithinProjectandisPrivateProjectFileinpackages/studio-server/src/helpers/safePath.ts.Decisions
renders/<jobId>/. The route and both adapter hosts branch on the artifactkind, not on
format === "png-sequence", so directory handling stays format-agnostic andusable by a future directory-producing format.
kind: "directory",frameCountand
audioSidecar, and keepsizenumeric as a summed directory footprint. Entries come fromdisk, dot-prefixed producer staging names are skipped, and symlinked directories are rejected.
Directory facts are cached in the meta sidecar, with a directory scan as the fallback when the
sidecar is missing or incomplete.
Content-Lengthis computed from the known entries and PNG bytes are stored, not re-deflated.In-memory
adm-zipis not used for render frames./render/:jobId/viewreturns 404 for adirectory and Studio hides the preview control; individual frames are served as
image/png.sibling and only publishes at commit, so the final path does not exist during the render; a
DELETE that reported success there would see the render reappear in history. Cancelled and
failed renders still delete with 200, removing a validated real directory recursively.
frameCount,sizeandaudioSidecarare newoptional keys, and the sidecar path is built from the renders directory and job id instead of
replacing an extension on the output path.
Type decisions carried from the design record:
RenderJobStategains an optionalkind?: "file" | "directory", set at render start; existing file listing rows keep their shape.What landed
packages/studio-server/src/routes/render.ts): format validation and aFORMAT_KINDmap, kind-aware artifact resolution (the bare job-id directory candidate is considered only for
directory renders),
lstat-guarded recursive removal, an empty and dot-only job-id guard ondelete, directory download, listing rows for directories, and a disk-derived artifact kind for a
job id the server has no record of (see "Review feedback addressed").
docs/studio/export.mdx): the Format row lists PNG sequence, the Quality row notes itis hidden for the lossless format, and the render-queue section describes the frames folder, the
PNG badge with frame count, "Download as ZIP" and what Delete removes.
renderArtifactFs.ts(artifact discovery, metadata path, removal),renderDirectoryStats.ts(frame count and summed footprint, exported as therender-directory-statssubpath),renderZipStream.ts(STORE ZIP64 on-demand stream), andcontentDisposition.ts(the header pair the route and download share).packages/cli/src/server/studioServer.tsandpackages/studio/vite.adapter.tsaccept the format, widen cancel cleanup to a directory plus its sidecar, and record the
directory facts in the sidecar. Render telemetry accepts the fifth format (GIF landed in
between and is preserved).
renderSettings.tswidens the persisted format union,RenderQueue.tsxadds thePNG sequence option (fixed-quality, like MOV), and
RenderQueueItem.tsxrenders the directoryrow and the ZIP download.
directory route (
packages/studio-server/src/helpers/*.test.ts,packages/studio-server/src/routes/renderDirectory.test.ts, which also carries the same-stemdelete-scope and dot-id regressions), plus Studio rows (
RenderQueueItem.test.tsx,useRenderQueueDirectory.test.tsx) and the CLI adapter test.Review feedback addressed
mainand fork workflows wait for amaintainer. It is now rebased onto
mainat273672ab1. The upstream route changes that landedin between are kept as-is: the streaming
renderFileResponsewith HEAD support (fix(studio-server): download exports larger than 2 GiB #6602),nextFreeRenderJobId(fix(studio-server): two exports in the same second get separate jobs #7053, now also checked against the artifact map so an id with a recordedartifact is never reused), the shared
forgetJobsFinishedLongerThansweep (fix(studio-server): a render that ran over 5 minutes can still be read after it finishes #7087) and the async cancel (fix(studio-server): the cancel route replies once the adapter's stop has settled #7221).This push carries the suite run from that base.
resolveRenderArtifactsis kind-aware — the bare<jobId>directory candidate is consideredonly for directory renders, and the suffixed file candidates only for file renders. The delete
route reads the kind from the job record, and an unknown id defaults to "file", so an unknown
render can never trigger recursive removal. Two same-stem regression tests cover a file render
next to a directory render, in both directions.
.and..job ids on delete, and never remove the renders folder itself (James):the DELETE handler refuses empty,
.and..ids — raw, router-normalized and percent-decodedforms — and
resolveRenderArtifactsalso refuses any candidate that names"",.or...The regression test drives all three forms.
separately from the expiring job records, so DELETE still targets completed PNG-sequence output
after the finished-job sweep drops the progress entry. Listing hydration restores the identity,
and successful deletion clears it. Regression tests drive the real sweep (
TTL_MS300 s, sweptevery 60 s, so the job is gone by 420 s) and cover posted and listed file/directory renders,
same-stem artifacts, and symlink protections after expiry.
before any listing, left the route with no kind to look up and the "unknown id is a file" default
stranded the directory on disk.
renderKindOnDisknow derives the kind the same way the listingdoes: a file render of the same stem wins, a dot-prefixed id is always a file, and a real
(non-symlink)
renders/<jobId>directory counts as a directory render only when it carries asidecar or at least one frame. Five tests cover the restart case, a frames-only folder, a folder
that is not render-shaped, an unrecorded same-stem pair and an unrecorded symlink at the job id.
Local and central headers now carry each frame's mtime (
dosDateTime, clamped to the DOS epochfor older files), the entry order is a plain code-point sort, and a stream that fails mid-body
logs a warning instead of closing silently. Verified with
unzip -t,unzip -l(real dates) and abyte-identical
dittoextraction.vite.adapter.render.test.tsgains a PNG-sequencesuite (directory kind and sidecar facts, cancel cleanup of the folder and sidecar), and the
optimizeDeps: { noDiscovery: true, include: [] }setting there is now explained in a comment:without it Vite's background dependency scan races the server
close()and logs errors.docs/studio/export.mdxpreviously described only the deletefix. It now documents the PNG sequence export itself (see "What landed").
CI fixes after the rebase
The rebase onto
273672ab1brought in four gates that did not exist when this PR was written.Each is satisfied without weakening the gate:
check:package-subpaths(Preflight, Lint):packages/studio-server/package-subpaths.jsonis now the source of the export map (fix(producer): local renders bring in only media, font, CSS and JS from outside the project #7647). The
./render-directory-statsexport, whichpackages/cli/src/commands/render.tsimports, is registered there withenvironments: ["bun", "node"]; the regeneratedpackage.jsonis unchanged. This also clears theWindows and preview jobs that wait on Preflight, and the packed-consumer Vite build, which treated
the unregistered subpath as browser-safe and tried to bundle
node:fs.Package size budget (Build):
@hyperframes/studio-servergoes from 168,500 / 654,000 to182,300 / 708,700 bytes (packed / unpacked). The ZIP64 stream, the artifact helpers,
renderDirectoryStats,contentDispositionand their declarations add about 2% to the tarball,and the previous budget had under 1% of headroom. The new figures are the measured size at this
head (173,603 / 670,116 with
pnpm pack) times the verifier's own 1.05 headroom;@hyperframes/cliand
@hyperframes/studiostay inside their existing budgets.Comment ratchet (Comments): the comments this PR added are condensed so each changed file stays
at or under its comment share on
mainand the new helpers open under the package's share. Thecontainment and symlink invariants are still stated, one line each. No behavior change.
render-selectse2e (Studio: timeline viewport gate): the test picks each select's last optionand then expects Quality to still be on screen, which PNG sequence hides. PNG sequence now sits
beside MOV (ProRes), the other fixed-quality format, and GIF stays last as on
main. The unit teststhat arrow through the list follow the new order.
Open the archive in the system tools (Miguel): the ZIP was opened and expanded with macOS
Archive Utility and extracted with CLI tools, byte-identical to the source frames in both.
Windows Explorer steps are in Testing below for a reviewer on Windows.
Scope (Miguel, James): the planning notes and the unrelated engine test changes are out of
the diff, and the commits are signed.
Testing
hyperframes preview), rendering atwo-frame PNG sequence: the listing reported
kind: "directory"withframeCount: 2and asummed
size;renders/<jobId>/heldframe_000001.pngandframe_000002.png;/render/:jobId/viewreturned 404;/render/:jobId/downloadstreamedapplication/zipwith acomputed
Content-Lengththatunzip -taccepted as a valid archive; and DELETE while therender was still active returned
409.has the expected entries (
frame_000001.png…frame_000005.pngplusaudio.m4a), passesunzip -t, and expands byte-identically to the source both viaunzipand via macOS ArchiveUtility.
bun run --cwd packages/studio-server test— 109 files, 1,919 passed, 4 skipped, 1 failed.The one failure is
src/helpers/undecodableAudioTrack.test.ts, which this diff does not touch:it fails identically on
mainat273672ab1on this machine, which runs FFmpeg 9.0.2 (the testshells out to ffmpeg, so the result depends on the local build).
bun run --cwd packages/cli test src/server/studioServer.test.ts— 57 passed.bun run --cwd packages/studio test src/components/renders vite.adapter.render.test.ts—10 files, 60 passed, including the new PNG-sequence suite for the Vite host.
packages/core,packages/engineandpackages/producerare untouched by this diff (theirtrees are identical to
main).tsc --noEmitin studio-server, cli and studio,oxlintandoxfmt --checkon every changedfile, and
bunx fallow audit --base origin/main --fail-on-issuesare clean.Testing on Windows
The archive is a STORE ZIP64 stream (
frame_000001.png… plusaudio.m4awhen the compositionhas audio), so verify it with the tools Windows ships:
Start Studio (
npx hyperframes preview) and render a composition with formatPNG sequence — once with audio in the composition, once without.
In the render history row, use Download as ZIP; the file saves as
<jobId>.zip.Double-click the archive: Windows Explorer should open it and list the frames flat (no nested
folder), with
audio.m4apresent only for the audio render.Use Extract All, then confirm the extraction (the archive is STORE, so bytes are identical
to the source frames):
Optionally list without extracting:
tar -tf .\<jobId>.zip(Windows 10+ ships bsdtar, whichreads the ZIP64 central directory the same way
unzip -tdoes on macOS).Before
The render panel on
main: the format selector offers MP4, MOV (ProRes) and WebM only, and everyqueue row resolves to a single file. (Captured before the GIF format landed upstream; the selector
on current
mainalso offers GIF.)After
The same panel with PNG sequence available and selected. The quality selector disappears, since
PNG is lossless and fixed-quality like MOV. (The GIF option also appears on current
main; it isunrelated to this diff. The capture predates the CI fix that moved PNG sequence next to MOV (ProRes);
the shipped order is MP4, MOV (ProRes), PNG sequence, WebM, GIF.)
And a finished PNG-sequence render in the queue: a PNG badge and a frame count instead of a video
thumbnail, with the download control reading "Download as ZIP".
Notes for review
archive is generated in the suite.
that case, which is a follow-up rather than part of this change.
(
studioServer.test.tsandvite.adapter.render.test.ts).renderArtifacts(job id → kind and output path) is never evicted on purpose: it holds one smallentry per render started in this process, and it is what lets DELETE and the listing agree after
the progress record is swept. The comment in
render.tsrecords this.server (see Testing), the archive was additionally opened in macOS Archive Utility and
extracted byte-identically, and the Windows steps above cover the Explorer side.