diff --git a/skills-manifest.json b/skills-manifest.json index 11e90f1fa4..ceab4acf60 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -50,8 +50,8 @@ "files": 12 }, "media-use": { - "hash": "e870f18213f27c8b", - "files": 154 + "hash": "a397849a32047f7e", + "files": 156 }, "motion-graphics": { "hash": "853ac75cbab69036", diff --git a/skills/media-use/audio/scripts/audio.mjs b/skills/media-use/audio/scripts/audio.mjs index bc005eeff9..167eb679ca 100644 --- a/skills/media-use/audio/scripts/audio.mjs +++ b/skills/media-use/audio/scripts/audio.mjs @@ -39,7 +39,7 @@ // the generate path it is spawned detached (bgm_pending:true) — run wait-bgm.mjs // before assembling. -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { heygenAuthHeaders, heygenCredential, loadEnvFromDir } from "./lib/heygen.mjs"; @@ -54,6 +54,7 @@ import { import { generateBgmDetached, inferBgmPrompt, retrieveBgm } from "./lib/bgm.mjs"; import { resolveSfx } from "./lib/sfx.mjs"; import { mapWithConcurrency } from "./lib/concurrency.mjs"; +import { openAudioMeta } from "./lib/audio-meta.mjs"; const HERE = dirname(fileURLToPath(import.meta.url)); const argv = process.argv.slice(2); @@ -115,7 +116,8 @@ const heygenOK = heygenCredential() !== null; const headers = heygenOK ? heygenAuthHeaders() : null; // ── merge base: preserve sections not selected by --only ────────────────────── -const prev = existsSync(outPath) ? JSON.parse(readFileSync(outPath, "utf8")) : {}; +const audioMeta = openAudioMeta(outPath); +const prev = audioMeta.value; const anomalies = []; // ── TTS ─────────────────────────────────────────────────────────────────────── @@ -279,7 +281,7 @@ const meta = { total_duration_s: totalDuration, }; mkdirSync(dirname(outPath), { recursive: true }); -writeFileSync(outPath, JSON.stringify(meta, null, 2)); +audioMeta.write(meta); console.log(`✓ audio engine → ${outPath}`); console.log(` heygen: ${heygenOK ? "yes" : "no"} · ran: ${[...only].join(",")}`); diff --git a/skills/media-use/audio/scripts/lib/audio-meta.mjs b/skills/media-use/audio/scripts/lib/audio-meta.mjs new file mode 100644 index 0000000000..f3c032a9c4 --- /dev/null +++ b/skills/media-use/audio/scripts/lib/audio-meta.mjs @@ -0,0 +1,39 @@ +import { closeSync, ftruncateSync, openSync, readFileSync, writeSync } from "node:fs"; + +// Keep the merge base and output on the same file even if its pathname changes +// while audio generation runs. The CLI owns this handle until write or exit. +export function openAudioMeta(path) { + let fd; + try { + fd = openSync(path, "r+"); + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + let value = {}; + if (fd !== undefined) { + try { + value = JSON.parse(readFileSync(fd, "utf8")); + } catch (error) { + closeSync(fd); + throw error; + } + } + return { + value, + write(meta) { + const bytes = Buffer.from(JSON.stringify(meta, null, 2)); + // Defer new-file creation until generation succeeds. Never overwrite a + // file (or follow a link) that appeared since the missing merge base. + if (fd === undefined) fd = openSync(path, "wx"); + try { + let offset = 0; + while (offset < bytes.length) { + offset += writeSync(fd, bytes, offset, bytes.length - offset, offset); + } + ftruncateSync(fd, bytes.length); + } finally { + closeSync(fd); + } + }, + }; +} diff --git a/skills/media-use/audio/scripts/lib/audio-meta.test.mjs b/skills/media-use/audio/scripts/lib/audio-meta.test.mjs new file mode 100644 index 0000000000..2fa3a68807 --- /dev/null +++ b/skills/media-use/audio/scripts/lib/audio-meta.test.mjs @@ -0,0 +1,136 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + mkdtempSync, + readFileSync, + renameSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { openAudioMeta } from "./audio-meta.mjs"; + +function fixture(t) { + const dir = mkdtempSync(join(tmpdir(), "audio-meta-")); + t.after(() => rmSync(dir, { recursive: true, force: true })); + return dir; +} + +test("updates the read file from byte zero, truncates, and retains its mode", (t) => { + const path = join(fixture(t), "meta.json"); + writeFileSync(path, JSON.stringify({ voices: ["long existing voice metadata"] }), { + mode: 0o600, + }); + const originalMode = statSync(path).mode; + const handle = openAudioMeta(path); + assert.deepEqual(handle.value, { voices: ["long existing voice metadata"] }); + handle.write({ voices: ["声"] }); + assert.equal(readFileSync(path, "utf8"), JSON.stringify({ voices: ["声"] }, null, 2)); + assert.equal(statSync(path).mode, originalMode); +}); + +test("pathname replacement cannot redirect the existing-file write", (t) => { + const dir = fixture(t); + const path = join(dir, "meta.json"); + const moved = join(dir, "original.json"); + writeFileSync(path, "{}"); + const handle = openAudioMeta(path); + renameSync(path, moved); + writeFileSync(path, "replacement must survive"); + handle.write({ voices: [] }); + assert.equal(readFileSync(path, "utf8"), "replacement must survive"); + assert.deepEqual(JSON.parse(readFileSync(moved, "utf8")), { voices: [] }); +}); + +test("existing symlink outputs retain their original target even after retargeting", (t) => { + const dir = fixture(t); + const path = join(dir, "meta.json"); + const target = join(dir, "target.json"); + const victim = join(dir, "victim.json"); + writeFileSync(target, "{}"); + writeFileSync(victim, "untouched"); + symlinkSync(target, path); + const handle = openAudioMeta(path); + rmSync(path); + symlinkSync(victim, path); + handle.write({ bgm: null }); + assert.equal(readFileSync(victim, "utf8"), "untouched"); + assert.deepEqual(JSON.parse(readFileSync(target, "utf8")), { bgm: null }); +}); + +test("missing output is created only when saved", (t) => { + const path = join(fixture(t), "meta.json"); + const handle = openAudioMeta(path); + assert.deepEqual(handle.value, {}); + assert.throws(() => statSync(path), { code: "ENOENT" }); + handle.write({ sfx: [] }); + assert.deepEqual(JSON.parse(readFileSync(path, "utf8")), { sfx: [] }); +}); + +for (const replacement of ["file", "symlink"]) { + test(`new output does not overwrite a racing ${replacement}`, (t) => { + const dir = fixture(t); + const path = join(dir, "meta.json"); + const victim = join(dir, "victim.json"); + const handle = openAudioMeta(path); + writeFileSync(victim, "untouched"); + if (replacement === "symlink") symlinkSync(victim, path); + else writeFileSync(path, "untouched"); + assert.throws(() => handle.write({}), { code: "EEXIST" }); + assert.equal(readFileSync(path, "utf8"), "untouched"); + assert.equal(readFileSync(victim, "utf8"), "untouched"); + }); +} + +test("malformed merge base remains an error without changing bytes", (t) => { + const path = join(fixture(t), "meta.json"); + writeFileSync(path, "bad json"); + assert.throws(() => openAudioMeta(path), SyntaxError); + assert.equal(readFileSync(path, "utf8"), "bad json"); +}); + +test("CLI partial run retains unselected voices, BGM and SFX", (t) => { + const dir = fixture(t); + const path = join(dir, "meta.json"); + const request = join(dir, "request.json"); + const previous = { + voices: [{ id: "a", duration_s: 2 }], + bgm: { path: "bgm.wav" }, + sfx: [{ name: "click" }], + tts_provider: "kokoro", + voice_id: "voice-a", + }; + writeFileSync(path, JSON.stringify(previous)); + writeFileSync(request, "{}"); + const result = spawnSync( + process.execPath, + [ + fileURLToPath(new URL("../audio.mjs", import.meta.url)), + "--request", + request, + "--hyperframes", + dir, + "--out", + path, + "--only", + "none", + ], + { + encoding: "utf8", + env: { + ...process.env, + HEYGEN_CONFIG_DIR: dir, + HEYGEN_API_KEY: "", + HYPERFRAMES_API_KEY: "", + }, + }, + ); + assert.equal(result.status, 0, result.stderr); + const actual = JSON.parse(readFileSync(path, "utf8")); + for (const key of Object.keys(previous)) assert.deepEqual(actual[key], previous[key]); +});