mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-04 13:07:11 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 }) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user