mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
* fix(media-use): codex gate misfires as 'not logged in' when piped codexUnavailableReason() gated generation on parsing `codex login status` stdout, but that command prints 'Logged in using ChatGPT' to stderr and exits 0 — so the piped stdout media-use captures (execFileSync returns stdout only on success) was empty, and the gate falsely reported 'not logged in'. Every headless / CI / agent run was blocked from codex image gen even when fully authed. Gate on the durable credentials file ($CODEX_HOME/auth.json) instead of the TTY/stderr-only human text. Token validity is still proven by the exec, which fails cleanly on a stale login. The stdout `features list` capability check is unchanged. Verified: reproduced the false 'not logged in' block, then after the fix generated end-to-end via `resolve -t image --provider codex` (valid 1254x1254 PNG, source=generated, provider=codex.image_gen). * fix(media-use): bug-bash fixes — id race, provider/reuse/adopt guards From the bug-bash against main: - MU-23 (HIGH): concurrent resolves raced on nextId (read-max-then-append, non-atomic), so parallel agents got duplicate ids and clobbered each other's files. Add allocateId(): a coarse per-project lock (.media/.lock, 15s stale-steal) around id allocation that scans the manifest AND the type dir for reserved ids, then O_EXCL-creates a placeholder file so the slow download between allocate and append can't collide. 5 parallel resolves now yield 5 distinct ids + files. - X4: --reuse imported across a type mismatch (bgm asset under images/). Apply typesMatch on the --reuse path; reject mismatches (icon<->image still interchangeable). - X5: --provider silently overrode --local-only and made a network call. --local-only is now a hard guard: network providers are skipped even under a forced provider; the miss message explains the conflict. - BUG-2: --provider ignored the exact-cache floor and could hand back an asset from a different provider. A forced --provider now bypasses all reuse rungs (regenerate with THIS provider); the unforced floor is intact. - MU-26/X6: 0-byte assets accepted. --adopt skips 0-byte files (loud); ingest refuses a 0-byte local file (freezeUrl already rejects empty responses). - BUG-4: unknown/unavailable --provider now errors with the available list instead of a generic 'no provider could resolve' (typo != catalog miss). - BUG-5: --reuse "" gave the wrong 'type and intent required' error; it now routes to a clear empty-sha message. - BUG-3: voice duration leaked an unrounded float into index.md; round all durations to 0.1s centrally at record build (matches probe). - Nits: whitespace-only --intent is rejected; nudge grammar (exists/exist). Tests: allocateId reservation + registry local-only-wins added; full media-use suite green. All fixes verified e2e. * fix(cli): reject unknown flags instead of silently ignoring them citty is permissive: an unrecognized flag was dropped, not rejected — so `render . --out x` (the flag is --output/-o) silently ignored --out and rendered to the default renders/<name>.mp4 path. A mistyped flag read as a render/catalog miss. Add assertKnownFlags(): validate every dash-prefixed token against the command's declared args + aliases + the global set (help/version/json) before the command runs, in the shared trackCommandFailures run-wrapper so every leaf command is covered. Handles --flag=value, --no-<bool> negation, camelCase<->kebab arg names, and combined shorts; stops at --; positionals and flag values pass through. Verified: `render . --out x` -> 'Error: Unknown flag: --out'; --output/-o/ --json/--help still accepted. Unit tests added. * docs(skills): install with --full-depth so agents get current main The documented `npx skills add heygen-com/hyperframes` fetched the skills.sh registry blob, which lags GitHub main by hours — so users following the docs got a stale skill (e.g. media-use v1: no --candidates, voice stubbed). The CLI's own `hyperframes skills` command already forces a full clone via --full-depth to bypass this; the docs didn't pass it. Add --full-depth to every documented install command (README, CLAUDE.md, docs/guides/skills.mdx) with a one-line note on the lag. Addresses the user-facing half of the publish/registry lag (#2034). * chore(media-use): collapse resolve.mjs import to satisfy oxfmt --check * fix(cli): extract longFlagName to keep flag validator under complexity gate Also regenerate skills-manifest.json (resolve.mjs formatting change re-hashed the media-use skill). Fixes the Fallow audit + skills-manifest-in-sync CI gates.
129 lines
4.3 KiB
JavaScript
129 lines
4.3 KiB
JavaScript
import { readdirSync, statSync, existsSync } from "node:fs";
|
|
import { join, extname, basename } from "node:path";
|
|
import { readManifest, appendRecord, nextId } from "./manifest.mjs";
|
|
import { regenerateIndex } from "./index-gen.mjs";
|
|
import { probe } from "./probe.mjs";
|
|
import { matchTokens } from "./match.mjs";
|
|
|
|
const AUDIO_EXT = new Set([".mp3", ".wav", ".ogg", ".m4a", ".aac"]);
|
|
const IMAGE_EXT = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".ico"]);
|
|
const VIDEO_EXT = new Set([".mp4", ".webm", ".mov"]);
|
|
|
|
function inferType(filePath) {
|
|
const ext = extname(filePath).toLowerCase();
|
|
if (AUDIO_EXT.has(ext)) {
|
|
const lower = filePath.toLowerCase();
|
|
if (lower.includes("/bgm/") || lower.includes("/music/") || lower.startsWith("bgm/"))
|
|
return "bgm";
|
|
if (lower.includes("/sfx/") || lower.includes("/sound") || lower.startsWith("sfx/"))
|
|
return "sfx";
|
|
if (lower.includes("/voice/") || lower.includes("/narrat") || lower.startsWith("voice/"))
|
|
return "voice";
|
|
return "bgm";
|
|
}
|
|
if (IMAGE_EXT.has(ext)) {
|
|
if (ext === ".svg" || ext === ".ico") return "icon";
|
|
return "image";
|
|
}
|
|
if (VIDEO_EXT.has(ext)) return "video";
|
|
return null;
|
|
}
|
|
|
|
function walkDir(dir, base = "") {
|
|
const files = [];
|
|
if (!existsSync(dir)) return files;
|
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
const rel = base ? `${base}/${entry.name}` : entry.name;
|
|
if (entry.isDirectory()) {
|
|
files.push(...walkDir(join(dir, entry.name), rel));
|
|
} else {
|
|
files.push(rel);
|
|
}
|
|
}
|
|
return files;
|
|
}
|
|
|
|
export function scanExistingAssets(projectDir) {
|
|
const assetsDir = join(projectDir, "assets");
|
|
if (!existsSync(assetsDir)) return [];
|
|
|
|
const files = walkDir(assetsDir);
|
|
const found = [];
|
|
for (const rel of files) {
|
|
const type = inferType(rel);
|
|
if (!type) continue;
|
|
const fullPath = join(assetsDir, rel);
|
|
const stat = statSync(fullPath);
|
|
if (stat.size === 0) {
|
|
// A 0-byte asset would register clean but fail at render — skip it loudly
|
|
// rather than adopt a broken file.
|
|
console.error(`media-use: skipping 0-byte asset assets/${rel}`);
|
|
continue;
|
|
}
|
|
const meta = probe(fullPath);
|
|
found.push({
|
|
relativePath: `assets/${rel}`,
|
|
type,
|
|
size: stat.size,
|
|
name: basename(rel, extname(rel)),
|
|
...meta,
|
|
});
|
|
}
|
|
return found;
|
|
}
|
|
|
|
export function adoptExistingAssets(projectDir) {
|
|
const existing = scanExistingAssets(projectDir);
|
|
if (existing.length === 0) return [];
|
|
|
|
const manifest = readManifest(projectDir);
|
|
const knownPaths = new Set(manifest.map((r) => r.path));
|
|
|
|
const adopted = [];
|
|
for (const asset of existing) {
|
|
if (knownPaths.has(asset.relativePath)) continue;
|
|
|
|
const id = nextId(projectDir, asset.type);
|
|
const record = {
|
|
id,
|
|
type: asset.type,
|
|
path: asset.relativePath,
|
|
source: "existing",
|
|
description: asset.name.replace(/[-_]/g, " "),
|
|
...(asset.duration != null && { duration: asset.duration }),
|
|
...(asset.width != null && { width: asset.width }),
|
|
...(asset.height != null && { height: asset.height }),
|
|
provenance: { provider: "local", adopted: true },
|
|
};
|
|
appendRecord(projectDir, record);
|
|
adopted.push(record);
|
|
}
|
|
|
|
if (adopted.length > 0) regenerateIndex(projectDir);
|
|
return adopted;
|
|
}
|
|
|
|
// Adopt a pre-existing assets/ file only when it shares a meaningful word with
|
|
// the intent. The old test — `name.includes(intent) || intent.includes(name)` —
|
|
// silently returned the WRONG file: "whoosh" grabbed a stray who.mp3, and a
|
|
// one-letter filename matched every intent. A false negative just falls through
|
|
// to a catalog search (safe); a false positive ships the wrong asset. So bias to
|
|
// precision: require a shared token, don't guess from substrings.
|
|
export function findExistingAsset(projectDir, intent, type) {
|
|
const assetsDir = join(projectDir, "assets");
|
|
if (!existsSync(assetsDir)) return null;
|
|
const intentTokens = matchTokens(intent);
|
|
if (intentTokens.size === 0) return null;
|
|
for (const rel of walkDir(assetsDir)) {
|
|
const t = inferType(rel);
|
|
if (!t || (type && t !== type)) continue;
|
|
const stem = basename(rel, extname(rel));
|
|
for (const tok of matchTokens(stem)) {
|
|
if (intentTokens.has(tok)) {
|
|
return { relativePath: `assets/${rel}`, type: t, name: stem };
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|