reelkit-cli 0.12.0 → 0.12.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,20 @@
3
3
  What changed in each published version of `reelkit-cli`, newest first. A push to `main` publishes the version in
4
4
  `package.json` when it is not on npm yet, and it must have an entry here.
5
5
 
6
+ ## 0.12.3 - 2026-10-09
7
+
8
+ - A template's components are tied to scenes in more of the ways compositions are written: a project's own helper that is handed a scene's id, a lookup by id, a list that pairs ids with components, a branch on a scene's id, and components the file declares for itself.
9
+
10
+ ## 0.12.2 - 2026-10-09
11
+
12
+ - You can keep 50 templates (it was 20).
13
+
14
+ ## 0.12.1 - 2026-10-09
15
+
16
+ - A pushed template now carries, for each scene, the plan's visual direction and the components the composition times to it, and for the film every component it uses (written for the project, pulled from the library or from the kit).
17
+ - Sound effects and the music track carry their library ids, so a template's timeline can copy the command that pulls each one.
18
+ - Scene pictures are sharper (640 pixels wide).
19
+
6
20
  ## 0.12.0 - 2026-10-09
7
21
 
8
22
  - Templates: `reelkit template push` saves a whole project in your private files (the plan, the composition, everything under `assets/` and the rendered video), and `reelkit template clone <id>` unpacks one into a new project to continue or remix. `reelkit template list` and `reelkit template delete <id>` manage them.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reelkit-cli",
3
- "version": "0.12.0",
3
+ "version": "0.12.3",
4
4
  "description": "CLI and Claude skill for making short-form video with a shared asset library.",
5
5
  "license": "MIT",
6
6
  "author": "Daniel Livshin",
package/skill/SKILL.md CHANGED
@@ -15,7 +15,7 @@ For image or video cutouts, editable Blender product shots, custom 3D materials,
15
15
 
16
16
  To implement a chosen style, read its row in `reference/style-recipes.md` for action, timing, sound and proof. `reference/style-components.md` maps the shared components and built-in kit to shot roles; inspect the selected source before using its props.
17
17
 
18
- This skill targets `reelkit-cli` 0.12.0 and its HyperFrames kit. Check the installed version with `reelkit --version`; in a source checkout, invoke its `bin/reelkit.mjs` with Node. Use the matching CLI before authoring with `reelkit/frame`.
18
+ This skill targets `reelkit-cli` 0.12.3 and its HyperFrames kit. Check the installed version with `reelkit --version`; in a source checkout, invoke its `bin/reelkit.mjs` with Node. Use the matching CLI before authoring with `reelkit/frame`.
19
19
 
20
20
  ## What decides whether the film is accepted
21
21
 
@@ -11,6 +11,8 @@ import type { MusicRecord } from "../project/music";
11
11
  import { FILES, openProject, Project } from "../project/project";
12
12
  import { SOUND_MAP, type SoundMapEntry } from "./build";
13
13
  import { decodeMono } from "../project/refmeasure";
14
+ import { componentsOf } from "../project/scene-components";
15
+ import { leadingComment } from "./components";
14
16
  import { AUDIO_RATE, hitTimes, swells } from "../project/soundreport";
15
17
 
16
18
  const sh = promisify(execFile);
