mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
fix(engine): validate remote download integrity (#2938)
This commit is contained in:
@@ -399,7 +399,8 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
await import("../../../producer/src/services/deterministicFonts.js");
|
||||
const { prepareAnimatedGifInputs } =
|
||||
await import("../../../producer/src/services/animatedGifPrep.js");
|
||||
const { downloadToTemp } = await import("../../../producer/src/utils/urlDownloader.js");
|
||||
const { downloadToTemp, writeUrlDownloadTelemetry } =
|
||||
await import("../../../producer/src/utils/urlDownloader.js");
|
||||
const gifOutputDir = join(project.dir, ".hyperframes", "prepared-assets", "gif");
|
||||
const gifDownloadDir = join(project.dir, ".hyperframes", "prepared-assets", "downloads");
|
||||
const prepared = await prepareAnimatedGifInputs(html, {
|
||||
@@ -408,7 +409,11 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
outputDir: gifOutputDir,
|
||||
outputSrcPrefix: ".hyperframes/prepared-assets/gif",
|
||||
cacheDir: gifOutputDir,
|
||||
sourceAssets: await downloadRemoteGifImageSources(html, gifDownloadDir, downloadToTemp),
|
||||
sourceAssets: await downloadRemoteGifImageSources(html, gifDownloadDir, (url, destDir) =>
|
||||
downloadToTemp(url, destDir, undefined, undefined, undefined, {
|
||||
onTelemetry: writeUrlDownloadTelemetry,
|
||||
}),
|
||||
),
|
||||
});
|
||||
return injectDeterministicFontFaces(prepared.html);
|
||||
},
|
||||
|
||||
@@ -273,7 +273,18 @@ export {
|
||||
type KeyframeAnalysis,
|
||||
} from "./utils/ffprobe.js";
|
||||
|
||||
export { assertPublicHttpsUrl, downloadToTemp, isHttpUrl } from "./utils/urlDownloader.js";
|
||||
export {
|
||||
assertPublicHttpsUrl,
|
||||
downloadToTemp,
|
||||
fetchPublicHttpsText,
|
||||
isHttpUrl,
|
||||
safeDownloadUrlIdentity,
|
||||
writeUrlDownloadTelemetry,
|
||||
type SafeDownloadUrlIdentity,
|
||||
type UrlDownloadOptions,
|
||||
type UrlDownloadTelemetry,
|
||||
type PublicHttpsTextOptions,
|
||||
} from "./utils/urlDownloader.js";
|
||||
export {
|
||||
runFfmpeg,
|
||||
formatFfmpegError,
|
||||
|
||||
@@ -52,6 +52,7 @@ describe("processCompositionAudio", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
runFfmpegMock.mockClear();
|
||||
extractAudioMetadataMock.mockReset();
|
||||
extractAudioMetadataMock.mockResolvedValue({
|
||||
@@ -66,6 +67,44 @@ describe("processCompositionAudio", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("classifies an HTML-as-200 audio source as deterministic user input", async () => {
|
||||
const baseDir = mkdtempSync(join(tmpdir(), "hf-audio-base-"));
|
||||
const workDir = mkdtempSync(join(tmpdir(), "hf-audio-work-"));
|
||||
tempDirs.push(baseDir, workDir);
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Response("<!doctype html><html><body>denied</body></html>"));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await processCompositionAudio(
|
||||
[
|
||||
{
|
||||
id: "remote-voice",
|
||||
src: "https://cdn.example/voice",
|
||||
start: 0,
|
||||
end: 2,
|
||||
mediaStart: 0,
|
||||
layer: 0,
|
||||
volume: 1,
|
||||
type: "audio",
|
||||
},
|
||||
],
|
||||
baseDir,
|
||||
workDir,
|
||||
join(baseDir, "out.m4a"),
|
||||
2,
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(result.failures).toEqual([
|
||||
expect.objectContaining({
|
||||
stage: "download",
|
||||
owner: "user",
|
||||
retryable: false,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
message: "AbortError: ffprobe operation aborted",
|
||||
|
||||
@@ -9,7 +9,12 @@ import { closeSync, existsSync, mkdirSync, mkdtempSync, openSync, rmSync, writeF
|
||||
import { join, dirname } from "path";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { extractAudioMetadata } from "../utils/ffprobe.js";
|
||||
import { downloadToTemp, isHttpUrl } from "../utils/urlDownloader.js";
|
||||
import {
|
||||
downloadToTemp,
|
||||
isHttpUrl,
|
||||
UrlDownloadError,
|
||||
writeUrlDownloadTelemetry,
|
||||
} from "../utils/urlDownloader.js";
|
||||
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
|
||||
import { formatFfmpegError, runFfmpeg, type RunFfmpegResult } from "../utils/runFfmpeg.js";
|
||||
import { unwrapTemplate } from "../utils/htmlTemplate.js";
|
||||
@@ -241,16 +246,23 @@ function probeFailure(message: string, elementId: string): AudioProcessingFailur
|
||||
};
|
||||
}
|
||||
|
||||
function downloadFailure(message: string, elementId: string): AudioProcessingFailure {
|
||||
function downloadFailure(error: unknown, elementId: string): AudioProcessingFailure {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const invalidSource =
|
||||
/(?:invalid URL|only HTTPS|private\/reserved|HTTP (?:400|401|403|404|405|410|422)\b)/i.test(
|
||||
message,
|
||||
);
|
||||
error instanceof UrlDownloadError
|
||||
? error.kind === "http_not_found" ||
|
||||
error.kind === "http_rejected" ||
|
||||
error.kind === "invalid_payload" ||
|
||||
error.kind === "cancelled"
|
||||
: /(?:invalid URL|only HTTPS|private\/reserved|HTTP (?:400|401|403|404|405|410|422)\b)/i.test(
|
||||
message,
|
||||
);
|
||||
const retryable = error instanceof UrlDownloadError ? error.retryable : !invalidSource;
|
||||
return {
|
||||
stage: "download",
|
||||
reason: "download_failed",
|
||||
owner: invalidSource ? "user" : "system",
|
||||
retryable: !invalidSource,
|
||||
retryable,
|
||||
elementId,
|
||||
detail: boundedDetail(`Download failed for audio element ${elementId}: ${message}`),
|
||||
};
|
||||
@@ -712,11 +724,11 @@ export async function processCompositionAudio(
|
||||
|
||||
if (isHttpUrl(srcPath)) {
|
||||
try {
|
||||
srcPath = await downloadToTemp(srcPath, workDir);
|
||||
srcPath = await downloadToTemp(srcPath, workDir, undefined, signal, undefined, {
|
||||
onTelemetry: writeUrlDownloadTelemetry,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
failures.push(
|
||||
downloadFailure(err instanceof Error ? err.message : String(err), element.id),
|
||||
);
|
||||
failures.push(downloadFailure(err, element.id));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { classifyFfmpegSpawnError } from "./videoFrameExtractor.js";
|
||||
import { UrlDownloadError } from "../utils/urlDownloader.js";
|
||||
import { classifyFfmpegSpawnError, classifyVideoExtractionError } from "./videoFrameExtractor.js";
|
||||
|
||||
describe("classifyFfmpegSpawnError", () => {
|
||||
it.each(["ENOENT", "EACCES", "ENOEXEC", "UNKNOWN"])(
|
||||
@@ -18,3 +19,21 @@ describe("classifyFfmpegSpawnError", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyVideoExtractionError download integrity", () => {
|
||||
it("keeps deterministic HTML payloads non-retryable and user-owned as invalid media", () => {
|
||||
const classified = classifyVideoExtractionError(
|
||||
new UrlDownloadError("invalid_payload", false, "HTML payload"),
|
||||
);
|
||||
expect(classified).toMatchObject({ kind: "invalid_media", retryable: false });
|
||||
});
|
||||
|
||||
it.each(["range_protocol", "length_mismatch", "hash_mismatch"] as const)(
|
||||
"keeps %s retryable after the downloader's one clean refetch is exhausted",
|
||||
(kind) => {
|
||||
expect(
|
||||
classifyVideoExtractionError(new UrlDownloadError(kind, true, "integrity failure")),
|
||||
).toMatchObject({ kind: "download_transient", retryable: true });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -28,7 +28,12 @@ import {
|
||||
isHdrColorSpace as isHdrColorSpaceUtil,
|
||||
type HdrTransfer,
|
||||
} from "../utils/hdr.js";
|
||||
import { downloadToTemp, isHttpUrl, UrlDownloadError } from "../utils/urlDownloader.js";
|
||||
import {
|
||||
downloadToTemp,
|
||||
isHttpUrl,
|
||||
UrlDownloadError,
|
||||
writeUrlDownloadTelemetry,
|
||||
} from "../utils/urlDownloader.js";
|
||||
import { runFfmpeg } from "../utils/runFfmpeg.js";
|
||||
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
|
||||
import { unwrapTemplate } from "../utils/htmlTemplate.js";
|
||||
@@ -342,6 +347,14 @@ export function classifyVideoExtractionError(error: unknown): VideoSourceExtract
|
||||
diagnostic,
|
||||
);
|
||||
}
|
||||
if (error.kind === "invalid_payload") {
|
||||
return new VideoSourceExtractionError(
|
||||
"invalid_media",
|
||||
false,
|
||||
"Video source download returned a non-media payload",
|
||||
diagnostic,
|
||||
);
|
||||
}
|
||||
if (error.retryable) {
|
||||
return new VideoSourceExtractionError(
|
||||
"download_transient",
|
||||
@@ -1424,8 +1437,13 @@ export async function extractAllVideoFrames(
|
||||
if (isHttpUrl(videoPath)) {
|
||||
const downloadDir = join(options.outputDir, "_downloads");
|
||||
mkdirSync(downloadDir, { recursive: true });
|
||||
videoPath = await downloadToTemp(videoPath, downloadDir, undefined, signal, () =>
|
||||
recordTransientRetries(1),
|
||||
videoPath = await downloadToTemp(
|
||||
videoPath,
|
||||
downloadDir,
|
||||
undefined,
|
||||
signal,
|
||||
() => recordTransientRetries(1),
|
||||
{ onTelemetry: writeUrlDownloadTelemetry },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -85,6 +85,22 @@ describe("discoverMediaFromBrowser", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function validTestMediaResponse(): Response {
|
||||
const bytes = new Uint8Array([
|
||||
0, 0, 0, 24, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d, 0, 0, 0, 0, 0x69, 0x73, 0x6f, 0x6d,
|
||||
0x6d, 0x70, 0x34, 0x32,
|
||||
]);
|
||||
return new Response(bytes, { status: 200 });
|
||||
}
|
||||
|
||||
function validTestImageResponse(): Response {
|
||||
const png = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||
"base64",
|
||||
);
|
||||
return new Response(png, { status: 200 });
|
||||
}
|
||||
|
||||
describe("injectSdkPositionEditsRenderScript", () => {
|
||||
it("injects before </body> when SDK position-edit markers are present", () => {
|
||||
const html =
|
||||
@@ -1424,7 +1440,7 @@ describe("localizeRemoteMediaSources", () => {
|
||||
it("rewrites remote <video> src to _remote_media path when download succeeds", async () => {
|
||||
const orig = globalThis.fetch;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).fetch = async () => new Response(new Uint8Array(100), { status: 200 });
|
||||
(globalThis as any).fetch = async () => validTestMediaResponse();
|
||||
try {
|
||||
const dl = mkdtempSync(join(tmpdir(), "hf-dl-ok-"));
|
||||
const html = `<video id="v1" src="https://media-ok.example.com/a/clip.mp4" data-start="0" data-end="10" muted></video>`;
|
||||
@@ -1447,13 +1463,42 @@ describe("localizeRemoteMediaSources", () => {
|
||||
expect(remoteMediaAssets.size).toBe(0);
|
||||
});
|
||||
|
||||
it("logs only a safe fingerprint and host for a signed-URL media failure", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalWarn = defaultLogger.warn;
|
||||
const warnings: unknown[] = [];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).fetch = async () =>
|
||||
new Response("<!doctype html><html><body>expired</body></html>", { status: 200 });
|
||||
defaultLogger.warn = (message, meta) => warnings.push({ message, meta });
|
||||
try {
|
||||
const dl = mkdtempSync(join(tmpdir(), "hf-dl-safe-log-"));
|
||||
const url = "https://cdn.example/private/customer.mp4?X-Amz-Signature=super-secret-signature";
|
||||
const html = `<video id="v1" src="${url}" data-start="0" data-end="10"></video>`;
|
||||
const { html: result, remoteMediaAssets } = await localizeRemoteMediaSources(html, dl);
|
||||
|
||||
expect(result).toContain(url);
|
||||
expect(remoteMediaAssets.size).toBe(0);
|
||||
expect(warnings).toHaveLength(1);
|
||||
const serialized = JSON.stringify(warnings);
|
||||
expect(serialized).toContain("cdn.example");
|
||||
expect(serialized).toContain("urlFingerprint");
|
||||
expect(serialized).not.toContain("customer.mp4");
|
||||
expect(serialized).not.toContain("super-secret-signature");
|
||||
} finally {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).fetch = originalFetch;
|
||||
defaultLogger.warn = originalWarn;
|
||||
}
|
||||
});
|
||||
|
||||
it("deduplicates: two tags with the same src URL → one download", async () => {
|
||||
const orig = globalThis.fetch;
|
||||
let fetchCount = 0;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).fetch = async () => {
|
||||
fetchCount++;
|
||||
return new Response(new Uint8Array(100), { status: 200 });
|
||||
return validTestMediaResponse();
|
||||
};
|
||||
try {
|
||||
const dl = mkdtempSync(join(tmpdir(), "hf-dl-dedup-"));
|
||||
@@ -1479,7 +1524,7 @@ describe("localizeRemoteMediaSources", () => {
|
||||
it("rewrites src in both double-quoted and single-quoted attributes", async () => {
|
||||
const orig = globalThis.fetch;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).fetch = async () => new Response(new Uint8Array(100), { status: 200 });
|
||||
(globalThis as any).fetch = async () => validTestMediaResponse();
|
||||
try {
|
||||
const dl = mkdtempSync(join(tmpdir(), "hf-dl-quotes-"));
|
||||
const html = `<video id="v1" src="https://q.example.com/c/dq.mp4" data-start="0" data-end="10" muted></video>
|
||||
@@ -1520,7 +1565,7 @@ describe("localizeRemoteImageSources", () => {
|
||||
it("rewrites remote <img> src to _remote_media path when download succeeds", async () => {
|
||||
const orig = globalThis.fetch;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).fetch = async () => new Response(new Uint8Array(100), { status: 200 });
|
||||
(globalThis as any).fetch = async () => validTestImageResponse();
|
||||
try {
|
||||
const dl = mkdtempSync(join(tmpdir(), "hf-img-ok-"));
|
||||
const html = `<img class="hero" src="https://img-ok.example.com/photo.png" />`;
|
||||
@@ -1549,7 +1594,7 @@ describe("localizeRemoteImageSources", () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).fetch = async () => {
|
||||
fetchCount++;
|
||||
return new Response(new Uint8Array(100), { status: 200 });
|
||||
return validTestImageResponse();
|
||||
};
|
||||
try {
|
||||
const dl = mkdtempSync(join(tmpdir(), "hf-img-dedup-"));
|
||||
@@ -1583,7 +1628,7 @@ describe("localizeRemoteImageSources", () => {
|
||||
it("rewrites both double-quoted and single-quoted src attributes", async () => {
|
||||
const orig = globalThis.fetch;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).fetch = async () => new Response(new Uint8Array(100), { status: 200 });
|
||||
(globalThis as any).fetch = async () => validTestImageResponse();
|
||||
try {
|
||||
const dl = mkdtempSync(join(tmpdir(), "hf-img-quotes-"));
|
||||
const html = `<img src="https://q-img.example.com/dq.png" />
|
||||
@@ -1607,7 +1652,7 @@ describe("localizeRemoteImageSources", () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).fetch = async () => {
|
||||
fetchCount++;
|
||||
return new Response(new Uint8Array(100), { status: 200 });
|
||||
return validTestImageResponse();
|
||||
};
|
||||
try {
|
||||
const dl = mkdtempSync(join(tmpdir(), "hf-img-datasrc-"));
|
||||
@@ -1628,7 +1673,7 @@ describe("localizeRemoteImageSources", () => {
|
||||
// <img> tags with `class` before `src`. Regex must not assume src position.
|
||||
const orig = globalThis.fetch;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).fetch = async () => new Response(new Uint8Array(100), { status: 200 });
|
||||
(globalThis as any).fetch = async () => validTestImageResponse();
|
||||
try {
|
||||
const dl = mkdtempSync(join(tmpdir(), "hf-img-attr-order-"));
|
||||
const html = `<img class="kobe-cutout" alt="kobe" src="https://astral.example.com/d828bca.png" />`;
|
||||
@@ -1798,6 +1843,7 @@ h1 { font-size: 2rem; }`;
|
||||
const { html: result, remoteMediaAssets } = await localizeRemoteFontFaces(html, dl);
|
||||
// The <link> tag should be replaced with an inline <style> containing the @font-face
|
||||
expect(result).not.toContain(`href="${STYLESHEET_URL}"`);
|
||||
expect(result).not.toContain(STYLESHEET_URL);
|
||||
expect(result).not.toContain("<link");
|
||||
expect(result).toContain("@font-face");
|
||||
expect(result).toContain("CustomFont");
|
||||
@@ -1828,6 +1874,57 @@ h1 { font-size: 2rem; }`;
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a stylesheet redirect to a private host before the second request", async () => {
|
||||
const STYLESHEET_URL = "https://styles.example.com/fonts.css";
|
||||
const orig = globalThis.fetch;
|
||||
let fetchCount = 0;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).fetch = async () => {
|
||||
fetchCount++;
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: "https://169.254.169.254/latest/meta-data/" },
|
||||
});
|
||||
};
|
||||
try {
|
||||
const dl = mkdtempSync(join(tmpdir(), "hf-ff-private-redirect-"));
|
||||
const html = `<link rel="stylesheet" href="${STYLESHEET_URL}">`;
|
||||
const { html: result, remoteMediaAssets } = await localizeRemoteFontFaces(html, dl);
|
||||
|
||||
expect(fetchCount).toBe(1);
|
||||
expect(result).toBe(html);
|
||||
expect(remoteMediaAssets.size).toBe(0);
|
||||
} finally {
|
||||
globalThis.fetch = orig;
|
||||
}
|
||||
});
|
||||
|
||||
it("does not log a signed stylesheet path or query on failure", async () => {
|
||||
const STYLESHEET_URL =
|
||||
"https://styles.example.com/private/customer.css?X-Amz-Signature=super-secret";
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalWarn = defaultLogger.warn;
|
||||
const warnings: unknown[] = [];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).fetch = async () =>
|
||||
new Response(null, { status: 503, statusText: STYLESHEET_URL });
|
||||
defaultLogger.warn = (message, meta) => warnings.push({ message, meta });
|
||||
try {
|
||||
const dl = mkdtempSync(join(tmpdir(), "hf-ff-safe-style-log-"));
|
||||
const html = `<link rel="stylesheet" href="${STYLESHEET_URL}">`;
|
||||
await localizeRemoteFontFaces(html, dl);
|
||||
|
||||
const serialized = JSON.stringify(warnings);
|
||||
expect(serialized).toContain("styles.example.com");
|
||||
expect(serialized).toContain("urlFingerprint");
|
||||
expect(serialized).not.toContain("customer.css");
|
||||
expect(serialized).not.toContain("super-secret");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
defaultLogger.warn = originalWarn;
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps <link> tag when external stylesheet has no @font-face rules", async () => {
|
||||
const STYLESHEET_URL = "https://cdn.example.com/reset.css";
|
||||
const orig = globalThis.fetch;
|
||||
|
||||
@@ -53,7 +53,13 @@ import {
|
||||
analyzeKeyframeIntervals,
|
||||
probeMediaProfile,
|
||||
} from "@hyperframes/engine";
|
||||
import { assertPublicHttpsUrl, downloadToTemp, isHttpUrl } from "../utils/urlDownloader.js";
|
||||
import {
|
||||
downloadToTemp,
|
||||
fetchPublicHttpsText,
|
||||
isHttpUrl,
|
||||
safeDownloadUrlIdentity,
|
||||
type UrlDownloadTelemetry,
|
||||
} from "../utils/urlDownloader.js";
|
||||
import type { Page } from "puppeteer-core";
|
||||
import {
|
||||
injectDeterministicFontFaces,
|
||||
@@ -66,6 +72,10 @@ import { defaultLogger, type ProducerLogger } from "../logger.js";
|
||||
import { assertAssetMediaTypeProfile } from "./assetMediaType.js";
|
||||
import { withMediaProbeSlot } from "../utils/mediaProbeConcurrency.js";
|
||||
|
||||
function logRemoteDownloadTelemetry(event: UrlDownloadTelemetry): void {
|
||||
defaultLogger.info("[Compiler] Remote asset download integrity", { ...event });
|
||||
}
|
||||
|
||||
export interface CompiledComposition {
|
||||
html: string;
|
||||
subCompositions: Map<string, string>;
|
||||
@@ -419,7 +429,9 @@ async function resolveMediaDuration(
|
||||
if (isHttpUrl(src)) {
|
||||
if (!existsSync(downloadDir)) mkdirSync(downloadDir, { recursive: true });
|
||||
try {
|
||||
filePath = await downloadToTemp(src, downloadDir);
|
||||
filePath = await downloadToTemp(src, downloadDir, undefined, undefined, undefined, {
|
||||
onTelemetry: logRemoteDownloadTelemetry,
|
||||
});
|
||||
} catch {
|
||||
// Download failed (e.g. 404 placeholder URL) — skip gracefully.
|
||||
// The element will get duration 0 and be excluded from the render.
|
||||
@@ -1306,14 +1318,17 @@ async function downloadAndRewriteUrls(
|
||||
await Promise.all(
|
||||
[...urlSet].map(async (url) => {
|
||||
try {
|
||||
const localPath = await downloadToTemp(url, remoteDir);
|
||||
const localPath = await downloadToTemp(url, remoteDir, undefined, undefined, undefined, {
|
||||
onTelemetry: logRemoteDownloadTelemetry,
|
||||
});
|
||||
urlToLocal.set(url, localPath);
|
||||
} catch (err) {
|
||||
defaultLogger.warn(
|
||||
`[Compiler] ${warnLabel} ${url} — using original URL as fallback. ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
const identity = safeDownloadUrlIdentity(url);
|
||||
defaultLogger.warn(`[Compiler] ${warnLabel} — using original URL as fallback.`, {
|
||||
urlFingerprint: identity.urlFingerprint,
|
||||
host: identity.host,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -1369,7 +1384,7 @@ export async function localizeRemoteMediaSources(
|
||||
urlSet,
|
||||
html,
|
||||
join(downloadDir, REMOTE_MEDIA_SUBDIR),
|
||||
"Remote media download failed for",
|
||||
"Remote media download failed",
|
||||
"Localized remote media source(s)",
|
||||
);
|
||||
}
|
||||
@@ -1412,7 +1427,7 @@ export async function localizeRemoteImageSources(
|
||||
urlSet,
|
||||
html,
|
||||
join(downloadDir, REMOTE_MEDIA_SUBDIR),
|
||||
"Remote image download failed for",
|
||||
"Remote image download failed",
|
||||
"Localized remote image source(s)",
|
||||
);
|
||||
}
|
||||
@@ -1450,7 +1465,7 @@ export async function localizeRemoteBackgroundImages(
|
||||
urlSet,
|
||||
html,
|
||||
join(downloadDir, REMOTE_MEDIA_SUBDIR),
|
||||
"Remote background-image download failed for",
|
||||
"Remote background-image download failed",
|
||||
"Localized remote background-image(s)",
|
||||
// Quoted url('..')/url("..") are rewritten by downloadAndRewriteUrls' default
|
||||
// replaceAll; this handles the unquoted url(https://..) form.
|
||||
@@ -1492,42 +1507,18 @@ function isGoogleFontsUrl(href: string): boolean {
|
||||
const MAX_STYLESHEET_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
async function fetchExternalStylesheetCss(href: string): Promise<string | null> {
|
||||
const identity = safeDownloadUrlIdentity(href);
|
||||
try {
|
||||
assertPublicHttpsUrl(href);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(href, {
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
return await fetchPublicHttpsText(href, {
|
||||
maxBytes: MAX_STYLESHEET_BYTES,
|
||||
timeoutMs: 15_000,
|
||||
});
|
||||
if (!response.ok) {
|
||||
defaultLogger.warn(
|
||||
`[Compiler] External stylesheet fetch failed for ${href} — HTTP ${response.status}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const contentLength = response.headers.get("content-length");
|
||||
if (contentLength && parseInt(contentLength, 10) > MAX_STYLESHEET_BYTES) {
|
||||
defaultLogger.warn(
|
||||
`[Compiler] External stylesheet too large (${contentLength} bytes): ${href}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const text = await response.text();
|
||||
if (text.length > MAX_STYLESHEET_BYTES) {
|
||||
defaultLogger.warn(
|
||||
`[Compiler] External stylesheet too large (${text.length} bytes): ${href}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return text;
|
||||
} catch (err) {
|
||||
defaultLogger.warn(
|
||||
`[Compiler] External stylesheet fetch failed for ${href} — ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
defaultLogger.warn("[Compiler] External stylesheet fetch failed — preserving link tag.", {
|
||||
urlFingerprint: identity.urlFingerprint,
|
||||
host: identity.host,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1608,11 +1599,14 @@ async function inlineExternalFontStylesheets(html: string): Promise<string> {
|
||||
if (css === null) continue;
|
||||
const fontFaceBlocks = extractFontFaceBlocks(css);
|
||||
if (fontFaceBlocks.length === 0) continue;
|
||||
const inlineStyle = `<style>/* Inlined from ${href} */\n${fontFaceBlocks.join("\n")}\n</style>`;
|
||||
const identity = safeDownloadUrlIdentity(href);
|
||||
const inlineStyle = `<style>/* Inlined external font stylesheet */\n${fontFaceBlocks.join("\n")}\n</style>`;
|
||||
result = result.replace(fullMatch, inlineStyle);
|
||||
defaultLogger.info(
|
||||
`[Compiler] Inlined ${fontFaceBlocks.length} @font-face rule(s) from external stylesheet: ${href}`,
|
||||
);
|
||||
defaultLogger.info("[Compiler] Inlined external @font-face rule(s)", {
|
||||
count: fontFaceBlocks.length,
|
||||
urlFingerprint: identity.urlFingerprint,
|
||||
host: identity.host,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -1672,7 +1666,7 @@ export async function localizeRemoteFontFaces(
|
||||
urlSet,
|
||||
processed,
|
||||
join(downloadDir, REMOTE_MEDIA_SUBDIR),
|
||||
"Remote font download failed for",
|
||||
"Remote font download failed",
|
||||
"Localized remote font face(s)",
|
||||
(h, url, relPath) => h.replaceAll(`url(${url})`, `url("${relPath}")`),
|
||||
);
|
||||
|
||||
@@ -2,4 +2,11 @@
|
||||
* Re-exported from @hyperframes/engine.
|
||||
* @see engine/src/utils/urlDownloader.ts for implementation.
|
||||
*/
|
||||
export { assertPublicHttpsUrl, downloadToTemp, isHttpUrl } from "@hyperframes/engine";
|
||||
export {
|
||||
downloadToTemp,
|
||||
fetchPublicHttpsText,
|
||||
isHttpUrl,
|
||||
safeDownloadUrlIdentity,
|
||||
writeUrlDownloadTelemetry,
|
||||
type UrlDownloadTelemetry,
|
||||
} from "@hyperframes/engine";
|
||||
|
||||
Reference in New Issue
Block a user