rvlib-shipkit 2.2.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 (140) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/package.json +1 -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 +5 -2
  12. package/src/cli/cmd_Commands.ts +9 -0
  13. package/src/cli/cmd_Review.ts +974 -0
  14. package/src/cli/cmd_Sound.ts +209 -0
  15. package/src/cli/cmd_Time.ts +112 -0
  16. package/src/cli/cmd_Trailer.ts +264 -6
  17. package/src/cli/kit/verb.ts +22 -4
  18. package/src/cli.ts +8 -4
  19. package/src/collections/collection-types.ts +6 -1
  20. package/src/collections/providers.ts +3 -0
  21. package/src/collections/review.ts +70 -0
  22. package/src/core/app-icon.ts +10 -6
  23. package/src/core/layout.ts +2 -0
  24. package/src/reflect/prompt-corpus.ts +15 -119
  25. package/src/reflect/transcript.ts +254 -0
  26. package/src/repo-config.ts +3 -0
  27. package/src/review/anim.ts +228 -0
  28. package/src/review/auto.ts +141 -0
  29. package/src/review/format.ts +158 -0
  30. package/src/review/http.ts +23 -0
  31. package/src/review/marks.ts +43 -0
  32. package/src/review/media.ts +45 -0
  33. package/src/review/model.ts +896 -0
  34. package/src/review/project.ts +126 -0
  35. package/src/review/role.ts +48 -0
  36. package/src/review/routes.ts +239 -0
  37. package/src/review/store.ts +1011 -0
  38. package/src/review/threads.ts +237 -0
  39. package/src/review/trailer-desk.ts +385 -0
  40. package/src/review/trailer-model.ts +183 -0
  41. package/src/review/trailer-routes.ts +136 -0
  42. package/src/review/unit.ts +183 -0
  43. package/src/review/watchers.ts +54 -0
  44. package/src/serve/embed.ts +35 -0
  45. package/src/serve/hub-app/activity-view.tsx +1 -1
  46. package/src/serve/hub-app/activity.css +0 -4
  47. package/src/serve/hub-app/admin-svc.ts +1 -1
  48. package/src/serve/hub-app/admin-view.tsx +1 -1
  49. package/src/serve/hub-app/dock-view.tsx +5 -4
  50. package/src/serve/hub-app/hash-route.ts +36 -1
  51. package/src/serve/hub-app/header-bar.tsx +3 -1
  52. package/src/serve/hub-app/home-st.ts +17 -3
  53. package/src/serve/hub-app/home-strip.tsx +3 -2
  54. package/src/serve/hub-app/home.html +1 -0
  55. package/src/serve/hub-app/hub-app.tsx +80 -11
  56. package/src/serve/hub-app/hub.css +83 -14
  57. package/src/serve/hub-app/icons.tsx +15 -0
  58. package/src/serve/hub-app/index.html +1 -0
  59. package/src/serve/hub-app/insights-view.tsx +4 -3
  60. package/src/serve/hub-app/keys.ts +14 -10
  61. package/src/serve/hub-app/layout-st.ts +63 -2
  62. package/src/serve/hub-app/md.ts +32 -0
  63. package/src/serve/hub-app/planned-view.tsx +3 -2
  64. package/src/serve/hub-app/planned.css +0 -1
  65. package/src/serve/hub-app/project-list.css +26 -6
  66. package/src/serve/hub-app/project-list.tsx +46 -10
  67. package/src/serve/hub-app/projects-svc.ts +7 -2
  68. package/src/serve/hub-app/repo-sections.ts +15 -3
  69. package/src/serve/hub-app/repo-st.ts +61 -4
  70. package/src/serve/hub-app/review/clip-st.ts +258 -0
  71. package/src/serve/hub-app/review/kind-view.tsx +240 -0
  72. package/src/serve/hub-app/review/kit.tsx +113 -0
  73. package/src/serve/hub-app/review/review-answer.tsx +269 -0
  74. package/src/serve/hub-app/review/review-board.tsx +46 -0
  75. package/src/serve/hub-app/review/review-list.tsx +156 -0
  76. package/src/serve/hub-app/review/review-page.tsx +96 -0
  77. package/src/serve/hub-app/review/review-st.ts +902 -0
  78. package/src/serve/hub-app/review/review-views.tsx +648 -0
  79. package/src/serve/hub-app/review/trailer-page.tsx +416 -0
  80. package/src/serve/hub-app/review/trailer-st.ts +602 -0
  81. package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
  82. package/src/serve/hub-app/review.css +389 -0
  83. package/src/serve/hub-app/theme-st.ts +56 -0
  84. package/src/serve/hub-app/tree-build.ts +4 -0
  85. package/src/serve/hub-app/tree-row.tsx +1 -1
  86. package/src/serve/hub-app/tree-svc.ts +4 -0
  87. package/src/serve/hub-app/wiki-st.ts +190 -0
  88. package/src/serve/hub-app/wikis-view.tsx +134 -5
  89. package/src/serve/hub-runtime.ts +9 -7
  90. package/src/serve/project-gutter-route.ts +25 -1
  91. package/src/serve/project-gutter.ts +1 -1
  92. package/src/serve/same-origin.ts +5 -1
  93. package/src/serve/serve-hub.ts +15 -0
  94. package/src/shipkit-files.ts +9 -5
  95. package/src/shot/client.ts +17 -5
  96. package/src/shot/shooter.ts +19 -3
  97. package/src/shot/shot-ask.ts +9 -1
  98. package/src/shot/stepped-clip.ts +53 -0
  99. package/src/trailer/adapter.ts +90 -6
  100. package/src/trailer/build.ts +459 -59
  101. package/src/trailer/ffmpeg.ts +22 -0
  102. package/src/trailer/fonts.ts +55 -0
  103. package/src/trailer/footage.ts +112 -43
  104. package/src/trailer/grammar.ts +864 -127
  105. package/src/trailer/graph.ts +308 -49
  106. package/src/trailer/holes.ts +72 -0
  107. package/src/trailer/load-unit.ts +20 -3
  108. package/src/trailer/look.ts +20 -3
  109. package/src/trailer/motion.ts +207 -0
  110. package/src/trailer/passage.ts +129 -0
  111. package/src/trailer/plan.ts +82 -23
  112. package/src/trailer/prune.ts +204 -0
  113. package/src/trailer/score/compose.ts +256 -0
  114. package/src/trailer/score/dsp.ts +272 -0
  115. package/src/trailer/score/gm-render.ts +43 -0
  116. package/src/trailer/score/gm.ts +90 -0
  117. package/src/trailer/score/midi.ts +143 -0
  118. package/src/trailer/score/palette.ts +188 -0
  119. package/src/trailer/score/pattern.ts +98 -0
  120. package/src/trailer/score/score.ts +228 -0
  121. package/src/trailer/score/sound.ts +123 -0
  122. package/src/trailer/score/synth.ts +383 -0
  123. package/src/trailer/sound/audio.ts +148 -0
  124. package/src/trailer/sound/brand.ts +42 -0
  125. package/src/trailer/sound/comfy.ts +38 -0
  126. package/src/trailer/sound/eleven.ts +72 -0
  127. package/src/trailer/sound/fluid.ts +71 -0
  128. package/src/trailer/sound/gen.ts +139 -0
  129. package/src/trailer/sound/index.ts +60 -0
  130. package/src/trailer/sound/presets.ts +69 -0
  131. package/src/trailer/sound/run.ts +189 -0
  132. package/src/trailer/states.ts +76 -0
  133. package/src/trailer/stitch.ts +92 -9
  134. package/src/trailer/titles.ts +471 -150
  135. package/src/trailer/url.ts +18 -3
  136. package/src/trailer/vocab.ts +34 -4
  137. package/src/trailer/words.ts +275 -0
  138. package/src/wiki/graph-mount.ts +3 -0
  139. package/src/wiki/graph-page.ts +45 -9
  140. package/src/wiki/graph-pages.ts +128 -0
