ingest.js
5.82 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
129
130
131
132
133
134
135
136
// 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;
// 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);
});
}