rvlib-shipkit 2.1.1 → 2.3.0

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