mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -46,8 +46,8 @@
|
||||
"files": 10
|
||||
},
|
||||
"media-use": {
|
||||
"hash": "db5787c5ff2bd852",
|
||||
"files": 97
|
||||
"hash": "caf64c363a39ad9b",
|
||||
"files": 98
|
||||
},
|
||||
"motion-graphics": {
|
||||
"hash": "f5a432862116b39a",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user