rvlib-shipkit 2.3.0 → 2.4.0

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.
Files changed (80) hide show
  1. package/CHANGELOG.md +11 -2
  2. package/package.json +1 -1
  3. package/src/assets/kits/identity-kit.ts +5 -2
  4. package/src/cli/cmd_Commands.ts +4 -0
  5. package/src/cli/cmd_Review.ts +36 -10
  6. package/src/cli/cmd_Sound.ts +209 -0
  7. package/src/cli/cmd_Trailer.ts +7 -2
  8. package/src/cli/kit/verb.ts +11 -3
  9. package/src/cli.ts +2 -0
  10. package/src/core/app-icon.ts +10 -6
  11. package/src/repo-config.ts +3 -0
  12. package/src/review/format.ts +11 -3
  13. package/src/review/model.ts +47 -5
  14. package/src/review/project.ts +20 -4
  15. package/src/review/routes.ts +12 -1
  16. package/src/review/store.ts +15 -6
  17. package/src/review/unit.ts +19 -1
  18. package/src/serve/hub-app/admin-svc.ts +1 -1
  19. package/src/serve/hub-app/admin-view.tsx +1 -1
  20. package/src/serve/hub-app/hash-route.ts +12 -4
  21. package/src/serve/hub-app/home-st.ts +7 -1
  22. package/src/serve/hub-app/hub.css +38 -1
  23. package/src/serve/hub-app/icons.tsx +6 -0
  24. package/src/serve/hub-app/layout-st.ts +2 -2
  25. package/src/serve/hub-app/md.ts +32 -0
  26. package/src/serve/hub-app/project-list.css +9 -7
  27. package/src/serve/hub-app/project-list.tsx +5 -2
  28. package/src/serve/hub-app/projects-svc.ts +3 -2
  29. package/src/serve/hub-app/repo-st.ts +22 -7
  30. package/src/serve/hub-app/review/clip-st.ts +57 -7
  31. package/src/serve/hub-app/review/kind-view.tsx +78 -9
  32. package/src/serve/hub-app/review/review-answer.tsx +83 -53
  33. package/src/serve/hub-app/review/review-page.tsx +9 -14
  34. package/src/serve/hub-app/review/review-st.ts +141 -18
  35. package/src/serve/hub-app/review/review-views.tsx +154 -9
  36. package/src/serve/hub-app/review.css +70 -10
  37. package/src/serve/hub-app/wiki-st.ts +190 -0
  38. package/src/serve/hub-app/wikis-view.tsx +134 -5
  39. package/src/serve/hub-runtime.ts +4 -4
  40. package/src/serve/project-gutter-route.ts +25 -1
  41. package/src/serve/project-gutter.ts +1 -1
  42. package/src/serve/serve-hub.ts +7 -0
  43. package/src/shot/client.ts +17 -5
  44. package/src/shot/shooter.ts +19 -3
  45. package/src/shot/shot-ask.ts +9 -1
  46. package/src/shot/stepped-clip.ts +53 -0
  47. package/src/trailer/adapter.ts +33 -4
  48. package/src/trailer/build.ts +170 -29
  49. package/src/trailer/fonts.ts +55 -0
  50. package/src/trailer/footage.ts +4 -4
  51. package/src/trailer/grammar.ts +429 -67
  52. package/src/trailer/graph.ts +130 -56
  53. package/src/trailer/look.ts +3 -0
  54. package/src/trailer/motion.ts +207 -0
  55. package/src/trailer/plan.ts +2 -2
  56. package/src/trailer/prune.ts +13 -2
  57. package/src/trailer/score/compose.ts +18 -2
  58. package/src/trailer/score/gm-render.ts +43 -0
  59. package/src/trailer/score/gm.ts +90 -0
  60. package/src/trailer/score/midi.ts +143 -0
  61. package/src/trailer/score/pattern.ts +13 -6
  62. package/src/trailer/score/score.ts +28 -14
  63. package/src/trailer/score/sound.ts +12 -1
  64. package/src/trailer/score/synth.ts +24 -2
  65. package/src/trailer/sound/audio.ts +148 -0
  66. package/src/trailer/sound/brand.ts +42 -0
  67. package/src/trailer/sound/comfy.ts +38 -0
  68. package/src/trailer/sound/eleven.ts +72 -0
  69. package/src/trailer/sound/fluid.ts +71 -0
  70. package/src/trailer/sound/gen.ts +139 -0
  71. package/src/trailer/sound/index.ts +60 -0
  72. package/src/trailer/sound/presets.ts +69 -0
  73. package/src/trailer/sound/run.ts +189 -0
  74. package/src/trailer/titles.ts +451 -235
  75. package/src/trailer/url.ts +18 -3
  76. package/src/trailer/vocab.ts +12 -1
  77. package/src/trailer/words.ts +29 -8
  78. package/src/wiki/graph-mount.ts +3 -0
  79. package/src/wiki/graph-page.ts +45 -9
  80. package/src/wiki/graph-pages.ts +128 -0
package/CHANGELOG.md CHANGED
@@ -15,8 +15,17 @@
15
15
  - **`sk time` shows turn count, what makes agents slow when no command is.** Every call costs a model turn before it. The summary gives, for the main sessions and for the sub-agents, the model time before each call, the share of single read calls and the share sent in parallel. The sub-agent lines lead with a verdict (turn count or command speed, with a fix line when reads pile up one per turn), then their work split (model, tools, their own waits), run length p50 and p90, and calls per run. The `turns` view lists the runs that worked longest with their turns, read and parallel shares and model per turn, then the read shapes by the model time of the turns that issued them. A read is Read, Grep, Glob, or a Bash command of a read shape whose every part reads (a redirect to a file, `sed -i`, `find -delete` or `-exec`, `tee`, or a second command that writes or runs makes it not one), judged when a call is indexed and again when the rules change (`src/agent-time/turns.ts`). A call is parallel when it starts while the previous call still runs, within 2 s of its start. The model stretch before a call comes from the same sweep that cuts the wall. pkm-multi, every recorded session: sub-agents worked 905.4h, model 67%, 7.7 s of model before each of 283,821 calls, 51% single reads, 2.3% in parallel.
