Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 @@ -50,8 +50,8 @@
"files": 12
},
"media-use": {
"hash": "1b0ce647f5c7df95",
"files": 152
"hash": "36eedcb77a5f40f3",
"files": 153
},
"motion-graphics": {
"hash": "853ac75cbab69036",
Expand Down
47 changes: 35 additions & 12 deletions skills/media-use/scripts/lib/local-models.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@
// (quality that is NOT size, e.g. ASR), else by RAM footprint (the quality
// proxy for generation). No fit -> recommend the CLI/cloud path.
//
// selectModelLadder() returns EVERY fitting model in that same order. Callers
// that can retry walk it so ONE unusable entry (gated weights, a missing
// binary, an OOM) demotes to the next tier instead of killing the local path.
//
// Picks reflect the 2026 research pass, verified live where noted.

export const CAPABILITIES = ["tts", "asr", "upscale", "videogen", "imagegen"];
Expand Down Expand Up @@ -102,13 +106,17 @@ const MODELS = {
},
],
videogen: [
// 2026-07 X research pass + live verification on a 24GB M-series Mac.
// 2026-07 X research pass + live verification on a 24GB M-series Mac -
// which reaches the q4 tier only: a 24GB machine cannot select the 32GB
// entry below it, so that tier's claims stay unverified until someone
// runs it on a 32GB+ machine.
// The Mac-local video story is LTX 2.3 on MLX via dgrauet/ltx-2-mlx (the
// pipeline these weights were converted for; also powers Phosphene).
// Wan 2.x MLX exists only as A14B conversions (too large for consumer
// unified memory); revisit when a 5B Wan MLX conversion lands.
// IMPORTANT: download the weights with a targeted include list first;
// pointing tools at the repo blind snapshot-downloads all 60 GB:
// pointing tools at the repo blind snapshot-downloads the lot (60 GB q4,
// 88 GB q8):
// hf download dgrauet/ltx-2.3-mlx-q4 --include \
// transformer-distilled-1.1.safetensors connector.safetensors \
// "vae_*.safetensors" audio_vae.safetensors vocoder.safetensors "*.json"
Expand All @@ -119,24 +127,24 @@ const MODELS = {
needs: { ramMB: 16384, gpu: true },
wordTimestamps: false,
install:
"git clone https://github.com/dgrauet/ltx-2-mlx && cd ltx-2-mlx && uv sync --all-extras",
'git clone https://github.com/dgrauet/ltx-2-mlx && cd ltx-2-mlx && uv sync --all-extras && export PATH="$PWD/.venv/bin:$PATH"',
invoke:
"ltx-2-mlx generate --prompt {prompt} --distilled --low-ram --model dgrauet/ltx-2.3-mlx-q4 --width {w} --height {h} --frames {frames} --frame-rate 24 --output {out}",
notes:
"LTX 2.3 int4 on MLX. Verified on 24GB unified: 512x320 x 33 frames in ~19 min cold (incl. text-encoder download), t2v with audio. Dims must be multiples of 64. i2v, retake/extend, keyframe interpolation supported.",
},
{
id: "ltx-2.3-mlx-bf16",
id: "ltx-2.3-mlx-q8",
tier: "large",
sizeMB: 45000,
sizeMB: 28800,
needs: { ramMB: 32768, gpu: true },
wordTimestamps: false,
install:
"git clone https://github.com/dgrauet/ltx-2-mlx && cd ltx-2-mlx && uv sync --all-extras",
'git clone https://github.com/dgrauet/ltx-2-mlx && cd ltx-2-mlx && uv sync --all-extras && export PATH="$PWD/.venv/bin:$PATH"',
invoke:
"ltx-2-mlx generate --prompt {prompt} --two-stage --model dgrauet/ltx-2.3-mlx-bf16 --width {w} --height {h} --frames {frames} --frame-rate 24 --output {out}",
"ltx-2-mlx generate --prompt {prompt} --two-stage --low-ram --model dgrauet/ltx-2.3-mlx-q8 --width {w} --height {h} --frames {frames} --frame-rate 24 --output {out}",
notes:
"Full-precision two-stage pipeline (upstream production default). 32GB with --low-ram block streaming; 64-128GB Macs for long/HD runs (the 25s multi-scene spots seen in the wild).",
"LTX 2.3 int8 on MLX, two-stage (upstream production default; higher quality than the q4 distilled tier). Replaced dgrauet/ltx-2.3-mlx-bf16, which is gated (HTTP 401) and cannot be downloaded at all. sizeMB is the targeted include-list subset, measured 28.8GB. --low-ram matches this tier's 32GB floor (block streaming); 64-128GB Macs for long/HD runs. NOT live-verified on a 32GB+ machine - the q4 tier below is the verified one.",
},
],
imagegen: [
Expand Down Expand Up @@ -247,6 +255,23 @@ function rankedByPreference(table) {
});
}

