rvlib-shipkit 2.2.0 → 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 (107) hide show
  1. package/CHANGELOG.md +38 -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/cli/cmd_Commands.ts +5 -0
  12. package/src/cli/cmd_Review.ts +948 -0
  13. package/src/cli/cmd_Time.ts +112 -0
  14. package/src/cli/cmd_Trailer.ts +259 -6
  15. package/src/cli/kit/verb.ts +14 -4
  16. package/src/cli.ts +6 -4
  17. package/src/collections/collection-types.ts +6 -1
  18. package/src/collections/providers.ts +3 -0
  19. package/src/collections/review.ts +70 -0
  20. package/src/core/layout.ts +2 -0
  21. package/src/reflect/prompt-corpus.ts +15 -119
  22. package/src/reflect/transcript.ts +254 -0
  23. package/src/review/anim.ts +228 -0
  24. package/src/review/auto.ts +141 -0
  25. package/src/review/format.ts +150 -0
  26. package/src/review/http.ts +23 -0
  27. package/src/review/marks.ts +43 -0
  28. package/src/review/media.ts +45 -0
  29. package/src/review/model.ts +854 -0
  30. package/src/review/project.ts +110 -0
  31. package/src/review/role.ts +48 -0
  32. package/src/review/routes.ts +228 -0
  33. package/src/review/store.ts +1002 -0
  34. package/src/review/threads.ts +237 -0
  35. package/src/review/trailer-desk.ts +385 -0
  36. package/src/review/trailer-model.ts +183 -0
  37. package/src/review/trailer-routes.ts +136 -0
  38. package/src/review/unit.ts +165 -0
  39. package/src/review/watchers.ts +54 -0
  40. package/src/serve/embed.ts +35 -0
  41. package/src/serve/hub-app/activity-view.tsx +1 -1
  42. package/src/serve/hub-app/activity.css +0 -4
  43. package/src/serve/hub-app/dock-view.tsx +5 -4
  44. package/src/serve/hub-app/hash-route.ts +28 -1
  45. package/src/serve/hub-app/header-bar.tsx +3 -1
  46. package/src/serve/hub-app/home-st.ts +10 -2
  47. package/src/serve/hub-app/home-strip.tsx +3 -2
  48. package/src/serve/hub-app/home.html +1 -0
  49. package/src/serve/hub-app/hub-app.tsx +80 -11
  50. package/src/serve/hub-app/hub.css +45 -13
  51. package/src/serve/hub-app/icons.tsx +9 -0
  52. package/src/serve/hub-app/index.html +1 -0
  53. package/src/serve/hub-app/insights-view.tsx +4 -3
  54. package/src/serve/hub-app/keys.ts +14 -10
  55. package/src/serve/hub-app/layout-st.ts +63 -2
  56. package/src/serve/hub-app/planned-view.tsx +3 -2
  57. package/src/serve/hub-app/planned.css +0 -1
  58. package/src/serve/hub-app/project-list.css +22 -4
  59. package/src/serve/hub-app/project-list.tsx +41 -8
  60. package/src/serve/hub-app/projects-svc.ts +4 -0
  61. package/src/serve/hub-app/repo-sections.ts +15 -3
  62. package/src/serve/hub-app/repo-st.ts +45 -3
  63. package/src/serve/hub-app/review/clip-st.ts +208 -0
  64. package/src/serve/hub-app/review/kind-view.tsx +171 -0
  65. package/src/serve/hub-app/review/kit.tsx +113 -0
  66. package/src/serve/hub-app/review/review-answer.tsx +239 -0
  67. package/src/serve/hub-app/review/review-board.tsx +46 -0
  68. package/src/serve/hub-app/review/review-list.tsx +156 -0
  69. package/src/serve/hub-app/review/review-page.tsx +101 -0
  70. package/src/serve/hub-app/review/review-st.ts +779 -0
  71. package/src/serve/hub-app/review/review-views.tsx +503 -0
  72. package/src/serve/hub-app/review/trailer-page.tsx +416 -0
  73. package/src/serve/hub-app/review/trailer-st.ts +602 -0
  74. package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
  75. package/src/serve/hub-app/review.css +329 -0
  76. package/src/serve/hub-app/theme-st.ts +56 -0
  77. package/src/serve/hub-app/tree-build.ts +4 -0
  78. package/src/serve/hub-app/tree-row.tsx +1 -1
  79. package/src/serve/hub-app/tree-svc.ts +4 -0
  80. package/src/serve/hub-runtime.ts +5 -3
  81. package/src/serve/same-origin.ts +5 -1
  82. package/src/serve/serve-hub.ts +8 -0
  83. package/src/shipkit-files.ts +9 -5
  84. package/src/trailer/adapter.ts +58 -3
  85. package/src/trailer/build.ts +300 -41
  86. package/src/trailer/ffmpeg.ts +22 -0
  87. package/src/trailer/footage.ts +109 -40
  88. package/src/trailer/grammar.ts +444 -69
  89. package/src/trailer/graph.ts +199 -14
  90. package/src/trailer/holes.ts +72 -0
  91. package/src/trailer/load-unit.ts +20 -3
  92. package/src/trailer/look.ts +17 -3
  93. package/src/trailer/passage.ts +129 -0
  94. package/src/trailer/plan.ts +80 -21
  95. package/src/trailer/prune.ts +193 -0
  96. package/src/trailer/score/compose.ts +240 -0
  97. package/src/trailer/score/dsp.ts +272 -0
  98. package/src/trailer/score/palette.ts +188 -0
  99. package/src/trailer/score/pattern.ts +91 -0
  100. package/src/trailer/score/score.ts +214 -0
  101. package/src/trailer/score/sound.ts +112 -0
  102. package/src/trailer/score/synth.ts +361 -0
  103. package/src/trailer/states.ts +76 -0
  104. package/src/trailer/stitch.ts +92 -9
  105. package/src/trailer/titles.ts +123 -18
  106. package/src/trailer/vocab.ts +23 -4
  107. package/src/trailer/words.ts +254 -0
