mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(engine): isolate WAV staging in a private directory (#3709)
* fix(engine): create WAV staging files exclusively * fix(engine): isolate WAV staging in a private directory
This commit is contained in:
@@ -1,11 +1,22 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import * as fs from "fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { getFfmpegBinary } from "../utils/ffmpegBinaries.js";
|
||||
import { applyVolumeEnvelopeToWav } from "./audioVolumeEnvelope.js";
|
||||
|
||||
vi.mock("fs", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:fs")>();
|
||||
return {
|
||||
...actual,
|
||||
writeFileSync: vi.fn(actual.writeFileSync),
|
||||
renameSync: vi.fn(actual.renameSync),
|
||||
rmSync: vi.fn(actual.rmSync),
|
||||
};
|
||||
});
|
||||
|
||||
const SAMPLE_RATE = 48000;
|
||||
const CHANNELS = 2;
|
||||
const HAS_FFMPEG = spawnSync(getFfmpegBinary(), ["-version"], { encoding: "utf-8" }).status === 0;
|
||||
@@ -48,6 +59,53 @@ describe("applyVolumeEnvelopeToWav", () => {
|
||||
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it.each(["write", "rename"])(
|
||||
"preserves the WAV and removes staging after a %s failure",
|
||||
async (stage) => {
|
||||
const dir = tmp();
|
||||
const path = join(dir, "failure.wav");
|
||||
writeConstantWav(path, 16, 10000);
|
||||
const original = readFileSync(path);
|
||||
const actual = await vi.importActual<typeof import("node:fs")>("fs");
|
||||
if (stage === "write") {
|
||||
vi.mocked(fs.writeFileSync).mockImplementationOnce((...args) => {
|
||||
// Simulate a write that leaves a partial staging file before failing.
|
||||
Reflect.apply(actual.writeFileSync, actual, args);
|
||||
throw new Error("injected write failure");
|
||||
});
|
||||
} else {
|
||||
vi.mocked(fs.renameSync).mockImplementationOnce(() => {
|
||||
throw new Error("injected rename failure");
|
||||
});
|
||||
}
|
||||
expect(applyVolumeEnvelopeToWav(path, [{ time: 0, volume: 0 }], 0, 0)).toBe(false);
|
||||
expect(readFileSync(path)).toEqual(original);
|
||||
expect(readdirSync(dir)).toEqual(["failure.wav"]);
|
||||
},
|
||||
);
|
||||
|
||||
it("uses a private sibling directory and preserves success if cleanup fails", async () => {
|
||||
const dir = tmp();
|
||||
const path = join(dir, "private.wav");
|
||||
writeConstantWav(path, 16, 10000);
|
||||
const actual = await vi.importActual<typeof import("node:fs")>("fs");
|
||||
let stagingDir = "";
|
||||
vi.mocked(fs.writeFileSync).mockImplementationOnce((...args) => {
|
||||
stagingDir = dirname(String(args[0]));
|
||||
expect(dirname(stagingDir)).toBe(dir);
|
||||
expect(stagingDir).not.toBe(dir);
|
||||
if (process.platform !== "win32")
|
||||
expect(actual.statSync(stagingDir).mode & 0o777).toBe(0o700);
|
||||
Reflect.apply(actual.writeFileSync, actual, args);
|
||||
});
|
||||
vi.mocked(fs.rmSync).mockImplementationOnce(() => {
|
||||
throw new Error("injected cleanup failure");
|
||||
});
|
||||
expect(applyVolumeEnvelopeToWav(path, [{ time: 0, volume: 0 }], 0, 0)).toBe(true);
|
||||
expect(sampleAt(path, 0)).toBe(0);
|
||||
expect(readdirSync(stagingDir)).toEqual([]);
|
||||
});
|
||||
|
||||
it("applies a linear fade sample-accurately", () => {
|
||||
const path = join(tmp(), "a.wav");
|
||||
const frames = SAMPLE_RATE; // 1 second
|
||||
@@ -64,6 +122,7 @@ describe("applyVolumeEnvelopeToWav", () => {
|
||||
0,
|
||||
);
|
||||
expect(applied).toBe(true);
|
||||
expect(readdirSync(dirname(path))).toEqual(["a.wav"]);
|
||||
|
||||
expect(sampleAt(path, 0)).toBe(0); // gain 0
|
||||
expect(sampleAt(path, frames / 2)).toBeCloseTo(5000, -2); // gain ~0.5
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
* the caller can fall back to the expression path rather than corrupting audio.
|
||||
*/
|
||||
|
||||
import { readFileSync, renameSync, writeFileSync } from "fs";
|
||||
import { randomBytes } from "crypto";
|
||||
import { mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "fs";
|
||||
import { dirname, join } from "path";
|
||||
import type { AudioVolumeKeyframe } from "./audioMixer.types.js";
|
||||
import { normaliseEnvelope } from "@hyperframes/core/media-volume-envelope";
|
||||
import { riffChunks } from "./wavChunks.js";
|
||||
@@ -166,6 +166,7 @@ export function applyVolumeEnvelopeToWav(
|
||||
const gainAt = createEnvelopeWalker(keyframes, trackStart, baseVolume);
|
||||
if (!gainAt) return false;
|
||||
|
||||
let stagingDir: string | undefined;
|
||||
try {
|
||||
const buffer = readFileSync(wavPath);
|
||||
const layout = parseWavLayout(buffer);
|
||||
@@ -173,17 +174,24 @@ export function applyVolumeEnvelopeToWav(
|
||||
|
||||
scaleSamples(buffer, layout, gainAt);
|
||||
|
||||
// Write to a uniquely-named sibling then atomically rename over the
|
||||
// original. The random name avoids following a pre-planted symlink at a
|
||||
// predictable path, and the rename means a crash mid-write can't leave a
|
||||
// truncated WAV for the downstream mix.
|
||||
const tempPath = `${wavPath}.${randomBytes(6).toString("hex")}.tmp`;
|
||||
writeFileSync(tempPath, buffer);
|
||||
// A private sibling directory owns the staging file; the same-filesystem
|
||||
// rename keeps a failed write from truncating the original WAV.
|
||||
stagingDir = mkdtempSync(join(dirname(wavPath), ".hf-volume-"));
|
||||
const tempPath = join(stagingDir, "audio.wav");
|
||||
writeFileSync(tempPath, buffer, { flag: "wx" });
|
||||
renameSync(tempPath, wavPath);
|
||||
return true;
|
||||
} catch {
|
||||
// Any read/parse/write failure → leave the file untouched and let the
|
||||
// caller fall back to the ffmpeg expression path rather than losing audio.
|
||||
return false;
|
||||
} finally {
|
||||
if (stagingDir) {
|
||||
try {
|
||||
rmSync(stagingDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Cleanup must not turn a successfully baked envelope into a fallback.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user