rvlib-shipkit 2.1.1 → 2.3.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 (142) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/package.json +2 -1
  3. package/src/agent-time/mask.ts +43 -0
  4. package/src/agent-time/render.ts +187 -0
  5. package/src/agent-time/report.ts +335 -0
  6. package/src/agent-time/shape.ts +271 -0
  7. package/src/agent-time/signals.ts +84 -0
  8. package/src/agent-time/store.ts +379 -0
  9. package/src/agent-time/timeline.ts +218 -0
  10. package/src/agent-time/turns.ts +135 -0
  11. package/src/assets/kits/identity-kit.ts +7 -3
  12. package/src/assets/run-assets.ts +18 -4
  13. package/src/checks/SK004-gitignore-policy.check.ts +11 -6
  14. package/src/checks/SK013-one-way-in-policy.check.ts +13 -8
  15. package/src/cli/cmd_Calls.ts +53 -0
  16. package/src/cli/cmd_Commands.ts +163 -43
  17. package/src/cli/cmd_Review.ts +948 -0
  18. package/src/cli/cmd_Shot.ts +63 -0
  19. package/src/cli/cmd_Start.ts +8 -4
  20. package/src/cli/cmd_Time.ts +112 -0
  21. package/src/cli/cmd_Trailer.ts +361 -0
  22. package/src/cli/kit/builtins.ts +40 -0
  23. package/src/cli/kit/calls.ts +177 -0
  24. package/src/cli/kit/families.ts +67 -0
  25. package/src/cli/kit/index.ts +8 -0
  26. package/src/cli/kit/pages-check.ts +36 -0
  27. package/src/cli/kit/project-cli.ts +73 -0
  28. package/src/cli/kit/verb.ts +167 -0
  29. package/src/cli.ts +15 -2
  30. package/src/collections/collection-types.ts +6 -1
  31. package/src/collections/providers.ts +3 -0
  32. package/src/collections/review.ts +70 -0
  33. package/src/core/layout.ts +12 -2
  34. package/src/core/migrate-layout.ts +31 -8
  35. package/src/core/one-way-in-policy.ts +12 -4
  36. package/src/reflect/prompt-corpus.ts +15 -114
  37. package/src/reflect/transcript.ts +254 -0
  38. package/src/review/anim.ts +228 -0
  39. package/src/review/auto.ts +141 -0
  40. package/src/review/format.ts +150 -0
  41. package/src/review/http.ts +23 -0
  42. package/src/review/marks.ts +43 -0
  43. package/src/review/media.ts +45 -0
  44. package/src/review/model.ts +854 -0
  45. package/src/review/project.ts +110 -0
  46. package/src/review/role.ts +48 -0
  47. package/src/review/routes.ts +228 -0
  48. package/src/review/store.ts +1002 -0
  49. package/src/review/threads.ts +237 -0
  50. package/src/review/trailer-desk.ts +385 -0
  51. package/src/review/trailer-model.ts +183 -0
  52. package/src/review/trailer-routes.ts +136 -0
  53. package/src/review/unit.ts +165 -0
  54. package/src/review/watchers.ts +54 -0
  55. package/src/scaffold/cli-skeleton.ts +149 -0
  56. package/src/scaffold/files/agent-index.md +2 -2
  57. package/src/scaffold/files/tsconfig.json +3 -0
  58. package/src/scaffold/files/verify-skill.md +1 -1
  59. package/src/scaffold/project-scaffold.ts +90 -12
  60. package/src/serve/embed.ts +35 -0
  61. package/src/serve/hub-app/activity-view.tsx +1 -1
  62. package/src/serve/hub-app/activity.css +0 -4
  63. package/src/serve/hub-app/dock-view.tsx +5 -4
  64. package/src/serve/hub-app/hash-route.ts +28 -1
  65. package/src/serve/hub-app/header-bar.tsx +3 -1
  66. package/src/serve/hub-app/home-st.ts +10 -2
  67. package/src/serve/hub-app/home-strip.tsx +3 -2
  68. package/src/serve/hub-app/home.html +1 -0
  69. package/src/serve/hub-app/hub-app.tsx +80 -11
  70. package/src/serve/hub-app/hub.css +45 -13
  71. package/src/serve/hub-app/icons.tsx +9 -0
  72. package/src/serve/hub-app/index.html +1 -0
  73. package/src/serve/hub-app/insights-view.tsx +4 -3
  74. package/src/serve/hub-app/keys.ts +14 -10
  75. package/src/serve/hub-app/layout-st.ts +63 -2
  76. package/src/serve/hub-app/planned-view.tsx +3 -2
  77. package/src/serve/hub-app/planned.css +0 -1
  78. package/src/serve/hub-app/project-list.css +22 -4
  79. package/src/serve/hub-app/project-list.tsx +41 -8
  80. package/src/serve/hub-app/projects-svc.ts +4 -0
  81. package/src/serve/hub-app/repo-sections.ts +15 -3
  82. package/src/serve/hub-app/repo-st.ts +45 -3
  83. package/src/serve/hub-app/review/clip-st.ts +208 -0
  84. package/src/serve/hub-app/review/kind-view.tsx +171 -0
  85. package/src/serve/hub-app/review/kit.tsx +113 -0
  86. package/src/serve/hub-app/review/review-answer.tsx +239 -0
  87. package/src/serve/hub-app/review/review-board.tsx +46 -0
  88. package/src/serve/hub-app/review/review-list.tsx +156 -0
  89. package/src/serve/hub-app/review/review-page.tsx +101 -0
  90. package/src/serve/hub-app/review/review-st.ts +779 -0
  91. package/src/serve/hub-app/review/review-views.tsx +503 -0
  92. package/src/serve/hub-app/review/trailer-page.tsx +416 -0
  93. package/src/serve/hub-app/review/trailer-st.ts +602 -0
  94. package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
  95. package/src/serve/hub-app/review.css +329 -0
  96. package/src/serve/hub-app/theme-st.ts +56 -0
  97. package/src/serve/hub-app/tree-build.ts +4 -0
  98. package/src/serve/hub-app/tree-row.tsx +1 -1
  99. package/src/serve/hub-app/tree-svc.ts +4 -0
  100. package/src/serve/hub-runtime.ts +5 -3
  101. package/src/serve/same-origin.ts +5 -1
  102. package/src/serve/serve-hub.ts +8 -0
  103. package/src/shipkit-files.ts +12 -4
  104. package/src/shot/client.ts +67 -0
  105. package/src/shot/clip-encoder.ts +146 -0
  106. package/src/shot/dom-clip.ts +124 -0
  107. package/src/shot/serve-shot.ts +54 -0
  108. package/src/shot/shooter.ts +343 -0
  109. package/src/shot/shot-ask.ts +189 -0
  110. package/src/shot/shot.shipkit.ts +14 -0
  111. package/src/smoke/run-smoke.ts +2 -2
  112. package/src/start-repo.ts +29 -14
  113. package/src/suite/judge-suites.ts +56 -10
  114. package/src/suite/select.ts +2 -5
  115. package/src/suite/suite-cli.ts +3 -2
  116. package/src/trailer/adapter.ts +78 -0
  117. package/src/trailer/build.ts +603 -0
  118. package/src/trailer/ffmpeg.ts +72 -0
  119. package/src/trailer/footage.ts +173 -0
  120. package/src/trailer/grammar.ts +1290 -0
  121. package/src/trailer/graph.ts +508 -0
  122. package/src/trailer/holes.ts +72 -0
  123. package/src/trailer/load-unit.ts +31 -0
  124. package/src/trailer/look.ts +124 -0
  125. package/src/trailer/passage.ts +129 -0
  126. package/src/trailer/plan.ts +192 -0
  127. package/src/trailer/prune.ts +193 -0
  128. package/src/trailer/score/compose.ts +240 -0
  129. package/src/trailer/score/dsp.ts +272 -0
  130. package/src/trailer/score/palette.ts +188 -0
  131. package/src/trailer/score/pattern.ts +91 -0
  132. package/src/trailer/score/score.ts +214 -0
  133. package/src/trailer/score/sound.ts +112 -0
  134. package/src/trailer/score/synth.ts +361 -0
  135. package/src/trailer/states.ts +76 -0
  136. package/src/trailer/stitch.ts +233 -0
  137. package/src/trailer/titles.ts +379 -0
  138. package/src/trailer/url.ts +108 -0
  139. package/src/trailer/vertical-draw.ts +286 -0
  140. package/src/trailer/vertical.ts +148 -0
  141. package/src/trailer/vocab.ts +59 -0
  142. package/src/trailer/words.ts +254 -0
