mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
fix(cli): serve project media with HTTP Range so validate reads WAV duration (#1811)
* fix(cli): serve project media with HTTP Range so validate reads WAV duration
The local static server used by validate/snapshot/layout answered every
asset request with a plain 200 and no Accept-Ranges header. Chromium treats
such resources as non-seekable, and for WAV that makes the media element
report `.duration` as Infinity no matter how long it buffers (readyState
reaches HAVE_ENOUGH_DATA but duration never resolves). The duration audit in
validate then emitted a spurious "Could not read the duration of N media
element(s) within the validate timeout" warning for a perfectly valid local
WAV, and a longer --timeout never helped because the value is never going to
arrive. MP3/MP4 carry duration in their container metadata so they were
unaffected.
Serve files with Range support (206 + Content-Range, plus Accept-Ranges on
the full 200) so the element is seekable. WAV duration now resolves, the
false warning is gone, and the genuine "media shorter than its slot" check
works for WAV for the first time.
* perf(cli): stream Range responses instead of buffering the whole file
serveFileWithRange read the entire asset with readFileSync and then sliced
it, so a 1KB Range of a 50MB MP4 still allocated the full 50MB per request.
Switch to statSync for the total size and createReadStream(filePath, { start,
end }) piped to the response, reading only the requested window. Behavior is
unchanged: 206 + Content-Range + Content-Length for a satisfiable range, 416
for an unsatisfiable one, Accept-Ranges advertised on every response, and a
plain 200 full-body stream when there is no Range header. writeHead is
deferred to the stream's open event so a failed open still answers 500, and
the fd closes on end/error. This benefits MP4 seek too, not just WAV duration.
Extend staticProjectServer.test.ts with an 8MB-file case that pulls a 4-byte
slice from deep inside and asserts the streamed bytes, Content-Range, and
Content-Length are correct.
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { serveStaticProjectHtml, type StaticProjectServer } from "./staticProjectServer.js";
|
||||
|
||||
let server: StaticProjectServer | undefined;
|
||||
let dir: string | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
await server?.close();
|
||||
server = undefined;
|
||||
if (dir) rmSync(dir, { recursive: true, force: true });
|
||||
dir = undefined;
|
||||
});
|
||||
|
||||
async function serveWith(bytes: Buffer): Promise<{ url: string }> {
|
||||
dir = mkdtempSync(join(tmpdir(), "hf-static-"));
|
||||
writeFileSync(join(dir, "tone.wav"), bytes);
|
||||
server = await serveStaticProjectHtml(dir, "<html></html>");
|
||||
return { url: server.url };
|
||||
}
|
||||
|
||||
describe("serveStaticProjectHtml range support", () => {
|
||||
it("answers a Range request with 206 + the requested byte slice", async () => {
|
||||
// Chromium needs byte-range seekability or WAV `.duration` reports Infinity,
|
||||
// which makes `hyperframes validate` falsely warn it cannot read the duration.
|
||||
const body = Buffer.from("0123456789", "utf-8");
|
||||
const { url } = await serveWith(body);
|
||||
|
||||
const res = await fetch(`${url}tone.wav`, { headers: { Range: "bytes=2-5" } });
|
||||
expect(res.status).toBe(206);
|
||||
expect(res.headers.get("accept-ranges")).toBe("bytes");
|
||||
expect(res.headers.get("content-range")).toBe(`bytes 2-5/${body.length}`);
|
||||
expect(await res.text()).toBe("2345");
|
||||
});
|
||||
|
||||
it("advertises Accept-Ranges even on a full 200 response", async () => {
|
||||
const { url } = await serveWith(Buffer.from("abcdef", "utf-8"));
|
||||
const res = await fetch(`${url}tone.wav`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("accept-ranges")).toBe("bytes");
|
||||
expect(await res.text()).toBe("abcdef");
|
||||
});
|
||||
|
||||
it("streams a small slice out of a large file without buffering the whole thing", async () => {
|
||||
// 8MB file, ask for 4 bytes deep inside it. The handler must createReadStream
|
||||
// the [start,end] window only, not readFileSync the whole 8MB and slice.
|
||||
const size = 8 * 1024 * 1024;
|
||||
const big = Buffer.alloc(size, 0x61); // 'a' everywhere...
|
||||
big.write("WXYZ", 5_000_000); // ...except a 4-byte marker
|
||||
const { url } = await serveWith(big);
|
||||
|
||||
const res = await fetch(`${url}tone.wav`, { headers: { Range: "bytes=5000000-5000003" } });
|
||||
expect(res.status).toBe(206);
|
||||
expect(res.headers.get("content-range")).toBe(`bytes 5000000-5000003/${size}`);
|
||||
expect(res.headers.get("content-length")).toBe("4");
|
||||
expect(await res.text()).toBe("WXYZ");
|
||||
});
|
||||
|
||||
it("returns 416 for an unsatisfiable range", async () => {
|
||||
const body = Buffer.from("abc", "utf-8");
|
||||
const { url } = await serveWith(body);
|
||||
const res = await fetch(`${url}tone.wav`, { headers: { Range: "bytes=99-200" } });
|
||||
expect(res.status).toBe(416);
|
||||
expect(res.headers.get("content-range")).toBe(`bytes */${body.length}`);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createServer } from "node:http";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { createServer, type ServerResponse } from "node:http";
|
||||
import { createReadStream, existsSync, statSync } from "node:fs";
|
||||
import { isAbsolute, relative, resolve } from "node:path";
|
||||
import { getMimeType } from "@hyperframes/core/studio-api";
|
||||
|
||||
@@ -9,6 +9,64 @@ export interface StaticProjectServer {
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve a file with HTTP Range support. Chromium needs byte-range seekability
|
||||
* to determine the duration of formats that carry it in a trailing/implicit
|
||||
* position (notably WAV, which otherwise reports `.duration` as `Infinity`
|
||||
* however long it buffers). A plain 200 with no `Accept-Ranges` makes the
|
||||
* media element non-seekable, so `hyperframes validate` would spuriously warn
|
||||
* that a perfectly valid local WAV's duration "could not be read".
|
||||
*/
|
||||
function serveFileWithRange(
|
||||
filePath: string,
|
||||
rangeHeader: string | undefined,
|
||||
res: ServerResponse,
|
||||
) {
|
||||
const size = statSync(filePath).size;
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": getMimeType(filePath),
|
||||
"Accept-Ranges": "bytes",
|
||||
};
|
||||
|
||||
// Resolve the requested byte window. Absent/malformed Range serves the
|
||||
// whole file (200); a valid `bytes=start-end` (including the open-ended
|
||||
// `start-` and suffix `-N` forms) serves a 206 slice.
|
||||
const last = size - 1;
|
||||
let start = 0;
|
||||
let end = last;
|
||||
let status = 200;
|
||||
const match = rangeHeader ? /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim()) : null;
|
||||
if (match) {
|
||||
const hasStart = match[1] !== "";
|
||||
start = hasStart ? Number(match[1]) : Math.max(0, size - Number(match[2]));
|
||||
end = !hasStart ? last : match[2] !== "" ? Math.min(Number(match[2]), last) : last;
|
||||
|
||||
if (start > end || start > last) {
|
||||
res.writeHead(416, { ...headers, "Content-Range": `bytes */${size}` });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
status = 206;
|
||||
headers["Content-Range"] = `bytes ${start}-${end}/${size}`;
|
||||
}
|
||||
headers["Content-Length"] = String(end - start + 1);
|
||||
|
||||
// Stream only the requested window instead of buffering the whole file: a
|
||||
// 1KB Range of a 50MB asset must not allocate 50MB. createReadStream reads
|
||||
// just `[start, end]` and closes its own fd on end/error. writeHead is
|
||||
// deferred to `open` so a failed open can still answer 500.
|
||||
const stream = createReadStream(filePath, { start, end });
|
||||
stream.on("open", () => {
|
||||
res.writeHead(status, headers);
|
||||
stream.pipe(res);
|
||||
});
|
||||
stream.on("error", () => {
|
||||
if (!res.headersSent) res.writeHead(500);
|
||||
res.end();
|
||||
stream.destroy();
|
||||
});
|
||||
}
|
||||
|
||||
export async function serveStaticProjectHtml(
|
||||
projectDir: string,
|
||||
html: string,
|
||||
@@ -31,8 +89,7 @@ export async function serveStaticProjectHtml(
|
||||
return;
|
||||
}
|
||||
if (existsSync(filePath)) {
|
||||
res.writeHead(200, { "Content-Type": getMimeType(filePath) });
|
||||
res.end(readFileSync(filePath));
|
||||
serveFileWithRange(filePath, req.headers.range, res);
|
||||
return;
|
||||
}
|
||||
res.writeHead(404);
|
||||
|
||||
Reference in New Issue
Block a user