Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions skills-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@
"files": 10
},
"media-use": {
"hash": "14ea99a7f4d750cd",
"files": 116
"hash": "6c4aa8649e1eaf99",
"files": 121
},
"motion-graphics": {
"hash": "96ed2f7d8051b009",
Expand Down
24 changes: 24 additions & 0 deletions skills/media-use/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ node <SKILL_DIR>/scripts/resolve.mjs --type lut --intent "teal orange blockbuste
| `--provider` | Force one generator (e.g. `codex`, `mflux`, `kokoro`, `heygen`) |
| `--adopt` | Bulk-import existing assets/ into manifest |
| `--doctor` | Check local CLI dependencies; no manifest changes |
| `--stats` | Print local usage stats from `.media/` and `~/.media`; no manifest changes |
| `--days N` | Limit `--stats` to timestamped records/misses from the last N days |
| `--json` | Output JSON instead of one-line result |

## Reuse before you resolve
Expand Down Expand Up @@ -316,11 +318,24 @@ Assets are cached automatically on resolve. Every resolved/ingested asset is aut

For a _semantically_ similar (not identical) need in another project, the exact-match floor won't fire — use [Reuse before you resolve](#reuse-before-you-resolve): `--candidates` lists the global assets, and `--reuse <sha>` imports the one you pick. This is how a track resolved in one project gets reused in the next when the wording differs.

## Usage stats

Use `resolve --stats` for a local, shareable report over the current project's `.media/` manifest, the global `~/.media/` cache, and local resolve misses. Human output is compact; add `--json` for a single machine-readable object, and `--days N` to window timestamped records.

```bash
node <SKILL_DIR>/scripts/resolve.mjs --stats --project . --days 7
# media-use stats
# total resolves: 12
# misses: 2
# hit rate: 86%
```

## Files

- `.media/manifest.jsonl`: machine SSOT, one JSON record per line
- `.media/index.md`: agent-readable table (id, type, dur, dims, path, description)
- `~/.media/`: global cross-project reuse cache (content-addressed, SHA-256)
- `~/.media/misses.jsonl`: local-only resolve misses, including intent text for `--stats`

## Audio engine: voiceover, music, SFX, captions, transcription

Expand Down Expand Up @@ -389,3 +404,12 @@ resolve never waits on or fails from telemetry).

Opt out with `DO_NOT_TRACK=1` or `HYPERFRAMES_NO_TELEMETRY=1` (also off in CI and
dev). Same public PostHog project key and opt-outs as the `hyperframes` CLI.

## Privacy

media-use uses the same shared install id as the `hyperframes` CLI/studio
(`~/.hyperframes/config.json`). When you are signed in to HeyGen, usage is
linked to your account email, or username when email is unavailable, matching
the CLI behavior. The events stay coarse: media type, source, provider, and
small counts only; intent text and paths stay local. Disable telemetry with
`HYPERFRAMES_NO_TELEMETRY=1` or `DO_NOT_TRACK=1`.
50 changes: 50 additions & 0 deletions skills/media-use/references/telemetry-dashboard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# media-use usage dashboard (PostHog)

Reproducible definition of the media-use usage dashboard. The dashboard answers
"how much is media-use used, for what, is reuse working, and what can't it
satisfy" from the telemetry `scripts/lib/telemetry.mjs` already emits. Build it
in PostHog project **Hyperframes (356858)**; this doc is the source of truth so
it can be recreated. Local complement: `resolve --stats` (same questions, from
`.media/` + `~/.media`, no PostHog access needed).

## Identity (see `scripts/lib/telemetry.mjs`)

Events attribute to the **same PostHog person as the hyperframes CLI and studio**
— the shared install id in `~/.hyperframes/config.json` (`anonymousId`), stitched
to the HeyGen account (`$identify`, `distinct_id` = email/username) on sign-in.
Not fully anonymous by design; pseudonymous before sign-in, account-linked after.
`$ip:null`. Opt-out: `HYPERFRAMES_NO_TELEMETRY=1` / `DO_NOT_TRACK=1` (also CI, dev).

## Event catalog (verified present in-project)

Every event carries `surface: "media-use"`. Event **properties are coarse** —
never intent text, file names, or paths.

| Event | Fires on | Key properties |
| ---------------------------------------------------------------------- | ----------------------------------------- | ---------------------------------------------------------------------- |
| `media_use_resolve` | a resolve that produced/returned an asset | `type`, `source`, `provider`, `via`, `local_only`, `provider_override` |
| `media_use_resolve_miss` | a resolve that found nothing | `type`, `local_only`, `provider_override` (no intent) |
| `media_use_candidates` | `--candidates` / `--dry-run` listing | `type`, counts |
| `media_use_doctor_run` | `--doctor` | `ok`, `checks_failed`, `failed[]` |
| `media_use_compare` | `grade-compare` / `compare` | `command`, `cells`, `truncated`, `total`, `render_ready_timed_out` |
| `media_use_transcribe` · `media_use_duck` · `media_use_transcript_cut` | audio-engine ops | op-specific |

