Setting the file. One moment. Manifest · Media Use · heygen-com/hyperframes · Skills Docs⋯
scripts/11 files
123
function sleepMs
— line 123
This file
- Number
- 27.20
- Position
- 20 of 78
- Type
- JavaScript
- Size
- 7 KB
- Lines
- 229
scripts/lib/manifest.mjs
JavaScript·229 lines·7 KB
15const MANIFEST_FILE = "manifest.jsonl";
16const INDEX_FILE = "index.md";
17
18const TYPE_DIRS = {
19 bgm: "audio/bgm",
20 sfx: "audio/sfx",
21 voice: "audio/voice",
22 image: "images",
23 icon: "images",
24 logo: "images",
25 brand: "images",
26 video: "video",
27 grade: "luts",
28 lut: "luts",
29 recipe: "recipes",
30};
31
32export function mediaDir(projectDir) {
33 return join(projectDir, ".media");
34}
35
36export function manifestPath(projectDir) {
37 return join(mediaDir(projectDir), MANIFEST_FILE);
38}
39
40export function indexPath(projectDir) {
41 return join(mediaDir(projectDir), INDEX_FILE);
42}
43
44export function typeSubdir(type) {
45 const sub = TYPE_DIRS[type];
46 if (!sub) throw new Error(`unknown media type: ${type}`);
47 return sub;
48}
49
50export function typeDirPath(projectDir, type) {
51 return join(mediaDir(projectDir), typeSubdir(type));
52}
53
54export function readManifest(projectDir) {
55 const p = manifestPath(projectDir);
56 if (!existsSync(p)) return [];
57 const raw = readFileSync(p, "utf8");
58 const records = [];
59 for (const line of raw.split(/\r?\n/)) {
60 const trimmed = line.trim();
61 if (!trimmed) continue;
62 try {
63 records.push(JSON.parse(trimmed));
64 } catch {
65 // ponytail: skip malformed lines, don't crash
66 }
67 }
68 return records;
69}
70
71export function appendRecord(projectDir, record) {
72 const dir = mediaDir(projectDir);
73 mkdirSync(dir, { recursive: true });
74 const typeDir = typeDirPath(projectDir, record.type);
75 mkdirSync(typeDir, { recursive: true });
76
77 const p = manifestPath(projectDir);
78 const line = JSON.stringify(record) + "\n";
79 appendFileSync(p, line);
80}
81
82// Match prompts forgivingly. Agents rarely re-emit a byte-identical intent, so
83// keying cache lookups on exact equality meant "Calm piano" and "calm piano"
84// re-searched and re-downloaded. Normalize (trim, lowercase, collapse internal
85// whitespace) on both sides; the raw prompt is still stored for audit.
86export function normalizePrompt(prompt) {
87 return String(prompt ?? "")
88 .trim()
89 .toLowerCase()
90 .replace(/\s+/g, " ");
91}
92
93export function findByPrompt(projectDir, prompt, type) {
94 const key = normalizePrompt(prompt);
95 if (!key) return null;
96 const records = readManifest(projectDir);
97 return (
98 records.find(
99 (r) => normalizePrompt(r.provenance?.prompt) === key && (type == null || r.type === type),
100 ) || null
101 );
102}
103
104export function findByEntity(projectDir, entity) {
105 const lower = entity.toLowerCase();
106 const records = readManifest(projectDir);
107 return records.find((r) => r.entity && r.entity.toLowerCase() === lower) || null;
108}
109
110export function nextId(projectDir, type) {
111 const records = readManifest(projectDir);
112 const prefix = type;
113 let max = 0;
114 for (const r of records) {
115 if (r.type !== type) continue;
116 const m = r.id?.match(new RegExp(`^${prefix}_(\\d+)$`));
117 if (m) max = Math.max(max, parseInt(m[1], 10));
118 }
119 return `${prefix}_${String(max + 1).padStart(3, "0")}`;
120}
121
122// Sync sleep (no busy-spin) for the allocation lock retry.
123function sleepMs(ms) {
124 Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
125}
126
127// Coarse per-project lock so concurrent resolves don't race on id allocation.
128// ponytail: one lock file with a 15s stale-steal (a crashed holder can't wedge
129// the project); fine for agent-scale concurrency — revisit if throughput needs
130// finer locking. Date.now() is available here (a normal Node CLI, not a
131// workflow DSL), so mtime-based staleness is safe.
132const LOCK_STALE_MS = 15000;
133const LOCK_TIMEOUT_MS = 20000;
134
135function withLock(dir, fn) {
136 const lock = join(dir, ".lock");
137 const start = Date.now();
138 for (;;) {
139 try {
140 closeSync(openSync(lock, "wx")); // O_EXCL: atomic acquire
141 break;
142 } catch (err) {
143 if (err.code !== "EEXIST") throw err;
144 try {
145 if (Date.now() - statSync(lock).mtimeMs > LOCK_STALE_MS) {
146 rmSync(lock, { force: true }); // steal a stale lock from a dead holder
147 continue;
148 }
149 } catch {
150 continue; // lock vanished between check and stat — retry the acquire
151 }
152 if (Date.now() - start > LOCK_TIMEOUT_MS) {
153 throw new Error("media-use: timed out acquiring .media/.lock");
154 }
155 sleepMs(25);
156 }
157 }
158 try {
159 return fn();
160 } finally {
161 rmSync(lock, { force: true });
162 }
163}
164
165// Atomically allocate the next free id for `type` AND reserve its file, so a
166// slow download/copy between allocation and appendRecord can't let a concurrent
167// caller grab the same id (the MU-23 clobber). Under the lock we take the max id
168// across BOTH the manifest and any already-reserved files in the type dir, then
169// O_EXCL-create an empty placeholder at the target path; freeze/copy overwrites
170// it. Returns { id, localPath }.
171export function allocateId(projectDir, type, ext) {
172 mkdirSync(mediaDir(projectDir), { recursive: true });
173 const typeDir = typeDirPath(projectDir, type);
174 mkdirSync(typeDir, { recursive: true });
175 return withLock(mediaDir(projectDir), () => {
176 const re = new RegExp(`^${type}_(\\d+)`);
177 let max = 0;
178 for (const r of readManifest(projectDir)) {
179 if (r.type !== type) continue;
180 const m = r.id?.match(re);
181 if (m) max = Math.max(max, parseInt(m[1], 10));
182 }
183 for (const f of readdirSync(typeDir)) {
184 const m = f.match(re);
185 if (m) max = Math.max(max, parseInt(m[1], 10)); // skip ids reserved but not yet appended
186 }
187 const id = `${type}_${String(max + 1).padStart(3, "0")}`;
188 const localPath = `.media/${typeSubdir(type)}/${id}${ext}`;
189 writeFileSync(join(projectDir, localPath), "", { flag: "wx" }); // durable reservation
190 return { id, localPath };
191 });
192}
193
194function reservedFile(projectDir, type, ext) {
195 const allocation = allocateId(projectDir, type, ext);
196 return { ...allocation, fullPath: join(projectDir, allocation.localPath) };
197}
198
199function rollbackReservation(reservation) {
200 rmSync(reservation.fullPath, { force: true });
201}
202
203// A reservation is committed only when populate returns a non-null value.
204// Throwing/rejecting or returning null means no usable asset was produced, so
205// the placeholder must be released. Keeping this transaction beside allocateId
206// prevents individual provider/cache/LUT paths from forgetting the rollback.
207export function withReservedFileSync(projectDir, type, ext, populate) {
208 const reservation = reservedFile(projectDir, type, ext);
209 try {
210 const result = populate(reservation);
211 if (result == null) rollbackReservation(reservation);
212 return result;
213 } catch (error) {
214 rollbackReservation(reservation);
215 throw error;
216 }
217}
218
219export async function withReservedFile(projectDir, type, ext, populate) {
220 const reservation = reservedFile(projectDir, type, ext);
221 try {
222 const result = await populate(reservation);
223 if (result == null) rollbackReservation(reservation);
224 return result;
225 } catch (error) {
226 rollbackReservation(reservation);
227 throw error;
228 }
229}