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);