diff --git a/packages/aws-lambda/src/events.ts b/packages/aws-lambda/src/events.ts index 4e23b03da..44b5e6066 100644 --- a/packages/aws-lambda/src/events.ts +++ b/packages/aws-lambda/src/events.ts @@ -117,7 +117,7 @@ interface AssembleEventBase { Action: "assemble"; /** S3 URIs of every chunk, ordered by chunk index. Length must equal `chunkCount`. */ ChunkS3Uris: string[]; - /** S3 URI of the planDir's `audio.aac` if the composition has audio; `null` otherwise. */ + /** S3 URI of the planDir's audio artifact if the composition has audio; `null` otherwise. */ AudioS3Uri: string | null; /** Final output S3 URI (`s3://bucket/key.mp4`). */ OutputS3Uri: string; diff --git a/packages/aws-lambda/src/handler.test.ts b/packages/aws-lambda/src/handler.test.ts index e7323412e..d1af1a96d 100644 --- a/packages/aws-lambda/src/handler.test.ts +++ b/packages/aws-lambda/src/handler.test.ts @@ -563,7 +563,7 @@ describe("handler dispatch", () => { ); const renderChunkMock = mock( async (planDir: string, _chunkIndex: number, outputPath: string): Promise => { - expect(existsSync(join(planDir, "audio.aac"))).toBe(false); + expect(existsSync(join(planDir, "audio.m4a"))).toBe(false); writeFileSync(outputPath, "V2-CHUNK"); return { outputPath, @@ -818,7 +818,7 @@ function makeMinimalV1PlanDir(dir: string, withAudio: boolean): void { JSON.stringify([{ index: 0, startFrame: 0, endFrame: 30 }]), ); writeFileSync(join(dir, "meta", "encoder.json"), "{}"); - if (withAudio) writeFileSync(join(dir, "audio.aac"), "AAC"); + if (withAudio) writeFileSync(join(dir, "audio.m4a"), "AAC"); planJson.planHash = recomputePlanHashFromPlanDir(dir); writeFileSync(join(dir, "plan.json"), JSON.stringify(planJson)); } diff --git a/packages/aws-lambda/src/handler.ts b/packages/aws-lambda/src/handler.ts index bd10c12c2..e16b68b78 100644 --- a/packages/aws-lambda/src/handler.ts +++ b/packages/aws-lambda/src/handler.ts @@ -24,6 +24,9 @@ import { listPlanV2ArtifactsForTarget, materializePlanV2Target, plan, + isPlanAudioArtifactPath, + PLAN_AUDIO_RELATIVE_PATH, + resolvePlanAudioPath, planV2WithPublisher, type PlanResult, type PlanV2Artifact, @@ -318,9 +321,11 @@ async function handlePlan(event: PlanEvent, deps?: HandlerDeps): Promise 0; - const audioUri = hasAudio ? `${trimTrailingSlash(event.PlanOutputS3Prefix)}/audio.aac` : null; + const audioUri = hasAudio + ? `${trimTrailingSlash(event.PlanOutputS3Prefix)}/${PLAN_AUDIO_RELATIVE_PATH}` + : null; // Plan and audio are independent S3 PUTs; run them in parallel so // the response returns as soon as the slower of the two completes. await Promise.all([ @@ -390,7 +395,7 @@ async function handlePlanV2( Width: manifest.width, Height: manifest.height, Format: manifest.format, - HasAudio: manifest.artifacts.some((artifact) => artifact.path === "audio.aac"), + HasAudio: manifest.artifacts.some((artifact) => isPlanAudioArtifactPath(artifact.path)), AudioS3Uri: null, FfmpegVersion: manifest.ffmpegVersion, ProducerVersion: manifest.producerVersion, @@ -568,7 +573,7 @@ async function handleAssemble( let audioPath: string | null = null; if (event.AudioS3Uri) { - audioPath = join(planDir, "audio.aac"); + audioPath = resolvePlanAudioPath(planDir) ?? join(planDir, PLAN_AUDIO_RELATIVE_PATH); await downloadS3ObjectToFile(s3, event.AudioS3Uri, audioPath); } @@ -616,7 +621,7 @@ async function handleAssembleV2( const planDir = await downloadAndMaterializePlanV2(s3, event, { role: "assembler" }, work); // `downloadAndMaterializePlanV2` materializes atomically. Audio is // assembler-only and lives at the familiar v1-compatible location. - const audioPath = existsSync(join(planDir, "audio.aac")) ? join(planDir, "audio.aac") : null; + const audioPath = resolvePlanAudioPath(planDir); const chunkPaths = await downloadChunkObjects(s3, event.ChunkS3Uris, work, event.Format); const finalOutput = event.Format === "png-sequence" diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index fe29c8bfa..a10070ce1 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -213,7 +213,11 @@ export { export { createVideoFrameInjector } from "./services/videoFrameInjector.js"; -export { parseAudioElements, processCompositionAudio } from "./services/audioMixer.js"; +export { + MIXED_AUDIO_FILENAME, + parseAudioElements, + processCompositionAudio, +} from "./services/audioMixer.js"; export { cloneCaptureWarning, cloneCaptureWarnings } from "./services/captureWarning.js"; export type { AudioElement, diff --git a/packages/engine/src/services/audioMixer.level.test.ts b/packages/engine/src/services/audioMixer.level.test.ts index 39395543b..2ad7f5a5c 100644 --- a/packages/engine/src/services/audioMixer.level.test.ts +++ b/packages/engine/src/services/audioMixer.level.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { getFfmpegBinary } from "../utils/ffmpegBinaries.js"; -import { processCompositionAudio } from "./audioMixer.js"; +import { MIXED_AUDIO_FILENAME, processCompositionAudio } from "./audioMixer.js"; const HAS_FFMPEG = spawnSync(getFfmpegBinary(), ["-version"], { encoding: "utf-8" }).status === 0; const tempDirs: string[] = []; @@ -22,6 +22,39 @@ function meanVolumeDb(path: string): number { return Number(match[1]); } +/** Seconds until the first sample loud enough to be signal rather than codec noise. */ +function firstAudibleSeconds(path: string): number { + const sampleRate = 48_000; + const result = spawnSync( + getFfmpegBinary(), + [ + "-nostdin", + "-v", + "error", + "-i", + path, + "-map", + "0:a", + "-ac", + "1", + "-ar", + String(sampleRate), + "-f", + "s16le", + "-", + ], + { maxBuffer: 1 << 28 }, + ); + if (result.status !== 0) { + throw new Error(`Could not decode ${path}: ${result.stderr?.toString()}`); + } + const pcm = result.stdout; + for (let i = 0; i < pcm.length / 2; i += 1) { + if (Math.abs(pcm.readInt16LE(i * 2)) > 512) return i / sampleRate; + } + throw new Error(`No audible sample found in ${path}`); +} + describe.skipIf(!HAS_FFMPEG)("processCompositionAudio levels", () => { afterEach(() => { for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); @@ -75,4 +108,57 @@ describe.skipIf(!HAS_FFMPEG)("processCompositionAudio levels", () => { expect(result.success).toBe(true); expect(meanVolumeDb(outputPath) - meanVolumeDb(sourcePath)).toBeGreaterThan(-0.3); }); + + it("places a delayed track on its authored start, not one AAC frame later", async () => { + // The mix is AAC-encoded, and AAC encoders emit ~1024 priming samples. A + // raw ADTS container has nowhere to record that delay, so it decodes as + // real leading silence and drags the whole track 21.33 ms late against a + // frame-accurate video. MIXED_AUDIO_FILENAME picks a container that stores + // the delay as an edit list instead; this asserts the artifact we actually + // ship lands on time. + const projectDir = mkdtempSync(join(tmpdir(), "hf-onset-")); + const workDir = mkdtempSync(join(tmpdir(), "hf-onset-work-")); + tempDirs.push(projectDir, workDir); + const sourcePath = join(projectDir, "tone.wav"); + const outputPath = join(projectDir, MIXED_AUDIO_FILENAME); + const setup = spawnSync( + getFfmpegBinary(), + [ + "-nostdin", + "-v", + "error", + "-f", + "lavfi", + "-i", + "sine=frequency=1000:duration=1:sample_rate=48000", + "-c:a", + "pcm_s16le", + sourcePath, + ], + { encoding: "utf-8" }, + ); + expect(setup.status, setup.stderr).toBe(0); + + const result = await processCompositionAudio( + [ + { + id: "tone", + src: "tone.wav", + start: 2, + end: 3, + mediaStart: 0, + layer: 0, + volume: 1, + type: "audio", + }, + ], + projectDir, + workDir, + outputPath, + 4, + ); + + expect(result.success).toBe(true); + expect(firstAudibleSeconds(outputPath)).toBeCloseTo(2, 2); + }); }); diff --git a/packages/engine/src/services/audioMixer.ts b/packages/engine/src/services/audioMixer.ts index 61eb9e417..cb0da8f85 100644 --- a/packages/engine/src/services/audioMixer.ts +++ b/packages/engine/src/services/audioMixer.ts @@ -32,6 +32,21 @@ import { applyVolumeEnvelopeToWav } from "./audioVolumeEnvelope.js"; export type { AudioElement, MixResult } from "./audioMixer.types.js"; +/** + * Filename every caller must use for the mixed-audio artifact. + * + * The extension is load-bearing, not cosmetic: FFmpeg picks the muxer from it, + * and the mix is AAC-encoded. A raw ADTS `.aac` stream has nowhere to record + * the encoder's priming delay, so those leading samples decode as real silence + * and shift the whole track ~1024 samples (21.33 ms at 48 kHz) late against a + * frame-accurate video track. An MP4-family container carries the delay as an + * edit list, which every decoder then strips, so the mix lands on its authored + * start. Keep the choice here rather than at each call site: the same file is + * muxed into the video, shipped in a distributed plan, and handed to users as + * the PNG-sequence sidecar, and all three have to agree. + */ +export const MIXED_AUDIO_FILENAME = "audio.m4a"; + function clampVolume(volume: number): number { if (!Number.isFinite(volume)) return 1; return Math.max(0, Math.min(1, volume)); diff --git a/packages/gcp-cloud-run/src/server.test.ts b/packages/gcp-cloud-run/src/server.test.ts index eced0b048..691dc82fb 100644 --- a/packages/gcp-cloud-run/src/server.test.ts +++ b/packages/gcp-cloud-run/src/server.test.ts @@ -87,7 +87,7 @@ function makeMinimalV1PlanDir(dir: string, withAudio: boolean): void { JSON.stringify([{ index: 0, startFrame: 0, endFrame: 30 }]), ); writeFileSync(join(dir, "meta", "encoder.json"), "{}"); - if (withAudio) writeFileSync(join(dir, "audio.aac"), "AAC"); + if (withAudio) writeFileSync(join(dir, "audio.m4a"), "AAC"); planJson.planHash = recomputePlanHashFromPlanDir(dir); writeFileSync(join(dir, "plan.json"), JSON.stringify(planJson)); } @@ -273,7 +273,7 @@ describe("dispatch", () => { chunkIndex: number, outputBase: string, ): Promise => { - expect(existsSync(join(planDir, "audio.aac"))).toBe(false); + expect(existsSync(join(planDir, "audio.m4a"))).toBe(false); writeFileSync(outputBase, `chunk-${chunkIndex}`); return { outputPath: outputBase, diff --git a/packages/gcp-cloud-run/src/server.ts b/packages/gcp-cloud-run/src/server.ts index a7e828572..81aae4c2b 100644 --- a/packages/gcp-cloud-run/src/server.ts +++ b/packages/gcp-cloud-run/src/server.ts @@ -32,6 +32,9 @@ import { listPlanV2ArtifactsForTarget, materializePlanV2Target, plan, + isPlanAudioArtifactPath, + PLAN_AUDIO_RELATIVE_PATH, + resolvePlanAudioPath, planV2WithPublisher, type PlanResult, type PlanV2Artifact, @@ -322,7 +325,7 @@ async function handlePlan(event: PlanEvent, deps?: HandlerDeps): Promise 0; await uploadFileToGcs(storage, planTar, planTarUri, "application/gzip"); @@ -397,7 +400,7 @@ async function handlePlanV2( Width: manifest.width, Height: manifest.height, Format: manifest.format, - HasAudio: manifest.artifacts.some((artifact) => artifact.path === "audio.aac"), + HasAudio: manifest.artifacts.some((artifact) => isPlanAudioArtifactPath(artifact.path)), AudioGcsUri: null, FfmpegVersion: manifest.ffmpegVersion, ProducerVersion: manifest.producerVersion, @@ -567,7 +570,7 @@ async function handleAssemble( // only for backward compatibility with an older Plan that uploaded it // standalone. let audioPath: string | null = null; - const planAudio = join(planDir, "audio.aac"); + const planAudio = resolvePlanAudioPath(planDir) ?? join(planDir, PLAN_AUDIO_RELATIVE_PATH); if (existsSync(planAudio) && statSync(planAudio).size > 0) { audioPath = planAudio; } else if (event.AudioGcsUri) { @@ -619,7 +622,7 @@ async function handleAssembleV2( const work = mkdtempSync(join(deps?.tmpRoot ?? tmpdir(), "hf-cr-assemble-v2-")); try { const planDir = await downloadAndMaterializePlanV2(storage, event, { role: "assembler" }, work); - const audioPath = existsSync(join(planDir, "audio.aac")) ? join(planDir, "audio.aac") : null; + const audioPath = resolvePlanAudioPath(planDir); const chunkPaths = await downloadChunkObjects(storage, event.ChunkGcsUris, work, event.Format); const finalOutput = event.Format === "png-sequence" diff --git a/packages/producer/src/distributed.ts b/packages/producer/src/distributed.ts index 14a15fa61..0a1366a41 100644 --- a/packages/producer/src/distributed.ts +++ b/packages/producer/src/distributed.ts @@ -149,6 +149,18 @@ export { // CLI / adopter SDKs can derive runtime allowlists from one source. export { PlanVideosMetadataError, type DistributedFormat } from "./services/distributed/shared.js"; +// ── Plan artifact names ───────────────────────────────────────────────────── +// The cloud adapters locate and publish the plan's audio artifact by name. Its +// extension selects the container, so they must read it from here rather than +// restate it: a literal that drifts from the writer's is a silently missing +// audio track, not a loud failure. +export { + isPlanAudioArtifactPath, + PLAN_AUDIO_LEGACY_RELATIVE_PATH, + PLAN_AUDIO_RELATIVE_PATH, + resolvePlanAudioPath, +} from "./services/distributed/shared.js"; + // ── Plan-time shared types from `freezePlan` ─────────────────────────────── // Re-exported so adopters that deserialize a planDir's `meta/encoder.json` // or `meta/chunks.json` see the same shapes the producer wrote them as. diff --git a/packages/producer/src/regression-harness-distributed.ts b/packages/producer/src/regression-harness-distributed.ts index 4451ad655..b35dbaa7f 100644 --- a/packages/producer/src/regression-harness-distributed.ts +++ b/packages/producer/src/regression-harness-distributed.ts @@ -35,7 +35,7 @@ import { existsSync, mkdirSync } from "node:fs"; import { join } from "node:path"; import type { Fps } from "@hyperframes/core"; import { assemble, plan, renderChunk } from "./distributed.js"; -import type { DistributedFormat } from "./services/distributed/shared.js"; +import { PLAN_AUDIO_RELATIVE_PATH, type DistributedFormat } from "./services/distributed/shared.js"; /** * Three-mode contract that backs `--mode=` on the regression @@ -199,9 +199,9 @@ export async function runDistributedSimulatedRender( chunkPaths.push(chunkPath); } - // Step C: assemble. `audio.aac` only exists when the composition has + // Step C: assemble. The audio artifact only exists when the composition has // audio — pass null otherwise so `assemble()` doesn't try to mux silence. - const audioPath = join(planDir, "audio.aac"); + const audioPath = join(planDir, PLAN_AUDIO_RELATIVE_PATH); const audioForAssemble = existsSync(audioPath) ? audioPath : null; await assemble(planDir, chunkPaths, audioForAssemble, input.renderedOutputPath); } diff --git a/packages/producer/src/services/distributed/assemble.test.ts b/packages/producer/src/services/distributed/assemble.test.ts index fd3f71e2b..6a6e69c02 100644 --- a/packages/producer/src/services/distributed/assemble.test.ts +++ b/packages/producer/src/services/distributed/assemble.test.ts @@ -37,7 +37,7 @@ afterAll(() => { /** * Build a synthetic planDir whose `meta/chunks.json` declares N chunks of * `framesPerChunk` frames each. Does NOT materialize compiled/, video-frames/, - * audio.aac — assemble only reads `plan.json` + `meta/chunks.json`, and we + * audio.m4a — assemble only reads `plan.json` + `meta/chunks.json`, and we * pass chunk paths explicitly. Keeping the dir lean speeds up the test * loop. */ @@ -287,7 +287,7 @@ describe("assemble()", () => { ); it( - "muxes audio with frame-count-derived duration when audio.aac is present", + "muxes audio with frame-count-derived duration when audio.m4a is present", async () => { if (!hasFfmpeg) return; @@ -301,7 +301,7 @@ describe("assemble()", () => { const chunkAPath = join(planDir, "chunk-0.mp4"); const chunkBPath = join(planDir, "chunk-1.mp4"); - const audioPath = join(planDir, "audio.aac"); + const audioPath = join(planDir, "audio.m4a"); makeMp4Chunk(chunkAPath, 6); makeMp4Chunk(chunkBPath, 6); // Audio is half a second longer than the video — `padOrTrimAudioToVideoFrameCount` @@ -346,7 +346,7 @@ describe("assemble()", () => { const chunkAPath = join(planDir, "chunk-0.mp4"); const chunkBPath = join(planDir, "chunk-1.mp4"); - const audioPath = join(planDir, "audio.aac"); + const audioPath = join(planDir, "audio.m4a"); makeMp4Chunk(chunkAPath, 6); makeMp4Chunk(chunkBPath, 6); // Audio is shorter than the video, forcing the distributed pad branch. diff --git a/packages/producer/src/services/distributed/assemble.ts b/packages/producer/src/services/distributed/assemble.ts index 95977af54..e7478c41a 100644 --- a/packages/producer/src/services/distributed/assemble.ts +++ b/packages/producer/src/services/distributed/assemble.ts @@ -34,7 +34,12 @@ import { writeFileSync, } from "node:fs"; import { dirname, join } from "node:path"; -import { applyFaststart, muxVideoWithAudio, runFfmpeg } from "@hyperframes/engine"; +import { + applyFaststart, + MIXED_AUDIO_FILENAME, + muxVideoWithAudio, + runFfmpeg, +} from "@hyperframes/engine"; import { fpsToFfmpegArg } from "@hyperframes/core"; import { defaultLogger, type ProducerLogger } from "../../logger.js"; import { formatExportFrameName } from "../../utils/paths.js"; @@ -78,7 +83,7 @@ interface PlanJsonForAssemble { * @param chunkPaths — ordered chunk outputs, length === `chunks.json` length. * For mp4/mov each entry is a path to an encoded chunk file; for * png-sequence each entry is a path to a directory of frames. - * @param audioPath — `/audio.aac` for mux'd formats. Pass `null` + * @param audioPath — `/` for mux'd formats. Pass `null` * when the composition has no audio (or `assemble` is being called for a * format whose audio is muxed elsewhere). `assemble` always normalizes * audio length against the assembled video's frame count when @@ -385,9 +390,9 @@ export async function assemble( * into the merged output so consumers see one continuous numbered sequence. * * Audio is intentionally NOT muxed here — png-sequence has no container. - * If `audioPath` is non-null we copy it alongside as `audio.aac` so callers - * who need to re-mux later (After Effects, Nuke, ffmpeg image2 + audio) can - * find it. + * If `audioPath` is non-null we copy it alongside under the engine's mixed-audio + * filename so callers who need to re-mux later (After Effects, Nuke, ffmpeg + * image2 + audio) can find it. */ function mergePngFrameDirs( chunkPaths: readonly string[], @@ -435,7 +440,7 @@ function mergePngFrameDirs( // containers); png-sequence has no encoder, so we copy the audio // verbatim. The sidecar matches the in-process png-sequence convention. if (audioPath !== null && existsSync(audioPath)) { - const sidecar = join(outputPath, "audio.aac"); + const sidecar = join(outputPath, MIXED_AUDIO_FILENAME); cpSync(audioPath, sidecar); } diff --git a/packages/producer/src/services/distributed/chunkBoundary.test.ts b/packages/producer/src/services/distributed/chunkBoundary.test.ts index e701dfd8f..0da3d2d28 100644 --- a/packages/producer/src/services/distributed/chunkBoundary.test.ts +++ b/packages/producer/src/services/distributed/chunkBoundary.test.ts @@ -96,7 +96,7 @@ async function planAndAssemble(input: { chunkPaths.push(chunkPath); } - const audioPath = join(planDir, "audio.aac"); + const audioPath = join(planDir, "audio.m4a"); const audioForAssemble = existsSync(audioPath) ? audioPath : null; await assemble(planDir, chunkPaths, audioForAssemble, outputPath); return outputPath; diff --git a/packages/producer/src/services/distributed/plan.test.ts b/packages/producer/src/services/distributed/plan.test.ts index 9680c53b6..8b0b358ca 100644 --- a/packages/producer/src/services/distributed/plan.test.ts +++ b/packages/producer/src/services/distributed/plan.test.ts @@ -492,8 +492,8 @@ describe("plan() — golden planDir + planHash determinism", () => { expect(existsSync(join(planDir, "plan.json"))).toBe(true); expect(existsSync(join(planDir, "compiled", "index.html"))).toBe(true); expect(existsSync(join(planDir, "video-frames"))).toBe(true); - // No audio in the fixture — audio.aac must NOT exist. - expect(existsSync(join(planDir, "audio.aac"))).toBe(false); + // No audio in the fixture — audio.m4a must NOT exist. + expect(existsSync(join(planDir, "audio.m4a"))).toBe(false); expect(existsSync(join(planDir, "meta", "composition.json"))).toBe(true); expect(existsSync(join(planDir, "meta", "encoder.json"))).toBe(true); expect(existsSync(join(planDir, "meta", "chunks.json"))).toBe(true); @@ -718,7 +718,7 @@ describe("plan() — golden planDir + planHash determinism", () => { // Verify the audio stage actually fired (otherwise the test // pins the wrong path — the same false-pass mode as the // no-audio variant above). - expect(existsSync(join(planDir, "audio.aac"))).toBe(true); + expect(existsSync(join(planDir, "audio.m4a"))).toBe(true); }, TIMEOUT_MS, ); diff --git a/packages/producer/src/services/distributed/plan.ts b/packages/producer/src/services/distributed/plan.ts index a7d1deb96..50150e390 100644 --- a/packages/producer/src/services/distributed/plan.ts +++ b/packages/producer/src/services/distributed/plan.ts @@ -10,7 +10,7 @@ * ├── plan.json * ├── compiled/ # compileForRender output (self-contained) * ├── video-frames/ # per-video JPEG sequences (dereferenced) - * ├── audio.aac # only when composition has audio + * ├── audio.m4a # only when composition has audio * └── meta/ * ├── composition.json * ├── encoder.json # LockedRenderConfig diff --git a/packages/producer/src/services/distributed/planAudioCompat.test.ts b/packages/producer/src/services/distributed/planAudioCompat.test.ts new file mode 100644 index 000000000..6b446d343 --- /dev/null +++ b/packages/producer/src/services/distributed/planAudioCompat.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + isPlanAudioArtifactPath, + PLAN_AUDIO_LEGACY_RELATIVE_PATH, + PLAN_AUDIO_RELATIVE_PATH, + resolvePlanAudioPath, +} from "./shared.js"; + +/** + * `plan` and `assemble` are separate invocations bridged by object storage, so a + * rolling deploy can pair a pre-rollout planner with a post-rollout assembler. + * Both readers locate the artifact by existence alone, which would make that + * pairing a silently muted video rather than an error. + * + * Delete this file, the legacy constant, and the fallback branch one release + * after the container change ships. + */ +describe("plan audio artifact compatibility", () => { + const dirs: string[] = []; + + afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); + }); + + const planDir = (files: string[]): string => { + const dir = mkdtempSync(join(tmpdir(), "hf-plan-audio-")); + dirs.push(dir); + for (const name of files) writeFileSync(join(dir, name), "audio"); + return dir; + }; + + it("prefers the current name", () => { + const dir = planDir([PLAN_AUDIO_RELATIVE_PATH, PLAN_AUDIO_LEGACY_RELATIVE_PATH]); + expect(resolvePlanAudioPath(dir)).toBe(join(dir, PLAN_AUDIO_RELATIVE_PATH)); + }); + + it("falls back to a legacy plan's name so a rolling deploy keeps its audio", () => { + const dir = planDir([PLAN_AUDIO_LEGACY_RELATIVE_PATH]); + expect(resolvePlanAudioPath(dir)).toBe(join(dir, PLAN_AUDIO_LEGACY_RELATIVE_PATH)); + }); + + it("reports no audio when the plan carries neither name", () => { + expect(resolvePlanAudioPath(planDir([]))).toBeNull(); + }); + + it("recognises both names as the audio artifact, and nothing else", () => { + expect(isPlanAudioArtifactPath(PLAN_AUDIO_RELATIVE_PATH)).toBe(true); + expect(isPlanAudioArtifactPath(PLAN_AUDIO_LEGACY_RELATIVE_PATH)).toBe(true); + expect(isPlanAudioArtifactPath("plan.json")).toBe(false); + expect(isPlanAudioArtifactPath("meta/chunks.json")).toBe(false); + }); + + it("writes only the current name", () => { + expect(PLAN_AUDIO_RELATIVE_PATH).not.toBe(PLAN_AUDIO_LEGACY_RELATIVE_PATH); + expect(PLAN_AUDIO_RELATIVE_PATH.endsWith(".m4a")).toBe(true); + }); +}); diff --git a/packages/producer/src/services/distributed/planSize.ts b/packages/producer/src/services/distributed/planSize.ts index c79874507..f8a69a12d 100644 --- a/packages/producer/src/services/distributed/planSize.ts +++ b/packages/producer/src/services/distributed/planSize.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; import { lstatSync, readdirSync } from "node:fs"; import { extname, join, relative } from "node:path"; +import { PLAN_AUDIO_RELATIVE_PATH } from "./shared.js"; const MAX_TOP_COMPONENTS = 10; const SOURCE_MEDIA_EXTENSIONS = new Set([ @@ -56,7 +57,7 @@ interface ClassifiedFile { const PLAN_ROOT_CATEGORY: Readonly> = { compiled: "compiled", - "audio.aac": "audio", + [PLAN_AUDIO_RELATIVE_PATH]: "audio", meta: "metadata", "plan.json": "metadata", }; diff --git a/packages/producer/src/services/distributed/planSizeCap.test.ts b/packages/producer/src/services/distributed/planSizeCap.test.ts index de1b389c2..fe2a0e9b7 100644 --- a/packages/producer/src/services/distributed/planSizeCap.test.ts +++ b/packages/producer/src/services/distributed/planSizeCap.test.ts @@ -87,7 +87,7 @@ describe("measurePlanSizeBreakdown", () => { join(dir, "video-frames", "customer-video-name", "frame_000001.jpg"), Buffer.alloc(300), ); - writeFileSync(join(dir, "audio.aac"), Buffer.alloc(40)); + writeFileSync(join(dir, "audio.m4a"), Buffer.alloc(40)); writeFileSync(join(dir, "meta", "encoder.json"), Buffer.alloc(20)); writeFileSync(join(dir, "plan.json"), Buffer.alloc(10)); writeFileSync(join(dir, "other.bin"), Buffer.alloc(5)); diff --git a/packages/producer/src/services/distributed/planV2.test.ts b/packages/producer/src/services/distributed/planV2.test.ts index fee951f07..4658896f1 100644 --- a/packages/producer/src/services/distributed/planV2.test.ts +++ b/packages/producer/src/services/distributed/planV2.test.ts @@ -141,7 +141,7 @@ function createV1Plan( rmSync(join(planDir, "meta", "videos.json")); } } - if (audio) writeFileSync(join(planDir, "audio.aac"), "assemble-only-audio"); + if (audio) writeFileSync(join(planDir, "audio.m4a"), "assemble-only-audio"); writeFileSync( join(planDir, "plan.json"), JSON.stringify({ @@ -387,9 +387,9 @@ describe("Plan v2 manifest", () => { const chunk = listPlanV2ArtifactsForTarget(manifest, { role: "chunk", chunkIndex: 0 }); const assembler = listPlanV2ArtifactsForTarget(manifest, { role: "assembler" }); - expect(chunk.some((artifact) => artifact.path === "audio.aac")).toBe(false); + expect(chunk.some((artifact) => artifact.path === "audio.m4a")).toBe(false); expect(chunk.some((artifact) => artifact.path === "compiled/index.html")).toBe(true); - expect(assembler.some((artifact) => artifact.path === "audio.aac")).toBe(true); + expect(assembler.some((artifact) => artifact.path === "audio.m4a")).toBe(true); expect(assembler.some((artifact) => artifact.path === "compiled/index.html")).toBe(false); }); @@ -626,11 +626,11 @@ describe("Plan v2 manifest", () => { ); const assembler = materializePlanV2Target(result.planDir, { role: "assembler" }, assemblerDir); - expect(existsSync(join(chunkDir, "audio.aac"))).toBe(false); + expect(existsSync(join(chunkDir, "audio.m4a"))).toBe(false); expect( validatePlanV2MaterializedTarget(chunkDir, { role: "chunk", chunkIndex: 1 })?.planHash, ).toBe(result.planHash); - expect(assembler.audioPath).toBe(join(assemblerDir, "audio.aac")); + expect(assembler.audioPath).toBe(join(assemblerDir, "audio.m4a")); expect(validatePlanV2MaterializedTarget(assemblerDir, { role: "assembler" })?.planHash).toBe( result.planHash, ); diff --git a/packages/producer/src/services/distributed/planV2.ts b/packages/producer/src/services/distributed/planV2.ts index 4131a58f2..dc6c6db6d 100644 --- a/packages/producer/src/services/distributed/planV2.ts +++ b/packages/producer/src/services/distributed/planV2.ts @@ -47,7 +47,7 @@ import { import { PLAN_V2_INTEGRITY_UNRECOVERABLE, PlanV2IntegrityError } from "./planV2Errors.js"; import { planV2BlobPath } from "./planV2Layout.js"; import { - PLAN_AUDIO_RELATIVE_PATH, + isPlanAudioArtifactPath, PLAN_VIDEOS_META_RELATIVE_PATH, type DistributedFormat, parsePlanVideosJson as parseSharedPlanVideosJson, @@ -283,7 +283,7 @@ function artifactTargets( path: string, videoDependencies: ReadonlyMap | null, ): Pick { - if (path === PLAN_AUDIO_RELATIVE_PATH) return { chunks: [], assembler: true }; + if (isPlanAudioArtifactPath(path)) return { chunks: [], assembler: true }; if (path === "plan.json" || path === "meta/chunks.json" || path === "meta/encoder.json") { return { chunks: "all", assembler: true }; } @@ -898,6 +898,7 @@ export function materializePlanV2Target( } const manifest = readPlanV2Manifest(planV2Dir); const artifacts = listPlanV2ArtifactsForTarget(manifest, target); + const audioArtifact = artifacts.find((artifact) => isPlanAudioArtifactPath(artifact.path)); const verified = artifacts.map((artifact) => ({ artifact, sourcePath: verifyBlob(planV2Dir, artifact), @@ -933,10 +934,14 @@ export function materializePlanV2Target( sourcePlanV1Hash: getPlanV2ExecutionPlanHash(manifest), artifactCount: artifacts.length, sizeBytes: artifacts.reduce((sum, artifact) => sum + artifact.sizeBytes, 0), + // Join the artifact's OWN name, not the current constant: a plan written + // before the container change carries the legacy name, and materializing it + // under the new one would point at a file that was never written. audioPath: - target.role === "assembler" && - artifacts.some((artifact) => artifact.path === PLAN_AUDIO_RELATIVE_PATH) - ? join(destinationDir, PLAN_AUDIO_RELATIVE_PATH) + target.role === "assembler" + ? audioArtifact + ? join(destinationDir, audioArtifact.path) + : null : null, }; } diff --git a/packages/producer/src/services/distributed/shared.ts b/packages/producer/src/services/distributed/shared.ts index 063864a2c..0217686ac 100644 --- a/packages/producer/src/services/distributed/shared.ts +++ b/packages/producer/src/services/distributed/shared.ts @@ -10,7 +10,12 @@ import { existsSync, readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { type Fps } from "@hyperframes/core"; -import { type VideoElement, type VideoFrameFormat, type VideoMetadata } from "@hyperframes/engine"; +import { + MIXED_AUDIO_FILENAME, + type VideoElement, + type VideoFrameFormat, + type VideoMetadata, +} from "@hyperframes/engine"; import { type RenderConfig, type RenderJob, createRenderJob } from "../renderOrchestrator.js"; import { defaultLogger, type ProducerLogger } from "../../logger.js"; @@ -35,8 +40,42 @@ export const PLAN_VIDEOS_META_RELATIVE_PATH = "meta/videos.json"; * Relative path of the normalized audio artifact written into a distributed * plan. Keep writers and transport readers coupled through this contract * rather than duplicating a filename literal. + * + * Derived from the engine's filename rather than restated, because the + * extension selects the muxer: the plan artifact is the mix moved into place, + * so if the two ever disagreed the plan would claim a container the file + * doesn't have. */ -export const PLAN_AUDIO_RELATIVE_PATH = "audio.aac"; +export const PLAN_AUDIO_RELATIVE_PATH = MIXED_AUDIO_FILENAME; + +/** + * Name the audio artifact carried before it moved to an MP4-family container. + * + * COMPATIBILITY SHIM, remove one release after the container change ships. + * `plan` and `assemble` are separate invocations bridged by object storage, so + * a rolling deploy can pair a pre-rollout planner with a post-rollout + * assembler. Both readers locate the artifact by existence alone, which makes + * that pairing a silently muted video rather than an error, so reads accept the + * old name for one release while writes only ever emit the new one. + */ +export const PLAN_AUDIO_LEGACY_RELATIVE_PATH = "audio.aac"; + +/** True for either the current or the legacy plan-audio artifact name. */ +export function isPlanAudioArtifactPath(path: string): boolean { + return path === PLAN_AUDIO_RELATIVE_PATH || path === PLAN_AUDIO_LEGACY_RELATIVE_PATH; +} + +/** + * Locate a plan's audio artifact on disk, preferring the current name and + * falling back to the legacy one. Returns `null` when the plan has no audio. + */ +export function resolvePlanAudioPath(planDir: string): string | null { + for (const name of [PLAN_AUDIO_RELATIVE_PATH, PLAN_AUDIO_LEGACY_RELATIVE_PATH]) { + const candidate = join(planDir, name); + if (existsSync(candidate)) return candidate; + } + return null; +} /** * On-disk shape of `/meta/videos.json`. The engine's diff --git a/packages/producer/src/services/render/audioPadTrim.test.ts b/packages/producer/src/services/render/audioPadTrim.test.ts index 13b78fbaa..6ea808c94 100644 --- a/packages/producer/src/services/render/audioPadTrim.test.ts +++ b/packages/producer/src/services/render/audioPadTrim.test.ts @@ -97,8 +97,8 @@ describe("buildPadTrimAudioArgs", () => { // Bundled Windows FFmpeg builds reject `apad=whole_dur`. Match the // portable finite-padding shape used by the main audio mixer. const winPlan = buildPadTrimAudioPlan( - "C:\\Users\\alice\\AppData\\Local\\Temp\\hf-render-abc\\audio.aac", - "C:\\Users\\alice\\AppData\\Local\\Temp\\hf-render-abc\\audio-padded.aac", + "C:\\Users\\alice\\AppData\\Local\\Temp\\hf-render-abc\\audio.m4a", + "C:\\Users\\alice\\AppData\\Local\\Temp\\hf-render-abc\\audio-padded.m4a", 4.0, 5.0, ); @@ -301,7 +301,7 @@ describe("PadTrimAudioResult.error never carries the input path", () => { it(`redacts ${name} raised by the video probe`, async () => { const result = await padOrTrimAudioToVideoFrameCount({ videoPath, - audioPath: "/tmp/audio.aac", + audioPath: "/tmp/audio.m4a", outputPath: "/tmp/out.aac", // Reproduces the real thrower: defaultProbeVideoFrameInfo raises // `ffprobe found no video stream in ${videoPath}` with the raw path. @@ -323,12 +323,12 @@ describe("PadTrimAudioResult.error never carries the input path", () => { it("redacts raw ffprobe stderr surfaced through the audio probe", async () => { const result = await padOrTrimAudioToVideoFrameCount({ videoPath: "/tmp/v.mp4", - audioPath: "/data/acme-secret/audio.aac", + audioPath: "/data/acme-secret/audio.m4a", outputPath: "/tmp/out.aac", probeVideoFrameInfo: () => Promise.resolve({ frameCount: 30, fpsNum: 30, fpsDen: 1 }), probeAudioInfo: () => Promise.reject( - new Error("/data/acme-secret/audio.aac: Invalid data found when processing input"), + new Error("/data/acme-secret/audio.m4a: Invalid data found when processing input"), ), runFfmpeg: () => Promise.resolve({ success: true }), }); @@ -354,7 +354,7 @@ describe("PadTrimAudioResult.error never carries the input path", () => { it(`still returns a failed result when the video probe rejects with ${label}`, async () => { const result = await padOrTrimAudioToVideoFrameCount({ videoPath: "/data/acme-secret/video.mp4", - audioPath: "/tmp/audio.aac", + audioPath: "/tmp/audio.m4a", outputPath: "/tmp/out.aac", probeVideoFrameInfo: () => Promise.reject(reason), probeAudioInfo: () => Promise.resolve({ durationSeconds: 1 }), @@ -367,7 +367,7 @@ describe("PadTrimAudioResult.error never carries the input path", () => { it(`still returns a failed result when the audio probe rejects with ${label}`, async () => { const result = await padOrTrimAudioToVideoFrameCount({ videoPath: "/tmp/v.mp4", - audioPath: "/data/acme-secret/audio.aac", + audioPath: "/data/acme-secret/audio.m4a", outputPath: "/tmp/out.aac", probeVideoFrameInfo: () => Promise.resolve({ frameCount: 30, fpsNum: 30, fpsDen: 1 }), probeAudioInfo: () => Promise.reject(reason), diff --git a/packages/producer/src/services/render/audioPadTrim.ts b/packages/producer/src/services/render/audioPadTrim.ts index 381d4d325..ae98e985f 100644 --- a/packages/producer/src/services/render/audioPadTrim.ts +++ b/packages/producer/src/services/render/audioPadTrim.ts @@ -1,6 +1,6 @@ // fallow-ignore-file complexity /** - * audioPadTrim — pad-or-trim an `audio.aac` file so its exact duration + * audioPadTrim — pad-or-trim the mixed-audio file so its exact duration * matches the assembled video's frame count divided by fps. * * Distributed render assemble step needs this because: @@ -62,7 +62,7 @@ export interface AudioProbeInfo { export interface PadTrimAudioInput { /** Path to the assembled video. Used to derive `frameCount / fps`. */ videoPath: string; - /** Path to the pre-mixed audio (typically `/audio.aac`). */ + /** Path to the pre-mixed audio (typically `/audio.m4a`). */ audioPath: string; /** Path the helper writes the duration-corrected audio to. */ outputPath: string; @@ -109,10 +109,10 @@ export interface PadTrimAudioPlan { * sequence that materializes it. Exported separately so unit tests can pin * every branch without spawning ffmpeg. * - * - `sourceDuration < targetDuration` → generate only the missing silence - * tail, then concat-copy the source AAC plus that tail. This avoids - * re-encoding the already mixed `audio.aac`; the pad branch remains the - * inverse of trim instead of becoming a second full-source AAC encode. + * - `sourceDuration < targetDuration` → pad with `apad` to the exact target + * and re-encode AAC. This decodes and re-encodes the already mixed audio; + * an earlier concat-copy shape avoided that but could not produce a + * portable result on the bundled Windows FFmpeg builds. * - `sourceDuration > targetDuration` → filter to the exact target and * re-encode AAC so packet padding cannot outlast the video. * - `|Δ| < AUDIO_DURATION_TOLERANCE_SECONDS` → no-op `copy`, but we still @@ -241,7 +241,7 @@ function sanitizeProbeFailure(reason: unknown, paths: readonly string[]): string } /** - * Pad or trim `audio.aac` so its exact duration matches `frameCount / fps` + * Pad or trim the mixed audio so its exact duration matches `frameCount / fps` * for the assembled video. */ export async function padOrTrimAudioToVideoFrameCount( diff --git a/packages/producer/src/services/render/stages/assembleStage.test.ts b/packages/producer/src/services/render/stages/assembleStage.test.ts index ae0696202..ae782ac75 100644 --- a/packages/producer/src/services/render/stages/assembleStage.test.ts +++ b/packages/producer/src/services/render/stages/assembleStage.test.ts @@ -33,7 +33,7 @@ function makeInput(overrides: Partial = {}): AssembleStageIn duration: 1, }, videoOnlyPath: "/tmp/video-only.mp4", - audioOutputPath: "/tmp/audio.aac", + audioOutputPath: "/tmp/audio.m4a", outputPath: "/tmp/output.mp4", hasAudio: true, abortSignal: undefined, @@ -61,7 +61,7 @@ describe("runAssembleStage audio duration parity", () => { expect(padOrTrimAudioMock).toHaveBeenCalledWith({ videoPath: "/tmp/video-only.mp4", - audioPath: "/tmp/audio.aac", + audioPath: "/tmp/audio.m4a", outputPath: "/tmp/audio.duration-normalized.m4a", }); expect(muxVideoWithAudioMock).toHaveBeenCalledWith( diff --git a/packages/producer/src/services/render/stages/audioStage.test.ts b/packages/producer/src/services/render/stages/audioStage.test.ts index 38bbc8296..c64dde37c 100644 --- a/packages/producer/src/services/render/stages/audioStage.test.ts +++ b/packages/producer/src/services/render/stages/audioStage.test.ts @@ -49,7 +49,7 @@ describe("runAudioStage", () => { it("surfaces the mixer's error as audioError when the mix fails", async () => { processCompositionAudioMock.mockResolvedValue({ success: false, - outputPath: "audio.aac", + outputPath: "audio.m4a", durationMs: 1, tracksProcessed: 0, error: "Source not found: a1 (narration.wav)", @@ -77,7 +77,7 @@ describe("runAudioStage", () => { it("falls back to a generic message when the mixer fails without an error string", async () => { processCompositionAudioMock.mockResolvedValue({ success: false, - outputPath: "audio.aac", + outputPath: "audio.m4a", durationMs: 1, tracksProcessed: 0, }); @@ -91,7 +91,7 @@ describe("runAudioStage", () => { it("does not set audioError when the mix succeeds", async () => { processCompositionAudioMock.mockResolvedValue({ success: true, - outputPath: "audio.aac", + outputPath: "audio.m4a", durationMs: 1, tracksProcessed: 1, }); diff --git a/packages/producer/src/services/render/stages/audioStage.ts b/packages/producer/src/services/render/stages/audioStage.ts index 34da4c0d7..ddcbdaa28 100644 --- a/packages/producer/src/services/render/stages/audioStage.ts +++ b/packages/producer/src/services/render/stages/audioStage.ts @@ -1,13 +1,15 @@ /** - * audioStage — mix the composition's audio tracks into `workDir/audio.aac`. + * audioStage — mix the composition's audio tracks into + * `workDir/`. * * Trivial wrapper around `processCompositionAudio`. The stage is skipped * (no ffmpeg invocation) when the composition has no audio elements; the * timer is still set so the perf summary stays consistent across renders. * * Hard constraints preserved verbatim: - * - `audioOutputPath` is always `join(workDir, "audio.aac")`, regardless - * of whether any audio was actually produced. + * - `audioOutputPath` is always `join(workDir, MIXED_AUDIO_FILENAME)`, + * regardless of whether any audio was actually produced. The engine owns + * that filename because its extension selects the muxer (see the constant). * - `hasAudio` reflects `audioResult.success` from * `processCompositionAudio`; it is `false` when there are no audio * elements (skips the call entirely) and also when the call returns @@ -16,7 +18,11 @@ */ import { join } from "node:path"; -import { processCompositionAudio, type AudioProcessingFailure } from "@hyperframes/engine"; +import { + MIXED_AUDIO_FILENAME, + processCompositionAudio, + type AudioProcessingFailure, +} from "@hyperframes/engine"; import type { CompositionMetadata } from "../shared.js"; export interface AudioStageInput { @@ -33,7 +39,7 @@ export interface AudioStageInput { } export interface AudioStageResult { - /** Always `join(workDir, "audio.aac")`. */ + /** Always `join(workDir, MIXED_AUDIO_FILENAME)`. */ audioOutputPath: string; /** True iff the audio mix actually produced a file. False when there are no audio elements. */ hasAudio: boolean; @@ -54,7 +60,7 @@ export async function runAudioStage(input: AudioStageInput): Promise ({ })); mock.module("@hyperframes/engine", () => ({ + MIXED_AUDIO_FILENAME: "audio.m4a", DEFAULT_CONFIG: { ffmpegEncodeTimeout: 600_000 }, encodeFramesChunkedConcat: encodeFramesChunkedConcatMock, encodeFramesFromDir: encodeFramesFromDirMock, diff --git a/packages/producer/src/services/render/stages/encodeStage.ts b/packages/producer/src/services/render/stages/encodeStage.ts index dcebb4a70..82b9b3859 100644 --- a/packages/producer/src/services/render/stages/encodeStage.ts +++ b/packages/producer/src/services/render/stages/encodeStage.ts @@ -3,7 +3,7 @@ * * 1. png-sequence: no encoder. Captured PNGs are renamed to * `frame_NNNNNN.png` and copied to `outputPath`. Audio (if any) is - * written as an `audio.aac` sidecar. + * written as a `MIXED_AUDIO_FILENAME` sidecar. * 2. gif: runs a two-pass FFmpeg palette encode and writes directly to * `outputPath`. GIF has no mux/faststart stage and ignores audio. * 3. mp4 / webm / mov: invokes `encodeFramesFromDir` (or the chunked- @@ -35,6 +35,7 @@ import { encodeFramesFromDir, formatFfmpegError, getEncoderPreset, + MIXED_AUDIO_FILENAME, resolveConfig, runFfmpeg, type EngineConfig, @@ -246,8 +247,10 @@ export async function runEncodeStage(input: EncodeStageInput): Promise/audio.aac` was produced. */ + /** Whether the plan's mixed-audio artifact was produced. */ hasAudio: boolean; } diff --git a/packages/producer/src/services/renderOrchestrator.ts b/packages/producer/src/services/renderOrchestrator.ts index 69100e1e1..e5d67c6d0 100644 --- a/packages/producer/src/services/renderOrchestrator.ts +++ b/packages/producer/src/services/renderOrchestrator.ts @@ -296,7 +296,7 @@ export interface RenderConfig { * stream; transparency is binary because GIF has no partial alpha. * - `"png-sequence"`: a directory of zero-padded RGBA PNGs * (`frame_000001.png` …). Lossless alpha, largest on disk, no muxed - * audio (an `audio.aac` sidecar is written alongside the PNGs when + * audio (an `audio.m4a` sidecar is written alongside the PNGs when * the composition has audio elements). Use for After Effects / Nuke * / Fusion ingest, or when frames need post-processing before * encoding. `outputPath` is treated as a directory; it is created if diff --git a/packages/producer/tests/missing-host-comp-id/output/output.mp4 b/packages/producer/tests/missing-host-comp-id/output/output.mp4 index 96ccafea3..14901cb36 100644 --- a/packages/producer/tests/missing-host-comp-id/output/output.mp4 +++ b/packages/producer/tests/missing-host-comp-id/output/output.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a84e50ad31694432affb1bffd42bde653514662ccb1dbb8172b0eab2d80147d5 -size 129943 +oid sha256:61cc9a67d53cfe17fd701f716347567be875f53a71fd3a6df9bef9c306937ace +size 130189 diff --git a/packages/producer/tests/variables-prod/output/output.mp4 b/packages/producer/tests/variables-prod/output/output.mp4 index c7bca5a9f..5ba10cd2b 100644 --- a/packages/producer/tests/variables-prod/output/output.mp4 +++ b/packages/producer/tests/variables-prod/output/output.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:694ae6f83914f3c51161804ffdb51acddc02bb523a0fa877f8bff667bfa0b5e9 -size 117758 +oid sha256:8661c4f42b2ec4315180d51a498b199f90e8234983e27b4c572f93e12ca44992 +size 116892