diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index df414c102b..f6f6f1f76b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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, diff --git a/packages/core/src/previewProxyBox.ts b/packages/core/src/previewProxyBox.ts index f77fac1372..6190eb193f 100644 --- a/packages/core/src/previewProxyBox.ts +++ b/packages/core/src/previewProxyBox.ts @@ -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"; diff --git a/packages/core/src/runtime/media.ts b/packages/core/src/runtime/media.ts index 1de036bd02..44e3286726 100644 --- a/packages/core/src/runtime/media.ts +++ b/packages/core/src/runtime/media.ts @@ -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 ( diff --git a/packages/core/src/runtime/mediaProxy.test.ts b/packages/core/src/runtime/mediaProxy.test.ts index eae3f1ead7..fc293b8f61 100644 --- a/packages/core/src/runtime/mediaProxy.test.ts +++ b/packages/core/src/runtime/mediaProxy.test.ts @@ -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 () => { diff --git a/packages/core/src/runtime/mediaProxy.ts b/packages/core/src/runtime/mediaProxy.ts index 4348fad9d1..ea9620a19a 100644 --- a/packages/core/src/runtime/mediaProxy.ts +++ b/packages/core/src/runtime/mediaProxy.ts @@ -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(pathname: string, map: Record): T | n ); } +const stills = new WeakSet(); + +/** 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)); } /** diff --git a/packages/core/src/runtime/previewCopies.test.ts b/packages/core/src/runtime/previewCopies.test.ts new file mode 100644 index 0000000000..d7501af758 --- /dev/null +++ b/packages/core/src/runtime/previewCopies.test.ts @@ -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 }) }), + ); + }); +}); diff --git a/packages/core/src/runtime/previewCopies.ts b/packages/core/src/runtime/previewCopies.ts index fb95e674bc..6f9fdf7a89 100644 --- a/packages/core/src/runtime/previewCopies.ts +++ b/packages/core/src/runtime/previewCopies.ts @@ -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(); @@ -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"); } diff --git a/packages/core/src/runtime/proxyWait.ts b/packages/core/src/runtime/proxyWait.ts index 7be849c842..13767ff485 100644 --- a/packages/core/src/runtime/proxyWait.ts +++ b/packages/core/src/runtime/proxyWait.ts @@ -12,7 +12,12 @@ function withoutRetryFlag(href: string): string { return url.href; } -function askHeaders(atPlayhead: boolean, background: boolean): Record { +function askHeaders( + atPlayhead: boolean, + background: boolean, + onAsk?: () => void, +): Record { + onAsk?.(); const headers: Record = { 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 { 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(() => {}); diff --git a/packages/studio-server/src/helpers/mediaCodecMap.ts b/packages/studio-server/src/helpers/mediaCodecMap.ts index e5553b9072..270d344be3 100644 --- a/packages/studio-server/src/helpers/mediaCodecMap.ts +++ b/packages/studio-server/src/helpers/mediaCodecMap.ts @@ -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); } diff --git a/packages/studio-server/src/helpers/proxyCache.test.ts b/packages/studio-server/src/helpers/proxyCache.test.ts index 9a4222c0fb..32b8fce9ed 100644 --- a/packages/studio-server/src/helpers/proxyCache.test.ts +++ b/packages/studio-server/src/helpers/proxyCache.test.ts @@ -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; diff --git a/packages/studio-server/src/helpers/proxyCache.ts b/packages/studio-server/src/helpers/proxyCache.ts index fba82e1489..dcbbb52305 100644 --- a/packages/studio-server/src/helpers/proxyCache.ts +++ b/packages/studio-server/src/helpers/proxyCache.ts @@ -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 = new Set( - Object.values(PROXY_VARIANT_CONFIG).map(({ extension }) => extension), -); +const PROXY_EXTENSIONS: ReadonlySet = 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+)$/; diff --git a/packages/studio-server/src/helpers/proxyTranscoder.test.ts b/packages/studio-server/src/helpers/proxyTranscoder.test.ts index 53db85877b..5e37095b6e 100644 --- a/packages/studio-server/src/helpers/proxyTranscoder.test.ts +++ b/packages/studio-server/src/helpers/proxyTranscoder.test.ts @@ -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"], diff --git a/packages/studio-server/src/helpers/proxyTranscoder.ts b/packages/studio-server/src/helpers/proxyTranscoder.ts index fe7ed18f87..3312e4b178 100644 --- a/packages/studio-server/src/helpers/proxyTranscoder.ts +++ b/packages/studio-server/src/helpers/proxyTranscoder.ts @@ -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>(); + +/** 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 { + 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 { + 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; diff --git a/packages/studio-server/src/routes/preview.test.ts b/packages/studio-server/src/routes/preview.test.ts index 4b059dfa6b..b52f5ce2fe 100644 --- a/packages/studio-server/src/routes/preview.test.ts +++ b/packages/studio-server/src/routes/preview.test.ts @@ -1477,6 +1477,12 @@ describe("hf-proxy negotiation and media codec map injection (U3)", () => { variant?: "h264" | "vp8", box?: { width: number; height: number }, ) => Promise; + resolveStillImpl?: ( + projectDir: string, + absoluteSourcePath: string, + at: number, + box?: { width: number; height: number }, + ) => Promise; 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"); diff --git a/packages/studio-server/src/routes/preview.ts b/packages/studio-server/src/routes/preview.ts index c39bb3acc8..f8451633c9 100644 --- a/packages/studio-server/src/routes/preview.ts +++ b/packages/studio-server/src/routes/preview.ts @@ -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 = isText