@@ -0,0 +1,237 @@
1
+ // the threads of the store (wiki build/shipkit/review.md): a card's versions, each superseding the last, and the raised
2
+ // items (a report, his note) the card answers, read as one thread per bug; their status, conversation and order, and
3
+ // the active thread a new card already covers. Pure, over the items of review/model.ts
4
+ import {
5
+ isReply,
6
+ partyOf,
7
+ reworkOf,
8
+ stateOf,
9
+ subjectKey,
10
+ unreadBy,
11
+ verdictOf,
12
+ withdrawnOf,
13
+ wordsOf,
14
+ type Message,
15
+ type ReviewItem,
16
+ type ShownStatus,
17
+ type Spec,
18
+ type Subject,
19
+ NO_SPEC,
20
+ } from './model.ts'
21
+
22
+ /** one thread of versions: a head no later card supersedes, then the chain it supersedes back to the first, and the
23
+ * raised items any of them answers */
24
+ export interface Thread {
25
+ head: ReviewItem
26
+ /** the head first, then each older version; v1 is the last */
27
+ versions: ReviewItem[]
28
+ /** the reports and notes the versions answer, oldest first */
29
+ answers: ReviewItem[]
30
+ }
31
+
32
+ /** every item of a thread: its versions, then the raised items they answer */
33
+ export const membersOf = (t: Thread): ReviewItem[] => [...t.versions, ...t.answers]
34
+
35
+ const answersOf = (x: ReviewItem): readonly string[] => (x.item.origin.by === 'agent' ? (x.item.answers ?? []) : [])
36
+
37
+ const newest = (a: ReviewItem, b: ReviewItem): number => b.item.created.localeCompare(a.item.created) || b.item.id.localeCompare(a.item.id)
38
+ const oldest = (a: ReviewItem, b: ReviewItem): number => a.item.created.localeCompare(b.item.created) || a.item.id.localeCompare(b.item.id)
39
+
40
+ function answeredBy(versions: readonly ReviewItem[], byId: ReadonlyMap<string, ReviewItem>): ReviewItem[] {
41
+ return [...new Set(versions.flatMap(answersOf))]
42
+ .flatMap((id) => {
43
+ const y = byId.get(id)
44
+ return y && y.item.origin.by !== 'agent' ? [y] : []
45
+ })
46
+ .sort(oldest)
47
+ }
48
+
49
+ /** the raised item's opening: his or the player's words and rectangles that raised it, none on an agent card */
50
+ export const openingOf = (x: ReviewItem): Message | undefined =>
51
+ x.item.origin.by === 'agent' ? undefined : x.thread.find((m) => m.op === undefined && partyOf(m.who) === 'owner' && (m.text !== undefined || m.rects !== undefined))
52
+
53
+ /** the links of one list of items, built once per list: every reader of a 3000 item store walks them per item */
54
+ interface Links {
55
+ byId: Map<string, ReviewItem>
56
+ /** by id, the cards superseding it, newest first */
57
+ superseders: Map<string, ReviewItem[]>
58
+ /** by id, the cards answering it, newest first */
59
+ answerers: Map<string, ReviewItem[]>
60
+ }
61
+ const links = new WeakMap<readonly ReviewItem[], Links>()
62
+
63
+ function linksOf(items: readonly ReviewItem[]): Links {
64
+ const held = links.get(items)
65
+ if (held) return held
66
+ const l: Links = { byId: new Map(), superseders: new Map(), answerers: new Map() }
67
+ const push = (m: Map<string, ReviewItem[]>, id: string, x: ReviewItem): void => void (m.get(id) ?? m.set(id, []).get(id))?.push(x)
68
+ for (const x of items) {
69
+ l.byId.set(x.item.id, x)
70
+ if (x.item.supersedes) push(l.superseders, x.item.supersedes, x)
71
+ for (const a of answersOf(x)) push(l.answerers, a, x)
72
+ }
73
+ for (const m of [l.superseders, l.answerers]) for (const xs of m.values()) xs.sort(newest)
74
+ links.set(items, l)
75
+ return l
76
+ }
77
+
78
+ /** the item, the one it supersedes, that one's, back to the first; a missing link or a cycle ends the walk */
79
+ export function chainOf(x: ReviewItem, items: readonly ReviewItem[]): ReviewItem[] {
80
+ const byId = linksOf(items).byId
81
+ const out = [x]
82
+ const seen = new Set([x.item.id])
83
+ for (let cur = x; cur.item.supersedes !== undefined; ) {
84
+ const next = byId.get(cur.item.supersedes)
85
+ if (!next || seen.has(next.item.id)) break
86
+ out.push(next)
87
+ seen.add(next.item.id)
88
+ cur = next
89
+ }
90
+ return out
91
+ }
92
+
93
+ /** every card superseding this one, the newest first */
94
+ export const childrenOf = (id: string, items: readonly ReviewItem[]): ReviewItem[] => [...(linksOf(items).superseders.get(id) ?? [])]
95
+
96
+ /** every item a later card follows: a version it supersedes, a raised item it answers */
97
+ export const followedIds = (items: readonly ReviewItem[]): Set<string> => {
98
+ const l = linksOf(items)
99
+ return new Set([...l.superseders.keys(), ...l.answerers.keys()])
100
+ }
101
+
102
+ /** one thread per head: two cards superseding the same one are two threads sharing their history; a raised item a
103
+ * card answers is in that card's thread, never a head */
104
+ export function threadsOf(items: readonly ReviewItem[]): Thread[] {
105
+ const byId = linksOf(items).byId
106
+ const followed = followedIds(items)
107
+ return items
108
+ .filter((x) => !followed.has(x.item.id))
109
+ .map((head) => {
110
+ const versions = chainOf(head, items)
111
+ return { head, versions, answers: answeredBy(versions, byId) }
112
+ })
113
+ }
114
+
115
+ /** the newest card answering this raised item, null when none does */
116
+ export const answererOf = (id: string, items: readonly ReviewItem[]): ReviewItem | null => linksOf(items).answerers.get(id)?.[0] ?? null
117
+
118
+ const supersederOf = (id: string, items: readonly ReviewItem[]): ReviewItem | null => linksOf(items).superseders.get(id)?.[0] ?? null
119
+
120
+ /** a later card follows the item: a new version of it, or a card answering it (stateOf's `superseded`) */
121
+ export const isFollowed = (id: string, items: readonly ReviewItem[]): boolean => supersederOf(id, items) !== null || answererOf(id, items) !== null
122
+
123
+ /** the thread an item reads in: its own, the one whose history holds it, or the card's that answers it */
124
+ export const threadOfItem = (id: string, threads: readonly Thread[]): Thread | null =>
125
+ threads.find((t) => t.head.item.id === id) ?? threads.find((t) => membersOf(t).some((x) => x.item.id === id)) ?? null
126
+
127
+ /** a thread still needs someone: its head pending, or answered and not yet acted on by the agent (an approval needs
128
+ * nothing), or a lane reworking it; what he or a player raised, until it is closed */
129
+ export function isActive(t: Thread, now = new Date()): boolean {
130
+ if (t.versions.some((x) => unreadBy(x, 'agent').some((m) => m !== openingOf(x)))) return true
131
+ if (t.answers.some((x) => unreadBy(x, 'agent').some((m) => m !== openingOf(x)))) return true
132
+ if (t.head.item.origin.by !== 'agent') return stateOf(t.head, false).state !== 'closed'
133
+ if (reworkOf(t.head, false, now)) return true
134
+ const f = verdictOf(t.head)
135
+ if (!f) return withdrawnOf(t.head) === undefined
136
+ return f.status !== 'approved' && f.status !== 'archived' && !f.seen
137
+ }
138
+
139
+ /** the thread's conversation: every version's and every answered item's replies, oldest first, each with the item it
140
+ * sits on; an answered item's opening words show with the item, not here */
141
+ export function conversationOf(t: Thread): (Message & { on: string })[] {
142
+ const openings = new Set(t.answers.map(openingOf))
143
+ return membersOf(t)
144
+ .flatMap((x) => x.thread.filter((m) => isReply(m) && !openings.has(m)).map((m) => ({ ...m, on: x.item.id })))
145
+ .sort((a, b) => a.at.localeCompare(b.at) || a.id.localeCompare(b.id))
146
+ }
147
+
148
+ /** a head waits for the owner's verdict: an agent's card, none yet, not withdrawn, not a draft */
149
+ export const waits = (x: ReviewItem): boolean => x.item.origin.by === 'agent' && !x.item.wip && verdictOf(x) === null && withdrawnOf(x) === undefined
150
+
151
+ /** the active pending heads first in the order they were sent, then every other thread, newest first */
152
+ export function orderedThreads(items: readonly ReviewItem[]): Thread[] {
153
+ const all = threadsOf(items)
154
+ const first = (x: ReviewItem): boolean => waits(x) || (x.item.wip !== undefined && verdictOf(x) === null && withdrawnOf(x) === undefined)
155
+ const pending = all.filter((t) => first(t.head)).sort((a, b) => oldest(a.head, b.head))
156
+ const rest = all.filter((t) => !first(t.head)).sort((a, b) => newest(a.head, b.head))
157
+ return [...pending, ...rest]
158
+ }
159
+
160
+ const STOP = new Set(['a', 'an', 'the', 'of', 'on', 'in', 'at', 'to', 'and', 'with', 'by', 'de', 'la', 'le', 'les', 'du', 'des', 'au', 'aux', 'l', 'd', 'et', 'en'])
161
+
162
+ /** two titles share most of their words (Dice over the words, stop words aside): a new version sent without --supersedes */
163
+ export function closeTitles(a: string, b: string): boolean {
164
+ const wa = new Set(wordsOf(a).filter((w) => !STOP.has(w)))
165
+ const wb = new Set(wordsOf(b).filter((w) => !STOP.has(w)))
166
+ if (wa.size === 0 || wb.size === 0) return false
167
+ const common = [...wa].filter((w) => wb.has(w)).length
168
+ return (2 * common) / (wa.size + wb.size) >= 0.6
169
+ }
170
+
171
+ /** the head of an active thread that already covers this subject, null when none. Subjects first: two that each name
172
+ * one thing are one subject exactly when they name the same; else the same topic and a subject its kind calls near;
173
+ * else a close title */
174
+ export function coveringHead(card: { title: string; topic?: string; subject: Subject }, items: readonly ReviewItem[], spec: Spec = NO_SPEC): { head: ReviewItem; by: 'subject' | 'near' | 'title' } | null {
175
+ const key = subjectKey(card.subject, spec)
176
+ const comparable = threadsOf(items)
177
+ .filter((t) => isActive(t))
178
+ .filter((t) => {
179
+ const k = subjectKey(t.head.item.subject, spec)
180
+ return key === null || k === null || k === key
181
+ })
182
+ const same = key === null ? undefined : comparable.find((t) => subjectKey(t.head.item.subject, spec) === key)
183
+ if (same) return { head: same.head, by: 'subject' }
184
+ const near = spec.kinds[card.subject.kind]?.near
185
+ const close = near
186
+ ? comparable.find((t) => t.head.item.topic === card.topic && t.head.item.subject.kind === card.subject.kind && near(card.subject, t.head.item.subject))
187
+ : undefined
188
+ if (close) return { head: close.head, by: 'near' }
189
+ const title = comparable.find((t) => closeTitles(card.title, t.head.item.title))
190
+ return title ? { head: title.head, by: 'title' } : null
191
+ }
192
+
193
+ /** what the list says of a card: the owner's verdict, else withdrawn, a draft, replaced, or waiting for him */
194
+ export function statusOf(x: ReviewItem, items: readonly ReviewItem[]): ShownStatus {
195
+ if (x.item.origin.by !== 'agent') {
196
+ // a report a card answers shows the bug's status: its card's, at the newest version
197
+ let card = answererOf(x.item.id, items)
198
+ const seen = new Set<string>()
199
+ for (let next = card; next && !seen.has(next.item.id); next = supersederOf(next.item.id, items)) {
200
+ seen.add(next.item.id)
201
+ card = next
202
+ }
203
+ if (card) return statusOf(card, items)
204
+ const st = stateOf(x, false)
205
+ if (st.state !== 'closed') return 'raised'
206
+ return st.why === 'wontfix' || st.why === 'dropped' ? 'wontfix' : st.why === 'rated' ? 'rated' : 'fixed'
207
+ }
208
+ const v = verdictOf(x)
209
+ if (v) return v.status
210
+ if (withdrawnOf(x) !== undefined) return 'withdrawn'
211
+ if (x.item.wip) return 'wip'
212
+ return supersederOf(x.item.id, items) ? 'superseded' : 'pending'
213
+ }
214
+
215
+ /** the latest thing on `x` the agent has not seen: its verdict or a reply of the owner */
216
+ const lastUnseen = (x: ReviewItem, answered: boolean): string => {
217
+ const v = verdictOf(x)
218
+ const opening = answered || stateOf(x, false).state === 'closed' ? openingOf(x) : undefined
219
+ const replies = unreadBy(x, 'agent').filter((m) => m !== opening)
220
+ return [...(v && !v.seen ? [v.at] : []), ...replies.map((m) => m.at)].sort().at(-1) ?? ''
221
+ }
222
+
223
+ /** the items with a verdict or an owner reply the agent has not marked seen yet, oldest first */
224
+ export function unseen(items: readonly ReviewItem[]): ReviewItem[] {
225
+ const answered = new Set(items.flatMap(answersOf))
226
+ return items
227
+ .map((x) => ({ x, at: lastUnseen(x, answered.has(x.item.id)) }))
228
+ .filter((u) => u.at !== '')
229
+ .sort((a, b) => a.at.localeCompare(b.at))
230
+ .map((u) => u.x)
231
+ }
232
+
233
+ /** what waits on him: the cards waiting for the owner's verdict, and what he raised that asks him a question or is blocked on him */
234
+ export function waitingOnOwner(items: readonly ReviewItem[]): Thread[] {
235
+ const followed = followedIds(items)
236
+ return orderedThreads(items).filter((t) => waits(t.head) || (t.head.item.origin.by !== 'agent' && ['question', 'blocked'].includes(stateOf(t.head, followed.has(t.head.item.id)).state)))
237
+ }
@@ -0,0 +1,385 @@
1
+ // the trailer view's cuts, renders and timelines (wiki build/trailer.md, Frame notes), and the still a note on a frame
2
+ // is raised with: the note itself is an item of the one store, its subject the frame, anchored on its shot's name, so it
3
+ // survives a new render of the cut. A cut is a `trailer/<id>/<cut>.trailer` and its render `out/<name>.mp4` (`<name>`
4
+ // its `trailer` line), or a render with no file beside it. The trailer core (src/trailer/) is read, never changed
5
+ import { constants, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
6
+ import { join, relative } from 'node:path'
7
+ import { LAYOUT } from '../core/layout.ts'
8
+ import { parseTrailer, starts, total, type Trailer } from '../trailer/grammar.ts'
9
+ import { THUMB_W } from '../trailer/graph.ts'
10
+ import { probe, stillAt } from './media.ts'
11
+ import type { FrameSubject } from './model.ts'
12
+ import { readAllCached, storeDir } from './store.ts'
13
+ import { CUT_RE, notesOf, shotAt, TRAILER_ID_RE, type CutInfo, type SectionSpan, type ShotSpan, type Timeline, type TrailerInfo } from './trailer-model.ts'
14
+
15
+ const round = (v: number, d = 4): number => Number(v.toFixed(d))
16
+
17
+ /** the cut's montage on its clock: each shot from its start to the next one's, the sections, the captions */
18
+ export function timelineOf(t: Pick<Trailer, 'shots' | 'fps' | 'size'>): Timeline {
19
+ const st = starts(t)
20
+ const end = total(t)
21
+ let section: string | null = null
22
+ const shots: ShotSpan[] = t.shots.map((s, i) => {
23
+ if (s.section) section = s.section.name
24
+ const start = st[i] ?? 0
25
+ return {
26
+ name: s.name,
27
+ line: s.line,
28
+ start: round(start),
29
+ end: round(st[i + 1] ?? end),
30
+ length: s.length,
31
+ section,
32
+ captions: s.titles.map((ti) => ({ text: ti.text, style: ti.style, start: round(start + ti.at), end: round(start + (ti.for === null ? s.length : ti.at + ti.for)) })),
33
+ }
34
+ })
35
+ const sections: SectionSpan[] = []
36
+ for (const s of shots) {
37
+ if (s.section === null) continue
38
+ const last = sections.at(-1)
39
+ if (last?.name === s.section) last.end = s.end
40
+ else sections.push({ name: s.section, start: s.start, end: s.end })
41
+ }
42
+ return { total: round(end), fps: t.fps, size: t.size, shots, sections }
43
+ }
44
+
45
+ /** a render with no .trailer beside it: one shot spanning the whole video, so the player and the notes still work */
46
+ const wholeTimeline = (duration: number, fps: number, size: [number, number]): Timeline => ({
47
+ total: round(duration),
48
+ fps,
49
+ size,
50
+ shots: [{ name: 'cut', line: 0, start: 0, end: round(duration), length: round(duration), section: null, captions: [] }],
51
+ sections: [],
52
+ })
53
+
54
+ const trailersDir = (root: string): string => join(root, 'trailer')
55
+
56
+ /** a trailer folder by its name, or null when the name is not one */
57
+ export function trailerDir(root: string, id: string): string | null {
58
+ if (!TRAILER_ID_RE.test(id)) return null
59
+ const dir = join(trailersDir(root), id)
60
+ return existsSync(dir) && statSync(dir).isDirectory() ? dir : null
61
+ }
62
+
63
+ /** one cut of a folder, as found on disk */
64
+ interface CutFiles {
65
+ cut: string
66
+ /** the render's stem */
67
+ name: string
68
+ trailer: string | null
69
+ /** a kept version: its text in versions/, its render and strip in versions/<v>/ */
70
+ version?: true
71
+ }
72
+
73
+ /** the `trailer <name>` line of a file, read without a parse; its stem when it has none */
74
+ const nameOf = (file: string, stem: string): string => /^\s*trailer\s+([a-z0-9][a-z0-9-]*)\s*(#.*)?$/m.exec(readFileSync(file, 'utf8'))?.[1] ?? stem
75
+
76
+ /** the one file of a version's folder ending so, null when it has none */
77
+ const versionFile = (dir: string, v: string, end: string): string | null => {
78
+ const d = join(dir, 'versions', v)
79
+ const f = existsSync(d) ? readdirSync(d).find((x) => x.endsWith(end) && (end !== '.mp4' || !x.endsWith('.part.mp4'))) : undefined
80
+ return f ? join(d, f) : null
81
+ }
82
+
83
+ /** a trailer's kept versions (wiki build/trailer.md, Versions): each `versions/<v>.trailer`, its render in its folder
84
+ * when the machine still holds it */
85
+ function versionsOf(dir: string): CutFiles[] {
86
+ const vd = join(dir, 'versions')
87
+ if (!existsSync(vd)) return []
88
+ return readdirSync(vd)
89
+ .filter((f) => f.endsWith('.trailer') && CUT_RE.test(f.slice(0, -'.trailer'.length)))
90
+ .map((f) => f.slice(0, -'.trailer'.length))
91
+ .sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
92
+ .map((v) => ({ cut: v, name: v, trailer: join(vd, `${v}.trailer`), version: true as const }))
93
+ }
94
+
95
+ /** a trailer's cuts: each .trailer file, then each render no file names (a shot's preview `shot-*`, a passage `clip-*`
96
+ * and a section's preview `<cut>-<section>` are never a cut), then each kept version a cut of that name does not shadow */
97
+ export function cutsOf(dir: string): CutFiles[] {
98
+ const files = readdirSync(dir)
99
+ .filter((f) => f.endsWith('.trailer') && CUT_RE.test(f.slice(0, -'.trailer'.length)))
100
+ .sort()
101
+ const out: CutFiles[] = files.map((f) => {
102
+ const cut = f.slice(0, -'.trailer'.length)
103
+ return { cut, name: nameOf(join(dir, f), cut), trailer: join(dir, f) }
104
+ })
105
+ const named = new Set(out.map((c) => c.name))
106
+ const outDir = join(dir, 'out')
107
+ if (existsSync(outDir))
108
+ for (const f of readdirSync(outDir).sort()) {
109
+ const stem = f.slice(0, -'.mp4'.length)
110
+ if (!f.endsWith('.mp4') || /^(shot|clip)-/.test(f) || named.has(stem) || [...named].some((n) => stem.startsWith(`${n}-`)) || !CUT_RE.test(stem) || out.some((c) => c.cut === stem)) continue
111
+ out.push({ cut: stem, name: stem, trailer: null })
112
+ }
113
+ out.sort((a, b) => (a.cut === 'main' ? -1 : b.cut === 'main' ? 1 : a.cut.localeCompare(b.cut)))
114
+ return [...out, ...versionsOf(dir).filter((v) => !out.some((c) => c.cut === v.cut))]
115
+ }
116
+
117
+ /** every trailer folder: a folder of trailer/ that holds a cut */
118
+ export function trailerIds(root: string): string[] {
119
+ const d = trailersDir(root)
120
+ if (!existsSync(d)) return []
121
+ return readdirSync(d)
122
+ .filter((id) => TRAILER_ID_RE.test(id) && statSync(join(d, id)).isDirectory() && cutsOf(join(d, id)).length > 0)
123
+ .sort()
124
+ }
125
+
126
+ export const cutOf = (dir: string, cut: string): CutFiles | null => (CUT_RE.test(cut) ? (cutsOf(dir).find((c) => c.cut === cut) ?? null) : null)
127
+ /** a cut's render: out/<name>.mp4, or a version's own */
128
+ export const videoOf = (dir: string, c: CutFiles): string => (c.version ? (versionFile(dir, c.cut, '.mp4') ?? join(dir, 'versions', c.cut, `${c.cut}.mp4`)) : join(dir, 'out', `${c.name}.mp4`))
129
+ const stripOf = (dir: string, c: CutFiles): string => (c.version ? (versionFile(dir, c.cut, '.strip.png') ?? join(dir, 'versions', c.cut, `${c.cut}.strip.png`)) : join(dir, 'out', `${c.name}.strip.png`))
130
+ export const montageOf = (dir: string, c: CutFiles): string => join(dir, 'out', `${c.name}.montage.md`)
131
+
132
+ /** a cut's .trailer parsed: the film lines need the project CLI's words, which a timeline never does, so their errors
133
+ * are left out */
134
+ function parseCut(file: string): { trailer: Trailer; errors: string[] } {
135
+ const text = readFileSync(file, 'utf8')
136
+ const lines = text.split('\n')
137
+ const { trailer, errors } = parseTrailer(text, { verbs: null })
138
+ const own = errors.filter((e) => !/^\s*film\s/.test(lines[e.line - 1] ?? ''))
139
+ return { trailer, errors: own.map((e) => `${file.split('/').pop()}:${e.line}: ${e.message}`) }
140
+ }
141
+
142
+ /** a render younger than this may still be written: ffmpeg writes in place */
143
+ const SETTLE_MS = 2500
144
+ const settled = (mtime: number, now: number): boolean => now - mtime > SETTLE_MS
145
+
146
+ /** where each finished render is kept as the page plays it: an APFS clone, free, untouched by the next render */
147
+ export const viewDir = (root: string): string => join(root, LAYOUT.cache, 'trailer-view')
148
+ /** the versions kept per cut: the one on screen may be the one before the newest */
149
+ const VIEWS_KEPT = 3
150
+
151
+ /** a finished render of a cut: its clone, its length and rate, its size, the timeline it was rendered from (null: unknown) */
152
+ export interface View {
153
+ mtime: number
154
+ duration: number
155
+ fps: number
156
+ size: [number, number]
157
+ timeline: Timeline | null
158
+ }
159
+
160
+ const viewBase = (root: string, id: string, cut: string, mtime: number): string => join(viewDir(root), `${id}--${cut}--${Math.round(mtime)}`)
161
+ export const viewVideo = (root: string, id: string, cut: string, mtime: number): string => `${viewBase(root, id, cut, mtime)}.mp4`
162
+ export const viewStrip = (root: string, id: string, cut: string, mtime: number): string => `${viewBase(root, id, cut, mtime)}.strip.png`
163
+
164
+ /** the render's contact strip kept beside it, once the build has written it (just after the mp4) */
165
+ function keepStrip(root: string, dir: string, id: string, c: CutFiles, mtime: number): void {
166
+ const kept = viewStrip(root, id, c.cut, mtime)
167
+ const sf = stripOf(dir, c)
168
+ if (existsSync(kept) || !existsSync(sf) || statSync(sf).mtimeMs < mtime || !settled(statSync(sf).mtimeMs, Date.now())) return
169
+ copyFileSync(sf, kept, constants.COPYFILE_FICLONE)
170
+ }
171
+
172
+ /** a kept version by its mtime, null when it was pruned */
173
+ export function readView(root: string, id: string, cut: string, mtime: number): View | null {
174
+ const f = `${viewBase(root, id, cut, mtime)}.json`
175
+ return existsSync(f) && existsSync(viewVideo(root, id, cut, mtime)) ? (JSON.parse(readFileSync(f, 'utf8')) as View) : null
176
+ }
177
+
178
+ function newestView(root: string, id: string, cut: string): View | null {
179
+ if (!existsSync(viewDir(root))) return null
180
+ const prefix = `${id}--${cut}--`
181
+ const ms = readdirSync(viewDir(root))
182
+ .filter((f) => f.startsWith(prefix) && f.endsWith('.json'))
183
+ .map((f) => Number(f.slice(prefix.length, -5)))
184
+ .filter((m) => Number.isFinite(m))
185
+ .sort((x, y) => y - x)
186
+ for (const m of ms) {
187
+ const v = readView(root, id, cut, m)
188
+ if (v) return v
189
+ }
190
+ return null
191
+ }
192
+
193
+ /** the render's length, rate and size; null while ffprobe cannot read it (a render still written) */
194
+ function probeVideo(video: string): { duration: number; fps: number; size: [number, number] } | null {
195
+ try {
196
+ const p = probe(video)
197
+ return { duration: p.duration, fps: p.video?.fps || 30, size: [p.video?.width ?? 1920, p.video?.height ?? 1080] }
198
+ } catch {
199
+ return null
200
+ }
201
+ }
202
+
203
+ /** every repo the hub serves keeps its own: the key carries the root */
204
+ const views = new Map<string, View>()
205
+
206
+ /** the cut's render as the page plays it: the newest finished version, kept; while a new one is written, the last one */
207
+ export function viewOf(root: string, dir: string, id: string, c: CutFiles, now = Date.now()): { view: View | null; rendering: boolean } {
208
+ const key = `${root}\0${id}/${c.cut}`
209
+ const kept = views.get(key) ?? newestView(root, id, c.cut)
210
+ const video = videoOf(dir, c)
211
+ if (!existsSync(video)) return { view: kept, rendering: false }
212
+ const mtime = Math.round(statSync(video).mtimeMs)
213
+ if (kept?.mtime === mtime) {
214
+ views.set(key, kept)
215
+ keepStrip(root, dir, id, c, mtime)
216
+ return { view: kept, rendering: false }
217
+ }
218
+ if (!settled(mtime, now)) return { view: kept, rendering: true }
219
+ const p = probeVideo(video)
220
+ if (!p) return { view: kept, rendering: true }
221
+ mkdirSync(viewDir(root), { recursive: true })
222
+ copyFileSync(video, viewVideo(root, id, c.cut, mtime), constants.COPYFILE_FICLONE)
223
+ // the .trailer saved before the render is the one it was rendered from; one saved after may differ
224
+ let timeline: Timeline | null = null
225
+ if (c.trailer === null) timeline = wholeTimeline(p.duration, p.fps, p.size)
226
+ else if (statSync(c.trailer).mtimeMs <= mtime)
227
+ try {
228
+ timeline = timelineOf(parseCut(c.trailer).trailer)
229
+ } catch {
230
+ timeline = null
231
+ }
232
+ const view: View = { mtime, ...p, timeline }
233
+ writeFileSync(`${viewBase(root, id, c.cut, mtime)}.json`, JSON.stringify(view))
234
+ keepStrip(root, dir, id, c, mtime)
235
+ views.set(key, view)
236
+ pruneViews(root, id, c.cut)
237
+ return { view, rendering: false }
238
+ }
239
+
240
+ function pruneViews(root: string, id: string, cut: string): void {
241
+ const prefix = `${id}--${cut}--`
242
+ for (const f of viewsToDrop(root)) if (f.slice(viewDir(root).length + 1).startsWith(prefix)) rmSync(f, { force: true })
243
+ }
244
+
245
+ /** the kept renders that go: every one past the newest VIEWS_KEPT of a cut, and every one of a cut its folder no longer
246
+ * has (a note keeps its own still) */
247
+ export function viewsToDrop(root: string): string[] {
248
+ const vd = viewDir(root)
249
+ if (!existsSync(vd)) return []
250
+ const live = new Set(trailerIds(root).flatMap((id) => cutsOf(join(trailersDir(root), id)).map((c) => `${id}--${c.cut}`)))
251
+ const byCut = new Map<string, Set<number>>()
252
+ const files = readdirSync(vd)
253
+ for (const f of files) {
254
+ const m = /^(.+)--(\d+)\./.exec(f)
255
+ if (!m) continue
256
+ const ms = byCut.get(m[1]!) ?? new Set<number>()
257
+ byCut.set(m[1]!, ms.add(Number(m[2])))
258
+ }
259
+ const kept = new Set(
260
+ [...byCut].flatMap(([cut, ms]) =>
261
+ live.has(cut)
262
+ ? [...ms]
263
+ .sort((x, y) => y - x)
264
+ .slice(0, VIEWS_KEPT)
265
+ .map((m) => `${cut}--${m}`)
266
+ : [],
267
+ ),
268
+ )
269
+ return files.filter((f) => !kept.has(/^(.+--\d+)\./.exec(f)?.[1] ?? '')).map((f) => join(vd, f))
270
+ }
271
+
272
+ /** the contact strip's grid as src/trailer/stitch.ts lays it: rows of six tiles THUMB_W wide, 6 px apart */
273
+ const STRIP_COLS = 6
274
+ const STRIP_PAD = 6
275
+
276
+ /** the strip's grid, when the file's size fits this timeline's shots */
277
+ function stripInfo(file: string, tl: Timeline): CutInfo['strip'] {
278
+ if (!existsSync(file) || tl.shots.length === 0) return null
279
+ const head = readFileSync(file).subarray(0, 24)
280
+ const w = head.readUInt32BE(16)
281
+ const h = head.readUInt32BE(20)
282
+ const cols = Math.min(STRIP_COLS, tl.shots.length)
283
+ const rows = Math.ceil(tl.shots.length / cols)
284
+ const tileH = Math.round((THUMB_W * tl.size[1]) / tl.size[0] / 2) * 2
285
+ const fits = w === cols * THUMB_W + (cols - 1) * STRIP_PAD && h === rows * tileH + (rows - 1) * STRIP_PAD
286
+ return fits ? { cols, tileW: THUMB_W, tileH, pad: STRIP_PAD } : null
287
+ }
288
+
289
+ export function cutInfo(root: string, dir: string, id: string, c: CutFiles): CutInfo {
290
+ const video = videoOf(dir, c)
291
+ const fileMtime = c.trailer ? statSync(c.trailer).mtimeMs : existsSync(video) ? statSync(video).mtimeMs : 0
292
+ let parsed: Timeline | null = null
293
+ let errors: string[] = []
294
+ if (c.trailer)
295
+ try {
296
+ const p = parseCut(c.trailer)
297
+ parsed = timelineOf(p.trailer)
298
+ errors = p.errors
299
+ } catch (e) {
300
+ errors = [`${c.cut}.trailer: ${(e as Error).message}`]
301
+ }
302
+ const { view, rendering } = viewOf(root, dir, id, c)
303
+ const timeline = view?.timeline ?? parsed
304
+ const rel = (p: string): string => relative(root, p)
305
+ const montage = montageOf(dir, c)
306
+ return {
307
+ cut: c.cut,
308
+ name: c.name,
309
+ files: { trailer: c.trailer ? rel(c.trailer) : null, video: rel(video), strip: rel(stripOf(dir, c)), montage: existsSync(montage) ? rel(montage) : null },
310
+ timeline,
311
+ timelineFromRender: view?.timeline != null,
312
+ errors,
313
+ video: view ? { mtime: view.mtime, duration: view.duration, fps: view.fps } : null,
314
+ rendering: !c.version && rendering,
315
+ edited: !c.version && c.trailer !== null && view !== null && fileMtime > view.mtime,
316
+ strip: timeline && view ? stripInfo(viewStrip(root, id, c.cut, view.mtime), timeline) : null,
317
+ fileMtime,
318
+ version: c.version === true,
319
+ }
320
+ }
321
+
322
+ export function trailerInfo(root: string, id: string): TrailerInfo | null {
323
+ const dir = trailerDir(root, id)
324
+ if (!dir) return null
325
+ return { id, cuts: cutsOf(dir).map((c) => cutInfo(root, dir, id, c)), notes: notesOf(readAllCached(storeDir(root)).items, id) }
326
+ }
327
+
328
+ /** the montage page of a cut, null when its build wrote none */
329
+ export function montagePage(root: string, id: string, cut: string): string | null {
330
+ const dir = trailerDir(root, id)
331
+ const c = dir ? cutOf(dir, cut) : null
332
+ if (!dir || !c) return null
333
+ const f = montageOf(dir, c)
334
+ return existsSync(f) ? readFileSync(f, 'utf8') : null
335
+ }
336
+
337
+ // ── a note's frame ──
338
+
339
+ /** a note's frame on a cut: its index and the frame's middle, where the player and ffmpeg both show it */
340
+ export function frameAt(time: number, fps: number, duration: number): { frame: number; time: number } {
341
+ const last = Math.max(0, Math.round(duration * fps) - 1)
342
+ const frame = Math.max(0, Math.min(last, Math.floor(time * fps + 1e-6)))
343
+ return { frame, time: (frame + 0.5) / fps }
344
+ }
345
+
346
+ export interface NewNote {
347
+ cut: string
348
+ /** the render on screen, by its mtime: the still is cut from that version, whatever came after */
349
+ version: number
350
+ time: number
351
+ }
352
+
353
+ /** the frame at `time` of the render on screen as a note's subject, and its still cut into `still`: the shot read from
354
+ * that render's timeline */
355
+ export async function frameNote(root: string, trailer: string, input: NewNote, still: string): Promise<FrameSubject> {
356
+ const dir = trailerDir(root, trailer)
357
+ const c = dir ? cutOf(dir, input.cut) : null
358
+ if (!dir || !c) throw new Error(`no cut ${trailer}/${input.cut}`)
359
+ const v = readView(root, trailer, input.cut, input.version)
360
+ if (!v) throw new Error(`the render of ${input.cut} on screen is gone: load the new one`)
361
+ const tl = v.timeline ?? (c.trailer ? timelineOf(parseCut(c.trailer).trailer) : wholeTimeline(v.duration, v.fps, v.size))
362
+ const f = frameAt(input.time, v.fps, v.duration)
363
+ const shot = shotAt(tl, f.time)
364
+ if (!shot) throw new Error(`${input.cut} has no shot`)
365
+ await stillAt(viewVideo(root, trailer, input.cut, v.mtime), (f.frame - 0.5) / v.fps, still)
366
+ return { kind: 'frame', trailer, cut: input.cut, version: String(v.mtime), time: round(f.time), frame: f.frame, shot: shot.name, shotTime: round(f.time - shot.start), section: shot.section }
367
+ }
368
+
369
+ /** what a live poll compares: every .trailer file and render of every trailer, by name and mtime, and whether each
370
+ * render has settled */
371
+ export function trailerStamp(root: string, now = Date.now()): string {
372
+ const parts: string[] = []
373
+ for (const id of trailerIds(root)) {
374
+ const dir = join(trailersDir(root), id)
375
+ for (const d of [dir, join(dir, 'out'), join(dir, 'versions')]) {
376
+ if (!existsSync(d)) continue
377
+ for (const f of readdirSync(d)) {
378
+ if (!/\.(trailer|mp4)$/.test(f) || f.startsWith('shot-')) continue
379
+ const m = statSync(join(d, f)).mtimeMs
380
+ parts.push(`${id}/${f}:${m}${f.endsWith('.mp4') && !settled(m, now) ? ':writing' : ''}`)
381
+ }
382
+ }
383
+ }
384
+ return parts.join('|')
385
+ }