Repository navigation
Description
Describe the bug
A finished MOV export larger than 2 GiB appears as complete in Studio's Renders panel, but opening or downloading it returns HTTP 500. The three render file routes call readFileSync() before constructing the response, and Node refuses a file this large with ERR_FS_FILE_TOO_LARGE.
This affects high-quality masters such as a few minutes of 1080p ProRes. The file itself is valid: FFprobe identifies the generated fixture as ProRes, and FFmpeg decodes its first and last frames.
Minimal reproduction
Run from a repository checkout after bun install --frozen-lockfile and bun run build. Everything below uses generated test footage. It needs Node and FFmpeg, with enough disk space for a 2.3 GB file.
mkdir -p /tmp/hf-large-export/renders
ffmpeg -hide_banner -loglevel error \
-f lavfi -i testsrc2=size=1920x1080:rate=30:duration=1 -an \
-c:v prores_ks -profile:v 3 -pix_fmt yuv422p10le -threads 4 \
/tmp/hf-large-export/seed.mov
ffmpeg -hide_banner -loglevel error -stream_loop -1 \
-i /tmp/hf-large-export/seed.mov -map 0:v -c copy -fs 2300000000 \
/tmp/hf-large-export/renders/large.mov
ffprobe -v error -show_entries format=size,duration:stream=codec_name,width,height \
-of json /tmp/hf-large-export/renders/large.movSave the following as http-proof.mjs. It starts the real render routes on an ephemeral loopback port, discovers the existing export from disk, then hashes each complete HTTP response against the file. The adapter supplies only the project and render directory; it does not replace file serving.
Complete HTTP reproduction
import { createHash } from 'node:crypto';
import { createReadStream } from 'node:fs';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
const root = process.cwd();
const { Hono } = await import(pathToFileURL(join(root, 'packages/studio-server/node_modules/hono/dist/index.js')));
const { serve } = await import(pathToFileURL(join(root, 'packages/cli/node_modules/@hono/node-server/dist/index.mjs')));
const { registerRenderRoutes } = await import(pathToFileURL(join(root, 'packages/studio-server/src/routes/render.ts')));
const project = process.argv[2];
const app = new Hono();
const errors = [];
app.onError((error, c) => { errors.push({ code: error.code, message: error.message }); return c.text(error.code ?? error.message, 500); });
registerRenderRoutes(app, {
resolveProject: id => ({ id, dir: project }),
listProjects: () => [{ id: 'demo', dir: project }],
bundle: async () => null,
lint: async () => ({ findings: [] }),
runtimeUrl: '/api/runtime.js',
rendersDir: () => join(project, 'renders'),
startRender: () => { throw new Error('Only existing exports are used'); },
});
const server = serve({ fetch: app.fetch, hostname: '127.0.0.1', port: 0 });
if (!server.listening) await new Promise(resolve => server.once('listening', resolve));
const base = `http://127.0.0.1:${server.address().port}`;
async function digest(readable) {
const hash = createHash('sha256');
let bytes = 0;
for await (const chunk of readable) { bytes += chunk.length; hash.update(chunk); }
return { bytes, sha256: hash.digest('hex') };
}
try {
const disk = await digest(createReadStream(join(project, 'renders/large.mov')));
console.log(JSON.stringify({ disk }));
const listing = await (await fetch(`${base}/projects/demo/renders`)).json();
console.log(JSON.stringify({ listing }));
for (const route of ['/projects/demo/renders/file/large.mov', '/render/large/view', '/render/large/download']) {
await (await fetch(`${base}/projects/demo/renders`)).json();
const response = await fetch(base + route);
const body = await digest(response.body);
console.log(JSON.stringify({ route, status: response.status, length: response.headers.get('content-length'), type: response.headers.get('content-type'), disposition: response.headers.get('content-disposition'), body, matchesDisk: body.sha256 === disk.sha256 }));
}
console.log(JSON.stringify({ errors }));
} finally {
server.closeAllConnections();
await new Promise(resolve => server.close(resolve));
}Steps to reproduce
node --import tsx http-proof.mjs /tmp/hf-large-exportStudio's Renders panel uses /api/projects/:id/renders/file/:filename for its thumbnail, open and download actions. The reproduction mounts the routes without the /api prefix and also exercises both job-specific delivery routes after discovering the saved export.
Expected behavior
The completed export opens/downloads successfully. Its HTTP body matches the file on disk.
Actual behavior
On canonical main 3b2058a2b (the render route is unchanged on refreshed main 439d68567):
| Request | Actual status | Error |
|---|---|---|
/projects/demo/renders/file/large.mov |
500 | ERR_FS_FILE_TOO_LARGE |
/render/large/view |
500 | ERR_FS_FILE_TOO_LARGE |
/render/large/download |
500 | ERR_FS_FILE_TOO_LARGE |
All three log File size (2300130263) is greater than 2 GiB. The listing reports the same 2,300,130,263-byte file as complete. The synthetic fixture is 239.466667 seconds, 1920×1080 ProRes. Its SHA-256 in this run is a54520dfaaa0daa1fdb5f4c75821f8d927e6351ee5f414f3c1dd3e7d256667fa.
Environment
Linux x64; Node 24.22.1; Bun 1.4.4; HyperFrames main 0.8.209. This is an HTTP/filesystem reproduction and does not launch Chrome or render a composition.
Related work
#3745 streams composition preview assets, while this report concerns finished render files. #6033 concerns open-ended media ranges. #5578 adds directory/PNG-sequence exports and touches the same route, but its single-file response still buffers the complete export; this report does not propose changes to that directory workflow.
Activity