// Pulls the H3 Console's video library and rewrites library.json with a // short display prompt rule-extracted from each job's long structured prompt. // Rerun any time to refresh (rebuilds the file fresh — library is small, ~200 items). // See H3-CONSOLE.md for the API this talks to. const fs = require("fs"); const path = require("path"); try { process.loadEnvFile(path.join(__dirname, ".env")); } catch {} const BASE = process.env.H3_CONSOLE_BASE; const TOKEN = process.env.H3_CONSOLE_TOKEN; const FPS = 24; // ponytail: regex extraction, not an LLM call (explicit decision — free, "good enough"). // Prefers the first shot's visual description; falls back to the summary // section, then to a flat truncation of the raw prompt. function extractShortPrompt(fullPrompt) { const clean = (s) => s .replace(//g, "") .replace(//g, "角色$1") .replace(/\s+/g, " ") .trim(); // Many clips are generic "speaking-performance loop" templates that only // differ by this tagged state (e.g. "gentle_but_alert") — pull it out so // near-identical summaries stay distinguishable. const stateMatch = fullPrompt.match(/expression state "([a-zA-Z0-9_ ]+)"/); const state = stateMatch ? stateMatch[1] : null; const withState = (s) => (state && !s.includes(state) ? `${s}(${state})` : s); const shotMatch = fullPrompt.match(/detailed_description:\s*\n+\[Shot 1\]\s*([^.]+\.)/); if (shotMatch) return withState(clean(shotMatch[1])).slice(0, 120); const summaryMatch = fullPrompt.match(/summary:\s*\n+(?:\[[^\]]*\]\s*)?([^.]+\.)/); if (summaryMatch) return withState(clean(summaryMatch[1])).slice(0, 120); return clean(fullPrompt).slice(0, 120); } // Infra/ops smoke-test jobs (ours and others') that occasionally land in the // same H3 console history as real drama content — keep them out of the // library rather than maintaining a per-job-id exclude list. // Real drama prompts are consistently long (150+ chars, usually much more — // structured multi-section H3 prompts). Test jobs are short one-liners that // mention "test" somewhere ("test verify", "cancel test - a bird...", // "final flip test") — five slipped past the old prefix-only pattern before // being caught by hand, so this is now a length-gated word-boundary check // instead of trying to enumerate every phrasing. function isTestJob(fullPrompt) { return fullPrompt.length < 150 && /\btest(ing|ed)?\b/i.test(fullPrompt); } // Fetched, not hardcoded to "all of it" — the console's job count grows // over time (production keeps generating), so this needs headroom rather // than an exact count. Merge logic below is also defense-in-depth: even if // this ever undercounts again, an entry just falls back to "carried over // unchanged" instead of vanishing (see the 200->384 incident in git history // of this comment, effectively: a hardcoded limit=200 silently dropped any // job older than the newest 200 from the library on every sync). const LIST_FETCH_LIMIT = 5000; // Rebuilds library.json from the H3 Console's current job list. Exported so // server.js can call it on startup + a periodic timer (auto-sync), in // addition to running this file directly as a one-off CLI script. async function syncLibrary() { if (!BASE || !TOKEN) throw new Error("H3_CONSOLE_BASE / H3_CONSOLE_TOKEN missing from .env"); const res = await fetch(`${BASE}/v1/videos?limit=${LIST_FETCH_LIMIT}&sort=desc`, { headers: { Authorization: `Bearer ${TOKEN}` }, }); if (!res.ok) throw new Error(`H3 console list failed: HTTP ${res.status}`); const { jobs } = await res.json(); const libraryPath = path.join(__dirname, "library.json"); let oldLibrary = []; try { oldLibrary = JSON.parse(fs.readFileSync(libraryPath, "utf8")); } catch {} const oldById = new Map(oldLibrary.map((c) => [c.id, c])); const seenIds = new Set(); const upserted = jobs .filter((j) => j.status === "done" && j.download && !isTestJob(j.req.prompt)) .map((j) => { seenIds.add(j.id); const old = oldById.get(j.id); // Once an entry has been touched in admin.html, it's carried forward // as-is (prompt/fullPrompt/videoUrl overrides included) instead of // being rebuilt from the H3 job on every sync. if (old && old.edited) return old; // playCount/lastPlayedAt only exist once a clip has actually been // played (markPlayed() in server.js), so only carry them forward when // the old record had them — otherwise every 5-min sync silently reset // a clip's play stats back to "never played". const playStats = {}; if (old && old.lastPlayedAt !== undefined) playStats.lastPlayedAt = old.lastPlayedAt; if (old && old.playCount !== undefined) playStats.playCount = old.playCount; return { id: j.id, source: "h3console", prompt: extractShortPrompt(j.req.prompt), fullPrompt: j.req.prompt, edited: false, width: j.req.width, height: j.req.height, duration: Math.round((j.req.frames / FPS) * 10) / 10, partition: j.partition, likes: (old && old.likes) || 0, createdAt: Math.round(j.created_at * 1000), ...playStats, }; }); // Anything we already had that this fetch didn't return (fell outside the // window, transient API hiccup, manual entries which are never backed by // a job at all) is kept as-is — sync only ever adds/refreshes, never drops. const carriedOver = oldLibrary.filter((c) => !seenIds.has(c.id)); const library = [...upserted, ...carriedOver]; fs.writeFileSync(libraryPath, JSON.stringify(library, null, 2)); return library.length; } module.exports = { syncLibrary }; if (require.main === module) { syncLibrary() .then((n) => console.log(`wrote library.json — ${n} clips`)) .catch((err) => { console.error(err.message); process.exit(1); }); }