Merge pull request #2028 from heygen-com/worktree-fix-media-use-issues

fix(media-use): forgiving prompt matching + precise assets/ scan
This commit is contained in:
Miguel Ángel
2026-07-07 16:25:33 -04:00
committed by GitHub
7 changed files with 179 additions and 13 deletions
+2 -2
View File
@@ -46,8 +46,8 @@
"files": 10
},
"media-use": {
"hash": "db5787c5ff2bd852",
"files": 97
"hash": "caf64c363a39ad9b",
"files": 98
},
"motion-graphics": {
"hash": "f5a432862116b39a",
+3 -3
View File
@@ -111,8 +111,8 @@ leaving the project + global cache and any local provider.
## How it works
1. Check project `.media/manifest.jsonl` for exact-prompt match
2. Scan existing `assets/` directory for unregistered files matching the need
1. Check project `.media/manifest.jsonl` for a prompt match (case- and whitespace-insensitive)
2. Scan existing `assets/` directory for unregistered files that share a word with the need
3. Check global cache `~/.media/` for reusable asset
4. Search via provider (HeyGen audio catalog, HeyGen asset search)
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
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
+37 -4
View File
@@ -96,16 +96,49 @@ export function adoptExistingAssets(projectDir) {
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) {
const assetsDir = join(projectDir, "assets");
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)) {
const t = inferType(rel);
if (!t || (type && t !== type)) continue;
const name = basename(rel, extname(rel)).toLowerCase().replace(/[-_]/g, " ");
if (name.includes(lower) || lower.includes(name)) {
return { relativePath: `assets/${rel}`, type: t, name: basename(rel, extname(rel)) };
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;
@@ -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();
+7 -2
View File
@@ -2,7 +2,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync } from
import { join, basename } from "node:path";
import { createHash } from "node:crypto";
import { homedir } from "node:os";
import { readManifest, appendRecord } from "./manifest.mjs";
import { readManifest, appendRecord, normalizePrompt } from "./manifest.mjs";
const SCHEMA_PREFIX = "mu-v1-";
const KEY_HEX_CHARS = 16;
@@ -43,9 +43,14 @@ function validateCacheHit(match) {
}
export function cacheGet(prompt, type) {
const key = normalizePrompt(prompt);
if (!key) return null;
return validateCacheHit(
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),
),
);
}
+16 -2
View File
@@ -64,11 +64,25 @@ export function appendRecord(projectDir, record) {
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) {
const key = normalizePrompt(prompt);
if (!key) return null;
const records = readManifest(projectDir);
return (
records.find((r) => r.provenance?.prompt === prompt && (type == null || r.type === type)) ||
null
records.find(
(r) => normalizePrompt(r.provenance?.prompt) === key && (type == null || r.type === type),
) || null
);
}
@@ -8,6 +8,7 @@ import {
findByPrompt,
findByEntity,
nextId,
normalizePrompt,
manifestPath,
mediaDir,
typeDirPath,
@@ -114,6 +115,20 @@ function runTests() {
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", () => {
setup();
appendRecord(tmp, makeRecord({ entity: "GitHub", type: "icon" }));
@@ -203,6 +218,10 @@ function runTests() {
assert.ok(found);
assert.equal(found.reusable, true);
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();
});