16
16
  - **A message relayed into a sub-agent ends its park.** The harness writes "The coordinator sent a message while you were working" (or the user, or another session) into a sub-agent as an `isMeta` user entry with no peer origin, which the transcript reader dropped, so a sub-agent that handed back and sat for hours until someone wrote to it counted those hours as model: 352h of pkm-multi's sub-agent time. It is now a `message` event that counts as a prompt (idle past 30 minutes). The index version moves to 6, so every file is read again once.
17
17
 
18
- ## Unreleased (patch)
19
-
18
+ ## 2.4.0
19
+
20
+ - **The wiki preview opens on the index, beside the graph on the left, right or bottom.** A switch of three icons in the preview's head places it, kept per browser; buttons open the page in VS Code and copy its path. Refs read by kind: on demand (`→`, a link, a path in a code span, written `→ path` whichever the page used) in orange, loaded with the page (`@`) in blue and bold. A page that names `agent/spec.md` opens the agent/ graph's `spec.md`.
21
+ - **The wikis page searches and previews.** A search bar over the graph finds every page by title and full text (`/graph/<id>/search`), a dropdown the arrows walk; enter, or a click on a ball, opens the page in a resizable panel on the right (`/graph/<id>/page`), whose refs open their page and focus its ball. The graph follows the hub's theme. `serve.wikiGraph.dir` names the folder a custom wiki's node ids are relative to (default the repo root).
22
+ - **A choice card.** `sk review add --choice --view "voice A" a.flac --note "low" --view "voice B" b.flac`: every view is an option he scores (1 to 5 stars) and picks one of. His pick is an approval carrying `pick`; `inbox` and `watch` print `picked [voice B]` and each score. An approval with reactions or a pick is no longer counted as a plain one by `inbox`.
23
+ - **Options as thin rows.** A choice, and any card of two clips or sounds or more, shows its views one below the other: ▶, the label, a strip that scrubs it, the time, then stars and « pick » (a choice) or OK, fix, no (a group), ✎ a word, ⤢ the full view. Keys 1 to 9 play a row, ⇧1 to ⇧5 score it, P picks it. « rows | full » switches back to every view stacked.
24
+ - **One clip or sound plays at a time on the whole page.** Any audio or video that starts pauses the one playing (the review page, the trailer page, the atelier's rolls). A card's first clip loops alone; the others wait on their first frame.
25
+ - **The card's header reads.** The title, then one line: the list's own pill, the lane, when it was sent or answered, the commit, an id to copy. No subject row, no id row, no banner. `top` is one column from the top: the header and the verdicts, the views, then the answer. A link to a card on a phone opens the card.
26
+ - **An svg mark is a repo's icon.** `.shipkit/identity/icon.svg` comes right after `icon.png` for the project list's icon and for the app icon, which draws it on the identity plate like a png.
27
+ - **The hub's pages read from the left.** Every page card of a repo (admin, checks, tests, plan, git, when, processes, status, readme) and a review card's pictures start at the left of their pane instead of floating centred; a guard keeps it.
28
+ - **The rail draws bigger icons: each repo's app icon.** The sizes are 24, 32 and 40px (S, M, L) with 6px a side, the rail 36, 44 or 52px; the ask badge sits on the icon's top right corner. Folded, a row wears the repo's app icon, its mark on the identity plate the admin page previews, cropped to the plate (`appIcon` on `/api/projects.json`, `appIconPngs(…, 'plate')`, drawn once per mark and colours); a repo with no png mark keeps its plain icon. A repo closed in VS Code dims its name, never its icon.
20
29
  - **The rail's icons come in three sizes.** Folded, the list's foot shows an S, M or L button: 18, 24 or 32px icons, the rail 44, 50 or 58px wide to fit, kept per browser (`hub:rail-icon`). M is the default.
21
30
  - **The project list folds to an icon rail.** ⌘B, the fold button at the list's foot, or a drag of its separator below the list's min folds it to 44px of icons: the names, the ⌘ hints and the step hint hide, a badge rides its icon's corner, the hover card still names the row. A drag out or ⌘B again unfolds it to its kept width; the fold is kept per browser (`hub:list-rail`). The dock moves to ⌘⇧B.
22
31
  - **⌘; opens the insights**, its filter focused, so a package name is one key and a word away.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rvlib-shipkit",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "funding": "https://github.com/sponsors/rvion",
5
5
  "description": "All-in-one repo companion: ci gate (task DAG), auto-discovered health checks, release-contract fulfillness, asks queue, md-wiki tools, asset atelier, live tui",
6
6
  "license": "MIT",
@@ -16,6 +16,7 @@ import { dirname, join } from 'path'
16
16
  import sharp from 'sharp'
17
17
  import { defineKind, type CanvasGen, type ComfyGen, type Kind } from '../kit-config.ts'
18
18
  import { HOUSE_FAMILIES, type TrailerLookInput } from '../../trailer/look.ts'
19
+ import type { TrailerSoundInput } from '../../trailer/sound/brand.ts'
19
20
 
20
21
  export type Brand = {
21
22
  name: string
@@ -31,8 +32,10 @@ export type Brand = {
31
32
  * name/tagline right); the hook owns compositing the mark if it wants one */
32
33
  drawOg?: (ctx: SKRSContext2D, brand: Brand, size: { w: number; h: number }) => void
33
34
  /** the look every trailer of the repo wears (titles, card, face, logo);
34
- * each value left out is derived from the palette (trailer/look.ts) */
35
- trailer?: TrailerLookInput
35
+ * each value left out is derived from the palette (trailer/look.ts);
36
+ * its sound section (`comfy`, `elevenKey`, `voice`, `voices`) makes the
37
+ * voice, sfx and music lines (trailer/sound/brand.ts) */
38
+ trailer?: TrailerLookInput & TrailerSoundInput
36
39
  }
37
40
 
38
41
  export const FAVICON_SIZE = 512
@@ -20,6 +20,8 @@ export type CommandRow = {
20
20
  /** a filmable command's take version and length (build/trailer.md, Film lines) */
21
21
  version?: number | string
22
22
  length?: 'own'
23
+ words?: Record<string, unknown>
24
+ stamp?: string[]
23
25
  /** listed on the essentials page */
24
26
  essential?: Essential
25
27
  /** what a kit row runs */
@@ -81,6 +83,8 @@ export function commandRows(plain: Definition[], detailed: Definition[] = [], bi
81
83
  if (m.film != null) row.film = m.film
82
84
  if (m.version != null) row.version = m.version
83
85
  if (m.length != null) row.length = m.length
86
+ if (m.words != null) row.words = m.words
87
+ if (m.stamp != null) row.stamp = m.stamp
84
88
  if (m.essential != null) row.essential = m.essential
85
89
  const v = ROW_OF.get(c)
86
90
  if (v != null) row.runs = runText(v.run)
@@ -83,7 +83,7 @@ function tileOf(arg: string): { file: string; label: string } {
83
83
 
84
84
  type ViewInput = import('../review/store.ts').ViewInput
85
85
 
86
- /** `--view "<label>" a.png [b.png]` and `--gallery "<label>" a.png b.png …` out of the args */
86
+ /** `--view "<label>" a.png [b.png] [--note "<text>"]` and `--gallery "<label>" a.png b.png …` out of the args */
87
87
  function takeViews(args: readonly string[]): { views: ViewInput[]; rest: string[] } {
88
88
  const views: ViewInput[] = []
89
89
  const rest: string[] = []
@@ -100,6 +100,13 @@ function takeViews(args: readonly string[]): { views: ViewInput[]; rest: string[
100
100
  if (a === '--gallery') views.push({ label, gallery: files.map(tileOf) })
101
101
  else views.push(files.length === 1 ? { label, image: files[0] } : { label, before: files[0], after: files[1] })
102
102
  i += 1 + files.length
103
+ if (args[i + 1] === '--note') {
104
+ const note = args[i + 2]
105
+ if (note === undefined || note.startsWith('--')) throw new Error(`--view "${label}" … --note "<text>": a line on that view`)
106
+ const v = views[views.length - 1]
107
+ if (v) v.note = note
108
+ i += 2
109
+ }
103
110
  }
104
111
  return { views, rest }
105
112
  }
@@ -139,7 +146,8 @@ const ADD_FLAGS = `flags:
139
146
  --audio <sound.wav> a sound: a waveform and a player (wav, mp3, flac, ogg, opus, m4a)
140
147
  --before <a.png> --after <b.png> a before and after: two images, two clips of one size, or two sounds
141
148
  --label "<label>" the first view's label
142
- --view "<label>" <a.png> [<b.png>] another view of the same subject (repeat it)
149
+ --view "<label>" <a.png> [<b.png>] another view of the same subject (repeat it); --note "<text>" right after it: a line on that view
150
+ --choice every view is an option he scores and picks one of (two --view or more)
143
151
  --gallery "<label>" <a.png> … a grid of small images; "<tile label>=<c.png>" names a tile (repeat it)
144
152
  --topic <topic> what part of the project it is about (sk review topics)
145
153
  --subject <kind> what is judged (default shot; the repo's unit names its kinds)
@@ -150,6 +158,19 @@ const ADD_FLAGS = `flags:
150
158
  --keeps "<what stays>" with --supersedes on an approved look: what of it stays
151
159
  --new --why "<reason>" a new card beside an active one of the same subject`
152
160
 
161
+ /** the help of add or add-view, then the flags this repo's review unit adds (any kind's, then each kind's under
162
+ * `--subject <kind>`); a repo with no unit, or one that does not load, says so on one line */
163
+ async function helpWithUnit(context: Ctx, usage: string, kinds: boolean): Promise<number> {
164
+ context.stdout.write(usage)
165
+ await run(context, (h) => {
166
+ const lines = Object.entries(h.unit?.flags ?? {}).map(([k, d]) => ` --${k} ${d}`)
167
+ if (kinds) for (const [name, k] of Object.entries(h.unit?.kinds ?? {})) for (const [f, d] of Object.entries(k.flags ?? {})) lines.push(` --${f} ${d} (with --subject ${name})`)
168
+ h.out(lines.length > 0 ? `\nthis repo's review unit adds:\n${lines.join('\n')}` : "\nthis repo's review unit adds no flags")
169
+ return 0
170
+ })
171
+ return 0
172
+ }
173
+
153
174
  export class ReviewAddCommand extends Command {
154
175
  static override paths = [['review', 'add']]
155
176
  static override usage = Command.Usage({
@@ -160,14 +181,14 @@ export class ReviewAddCommand extends Command {
160
181
  ['one image', 'sk review add --title "the new menu" --what "the menu, bigger" --image shots/menu.png'],
161
182
  ['a before and after', 'sk review add --title "lamps lit" --what "..." --before a.png --after b.png'],
162
183
  ['a second version after his answer', 'sk review add --title "lamps lit" --what "..." --image c.png --supersedes <id>'],
184
+ ['a pick among three voices', 'sk review add --choice --title "the narrator" --what "..." --view "voice 0" a.flac --note "low, breathy" --view "voice 1" b.flac --view "voice 2" c.flac'],
163
185
  ],
164
186
  })
165
187
  args = Option.Proxy()
166
188
 
167
189
  async execute(): Promise<number> {
168
190
  if (this.args.some(isHelp)) {
169
- this.context.stdout.write(this.cli.usage(ReviewAddCommand, { detailed: true }))
170
- return 0
191
+ return helpWithUnit(this.context, this.cli.usage(ReviewAddCommand, { detailed: true }), true)
171
192
  }
172
193
  return run(this.context, async (h) => {
173
194
  const { addItem } = await import('../review/store.ts')
@@ -176,7 +197,7 @@ export class ReviewAddCommand extends Command {
176
197
  const kindName = rest[rest.indexOf('--subject') + 1]
177
198
  const kind = rest.includes('--subject') && kindName ? h.unit?.kinds?.[kindName] : undefined
178
199
  const own = [...Object.keys(kind?.flags ?? {}), ...Object.keys(h.unit?.flags ?? {})]
179
- const f = flagsOf(rest, ['title', 'what', 'image', 'video', 'audio', 'before', 'after', 'label', 'topic', 'subject', 'commit', 'lane', 'supersedes', 'keeps', 'why', 'live'], ['answers', ...own], ['new', 'wip'])
200
+ const f = flagsOf(rest, ['title', 'what', 'image', 'video', 'audio', 'before', 'after', 'label', 'topic', 'subject', 'commit', 'lane', 'supersedes', 'keeps', 'why', 'live'], ['answers', ...own], ['new', 'wip', 'choice'])
180
201
  if (f.positionals.length > 0) throw new Error(`unexpected ${f.positionals.join(' ')}`)
181
202
  const o = f.one
182
203
  const repoFlags = Object.fromEntries(own.flatMap((k) => (f.many[k] ? [[k, f.many[k]]] : [])))
@@ -209,6 +230,7 @@ export class ReviewAddCommand extends Command {
209
230
  ...(o.why ? { why: o.why } : {}),
210
231
  ...(answers.length > 0 ? { answers } : {}),
211
232
  ...(f.on.has('new') ? { fresh: true } : {}),
233
+ ...(f.on.has('choice') ? { choice: true } : {}),
212
234
  ...(o.live !== undefined ? { image: wipViews[0]?.image ?? '', views: wipViews.slice(1), wip: { live: o.live } } : { views }),
213
235
  }
214
236
  if (h.unit?.add) input = await h.unit.add(input, repoFlags)
@@ -225,27 +247,29 @@ export class ReviewAddViewCommand extends Command {
225
247
  static override usage = Command.Usage({
226
248
  category: 'Review',
227
249
  description: 'another angle on a card he has not answered yet: an image, a pair, a clip or a gallery',
228
- details: `${DETAILS}\n\nsk review add-view <id> "<label>" <a.png> [<b.png>] | --video <clip.mp4> | --gallery <a.png> <b.png> …`,
250
+ details: `${DETAILS}\n\nsk review add-view <id> "<label>" <a.png> [<b.png>] | --video <clip.mp4> | --audio <sound.wav> | --gallery <a.png> <b.png> … [--note "<a line on that view>"]`,
229
251
  examples: [['a view from above', 'sk review add-view <id> "from above" top.png']],
230
252
  })
231
253
  args = Option.Proxy()
232
254
 
233
255
  async execute(): Promise<number> {
234
256
  if (this.args.some(isHelp)) {
235
- this.context.stdout.write(this.cli.usage(ReviewAddViewCommand, { detailed: true }))
236
- return 0
257
+ return helpWithUnit(this.context, this.cli.usage(ReviewAddViewCommand, { detailed: true }), false)
237
258
  }
238
259
  return run(this.context, async (h) => {
239
260
  const { addView } = await import('../review/store.ts')
240
261
  const own = Object.keys(h.unit?.flags ?? {})
241
262
  const repoFlags: Record<string, string[]> = {}
242
263
  const args: string[] = []
264
+ let note: string | undefined
243
265
  for (let i = 0; i < this.args.length; i++) {
244
266
  const a = this.args[i] ?? ''
245
267
  const name = a.startsWith('--') ? a.slice(2) : ''
246
268
  if (own.includes(name)) (repoFlags[name] ??= []).push(this.args[++i] ?? '')
269
+ else if (name === 'note') note = this.args[++i]
247
270
  else args.push(a)
248
271
  }
272
+ if (this.args.includes('--note') && (note === undefined || note.startsWith('--'))) throw new Error('--note "<text>": a line on that view')
249
273
  const [id, label, ...rest] = args
250
274
  if (!id || !label) throw new Error('add-view <id> "<label>" <media>')
251
275
  const v: ViewInput =
@@ -262,6 +286,7 @@ export class ReviewAddViewCommand extends Command {
262
286
  : (() => {
263
287
  throw new Error('add-view <id> "<label>" <a.png> [<b.png>] | --video <clip> | --gallery <a.png> …')
264
288
  })()
289
+ if (note !== undefined) v.note = note
265
290
  const extra = h.unit?.addView ? await h.unit.addView(v, repoFlags) : {}
266
291
  const item = await addView(h.dir, id, v, extra)
267
292
  h.out(`${id}: view ${item.views.length} "${label}" added`)
@@ -380,10 +405,11 @@ export class ReviewInboxCommand extends Command {
380
405
  const followed = followedIds(all.items)
381
406
  // what he or a player raised and nobody closed is the agents' move, read or not
382
407
  const open = all.items.filter((x) => of(x) && x.item.origin.by !== 'agent' && moveOf(stateOf(x, followed.has(x.item.id)).state) === 'agents' && !news.includes(x))
383
- // a plain approval (no comment, no rectangle, no reply) asks for no work: counted on one line unless --all
408
+ // a plain approval (no comment, no rectangle, no reaction, no pick, no reply) asks for no work: counted on one
409
+ // line unless --all
384
410
  const plain = (x: (typeof news)[number]): boolean => {
385
411
  const v = verdictOf(x)
386
- return v?.status === 'approved' && !v.seen && !v.comment && v.rects.length === 0 && unreadBy(x, 'agent').length === 0
412
+ return v?.status === 'approved' && !v.seen && !v.comment && v.rects.length === 0 && !v.reactions && v.pick === undefined && unreadBy(x, 'agent').length === 0
387
413
  }
388
414
  const shown = [...(this.all ? news : news.filter((x) => !plain(x))), ...open]
389
415
  if (shown.length === 0 && news.length === 0) h.out('review: no unseen feedback or reply')
@@ -0,0 +1,209 @@
1
+ // sk sound: the house sound generators, one verb each, for a trailer as much as a game. Each writes --out and says its
2
+ // measures; a trailer's `voice`, `sfx` and `music` words run them through its sound cache (trailer/sound/). Every
3
+ // module loads inside execute: the cli registers this file on every `sk` call
4
+ import { Command, Option } from 'clipanion'
5
+
6
+ const PAGE = 'Page: wiki build/trailer.md, Sound'
7
+
8
+ async function run(context: { stdout: NodeJS.WritableStream; stderr: NodeJS.WritableStream }, body: (say: (l: string) => void, root: string, cwd: string) => Promise<void>): Promise<number> {
9
+ const say = (l: string): void => void context.stdout.write(`${l}\n`)
10
+ const { findRepoRoot } = await import('../ts/config.ts')
11
+ const cwd = process.cwd()
12
+ try {
13
+ await body(say, findRepoRoot(cwd), cwd)
14
+ return 0
15
+ } catch (e) {
16
+ context.stderr.write(`FAIL sound: ${e instanceof Error ? e.message : String(e)}\n`)
17
+ return 1
18
+ }
19
+ }
20
+
21
+ /** the brand's sound section, when the repo has a brand */
22
+ async function brandSound(root: string): Promise<import('../trailer/sound/brand.ts').TrailerSoundInput | undefined> {
23
+ const { loadBrand } = await import('../assets/run-assets.ts')
24
+ const b = await loadBrand(root)
25
+ if (typeof b === 'string') throw new Error(b)
26
+ return b?.trailer
27
+ }
28
+
29
+ const COMFY = 'the comfy-ts module folder (default: the brand\'s trailer.comfy)'
30
+
31
+ /** --out, which every making verb needs */
32
+ function outOf(out: string | undefined): string {
33
+ if (out === undefined) throw new Error('--out <file>: the file it writes')
34
+ return out
35
+ }
36
+
37
+ const num = (name: string, v: string | undefined, fallback: number): number => {
38
+ if (v === undefined) return fallback
39
+ const n = Number(v)
40
+ if (!Number.isFinite(n)) throw new Error(`${name} takes a number, got ${v}`)
41
+ return n
42
+ }
43
+
44
+ export class SoundVoiceCommand extends Command {
45
+ static override paths = [['sound', 'voice']]
46
+ static override usage = Command.Usage({
47
+ category: 'Sound',
48
+ description: 'one spoken line in a voice: ElevenLabs, or comfy-ts cloning a clip or a designed voice',
49
+ details: `no voice flag: the brand's trailer.voice (--by <name>: one of its trailer.voices), else the house narrator. ElevenLabs is paid: it says the characters spent and left. The line is trimmed at both ends and set at -16 LUFS. ${PAGE}`,
50
+ examples: [
51
+ ['the brand\'s voice', 'sk sound voice "The storm is coming." --out lines/storm.wav'],
52
+ ['an ElevenLabs voice, its second take', 'sk sound voice "[whispers] Hey." --eleven pNInz6obpgDQGcFmaJgB --seed 2 --out hey.wav'],
53
+ ['a clip cloned, free', 'sk sound voice "Ahoy." --clone voices/sailor.flac --out ahoy.wav'],
54
+ ['a voice designed from words, free', 'sk sound voice "Ahoy." --design "an old sailor, gravelly and warm" --out ahoy.wav'],
55
+ ],
56
+ })
57
+ text = Option.String({ required: true, name: 'line' })
58
+ by = Option.String('--by', { description: 'one of the brand\'s trailer.voices' })
59
+ eleven = Option.String('--eleven', { description: 'an ElevenLabs voice id (paid)' })
60
+ model = Option.String('--model', { description: 'the ElevenLabs model (default eleven_v3)' })
61
+ stability = Option.String('--stability')
62
+ similarity = Option.String('--similarity')
63
+ speed = Option.String('--speed')
64
+ clone = Option.String('--clone', { description: 'a clean 5 to 10 s clip to clone, repo relative' })
65
+ transcript = Option.String('--transcript', { description: 'what the clip says (default: <clip>.txt)' })
66
+ design = Option.String('--design', { description: 'a voice designed from its description' })
67
+ designLine = Option.String('--design-line', { description: 'what the designed voice\'s reference reads' })
68
+ designSeed = Option.String('--design-seed')
69
+ engine = Option.String('--engine', { description: 'comfy-ts engine: chatterbox, qwen3, fishs2, cosyvoice3, omnivoice' })
70
+ language = Option.String('--language', { description: 'en, fr… (ElevenLabs) or English, French… (comfy-ts)' })
71
+ comfy = Option.String('--comfy', { description: COMFY })
72
+ seed = Option.String('--seed', { description: 'the take (default 1)' })
73
+ out = Option.String('--out', { description: 'the file it writes (required)' })
74
+
75
+ execute(): Promise<number> {
76
+ return run(this.context, async (say, root, cwd) => {
77
+ const [{ resolve }, { genVoice }, { voiceOf }] = await Promise.all([import('node:path'), import('../trailer/sound/gen.ts'), import('../trailer/sound/brand.ts')])
78
+ const brand = await brandSound(root)
79
+ const given = [this.eleven, this.clone, this.design].filter((x) => x !== undefined).length
80
+ if (given > 1) throw new Error('one voice: --eleven, --clone or --design')
81
+ const n = (k: string, v: string | undefined): number | undefined => (v === undefined ? undefined : num(k, v, 0))
82
+ const lang = this.language !== undefined ? { language: this.language } : {}
83
+ const eng = this.engine !== undefined ? { engine: this.engine } : {}
84
+ let voice
85
+ if (this.eleven !== undefined) voice = { eleven: this.eleven, ...(this.model ? { model: this.model } : {}), ...strip({ stability: n('--stability', this.stability), similarity: n('--similarity', this.similarity), speed: n('--speed', this.speed) }), ...lang }
86
+ else if (this.clone !== undefined) voice = { clone: this.clone, ...(this.transcript !== undefined ? { transcript: this.transcript } : {}), ...eng, ...lang }
87
+ else if (this.design !== undefined) voice = { design: this.design, ...(this.designLine !== undefined ? { line: this.designLine } : {}), ...strip({ seed: n('--design-seed', this.designSeed) }), ...eng, ...lang }
88
+ else voice = voiceOf(brand, this.by)
89
+ await genVoice({ root, text: this.text, voice, seed: num('--seed', this.seed, 1), comfy: this.comfy ?? brand?.comfy, elevenKey: brand?.elevenKey, out: resolve(cwd, outOf(this.out)), say })
90
+ })
91
+ }
92
+ }
93
+
94
+ const strip = <T extends Record<string, unknown>>(o: T): Partial<T> => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined)) as Partial<T>
95
+
96
+ export class SoundSfxCommand extends Command {
97
+ static override paths = [['sound', 'sfx']]
98
+ static override usage = Command.Usage({
99
+ category: 'Sound',
100
+ description: 'a sound effect from a prompt: comfy-ts stable-audio, trimmed, its peak at -3 dBFS',
101
+ details: `its silence before is cut, its tail cut where it falls 45 dB under its peak with a 30 ms fade; --hit keeps its first hit alone. The GPU box must be up: sk gp start comfyui. ${PAGE}`,
102
+ examples: [
103
+ ['a clash cut to its first hit', 'sk sound sfx "a sword clash, metal on metal" --hit --out sfx/clash.wav'],
104
+ ['four seconds of rain, another take', 'sk sound sfx "rain on a tin roof" --seconds 4 --seed 2 --out sfx/rain.wav'],
105
+ ],
106
+ })
107
+ prompt = Option.String({ required: true, name: 'prompt' })
108
+ seconds = Option.String('--seconds', { description: 'seconds made (default 2)' })
109
+ seed = Option.String('--seed', { description: 'the take (default 1)' })
110
+ hit = Option.Boolean('--hit', false, { description: 'its first hit alone' })
111
+ comfy = Option.String('--comfy', { description: COMFY })
112
+ out = Option.String('--out', { description: 'the file it writes (required)' })
113
+
114
+ execute(): Promise<number> {
115
+ return run(this.context, async (say, root, cwd) => {
116
+ const [{ resolve }, { genSfx }] = await Promise.all([import('node:path'), import('../trailer/sound/gen.ts')])
117
+ const comfy = this.comfy ?? (await brandSound(root))?.comfy
118
+ await genSfx({ prompt: this.prompt, seconds: num('--seconds', this.seconds, 2), seed: num('--seed', this.seed, 1), hit: this.hit, comfy, out: resolve(cwd, outOf(this.out)), say })
119
+ })
120
+ }
121
+ }
122
+
123
+ export class SoundMusicCommand extends Command {
124
+ static override paths = [['sound', 'music']]
125
+ static override usage = Command.Usage({
126
+ category: 'Sound',
127
+ description: 'a music bed or a song from a prompt: comfy-ts stable-audio or ace-step, -16 LUFS',
128
+ details: `stable-audio plays an instrumental bed (up to 300 s); ace-step reads the prompt's first line as its style caption and sings the lines after it ([Verse], [Chorus]), else plays instrumental. The GPU box must be up: sk gp start comfyui. ${PAGE}`,
129
+ examples: [
130
+ ['a 30 s bed', 'sk sound music "warm lofi piano bed, slow, soft" --seconds 30 --out music/bed.wav'],
131
+ ['a sung theme, its third take', 'sk sound music "an epic orchestral theme, brass and choir" --engine ace-step --seed 3 --out music/theme.wav'],
132
+ ],
133
+ })
134
+ prompt = Option.String({ required: true, name: 'prompt' })
135
+ engine = Option.String('--engine', { description: 'stable-audio (default) or ace-step' })
136
+ seconds = Option.String('--seconds', { description: 'seconds made (default 30)' })
137
+ seed = Option.String('--seed', { description: 'the take (default 1)' })
138
+ comfy = Option.String('--comfy', { description: COMFY })
139
+ out = Option.String('--out', { description: 'the file it writes (required)' })
140
+
141
+ execute(): Promise<number> {
142
+ return run(this.context, async (say, root, cwd) => {
143
+ const [{ resolve }, { genMusic }] = await Promise.all([import('node:path'), import('../trailer/sound/gen.ts')])
144
+ const engine = this.engine ?? 'stable-audio'
145
+ if (engine !== 'stable-audio' && engine !== 'ace-step') throw new Error(`--engine ${engine}: stable-audio or ace-step`)
146
+ const comfy = this.comfy ?? (await brandSound(root))?.comfy
147
+ await genMusic({ prompt: this.prompt.replaceAll('\\n', '\n'), engine, seconds: num('--seconds', this.seconds, 30), seed: num('--seed', this.seed, 1), comfy, out: resolve(cwd, outOf(this.out)), say })
148
+ })
149
+ }
150
+ }
151
+
152
+ export class SoundMidiCommand extends Command {
153
+ static override paths = [['sound', 'midi']]
154
+ static override usage = Command.Usage({
155
+ category: 'Sound',
156
+ description: 'a MIDI file through the soundfont: FluidSynth, its own room on, -16 LUFS',
157
+ details: `the machine's fluidsynth and ~/.local/share/soundfonts/GeneralUser-GS.sf2 (SK_SOUNDFONT names another). ${PAGE}`,
158
+ examples: [['a .mid as a wav', 'sk sound midi theme.mid --out music/theme.wav']],
159
+ })
160
+ file = Option.String({ required: true, name: 'file.mid' })
161
+ out = Option.String('--out', { description: 'the file it writes (required)' })
162
+
163
+ execute(): Promise<number> {
164
+ return run(this.context, async (say, _root, cwd) => {
165
+ const [{ resolve }, { genMidi }] = await Promise.all([import('node:path'), import('../trailer/sound/gen.ts')])
166
+ await genMidi({ file: resolve(cwd, this.file), out: resolve(cwd, outOf(this.out)), say })
167
+ })
168
+ }
169
+ }
170
+
171
+ export class SoundMeasureCommand extends Command {
172
+ static override paths = [['sound', 'measure']]
173
+ static override usage = Command.Usage({
174
+ category: 'Sound',
175
+ description: 'a sound as numbers and a picture: length, peak, loudness, onsets, the waveform png',
176
+ details: `nobody hears a build: read these before you report a sound. The png lands at <file>.png. ${PAGE}`,
177
+ examples: [['a made sfx', 'sk sound measure sfx/clash.wav']],
178
+ })
179
+ file = Option.String({ required: true, name: 'file' })
180
+
181
+ execute(): Promise<number> {
182
+ return run(this.context, async (say, _root, cwd) => {
183
+ const [{ resolve }, { measure, measureLine, waveformPng }] = await Promise.all([import('node:path'), import('../trailer/sound/audio.ts')])
184
+ const f = resolve(cwd, this.file)
185
+ say(`${this.file}: ${measureLine(await measure(f))}`)
186
+ await waveformPng(f, `${f}.png`)
187
+ say(`ok: ${f}.png`)
188
+ })
189
+ }
190
+ }
191
+
192
+ export class SoundGmCommand extends Command {
193
+ static override paths = [['sound', 'gm']]
194
+ static override usage = Command.Usage({
195
+ category: 'Sound',
196
+ description: 'the General MIDI programs, drums and kits a score\'s gm: instruments take',
197
+ details: `a score track plays one through the soundfont: track piano gm:acoustic-grand, drums k=gm:kick, or gm('strings', { vol, pan, reverb }) in its code. ${PAGE}`,
198
+ examples: [['every name', 'sk sound gm']],
199
+ })
200
+
201
+ execute(): Promise<number> {
202
+ return run(this.context, async (say) => {
203
+ const { gmTable } = await import('../trailer/score/gm.ts')
204
+ for (const l of gmTable()) say(l)
205
+ })
206
+ }
207
+ }
208
+
209
+ export const SOUND_COMMANDS = [SoundVoiceCommand, SoundSfxCommand, SoundMusicCommand, SoundMidiCommand, SoundMeasureCommand, SoundGmCommand]
@@ -32,17 +32,19 @@ export class TrailerCommand extends Command {
32
32
  ['one cut by its file', 'sk trailer trailer/launch/main.trailer'],
33
33
  ['film again the takes of two shots', 'sk trailer --refilm intro,menu'],
34
34
  ['encoded by x264 for a release', 'sk trailer --final'],
35
+ ['the music variant b on the same picture', 'sk trailer --variant b'],
35
36
  ],
36
37
  })
37
38
 
38
39
  file = Option.String({ required: false })
39
40
  refilm = Option.String('--refilm', { tolerateBoolean: true, description: 'film again: every stale take, or the named shots (a,b)' })
40
41
  final = Option.Boolean('--final', false, { description: 'encode with x264 for a release (default: the fast draft encoder)' })
42
+ variant = Option.String('--variant', { description: 'a music variant the cut names (music … variant <name>): out/<name>-<variant>.mp4' })
41
43
 
42
44
  async execute(): Promise<number> {
43
45
  return run(this.context, async (say, root, cwd) => {
44
46
  const b = await import('../trailer/build.ts')
45
- const l = await b.load(b.trailerFile(this.file, root, cwd), root)
47
+ const l = await b.load(b.trailerFile(this.file, root, cwd), root, this.variant !== undefined ? { variant: this.variant } : {})
46
48
  if (!b.sayErrors(l, say, cwd)) return 1
47
49
  await b.build(l, { refilm: refilmOf(this.refilm), final: this.final, say, cwd })
48
50
  return 0
@@ -64,14 +66,17 @@ export class TrailerCheckCommand extends Command {
64
66
  })
65
67
 
66
68
  file = Option.String({ required: false })
69
+ variant = Option.String('--variant', { description: 'a music variant the cut names' })
67
70
 
68
71
  async execute(): Promise<number> {
69
72
  return run(this.context, async (say, root, cwd) => {
70
73
  const t0 = performance.now()
71
74
  const b = await import('../trailer/build.ts')
72
- const l = await b.load(b.trailerFile(this.file, root, cwd), root)
75
+ const l = await b.load(b.trailerFile(this.file, root, cwd), root, this.variant !== undefined ? { variant: this.variant } : {})
73
76
  if (!b.sayErrors(l, say, cwd)) return 1
74
77
  for (const line of b.timeline(l, b.takesOfLoaded(l), await b.worldOf(l), cwd)) say(line)
78
+ for (const line of await b.soundLines(l)) say(` ${line}`)
79
+ for (const line of await b.fontLines(l)) say(` ${line}`)
75
80
  say(`ok: checked in ${((performance.now() - t0) / 1000).toFixed(2)} s`)
76
81
  return 0
77
82
  })
@@ -65,6 +65,10 @@ export type Verb = {
65
65
  version?: number | string
66
66
  /** with film: `own`, a clip that films as long as it plays (no `--seconds`) */
67
67
  length?: 'own'
68
+ /** with film: the words it takes and their types (trailer/words.ts), each checked at its line of a .trailer */
69
+ words?: Record<string, unknown>
70
+ /** with film: the paths its takes film, their world */
71
+ stamp?: string[]
68
72
  }
69
73
 
70
74
  /** the argv a row runs */
@@ -126,6 +130,8 @@ export function verbs(rows: Verb[]): CommandClass[] {
126
130
  static film = v.film
127
131
  static filmVersion = v.version
128
132
  static filmLength = v.length
133
+ static filmWords = v.words
134
+ static filmStamp = v.stamp
129
135
  args = Option.Proxy()
130
136
  async execute(): Promise<number> {
131
137
  const { root, cwd } = this.context
@@ -154,14 +160,16 @@ export function verbs(rows: Verb[]): CommandClass[] {
154
160
 
155
161
  /** the marks a command class carries: set by `verbs()` from its row, or
156
162
  * declared as statics on a hand written class */
157
- export function marksOf(c: object): { essential?: Essential; film?: Film; version?: number | string; length?: 'own' } {
158
- const o = c as { essential?: unknown; film?: unknown; filmVersion?: unknown; filmLength?: unknown }
159
- const out: { essential?: Essential; film?: Film; version?: number | string; length?: 'own' } = {}
163
+ export function marksOf(c: object): { essential?: Essential; film?: Film; version?: number | string; length?: 'own'; words?: Record<string, unknown>; stamp?: string[] } {
164
+ const o = c as { essential?: unknown; film?: unknown; filmVersion?: unknown; filmLength?: unknown; filmWords?: unknown; filmStamp?: unknown }
165
+ const out: { essential?: Essential; film?: Film; version?: number | string; length?: 'own'; words?: Record<string, unknown>; stamp?: string[] } = {}
160
166
  if (typeof o.essential === 'object' && o.essential !== null) out.essential = o.essential as Essential
161
167
  if (o.film === 'still' || o.film === 'clip') {
162
168
  out.film = o.film
163
169
  if (typeof o.filmVersion === 'number' || typeof o.filmVersion === 'string') out.version = o.filmVersion
164
170
  if (o.filmLength === 'own') out.length = 'own'
171
+ if (typeof o.filmWords === 'object' && o.filmWords !== null) out.words = o.filmWords as Record<string, unknown>
172
+ if (Array.isArray(o.filmStamp)) out.stamp = o.filmStamp.map(String)
165
173
  }
166
174
  return out
167
175
  }
package/src/cli.ts CHANGED
@@ -41,6 +41,7 @@ import { ShotCommand } from './cli/cmd_Shot.ts'
41
41
  import { StatusCommand } from './cli/cmd_Status.ts'
42
42
  import { StartCommand } from './cli/cmd_Start.ts'
43
43
  import { TRAILER_COMMANDS } from './cli/cmd_Trailer.ts'
44
+ import { SOUND_COMMANDS } from './cli/cmd_Sound.ts'
44
45
  import { REVIEW_COMMANDS } from './cli/cmd_Review.ts'
45
46
  import { TuiCommand } from './cli/cmd_Tui.ts'
46
47
  import { WikiLintCommand, WikiServeCommand } from './cli/cmd_Wiki.ts'
@@ -73,6 +74,7 @@ export function buildCli(): Cli {
73
74
  cli.register(CheckCommand)
74
75
  cli.register(TuiCommand)
75
76
  for (const c of TRAILER_COMMANDS) cli.register(c)
77
+ for (const c of SOUND_COMMANDS) cli.register(c)
76
78
  for (const c of REVIEW_COMMANDS) cli.register(c)
77
79
  cli.register(ServeCommand)
78
80
  cli.register(PmCommand)
@@ -10,8 +10,9 @@ import { identityOfName, identityOfText, type Identity } from './identity-color.
10
10
  import { configName, CONFIG_FILE } from './identity.ts'
11
11
  import { LAYOUT } from './layout.ts'
12
12
 
13
- /** the mark an app wears: the repo's own, largest first. Rasters only */
14
- export const APP_ICON_FILES = [`${LAYOUT.identity}/icon.png`, 'public/logo.png', 'public/favicon.png']
13
+ /** the mark an app wears: the repo's own, largest first; an svg mark is drawn
14
+ * as an image too (the canvas decodes it) */
15
+ export const APP_ICON_FILES = [`${LAYOUT.identity}/icon.png`, `${LAYOUT.identity}/icon.svg`, 'public/logo.png', 'public/favicon.png']
15
16
 
16
17
  export function appIconOf(root: string): string | null {
17
18
  for (const f of APP_ICON_FILES) if (existsSync(join(root, f))) return join(root, f)
@@ -133,15 +134,18 @@ export async function drawAppIcon(bytes: Uint8Array, id: Identity): Promise<{ ca
133
134
  return { canvas: out, plate: colour }
134
135
  }
135
136
 
136
- /** the icon as pngs, one per size asked, drawn once */
137
- export async function appIconPngs(bytes: Uint8Array, id: Identity, sizes: number[] = [ICON_PX]): Promise<Buffer[]> {
137
+ /** the icon as pngs, one per size asked, drawn once. `plate` crops the macOS
138
+ * grid's margin away, the rounded plate edge to edge (the hub's rail) */
139
+ export async function appIconPngs(bytes: Uint8Array, id: Identity, sizes: number[] = [ICON_PX], crop: 'canvas' | 'plate' = 'canvas'): Promise<Buffer[]> {
138
140
  const { canvas } = await drawAppIcon(bytes, id)
141
+ const o = crop === 'plate' ? (ICON_PX - PLATE) / 2 : 0
142
+ const side = crop === 'plate' ? PLATE : ICON_PX
139
143
  return sizes.map((px) => {
140
- if (px === ICON_PX) return canvas.toBuffer('image/png')
144
+ if (px === ICON_PX && crop === 'canvas') return canvas.toBuffer('image/png')
141
145
  const small = createCanvas(px, px)
142
146
  const ctx = small.getContext('2d')
143
147
  ctx.imageSmoothingQuality = 'high'
144
- ctx.drawImage(canvas, 0, 0, px, px)
148
+ ctx.drawImage(canvas, o, o, side, side, 0, 0, px, px)
145
149
  return small.toBuffer('image/png')
146
150
  })
147
151
  }
@@ -57,6 +57,9 @@ export type ServeConfig = {
57
57
  * one failed, green when all passed. Default: the built-in md lint
58
58
  * (orphans and dangling refs) over the repo root */
59
59
  lint?: string[]
60
+ /** the folder its node ids are relative to, where the wikis page reads
61
+ * a page to search and preview (default: the repo root) */
62
+ dir?: string
60
63
  }
61
64
  /** agent/ graph entrypoint (default: index.md AND README.md) */
62
65
  agentIndex?: string
@@ -19,7 +19,13 @@ const atText = (t: number | undefined): string => (t === undefined ? '' : ` at $
19
19
 
20
20
  /** each reaction to one view or tile: its place by label, its status, its comment */
21
21
  const reactionTexts = (x: ReviewItem): string[] =>
22
- (verdictOf(x)?.reactions ?? []).map((q) => `${placeText(q, x.item.views)}${q.status ? ` ${q.status}` : ''}${q.comment ? `: ${flat(q.comment)}` : ''}`)
22
+ (verdictOf(x)?.reactions ?? []).map((q) => `${placeText(q, x.item.views)}${q.status ? ` ${q.status}` : ''}${q.stars ? ` ★${q.stars}` : ''}${q.comment ? `: ${flat(q.comment)}` : ''}`)
23
+
24
+ /** the view he picked on a choice card, `picked [voice 1]`; null when he picked none */
25
+ const pickText = (x: ReviewItem): string | null => {
26
+ const p = verdictOf(x)?.pick
27
+ return p === undefined ? null : `picked ${placeText({ view: p }, x.item.views)}`
28
+ }
23
29
 
24
30
  /** one line per feedback, for a monitor: the status, the overall comment, each view's reaction, then each rectangle's comment */
25
31
  export function line(x: ReviewItem): string {
@@ -28,7 +34,8 @@ export function line(x: ReviewItem): string {
28
34
  const views = x.item.views
29
35
  const many = views.length > 1
30
36
  const rects = f.rects.map((q, i) => `#${i + 1}${many ? ` ${placeText(q, views)}` : ''}${q.side ? ` ${q.side}` : ''}${atText(q.t)} ${flat(q.comment) || '(no comment)'}`)
31
- const parts = [...reactionTexts(x), ...rects]
37
+ const pick = pickText(x)
38
+ const parts = [...(pick ? [pick] : []), ...reactionTexts(x), ...rects]
32
39
  return `${x.item.id} ${f.status} "${x.item.title}": ${flat(f.comment) || '(no overall comment)'}${parts.length ? ` | ${parts.join(' | ')}` : ''}`
33
40
  }
34
41
 
@@ -131,7 +138,8 @@ export function detail(x: ReviewItem, dir: string, full = false, followed = fals
131
138
  ].join('\n')
132
139
  }
133
140
  if (!f) return [`${r.id} NO VERDICT YET`, head, ...sub, ...replies].join('\n')
134
- const out = [`${r.id} ${f.status.toUpperCase()} ${f.at}`, head, ...sub, ` comment: ${f.comment || '(none)'}`]
141
+ const pick = pickText(x)
142
+ const out = [`${r.id} ${f.status.toUpperCase()} ${f.at}`, head, ...sub, ...(pick ? [` ${pick}`] : []), ` comment: ${f.comment || '(none)'}`]
135
143
  for (const t of reactionTexts(x)) out.push(` view ${t}`)
136
144
  const px = (v: number, of: number): number => Math.round(v * of)
137
145
  const views = r.views