/**
* Every local model for a capability this machine can actually run, best-first
* (same ordering as selectModel, whose pick is this list's head).
*
* Callers that can retry should walk the whole list: a table entry can be
* unusable for reasons no spec check can see - weights pulled or gated behind a
* login, the runner missing from PATH, an OOM at a tier that nominally fits. On
* a single-select call any one of those fails the entire local path, because the
* cascade cannot tell "this model is broken" from "nothing here fits you".
* Demoting to the next fitting tier is almost always what the user wanted.
*/
export function selectModelLadder(capability, specs, { preferTier } = {}) {
const table = tableFor(capability);
const pool = preferTier ? table.filter((m) => m.tier === preferTier) : table;
return rankedByPreference(pool).filter((model) => meetsSpecs(model, specs));
}

/**
* Pick the best local model the machine can run for a capability: the
* highest-footprint model that fits the available-RAM budget (and GPU/VRAM).
Expand All @@ -255,10 +280,8 @@ function rankedByPreference(table) {
*/
export function selectModel(capability, specs, { preferTier } = {}) {
const table = tableFor(capability);
const pool = preferTier ? table.filter((m) => m.tier === preferTier) : table;
for (const model of rankedByPreference(pool)) {
if (meetsSpecs(model, specs)) return { model, tier: model.tier };
}
const [model] = selectModelLadder(capability, specs, { preferTier });
if (model) return { model, tier: model.tier };
const smallest = table.reduce((a, b) => (a.sizeMB <= b.sizeMB ? a : b));
return {
recommend: "cli",
Expand Down
66 changes: 66 additions & 0 deletions skills/media-use/scripts/lib/local-models.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
listModels,
meetsSpecs,
selectModel,
selectModelLadder,
describeModelLadder,
CAPABILITIES,
} from "./local-models.mjs";
Expand Down Expand Up @@ -152,3 +153,68 @@ test("ASR offers word-timestamp-capable models (better than plain whisper)", ()
"every ASR model must support word timestamps",
);
});

// A machine that clears BOTH videogen tiers (the 32GB entry and the 16GB one).
// The existing fixtures deliberately sit under the large tier's floor, which is
// exactly how a dead 32GB entry stayed invisible: nothing could select it.
const bothVideogenTiers = { availableRamMB: 40000, gpu: { present: true } };

test("selectModelLadder returns every fitting model, best-first", () => {
const ladder = selectModelLadder("videogen", bothVideogenTiers);
assert.deepEqual(
ladder.map((m) => m.tier),
["large", "medium"],
"both tiers fit 40GB, biggest first",
);
assert.equal(
selectModel("videogen", bothVideogenTiers).model.id,
ladder[0].id,
"selectModel's pick is the ladder's head",
);
});

test("selectModelLadder drops what the machine cannot run", () => {
const oneTier = selectModelLadder("videogen", { availableRamMB: 20000, gpu: { present: true } });
assert.deepEqual(
oneTier.map((m) => m.tier),
["medium"],
"20GB cannot reach the 32GB tier",
);
assert.deepEqual(
selectModelLadder("videogen", { availableRamMB: 100, gpu: { present: true } }),
[],
"nothing fits -> empty ladder, and selectModel recommends the CLI",
);
assert.equal(
selectModel("videogen", { availableRamMB: 100, gpu: { present: true } }).recommend,
"cli",
);
});

test("selectModelLadder honours preferTier", () => {
const pinned = selectModelLadder("videogen", bothVideogenTiers, { preferTier: "medium" });
assert.deepEqual(
pinned.map((m) => m.tier),
["medium"],
"preferTier pins the ladder to one tier",
);
});