@@ -38,6 +40,13 @@ export function filmOf(project: Project): TemplateFilm | undefined {
38
40
  const music = project.readJsonOr<MusicRecord | undefined>(FILES.music, undefined);
39
41
  const seconds = manifest.totalFrames / manifest.fps;
40
42
  const cut = (s: string, max: number) => (s.length > max ? s.slice(0, max) : s);
43
+ // The components, from the composition's source: which ones it uses, and which scenes time each of them.
44
+ const pulled = Object.values(project.readJsonOr<Record<string, { path?: string; kind?: string }>>(FILES.library, {})).filter((p) => p.kind === "component" && p.path).map((p) => basename(p.path!).replace(/\.tsx$/, ""));
45
+ let used: ReturnType<typeof componentsOf> = [];
46
+ try { if (project.exists("src/Video.tsx")) used = componentsOf(readFileSync(project.path("src/Video.tsx"), "utf8"), { sceneIds: plan.scenes.map((s) => s.id), pulled }); } catch { used = []; }
47
+ const about = (name: string): string | undefined => {
48
+ try { return project.exists(`src/${name}.tsx`) ? leadingComment(readFileSync(project.path(`src/${name}.tsx`), "utf8"), name)?.replace(/\s+/g, " ").trim() : undefined; } catch { return undefined; }
49
+ };
41
50
  return {
42
51
  aspect: plan.aspect, fps: manifest.fps, width: manifest.width, height: manifest.height, totalFrames: Math.round(manifest.totalFrames),
43
52
  scenes: manifest.scenes.slice(0, 200).map((m) => {
@@ -48,9 +57,12 @@ export function filmOf(project: Project): TemplateFilm | undefined {
48
57
  id: cut(m.id, 80), start: Math.max(0, Math.round(m.startFrame)), frames: Math.max(0, Math.round(m.durationFrames)),
49
58
  ...(p?.treatment ? { treatment: cut(p.treatment, 60) } : {}), ...(narration ? { narration: cut(narration, 4000) } : {}),
50
59
  ...(text.length ? { text } : {}), ...(m.words.length ? { words: m.words.slice(0, 600).map((w) => Math.max(0, Math.round(w.startSec * 1000) / 1000)) } : {}),
60
+ ...(p?.notes?.trim() ? { notes: cut(p.notes.trim(), 2000) } : {}),
61
+ ...(used.some((c) => c.scenes.includes(m.id)) ? { components: used.filter((c) => c.scenes.includes(m.id)).map((c) => cut(c.name, 80)).slice(0, 60) } : {}),
51
62
  };
52
63
  }),
53
- ...(music ? { music: { title: cut(music.title, 200), seconds: music.durationSec, ...(music.bpm ? { bpm: music.bpm } : {}), beats: music.beats.filter((b) => b >= 0 && b <= seconds).slice(0, 4000) } } : {}),
64
+ ...(music ? { music: { title: cut(music.title, 200), seconds: music.durationSec, ...(music.bpm ? { bpm: music.bpm } : {}), beats: music.beats.filter((b) => b >= 0 && b <= seconds).slice(0, 4000), ...(music.id ? { id: cut(music.id, 128) } : {}) } } : {}),
65
+ ...(used.length ? { components: used.slice(0, 300).map((c) => { const text = c.from === "kit" ? undefined : about(c.name); return { name: cut(c.name, 80), from: c.from, uses: c.uses, ...(text ? { about: cut(text, 400) } : {}) }; }) } : {}),
54
66
  voice: plan.voice !== "none",
55
67
  ...(manifest.captions ? { captions: manifest.captions } : {}),
56
68
  ...cuesOf(project, manifest, music?.key),
@@ -62,7 +74,7 @@ export function filmOf(project: Project): TemplateFilm | undefined {
62
74
  function cuesOf(project: Project, manifest: AssetManifest, musicKey: string | undefined): Pick<TemplateFilm, "cues"> {
63
75
  const map = project.readJsonOr<SoundMapEntry[]>(SOUND_MAP, []);
64
76
  if (!Array.isArray(map) || !map.length) return {};
65
- const pulled = Object.values(project.readJsonOr<Record<string, { path?: string; kind?: string; title?: string; meta?: { durationSec?: number } }>>(FILES.library, {}));
77
+ const pulled = Object.entries(project.readJsonOr<Record<string, { path?: string; kind?: string; title?: string; meta?: { durationSec?: number } }>>(FILES.library, {})).map(([id, p]) => ({ ...p, id }));
66
78
  const voice = new Set(manifest.scenes.map((s) => s.voiceoverKey).filter(Boolean));
67
79
  const cues = map.filter((e) => e && typeof e.path === "string" && e.path !== musicKey && e.path !== manifest.music?.key && !voice.has(e.path)).flatMap((e) => {
68
80
  const lib = pulled.find((p) => p.path === e.path);
@@ -71,7 +83,7 @@ function cuesOf(project: Project, manifest: AssetManifest, musicKey: string | un
71
83
  // A sound is registered until the end of the stretch it sits in, which is often the rest of the film: its own length is the file's.
72
84
  const own = typeof lib?.meta?.durationSec === "number" && lib.meta.durationSec > 0 ? lib.meta.durationSec : 0;
73
85
  const seconds = Math.max(0, Math.round(Math.min(own || 0, Math.max(0, e.end - e.start)) * 1000) / 1000);
74
- return [{ at: Math.max(0, e.start), seconds, title, ...(lib?.kind ? { kind: lib.kind.slice(0, 24) } : {}) }];
86
+ return [{ at: Math.max(0, e.start), seconds, title, ...(lib?.kind ? { kind: lib.kind.slice(0, 24) } : {}), ...(lib?.id ? { id: lib.id.slice(0, 128) } : {}) }];
75
87
  }).slice(0, 1500);
76
88
  return cues.length ? { cues } : {};
77
89
  }
@@ -84,7 +96,7 @@ async function thumbsOf(video: string, film: TemplateFilm, dir: string): Promise
84
96
  const at = (scene.start + scene.frames / 2) / film.fps;
85
97
  const file = join(dir, `thumb-${i}.jpg`);
86
98
  try {
87
- await sh("ffmpeg", ["-nostdin", "-v", "error", "-y", "-ss", at.toFixed(3), "-i", video, "-frames:v", "1", "-vf", "scale=320:-2", "-q:v", "7", file]);
99
+ await sh("ffmpeg", ["-nostdin", "-v", "error", "-y", "-ss", at.toFixed(3), "-i", video, "-frames:v", "1", "-vf", "scale=640:-2", "-q:v", "6", file]);
88
100
  const bytes = readFileSync(file);
89
101
  if (!bytes.length || bytes.length > MAX_TEMPLATE_THUMB_BYTES) break;
90
102
  out.push(new Uint8Array(bytes));
@@ -176,7 +176,7 @@ const Empty = z.object({});
176
176
  // A template is a whole video project kept in its owner's private files: the bundle (the project folder as a .tgz, without out/, refs/
177
177
  // and caches), the rendered video when there is one, a small picture per scene, and `film`, a summary of the timeline that a page can
178
178
  // draw without opening the bundle. Only its owner can list, read, clone or delete it. Nothing here reaches the shared library.
179
- export const MAX_TEMPLATES = 20;
179
+ export const MAX_TEMPLATES = 50;
180
180
  export const MAX_TEMPLATE_THUMBS = 60;
181
181
  export const MAX_TEMPLATE_THUMB_BYTES = 200_000;
182
182
  export const TEMPLATE_BUNDLE_TYPE = "application/gzip";
@@ -194,9 +194,12 @@ export const TemplateFilmSchema = z.object({
194
194
  scenes: z.array(z.object({
195
195
  id: text(80), start: frames, frames, treatment: text(60).optional(), narration: text(4000).optional(),
196
196
  text: z.array(text(400)).max(20).optional(), words: z.array(z.number().min(0)).max(600).optional(),
197
+ // The plan's visual direction for the scene, and the components the composition times to it (read from its source).
198
+ notes: text(2000).optional(), components: z.array(text(80)).max(60).optional(),
197
199
  })).min(1).max(200),
198
200
  // The music track, with the seconds at which its beats fall inside the film.
199
- music: z.object({ title: text(200), seconds: z.number().min(0), bpm: z.number().optional(), beats: z.array(z.number().min(0)).max(4000).optional() }).optional(),
201
+ // `id` is the track's id in the library, when it was pulled from there.
202
+ music: z.object({ title: text(200), seconds: z.number().min(0), bpm: z.number().optional(), beats: z.array(z.number().min(0)).max(4000).optional(), id: text(128).optional() }).optional(),
200
203
  voice: z.boolean().optional(),
201
204
  captions: z.string().max(16).optional(),
202
205
  // What a listener hears besides the voice and the music's pulse, measured from the rendered film's sound: the seconds at which a hit
@@ -204,7 +207,11 @@ export const TemplateFilmSchema = z.object({
204
207
  hits: z.array(z.number().min(0)).max(3000).optional(),
205
208
  swells: z.array(z.object({ start: z.number().min(0), length: z.number().min(0) })).max(600).optional(),
206
209
  // The sound effects by name, when the render recorded them: each one's title, when it starts and how long it plays, in seconds.
207
- cues: z.array(z.object({ at: z.number().min(0), seconds: z.number().min(0), title: text(200), kind: text(24).optional() })).max(1500).optional(),
210
+ // `id` is the sound's id in the library, when it was pulled from there.
211
+ cues: z.array(z.object({ at: z.number().min(0), seconds: z.number().min(0), title: text(200), kind: text(24).optional(), id: text(128).optional() })).max(1500).optional(),
212
+ // Every component the composition uses: one written in the project, one pulled from the library, or one of the kit's. `uses` is how
213
+ // many times it appears, and `about` is the comment above a project component.
214
+ components: z.array(z.object({ name: text(80), from: z.enum(["project", "library", "kit"]), uses: z.number().int().min(1), about: text(400).optional() })).max(300).optional(),
208
215
  });
209
216
  export type TemplateFilm = z.infer<typeof TemplateFilmSchema>;
210
217
  export const TemplateSchema = z.object({
@@ -0,0 +1,167 @@
1
+ import ts from "typescript";
2
+ import { sourceFile } from "../render/static-check";
3
+
4
+ // Which components a composition uses, and in which scenes, read from its source without running it.
5
+ //
6
+ // A composition is one tree, not a file per scene, so "this component belongs to that scene" is read from how it is placed and timed.
7
+ // A call that is handed a scene's id as a string (`sceneById(manifest, "ask")`, a project's own `scene("ask", <>...</>)` or
8
+ // `at("ask")`), or a lookup by it (`s["ask"]`), is about that scene. A variable made from such an expression stands for the scene, and
9
+ // so does one computed from it. A component is counted for a scene when it is written inside such a call, or when its props (or its
10
+ // enclosing element's props) mention one. A component placed by plain numbers, or by nothing, is counted for no scene. It is a
11
+ // reading of the code, not a measurement.
12
+ export type UsedComponent = { name: string; from: "project" | "kit" | "library"; uses: number; scenes: string[] };
13
+
14
+ // Tags that are plumbing rather than something the film shows.
15
+ const PLUMBING = new Set(["Sequence", "AbsoluteFill", "Series", "Fragment", "React.Fragment", "Audio", "Img", "Video", "OffthreadVideo", "Loop", "Freeze"]);
16
+
17
+ export function componentsOf(source: string, opts: { sceneIds: string[]; pulled?: string[] } = { sceneIds: [] }): UsedComponent[] {
18
+ const sf = sourceFile(source, "Video.tsx");
19
+ const known = new Set(opts.sceneIds);
20
+ const pulled = new Set(opts.pulled ?? []);
21
+ // Where each imported name comes from.
22
+ const origin = new Map<string, UsedComponent["from"]>();
23
+ for (const stmt of sf.statements) {
24
+ if (!ts.isImportDeclaration(stmt) || !ts.isStringLiteral(stmt.moduleSpecifier) || !stmt.importClause) continue;
25
+ const spec = stmt.moduleSpecifier.text;
26
+ const from: UsedComponent["from"] | undefined = spec === "reelkit/kit" ? "kit" : spec.startsWith("./") ? "project" : undefined;
27
+ if (!from) continue;
28
+ const names: string[] = [];
29
+ if (stmt.importClause.name) names.push(stmt.importClause.name.text);
30
+ const bindings = stmt.importClause.namedBindings;
31
+ if (bindings && ts.isNamedImports(bindings)) for (const el of bindings.elements) if (!el.isTypeOnly) names.push(el.name.text);
32
+ for (const name of names) origin.set(name, from === "project" && pulled.has(name) ? "library" : from);
33
+ }
34
+
35
+ // Variables that stand for a scene: `const grow = sceneById(manifest, "grow")`, then anything computed from one, in source order.
36
+ const sceneOf = new Map<string, Set<string>>();
37
+ // The scene ids this one node names outright: a string argument of a call, or the key of a lookup. With the plan's ids known, any
38
+ // call counts; without them only `sceneById`, so an ordinary string is never taken for a scene.
39
+ const named = (n: ts.Node): string[] => {
40
+ if (ts.isCallExpression(n)) {
41
+ if (!known.size) { const arg = ts.isIdentifier(n.expression) && n.expression.text === "sceneById" ? n.arguments[1] : undefined; return arg && ts.isStringLiteralLike(arg) ? [arg.text] : []; }
42
+ return n.arguments.filter((a): a is ts.StringLiteralLike => ts.isStringLiteralLike(a) && known.has(a.text)).map((a) => a.text);
43
+ }
44
+ if (ts.isElementAccessExpression(n) && ts.isStringLiteralLike(n.argumentExpression) && known.has(n.argumentExpression.text)) return [n.argumentExpression.text];
45
+ return [];
46
+ };
47
+ const idsIn = (node: ts.Node): Set<string> => {
48
+ const found = new Set<string>();
49
+ const walk = (n: ts.Node) => {
50
+ for (const id of named(n)) found.add(id);
51
+ if (ts.isIdentifier(n)) for (const id of sceneOf.get(n.text) ?? []) found.add(id);
52
+ ts.forEachChild(n, walk);
53
+ };
54
+ walk(node);
55
+ return found;
56
+ };
57
+ const declare = (node: ts.Node) => {
58
+ if (ts.isVariableDeclaration(node) && node.initializer) {
59
+ const ids = idsIn(node.initializer);
60
+ if (ids.size) {
61
+ const bind = (name: ts.BindingName) => {
62
+ if (ts.isIdentifier(name)) sceneOf.set(name.text, new Set([...(sceneOf.get(name.text) ?? []), ...ids]));
63
+ else for (const el of name.elements) if (ts.isBindingElement(el)) bind(el.name);
64
+ };
65
+ bind(node.name);
66
+ }
67
+ }
68
+ ts.forEachChild(node, declare);
69
+ };
70
+ // Twice, so a variable computed from one declared further down (a helper above its data) is still followed.
71
+ declare(sf); declare(sf);
72
+
73
+ // The components the file declares for itself (a `const HookShot = () => ...`): what is written inside one belongs to the scenes that
74
+ // one is used in. The composition itself (`Video`) is not one of them.
75
+ const local = new Map<string, ts.Node>();
76
+ const collect = (node: ts.Node) => {
77
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && /^[A-Z]/.test(node.name.text) && node.initializer && (ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer))) local.set(node.name.text, node.initializer);
78
+ if (ts.isFunctionDeclaration(node) && node.name && /^[A-Z]/.test(node.name.text)) local.set(node.name.text, node);
79
+ ts.forEachChild(node, collect);
80
+ };
81
+ collect(sf);
82
+ local.delete("Video");
83
+
84
+ type Use = { scenes: Set<string>; owner: string };
85
+ const uses = new Map<string, Use[]>(); // imported components
86
+ const localUses = new Map<string, Use[]>(); // the file's own components
87
+ const note = (map: Map<string, Use[]>, name: string, scenes: Set<string>, owner: string) => map.set(name, [...(map.get(name) ?? []), { scenes: new Set(scenes), owner }]);
88
+ // The ids a condition compares against: `scene.id === "hook"`.
89
+ const compared = (node: ts.Node): Set<string> => {
90
+ const found = new Set<string>();
91
+ const walk = (n: ts.Node) => {
92
+ if (ts.isBinaryExpression(n) && (n.operatorToken.kind === ts.SyntaxKind.EqualsEqualsEqualsToken || n.operatorToken.kind === ts.SyntaxKind.EqualsEqualsToken)) {
93
+ for (const side of [n.left, n.right]) if (ts.isStringLiteralLike(side) && known.has(side.text)) found.add(side.text);
94
+ }
95
+ ts.forEachChild(n, walk);
96
+ };
97
+ walk(node);
98
+ return found;
99
+ };
100
+ const visit = (node: ts.Node, inherited: Set<string>, owner: string) => {
101
+ let here = inherited;
102
+ let inside = owner;
103
+ for (const [name, body] of local) if (body === node) { inside = name; here = new Set(); }
104
+ const opening = ts.isJsxSelfClosingElement(node) ? node : ts.isJsxElement(node) ? node.openingElement : undefined;
105
+ if (opening) {
106
+ const own = idsIn(opening.attributes);
107
+ here = own.size ? own : here;
108
+ const tag = opening.tagName.getText(sf);
109
+ const root = tag.split(".")[0]!;
110
+ if (local.has(root)) note(localUses, root, here, inside);
111
+ else if (origin.get(root) && !PLUMBING.has(tag) && /^[A-Z]/.test(tag)) note(uses, tag, here, inside);
112
+ }
113
+ // A block of JSX produced inside an expression that mentions a scene (a `.map` over that scene's items, a condition on its frame)
114
+ // belongs to that scene too.
115
+ if (ts.isJsxExpression(node) && node.expression) { const own = idsIn(node.expression); if (own.size && !ts.isJsxElement(node.expression)) here = new Set([...own]); }
116
+ // What is written inside a call that names a scene (`scene("ask", <>...</>)`) is that scene's.
117
+ const direct = named(node);
118
+ if (direct.length) here = new Set(direct);
119
+ // A list that pairs a scene's id with a component (`["hook", HookShot]`), or an object keyed by scene ids, places that component.
120
+ if (ts.isArrayLiteralExpression(node)) {
121
+ const ids = node.elements.filter((e): e is ts.StringLiteralLike => ts.isStringLiteralLike(e) && known.has(e.text)).map((e) => e.text);
122
+ if (ids.length) { here = new Set(ids); for (const e of node.elements) if (ts.isIdentifier(e) && local.has(e.text)) note(localUses, e.text, here, inside); }
123
+ }
124
+ if (ts.isPropertyAssignment(node) && (ts.isIdentifier(node.name) || ts.isStringLiteralLike(node.name)) && known.has(node.name.text)) {
125
+ here = new Set([node.name.text]);
126
+ if (ts.isIdentifier(node.initializer) && local.has(node.initializer.text)) note(localUses, node.initializer.text, here, inside);
127
+ }
128
+ // The branch taken when a scene's id is the one compared against is that scene's.
129
+ if (ts.isConditionalExpression(node) || ts.isIfStatement(node)) {
130
+ const cond = ts.isConditionalExpression(node) ? node.condition : node.expression;
131
+ const then = ts.isConditionalExpression(node) ? node.whenTrue : node.thenStatement;
132
+ const ids = compared(cond);
133
+ if (ids.size) {
134
+ ts.forEachChild(node, (child) => visit(child, child === then ? ids : here, inside));
135
+ return;
136
+ }
137
+ }
138
+ if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken) {
139
+ const ids = compared(node.left);
140
+ if (ids.size) { visit(node.left, here, inside); visit(node.right, ids, inside); return; }
141
+ }
142
+ if (ts.isCaseClause(node) && ts.isStringLiteralLike(node.expression) && known.has(node.expression.text)) here = new Set([node.expression.text]);
143
+ ts.forEachChild(node, (child) => visit(child, here, inside));
144
+ };
145
+ visit(sf, new Set(), "");
146
+
147
+ // The scenes of each of the file's own components: where it is placed, or, when it is placed inside another of them with no scene
148
+ // of its own, wherever that one is. Repeated until nothing changes, so nesting of any depth is followed.
149
+ const scenesOfLocal = new Map<string, Set<string>>([...local.keys()].map((name) => [name, new Set<string>()]));
150
+ for (let changed = true, rounds = 0; changed && rounds < 12; rounds++) {
151
+ changed = false;
152
+ for (const [name, list] of localUses) {
153
+ const mine = scenesOfLocal.get(name)!;
154
+ for (const use of list) for (const id of use.scenes.size ? use.scenes : (scenesOfLocal.get(use.owner) ?? [])) if (!mine.has(id)) { mine.add(id); changed = true; }
155
+ }
156
+ }
157
+ const used = new Map<string, { uses: number; scenes: Set<string> }>();
158
+ for (const [tag, list] of uses) {
159
+ const scenes = new Set<string>();
160
+ for (const use of list) for (const id of use.scenes.size ? use.scenes : (scenesOfLocal.get(use.owner) ?? [])) scenes.add(id);
161
+ used.set(tag, { uses: list.length, scenes });
162
+ }
163
+
164
+ const order = opts.sceneIds;
165
+ return [...used].map(([name, u]) => ({ name, from: origin.get(name.split(".")[0]!)!, uses: u.uses, scenes: [...u.scenes].sort((a, b) => order.indexOf(a) - order.indexOf(b)) }))
166
+ .sort((a, b) => (a.from === b.from ? b.uses - a.uses || a.name.localeCompare(b.name) : ["project", "library", "kit"].indexOf(a.from) - ["project", "library", "kit"].indexOf(b.from)));
167
+ }