ingest.js 5.82 KB
// 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);
    });
}