@@ -0,0 +1,602 @@
1
+ // a repo's trailer page as state (wiki build/trailer.md, Frame notes): a cut's render on a clip clock, its montage as a
2
+ // timeline, the owner's notes on its frames. The render on screen is a kept version: a new render never swaps under
3
+ // him, the page offers it. The desk is read while the page shows, every 3 s
4
+ import { makeAutoObservable, observableRef, reaction, runInAction } from 'mobx'
5
+ import {
6
+ noteTime,
7
+ shotAt,
8
+ type CaptionSpan,
9
+ type CutInfo,
10
+ type NotePin,
11
+ type NoteState,
12
+ type SectionSpan,
13
+ type ShotSpan,
14
+ type Timeline,
15
+ type TrailerDesk,
16
+ type TrailerInfo,
17
+ type TrailerNote,
18
+ } from '../../../review/trailer-model.ts'
19
+ import { reach } from '../reach.ts'
20
+ import type { RepoSt } from '../repo-st.ts'
21
+ import { ClipSt } from './clip-st.ts'
22
+
23
+ const POLL_MS = 3000
24
+
25
+ /** the render on screen: its version and the timeline and strip that belong to it */
26
+ interface Shown {
27
+ trailer: string
28
+ cut: string
29
+ version: number
30
+ duration: number
31
+ fps: number
32
+ timeline: Timeline
33
+ strip: CutInfo['strip']
34
+ }
35
+
36
+ interface NoteDraft {
37
+ time: number
38
+ text: string
39
+ pin: NotePin | null
40
+ }
41
+
42
+ /** a note placed on the cut on screen: `at` its time there, null when its shot is not in it */
43
+ export interface PlacedNote {
44
+ note: TrailerNote
45
+ at: number | null
46
+ /** it was written on this cut and its shot has moved since */
47
+ moved: boolean
48
+ }
49
+
50
+ type NoteFilter = 'open' | 'all'
51
+
52
+ const ZOOMS = [1, 1.5, 2, 3, 4, 6, 8, 12, 16] as const
53
+
54
+ /** the caption's text as the title draws it: `\n` in the file is a line break */
55
+ export const captionText = (c: Pick<CaptionSpan, 'text'>): string => c.text.replaceAll('\\n', '\n')
56
+
57
+ async function send<T>(url: string, method: string, body?: unknown): Promise<T> {
58
+ const res = await reach(url, { method, headers: { 'content-type': 'application/json' }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) })
59
+ const text = await res.text()
60
+ if (!res.ok) {
61
+ let msg = text
62
+ try {
63
+ msg = (JSON.parse(text) as { error?: string }).error ?? text
64
+ } catch {
65
+ // a plain text answer says it already
66
+ }
67
+ throw new Error(msg || `HTTP ${res.status}`)
68
+ }
69
+ return JSON.parse(text) as T
70
+ }
71
+
72
+ export class TrailerSt {
73
+ desk: TrailerDesk | null = null
74
+ deskError: string | null = null
75
+ readonly clock = new ClipSt()
76
+ trailerId: string | null = null
77
+ cutId: string | null = null
78
+ shown: Shown | null = null
79
+ draft: NoteDraft | null = null
80
+ editing: { id: string; text: string } | null = null
81
+ /** the note asked a reply before it closes (done or wontfix) */
82
+ closing: { id: string; state: Exclude<NoteState, 'open'>; reply: string } | null = null
83
+ selected: string | null = null
84
+ filter: NoteFilter = 'all'
85
+ zoom = 1
86
+ saving = false
87
+ error: string | null = null
88
+ /** the montage page of the cut on screen, its markdown, once asked */
89
+ montage: { key: string; md: string | null } | null = null
90
+ montageOpen = false
91
+ private landing: { shot: string; offset: number; play: boolean } | null = null
92
+ private noteLanding: string | null = null
93
+ private timer: ReturnType<typeof setInterval> | null = null
94
+ private readonly prefs: string
95
+
96
+ constructor(private readonly st: RepoSt) {
97
+ this.prefs = `shipkit.trailer.${st.key}`
98
+ try {
99
+ const p = JSON.parse(localStorage.getItem(this.prefs) ?? '{}') as { zoom?: number; filter?: NoteFilter; rate?: number }
100
+ if (p.zoom && ZOOMS.includes(p.zoom as never)) this.zoom = p.zoom
101
+ if (p.filter === 'open' || p.filter === 'all') this.filter = p.filter
102
+ if (p.rate) this.clock.setRate(p.rate)
103
+ } catch {
104
+ // a blocked storage only loses the kept choices
105
+ }
106
+ makeAutoObservable<this, 'st' | 'landing' | 'timer' | 'prefs' | 'noteLanding'>(this, { st: false, clock: false, shown: observableRef, landing: false, timer: false, prefs: false, noteLanding: false })
107
+ // a trailer is heard: the clock starts with its sound on
108
+ this.clock.hear(true)
109
+ // the first answer, or a cut picked: the render on screen follows
110
+ reaction(
111
+ () => [this.cut?.video?.mtime ?? null, this.trailer?.id ?? null, this.cut?.cut ?? null] as const,
112
+ () => {
113
+ if (!this.shown || this.shown.cut !== this.cut?.cut || this.shown.trailer !== this.trailer?.id) this.showCurrent()
114
+ },
115
+ { fireImmediately: true },
116
+ )
117
+ // a note opened or the filter changed on the page: the address follows, on the page that shows it
118
+ reaction(
119
+ () => JSON.stringify(this.address),
120
+ () => {
121
+ if (this.st.sel?.t === 'trailer') this.st.setSel(this.address)
122
+ },
123
+ )
124
+ // the notes arrive after the address that names one
125
+ reaction(
126
+ () => [this.shown?.version ?? null, this.notes.length] as const,
127
+ () => this.landOnNote(),
128
+ )
129
+ reaction(
130
+ () => JSON.stringify({ zoom: this.zoom, filter: this.filter, rate: this.clock.rate }),
131
+ (s) => {
132
+ try {
133
+ localStorage.setItem(this.prefs, s)
134
+ } catch {
135
+ // as above
136
+ }
137
+ },
138
+ )
139
+ }
140
+
141
+ start(): void {
142
+ void this.refresh()
143
+ if (this.timer === null) this.timer = setInterval(() => void this.refresh(), POLL_MS)
144
+ }
145
+
146
+ stop(): void {
147
+ if (this.timer !== null) clearInterval(this.timer)
148
+ this.timer = null
149
+ if (!this.clock.paused) this.clock.pause()
150
+ }
151
+
152
+ async refresh(): Promise<void> {
153
+ try {
154
+ const desk = await send<TrailerDesk>(this.st.url('/api/trailer'), 'GET')
155
+ runInAction(() => {
156
+ this.desk = desk
157
+ this.deskError = null
158
+ })
159
+ } catch (e) {
160
+ runInAction(() => (this.deskError = (e as Error).message))
161
+ }
162
+ }
163
+
164
+ get trailers(): TrailerInfo[] {
165
+ return this.desk?.trailers ?? []
166
+ }
167
+
168
+ get trailer(): TrailerInfo | null {
169
+ return this.trailers.find((t) => t.id === this.trailerId) ?? this.trailers[0] ?? null
170
+ }
171
+
172
+ get cut(): CutInfo | null {
173
+ const cuts = this.trailer?.cuts ?? []
174
+ return cuts.find((c) => c.cut === this.cutId) ?? cuts.find((c) => c.video !== null) ?? cuts[0] ?? null
175
+ }
176
+
177
+ /** a finished render newer than the one on screen */
178
+ get newer(): boolean {
179
+ const v = this.cut?.video
180
+ return !!v && !!this.shown && this.shown.cut === this.cut?.cut && v.mtime !== this.shown.version
181
+ }
182
+
183
+ /** the cut the address names, the note open on it (the player lands on its frame), the notes filter; an address
184
+ * with no filter keeps the one the page remembers */
185
+ select(trailer: string | null, cut: string | null, note: string | null = null, filter: NoteFilter | null = null): void {
186
+ if (cut !== this.cutId || trailer !== this.trailerId) {
187
+ this.draft = null
188
+ this.editing = null
189
+ }
190
+ this.trailerId = trailer
191
+ this.cutId = cut
192
+ if (note !== this.selected) {
193
+ this.selected = note
194
+ this.noteLanding = note
195
+ }
196
+ if (filter !== null) this.filter = filter
197
+ this.landOnNote()
198
+ }
199
+
200
+ /** what the address carries of this page; RepoSt.setSel writes it, the one place an address is written */
201
+ get address(): { t: 'trailer'; trailer: string | null; cut: string | null; note: string | null; filter: NoteFilter | null } {
202
+ return { t: 'trailer', trailer: this.trailerId, cut: this.cutId, note: this.selected, filter: this.filter === 'all' ? null : this.filter }
203
+ }
204
+
205
+ /** the note an address opened: the player lands on its frame once the cut on screen places it */
206
+ private landOnNote(): void {
207
+ const id = this.noteLanding
208
+ if (id === null || !this.shown) return
209
+ const p = this.placed.find((x) => x.note.id === id)
210
+ if (!p) return
211
+ this.noteLanding = null
212
+ if (p.at !== null) this.seekPaused(p.at)
213
+ }
214
+
215
+ /** a trailer or a cut picked on the page: its address follows */
216
+ pick(trailer: string, cut: string | null): void {
217
+ this.st.showTrailer(trailer, cut, trailer === this.trailerId ? this.selected : null, this.filter === 'all' ? null : this.filter)
218
+ }
219
+
220
+ showCurrent(): void {
221
+ const t = this.trailer
222
+ const c = this.cut
223
+ if (!t || !c?.video || !c.timeline) {
224
+ this.shown = null
225
+ return
226
+ }
227
+ this.shown = { trailer: t.id, cut: c.cut, version: c.video.mtime, duration: c.video.duration, fps: c.video.fps, timeline: c.timeline, strip: c.strip }
228
+ this.clock.load(`trailer:${t.id}/${c.cut}/${c.video.mtime}`, c.video.duration, c.video.fps)
229
+ const land = this.landing
230
+ this.landing = null
231
+ const s = land ? c.timeline.shots.find((x) => x.name === land.shot) : undefined
232
+ this.clock.seek(s && land ? s.start + Math.min(land.offset, s.length) : 0)
233
+ if (land?.play) this.clock.play()
234
+ }
235
+
236
+ /** the new render, on the shot and the time into it he was on */
237
+ loadNewer(): void {
238
+ const s = this.shot
239
+ if (s) this.landing = { shot: s.name, offset: this.time - s.start, play: !this.clock.paused }
240
+ this.shown = null
241
+ this.showCurrent()
242
+ }
243
+
244
+ get videoUrl(): string | null {
245
+ const s = this.shown
246
+ return s ? this.st.url(`/api/trailer/video/${s.trailer}/${s.cut}/${s.version}.mp4`) : null
247
+ }
248
+
249
+ get stripUrl(): string | null {
250
+ const s = this.shown
251
+ return s?.strip ? this.st.url(`/api/trailer/strip/${s.trailer}/${s.cut}/${s.version}.png`) : null
252
+ }
253
+
254
+ stillUrl(n: TrailerNote): string {
255
+ return this.st.url(`/api/review/${n.id}/${n.still}`)
256
+ }
257
+
258
+ /** the cut's montage page, read once per cut when he opens it */
259
+ async toggleMontage(): Promise<void> {
260
+ this.montageOpen = !this.montageOpen
261
+ const s = this.shown
262
+ if (!this.montageOpen || !s) return
263
+ const key = `${s.trailer}/${s.cut}/${s.version}`
264
+ if (this.montage?.key === key) return
265
+ try {
266
+ const res = await reach(this.st.url(`/api/trailer/montage/${s.trailer}/${s.cut}`))
267
+ const md = res.ok ? await res.text() : null
268
+ runInAction(() => (this.montage = { key, md }))
269
+ } catch (e) {
270
+ runInAction(() => (this.error = (e as Error).message))
271
+ }
272
+ }
273
+
274
+ // ── where the playhead is ──
275
+
276
+ get timeline(): Timeline | null {
277
+ return this.shown?.timeline ?? null
278
+ }
279
+
280
+ get time(): number {
281
+ return this.clock.time
282
+ }
283
+
284
+ get fps(): number {
285
+ return this.shown?.fps ?? 30
286
+ }
287
+
288
+ get frame(): number {
289
+ return this.clock.frameOf(this.clock.time)
290
+ }
291
+
292
+ get frames(): number {
293
+ return Math.max(1, Math.round((this.shown?.duration ?? 0) * this.fps))
294
+ }
295
+
296
+ get shot(): ShotSpan | null {
297
+ return this.timeline ? shotAt(this.timeline, this.time) : null
298
+ }
299
+
300
+ get shotIndex(): number {
301
+ const s = this.shot
302
+ return s ? (this.timeline?.shots.indexOf(s) ?? -1) : -1
303
+ }
304
+
305
+ get section(): SectionSpan | null {
306
+ return this.timeline?.sections.find((x) => x.start <= this.time + 1e-6 && this.time < x.end) ?? null
307
+ }
308
+
309
+ get captions(): CaptionSpan[] {
310
+ const t = this.time
311
+ return (this.timeline?.shots ?? []).flatMap((s) => s.captions.filter((c) => c.start <= t && t < c.end))
312
+ }
313
+
314
+ /** the captions on screen as one string: it changes when a caption comes or goes, never on every frame */
315
+ get captionsKey(): string {
316
+ return this.captions.map((c) => `${c.start}|${c.text}`).join('\n')
317
+ }
318
+
319
+ // ── the notes ──
320
+
321
+ get notes(): TrailerNote[] {
322
+ return this.trailer?.notes ?? []
323
+ }
324
+
325
+ get openCount(): number {
326
+ return this.notes.filter((n) => n.state === 'open').length
327
+ }
328
+
329
+ openOn(cut: CutInfo): number {
330
+ const tl = cut.timeline
331
+ return tl ? this.notes.filter((n) => n.state === 'open' && noteTime(n, tl) !== null).length : 0
332
+ }
333
+
334
+ /** every note of the trailer, placed on the cut on screen, by its time there; the filter applied */
335
+ get placed(): PlacedNote[] {
336
+ const tl = this.timeline
337
+ return this.notes
338
+ .filter((n) => this.filter === 'all' || n.state === 'open')
339
+ .map((n) => {
340
+ const at = tl ? noteTime(n, tl) : null
341
+ return { note: n, at, moved: at !== null && n.cut === this.shown?.cut && Math.abs(at - n.time) > 0.5 / this.fps }
342
+ })
343
+ .sort((a, b) => (a.at ?? Infinity) - (b.at ?? Infinity) || a.note.n - b.note.n)
344
+ }
345
+
346
+ get here(): PlacedNote[] {
347
+ return this.placed.filter((p) => p.at !== null)
348
+ }
349
+
350
+ get elsewhere(): PlacedNote[] {
351
+ return this.placed.filter((p) => p.at === null)
352
+ }
353
+
354
+ /** the notes whose pin shows over the frame: on this frame, or close while it plays */
355
+ get pinsNow(): PlacedNote[] {
356
+ const near = this.clock.paused ? 0.5 / this.fps + 1e-6 : 0.3
357
+ return this.here.filter((p) => p.note.pin && p.at !== null && Math.abs(p.at - this.time) <= near)
358
+ }
359
+
360
+ setFilter(f: NoteFilter): void {
361
+ this.filter = f
362
+ }
363
+
364
+ // ── moving ──
365
+
366
+ seek(t: number): void {
367
+ this.clock.jump(Math.max(0, Math.min(this.shown?.duration ?? 0, t)))
368
+ }
369
+
370
+ seekPaused(t: number): void {
371
+ this.clock.seek(Math.max(0, Math.min((this.shown?.duration ?? 0) - 0.5 / this.fps, t)))
372
+ }
373
+
374
+ by(dt: number): void {
375
+ this.seek(this.time + dt)
376
+ }
377
+
378
+ step(n: number): void {
379
+ this.clock.step(n)
380
+ }
381
+
382
+ /** the previous shot's start (this one's when past its first half second), or the next one's */
383
+ shotBy(d: 1 | -1): void {
384
+ const shots = this.timeline?.shots ?? []
385
+ const i = this.shotIndex
386
+ const cur = shots[i]
387
+ const target = d > 0 ? shots[i + 1] : cur && this.time - cur.start > 0.5 ? cur : shots[i - 1]
388
+ if (target) this.seek(target.start)
389
+ }
390
+
391
+ noteBy(d: 1 | -1): void {
392
+ const t = this.time
393
+ const eps = 0.5 / this.fps
394
+ const list = this.here
395
+ const next = d > 0 ? list.find((p) => (p.at ?? 0) > t + eps) : [...list].reverse().find((p) => (p.at ?? 0) < t - eps)
396
+ if (next) this.goTo(next)
397
+ }
398
+
399
+ goTo(p: PlacedNote): void {
400
+ this.selected = p.note.id
401
+ if (p.at !== null) this.seekPaused(p.at)
402
+ }
403
+
404
+ /** a note on a shot this cut lacks: the cut it was written on, on its frame */
405
+ openOnItsCut(n: TrailerNote): void {
406
+ this.selected = n.id
407
+ this.landing = { shot: n.shot, offset: n.shotTime, play: false }
408
+ this.pick(n.trailer, n.cut)
409
+ }
410
+
411
+ zoomBy(d: 1 | -1): void {
412
+ const i = ZOOMS.indexOf(this.zoom as never)
413
+ this.zoom = ZOOMS[Math.max(0, Math.min(ZOOMS.length - 1, (i < 0 ? 0 : i) + d))] ?? 1
414
+ }
415
+
416
+ // ── writing ──
417
+
418
+ /** N, or a click on the frame: a note on the frame on screen, paused there */
419
+ newNote(pin: NotePin | null = null): void {
420
+ if (!this.shown) return
421
+ if (!this.clock.paused) this.clock.pause()
422
+ this.editing = null
423
+ this.closing = null
424
+ this.draft = { time: this.clock.time, text: this.draft?.text ?? '', pin: pin ?? this.draft?.pin ?? null }
425
+ }
426
+
427
+ /** a click on the frame: the pin of the note being edited on this frame, else of the draft, else a new pinned note */
428
+ clickFrame(pin: NotePin): void {
429
+ const e = this.editing ? this.here.find((p) => p.note.id === this.editing?.id) : undefined
430
+ if (e && e.at !== null && Math.abs(e.at - this.time) <= 0.5 / this.fps + 1e-6) {
431
+ void this.edit(e.note.id, { pin })
432
+ return
433
+ }
434
+ if (this.draft) {
435
+ this.draft.pin = pin
436
+ if (!this.clock.paused) this.clock.pause()
437
+ this.draft.time = this.clock.time
438
+ return
439
+ }
440
+ this.newNote(pin)
441
+ }
442
+
443
+ setDraftText(t: string): void {
444
+ if (this.draft) this.draft.text = t
445
+ }
446
+
447
+ unpinDraft(): void {
448
+ if (this.draft) this.draft.pin = null
449
+ }
450
+
451
+ cancelDraft(): void {
452
+ this.draft = null
453
+ }
454
+
455
+ async saveDraft(): Promise<void> {
456
+ const d = this.draft
457
+ const s = this.shown
458
+ if (!d || !s || d.text.trim() === '' || this.saving) return
459
+ this.saving = true
460
+ this.error = null
461
+ try {
462
+ const note = await send<TrailerNote>(this.st.url('/api/trailer/notes'), 'POST', {
463
+ trailer: s.trailer,
464
+ cut: s.cut,
465
+ version: s.version,
466
+ time: d.time,
467
+ text: d.text,
468
+ ...(d.pin ? { pin: d.pin } : {}),
469
+ })
470
+ runInAction(() => {
471
+ this.draft = null
472
+ this.selected = note.id
473
+ })
474
+ void this.refresh()
475
+ // the cut's row in the tree says its open notes at once
476
+ void this.st.cols.poll(true)
477
+ } catch (e) {
478
+ runInAction(() => (this.error = (e as Error).message))
479
+ } finally {
480
+ runInAction(() => (this.saving = false))
481
+ }
482
+ }
483
+
484
+ startEdit(n: TrailerNote): void {
485
+ this.draft = null
486
+ this.closing = null
487
+ this.selected = n.id
488
+ this.editing = { id: n.id, text: n.text }
489
+ }
490
+
491
+ setEditText(t: string): void {
492
+ if (this.editing) this.editing.text = t
493
+ }
494
+
495
+ cancelEdit(): void {
496
+ this.editing = null
497
+ }
498
+
499
+ async saveEdit(): Promise<void> {
500
+ const e = this.editing
501
+ if (!e || e.text.trim() === '') return
502
+ await this.edit(e.id, { text: e.text })
503
+ runInAction(() => {
504
+ if (this.editing?.id === e.id) this.editing = null
505
+ })
506
+ }
507
+
508
+ askClose(n: TrailerNote, state: Exclude<NoteState, 'open'>): void {
509
+ this.editing = null
510
+ this.selected = n.id
511
+ this.closing = { id: n.id, state, reply: '' }
512
+ }
513
+
514
+ setReply(t: string): void {
515
+ if (this.closing) this.closing.reply = t
516
+ }
517
+
518
+ cancelClose(): void {
519
+ this.closing = null
520
+ }
521
+
522
+ async confirmClose(): Promise<void> {
523
+ const c = this.closing
524
+ if (!c) return
525
+ await this.edit(c.id, { state: c.state, reply: c.reply })
526
+ runInAction(() => (this.closing = null))
527
+ }
528
+
529
+ async reopen(n: TrailerNote): Promise<void> {
530
+ await this.edit(n.id, { state: 'open' })
531
+ }
532
+
533
+ async unpin(n: TrailerNote): Promise<void> {
534
+ await this.edit(n.id, { pin: null })
535
+ }
536
+
537
+ async edit(id: string, body: { text?: string; pin?: NotePin | null; state?: NoteState; reply?: string }): Promise<void> {
538
+ const t = this.trailer
539
+ if (!t) return
540
+ this.error = null
541
+ try {
542
+ await send<TrailerNote>(this.st.url(`/api/trailer/notes/${t.id}/${id}`), 'POST', body)
543
+ void this.refresh()
544
+ } catch (e) {
545
+ runInAction(() => (this.error = (e as Error).message))
546
+ }
547
+ }
548
+
549
+ /** the render on screen shown in the Finder */
550
+ async reveal(): Promise<void> {
551
+ const s = this.shown
552
+ if (!s) return
553
+ this.error = null
554
+ try {
555
+ await send(this.st.url('/api/trailer/reveal'), 'POST', { trailer: s.trailer, cut: s.cut })
556
+ } catch (e) {
557
+ runInAction(() => (this.error = `Finder: ${(e as Error).message}`))
558
+ }
559
+ }
560
+
561
+ async remove(n: TrailerNote): Promise<void> {
562
+ const t = this.trailer
563
+ if (!t) return
564
+ this.error = null
565
+ try {
566
+ await send(this.st.url(`/api/trailer/notes/${t.id}/${n.id}`), 'DELETE')
567
+ runInAction(() => {
568
+ if (this.selected === n.id) this.selected = null
569
+ if (this.editing?.id === n.id) this.editing = null
570
+ })
571
+ void this.refresh()
572
+ } catch (e) {
573
+ runInAction(() => (this.error = (e as Error).message))
574
+ }
575
+ }
576
+
577
+ /** the page's keys: Space plays or pauses, `,` and `.` a frame, ← → a second (Shift: five), ↑ ↓ a shot (Shift: a
578
+ * note), N a note on this frame, M the sound, - and + the timeline's zoom, Home the start. Arrows and plain keys,
579
+ * so an AZERTY keyboard reaches every one */
580
+ key(e: Pick<KeyboardEvent, 'key' | 'shiftKey'>): boolean {
581
+ const k = e.key.toLowerCase()
582
+ if (e.key === ' ') {
583
+ // a focused button would click again on the key's release
584
+ if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
585
+ this.clock.toggle()
586
+ } else if (e.key === ',') this.step(-1)
587
+ else if (e.key === '.') this.step(1)
588
+ else if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') this.by((e.key === 'ArrowRight' ? 1 : -1) * (e.shiftKey ? 5 : 1))
589
+ else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
590
+ const d = e.key === 'ArrowDown' ? 1 : -1
591
+ if (e.shiftKey) this.noteBy(d)
592
+ else this.shotBy(d)
593
+ } else if (k === 'n') this.newNote()
594
+ else if (k === 'm') this.clock.toggleSound()
595
+ else if (e.key === '-') this.zoomBy(-1)
596
+ else if (e.key === '+' || e.key === '=') this.zoomBy(1)
597
+ else if (e.key === 'Home') this.seek(0)
598
+ else if (e.key === 'Escape' && this.draft) this.cancelDraft()
599
+ else return false
600
+ return true
601
+ }
602
+ }