ingest.js
5.32 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
// 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(/<Picture \d+>/g, "")
.replace(/<Subject (\d+)>/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;
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),
};
});
// 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);
});
}