test("an invoke that names an owner/repo model agrees with the entry id", () => {
// Guards a half-done repoint: moving an entry to different weights means
// changing BOTH the id and the --model argument. Change one and the table
// selects one model while the runner downloads another.
let checked = 0;
for (const cap of CAPABILITIES) {
for (const m of listModels(cap)) {
if (m.repo) continue; // entries with an explicit repo resolve through it
const named = /--model\s+(\S+)/.exec(m.invoke);
if (!named) continue;
const [, name] = named[1].split("/");
if (!name) continue; // a bare model name, not an owner/repo id
assert.equal(name, m.id, `${cap}/${m.id}: invoke runs ${named[1]}`);
checked += 1;
}
}
assert.ok(checked > 0, "no entry pins an owner/repo model - guard would be vacuous");
});
66 changes: 39 additions & 27 deletions skills/media-use/scripts/lib/local-run.mjs
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { execFileSync } from "node:child_process";
import { selectModel } from "./local-models.mjs";
import { selectModel, selectModelLadder } from "./local-models.mjs";
import { probeSpecs } from "./specs.mjs";

// Run a USER-INSTALLED local model for a capability (tts/asr/upscale).
// Picks the best tier the machine supports (selectModel), checks the tool is on
// PATH, fills the model's invoke template, and runs it. Returns:
// Walks the tiers the machine supports best-first (selectModelLadder), checking
// the tool is on PATH, filling the model's invoke template, and running it. A
// tier whose tool is missing or whose run fails demotes to the next fitting
// tier, so one unusable entry does not fail the capability. Returns:
// { model, tier, out } on success
// { recommend:"install", model, command, reason } when the tool isn't installed
// { recommend:"cli", reason } when no tier fits the machine
Expand Down Expand Up @@ -35,30 +37,40 @@ export function runLocalModel(capability, opts = {}) {
vars = {},
preferTier,
} = opts;
const sel = selectModel(capability, specs, { preferTier });
if (sel.recommend) return sel; // no tier fits -> recommend the CLI path
const ladder = selectModelLadder(capability, specs, { preferTier });
// no tier fits at all -> recommend the CLI path (selectModel words the reason)
if (!ladder.length) return selectModel(capability, specs, { preferTier });

const { model } = sel;
const bin = model.invoke.split(/\s+/)[0];
try {
which(bin);
} catch {
return {
recommend: "install",
model: model.id,
command: model.install,
reason: `${model.id} not installed`,
};
// Best tier first, demoting past any tier that cannot run here: a missing
// tool or a failed run at the top tier must not hide a lower tier that works
// (fish-speech absent should still get you Kokoro). The last tier's failure is
// what gets reported, since by then nothing local ran.
let lastFailure = null;
for (const model of ladder) {
const bin = model.invoke.split(/\s+/)[0];
try {
which(bin);
} catch {
lastFailure = {
recommend: "install",
model: model.id,
command: model.install,
reason: `${model.id} not installed`,
};
continue;
}
try {
exec(fill(model.invoke, vars));
} catch (e) {
lastFailure = {
recommend: "install",
model: model.id,
command: model.install,
reason: e.message || String(e),
};
continue;
}
return { model: model.id, tier: model.tier, out: vars.out };
}
try {
exec(fill(model.invoke, vars));
} catch (e) {
return {
recommend: "install",
model: model.id,
command: model.install,
reason: e.message || String(e),
};
}
return { model: model.id, tier: sel.tier, out: vars.out };
return lastFailure;
}
37 changes: 37 additions & 0 deletions skills/media-use/scripts/lib/local-run.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,40 @@ test("a failing run degrades to an install recommendation, never throws", () =>
});
assert.equal(r.recommend, "install");
});

test("a tier whose tool is missing demotes to the next tier that fits", () => {
// 64GB + GPU fits BOTH tts tiers, so the ladder has two rungs: fish-speech
// (its own binary) above Kokoro (`python -m kokoro`). fish-speech absent must
// not cost the user Kokoro.
const strongGpu = { ramMB: 64000, gpu: { present: true, vramMB: 24000 } };
let ran = "";
const r = runLocalModel("tts", {
specs: strongGpu,
which: (bin) => {
if (bin === "fish-speech") throw new Error("not found");
},
exec: (cmd) => {
ran = cmd;
},
vars: { text: "hello", voice: "af_heart", out: "/tmp/v.wav" },
});

assert.equal(r.model, "kokoro", "demoted past the missing fish-speech binary");
assert.equal(r.tier, "medium");
assert.match(ran, /kokoro/);
});

test("every fitting tier failing reports the last tier's install command", () => {
const strongGpu = { ramMB: 64000, gpu: { present: true, vramMB: 24000 } };
const r = runLocalModel("tts", {
specs: strongGpu,
which: ok,
exec: () => {
throw new Error("boom");
},
vars: { text: "hi", out: "/tmp/v.wav" },
});

assert.equal(r.recommend, "install");
assert.equal(r.model, "kokoro", "the smallest fitting tier is the actionable one");
});
Loading
Loading