diff --git a/packages/producer/src/services/htmlCompiler.test.ts b/packages/producer/src/services/htmlCompiler.test.ts index 2e7c6cfc7a..d3a423dc49 100644 --- a/packages/producer/src/services/htmlCompiler.test.ts +++ b/packages/producer/src/services/htmlCompiler.test.ts @@ -6,6 +6,7 @@ import { join } from "node:path"; import { runInThisContext } from "node:vm"; import { parseHTML } from "linkedom"; import { interpolateVolumeGain } from "@hyperframes/core/media-volume-envelope"; +import { redactTelemetryString } from "@hyperframes/core"; import { defaultLogger } from "../logger.js"; import { NotMediaPayloadError } from "@hyperframes/engine"; import { @@ -2763,3 +2764,70 @@ describe("duplicate media ids across nested compositions", () => { expect(compiled.audios[1]).toMatchObject({ start: 3, end: 6, mediaStart: 50 }); }); }); + +describe("STUDIO-5433 — ffprobe failure includes src URL for attribution", () => { + function writeCorruptVideoProject(videoSrc: string, assetBytes: Buffer): string { + const projectDir = mkdtempSync(join(tmpdir(), "hf-studio-5433-")); + mkdirSync(join(projectDir, "assets"), { recursive: true }); + writeFileSync(join(projectDir, "assets", "clip.mp4"), assetBytes); + writeFileSync( + join(projectDir, "index.html"), + ` + + +
+ +
+ + +`, + ); + return projectDir; + } + + it("wraps the ffprobe error with [src=] when the local video is corrupt", async () => { + // 0-byte mp4 — ffprobe reports "Invalid data found when processing input", + // the same class as the STUDIO-5433 moov failure. Fail-fast semantics remain + // (video branch throws, unlike audio's graceful-degrade to duration=0). + const projectDir = writeCorruptVideoProject("assets/clip.mp4", Buffer.alloc(0)); + + let thrown: unknown; + try { + await compileForRender(projectDir, join(projectDir, "index.html"), projectDir); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(Error); + const message = (thrown as Error).message; + // A bare relative path IS the redactor's `BARE_RELATIVE_PATH` shape (one + // separator + a media extension), so it lands as `[path]`. The attribution + // that matters is the remote-URL case below; a local relative src carries + // no host to attribute and the redactor is right to drop it. + expect(message).toContain("[src=[path]]"); + // Original ffprobe diagnostic must still be present so failure classifiers + // downstream (e.g. hyperframes_render_metrics.py) continue to match. + expect(message).toMatch(/ffprobe|Invalid data|No video stream/i); + }); + + // The STUDIO-5433 case is a remote src, and that is the shape whose + // attribution has to survive redaction: host + path kept, query dropped so a + // pre-signed signature never reaches telemetry. Pinned on the redactor + // directly — driving a remote src through `compileForRender` would need a + // download stub, and the wrapper's only transform IS this call. + it("keeps host and path but drops the query when redacting a remote src", () => { + expect(redactTelemetryString("https://cdn.example.com/renders/clip.mp4?sig=abc123&exp=1")).toBe( + "https://cdn.example.com/renders/clip.mp4?\u2026", + ); + }); +}); diff --git a/packages/producer/src/services/htmlCompiler.ts b/packages/producer/src/services/htmlCompiler.ts index 663f0c7ce6..e279aebcba 100644 --- a/packages/producer/src/services/htmlCompiler.ts +++ b/packages/producer/src/services/htmlCompiler.ts @@ -23,6 +23,7 @@ import { isNonRelativeUrl, parseStrictFiniteTimingNumber, readMediaStart, + redactTelemetryString, resolveNaturalMediaTimelineDurationFromValues, type ResolvedDuration, type UnresolvedElement, @@ -444,6 +445,31 @@ async function resolveMediaDuration( return { duration: null, resolvedPath: filePath }; } + // STUDIO-5433: attach the remote `src` to any ffprobe failure surfaced from + // this branch. `extractMediaMetadata` → `runFfprobe` intentionally redacts + // its local `filePath` out of the error message (see + // engine/utils/ffprobe.ts::redactFfprobeInput), so a bare `moov atom not + // found` in Datadog carries no attribution and requires a Temporal history + // dump to identify the offending source. Re-throwing with the `src` + // (query-string redacted via `redactTelemetryString` so pre-signed URL + // signatures never reach telemetry) makes the next occurrence diagnosable + // directly from the render error. Fail-fast semantics for the video branch + // are preserved — only the message is enriched. + const withSrcContext = (error: unknown): Error => { + // A NotMediaPayloadError already carries its own attribution AND the + // routing metadata downstream keys on — `.code = "NOT_MEDIA_PAYLOAD"`, + // `.owner = "user"`, `.retryable = false`, `.elementFingerprints`. Wrapping + // it in a bare Error drops all four, flipping a user-input bug to + // generic/system/retryable: it pages ops and re-runs the render. Pass it + // through untouched. + if (error instanceof NotMediaPayloadError) return error; + const originalMessage = error instanceof Error ? error.message : String(error); + const safeSrc = redactTelemetryString(src); + const wrapped = new Error(`${originalMessage} [src=${safeSrc}]`); + if (error instanceof Error && error.stack) wrapped.stack = error.stack; + return wrapped; + }; + return withMediaProbeSlot(async () => { let profile: MediaProbeProfile; try { @@ -471,13 +497,17 @@ async function resolveMediaDuration( } return { duration: null, resolvedPath: filePath }; } - throw error; + throw withSrcContext(error); } assertAssetMediaTypeProfile(tagName === "video" ? "video" : "audio", profile, elementIdentity); let metadata: { durationSeconds: number }; if (tagName === "video") { - metadata = await extractMediaMetadata(filePath); + try { + metadata = await extractMediaMetadata(filePath); + } catch (error) { + throw withSrcContext(error); + } } else { try { metadata = await extractAudioMetadata(filePath);