## Dashboard tiles

1. **Invocation volume** — `query-trends`, count of `media_use_resolve` over time (daily). "How much."
2. **By media type** — `media_use_resolve` broken down by `type` (bgm/sfx/image/icon/logo/voice/grade/lut). "For what."
3. **Resolve hit-rate** — trends formula: `A / (A + B)` where A = `media_use_resolve`, B = `media_use_resolve_miss`. "Is the catalog covering needs."
4. **Provider mix** — `media_use_resolve` broken down by `provider`; a second tile by `via` (`url` / `params-fallback` / `params`) to catch CDN→params LUT downgrades.
5. **Top misses** — `media_use_resolve_miss` broken down by `type` (the tuning signal — pair with local `resolve --stats`, which also shows the missed _intents_ that telemetry deliberately omits).
6. **Doctor health** — `media_use_doctor_run` broken down by `failed[]` (which dependency check fails most) + `checks_failed` distribution.
7. **Compare cost** — `media_use_compare` by `command`, plus `truncated` / `render_ready_timed_out` rates (observe before lifting the 16-cell cap).
8. **Adoption (optional)** — if the `first_run` property ships (plan U5), segment `media_use_resolve` first-run vs repeat.

## Recreate via the PostHog MCP

For each tile: `read-data-schema` to confirm the event/property, then a
`query-*` tool (`query-trends` for 1–7), then `insight-create`, then
`dashboard-create` collecting the insights. Keep names prefixed `media-use:` so
the dashboard is greppable. Cross-surface note: because identity is shared with
CLI/studio, you can also break these down by the same person across `cli_command*`
and `studio:*` events.
49 changes: 49 additions & 0 deletions skills/media-use/scripts/lib/misses.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";

const MISSES_FILE = "misses.jsonl";

function missesPath() {
return join(homedir(), ".media", MISSES_FILE);
}

export function recordMiss({ type, intent, provider_override, local_only }) {
try {
const dir = join(homedir(), ".media");
mkdirSync(dir, { recursive: true });
appendFileSync(
join(dir, MISSES_FILE),
JSON.stringify({
ts: new Date().toISOString(),
type,
intent,
provider_override: !!provider_override,
local_only: !!local_only,
}) + "\n",
);
} catch {
// local miss logging is best-effort; never surface into resolve
}
}

export function readMisses() {
const p = missesPath();
try {
if (!existsSync(p)) return [];
const raw = readFileSync(p, "utf8");
const records = [];
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
records.push(JSON.parse(trimmed));
} catch {
// skip malformed local lines, don't crash stats
}
}
return records;
} catch {
return [];
}
}
78 changes: 78 additions & 0 deletions skills/media-use/scripts/lib/misses.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { strict as assert } from "node:assert";
import { test } from "node:test";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { readMisses, recordMiss } from "./misses.mjs";

function sandbox() {
const root = mkdtempSync(join(tmpdir(), "mu-misses-"));
const home = join(root, "home");
mkdirSync(home, { recursive: true });
process.env.HOME = home;
return { root, home };
}

function restoreEnv(saved) {
for (const k of Object.keys(process.env)) if (!(k in saved)) delete process.env[k];
Object.assign(process.env, saved);
}

test("recordMiss appends a well-formed local miss", () => {
const savedEnv = { ...process.env };
const { root, home } = sandbox();
try {
recordMiss({ type: "bgm", intent: "moody synth pulse", provider_override: true });
const misses = readMisses();
assert.equal(misses.length, 1);
assert.equal(misses[0].type, "bgm");
assert.equal(misses[0].intent, "moody synth pulse");
assert.equal(misses[0].provider_override, true);
assert.equal(misses[0].local_only, false);
assert.ok(!Number.isNaN(Date.parse(misses[0].ts)));

const raw = readFileSync(join(home, ".media/misses.jsonl"), "utf8");
assert.match(raw, /moody synth pulse/);
} finally {
restoreEnv(savedEnv);
rmSync(root, { recursive: true, force: true });
}
});

test("recordMiss swallows filesystem failures", () => {
const savedEnv = { ...process.env };
const { root, home } = sandbox();
try {
writeFileSync(join(home, ".media"), "not a directory");
assert.doesNotThrow(() =>
recordMiss({ type: "image", intent: "unwritable", local_only: true }),
);
assert.equal(existsSync(join(home, ".media/misses.jsonl")), false);
} finally {
restoreEnv(savedEnv);
rmSync(root, { recursive: true, force: true });
}
});

