feat(preview): an unplayable video shows its own frame while its copy is made (#6589)

* feat(preview): an unplayable video shows its own frame while its copy is made

* fix(preview): drop the comment on the copy's light flag

* fix(preview): shorten the still helper's doc comment

* test(preview): each proxy-wait test starts with no copy report left over

* fix(preview): cached still frames are swept with the proxy copies
This commit is contained in:
Miguel Ángel
2026-11-03 00:23:50 +00:00
committed by GitHub
parent b22af8f25c
commit eb3562966f
15 changed files with 341 additions and 26 deletions
+1
View File
@@ -291,6 +291,7 @@ export {
PREVIEW_PROXY_PRIORITY_HEADER,
PREVIEW_PROXY_PROGRESS_HEADER,
PREVIEW_PROXY_RETRY_PARAM,
PREVIEW_PROXY_STILL_PARAM,
PREVIEW_QUALITY_LEVELS,
formatPreviewProxyBox,
isHeavyForPreview,
+2
View File
@@ -4,6 +4,8 @@ export const PREVIEW_PROXY_BOX_PARAM = "hf-proxy-box";
export const PREVIEW_PROXY_LIGHT_PARAM = "hf-proxy-light";
export const PREVIEW_PROXY_RETRY_PARAM = "hf-proxy-retry";
export const PREVIEW_PROXY_STILL_PARAM = "hf-proxy-still";
export const PREVIEW_PROXY_PROGRESS_HEADER = "X-HF-Proxy-Progress";
export const PREVIEW_PROXY_PRIORITY_HEADER = "X-HF-Proxy-Priority";
+5
View File
@@ -258,6 +258,11 @@ export function isActiveAtPlayhead(el: HTMLMediaElement): boolean {
return lastRelativeTime.has(el);
}
/** The source time the clip shows at the playhead, while it is inside its window. */
export function sourceTimeAtPlayhead(el: HTMLMediaElement): number | undefined {
return lastRelativeTime.get(el);
}
/** Test-only seam: whether any per-source sync state is still tracked for `el`. */
export function hasMediaSyncStateForTest(el: HTMLMediaElement): boolean {
return (
+44 -3
View File
@@ -25,6 +25,7 @@ vi.mock("./bridge", () => ({
import { postRuntimeMessage } from "./bridge";
import { resetSeekDispatchState, waitForSeekCompletion } from "./adapters/seek-dispatch";
import { resetCopyReporting } from "./previewCopies";
const postRuntimeMessageMock = vi.mocked(postRuntimeMessage);
@@ -100,6 +101,7 @@ beforeEach(() => {
afterEach(() => {
resetSeekDispatchState();
resetCopyReporting();
vi.useRealTimers();
vi.unstubAllGlobals();
vi.clearAllMocks();
@@ -651,6 +653,39 @@ describe("waiting for the proxy copy", () => {
);
});
it("stands in the video's own frame as its poster while the copy is made, then drops it", async () => {
vi.useFakeTimers();
stubProxyRoute(202, 202, 206);
const active = createVideo("/active.mp4");
const idle = createVideo("/idle.mp4");
const clip = (el: HTMLVideoElement) => ({
el,
start: 0,
mediaStart: 4,
duration: 10,
end: 10,
volume: null,
playbackRate: 1,
loop: false,
sourceDuration: null,
});
syncRuntimeMedia({ clips: [clip(active)], timeSeconds: 1, playing: false, playbackRate: 1 });
swapToProxy(active, HEVC_ENTRY, "proactive");
swapToProxy(idle, HEVC_ENTRY, "proactive");
await vi.advanceTimersByTimeAsync(0);
const poster = new URL(active.getAttribute("poster")!, document.baseURI);
expect(poster.pathname).toBe("/active.mp4");
expect(poster.searchParams.get("hf-proxy")).toBe("h264");
expect(poster.searchParams.get("hf-proxy-still")).toBe("5.0");
expect(idle.hasAttribute("poster")).toBe(false);
await vi.advanceTimersByTimeAsync(10_000);
active.dispatchEvent(new Event("loadeddata"));
await vi.advanceTimersByTimeAsync(0);
expect(active.hasAttribute("poster")).toBe(false);
});
it("ignores the original's own metadata and error while the copy is being made", async () => {
vi.useFakeTimers();
window.__HF_MEDIA_CODEC_MAP__ = {};
@@ -663,7 +698,9 @@ describe("waiting for the proxy copy", () => {
handleMetadataForProxy(el);
handleErrorForProxy(el);
await vi.advanceTimersByTimeAsync(0);
expect(postRuntimeMessageMock).not.toHaveBeenCalled();
expect(postRuntimeMessageMock).not.toHaveBeenCalledWith(
expect.objectContaining({ type: "diagnostic" }),
);
expect(route).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(3_000);
@@ -683,7 +720,9 @@ describe("waiting for the proxy copy", () => {
expect(isProxied(el)).toBe(false);
expect(route).toHaveBeenCalledTimes(4);
expect(postRuntimeMessageMock).toHaveBeenCalledTimes(1);
expect(
postRuntimeMessageMock.mock.calls.filter(([message]) => message.type === "diagnostic"),
).toHaveLength(1);
expect(postRuntimeMessageMock).toHaveBeenCalledWith(
expect.objectContaining({
code: "runtime_media_proxy_unavailable",
@@ -761,7 +800,9 @@ describe("waiting for the proxy copy", () => {
expect(route).toHaveBeenCalledTimes(1);
expect(el.load).not.toHaveBeenCalled();
expect(postRuntimeMessageMock).not.toHaveBeenCalled();
expect(postRuntimeMessageMock).not.toHaveBeenCalledWith(
expect.objectContaining({ type: "diagnostic" }),
);
});
it("keeps one wait for a new file even after the dropped wait for the old one wakes", async () => {
+40 -9
View File
@@ -1,6 +1,11 @@
import { postRuntimeMessage } from "./bridge";
import { swallow } from "./diagnostics";
import { evictMediaSyncState, HOLD_CAP_MS, isActiveAtPlayhead } from "./media";
import {
evictMediaSyncState,
HOLD_CAP_MS,
isActiveAtPlayhead,
sourceTimeAtPlayhead,
} from "./media";
import { findInjectedRenderFrame } from "./renderFrameSibling";
import type { RuntimeJson } from "./types";
import { isVideoElement } from "./domRealm";
@@ -17,6 +22,7 @@ import {
PREVIEW_PROXY_BOX_PARAM,
PREVIEW_PROXY_LIGHT_PARAM,
PREVIEW_PROXY_RETRY_PARAM,
PREVIEW_PROXY_STILL_PARAM,
quantizePreviewProxyBox,
scalePreviewProxyBox,
type PreviewProxyBox,
@@ -193,14 +199,14 @@ function requestBox(el: HTMLMediaElement, copy: BoxedCopy, box: PreviewProxyBox
pendingUpgrades.set(el, href);
const wanted = () =>
pendingUpgrades.get(el) === href && el.isConnected && el.getAttribute("src") === copy.href;
noteCopy(el, "making");
noteCopy(el, "making", null, copy.light);
void waitForServedProxy(
href,
wanted,
(fraction) => wanted() && noteCopy(el, "making", fraction),
(fraction) => wanted() && noteCopy(el, "making", fraction, copy.light),
() => isActiveAtPlayhead(el),
).then((served) => {
if (wanted()) noteCopy(el, served ? "ready" : "failed");
if (wanted()) noteCopy(el, served ? "ready" : "failed", null, copy.light);
if (served && wanted()) loadProxy(el, href, copy.originalSrc, copy.variant, box, copy.light);
if (pendingUpgrades.get(el) === href) pendingUpgrades.delete(el);
});
@@ -390,6 +396,27 @@ function lookupCodecMapEntry<T>(pathname: string, map: Record<string, T>): T | n
);
}
const stills = new WeakSet<HTMLMediaElement>();
/** While a file the browser cannot play is being copied, its own frame at the playhead stands in as the poster. */
function showStill(
el: HTMLMediaElement,
originalSrc: string,
variant: string,
box: PreviewProxyBox | null,
): void {
const at = sourceTimeAtPlayhead(el);
if (at === undefined || stills.has(el) || el.hasAttribute("poster")) return;
const url = new URL(appendProxyParam(originalSrc, variant, box), document.baseURI);
url.searchParams.set(PREVIEW_PROXY_STILL_PARAM, at.toFixed(1));
stills.add(el);
el.setAttribute("poster", url.href);
}
function dropStill(el: HTMLMediaElement): void {
if (stills.delete(el)) el.removeAttribute("poster");
}
function appendProxyParam(
src: string,
variant: string,
@@ -471,14 +498,18 @@ async function makeCopy(el: HTMLMediaElement, job: CopyJob, scale: number | null
lightFacts !== undefined,
retryNext.delete(el),
);
noteCopy(el, "making");
const light = lightFacts !== undefined;
noteCopy(el, "making", null, light);
const served = await waitForServedProxy(
proxiedSrc,
live,
(fraction) => noteCopy(el, "making", fraction),
(fraction) => noteCopy(el, "making", fraction, light),
() => isActiveAtPlayhead(el),
false,
light ? undefined : () => showStill(el, originalSrc, variant, box),
);
if (!live()) {
dropStill(el);
if (proxyRequested.get(el) === originalAttr) proxyRequested.delete(el);
if (lightFacts && el.isConnected && el.getAttribute("src") === originalAttr) {
applyPreviewQuality(el);
@@ -486,11 +517,11 @@ async function makeCopy(el: HTMLMediaElement, job: CopyJob, scale: number | null
return;
}
if (!served) {
noteCopy(el, "failed");
noteCopy(el, "failed", null, light);
emitUnavailableDiagnostic(el, "proxy_playback_failed", originalSrc);
return;
}
noteCopy(el, "ready");
noteCopy(el, "ready", null, light);
swappedElements.set(el, originalAttr);
if (lightFacts) lightElements.add(el);
loadProxy(el, proxiedSrc, originalSrc, variant, box, lightFacts !== undefined);
@@ -499,7 +530,7 @@ async function makeCopy(el: HTMLMediaElement, job: CopyJob, scale: number | null
return new Promise((landed) => {
el.addEventListener("loadeddata", landed, { once: true });
el.addEventListener("error", landed, { once: true });
});
}).then(() => dropStill(el));
}
/**
@@ -0,0 +1,47 @@
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("./bridge", () => ({ postRuntimeMessage: vi.fn() }));
import { postRuntimeMessage } from "./bridge";
import { copiesReady, noteCopy, setCopyReportingLevel } from "./previewCopies";
const posted = vi.mocked(postRuntimeMessage);
const video = () => document.body.appendChild(document.createElement("video"));
afterEach(() => {
setCopyReportingLevel(1);
document.body.innerHTML = "";
posted.mockClear();
});
describe("copy reporting", () => {
it("counts the copy a file cannot play without at Full quality, and ignores a lighter one", () => {
setCopyReportingLevel(1);
posted.mockClear();
noteCopy(video(), "making", 0.5, true);
expect(posted).not.toHaveBeenCalled();
noteCopy(video(), "making", 0.5, false);
expect(posted).toHaveBeenCalledWith(
expect.objectContaining({
type: "preview-copies",
copies: expect.objectContaining({ total: 1, ready: 0, progress: 0.5 }),
}),
);
});
it("keeps the unplayable file's copy when the level changes, and leaves lighter readiness alone", () => {
setCopyReportingLevel(1);
noteCopy(video(), "making", null, false);
expect(copiesReady()).toBe(true);
setCopyReportingLevel(0.5);
noteCopy(video(), "making", null, true);
expect(copiesReady()).toBe(false);
setCopyReportingLevel(1);
expect(copiesReady()).toBe(true);
expect(posted).toHaveBeenLastCalledWith(
expect.objectContaining({ copies: expect.objectContaining({ total: 1 }) }),
);
});
});
+21 -7
View File
@@ -1,6 +1,10 @@
import { postRuntimeMessage } from "./bridge";
type CopyState = { state: "making" | "ready" | "failed"; progress: number | null };
type CopyState = {
state: "making" | "ready" | "failed";
progress: number | null;
light: boolean;
};
const REPORT_EVERY_MS = 1000;
const copies = new Map<HTMLMediaElement, CopyState>();
@@ -12,7 +16,7 @@ let dirtyWhileWaiting = false;
function report(): boolean {
for (const el of copies.keys()) if (!el.isConnected) copies.delete(el);
const states = [...copies.values()];
const wanted = reporting && states.length > 0;
const wanted = states.length > 0;
if (!wanted && !reportedAny) return false;
reportedAny = wanted;
const made = states.reduce(
@@ -50,9 +54,9 @@ function scheduleReport(): void {
}, REPORT_EVERY_MS);
}
/** Starts counting the copies wanted at a level; at full quality none are wanted. */
/** Starts counting the lighter copies wanted at a level; at full quality none are wanted. */
export function setCopyReportingLevel(level: number): void {
copies.clear();
for (const [el, copy] of copies) if (copy.light) copies.delete(el);
reporting = level < 1;
clearTimeout(timer);
timer = undefined;
@@ -60,13 +64,23 @@ export function setCopyReportingLevel(level: number): void {
scheduleReport();
}
export function resetCopyReporting(): void {
copies.clear();
clearTimeout(timer);
timer = undefined;
reporting = false;
reportedAny = false;
dirtyWhileWaiting = false;
}
export function noteCopy(
el: HTMLMediaElement,
state: CopyState["state"],
progress: number | null = null,
light = true,
): void {
if (!reporting) return;
copies.set(el, { state, progress });
if (light && !reporting) return;
copies.set(el, { state, progress, light });
scheduleReport();
}
@@ -76,5 +90,5 @@ export function failedCopyElements(): HTMLMediaElement[] {
/** True when every lighter copy wanted at this level is ready; true when none are wanted. */
export function copiesReady(): boolean {
return [...copies.values()].every((copy) => copy.state === "ready");
return [...copies.values()].every((copy) => !copy.light || copy.state === "ready");
}
+8 -2
View File
@@ -12,7 +12,12 @@ function withoutRetryFlag(href: string): string {
return url.href;
}
function askHeaders(atPlayhead: boolean, background: boolean): Record<string, string> {
function askHeaders(
atPlayhead: boolean,
background: boolean,
onAsk?: () => void,
): Record<string, string> {
onAsk?.();
const headers: Record<string, string> = { Range: "bytes=0-0" };
if (atPlayhead) headers[PREVIEW_PROXY_PRIORITY_HEADER] = "1";
else if (background) headers[PREVIEW_PROXY_PRIORITY_HEADER] = "0";
@@ -34,11 +39,12 @@ export async function waitForServedProxy(
onProgress?: (fraction: number) => void,
atPlayhead: () => boolean = () => false,
background = false,
onAsk?: () => void,
): Promise<boolean> {
let failed = 0;
let ask = href;
while (live()) {
const headers = askHeaders(atPlayhead(), background);
const headers = askHeaders(atPlayhead(), background, onAsk);
const res = await fetch(ask, { headers, cache: "no-store" }).catch(() => null);
ask = withoutRetryFlag(href);
void res?.body?.cancel().catch(() => {});
@@ -159,6 +159,8 @@ export const PROXY_VARIANT_CONFIG: Record<
vp8: { extension: ".webm", contentType: "video/webm" },
};
export const PROXY_STILL_EXTENSION = ".jpg";
export function isProxyVariant(value: string): value is ProxyVariant {
return Object.hasOwn(PROXY_VARIANT_CONFIG, value);
}
@@ -54,6 +54,26 @@ describe("cleanupProxyCache", () => {
expect(existsSync(newest)).toBe(true);
});
it("counts the cached still frames toward the budget and sweeps them with the copies", () => {
const cache = cacheDir();
const now = 1_800_000_000_000;
const oldStill = proxy(cache, "old-still", ".jpg");
const copy = proxy(cache, "copy");
writeEntry(oldStill, 6, now - 3_000);
writeEntry(copy, 6, now - 1_000);
const result = cleanupProxyCache(cache, {
now,
maxBytes: 8,
maxIdleMs: 30 * 24 * 60 * 60 * 1000,
minSweepIntervalMs: 0,
});
expect(result.removed).toEqual([oldStill]);
expect(existsSync(oldStill)).toBe(false);
expect(existsSync(copy)).toBe(true);
});
it("reports the same removals in a dry run and removes nothing", () => {
const cache = cacheDir();
const now = 1_800_000_000_000;
@@ -1,13 +1,14 @@
import { existsSync, readdirSync, statSync, unlinkSync } from "node:fs";
import { extname, join } from "node:path";
import { PROXY_VARIANT_CONFIG } from "./mediaCodecMap.js";
import { PROXY_STILL_EXTENSION, PROXY_VARIANT_CONFIG } from "./mediaCodecMap.js";
const DEFAULT_MAX_BYTES = 10 * 1024 * 1024 * 1024;
const DEFAULT_STALE_TEMP_MS = 60 * 60 * 1000;
const DEFAULT_MIN_SWEEP_INTERVAL_MS = 5 * 60 * 1000;
const PROXY_EXTENSIONS: ReadonlySet<string> = new Set(
Object.values(PROXY_VARIANT_CONFIG).map(({ extension }) => extension),
);
const PROXY_EXTENSIONS: ReadonlySet<string> = new Set([
...Object.values(PROXY_VARIANT_CONFIG).map(({ extension }) => extension),
PROXY_STILL_EXTENSION,
]);
const TRANSCODER_PROXY_NAME = /^[0-9a-f]{64}\.\w+$/;
const TRANSCODER_TEMP_NAME = /^\.tmp-[0-9a-f-]{36}-(?:\.part-[0-9a-f]{64}-\d+|[0-9a-f]{64})\.\w+$/;
const TRANSCODER_PIECE_NAME = /^\.part-([0-9a-f]{64})-\d+(\.\w+)$/;
@@ -7,6 +7,7 @@ import {
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
realpathSync,
rmSync,
symlinkSync,
@@ -1914,6 +1915,33 @@ describe("resolveProxy", () => {
});
});
describe("resolveStill", () => {
const canStill = Boolean(realFfmpeg) && Boolean(findFfBinary("ffprobe"));
it.skipIf(!canStill)(
"cuts one JPEG at the asked time and reuses it for the same tenth of a second",
async () => {
vi.resetModules();
vi.doUnmock("./mediaMetadata.js");
const { resolveStill } = await import("./proxyTranscoder.js");
const projectDir = tmpProject();
const clip = join(projectDir, "clip.mp4");
execFileSync(realFfmpeg!, [
...["-v", "error", "-f", "lavfi", "-i", "testsrc2=s=320x180:r=25:d=3"],
...["-c:v", "libx264", "-pix_fmt", "yuv420p", clip],
]);
const first = await resolveStill(projectDir, clip, 1.26, { width: 160, height: 90 });
expect(first.endsWith(".jpg")).toBe(true);
const bytes = readFileSync(first);
expect([bytes[0], bytes[1]]).toEqual([0xff, 0xd8]);
expect(await resolveStill(projectDir, clip, 1.34, { width: 160, height: 90 })).toBe(first);
expect(await resolveStill(projectDir, clip, 2.5, { width: 160, height: 90 })).not.toBe(first);
},
60_000,
);
});
describe("sourceFolderKind", () => {
it.each([
["/Users/a/Library/Mobile Documents/com~apple~CloudDocs/clip.mov", "icloud"],
@@ -27,7 +27,7 @@ import {
writeCopyIndex,
} from "./proxyCacheKey.js";
import { acquireCopyLock, CopyLockStalledError } from "./proxyLock.js";
import { PROXY_VARIANT_CONFIG, type ProxyVariant } from "./mediaCodecMap.js";
import { PROXY_STILL_EXTENSION, PROXY_VARIANT_CONFIG, type ProxyVariant } from "./mediaCodecMap.js";
import { mkdirWithinProject, realpath, realProjectRoot } from "./safePath.js";
/**
@@ -1101,6 +1101,57 @@ export async function resolveProxies(
return results;
}
const stillsInFlight = new Map<string, Promise<string>>();
/** One JPEG of the source at a time (to a tenth of a second), sized like the copy's box. */
export function resolveStill(
projectDir: string,
absoluteSourcePath: string,
atSeconds: number,
box?: PreviewProxyBox,
): Promise<string> {
const source = canonicalizeProxySource(projectDir, absoluteSourcePath);
const at = Math.max(0, Math.round(atSeconds * 10) / 10);
const key = fastCopyKey(source.sourcePath, PROXY_PARAMS_VERSION, `still@${at}`, box);
const cachePath = join(source.projectDir, CACHE_DIR_NAME, `${key}${PROXY_STILL_EXTENSION}`);
if (existsSync(cachePath)) return Promise.resolve(cachePath);
const shared = stillsInFlight.get(cachePath);
if (shared) return shared;
const made = makeStill(source, at, box, cachePath).finally(() =>
stillsInFlight.delete(cachePath),
);
stillsInFlight.set(cachePath, made);
return made;
}
async function makeStill(
source: CanonicalProxySource,
at: number,
box: PreviewProxyBox | undefined,
cachePath: string,
): Promise<string> {
const ffmpegPath = findFfBinary("ffmpeg", { configuredMustExist: true });
if (!ffmpegPath) throw new FfmpegUnavailableError();
const filter = await buildVideoFilter(
ffmpegPath,
source.sourcePath,
await probeMediaMetadata(source.sourcePath),
false,
box,
);
mkdirWithinProject(source.projectDir, dirname(cachePath));
const tempPath = join(dirname(cachePath), `.tmp-${randomUUID()}-${basename(cachePath)}`);
try {
const args = ["-y", "-ss", String(at), "-i", source.sourcePath, "-map", "0:V:0"];
args.push("-frames:v", "1", "-vf", filter, "-pix_fmt", "yuvj420p", "-q:v", "3", tempPath);
await spawnFfmpeg(ffmpegPath, args, TRANSCODE_STALL_MS, new AbortController().signal);
renameSync(tempPath, cachePath);
} finally {
rmSync(tempPath, { force: true });
}
return cachePath;
}
export interface ResolveProxyOptions {
priority?: boolean;
background?: boolean;
@@ -1477,6 +1477,12 @@ describe("hf-proxy negotiation and media codec map injection (U3)", () => {
variant?: "h264" | "vp8",
box?: { width: number; height: number },
) => Promise<string>;
resolveStillImpl?: (
projectDir: string,
absoluteSourcePath: string,
at: number,
box?: { width: number; height: number },
) => Promise<string>;
scanMapImpl?: ScanMapImpl;
probeAssetCodecImpl?: () => Promise<{
codecName: string;
@@ -1502,6 +1508,11 @@ describe("hf-proxy negotiation and media codec map injection (U3)", () => {
);
vi.doMock("../helpers/proxyTranscoder.js", () => ({
resolveProxy,
resolveStill:
opts.resolveStillImpl ??
(async () => {
throw new Error("no resolveStill impl configured for this test");
}),
waitForProxy,
ProxyWaitTimeoutError,
PROXY_PENDING_RETRY_AFTER_SECONDS,
@@ -1647,6 +1658,41 @@ describe("hf-proxy negotiation and media codec map injection (U3)", () => {
);
});
it("serves one frame of the video as a JPEG while its copy is made, behind the same gate", async () => {
const projectDir = createProjectDir();
writeFileSync(join(projectDir, "clip.mp4"), "original-hevc-bytes");
const resolveStillMock = vi.fn(async () => {
const stillPath = join(projectDir, "still.jpg");
writeFileSync(stillPath, "jpegbytes");
return stillPath;
});
const { registerPreviewRoutes: register } = await loadPreviewModule({
resolveStillImpl: resolveStillMock,
});
const app = new Hono();
register(app, createAdapter(projectDir));
const url = "http://localhost/projects/demo/preview/clip.mp4?hf-proxy=h264";
const still = await app.request(`${url}&hf-proxy-still=12.34`);
expect(still.status).toBe(200);
expect(still.headers.get("Content-Type")).toBe("image/jpeg");
expect(await still.text()).toBe("jpegbytes");
expect(resolveStillMock).toHaveBeenCalledWith(
projectDir,
join(projectDir, "clip.mp4"),
12.34,
undefined,
);
expect((await app.request(`${url}&hf-proxy-still=-1`)).status).toBe(404);
expect((await app.request(`${url}&hf-proxy-still=abc`)).status).toBe(404);
expect(
(await app.request("http://localhost/projects/demo/preview/clip.mp4?hf-proxy-still=1"))
.status,
).toBe(404);
expect(resolveStillMock).toHaveBeenCalledTimes(1);
});
it("honors If-None-Match on a repeat request with a 304, without re-invoking resolveProxy", async () => {
const projectDir = createProjectDir();
writeFileSync(join(projectDir, "clip.mp4"), "original-hevc-bytes");
@@ -25,6 +25,7 @@ import {
PREVIEW_PROXY_PRIORITY_HEADER,
PREVIEW_PROXY_PROGRESS_HEADER,
PREVIEW_PROXY_RETRY_PARAM,
PREVIEW_PROXY_STILL_PARAM,
parsePreviewProxyBox,
} from "@hyperframes/core";
import {
@@ -74,6 +75,7 @@ import { injectPreviewVariables } from "../helpers/previewVariables.js";
import {
proxyCopyProgress,
resolveProxy,
resolveStill,
retryProxyCopy,
waitForProxy,
ProxyCapacityError,
@@ -740,6 +742,24 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo
}
}
// One frame of the video stands in for its copy while that is made; same gate as the copy.
const stillParam = c.req.query(PREVIEW_PROXY_STILL_PARAM);
if (stillParam !== undefined) {
const at = Number(stillParam);
if (stillParam === "" || !Number.isFinite(at) || at < 0 || !proxyVariant) {
return c.text("not found", 404);
}
try {
const still = await resolveStill(project.dir, file, at, proxyBox);
return new Response(readFileSync(still), {
headers: { "Content-Type": "image/jpeg", "Cache-Control": "private, max-age=3600" },
});
} catch (err) {
if (isProjectRootMissing(err)) throw err;
return c.text("media still failed", 502);
}
}
const tag = settledFileTag(stat);
const etag = tag && `"${tag}${proxyEtagSalt(proxyVariant, proxyBox)}"`;
const cacheHeaders: Record<string, string> = isText