85453da49f
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled
192 lines
5.7 KiB
JavaScript
192 lines
5.7 KiB
JavaScript
import {
|
|
readFileSync,
|
|
appendFileSync,
|
|
mkdirSync,
|
|
existsSync,
|
|
readdirSync,
|
|
openSync,
|
|
closeSync,
|
|
writeFileSync,
|
|
rmSync,
|
|
statSync,
|
|
} from "node:fs";
|
|
import { join } from "node:path";
|
|
|
|
const MANIFEST_FILE = "manifest.jsonl";
|
|
const INDEX_FILE = "index.md";
|
|
|
|
const TYPE_DIRS = {
|
|
bgm: "audio/bgm",
|
|
sfx: "audio/sfx",
|
|
voice: "audio/voice",
|
|
image: "images",
|
|
icon: "images",
|
|
logo: "images",
|
|
brand: "images",
|
|
video: "video",
|
|
grade: "luts",
|
|
lut: "luts",
|
|
};
|
|
|
|
export function mediaDir(projectDir) {
|
|
return join(projectDir, ".media");
|
|
}
|
|
|
|
export function manifestPath(projectDir) {
|
|
return join(mediaDir(projectDir), MANIFEST_FILE);
|
|
}
|
|
|
|
export function indexPath(projectDir) {
|
|
return join(mediaDir(projectDir), INDEX_FILE);
|
|
}
|
|
|
|
export function typeSubdir(type) {
|
|
const sub = TYPE_DIRS[type];
|
|
if (!sub) throw new Error(`unknown media type: ${type}`);
|
|
return sub;
|
|
}
|
|
|
|
export function typeDirPath(projectDir, type) {
|
|
return join(mediaDir(projectDir), typeSubdir(type));
|
|
}
|
|
|
|
export function readManifest(projectDir) {
|
|
const p = manifestPath(projectDir);
|
|
if (!existsSync(p)) return [];
|
|
const raw = readFileSync(p, "utf8");
|
|
const records = [];
|
|
for (const line of raw.split(/\r?\n/)) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed) continue;
|
|
try {
|
|
records.push(JSON.parse(trimmed));
|
|
} catch {
|
|
// ponytail: skip malformed lines, don't crash
|
|
}
|
|
}
|
|
return records;
|
|
}
|
|
|
|
export function appendRecord(projectDir, record) {
|
|
const dir = mediaDir(projectDir);
|
|
mkdirSync(dir, { recursive: true });
|
|
const typeDir = typeDirPath(projectDir, record.type);
|
|
mkdirSync(typeDir, { recursive: true });
|
|
|
|
const p = manifestPath(projectDir);
|
|
const line = JSON.stringify(record) + "\n";
|
|
appendFileSync(p, line);
|
|
}
|
|
|
|
// Match prompts forgivingly. Agents rarely re-emit a byte-identical intent, so
|
|
// keying cache lookups on exact equality meant "Calm piano" and "calm piano"
|
|
// re-searched and re-downloaded. Normalize (trim, lowercase, collapse internal
|
|
// whitespace) on both sides; the raw prompt is still stored for audit.
|
|
export function normalizePrompt(prompt) {
|
|
return String(prompt ?? "")
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/\s+/g, " ");
|
|
}
|
|
|
|
export function findByPrompt(projectDir, prompt, type) {
|
|
const key = normalizePrompt(prompt);
|
|
if (!key) return null;
|
|
const records = readManifest(projectDir);
|
|
return (
|
|
records.find(
|
|
(r) => normalizePrompt(r.provenance?.prompt) === key && (type == null || r.type === type),
|
|
) || null
|
|
);
|
|
}
|
|
|
|
export function findByEntity(projectDir, entity) {
|
|
const lower = entity.toLowerCase();
|
|
const records = readManifest(projectDir);
|
|
return records.find((r) => r.entity && r.entity.toLowerCase() === lower) || null;
|
|
}
|
|
|
|
export function nextId(projectDir, type) {
|
|
const records = readManifest(projectDir);
|
|
const prefix = type;
|
|
let max = 0;
|
|
for (const r of records) {
|
|
if (r.type !== type) continue;
|
|
const m = r.id?.match(new RegExp(`^${prefix}_(\\d+)$`));
|
|
if (m) max = Math.max(max, parseInt(m[1], 10));
|
|
}
|
|
return `${prefix}_${String(max + 1).padStart(3, "0")}`;
|
|
}
|
|
|
|
// Sync sleep (no busy-spin) for the allocation lock retry.
|
|
function sleepMs(ms) {
|
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
}
|
|
|
|
// Coarse per-project lock so concurrent resolves don't race on id allocation.
|
|
// ponytail: one lock file with a 15s stale-steal (a crashed holder can't wedge
|
|
// the project); fine for agent-scale concurrency — revisit if throughput needs
|
|
// finer locking. Date.now() is available here (a normal Node CLI, not a
|
|
// workflow DSL), so mtime-based staleness is safe.
|
|
const LOCK_STALE_MS = 15000;
|
|
const LOCK_TIMEOUT_MS = 20000;
|
|
|
|
function withLock(dir, fn) {
|
|
const lock = join(dir, ".lock");
|
|
const start = Date.now();
|
|
for (;;) {
|
|
try {
|
|
closeSync(openSync(lock, "wx")); // O_EXCL: atomic acquire
|
|
break;
|
|
} catch (err) {
|
|
if (err.code !== "EEXIST") throw err;
|
|
try {
|
|
if (Date.now() - statSync(lock).mtimeMs > LOCK_STALE_MS) {
|
|
rmSync(lock, { force: true }); // steal a stale lock from a dead holder
|
|
continue;
|
|
}
|
|
} catch {
|
|
continue; // lock vanished between check and stat — retry the acquire
|
|
}
|
|
if (Date.now() - start > LOCK_TIMEOUT_MS) {
|
|
throw new Error("media-use: timed out acquiring .media/.lock");
|
|
}
|
|
sleepMs(25);
|
|
}
|
|
}
|
|
try {
|
|
return fn();
|
|
} finally {
|
|
rmSync(lock, { force: true });
|
|
}
|
|
}
|
|
|
|
// Atomically allocate the next free id for `type` AND reserve its file, so a
|
|
// slow download/copy between allocation and appendRecord can't let a concurrent
|
|
// caller grab the same id (the MU-23 clobber). Under the lock we take the max id
|
|
// across BOTH the manifest and any already-reserved files in the type dir, then
|
|
// O_EXCL-create an empty placeholder at the target path; freeze/copy overwrites
|
|
// it. Returns { id, localPath }.
|
|
export function allocateId(projectDir, type, ext) {
|
|
mkdirSync(mediaDir(projectDir), { recursive: true });
|
|
const typeDir = typeDirPath(projectDir, type);
|
|
mkdirSync(typeDir, { recursive: true });
|
|
return withLock(mediaDir(projectDir), () => {
|
|
const re = new RegExp(`^${type}_(\\d+)`);
|
|
let max = 0;
|
|
for (const r of readManifest(projectDir)) {
|
|
if (r.type !== type) continue;
|
|
const m = r.id?.match(re);
|
|
if (m) max = Math.max(max, parseInt(m[1], 10));
|
|
}
|
|
for (const f of readdirSync(typeDir)) {
|
|
const m = f.match(re);
|
|
if (m) max = Math.max(max, parseInt(m[1], 10)); // skip ids reserved but not yet appended
|
|
}
|
|
const id = `${type}_${String(max + 1).padStart(3, "0")}`;
|
|
const localPath = `.media/${typeSubdir(type)}/${id}${ext}`;
|
|
writeFileSync(join(projectDir, localPath), "", { flag: "wx" }); // durable reservation
|
|
return { id, localPath };
|
|
});
|
|
}
|