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,896 @@
1
+ // the one feedback store's model (wiki build/shipkit/review.md): every record the owner, a player or an agent raises
2
+ // is an ITEM, a head (`item.json`: a typed subject, who raised it, the views) and an append only thread of messages
3
+ // (verdicts, replies, ratings, state changes, read marks). The state is derived from the thread, never stored, so the
4
+ // hub and every CLI write at once with no lost update. Pure and browser safe: the store, the routes and the hub page
5
+ // share it. The on disk shape is pkm-multi's (scripts/review/store.ts) verbatim, so either store opens in the other.
6
+
7
+ /** WHAT is judged: `shot` (a plain image or clip), `screen`, `frame` (a frame of a trailer cut), or a kind a repo names
8
+ * for itself (pkm-multi's place, creature, song...), read as is */
9
+ export interface Subject {
10
+ kind: string
11
+ [k: string]: unknown
12
+ }
13
+
14
+ /** a frame of a trailer cut: THE ANCHOR is its shot's name and the seconds into it, so it survives a new render */
15
+ export interface FrameSubject extends Subject {
16
+ kind: 'frame'
17
+ trailer: string
18
+ cut: string
19
+ /** the render on screen when it was written, its mtime in ms */
20
+ version?: string
21
+ time: number
22
+ frame: number
23
+ shot: string
24
+ shotTime: number
25
+ section: string | null
26
+ }
27
+
28
+ export const isFrame = (s: Subject): s is FrameSubject => s.kind === 'frame'
29
+
30
+ /** what a repo's `review({...})` unit says of one subject kind that the pure model reads (review/unit.ts holds the rest):
31
+ * its words, and on the server its key, its near rule and its problems. The hub gets the words alone */
32
+ export interface SpecKind {
33
+ label: string
34
+ icon?: string
35
+ /** what names one subject of this kind exactly, the kind left out; null names nothing */
36
+ key?(s: Subject): string | null
37
+ /** two subjects of this kind, in one topic, close enough to be one card */
38
+ near?(a: Subject, b: Subject): boolean
39
+ /** what is wrong with a subject of this kind; empty: nothing */
40
+ problems?(s: Subject): string[]
41
+ }
42
+
43
+ /** an area raised items are routed to, and the pages its worker reads first */
44
+ export interface SpecTopic {
45
+ label: string
46
+ covers?: string
47
+ read?: readonly string[]
48
+ }
49
+
50
+ /** a repo's review parts as the model reads them; NO_SPEC for a repo with no unit */
51
+ export interface Spec {
52
+ kinds: Record<string, SpecKind>
53
+ topics: Record<string, SpecTopic>
54
+ /** one session holds the dispatcher role and routes what is raised */
55
+ dispatcher: boolean
56
+ }
57
+
58
+ /** the kinds every repo has, whatever its unit declares */
59
+ export const BUILTIN_KINDS: Record<string, SpecKind> = { shot: { label: 'images' }, screen: { label: 'screens' }, frame: { label: 'trailer frames' } }
60
+
61
+ export const NO_SPEC: Spec = { kinds: {}, topics: {}, dispatcher: false }
62
+
63
+ /** the words the list's subject filter shows; a kind no unit names shows its own name */
64
+ export const subjectLabel = (kind: string, spec: Spec = NO_SPEC): string => (spec.kinds[kind] ?? BUILTIN_KINDS[kind])?.label ?? kind
65
+
66
+ /** a topic's words, its id when no unit names it */
67
+ export const topicLabel = (topic: string, spec: Spec = NO_SPEC): string => spec.topics[topic]?.label ?? topic
68
+
69
+ /** WHO raised it: an agent's card goes back to its lane on a rejection */
70
+ export type Origin =
71
+ | { by: 'agent'; lane?: string; session?: string }
72
+ | { by: 'owner'; from: string; kind?: string; lane?: string }
73
+ | { by: 'player'; player: string; server: string; serverId: number; client: string; context: Record<string, unknown> }
74
+ | { by: 'auto'; rule: string; key: string; lane: string }
75
+
76
+ /** what names one subject exactly, null when it names nothing (a plain shot, a screen): two items of different keys
77
+ * are never one subject. A kind the unit declares names it by its own `key`, or names nothing without one; a kind no
78
+ * unit declares by its first of species, key, slug, id */
79
+ export function subjectKey(s: Subject, spec: Spec = NO_SPEC): string | null {
80
+ if (isFrame(s)) return `frame:${s.trailer}/${s.cut}/${s.shot}`
81
+ const kind = spec.kinds[s.kind]
82
+ if (kind) {
83
+ const k = kind.key?.(s)
84
+ return k ? `${s.kind}:${k}` : null
85
+ }
86
+ for (const k of ['species', 'key', 'slug', 'id'] as const) if (typeof s[k] === 'string' && s[k] !== '') return `${s.kind}:${s[k] as string}`
87
+ return null
88
+ }
89
+
90
+ /** an image, a clip, a sound, or a pair: two images, two clips or two sounds */
91
+ export type ReviewFiles = { image: string } | { video: string } | { audio: string } | { before: string; after: string }
92
+
93
+ /** a clip view's first frame (a png of the folder), its length in seconds, its frames a second */
94
+ export interface ReviewClip {
95
+ poster: string
96
+ duration: number
97
+ fps: number
98
+ }
99
+
100
+ /** a sound view: its length in seconds, and its waveform, a png of the folder (the after's, and the before's on a pair);
101
+ * the view's `size` is the waveform's */
102
+ export interface ReviewAudio {
103
+ duration: number
104
+ wave: string
105
+ waveBefore?: string
106
+ }
107
+
108
+ /** a ring a repo drew over its own shot (pkm-multi's world tiles): its center normalized 0..1, its radius as a share of
109
+ * the image width; `side` only on a pair */
110
+ export interface ReviewMark {
111
+ tile: [number, number]
112
+ edge?: 'n' | 'e' | 's' | 'w'
113
+ x: number
114
+ y: number
115
+ r: number
116
+ side?: Side
117
+ }
118
+
119
+ /** one view of a card's subject: an image or a pair, its size, its marks; the first view's label may be empty */
120
+ export interface MediaView {
121
+ label: string
122
+ files: ReviewFiles
123
+ size: { w: number; h: number }
124
+ clip?: ReviewClip
125
+ audio?: ReviewAudio
126
+ marks?: ReviewMark[]
127
+ /** a line on this view alone (an option's description on a choice card) */
128
+ note?: string
129
+ }
130
+
131
+ /** one image of a gallery view, a file of the folder (`v<n>-g<k>.<ext>`) */
132
+ export interface GalleryTile {
133
+ file: string
134
+ label: string
135
+ size: { w: number; h: number }
136
+ }
137
+
138
+ /** a view that is a list of small images, shown as a wrapping grid; never the first view */
139
+ export interface GalleryView {
140
+ label: string
141
+ gallery: GalleryTile[]
142
+ }
143
+
144
+ export type ReviewView = MediaView | GalleryView
145
+
146
+ export const isGallery = (v: ReviewView): v is GalleryView => 'gallery' in v
147
+
148
+ /** `item.json`: the head, written at creation, changed only by `add-view` */
149
+ export interface Item {
150
+ id: string
151
+ /** ISO time */
152
+ created: string
153
+ subject: Subject
154
+ origin: Origin
155
+ title: string
156
+ /** an agent card's markdown: what changed and why */
157
+ what?: string
158
+ topic?: string
159
+ commit?: string
160
+ /** the first is a media view, never a gallery */
161
+ views: ReviewView[]
162
+ /** an earlier card this one is a new version of; never a raised item */
163
+ supersedes?: string
164
+ /** the raised items (a report, his note) this card answers: one thread per bug */
165
+ answers?: string[]
166
+ /** what this new version keeps of the approved look it follows */
167
+ keeps?: string
168
+ /** why this card passed --new next to an active card */
169
+ why?: string
170
+ light?: 'review'
171
+ /** a draft card of a repo that shoots live: no verdict until it is ready */
172
+ wip?: { live: string; frame: number }
173
+ /** a choice: every view is an option, he scores them and picks one */
174
+ choice?: true
175
+ }
176
+
177
+ /** a choice card: he picks one of its views */
178
+ export const isChoice = (i: Item): boolean => i.choice === true
179
+
180
+ /** a view that plays on its own: one clip or one sound, never a pair */
181
+ export const isPlayable = (v: ReviewView | undefined): v is MediaView => v !== undefined && !isGallery(v) && !isPair(v.files) && (v.clip !== undefined || v.audio !== undefined)
182
+
183
+ /** a card shown as options, one thin row a view: a choice, or two views or more that each play on their own */
184
+ export const asOptions = (i: Item): boolean => isChoice(i) || (i.views.length >= 2 && i.views.every(isPlayable))
185
+
186
+ /** the most stars a reaction gives */
187
+ export const STARS = 5
188
+
189
+ /** a view's name as the page and the CLI show it: its label, or « view n » */
190
+ export const viewName = (v: ReviewView | undefined, i: number): string => v?.label || `view ${i + 1}`
191
+
192
+ /** the first view, media when there is one; a note with no image has none */
193
+ export const firstView = (i: Item): MediaView | undefined => i.views[0] as MediaView | undefined
194
+
195
+ /** the file that stands for a view in a list: a clip's first frame, the after of a pair, the image */
196
+ export function thumbFileOf(v: ReviewView): string {
197
+ if (isGallery(v)) return v.gallery[0]?.file ?? ''
198
+ if (v.clip) return v.clip.poster
199
+ if (v.audio) return v.audio.wave
200
+ const f = v.files
201
+ return 'image' in f ? f.image : 'video' in f ? f.video : 'audio' in f ? f.audio : f.after
202
+ }
203
+
204
+ /** a rectangle drawn at clip time `t` is on the frame shown at `time` */
205
+ export const onFrame = (t: number, time: number, fps: number): boolean => Math.floor(t * fps + 1e-6) === Math.floor(time * fps + 1e-6)
206
+
207
+ export type Side = 'before' | 'after'
208
+
209
+ /** a rectangle on a view, normalized 0..1; w = h = 0 is a pin. `view` the index of its view, absent on the first;
210
+ * `side` only on a pair; `t` only on a clip */
211
+ export interface Rect {
212
+ x: number
213
+ y: number
214
+ w: number
215
+ h: number
216
+ comment: string
217
+ view?: number
218
+ side?: Side
219
+ /** the clip time it was drawn at, in seconds */
220
+ t?: number
221
+ world?: { x0: number; y0: number; x1: number; y1: number }
222
+ nodes?: string[]
223
+ }
224
+
225
+ /** `approved-with-fix` counts as approved: its comment and rectangles are a small follow up; `archived` is set aside
226
+ * with no verdict, so it asks nothing of the agent */
227
+ export const STATUSES = ['approved', 'approved-with-fix', 'rejected', 'archived'] as const
228
+ export type ReviewStatus = (typeof STATUSES)[number]
229
+
230
+ export const isApproved = (s: ReviewStatus): boolean => s === 'approved' || s === 'approved-with-fix'
231
+
232
+ /** the owner's reaction to one view, or to one tile of a gallery view; one per view or tile */
233
+ export interface ReviewReaction {
234
+ view: number
235
+ tile?: number
236
+ status?: ReviewStatus
237
+ comment: string
238
+ /** his score of the view, 1 to STARS */
239
+ stars?: number
240
+ }
241
+
242
+ /** a state change, or a read mark (`seen`, with the ids it read in `of`) */
243
+ export const OPS = ['dispatch', 'ask', 'answer', 'reassign', 'claim', 'release', 'blocked', 'fixed', 'done', 'wontfix', 'drop', 'withdraw', 'reopen', 'ready', 'seen'] as const
244
+ export type Op = (typeof OPS)[number]
245
+
246
+ /** one act on an item, one file of `thread/`, never rewritten */
247
+ export interface Message {
248
+ id: string
249
+ /** ISO time */
250
+ at: string
251
+ /** `owner`, `player`, or an agent: its lane name, `agent` when unnamed */
252
+ who: string
253
+ text?: string
254
+ /** a file of the folder's `thread/` beside it */
255
+ image?: string
256
+ rects?: Rect[]
257
+ reactions?: ReviewReaction[]
258
+ verdict?: ReviewStatus
259
+ /** the view he picked on a choice card, its index */
260
+ pick?: number
261
+ /** a repo's own mark on a subject (stars, a song's mark): kept as written */
262
+ rate?: { stars?: number; mark?: string; part?: string }
263
+ op?: Op
264
+ /** the op's reason or answer */
265
+ note?: string
266
+ /** `dispatch`: where it goes */
267
+ topic?: string
268
+ /** `seen`: the messages it read */
269
+ of?: string[]
270
+ fix?: { commit: string }
271
+ }
272
+
273
+ /** what the store reads of one folder, what the list sends */
274
+ export interface ReviewItem {
275
+ item: Item
276
+ /** oldest first */
277
+ thread: Message[]
278
+ }
279
+
280
+ /** the two parties of a thread: the owner and the players on one side, the agents on the other */
281
+ export type Party = 'agent' | 'owner'
282
+ export const partyOf = (who: string): Party => (who === 'owner' || who === 'player' ? 'owner' : 'agent')
283
+
284
+ /** a message one party says to the other: a reply, a verdict, a rating, a question and its answer; never another op */
285
+ const isTalk = (m: Message): boolean => m.op === undefined || ((m.op === 'ask' || m.op === 'answer') && m.text !== undefined)
286
+
287
+ /** a reply: talk with words that is no verdict and no rating (a message moving a pin alone is no reply) */
288
+ export const isReply = (m: Message): boolean => isTalk(m) && m.verdict === undefined && m.rate === undefined && m.text !== undefined
289
+
290
+ export const MESSAGE_RE = /^\d{8}-\d{6}-\d{3}-(agent|owner|player)(-[a-z0-9]+)*$/
291
+ export const MESSAGE_FILE_RE = /^\d{8}-\d{6}-\d{3}-(agent|owner|player)(-[a-z0-9]+)*\.(png|jpe?g|webp)$/
292
+ const MAX_TEXT = 4000
293
+
294
+ /** an agent's name: its lane (`L135`, `REVIEW`) */
295
+ export const AGENT_RE = /^[A-Za-z][A-Za-z0-9-]{0,23}$/
296
+
297
+ const two = (n: number): string => String(n).padStart(2, '0')
298
+ const stampOf = (at: Date): string => `${at.getFullYear()}${two(at.getMonth() + 1)}${two(at.getDate())}-${two(at.getHours())}${two(at.getMinutes())}${two(at.getSeconds())}`
299
+
300
+ /** `YYYYMMDD-HHMMSS-mmm-<owner|player|agent>`, local time: the names sort in time */
301
+ export function messageIdOf(at: Date, who: string): string {
302
+ const role = who === 'owner' || who === 'player' ? who : 'agent'
303
+ return `${stampOf(at)}-${String(at.getMilliseconds()).padStart(3, '0')}-${role}`
304
+ }
305
+
306
+ /** a message's text as sent, trimmed; an error is the reason it is refused */
307
+ export function textOf(v: unknown): { text: string } | { error: string } {
308
+ if (typeof v !== 'string' || v.trim() === '') return { error: 'text: required' }
309
+ if (v.length > MAX_TEXT) return { error: `text: at most ${MAX_TEXT} characters` }
310
+ return { text: v.trim() }
311
+ }
312
+
313
+ /** every message id a party marked read */
314
+ const readBy = (x: ReviewItem, party: Party): Set<string> =>
315
+ new Set(x.thread.flatMap((m) => (m.op === 'seen' && partyOf(m.who) === party ? (m.of ?? []) : [])))
316
+
317
+ /** the talk of the other party that `party` has not read: replies, verdicts, ratings */
318
+ const unreadTalk = (x: ReviewItem, party: Party): Message[] => {
319
+ const read = readBy(x, party)
320
+ return x.thread.filter((m) => isTalk(m) && partyOf(m.who) !== party && !read.has(m.id))
321
+ }
322
+
323
+ /** the replies of the other party `party` has not read; a verdict is no reply (verdictOf says whether it was seen) */
324
+ export const unreadBy = (x: ReviewItem, party: Party): Message[] => unreadTalk(x, party).filter(isReply)
325
+
326
+ /** what a `seen` of `party` reads on `x`: every message of the other party it has not read yet */
327
+ export const toAcknowledge = (x: ReviewItem, party: Party): string[] => unreadTalk(x, party).map((m) => m.id)
328
+
329
+ /** the owner's verdict as the card shows it: the latest one since the last reopen; `seen`: an agent read it */
330
+ export interface Verdict {
331
+ id: string
332
+ status: ReviewStatus
333
+ comment: string
334
+ rects: Rect[]
335
+ reactions?: ReviewReaction[]
336
+ /** the view he picked, on a choice card */
337
+ pick?: number
338
+ at: string
339
+ seen: boolean
340
+ }
341
+
342
+ export function verdictOf(x: ReviewItem): Verdict | null {
343
+ let m: Message | undefined
344
+ let text: string | undefined
345
+ let rects: Rect[] | undefined
346
+ for (const n of x.thread) {
347
+ if (n.op === 'reopen') [m, text, rects] = [undefined, undefined, undefined]
348
+ else if (n.verdict !== undefined) {
349
+ if (m?.verdict !== n.verdict) [text, rects] = [undefined, undefined]
350
+ m = n
351
+ if (n.text !== undefined) text = n.text
352
+ if (n.rects !== undefined) rects = n.rects
353
+ }
354
+ }
355
+ if (!m?.verdict) return null
356
+ return {
357
+ id: m.id,
358
+ status: m.verdict,
359
+ // a bare repeat of the same verdict keeps the words and rectangles said with it before
360
+ comment: text ?? '',
361
+ rects: rects ?? [],
362
+ ...(m.reactions && m.reactions.length > 0 ? { reactions: m.reactions } : {}),
363
+ ...(m.pick !== undefined ? { pick: m.pick } : {}),
364
+ at: m.at,
365
+ seen: readBy(x, 'agent').has(m.id),
366
+ }
367
+ }
368
+
369
+ /** why the agent took the card back unanswered; undefined when it did not */
370
+ export const withdrawnOf = (x: ReviewItem): string | undefined => x.thread.filter((m) => m.op === 'withdraw').at(-1)?.note
371
+
372
+ /** what the list says of an item: an agent's card by the owner's verdict, what he or a player raised by its state */
373
+ export type ShownStatus = 'pending' | 'wip' | ReviewStatus | 'superseded' | 'withdrawn' | 'raised' | 'fixed' | 'wontfix' | 'rated'
374
+
375
+ export const STATUS_LABEL: Record<ShownStatus, string> = {
376
+ pending: 'waiting',
377
+ wip: 'in progress',
378
+ approved: 'approved',
379
+ 'approved-with-fix': 'ok with a fix',
380
+ rejected: 'rejected',
381
+ archived: 'archived, later',
382
+ superseded: 'replaced',
383
+ withdrawn: 'withdrawn',
384
+ raised: 'open',
385
+ fixed: 'done',
386
+ wontfix: 'not done',
387
+ rated: 'rated',
388
+ }
389
+
390
+ /** the lifecycle (wiki build/shipkit/review.md, The lifecycle) */
391
+ export type State = 'draft' | 'new' | 'question' | 'open' | 'claimed' | 'blocked' | 'asks' | 'closed'
392
+ type ClosedWhy = ReviewStatus | 'fixed' | 'wontfix' | 'dropped' | 'superseded' | 'withdrawn' | 'rated'
393
+
394
+ export interface ItemState {
395
+ state: State
396
+ why?: ClosedWhy
397
+ topic?: string
398
+ /** who holds a claim */
399
+ worker?: string
400
+ /** the time of the `claim` that holds it; none on the claim the owner's verdict gives the card's lane until it is read */
401
+ since?: string
402
+ }
403
+
404
+ /** whose move it is: the owner's (asks, question, blocked), the agents' (new, open, claimed), nobody's (draft, closed) */
405
+ export type Move = 'owner' | 'agents' | 'none'
406
+ export const moveOf = (s: State): Move =>
407
+ s === 'asks' || s === 'question' || s === 'blocked' ? 'owner' : s === 'new' || s === 'open' || s === 'claimed' ? 'agents' : 'none'
408
+
409
+ export const MOVE_LABEL: Record<Move, string> = { owner: 'mine', agents: 'in work', none: 'done' }
410
+
411
+ /** where an item starts: an agent's card asks for the owner's verdict (a draft waits); what he raises from a tool that named
412
+ * its topic (the trailer view: `film`) is open, else it waits on a dispatcher; an auto rule's item is open */
413
+ const startOf = (i: Item): ItemState =>
414
+ i.origin.by === 'agent'
415
+ ? { state: i.wip ? 'draft' : 'asks' }
416
+ : i.topic || i.origin.by === 'auto'
417
+ ? { state: 'open', ...(i.topic ? { topic: i.topic } : {}) }
418
+ : { state: 'new' }
419
+
420
+ /** the state of an item, folded from its thread. A verdict wins over a withdrawal, as on the card; `superseded`: a
421
+ * later version follows it. « fix » and « no » give the card back to its author until an agent reads them */
422
+ export function stateOf(x: ReviewItem, superseded: boolean): ItemState {
423
+ const i = x.item
424
+ const lane = i.origin.by === 'agent' ? i.origin.lane : undefined
425
+ let s: ItemState = startOf(i)
426
+ // what his latest verdict left: a released or superseded rework of an agent's card goes back to it
427
+ let given: ItemState = s
428
+ const read = readBy(x, 'agent')
429
+ for (const m of x.thread) {
430
+ const keep = s.topic ? { topic: s.topic } : {}
431
+ if (m.verdict !== undefined) {
432
+ const v = m.verdict
433
+ if (v === 'approved' || v === 'archived' || read.has(m.id)) s = { ...keep, state: 'closed', why: v }
434
+ else s = { ...keep, state: 'claimed', ...(lane ? { worker: lane } : {}) }
435
+ given = s
436
+ continue
437
+ }
438
+ if (m.rate !== undefined) {
439
+ s = { ...keep, state: 'closed', why: 'rated' }
440
+ continue
441
+ }
442
+ // the owner's words on a question answer it: it goes back to the dispatcher
443
+ if (m.op === undefined && partyOf(m.who) === 'owner' && m.text !== undefined && s.state === 'question') {
444
+ s = { ...keep, state: 'new' }
445
+ continue
446
+ }
447
+ switch (m.op) {
448
+ case 'dispatch':
449
+ case 'reassign':
450
+ s = { state: 'open', ...(m.topic ? { topic: m.topic } : keep) }
451
+ break
452
+ case 'ask':
453
+ s = { ...keep, state: 'question' }
454
+ break
455
+ case 'answer':
456
+ s = { ...keep, state: 'new' }
457
+ break
458
+ case 'claim':
459
+ s = { ...keep, state: 'claimed', worker: m.who, since: m.at }
460
+ break
461
+ case 'release':
462
+ s = i.origin.by === 'agent' ? given : { ...keep, state: 'open' }
463
+ break
464
+ case 'blocked':
465
+ s = { ...keep, state: 'blocked' }
466
+ break
467
+ case 'fixed':
468
+ s = { ...keep, state: 'closed', why: 'fixed' }
469
+ break
470
+ case 'done':
471
+ s = { ...keep, state: 'closed', why: verdictOf(x)?.status === 'approved-with-fix' ? 'approved-with-fix' : 'fixed' }
472
+ break
473
+ case 'wontfix':
474
+ s = { ...keep, state: 'closed', why: 'wontfix' }
475
+ break
476
+ case 'drop':
477
+ s = { ...keep, state: 'closed', why: 'dropped' }
478
+ break
479
+ case 'reopen':
480
+ s = i.origin.by === 'agent' ? { ...keep, state: 'asks' } : { ...keep, state: s.topic || i.origin.by === 'auto' ? 'open' : 'new' }
481
+ break
482
+ default:
483
+ break
484
+ }
485
+ }
486
+ // the card that redoes it landed: the rework is over
487
+ if (superseded && i.origin.by === 'agent' && s.since !== undefined) return given
488
+ if (verdictOf(x) === null) {
489
+ if (withdrawnOf(x) !== undefined) return { state: 'closed', why: 'withdrawn' }
490
+ if (superseded && s.state !== 'closed') return { ...(s.topic ? { topic: s.topic } : {}), state: 'closed', why: 'superseded' }
491
+ }
492
+ return s
493
+ }
494
+
495
+ /** the states each op leaves from */
496
+ const FROM: Record<Exclude<Op, 'seen'>, readonly State[]> = {
497
+ dispatch: ['new', 'blocked'],
498
+ ask: ['new', 'blocked'],
499
+ answer: ['question'],
500
+ reassign: ['open', 'claimed'],
501
+ drop: ['new', 'question', 'open', 'blocked'],
502
+ claim: ['open', 'claimed', 'closed'],
503
+ release: ['claimed'],
504
+ blocked: ['claimed'],
505
+ fixed: ['open', 'claimed'],
506
+ done: ['claimed'],
507
+ wontfix: ['open', 'claimed'],
508
+ withdraw: ['draft', 'asks'],
509
+ reopen: ['closed', 'claimed'],
510
+ ready: ['draft'],
511
+ }
512
+ const DISPATCHER_OPS = new Set<Op>(['dispatch', 'ask', 'drop', 'reassign'])
513
+ const WORKER_OPS = new Set<Op>(['release', 'blocked', 'fixed', 'done', 'wontfix'])
514
+
515
+ /** null when `who` may do `op` on an item in state `s`, else why not. The dispatcher (null: nobody holds the role)
516
+ * routes, a worker acts on its own claim, the owner answers and may act over a claim. `routed` false: the repo has no
517
+ * dispatcher, so a worker takes a new item as it is */
518
+ export function allowed(s: ItemState, op: Op, who: string, dispatcher: string | null, routed = true): string | null {
519
+ if (op === 'seen') return null
520
+ if (op === 'answer' && who !== 'owner') return 'only the owner answers a question'
521
+ if (DISPATCHER_OPS.has(op)) {
522
+ if (!routed) return 'this repo routes nothing: its review unit declares no dispatcher'
523
+ if (dispatcher === null) return 'nobody holds the dispatcher role: sk review dispatcher --as <name>'
524
+ if (who !== dispatcher) return `only the dispatcher (${dispatcher}) may ${op}`
525
+ }
526
+ if (!routed && s.state === 'new' && (op === 'claim' || op === 'fixed' || op === 'wontfix')) return op === 'claim' && who === 'owner' ? 'a worker claims, never the owner' : null
527
+ if (op === 'claim' && (who === 'owner' || who === dispatcher)) return `a worker claims, never ${who === 'owner' ? 'the owner' : 'the dispatcher'}`
528
+ if (WORKER_OPS.has(op) && who !== 'owner' && s.worker !== undefined && s.worker !== who) return `claimed by ${s.worker}, not by ${who}`
529
+ if (op === 'claim' && s.state === 'claimed' && s.since !== undefined && s.worker !== who) return `already claimed by ${s.worker}: release it first (--as ${s.worker})`
530
+ if (op === 'claim' && s.state === 'closed' && s.why !== 'rejected' && s.why !== 'approved-with-fix')
531
+ return `claim needs open, or a card he answered « fix » or « no », it is closed (${s.why})`
532
+ if (!FROM[op].includes(s.state)) {
533
+ if (op === 'claim' && s.state === 'claimed') return `already claimed by ${s.worker ?? 'an agent'}`
534
+ if (op === 'claim' && s.state === 'new') return 'not dispatched yet: only the dispatcher sends an item to a topic'
535
+ return `${op} needs ${FROM[op].join(' or ')}, it is ${s.state}${s.why ? ` (${s.why})` : ''}`
536
+ }
537
+ return null
538
+ }
539
+
540
+ /** a claim with no message for this long is stale: the dispatcher may take it back, a rework stops showing */
541
+ export const STALE_CLAIM_MS = 2 * 60 * 60 * 1000
542
+
543
+ /** a claim no message touched for STALE_CLAIM_MS */
544
+ export const isStale = (x: ReviewItem, now: Date): boolean =>
545
+ stateOf(x, false).state === 'claimed' && now.getTime() - Date.parse(x.thread.at(-1)?.at ?? x.item.created) > STALE_CLAIM_MS
546
+
547
+ /** the lane working on an item now: it claimed it, no later card redoes it, the claim is not stale; `until` is when it
548
+ * goes stale with no new message. Null when nobody does */
549
+ export function reworkOf(x: ReviewItem, superseded: boolean, now: Date): { lane: string; since: string; until: number } | null {
550
+ const s = stateOf(x, superseded)
551
+ if (s.state !== 'claimed' || s.since === undefined || s.worker === undefined) return null
552
+ const until = Date.parse(x.thread.at(-1)?.at ?? s.since) + STALE_CLAIM_MS
553
+ return now.getTime() > until ? null : { lane: s.worker, since: s.since, until }
554
+ }
555
+
556
+ /** an item the owner opened for marks only: every message a rating (an empty one is a marks item just opened) */
557
+ export const isMarkItem = (x: ReviewItem): boolean => x.item.origin.by === 'owner' && x.item.views.length === 0 && x.thread.every((m) => m.rate !== undefined || m.op === 'seen')
558
+
559
+ /** the lane an item belongs to: the lane that made a card, or the one he raised an item to */
560
+ export const laneOf = (x: ReviewItem): string | undefined => (x.item.origin.by === 'player' ? undefined : x.item.origin.lane)
561
+
562
+ /** what a watch keeps: one session's cards (`--mine`), one lane's items (`--lane`); null keeps all */
563
+ export interface WatchFilter {
564
+ session: string | null
565
+ lane: string | null
566
+ }
567
+
568
+ /** a live `sk review watch`: its process, its filter, when it started */
569
+ export interface Watcher extends WatchFilter {
570
+ pid: number
571
+ started: string
572
+ }
573
+
574
+ /** a watch with this filter reads his answers on `x` */
575
+ export const coversCard = (f: WatchFilter, x: ReviewItem): boolean =>
576
+ (f.session === null || (x.item.origin.by === 'agent' && x.item.origin.session === f.session)) && (f.lane === null || laneOf(x) === f.lane)
577
+
578
+ /** the live watchers that will read his answer on any of these cards */
579
+ export const listenersOf = (cards: readonly ReviewItem[], watchers: readonly Watcher[]): Watcher[] => watchers.filter((w) => cards.some((x) => coversCard(w, x)))
580
+
581
+ /** a card's own session has this long after its last message to claim it before a watch calls it untaken */
582
+ export const UNTAKEN_GRACE_MS = 2 * 60 * 1000
583
+ /** a card nobody takes is told again this often */
584
+ export const UNTAKEN_AGAIN_MS = STALE_CLAIM_MS
585
+
586
+ /** why an item waits on the agents and nobody holds it, else null; `superseded`: a later card follows it */
587
+ export function untakenOf(x: ReviewItem, superseded: boolean, watchers: readonly Watcher[], now: Date, routed = true): string | null {
588
+ if (superseded) return null
589
+ const quiet = now.getTime() - Date.parse(x.thread.at(-1)?.at ?? x.item.created)
590
+ if (quiet < UNTAKEN_GRACE_MS) return null
591
+ const s = stateOf(x, false)
592
+ if (s.state === 'new') return routed ? 'raised, never dispatched' : 'raised, nobody claimed it'
593
+ if (s.state === 'open') return `raised${s.topic ? ` on ${s.topic}` : ''}, nobody claimed it`
594
+ if (s.state !== 'claimed') return null
595
+ if (s.since !== undefined) return quiet > STALE_CLAIM_MS ? `${s.worker ?? 'its'} claim went stale` : null
596
+ return listenersOf([x], watchers).length === 0 ? `the owner's verdict went back to ${s.worker ?? 'its author'}, and no watch reads it` : null
597
+ }
598
+
599
+ export const ID_RE = /^\d{8}-\d{6}-[a-z0-9-]+$/
600
+ export const FILE_RE = /^((v\d+-)?((image|before|after|poster|wave|before-wave)\.(png|jpe?g|webp)|(video|before|after)\.(webm|mp4)|(audio|before|after)\.(wav|mp3|flac|ogg|opus|m4a))|v\d+-g\d+\.(png|jpe?g|webp))$/
601
+ const TILE_RE = /^v\d+-g\d+\.(png|jpe?g|webp)$/
602
+ const POSTER_RE = /^(v\d+-)?poster\.png$/
603
+
604
+ /** a clip by its extension: webm or mp4 */
605
+ export const isClipFile = (name: string): boolean => /\.(webm|mp4)$/i.test(name)
606
+
607
+ /** a sound by its extension */
608
+ export const isAudioFile = (name: string): boolean => /\.(wav|mp3|flac|ogg|opus|m4a)$/i.test(name)
609
+
610
+ const WAVE_RE = /^(v\d+-)?(wave|before-wave)\.png$/
611
+
612
+ export const isPair = (f: ReviewFiles): f is { before: string; after: string } => 'before' in f
613
+
614
+ /** what a card is about, as the list and the CLI name it: its topic, then its subject (once when they say the same) */
615
+ export function aboutText(i: Item, spec: Spec = NO_SPEC): string {
616
+ const subject = subjectLabel(i.subject.kind, spec)
617
+ const topic = i.topic ? topicLabel(i.topic, spec) : subject
618
+ return topic === subject ? subject : `${topic} · ${subject}`
619
+ }
620
+
621
+ /** the lowercase ascii words of a title */
622
+ export const wordsOf = (title: string): string[] =>
623
+ title
624
+ .normalize('NFD')
625
+ .replace(/\p{M}/gu, '')
626
+ .toLowerCase()
627
+ .split(/[^a-z0-9]+/)
628
+ .filter((w) => w !== '')
629
+
630
+ /** the lowercase ascii words of a title, for the id, cut on a word */
631
+ function slugOf(title: string): string {
632
+ let s = ''
633
+ for (const w of wordsOf(title)) {
634
+ if (s && s.length + 1 + w.length > 40) break
635
+ s = s ? `${s}-${w}` : w.slice(0, 40)
636
+ }
637
+ return s || 'request'
638
+ }
639
+
640
+ /** `YYYYMMDD-HHMMSS-<slug>`, local time */
641
+ export const idOf = (at: Date, title: string): string => `${stampOf(at)}-${slugOf(title)}`
642
+
643
+ const obj = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
644
+ const unit = (n: unknown): n is number => typeof n === 'number' && Number.isFinite(n) && n >= 0 && n <= 1
645
+ const EPS = 1e-6
646
+ /** a clip's last frame can read a hair past its length */
647
+ const EPS_T = 0.05
648
+
649
+ function subjectProblems(s: unknown, spec: Spec): string[] {
650
+ if (!obj(s) || typeof s.kind !== 'string' || s.kind === '') return ['subject: { kind }']
651
+ const kind = spec.kinds[s.kind]
652
+ const declared = Object.keys(spec.kinds)
653
+ if (!kind && !BUILTIN_KINDS[s.kind] && declared.length > 0) return [`subject: kind « ${s.kind} » is none of ${[...declared, ...Object.keys(BUILTIN_KINDS)].join(', ')}`]
654
+ if (kind?.problems) return kind.problems(s as Subject).map((p) => `subject: ${p}`)
655
+ if (s.kind !== 'frame') return []
656
+ const str = (k: string): boolean => typeof s[k] === 'string' && s[k] !== ''
657
+ const num = (k: string): boolean => typeof s[k] === 'number' && Number.isFinite(s[k])
658
+ return str('trailer') && str('cut') && str('shot') && num('time') && num('frame') && num('shotTime') ? [] : ['subject: a frame has trailer, cut, time, frame, shot, shotTime']
659
+ }
660
+
661
+ /** what the unit says is wrong with an item that reads well otherwise: its subject's kind and problems, its topic */
662
+ export function specProblems(i: Item, spec: Spec): string[] {
663
+ const out = subjectProblems(i.subject, spec)
664
+ const topics = Object.keys(spec.topics)
665
+ if (i.topic !== undefined && topics.length > 0 && !spec.topics[i.topic]) out.push(`topic: « ${i.topic} » is none of ${topics.join(', ')}`)
666
+ return out
667
+ }
668
+
669
+ function originProblems(o: unknown): string[] {
670
+ if (!obj(o)) return ['origin: { by }']
671
+ const text = (k: string): string[] => (o[k] === undefined || typeof o[k] === 'string' ? [] : [`origin.${k}: a text`])
672
+ if (o.by === 'agent') return [...text('lane'), ...text('session')]
673
+ if (o.by === 'owner') return [...(typeof o.from === 'string' ? [] : ['origin.from: where he raised it']), ...text('lane')]
674
+ if (o.by === 'player')
675
+ return typeof o.player === 'string' && typeof o.server === 'string' && Number.isInteger(o.serverId) && typeof o.client === 'string' && obj(o.context)
676
+ ? []
677
+ : ['origin: a player has player, server, serverId, client, context']
678
+ if (o.by === 'auto') return ['rule', 'key', 'lane'].every((k) => typeof o[k] === 'string' && o[k] !== '') ? [] : ['origin: an auto item has its rule, key and lane']
679
+ return ['origin.by: agent, owner, player or auto']
680
+ }
681
+
682
+ /** every reason an item.json read back is not an item; empty: it is one */
683
+ export function itemProblems(v: unknown): string[] {
684
+ if (!obj(v)) return ['not an object']
685
+ const out: string[] = []
686
+ for (const k of ['id', 'created', 'title']) if (typeof v[k] !== 'string' || v[k] === '') out.push(`${k}: a text`)
687
+ if (typeof v.id === 'string' && !ID_RE.test(v.id)) out.push(`id: ${v.id} is not YYYYMMDD-HHMMSS-slug`)
688
+ out.push(...subjectProblems(v.subject, NO_SPEC), ...originProblems(v.origin))
689
+ if (v.topic !== undefined && typeof v.topic !== 'string') out.push('topic: a text')
690
+ const agent = obj(v.origin) && v.origin.by === 'agent'
691
+ if (!Array.isArray(v.views) || (agent && v.views.length === 0)) out.push(`views: a list${agent ? ', the first a media view' : ''}`)
692
+ else
693
+ v.views.forEach((w: unknown, i: number) => {
694
+ if (i > 0 && (!obj(w) || typeof w.label !== 'string' || w.label.trim() === '')) out.push(`views[${i}].label: a text`)
695
+ if (obj(w) && w.gallery !== undefined) out.push(...(i === 0 ? ['views[0]: never a gallery'] : galleryProblems(w, `views[${i}].`)))
696
+ else out.push(...viewProblems(obj(w) ? w : {}, `views[${i}].`))
697
+ })
698
+ if (v.answers !== undefined && !(Array.isArray(v.answers) && v.answers.length > 0 && v.answers.every((a) => typeof a === 'string' && ID_RE.test(a))))
699
+ out.push('answers: a list of item ids')
700
+ for (const k of ['what', 'commit', 'supersedes', 'why', 'keeps']) if (v[k] !== undefined && typeof v[k] !== 'string') out.push(`${k}: a text`)
701
+ if (v.light !== undefined && v.light !== 'review') out.push('light: review, or absent')
702
+ if (v.wip !== undefined && !(obj(v.wip) && typeof v.wip.live === 'string' && v.wip.live !== '' && Number.isInteger(v.wip.frame))) out.push('wip: { live, frame }, or absent')
703
+ if (v.choice !== undefined && !(v.choice === true && Array.isArray(v.views) && v.views.length >= 2)) out.push('choice: true on a card of two views or more, or absent')
704
+ return out
705
+ }
706
+
707
+ /** every reason a thread file read back is not a message; empty: it is one */
708
+ export function messageProblems(v: unknown, name: string): string[] {
709
+ if (!obj(v)) return ['not an object']
710
+ const out: string[] = []
711
+ if (v.id !== name) out.push(`id: ${String(v.id)} is not its file name ${name}`)
712
+ if (typeof v.at !== 'string' || typeof v.who !== 'string' || v.who === '') out.push('at, who: texts')
713
+ if ('seen' in v) out.push('seen: a message of the old shape, before the one store')
714
+ if (v.op !== undefined && !OPS.includes(v.op as Op)) out.push(`op: one of ${OPS.join(', ')}`)
715
+ if (v.verdict !== undefined && !STATUSES.includes(v.verdict as ReviewStatus)) out.push(`verdict: one of ${STATUSES.join(', ')}`)
716
+ for (const k of ['text', 'image', 'note']) if (v[k] !== undefined && typeof v[k] !== 'string') out.push(`${k}: a text`)
717
+ if (v.pick !== undefined && !(Number.isInteger(v.pick) && (v.pick as number) >= 0 && v.verdict !== undefined)) out.push('pick: a view index, on a verdict')
718
+ if (v.op === 'seen' && !(Array.isArray(v.of) && v.of.every((x) => typeof x === 'string'))) out.push('of: the ids a seen message read')
719
+ if (v.op === undefined && v.verdict === undefined && v.rate === undefined && typeof v.text !== 'string' && !Array.isArray(v.rects))
720
+ out.push('a message says something: text, rects, verdict, rate or op')
721
+ return out
722
+ }
723
+
724
+ /** what is wrong with a gallery view: at least one image tile, each its file, its label and its size, and no media field */
725
+ function galleryProblems(v: Record<string, unknown>, at: string): string[] {
726
+ const g = v.gallery
727
+ if (!Array.isArray(g) || g.length === 0) return [`${at}gallery: a list of at least one { file, label, size }`]
728
+ const out: string[] = []
729
+ g.forEach((t: unknown, k: number) => {
730
+ const ok = obj(t) && typeof t.file === 'string' && TILE_RE.test(t.file) && typeof t.label === 'string' && obj(t.size) && Number(t.size.w) > 0 && Number(t.size.h) > 0
731
+ if (!ok) out.push(`${at}gallery[${k}]: { file: v<n>-g<k>.<png|jpg|webp>, label, size: { w, h } }`)
732
+ })
733
+ for (const k of ['files', 'size', 'clip', 'marks']) if (v[k] !== undefined) out.push(`${at}${k}: none on a gallery view`)
734
+ return out
735
+ }
736
+
737
+ /** what is wrong with one view's files, size and marks, each reason under `at` */
738
+ function viewProblems(v: Record<string, unknown>, at: string): string[] {
739
+ const out: string[] = []
740
+ if (typeof v.label !== 'string') out.push(`${at}label: a text`)
741
+ if (v.note !== undefined && typeof v.note !== 'string') out.push(`${at}note: a text`)
742
+ const f = v.files
743
+ const okImage = (n: unknown): boolean => typeof n === 'string' && FILE_RE.test(n) && !isClipFile(n) && !isAudioFile(n)
744
+ const okClip = (n: unknown): boolean => typeof n === 'string' && FILE_RE.test(n) && isClipFile(n)
745
+ const okAudio = (n: unknown): boolean => typeof n === 'string' && FILE_RE.test(n) && isAudioFile(n)
746
+ const clipFiles = obj(f) && (okClip(f.video) || (okClip(f.before) && okClip(f.after)))
747
+ const audioFiles = obj(f) && (okAudio(f.audio) || (okAudio(f.before) && okAudio(f.after)))
748
+ if (!obj(f) || !(okImage(f.image) || (okImage(f.before) && okImage(f.after)) || clipFiles || audioFiles))
749
+ out.push(`${at}files: { image }, { video }, { audio } or { before, after }, a pair two images, two clips or two sounds`)
750
+ const a = v.audio
751
+ if (audioFiles) {
752
+ const pair = obj(f) && typeof f.before === 'string'
753
+ if (!obj(a) || !(Number(a.duration) > 0) || typeof a.wave !== 'string' || !WAVE_RE.test(a.wave) || (pair && (typeof a.waveBefore !== 'string' || !WAVE_RE.test(a.waveBefore))))
754
+ out.push(`${at}audio: { duration, wave${obj(f) && typeof f.before === 'string' ? ', waveBefore' : ''} } on a sound`)
755
+ if (v.marks !== undefined) out.push(`${at}marks: none on a sound`)
756
+ } else if (a !== undefined) out.push(`${at}audio: only on a sound`)
757
+ const s = v.size
758
+ if (!obj(s) || !(Number(s.w) > 0) || !(Number(s.h) > 0)) out.push(`${at}size: { w, h } in pixels`)
759
+ const c = v.clip
760
+ if (clipFiles) {
761
+ if (!obj(c) || typeof c.poster !== 'string' || !POSTER_RE.test(c.poster) || !(Number(c.duration) > 0) || !(Number(c.fps) > 0)) out.push(`${at}clip: { poster, duration, fps } on a clip`)
762
+ if (v.marks !== undefined) out.push(`${at}marks: none on a clip`)
763
+ } else if (c !== undefined) out.push(`${at}clip: only on a clip`)
764
+ if (v.marks !== undefined) {
765
+ const pair = obj(f) && typeof f.before === 'string'
766
+ const ok =
767
+ Array.isArray(v.marks) &&
768
+ v.marks.every(
769
+ (m: unknown) =>
770
+ obj(m) &&
771
+ Array.isArray(m.tile) &&
772
+ m.tile.length === 2 &&
773
+ m.tile.every(Number.isInteger) &&
774
+ (m.edge === undefined || ['n', 'e', 's', 'w'].includes(m.edge as string)) &&
775
+ unit(m.x) &&
776
+ unit(m.y) &&
777
+ unit(m.r) &&
778
+ (pair ? m.side === 'before' || m.side === 'after' : m.side === undefined),
779
+ )
780
+ if (!ok) out.push(`${at}marks: each { tile: [x, y], edge?, x, y, r } normalized, with a side on a pair`)
781
+ }
782
+ return out
783
+ }
784
+
785
+ /** the verdict the page sends for an item, checked: every rectangle inside its view, a side exactly on a pair */
786
+ export function verdictMessageOf(body: unknown, item: Item): { ok: true; message: Pick<Message, 'verdict' | 'text' | 'rects' | 'reactions' | 'pick'> } | { ok: false; errors: string[] } {
787
+ if (!obj(body)) return { ok: false, errors: ['not an object'] }
788
+ const errors: string[] = []
789
+ if (!STATUSES.includes(body.status as ReviewStatus)) errors.push(`status: one of ${STATUSES.join(', ')}`)
790
+ if (body.comment !== undefined && typeof body.comment !== 'string') errors.push('comment: a text')
791
+ const rects = body.rects ?? []
792
+ if (!Array.isArray(rects)) errors.push('rects: a list')
793
+ const views = item.views
794
+ const out: Rect[] = []
795
+ for (const [i, r] of (Array.isArray(rects) ? rects : []).entries()) {
796
+ if (!obj(r) || !unit(r.x) || !unit(r.y) || !unit(r.w) || !unit(r.h) || r.w === 0 || r.h === 0) {
797
+ errors.push(`rects[${i}]: x, y, w, h between 0 and 1, w and h above 0`)
798
+ continue
799
+ }
800
+ if (r.x + r.w > 1 + EPS || r.y + r.h > 1 + EPS) errors.push(`rects[${i}]: leaves the image`)
801
+ if (r.comment !== undefined && typeof r.comment !== 'string') errors.push(`rects[${i}].comment: a text`)
802
+ const view = r.view === undefined ? 0 : r.view
803
+ const on = typeof view === 'number' && Number.isInteger(view) && (r.view === undefined || view >= 1) ? views[view] : undefined
804
+ if (!on) {
805
+ errors.push(`rects[${i}].view: the index of one of the card's other views (absent on the first)`)
806
+ continue
807
+ }
808
+ if (isGallery(on)) {
809
+ errors.push(`rects[${i}].view: no rectangle on a gallery view, react to its tile instead`)
810
+ continue
811
+ }
812
+ if (on.audio) {
813
+ errors.push(`rects[${i}].view: no rectangle on a sound, react to the view instead`)
814
+ continue
815
+ }
816
+ const pair = isPair(on.files)
817
+ if (pair && r.side !== 'before' && r.side !== 'after') errors.push(`rects[${i}].side: before or after, on a pair`)
818
+ if (!pair && r.side !== undefined) errors.push(`rects[${i}].side: none on a single image`)
819
+ const clip = on.clip
820
+ const t = r.t
821
+ if (clip && (typeof t !== 'number' || !Number.isFinite(t) || t < 0 || t > clip.duration + EPS_T)) errors.push(`rects[${i}].t: the clip time it was drawn at, 0 to ${clip.duration} s`)
822
+ if (!clip && t !== undefined) errors.push(`rects[${i}].t: none on an image`)
823
+ const rect: Rect = { x: r.x, y: r.y, w: r.w, h: r.h, comment: typeof r.comment === 'string' ? r.comment : '' }
824
+ if (view !== 0) rect.view = view as number
825
+ if (pair) rect.side = r.side as Side
826
+ if (clip && typeof t === 'number') rect.t = t
827
+ out.push(rect)
828
+ }
829
+ const reactions = reactionsOf(body.reactions, views, errors, isChoice(item))
830
+ const pick = body.pick
831
+ if (pick !== undefined) {
832
+ if (!isChoice(item)) errors.push('pick: only on a choice card')
833
+ else if (!(typeof pick === 'number' && Number.isInteger(pick) && pick >= 0 && pick < views.length)) errors.push(`pick: the index of one of the card's ${views.length} views`)
834
+ else if (!isApproved(body.status as ReviewStatus)) errors.push('pick: goes with approved or approved-with-fix; rejecting every option is a plain rejected')
835
+ }
836
+ if (errors.length > 0) return { ok: false, errors }
837
+ const comment = typeof body.comment === 'string' ? body.comment : ''
838
+ return {
839
+ ok: true,
840
+ message: {
841
+ verdict: body.status as ReviewStatus,
842
+ ...(comment ? { text: comment } : {}),
843
+ ...(out.length > 0 ? { rects: out } : {}),
844
+ ...(reactions.length > 0 ? { reactions } : {}),
845
+ ...(typeof pick === 'number' ? { pick } : {}),
846
+ },
847
+ }
848
+ }
849
+
850
+ /** the reactions the page sends, checked against the card's views: each on a view, a tile only on a gallery, one per view
851
+ * or tile; an empty one is dropped */
852
+ function reactionsOf(v: unknown, views: readonly ReviewView[], errors: string[], choice: boolean): ReviewReaction[] {
853
+ if (v === undefined) return []
854
+ if (!Array.isArray(v)) {
855
+ errors.push('reactions: a list')
856
+ return []
857
+ }
858
+ const out: ReviewReaction[] = []
859
+ const seen = new Set<string>()
860
+ for (const [i, r] of v.entries()) {
861
+ const on = obj(r) && typeof r.view === 'number' && Number.isInteger(r.view) ? views[r.view] : undefined
862
+ if (!obj(r) || !on) {
863
+ errors.push(`reactions[${i}].view: the index of one of the card's views`)
864
+ continue
865
+ }
866
+ const tiles = isGallery(on) ? on.gallery.length : 0
867
+ const tile = r.tile
868
+ if (tile !== undefined && !(typeof tile === 'number' && Number.isInteger(tile) && tile >= 0 && tile < tiles)) errors.push(`reactions[${i}].tile: the index of one tile of a gallery view`)
869
+ if (r.status !== undefined && !STATUSES.includes(r.status as ReviewStatus)) errors.push(`reactions[${i}].status: one of ${STATUSES.join(', ')}`)
870
+ if (r.comment !== undefined && typeof r.comment !== 'string') errors.push(`reactions[${i}].comment: a text`)
871
+ const stars = r.stars
872
+ if (stars !== undefined && !(choice && typeof stars === 'number' && Number.isInteger(stars) && stars >= 1 && stars <= STARS))
873
+ errors.push(choice ? `reactions[${i}].stars: 1 to ${STARS}` : `reactions[${i}].stars: only on a choice card`)
874
+ const key = `${String(r.view)}/${String(tile ?? '')}`
875
+ if (seen.has(key)) errors.push(`reactions[${i}]: a second reaction on the same view or tile`)
876
+ seen.add(key)
877
+ const comment = typeof r.comment === 'string' ? r.comment.trim() : ''
878
+ if (r.status === undefined && comment === '' && stars === undefined) continue
879
+ out.push({
880
+ view: r.view as number,
881
+ ...(typeof tile === 'number' ? { tile } : {}),
882
+ ...(r.status !== undefined ? { status: r.status as ReviewStatus } : {}),
883
+ comment,
884
+ ...(typeof stars === 'number' ? { stars } : {}),
885
+ })
886
+ }
887
+ return out
888
+ }
889
+
890
+ /** what a reaction or a rectangle is on, as the CLI prints it: `[from above]`, `[trees / oak-v2]` */
891
+ export function placeText(r: { view?: number | undefined; tile?: number | undefined }, views: readonly ReviewView[]): string {
892
+ const i = r.view ?? 0
893
+ const v = views[i]
894
+ const tile = r.tile !== undefined && v && isGallery(v) ? ` / ${v.gallery[r.tile]?.label || `image ${r.tile + 1}`}` : ''
895
+ return `[${viewName(v, i)}${tile}]`
896
+ }