@@ -0,0 +1,150 @@
1
+ // what `sk review inbox`, `show`, `watch`, `next` and `board` print of an item and its answer (wiki build/shipkit/review.md):
2
+ // one record per thread, rectangles normalized and in pixels, a frame note's cut, shot and time into it, and the lines the
3
+ // repo's unit adds (a kind's facts, a source's), handed in as `more` so this module stays pure
4
+ import { aboutText, isFrame, isGallery, isPair, NO_SPEC, placeText, stateOf, unreadBy, verdictOf, type Message, type Rect, type ReviewItem, type Spec, type Subject } from './model.ts'
5
+ import { answererOf, conversationOf, openingOf, type Thread } from './threads.ts'
6
+
7
+ /** the lines a repo's unit adds under an item's head, and its spec for the words */
8
+ export interface Say {
9
+ spec: Spec
10
+ more: (x: ReviewItem) => string[]
11
+ }
12
+
13
+ const PLAIN: Say = { spec: NO_SPEC, more: () => [] }
14
+
15
+ const flat = (t: string): string => t.replace(/\s+/g, ' ').trim()
16
+
17
+ /** a rectangle's clip time, ` at 1.25 s`; nothing on an image */
18
+ const atText = (t: number | undefined): string => (t === undefined ? '' : ` at ${t.toFixed(2)} s`)
19
+
20
+ /** each reaction to one view or tile: its place by label, its status, its comment */
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)}` : ''}`)
23
+
24
+ /** one line per feedback, for a monitor: the status, the overall comment, each view's reaction, then each rectangle's comment */
25
+ export function line(x: ReviewItem): string {
26
+ const f = verdictOf(x)
27
+ if (!f) return x.item.id
28
+ const views = x.item.views
29
+ const many = views.length > 1
30
+ 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]
32
+ return `${x.item.id} ${f.status} "${x.item.title}": ${flat(f.comment) || '(no overall comment)'}${parts.length ? ` | ${parts.join(' | ')}` : ''}`
33
+ }
34
+
35
+ /** a raised item as the owner names it: `live#85` for a player's report, else its id */
36
+ export const refOf = (x: ReviewItem): string => (x.item.origin.by === 'player' ? `${x.item.origin.server}#${x.item.origin.serverId}` : x.item.id)
37
+
38
+ /** one owner reply, for a monitor; on an item a card answers, it reads on the card's thread */
39
+ export const replyLine = (x: ReviewItem, m: Message, card: ReviewItem | null = null): string =>
40
+ `${(card ?? x).item.id} reply "${(card ?? x).item.title}"${card ? ` (on ${refOf(x)})` : ''}: ${flat(m.text ?? '')}${m.image ? ` [image thread/${m.image}]` : ''}`
41
+
42
+ /** what watch prints of an item, each with the key it prints once under: an unseen feedback (again when sent again), each unseen reply */
43
+ export function watchLines(x: ReviewItem, items: readonly ReviewItem[] = []): { key: string; text: string }[] {
44
+ const out: { key: string; text: string }[] = []
45
+ const f = verdictOf(x)
46
+ if (f && !f.seen) out.push({ key: `${x.item.id}@${f.at}`, text: line(x) })
47
+ const card = x.item.origin.by === 'agent' ? null : answererOf(x.item.id, items)
48
+ // the words that raised an item the card answers are what the card answers
49
+ const opening = card ? openingOf(x) : undefined
50
+ for (const m of unreadBy(x, 'agent')) if (m !== opening) out.push({ key: `${x.item.id}#${m.id}`, text: replyLine(x, m, card) })
51
+ return out
52
+ }
53
+
54
+ /** an auto item waiting on a taker, for its lane's watch: it carries no message to print (what he or a player raised
55
+ * prints through its words, an unread reply) */
56
+ export function raisedLine(x: ReviewItem): { key: string; text: string } | null {
57
+ const o = x.item.origin
58
+ if (o.by !== 'auto' || stateOf(x, false).state !== 'open') return null
59
+ return { key: `raised ${x.item.id}`, text: `${x.item.id} raised "${x.item.title}" (auto rule ${o.rule}, key ${o.key}) to ${o.lane}: take it: sk review next --lane ${o.lane}` }
60
+ }
61
+
62
+ /** an item nobody takes, for a watch and the inbox */
63
+ export const untakenLine = (x: ReviewItem, why: string, lane: string | null): string =>
64
+ `${x.item.id} untaken "${x.item.title}" (${why}): ask the other sessions (ListAgents, then SendMessage) whether one of them wants it; if none answers, claim it: sk review claim ${x.item.id} --as ${lane ?? '<your lane>'}`
65
+
66
+ /** one thread as one record: each item the card answers, then the card, its older versions, and with `full` the whole conversation */
67
+ export function threadDetail(t: Thread, dir: string, full = false, say: Say = PLAIN): string {
68
+ if (t.answers.length === 0 && t.versions.length === 1 && !full) return detail(t.head, dir, false, false, say)
69
+ const out: string[] = []
70
+ for (const r of t.answers) out.push(`answers ${refOf(r)}:`, detail(r, dir, false, true, say), '')
71
+ out.push(detail(t.head, dir, full, false, say))
72
+ const older = t.versions.slice(1)
73
+ if (older.length > 0) out.push(` versions: ${older.map((x) => x.item.id).join(' ')}`)
74
+ if (full) {
75
+ const conv = conversationOf(t)
76
+ if (conv.length > 0) out.push(' thread:')
77
+ for (const m of conv) out.push(` ${m.who} ${m.at}${m.on === t.head.item.id ? '' : ` [on ${m.on}]`}: ${flat(m.text ?? '')}${m.image ? ` [image ${dir}/${m.on}/thread/${m.image}]` : ''}`)
78
+ }
79
+ return out.join('\n')
80
+ }
81
+
82
+ /** `m:ss.cc` */
83
+ const clock = (t: number): string => `${Math.floor(t / 60)}:${(t % 60).toFixed(2).padStart(5, '0')}`
84
+
85
+ /** what the subject says beyond its kind: a frame's cut, shot and time into it */
86
+ function subjectText(s: Subject): string | null {
87
+ if (isFrame(s)) return ` frame: ${s.trailer}/${s.cut} at ${clock(s.time)} (frame ${s.frame}), shot ${s.shot} at ${s.shotTime.toFixed(2)} s${s.section ? `, section ${s.section}` : ''}`
88
+ return null
89
+ }
90
+
91
+ const boxText = (b: { x0: number; y0: number; x1: number; y1: number }): string => `${b.x0},${b.y0}..${b.x1},${b.y1}`
92
+
93
+ /** a rectangle's world box and the nodes under its middle, when the tool that drew it knew them */
94
+ const rectPlace = (r: Rect): string => (r.world ? ` world ${boxText(r.world)}${r.nodes?.length ? ` nodes ${r.nodes.join(' < ')}` : ''}` : '')
95
+
96
+ /** a pin or rectangle drawn with the raising words, on the item's image */
97
+ const drawnText = (m: Message): string[] =>
98
+ (m.rects ?? []).map((q, i) =>
99
+ q.w === 0 && q.h === 0
100
+ ? ` pin #${i + 1} ${Math.round(q.x * 100)}%,${Math.round(q.y * 100)}%`
101
+ : ` #${i + 1} ${q.x.toFixed(3)},${q.y.toFixed(3)} ${q.w.toFixed(3)}x${q.h.toFixed(3)}${rectPlace(q)}: ${q.comment || '(no comment)'}`,
102
+ )
103
+
104
+ const raisedBy = (o: ReviewItem['item']['origin']): string =>
105
+ o.by === 'auto' ? `auto rule ${o.rule} (key ${o.key}) to ${o.lane}` : o.by === 'player' ? `player ${o.player} (${o.server}#${o.serverId})` : o.by === 'owner' ? `owner from ${o.from}${o.lane ? ` to ${o.lane}` : ''}` : o.by
106
+
107
+ /** one item: a raised one by its state and the raising words, a card by the owner's verdict; `followed`: a later card supersedes or answers it */
108
+ export function detail(x: ReviewItem, dir: string, full = false, followed = false, say: Say = PLAIN): string {
109
+ const f = verdictOf(x)
110
+ const r = x.item
111
+ const head = ` "${r.title}" (${aboutText(r, say.spec)})`
112
+ const frame = subjectText(r.subject)
113
+ const sub = [...(frame ? [frame] : []), ...say.more(x)]
114
+ const replies = unreadBy(x, 'agent').map((m) => ` reply ${m.at}: ${m.text}${m.image ? ` [image ${dir}/${r.id}/thread/${m.image}]` : ''}`)
115
+ if (r.origin.by !== 'agent') {
116
+ const st = stateOf(x, followed)
117
+ const said = x.thread.filter((m) => m.who === 'owner' || m.who === 'player')
118
+ const words = full ? x.thread.filter((m) => (m.text !== undefined || m.op !== undefined) && m.op !== 'seen') : said.filter((m) => m.text !== undefined && m.op === undefined)
119
+ const pins = said.filter((m) => m.rects !== undefined).at(-1)
120
+ const first = r.views[0]
121
+ const image = first && 'files' in first ? Object.values(first.files) : []
122
+ return [
123
+ `${r.id} ${st.state.toUpperCase()}${st.why ? ` (${st.why})` : ''} raised by ${raisedBy(r.origin)} ${r.created}${r.commit ? ` at ${r.commit}` : ''}`,
124
+ head,
125
+ ...sub,
126
+ ...(r.origin.by === 'auto' && r.what ? r.what.split('\n').map((l) => ` ${l}`) : []),
127
+ ...(st.topic ? [` topic ${st.topic}${st.worker ? `, claimed by ${st.worker}` : ''}${r.origin.by === 'owner' && r.origin.kind ? `, ${r.origin.kind}` : ''}`] : []),
128
+ ...words.map((m) => ` ${m.who} ${m.at}${m.op ? ` ${m.op}${m.topic ? ` ${m.topic}` : ''}` : ''}: ${m.text ?? m.note ?? ''}${m.fix ? ` (${m.fix.commit})` : ''}`),
129
+ ...(pins ? drawnText(pins) : []),
130
+ ...(image.length > 0 ? [` image: ${image.map((n) => `${dir}/${r.id}/${n}`).join(' ')}`] : []),
131
+ ].join('\n')
132
+ }
133
+ 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)'}`]
135
+ for (const t of reactionTexts(x)) out.push(` view ${t}`)
136
+ const px = (v: number, of: number): number => Math.round(v * of)
137
+ const views = r.views
138
+ f.rects.forEach((q, i) => {
139
+ const v = views[q.view ?? 0] ?? views[0]
140
+ const size = v && !isGallery(v) ? v.size : { w: 0, h: 0 }
141
+ const on = q.view ? ` view ${q.view} "${v?.label ?? ''}"` : ''
142
+ const side = v && !isGallery(v) && isPair(v.files) ? ` ${q.side ?? '?'}` : ''
143
+ const norm = `${q.x.toFixed(3)},${q.y.toFixed(3)} ${q.w.toFixed(3)}x${q.h.toFixed(3)}`
144
+ const pix = `${px(q.x, size.w)},${px(q.y, size.h)} ${px(q.w, size.w)}x${px(q.h, size.h)} px`
145
+ out.push(` #${i + 1}${on}${side} ${norm} (${pix})${atText(q.t)}: ${q.comment || '(no comment)'}`)
146
+ })
147
+ const files = views.flatMap((v) => (isGallery(v) ? v.gallery.map((t) => t.file) : Object.values(v.files)))
148
+ out.push(` files: ${files.map((n) => `${dir}/${r.id}/${n}`).join(' ')}`, ...replies)
149
+ return out.join('\n')
150
+ }
@@ -0,0 +1,23 @@
1
+ // the hub's http helpers the review and trailer routes share: an error answer, the cache headers, a file with byte ranges
2
+ import { extname } from 'node:path'
3
+
4
+ export const TYPES: Record<string, string> = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.webm': 'video/webm', '.mp4': 'video/mp4' }
5
+ export const NO_STORE = { 'Cache-Control': 'no-store' }
6
+ /** an item's files never change once written, nor a kept render under its version: a phone keeps what it fetched */
7
+ export const KEEP = { 'Cache-Control': 'private, max-age=86400' }
8
+
9
+ export const bad = (e: unknown, status = 400): Response => Response.json({ error: e instanceof Error ? e.message : String(e) }, { status, headers: NO_STORE })
10
+
11
+ /** a file with byte ranges: a player seeks through them */
12
+ export async function ranged(req: Request, path: string, headers: Record<string, string> = KEEP): Promise<Response> {
13
+ const file = Bun.file(path)
14
+ if (!(await file.exists())) return bad(`no ${path.split('/').pop()}`, 404)
15
+ const size = file.size
16
+ const head = { 'Content-Type': TYPES[extname(path).toLowerCase()] ?? 'application/octet-stream', 'Accept-Ranges': 'bytes', ...headers }
17
+ const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.get('range') ?? '')
18
+ if (!range) return new Response(file, { headers: { ...head, 'Content-Length': String(size) } })
19
+ const start = range[1] ? Number(range[1]) : Math.max(0, size - Number(range[2]))
20
+ const end = range[1] && range[2] ? Math.min(Number(range[2]), size - 1) : size - 1
21
+ if (start >= size || start > end) return new Response(null, { status: 416, headers: { 'Content-Range': `bytes */${size}` } })
22
+ return new Response(file.slice(start, end + 1), { status: 206, headers: { ...head, 'Content-Range': `bytes ${start}-${end}/${size}`, 'Content-Length': String(end - start + 1) } })
23
+ }
@@ -0,0 +1,43 @@
1
+ // the owner's marks kept in the store (wiki build/shipkit/review.md, Verbs on the store): a mark is a `rate` message of his
2
+ // on a subject (stars, a mark word, a part's stars, a note); the latest is the mark, the older ones its history. Pure
3
+ import { partyOf, subjectKey, type Message, type ReviewItem, type Spec } from './model.ts'
4
+
5
+ /** one subject's mark now: its latest whole rate, each part's latest stars, the note said with it */
6
+ export interface SubjectMark {
7
+ key: string
8
+ kind: string
9
+ rate: { stars?: number; mark?: string }
10
+ parts: Record<string, number>
11
+ text?: string
12
+ at: string
13
+ /** how many marks it was given, the latest included */
14
+ history: number
15
+ }
16
+
17
+ /** every subject's mark, the latest first; a mark cleared (no stars, no mark) leaves its subject out */
18
+ export function marksOf(items: readonly ReviewItem[], spec: Spec): SubjectMark[] {
19
+ const rates: { key: string; kind: string; m: Message }[] = []
20
+ for (const x of items) {
21
+ const key = subjectKey(x.item.subject, spec)
22
+ if (key === null) continue
23
+ for (const m of x.thread) if (m.rate !== undefined && partyOf(m.who) === 'owner') rates.push({ key, kind: x.item.subject.kind, m })
24
+ }
25
+ rates.sort((a, b) => a.m.at.localeCompare(b.m.at) || a.m.id.localeCompare(b.m.id))
26
+ const out = new Map<string, SubjectMark>()
27
+ for (const { key, kind, m } of rates) {
28
+ const r = m.rate ?? {}
29
+ const e = out.get(key) ?? { key, kind, rate: {}, parts: {}, at: m.at, history: 0 }
30
+ e.history++
31
+ e.at = m.at
32
+ if (r.part) {
33
+ if (r.stars) e.parts[r.part] = r.stars
34
+ else delete e.parts[r.part]
35
+ } else {
36
+ e.rate = { ...(r.stars ? { stars: r.stars } : {}), ...(r.mark ? { mark: r.mark } : {}) }
37
+ if (m.text !== undefined) e.text = m.text
38
+ else delete e.text
39
+ }
40
+ out.set(key, e)
41
+ }
42
+ return [...out.values()].filter((e) => e.rate.stars !== undefined || e.rate.mark !== undefined || Object.keys(e.parts).length > 0).sort((a, b) => b.at.localeCompare(a.at))
43
+ }
@@ -0,0 +1,45 @@
1
+ // the media facts the store and the trailer view read: a clip's length, size and rate (ffprobe), one still of a clip at
2
+ // a time (ffmpeg, through the trailer core's one spawn), an image's size (sharp, loaded only by a writer)
3
+ import { ffmpeg } from '../trailer/ffmpeg.ts'
4
+
5
+ const FFPROBE = Bun.which('ffprobe') ?? '/opt/homebrew/bin/ffprobe'
6
+
7
+ export interface Probe {
8
+ duration: number
9
+ video: { width: number; height: number; fps: number } | null
10
+ audio: boolean
11
+ }
12
+
13
+ /** `30000/1001` → 29.97 */
14
+ const rate = (r: string | undefined): number => {
15
+ const [a = 0, b = 1] = (r ?? '').split('/').map(Number)
16
+ return b > 0 && a > 0 ? a / b : 0
17
+ }
18
+
19
+ /** one ffprobe of a media file; throws when ffprobe cannot read it or finds no length */
20
+ export function probe(file: string): Probe {
21
+ const r = Bun.spawnSync([FFPROBE, '-v', 'error', '-show_entries', 'stream=codec_type,width,height,r_frame_rate:format=duration', '-of', 'json', file], { stdout: 'pipe', stderr: 'pipe' })
22
+ if (r.exitCode !== 0) throw new Error(`ffprobe ${file}: ${r.stderr.toString().trim() || `exit ${r.exitCode}`}`)
23
+ const j = JSON.parse(r.stdout.toString()) as { streams?: { codec_type?: string; width?: number; height?: number; r_frame_rate?: string }[]; format?: { duration?: string } }
24
+ const duration = Number(j.format?.duration)
25
+ if (!(duration > 0)) throw new Error(`ffprobe ${file}: no length`)
26
+ const v = j.streams?.find((s) => s.codec_type === 'video')
27
+ return {
28
+ duration,
29
+ video: v?.width && v.height ? { width: v.width, height: v.height, fps: rate(v.r_frame_rate) } : null,
30
+ audio: j.streams?.some((s) => s.codec_type === 'audio') ?? false,
31
+ }
32
+ }
33
+
34
+ /** the first frame at or after `seconds` of a clip, as a png */
35
+ export async function stillAt(video: string, seconds: number, png: string): Promise<void> {
36
+ await ffmpeg(['-ss', Math.max(0, seconds).toFixed(5), '-i', video, '-frames:v', '1', '-update', '1', '-f', 'image2', '-c:v', 'png', png])
37
+ }
38
+
39
+ /** an image's size in pixels */
40
+ export async function sizeOf(file: string): Promise<{ w: number; h: number }> {
41
+ const { default: sharp } = await import('sharp')
42
+ const m = await sharp(file).metadata()
43
+ if (!m.width || !m.height) throw new Error(`${file}: no image size`)
44
+ return { w: m.width, h: m.height }
45
+ }