mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +00:00
fix(media-use): forgiving prompt matching + precise assets/ scan
Two defects in the resolve cascade that made cache and asset-reuse misbehave in practice: - Prompt matching was byte-exact and case-sensitive. findByPrompt and cacheGet compared provenance.prompt with ===, so "Calm piano" and "calm piano" re-searched and re-downloaded instead of reusing the cached asset (same project and cross-project). Add normalizePrompt (trim + lowercase + collapse whitespace) and key both lookups on it; the raw prompt is still stored for audit. - findExistingAsset matched with name.includes(intent) || intent.includes(name), which silently returned the WRONG local file: intent "whoosh" grabbed a stray who.mp3, and a one-letter filename matched every intent. Require a shared word token (>= 3 chars, minus stopwords) so a false negative just falls through to a catalog search rather than shipping the wrong asset. Adds lib/adopt.test.mjs and extends manifest.test.mjs. Full media-use suite green; verified e2e against the live catalog (case-variant cross-project resolve now reuses; whoosh no longer grabs who.mp3).
This commit is contained in:
@@ -46,8 +46,8 @@
|
|||||||
"files": 10
|
"files": 10
|
||||||
},
|
},
|
||||||
"media-use": {
|
"media-use": {
|
||||||
"hash": "db5787c5ff2bd852",
|
"hash": "caf64c363a39ad9b",
|
||||||
"files": 97
|
"files": 98
|
||||||
},
|
},
|
||||||
"motion-graphics": {
|
"motion-graphics": {
|
||||||
"hash": "f5a432862116b39a",
|
"hash": "f5a432862116b39a",
|
||||||
|
|||||||
@@ -111,8 +111,8 @@ leaving the project + global cache and any local provider.
|
|||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
1. Check project `.media/manifest.jsonl` for exact-prompt match
|
1. Check project `.media/manifest.jsonl` for a prompt match (case- and whitespace-insensitive)
|
||||||
2. Scan existing `assets/` directory for unregistered files matching the need
|
2. Scan existing `assets/` directory for unregistered files that share a word with the need
|
||||||
3. Check global cache `~/.media/` for reusable asset
|
3. Check global cache `~/.media/` for reusable asset
|
||||||
4. Search via provider (HeyGen audio catalog, HeyGen asset search)
|
4. Search via provider (HeyGen audio catalog, HeyGen asset search)
|
||||||
5. Freeze file to `.media/<type>/`, register in manifest, regenerate `index.md`
|
5. Freeze file to `.media/<type>/`, register in manifest, regenerate `index.md`
|
||||||
@@ -148,7 +148,7 @@ icon_001 icon - 200×200 .media/images/icon_001.png rocket
|
|||||||
|
|
||||||
## Cross-project reuse
|
## Cross-project reuse
|
||||||
|
|
||||||
Assets are cached automatically on resolve. Every resolved/ingested asset is auto-promoted to the global cache at `~/.media/`, so subsequent resolves for the same prompt, in any project, hit the cache with no re-download and no provider call.
|
Assets are cached automatically on resolve. Every resolved/ingested asset is auto-promoted to the global cache at `~/.media/`, so subsequent resolves for the same (or near-identical) prompt, in any project, hit the cache with no re-download and no provider call.
|
||||||
|
|
||||||
## Files
|
## Files
|
||||||
|
|
||||||
|
|||||||
@@ -96,16 +96,49 @@ export function adoptExistingAssets(projectDir) {
|
|||||||
return adopted;
|
return adopted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Common filler words that should never, on their own, make a filename match an
|
||||||
|
// intent (e.g. intent "the rocket" must not adopt "the-video.mp4").
|
||||||
|
const MATCH_STOPWORDS = new Set([
|
||||||
|
"the",
|
||||||
|
"and",
|
||||||
|
"for",
|
||||||
|
"with",
|
||||||
|
"from",
|
||||||
|
"this",
|
||||||
|
"that",
|
||||||
|
"your",
|
||||||
|
"our",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Split into lowercased word tokens of length >= 3, minus stopwords.
|
||||||
|
function matchTokens(text) {
|
||||||
|
return new Set(
|
||||||
|
String(text)
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/[^a-z0-9]+/)
|
||||||
|
.filter((t) => t.length >= 3 && !MATCH_STOPWORDS.has(t)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
export function findExistingAsset(projectDir, intent, type) {
|
||||||
const assetsDir = join(projectDir, "assets");
|
const assetsDir = join(projectDir, "assets");
|
||||||
if (!existsSync(assetsDir)) return null;
|
if (!existsSync(assetsDir)) return null;
|
||||||
const lower = intent.toLowerCase();
|
const intentTokens = matchTokens(intent);
|
||||||
|
if (intentTokens.size === 0) return null;
|
||||||
for (const rel of walkDir(assetsDir)) {
|
for (const rel of walkDir(assetsDir)) {
|
||||||
const t = inferType(rel);
|
const t = inferType(rel);
|
||||||
if (!t || (type && t !== type)) continue;
|
if (!t || (type && t !== type)) continue;
|
||||||
const name = basename(rel, extname(rel)).toLowerCase().replace(/[-_]/g, " ");
|
const stem = basename(rel, extname(rel));
|
||||||
if (name.includes(lower) || lower.includes(name)) {
|
for (const tok of matchTokens(stem)) {
|
||||||
return { relativePath: `assets/${rel}`, type: t, name: basename(rel, extname(rel)) };
|
if (intentTokens.has(tok)) {
|
||||||
|
return { relativePath: `assets/${rel}`, type: t, name: stem };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { strict as assert } from "node:assert";
|
||||||
|
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { findExistingAsset, adoptExistingAssets } from "./adopt.mjs";
|
||||||
|
|
||||||
|
let tmp;
|
||||||
|
function setup() {
|
||||||
|
tmp = mkdtempSync(join(tmpdir(), "mu-adopt-test-"));
|
||||||
|
}
|
||||||
|
function cleanup() {
|
||||||
|
if (tmp) rmSync(tmp, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
function drop(rel) {
|
||||||
|
const full = join(tmp, "assets", rel);
|
||||||
|
mkdirSync(join(full, ".."), { recursive: true });
|
||||||
|
writeFileSync(full, "x");
|
||||||
|
}
|
||||||
|
|
||||||
|
function runTests() {
|
||||||
|
const tests = [];
|
||||||
|
const test = (name, fn) => tests.push({ name, fn });
|
||||||
|
|
||||||
|
test("does NOT false-match a short filename stem (who.mp3 vs 'whoosh')", () => {
|
||||||
|
setup();
|
||||||
|
drop("sfx/who.mp3");
|
||||||
|
assert.equal(findExistingAsset(tmp, "whoosh", "sfx"), null);
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does NOT match a one-letter filename against any intent", () => {
|
||||||
|
setup();
|
||||||
|
drop("images/a.jpg");
|
||||||
|
assert.equal(findExistingAsset(tmp, "gradient tech background", "image"), null);
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("matches on a shared meaningful word", () => {
|
||||||
|
setup();
|
||||||
|
drop("images/hero-shot.jpg");
|
||||||
|
const hit = findExistingAsset(tmp, "hero image", "image");
|
||||||
|
assert.ok(hit);
|
||||||
|
assert.equal(hit.relativePath, "assets/images/hero-shot.jpg");
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("matches multi-word overlap", () => {
|
||||||
|
setup();
|
||||||
|
drop("images/gradient-tech-bg.jpg");
|
||||||
|
assert.ok(findExistingAsset(tmp, "gradient tech background", "image"));
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a shared stopword alone does not match", () => {
|
||||||
|
setup();
|
||||||
|
drop("video/the-video.mp4");
|
||||||
|
assert.equal(findExistingAsset(tmp, "the rocket", null), null);
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("respects the type filter", () => {
|
||||||
|
setup();
|
||||||
|
drop("sfx/rocket.mp3");
|
||||||
|
assert.equal(findExistingAsset(tmp, "rocket", "image"), null, "wrong type is skipped");
|
||||||
|
assert.ok(findExistingAsset(tmp, "rocket", "sfx"), "right type matches");
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("adoptExistingAssets still imports every typed file (unaffected by match rule)", () => {
|
||||||
|
setup();
|
||||||
|
drop("sfx/who.mp3");
|
||||||
|
drop("images/a.jpg");
|
||||||
|
assert.equal(adoptExistingAssets(tmp).length, 2);
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
for (const { name, fn } of tests) {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
passed++;
|
||||||
|
console.log(` \x1b[32m✓\x1b[0m ${name}`);
|
||||||
|
} catch (err) {
|
||||||
|
failed++;
|
||||||
|
console.log(` \x1b[31m✗\x1b[0m ${name}`);
|
||||||
|
console.log(` ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`\n${passed} passed, ${failed} failed`);
|
||||||
|
if (failed > 0) process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("media-use · adopt / findExistingAsset tests\n");
|
||||||
|
runTests();
|
||||||
@@ -2,7 +2,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync } from
|
|||||||
import { join, basename } from "node:path";
|
import { join, basename } from "node:path";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { readManifest, appendRecord } from "./manifest.mjs";
|
import { readManifest, appendRecord, normalizePrompt } from "./manifest.mjs";
|
||||||
|
|
||||||
const SCHEMA_PREFIX = "mu-v1-";
|
const SCHEMA_PREFIX = "mu-v1-";
|
||||||
const KEY_HEX_CHARS = 16;
|
const KEY_HEX_CHARS = 16;
|
||||||
@@ -43,9 +43,14 @@ function validateCacheHit(match) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function cacheGet(prompt, type) {
|
export function cacheGet(prompt, type) {
|
||||||
|
const key = normalizePrompt(prompt);
|
||||||
|
if (!key) return null;
|
||||||
return validateCacheHit(
|
return validateCacheHit(
|
||||||
readGlobalManifest().find(
|
readGlobalManifest().find(
|
||||||
(r) => r.reusable && r.provenance?.prompt === prompt && (type == null || r.type === type),
|
(r) =>
|
||||||
|
r.reusable &&
|
||||||
|
normalizePrompt(r.provenance?.prompt) === key &&
|
||||||
|
(type == null || r.type === type),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,11 +64,25 @@ export function appendRecord(projectDir, record) {
|
|||||||
appendFileSync(p, line);
|
appendFileSync(p, line);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Match prompts forgivingly. Agents rarely re-emit a byte-identical intent, so
|
||||||
|
// keying cache lookups on exact equality meant "Calm piano" and "calm piano"
|
||||||
|
// re-searched and re-downloaded. Normalize (trim, lowercase, collapse internal
|
||||||
|
// whitespace) on both sides; the raw prompt is still stored for audit.
|
||||||
|
export function normalizePrompt(prompt) {
|
||||||
|
return String(prompt ?? "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\s+/g, " ");
|
||||||
|
}
|
||||||
|
|
||||||
export function findByPrompt(projectDir, prompt, type) {
|
export function findByPrompt(projectDir, prompt, type) {
|
||||||
|
const key = normalizePrompt(prompt);
|
||||||
|
if (!key) return null;
|
||||||
const records = readManifest(projectDir);
|
const records = readManifest(projectDir);
|
||||||
return (
|
return (
|
||||||
records.find((r) => r.provenance?.prompt === prompt && (type == null || r.type === type)) ||
|
records.find(
|
||||||
null
|
(r) => normalizePrompt(r.provenance?.prompt) === key && (type == null || r.type === type),
|
||||||
|
) || null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
findByPrompt,
|
findByPrompt,
|
||||||
findByEntity,
|
findByEntity,
|
||||||
nextId,
|
nextId,
|
||||||
|
normalizePrompt,
|
||||||
manifestPath,
|
manifestPath,
|
||||||
mediaDir,
|
mediaDir,
|
||||||
typeDirPath,
|
typeDirPath,
|
||||||
@@ -114,6 +115,20 @@ function runTests() {
|
|||||||
cleanup();
|
cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("findByPrompt matches across case and whitespace variants", () => {
|
||||||
|
setup();
|
||||||
|
appendRecord(tmp, makeRecord({ provenance: { provider: "x", prompt: "calm ambient piano" } }));
|
||||||
|
assert.ok(findByPrompt(tmp, "Calm Ambient Piano", "bgm"), "case-insensitive");
|
||||||
|
assert.ok(findByPrompt(tmp, " calm ambient piano ", "bgm"), "whitespace-insensitive");
|
||||||
|
assert.equal(findByPrompt(tmp, "calm ambient guitar", "bgm"), null, "still a real miss");
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizePrompt trims, lowercases, collapses whitespace", () => {
|
||||||
|
assert.equal(normalizePrompt(" Upbeat Tech Launch "), "upbeat tech launch");
|
||||||
|
assert.equal(normalizePrompt(null), "");
|
||||||
|
});
|
||||||
|
|
||||||
test("findByEntity matches case-insensitively", () => {
|
test("findByEntity matches case-insensitively", () => {
|
||||||
setup();
|
setup();
|
||||||
appendRecord(tmp, makeRecord({ entity: "GitHub", type: "icon" }));
|
appendRecord(tmp, makeRecord({ entity: "GitHub", type: "icon" }));
|
||||||
@@ -203,6 +218,10 @@ function runTests() {
|
|||||||
assert.ok(found);
|
assert.ok(found);
|
||||||
assert.equal(found.reusable, true);
|
assert.equal(found.reusable, true);
|
||||||
assert.equal(found.sha, sha);
|
assert.equal(found.sha, sha);
|
||||||
|
|
||||||
|
// cross-project reuse must survive trivial prompt variation, not just
|
||||||
|
// byte-identical intents (the whole point of normalizePrompt).
|
||||||
|
assert.ok(cacheGet(" Cache Test ", "bgm"), "cacheGet is case/whitespace-insensitive");
|
||||||
cleanup();
|
cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user