Skill Tracer
Skills › HyperFrames › media-use › media-record.mjs

media-record.mjs

118 lines · skills/media-use/audio/scripts/lib/media-record.mjs at a595755

1import { existsSync } from "node:fs";
2import { extname, join } from "node:path";
3import { AGENT_SOURCES, latestRecordFor, recordInPlace } from "../../../scripts/lib/manifest.mjs";
4import { regenerateIndex } from "../../../scripts/lib/index-gen.mjs";
5
6/**
7 * Where the engine may write `rel`: there, unless another file of this run has `taken` it or the file there is
8 * the person's (not `reusable` and not recorded as agent-made). Then the first free `name-2.ext`, with an anomaly.
9 */
10export function agentWritePath(
11 hyperframesDir,
12 rel,
13 { anomalies, taken = new Set(), reusable = () => false },
14) {
15 const free = (path) =>
16 !taken.has(path) &&
17 (!existsSync(join(hyperframesDir, path)) ||
18 reusable(path) ||
19 AGENT_SOURCES.includes(latestRecordFor(hyperframesDir, path)?.source));
20 if (free(rel)) return rel;
21 const ext = extname(rel);
22 const stem = rel.slice(0, rel.length - ext.length);
23 let n = 2;
24 while (!free(`${stem}-${n}${ext}`)) n++;
25 const path = `${stem}-${n}${ext}`;
26 const why = taken.has(rel)
27 ? "another file of this run goes there"
28 : "the file there is yours (the media manifest does not record it as made by the engine)";
29 const note = `${rel}: kept, because ${why}; writing ${path} instead (audio_meta.json has the path used)`;
30 if (!anomalies.includes(note)) anomalies.push(note);
31 return path;
34/** Each spoken line's file, picked before lines synthesize concurrently so two never land on one free name. */
35export function voicePaths(hyperframesDir, lines, anomalies) {
36 const taken = new Set();
37 const paths = new Map();
38 for (const line of lines.filter((l) => String(l.text ?? "").trim())) {
39 const rel = agentWritePath(hyperframesDir, `assets/voice/${line.id}.wav`, { anomalies, taken });
40 taken.add(rel);
41 paths.set(String(line.id), rel);
42 }
43 return paths;
46// A cue's source as the manifest records it, and who provided it. A source missing here records as the person's
47// file, so every rerun would write a numbered copy beside it.
48const SFX_SOURCES = {
49 openverse: { source: "search", provider: "openverse.audio" },
50 local: { source: "bundled", provider: "bundled.sfx" },
51};
53// The files one engine run wrote, each with how it was made, for the project's media manifest.
54export function writtenAssets({ only, lines, voices, ttsProvider, bgm, bgmFields, sfx }) {
55 const textById = new Map(lines.map((line) => [String(line.id), String(line.text ?? "").trim()]));
56 const assets = [];
57 if (only.has("tts")) {
58 for (const voice of voices) {
59 assets.push({
60 path: voice.path,
61 type: "voice",
62 source: "generated",
63 intent: textById.get(voice.id),
64 duration: voice.duration_s,
65 provider: ttsProvider,
66 });
67 }
68 }
69 if (only.has("bgm") && bgm && !bgmFields.bgm_pending) {
70 assets.push({
71 path: bgm.path,
72 type: "bgm",
73 source: bgmFields.bgm_mode === "retrieve" ? "search" : "generated",
74 intent: bgm.query,
75 duration: bgm.duration_s,
76 provider: bgmFields.bgm_provider,
77 });
78 }
79 if (only.has("sfx")) {
80 for (const cue of new Map(sfx.map((entry) => [entry.file, entry])).values()) {
81 const { source, provider } = SFX_SOURCES[cue.source];
82 assets.push({
83 path: cue.file,
84 type: "sfx",
85 source,
86 intent: cue.name,
87 duration: cue.duration_s,
88 provider,
89 });
90 }
91 }
92 return assets;
95/** Records each asset where it lies; returns one anomaly per file left unrecorded. */
96export function recordInManifest(hyperframesDir, assets) {
97 const anomalies = [];
98 for (const { path, type, source, intent, duration, provider } of assets) {
99 try {
100 recordInPlace(hyperframesDir, {
101 type,
102 path,
103 source,
104 description: intent,
105 duration,
106 provenance: { provider: provider || "local", ...(intent && { prompt: intent }) },
107 });
108 } catch (error) {
109 anomalies.push(`${path}: not recorded in the media manifest (${error.message})`);
110 }
111 }
112 try {
113 if (anomalies.length < assets.length) regenerateIndex(hyperframesDir);
114 } catch (error) {
115 anomalies.push(`.media/index.md: not refreshed (${error.message})`);
116 }
117 return anomalies;