Skip to content

feat(studio-server): support png-sequence from the Studio render route (#5553) - #5578

Open
ym256-stanford wants to merge 12 commits into
heygen-com:mainfrom
ym256-stanford:design/studio-server-png-sequence
Open

ym256-stanford wants to merge 12 commits into
heygen-com:mainfrom
ym256-stanford:design/studio-server-png-sequence

Conversation

@ym256-stanford

@ym256-stanford ym256-stanford commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Status: implementation, two rounds of review feedback addressed, CI gates green after the rebase, ready for re-review. This
carries out the design recorded in plans/issue-5553-png-sequence-route.md, with the five
decisions the maintainer called in plans/feedback/2026-10-14-issue-5553-png-sequence-route.md.
The branch is rebased onto current main at 273672ab1, which brings in the upstream render
route changes that landed in between (streaming downloads with HEAD support, nextFreeRenderJobId,
the shared finished-job sweep and the async cancel); none of them is reverted here. The seven
commits are the feature plus one commit per review ask.

What

Studio's render route now accepts format: "png-sequence" and hands the producer a directory
rather 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:

  • Request validation accepted only mp4, webm and mov, and the output path was built as
    renders/<jobId>.mp4 with the extension swapped per format.
  • DELETE's artifact whitelist was *.mp4 | *.webm | *.mov | *.meta.json, and both adapter hosts
    derived the meta sidecar by replacing that extension.
  • The listing endpoint filtered readdirSync to those three extensions, so a directory render had
    no row at all.
  • StudioApiAdapter.startRender's format union in packages/studio-server/src/types.ts was the
    same 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 endpoint
that 404s, and a DELETE that tried to unlink a path that never existed.

Related work

  • Fixes Studio render route: support PNG-sequence output #5553
  • Design record: plans/issue-5553-png-sequence-route.md
  • Maintainer feedback this resolves: plans/feedback/2026-10-14-issue-5553-png-sequence-route.md,
    checked against main at 9b996059. The comment did not include a Studio PNG-sequence render run.
  • Producer directory publish: packages/producer/src/services/renderOrchestrator.ts selects the
    directory transaction, and packages/producer/src/services/render/stages/encodeStage.ts writes
    frame_NNNNNN.png plus the optional audio.m4a.
  • CLI directory output is the precedent for the listing and delivery shape:
    packages/cli/src/commands/render.ts.
  • Path safety reuses the existing helpers: resolveWithinProject and isPrivateProjectFile in
    packages/studio-server/src/helpers/safePath.ts.

Decisions

  1. Storage — renders/<jobId>/. The route and both adapter hosts branch on the artifact
    kind, not on format === "png-sequence", so directory handling stays format-agnostic and
    usable by a future directory-producing format.
  2. Listing — file rows unchanged. Directory rows alone add kind: "directory", frameCount
    and audioSidecar, and keep size numeric as a summed directory footprint. Entries come from
    disk, 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.
  3. Download — STORE ZIP64 streamed on demand. No archive is written at render completion;
    Content-Length is computed from the known entries and PNG bytes are stored, not re-deflated.
    In-memory adm-zip is not used for render frames. /render/:jobId/view returns 404 for a
    directory and Studio hides the preview control; individual frames are served as image/png.
  4. DELETE — 409 while the render is active. The producer stages directory output in a hidden
    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.
  5. Sidecar — additive, no version field. frameCount, size and audioSidecar are new
    optional 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: RenderJobState gains an optional
kind?: "file" | "directory", set at render start; existing file listing rows keep their shape.

What landed

  • Route (packages/studio-server/src/routes/render.ts): format validation and a FORMAT_KIND
    map, 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 on
    delete, 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 (docs/studio/export.mdx): the Format row lists PNG sequence, the Quality row notes it
    is 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.
  • Helpers: renderArtifactFs.ts (artifact discovery, metadata path, removal),
    renderDirectoryStats.ts (frame count and summed footprint, exported as the
    render-directory-stats subpath), renderZipStream.ts (STORE ZIP64 on-demand stream), and
    contentDisposition.ts (the header pair the route and download share).
  • Adapters: packages/cli/src/server/studioServer.ts and packages/studio/vite.adapter.ts
    accept 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).
  • Studio UI: renderSettings.ts widens the persisted format union, RenderQueue.tsx adds the
    PNG sequence option (fixed-quality, like MOV), and RenderQueueItem.tsx renders the directory
    row and the ZIP download.
  • Tests: new suites for the ZIP stream, directory stats, artifact filesystem helpers and the
    directory route (packages/studio-server/src/helpers/*.test.ts,
    packages/studio-server/src/routes/renderDirectory.test.ts, which also carries the same-stem
    delete-scope and dot-id regressions), plus Studio rows (RenderQueueItem.test.tsx,
    useRenderQueueDirectory.test.tsx) and the CLI adapter test.

Review feedback addressed

  • Rebase and CI (James, Miguel): the branch was behind main and fork workflows wait for a
    maintainer. It is now rebased onto main at 273672ab1. The upstream route changes that landed
    in between are kept as-is: the streaming renderFileResponse with 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 recorded
    artifact is never reused), the shared forgetJobsFinishedLongerThan sweep (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.
  • Deleting a file render could remove a same-named directory (Miguel):
    resolveRenderArtifacts is kind-aware — the bare <jobId> directory candidate is considered
    only 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.
  • Reject . 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-decoded
    forms — and resolveRenderArtifacts also refuses any candidate that names "", . or ...
    The regression test drives all three forms.
  • DELETE after progress expires (jrusso1020): artifact kind and validated output path now live
    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_MS 300 s, swept
    every 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.
  • DELETE with no in-memory record at all (second-round review): a server restart, or a DELETE
    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. renderKindOnDisk now derives the kind the same way the listing
    does: 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 a
    sidecar 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.
  • Archive entry timestamps (second-round review): ZIP entries used to be stamped 1980-01-01.
    Local and central headers now carry each frame's mtime (dosDateTime, clamped to the DOS epoch
    for 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 a
    byte-identical ditto extraction.
  • Vite host coverage (second-round review): vite.adapter.render.test.ts gains a PNG-sequence
    suite (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 (second-round review): docs/studio/export.mdx previously described only the delete
    fix. It now documents the PNG sequence export itself (see "What landed").

CI fixes after the rebase

The rebase onto 273672ab1 brought 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.json
    is 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-stats export, which
    packages/cli/src/commands/render.ts imports, is registered there with
    environments: ["bun", "node"]; the regenerated package.json is unchanged. This also clears the
    Windows 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-server goes from 168,500 / 654,000 to
    182,300 / 708,700 bytes (packed / unpacked). The ZIP64 stream, the artifact helpers,
    renderDirectoryStats, contentDisposition and 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/cli
    and @hyperframes/studio stay 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 main and the new helpers open under the package's share. The
    containment and symlink invariants are still stated, one line each. No behavior change.

  • render-selects e2e (Studio: timeline viewport gate): the test picks each select's last option
    and 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 tests
    that 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

  • Manual end-to-end run against a real Studio server (hyperframes preview), rendering a
    two-frame PNG sequence: the listing reported kind: "directory" with frameCount: 2 and a
    summed size; renders/<jobId>/ held frame_000001.png and frame_000002.png;
    /render/:jobId/view returned 404; /render/:jobId/download streamed application/zip with a
    computed Content-Length that unzip -t accepted as a valid archive; and DELETE while the
    render was still active returned 409.
  • Archive verification: a 5-frame sequence with audio, downloaded through the real route,
    has the expected entries (frame_000001.png … frame_000005.png plus audio.m4a), passes
    unzip -t, and expands byte-identically to the source both via unzip and via macOS Archive
    Utility.
  • 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 main at 273672ab1 on this machine, which runs FFmpeg 9.0.2 (the test
    shells 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/engine and packages/producer are untouched by this diff (their
    trees are identical to main).
  • tsc --noEmit in studio-server, cli and studio, oxlint and oxfmt --check on every changed
    file, and bunx fallow audit --base origin/main --fail-on-issues are clean.

Testing on Windows

The archive is a STORE ZIP64 stream (frame_000001.png … plus audio.m4a when the composition
has audio), so verify it with the tools Windows ships:

  1. Start Studio (npx hyperframes preview) and render a composition with format
    PNG sequence — once with audio in the composition, once without.

  2. In the render history row, use Download as ZIP; the file saves as <jobId>.zip.

  3. Double-click the archive: Windows Explorer should open it and list the frames flat (no nested
    folder), with audio.m4a present only for the audio render.

  4. Use Extract All, then confirm the extraction (the archive is STORE, so bytes are identical
    to the source frames):

    Expand-Archive .\<jobId>.zip -DestinationPath .\out
    (Get-ChildItem .\out).Count            # frame count, +1 with audio.m4a
    Get-FileHash .\out\frame_000001.png    # matches the source frame
  5. Optionally list without extracting: tar -tf .\<jobId>.zip (Windows 10+ ships bsdtar, which
    reads the ZIP64 central directory the same way unzip -t does on macOS).

Before

The render panel on main: the format selector offers MP4, MOV (ProRes) and WebM only, and every
queue row resolves to a single file. (Captured before the GIF format landed upstream; the selector
on current main also offers GIF.)

Before: the render panel's format selector on main, offering only MP4, MOV (ProRes) and WebM

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 is
unrelated 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.)

After: the format selector with PNG sequence added and selected

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".

After: the render history row for a finished PNG-sequence render, showing a PNG badge and 2 frames

Notes for review

  • The ZIP64 path is covered by arithmetic tests for sizes and offsets past 4 GiB; no real >4 GiB
    archive is generated in the suite.
  • Deleting a render that is still in flight returns 409 and logs nothing; the UI needs a retry for
    that case, which is a follow-up rather than part of this change.
  • The cancel cleanup is shared by both adapter hosts and both now have adapter-level cancel tests
    (studioServer.test.ts and vite.adapter.render.test.ts).
  • renderArtifacts (job id → kind and output path) is never evicted on purpose: it holds one small
    entry 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.ts records this.
  • The browser round trip has no automated suite. It was verified by hand against a real Studio
    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.

@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.

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

  1. 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,397 and cli/src/server/studioServer.ts:602,657,684. A png-sequence outputPath has no extension, so the replace does nothing and metaPath === outputPath.
    • On success, writeFileSync targets the frames directory and throws EISDIR after 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.
  2. Cancel cleanup does not "already handle" directories. removeCancelledOutput calls unlinkSync on outputPath, and the error is swallowed. unlinkSync on 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 uses rmSync(path, { recursive: true, force: true }).
    • Add a test that cancels a directory render after commit.

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". hls is also a directory output (renderOrchestrator.ts:2882-2885 passes isPngSequence || 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 frameCount and 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 new sizeBytes, so current clients keep working. Add kind: "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, lstat says it's a real directory (not a symlink), and it resolves through resolveWithinProject.
  • If the sidecar is missing or has no frameCount (for example a directory copied in from the CLI), fall back to one readdirSync count plus a size sum. The CLI's readOutputFootprint (cli/src/commands/render.ts:1913-1930) is the helper to share, not the block at 1892-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 the tar package). 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 stat before 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-zip is in packages/cli. It builds archives in memory, though, so it's the wrong tool for frames. Don't reuse it here.
  • audioMixer.ts doesn't use zlib.crc32/deflateRaw as the plan says. zlib.crc32 itself exists on Node ≥22.2, which is fine against engines: >=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/view has 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/png to RENDER_MIME. Today an unknown extension falls back to video/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 resolveRenderArtifacts with the bare ${jobId} candidate.
    • Keep the existing /, \ and NUL rejection.
    • lstat the 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.

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-2886 should be renderOrchestrator.ts:2882-2885; artifactTransaction.ts is 450 lines long.
  • The current DELETE has no rendering check. The :238-259 lines 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

@ym256-stanford ym256-stanford changed the title feat(studio-server): add design-direction PR body for png-sequence render route (#5553) feat(studio-server): support png-sequence from the Studio render route (#5553) Oct 18, 2026
@ym256-stanford
ym256-stanford force-pushed the design/studio-server-png-sequence branch 2 times, most recently from 0a5ca03 to a8c69e7 Compare October 18, 2026 04:34

@miguel-heygen miguel-heygen 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.

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.
  • /view returns 404 for a directory, and frames are served as image/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:

  1. 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.
  2. 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 -t alone doesn't show that.
  3. Deleting a file render can now remove a folder with the same name. resolveRenderArtifacts adds the bare <jobId> directory candidate for every job. If renders/intro.mp4 and a CLI PNG sequence renders/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.
  4. Scope. The PR also commits the planning material (plans/issue-5553-*, plans/feedback/, and a new top-level research/ folder) and some unrelated engine test changes (skipping the extractor suites when ffmpeg is missing, -vsync to -fps_mode, which main already 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.

@jrusso1020

Copy link
Copy Markdown
Collaborator

Still want this. Before merge: rebase on main, reject . and .. as job ids in DELETE /render/:jobId (and refuse to delete the renders folder itself), drop the plans/ and research/ notes, and sign the commits.

ym256-stanford added a commit to ym256-stanford/hyperframes that referenced this pull request Oct 27, 2026
… 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>
ym256-stanford added a commit to ym256-stanford/hyperframes that referenced this pull request Oct 27, 2026
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>
@ym256-stanford
ym256-stanford force-pushed the design/studio-server-png-sequence branch from a8c69e7 to 907b7f3 Compare October 27, 2026 18:27

@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.

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.

@mintlify

mintlify Bot commented Nov 8, 2026 •

Copy link
Copy Markdown

Preview approval required

This pull request comes from a fork, so Mintlify won't build a preview until someone with write access approves commit 11c2f62.

Approve preview

@ym256-stanford

Copy link
Copy Markdown
Contributor Author

@jrusso1020 Thanks for catching this. Fixed in 23198daa6: the route now retains the validated artifact kind and output path separately from the five-minute progress state, so DELETE can still find a completed PNG-sequence directory after progress expires. Listing hydration restores artifact identity, and DELETE clears it after handling the artifact.

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 optimizeDeps without dependency discovery.

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?

ym256-stanford and others added 7 commits November 13, 2026 14:18
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>
@ym256-stanford
ym256-stanford force-pushed the design/studio-server-png-sequence branch from 23198da to f8313b8 Compare November 14, 2026 01:36
@ym256-stanford

Copy link
Copy Markdown
Contributor Author

@jrusso1020 @miguel-heygen this is rebased onto current main (273672ab1, now mergeable) and ready for another look. Beyond the earlier asks, this round adds: DELETE derives a directory render's kind from disk when the server has no record of it (restart / delete-before-list), the PNG sequence export is documented in docs/studio/export.mdx, ZIP entries carry the frames' real mtimes, and the Vite host has PNG-sequence tests. Details are in the updated description under "Review feedback addressed".

ym256-stanford and others added 5 commits November 13, 2026 19:49
…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>
@ym256-stanford

Copy link
Copy Markdown
Contributor Author

@jrusso1020 @miguel-heygen The four CI gates that failed after the rebase are fixed in 03dcfd680..14210143b and the run on 14210143b is green (71 checks pass, 0 fail).

  • check:package-subpaths: ./render-directory-stats is registered in package-subpaths.json (Preflight, Lint, and the packed-consumer Vite build).
  • Package size budget: @hyperframes/studio-server raised to the measured size × 1.05; reason and numbers are in the PR description under "CI fixes after the rebase".
  • Comment ratchet: comments trimmed, no behavior change.
  • render-selects e2e: PNG sequence now sits next to MOV (ProRes); GIF stays last.

The branch is 24 commits behind main but merges cleanly (only overlap is the 0.9.43 version bump). Happy to rebase if you'd like it current before re-review.

This branch has not been deployed

No deployments
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.

Studio render route: support PNG-sequence output

3 participants