test("readMisses skips corrupt lines", () => {
const savedEnv = { ...process.env };
const { root, home } = sandbox();
try {
mkdirSync(join(home, ".media"), { recursive: true });
writeFileSync(
join(home, ".media/misses.jsonl"),
[
JSON.stringify({ ts: "2026-07-09T00:00:00.000Z", type: "bgm", intent: "one" }),
"{not json",
JSON.stringify({ ts: "2026-07-09T00:00:01.000Z", type: "sfx", intent: "two" }),
].join("\n"),
);
assert.deepEqual(
readMisses().map((miss) => miss.intent),
["one", "two"],
);
} finally {
restoreEnv(savedEnv);
rmSync(root, { recursive: true, force: true });
}
});
117 changes: 117 additions & 0 deletions skills/media-use/scripts/lib/stats.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { statSync } from "node:fs";
import { readGlobalManifest } from "./cache.mjs";
import { readManifest } from "./manifest.mjs";
import { readMisses } from "./misses.mjs";

const TOP_MISSES = 5;

function emptyReport() {
return {
total_resolves: 0,
by_type: {},
by_source: {},
by_provider: {},
by_via: {},
misses: 0,
hit_rate: null,
top_missed_intents: {},
global_cache_assets: 0,
global_cache_disk_bytes: 0,
cross_project_reuse: 0,
};
}

function increment(map, key) {
if (!key) return;
map[key] = (map[key] || 0) + 1;
}

function timestampOf(record) {
return record?.ts || record?.timestamp || record?.created_at || record?.createdAt || null;
}

function inWindow(record, cutoff) {
if (!cutoff) return true;
const ts = timestampOf(record);
// Older manifest records may not carry a timestamp; keep them in the report
// because --days can only window records/misses that carry a ts/timestamp.
if (!ts) return true;
const time = Date.parse(ts);
return Number.isNaN(time) ? true : time >= cutoff;
}

function sourceOf(record) {
return record?._source || record?.source || record?.provenance?.source || "unknown";
}

function normalizeIntent(intent) {
return String(intent ?? "")
.trim()
.toLowerCase()
.replace(/\s+/g, " ");
}

function topMissedIntents(misses) {
const grouped = {};
for (const miss of misses) {
const type = miss?.type || "unknown";
const intent = normalizeIntent(miss?.intent);
if (!intent) continue;
grouped[type] ||= {};
grouped[type][intent] = (grouped[type][intent] || 0) + 1;
}
const out = {};
for (const [type, intents] of Object.entries(grouped)) {
out[type] = Object.entries(intents)
.map(([intent, count]) => ({ intent, count }))
.sort((a, b) => b.count - a.count || a.intent.localeCompare(b.intent))
.slice(0, TOP_MISSES);
}
return out;
}

function diskBytes(records) {
let total = 0;
for (const record of records) {
const p = record?.cached_path || record?.path;
if (!p) continue;
try {
total += statSync(p).size;
} catch {
// cache entries can outlive files; stats skips missing files
}
}
return total;
}

export function buildStats({ projectDir, days, now = Date.now() } = {}) {
// Only a positive finite --days windows the report; null / NaN / <= 0 mean
// "all time" rather than silently excluding everything (a negative cutoff
// would land in the future and drop every record). The reads below are each
// best-effort (they return [] / skip on IO errors), so there is no top-level
// catch masking a real logic bug as an all-zero "no usage" report.
const n = Number(days);
const cutoff = Number.isFinite(n) && n > 0 ? Number(now) - n * 24 * 60 * 60 * 1000 : null;
const records = (projectDir ? readManifest(projectDir) : []).filter((r) => inWindow(r, cutoff));
const misses = readMisses().filter((miss) => inWindow(miss, cutoff));
const globalRecords = readGlobalManifest();
const report = emptyReport();

report.total_resolves = records.length;
report.misses = misses.length;
for (const record of records) {
increment(report.by_type, record?.type || "unknown");
increment(report.by_source, sourceOf(record));
increment(report.by_provider, record?.provenance?.provider);
increment(report.by_via, record?.provenance?.via);
}

const attempts = report.total_resolves + report.misses;
report.hit_rate = attempts === 0 ? null : report.total_resolves / attempts;
report.top_missed_intents = topMissedIntents(misses);
report.global_cache_assets = globalRecords.length;
report.global_cache_disk_bytes = diskBytes(globalRecords);
report.cross_project_reuse = globalRecords.filter((r) => r?.provenance?.reused_by).length;

return report;
}
Loading
Loading