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
@@ -5,7 +5,7 @@
5
5
  // (hash-route.ts), and setSel is the one place both move together. A repo
6
6
  // that is not shown keeps all of it and polls nothing (resume, pause), and
7
7
  // only the shown one writes the url.
8
- import { makeAutoObservable } from 'mobx'
8
+ import { makeAutoObservable, reaction } from 'mobx'
9
9
  import { ClockSvc } from './clock-svc.ts'
10
10
  import { ColsSvc } from './cols-svc.ts'
11
11
  import { canonicalSel, currentHash, hashFor, parseHash, selOf, type CheckOwner, type HashSel } from './hash-route.ts'
@@ -21,8 +21,11 @@ import { GitSt } from './git-st.ts'
21
21
  import type { ProcessRow } from '../../home/process-rows.ts'
22
22
  import { TestsSvc } from './tests-svc.ts'
23
23
  import { AdminSvc } from './admin-svc.ts'
24
+ import { ReviewSt } from './review/review-st.ts'
25
+ import { TrailerSt } from './review/trailer-st.ts'
26
+ import { WikiSt } from './wiki-st.ts'
24
27
  import { repoSections } from './repo-sections.ts'
25
- import { sectionKeyLabel } from './keys.ts'
28
+ import { isTreeWalk, sectionKeyLabel } from './keys.ts'
26
29
  import type { Resource } from './hub-types.ts'
27
30
  import type { HomeSt } from './home-st.ts'
28
31
 
@@ -66,6 +69,12 @@ export class RepoSt {
66
69
  tests: TestsSvc
67
70
  /** the admin page: archive behind its confirmation, reload, keep active */
68
71
  admin: AdminSvc
72
+ /** the review page: the cards of the one store and his answers (review/review-st.ts) */
73
+ review: ReviewSt
74
+ /** the trailer page: a cut played with its timeline and his frame notes (review/trailer-st.ts) */
75
+ trailer: TrailerSt
76
+ /** the wikis page's search and preview */
77
+ wiki: WikiSt
69
78
  sel: Sel = null
70
79
  filter: Filter = 'all'
71
80
  /** sidebar search — live substring filter over asset + collection rows */
@@ -103,7 +112,18 @@ export class RepoSt {
103
112
  this.git = new GitSt(this)
104
113
  this.tests = new TestsSvc(this)
105
114
  this.admin = new AdminSvc(this)
106
- makeAutoObservable<this, 'beat' | 'onceRead'>(this, { onceRead: false, home: false, key: false, base: false, clock: false, media: false, cols: false, tree: false, health: false, readme: false, checks: false, comfy: false, timeline: false, live: false, git: false, tests: false, admin: false, searchEl: false, beat: false })
115
+ this.review = new ReviewSt(this)
116
+ this.trailer = new TrailerSt(this)
117
+ this.wiki = new WikiSt(this)
118
+ makeAutoObservable<this, 'beat' | 'onceRead'>(this, { onceRead: false, home: false, key: false, base: false, clock: false, media: false, cols: false, tree: false, health: false, readme: false, checks: false, comfy: false, timeline: false, live: false, git: false, tests: false, admin: false, review: false, trailer: false, wiki: false, searchEl: false, beat: false })
119
+ // a view or a filter changed on the review page: its address follows, the card unchanged
120
+ reaction(
121
+ () => (this.sel?.t === 'review' ? this.review.address : null),
122
+ (q) => {
123
+ const s = this.sel
124
+ if (q !== null && s?.t === 'review' && (s.q ?? '') !== q) this.setSel({ t: 'review', id: s.id, ...(q ? { q } : {}) })
125
+ },
126
+ )
107
127
  }
108
128
 
109
129
  /** an absolute hub path ('/api/…', '/media/…', '/hub.json') as this
@@ -175,6 +195,8 @@ export class RepoSt {
175
195
  this.media.start()
176
196
  this.cols.start()
177
197
  this.live.start()
198
+ if (this.sel?.t === 'review') this.review.start()
199
+ if (this.sel?.t === 'trailer') this.trailer.start()
178
200
  }
179
201
 
180
202
  /** stop polling, keep every state: a resume shows the same page */
@@ -185,6 +207,8 @@ export class RepoSt {
185
207
  this.media.stop()
186
208
  this.cols.stop()
187
209
  this.live.stop()
210
+ this.review.stop()
211
+ this.trailer.stop()
188
212
  }
189
213
 
190
214
  dispose(): void {
@@ -285,7 +309,12 @@ export class RepoSt {
285
309
  const next = parseHash(h)
286
310
  // the tool set rides after the asset id (media-svc syncHash), so an
287
311
  // address is the same page whatever tools it carries
288
- if (cur.kind === next.kind && cur.col === next.col && cur.id === next.id) return
312
+ if (cur.kind === next.kind && cur.col === next.col && cur.id === next.id) {
313
+ // the trailer's note and filter, the review's view and filters ride on their page, as the tool set does on an
314
+ // asset: no history entry
315
+ if ((next.kind === 'trailer' || next.kind === 'review') && currentHash() !== h) history.replaceState(null, '', `#${h}`)
316
+ return
317
+ }
289
318
  if (this.walking && this.walked) history.replaceState(null, '', `#${h}`)
290
319
  else history.pushState(null, '', `#${h}`)
291
320
  this.walked = this.walking
@@ -295,6 +324,11 @@ export class RepoSt {
295
324
  * lands on the raw wall of text unless it is asked for by name */
296
325
  openResource(r: Resource): void {
297
326
  if (r.type === 'process') return this.showProcesses(r.id.replace(/^process:/, ''))
327
+ if (r.type === 'card') return this.showReview(r.id.replace(/^card:/, ''))
328
+ if (r.type === 'cut') {
329
+ const [trailer = null, cut = null] = r.id.replace(/^cut:/, '').split('/')
330
+ return this.showTrailer(trailer, cut)
331
+ }
298
332
  const assetId = r.id.replace(/^media:/, '')
299
333
  if (r.collection === 'media' && this.media.assets.some((a) => a.id === assetId)) {
300
334
  this.selectAsset(assetId)
@@ -339,6 +373,27 @@ export class RepoSt {
339
373
  showProcesses(id: string | null): void {
340
374
  this.setSel({ t: 'processes', id })
341
375
  }
376
+ /** the review page, `id` the card it opens, null for the first the list shows */
377
+ showReview(id: string | null, q?: string): void {
378
+ this.review.select(id)
379
+ if (q !== undefined) this.review.applyAddress(q)
380
+ const query = this.review.address
381
+ this.setSel({ t: 'review', id, ...(query ? { q: query } : {}) })
382
+ if (this.polling) this.review.start()
383
+ }
384
+ /** a plain key the page shown claims for itself: the review page (V F R A N M, a clip's Space and arrows) and
385
+ * the trailer page (its player keys), as the atelier takes its roll keys. True when the page used it. ↑ ↓ stay
386
+ * the tree's while its cursor walks it, so a walk crosses these sections; a click in the page hands them back */
387
+ pageKey(e: Pick<KeyboardEvent, 'key' | 'shiftKey'>): boolean {
388
+ if (isTreeWalk(e.key) && !e.shiftKey && this.tree.cursor != null) return false
389
+ return (this.sel?.t === 'review' && this.review.key(e)) || (this.sel?.t === 'trailer' && this.trailer.key(e))
390
+ }
391
+ /** the trailer page on a trailer folder and one of its cuts, null for the first */
392
+ showTrailer(trailer: string | null, cut: string | null, note: string | null = null, filter: 'open' | 'all' | null = null): void {
393
+ this.trailer.select(trailer, cut, note, filter)
394
+ this.setSel({ t: 'trailer', trailer, cut, note, filter })
395
+ if (this.polling) this.trailer.start()
396
+ }
342
397
 
343
398
  /** collection view — one card per instance */
344
399
  showCol(id: string): void {
@@ -360,6 +415,8 @@ export class RepoSt {
360
415
  case 'git': return this.showGit()
361
416
  case 'when': return this.showWhen()
362
417
  case 'checks': return this.showChecks(sel.from)
418
+ case 'review': return this.showReview(sel.id, sel.q ?? '')
419
+ case 'trailer': return this.showTrailer(sel.trailer, sel.cut, sel.note ?? null, sel.filter ?? null)
363
420
  }
364
421
  }
365
422
  /** the url decides the page: a reload, back, forward and a pasted link
@@ -0,0 +1,258 @@
1
+ // the one clock of a clip on screen (a card's clip view, a sound, a trailer cut): it plays, pauses, seeks and steps
2
+ // every video of the view at once, so a pair of clips stays in sync. A paused time is always the middle of a frame, so
3
+ // both videos of a pair and every rectangle drawn on it agree on the frame. The sound choice and the volume are the
4
+ // page's, kept in the browser. One thing plays at a time on the whole page (`solo`): any clock or player that starts
5
+ // pauses the one playing
6
+ import { makeAutoObservable, runInAction } from 'mobx'
7
+
8
+ /** the part of a video element the clock drives */
9
+ interface ClipEl {
10
+ currentTime: number
11
+ muted: boolean
12
+ volume: number
13
+ playbackRate: number
14
+ readonly paused: boolean
15
+ play(): Promise<void> | void
16
+ pause(): void
17
+ }
18
+
19
+ /** a follower further than this from the first video, in seconds, is put back on it */
20
+ const DRIFT = 0.06
21
+ const KEY = 'shipkit.review.sound'
22
+
23
+ /** the page's one sound choice and volume, kept for the next visit */
24
+ class Heard {
25
+ on = false
26
+ level = 0.8
27
+ constructor() {
28
+ try {
29
+ const v = JSON.parse(localStorage.getItem(KEY) ?? '{}') as { on?: boolean; level?: number }
30
+ if (typeof v.on === 'boolean') this.on = v.on
31
+ if (typeof v.level === 'number' && v.level > 0 && v.level <= 1) this.level = v.level
32
+ } catch {
33
+ // a blocked storage only loses the kept choice
34
+ }
35
+ makeAutoObservable(this)
36
+ }
37
+ private save(): void {
38
+ try {
39
+ localStorage.setItem(KEY, JSON.stringify({ on: this.on, level: this.level }))
40
+ } catch {
41
+ // as above
42
+ }
43
+ }
44
+ setOn(on: boolean): void {
45
+ this.on = on
46
+ this.save()
47
+ }
48
+ set(level: number): void {
49
+ this.level = Math.max(0, Math.min(1, level))
50
+ this.save()
51
+ }
52
+ }
53
+
54
+ export const heard = new Heard()
55
+
56
+ /** what plays now, page wide: anything that starts pauses it first */
57
+ interface Playing {
58
+ pause(): void
59
+ }
60
+ let playing: Playing | null = null
61
+ /** the clock each media element of the page plays under; an element no clock drives is its own */
62
+ const owners = new WeakMap<object, Playing>()
63
+
64
+ export const solo = {
65
+ /** `p` starts: the one playing before it pauses */
66
+ claim(p: Playing): void {
67
+ const before = playing
68
+ playing = p
69
+ if (before && before !== p) before.pause()
70
+ },
71
+ /** `p` stopped or left the page */
72
+ release(p: Playing): void {
73
+ if (playing === p) playing = null
74
+ },
75
+ }
76
+
77
+ // every audio or video of the page that starts (a sound's own player, the atelier's rolls) claims under its clock, so
78
+ // the rule holds for players no clock drives
79
+ if (typeof document !== 'undefined')
80
+ document.addEventListener(
81
+ 'play',
82
+ (e) => {
83
+ const el = e.target
84
+ if (!(el instanceof HTMLMediaElement)) return
85
+ let me = owners.get(el)
86
+ if (!me) {
87
+ me = { pause: () => el.pause() }
88
+ owners.set(el, me)
89
+ }
90
+ solo.claim(me)
91
+ },
92
+ true,
93
+ )
94
+
95
+ export class ClipSt {
96
+ /** which clip is on screen, empty when none */
97
+ key = ''
98
+ /** the clip's length in seconds, its frames a second */
99
+ duration = 0
100
+ fps = 20
101
+ paused = false
102
+ /** the audio track on: only the first video is heard */
103
+ sound: boolean
104
+ /** the time on screen, in seconds: exact while paused, the first video's while it plays */
105
+ time = 0
106
+ /** a play the browser refused, shown under the clip */
107
+ error: string | null = null
108
+ /** the playback speed, 1 the clip's own */
109
+ rate = 1
110
+ private els: ClipEl[] = []
111
+ private raf = 0
112
+
113
+ constructor() {
114
+ this.sound = heard.on
115
+ makeAutoObservable<this, 'els' | 'raf' | 'follow'>(this, { els: false, raf: false, follow: false })
116
+ }
117
+
118
+ get volume(): number {
119
+ return heard.level
120
+ }
121
+
122
+ /** another clip on screen: from the start, playing unless `still`; the same one again changes nothing */
123
+ load(key: string, duration: number, fps: number, still = false): void {
124
+ if (key === this.key) return
125
+ this.key = key
126
+ this.duration = duration
127
+ this.fps = fps > 0 ? fps : 20
128
+ this.time = 0
129
+ this.paused = still
130
+ this.error = null
131
+ }
132
+
133
+ /** the frame index of a time */
134
+ frameOf(t: number): number {
135
+ const last = Math.max(0, Math.round(this.duration * this.fps) - 1)
136
+ return Math.max(0, Math.min(last, Math.floor(t * this.fps + 1e-6)))
137
+ }
138
+
139
+ /** the middle of frame `k`, where a seek lands on that frame for sure */
140
+ private centerOf(k: number): number {
141
+ return (k + 0.5) / this.fps
142
+ }
143
+
144
+ /** a video of the view on screen; it returns its release */
145
+ attach(el: ClipEl): () => void {
146
+ this.els.push(el)
147
+ owners.set(el, this)
148
+ el.muted = !this.sound || this.els[0] !== el
149
+ el.volume = heard.level
150
+ el.playbackRate = this.rate
151
+ el.currentTime = this.paused ? this.time : (this.els[0]?.currentTime ?? this.time)
152
+ if (this.paused) el.pause()
153
+ else this.start(el)
154
+ if (this.raf === 0 && typeof requestAnimationFrame === 'function') this.raf = requestAnimationFrame(this.follow)
155
+ return () => {
156
+ this.els = this.els.filter((e) => e !== el)
157
+ if (this.els.length === 0) solo.release(this)
158
+ }
159
+ }
160
+
161
+ /** played to be heard: its sound on, from where it stands */
162
+ listen(): void {
163
+ this.hear(true)
164
+ this.play()
165
+ }
166
+
167
+ toggleSound(): void {
168
+ this.hear(!this.sound)
169
+ heard.setOn(this.sound)
170
+ }
171
+
172
+ /** this clip's sound on or off, the kept choice left as it is */
173
+ hear(on: boolean): void {
174
+ this.sound = on
175
+ this.els.forEach((el, i) => (el.muted = !this.sound || i > 0))
176
+ }
177
+
178
+ /** a volume asked is a sound wanted, and none is the sound off */
179
+ setVolume(v: number): void {
180
+ if (v > 0) heard.set(v)
181
+ for (const el of this.els) el.volume = heard.level
182
+ if (this.sound !== v > 0) this.toggleSound()
183
+ }
184
+
185
+ toggle(): void {
186
+ if (this.paused) this.play()
187
+ else this.pause()
188
+ }
189
+
190
+ play(): void {
191
+ this.paused = false
192
+ this.error = null
193
+ solo.claim(this)
194
+ for (const el of this.els) this.start(el)
195
+ }
196
+
197
+ /** stops on the frame the first video shows */
198
+ pause(): void {
199
+ this.seek(this.els[0]?.currentTime ?? this.time)
200
+ }
201
+
202
+ /** paused on the frame at `t` */
203
+ seek(t: number): void {
204
+ this.paused = true
205
+ this.time = this.centerOf(this.frameOf(t))
206
+ for (const el of this.els) {
207
+ el.pause()
208
+ el.currentTime = this.time
209
+ }
210
+ }
211
+
212
+ /** to `t`, playing on when it played: a jump while it plays never stops it */
213
+ jump(t: number): void {
214
+ if (this.paused) return this.seek(t)
215
+ const to = Math.max(0, Math.min(this.duration, t))
216
+ this.time = to
217
+ for (const el of this.els) el.currentTime = to
218
+ }
219
+
220
+ setRate(r: number): void {
221
+ this.rate = r
222
+ for (const el of this.els) el.playbackRate = r
223
+ }
224
+
225
+ /** `n` frames on (or back), paused, wrapping round the loop */
226
+ step(n: number): void {
227
+ const count = Math.max(1, Math.round(this.duration * this.fps))
228
+ const from = this.paused ? this.time : (this.els[0]?.currentTime ?? this.time)
229
+ this.seek(this.centerOf((((this.frameOf(from) + n) % count) + count) % count))
230
+ }
231
+
232
+ private start(el: ClipEl): void {
233
+ solo.claim(this)
234
+ const p = el.play()
235
+ if (p)
236
+ p.catch((e: Error) => {
237
+ // a pause that lands before the play resolves aborts it: that is the pause working
238
+ if (e.name === 'AbortError') return
239
+ runInAction(() => {
240
+ this.paused = true
241
+ this.error = `play refused: ${e.message}`
242
+ })
243
+ })
244
+ }
245
+
246
+ /** each frame while it plays: the time read from the first video, the others put back on it */
247
+ private follow = (): void => {
248
+ this.raf = 0
249
+ if (this.els.length === 0) return
250
+ const lead = this.els[0]
251
+ if (lead && !this.paused) {
252
+ const t = lead.currentTime
253
+ runInAction(() => (this.time = t))
254
+ for (const el of this.els.slice(1)) if (Math.abs(el.currentTime - t) > DRIFT) el.currentTime = t
255
+ }
256
+ this.raf = requestAnimationFrame(this.follow)
257
+ }
258
+ }
@@ -0,0 +1,240 @@
1
+ // what the repo's review unit says of the open item, drawn from DATA the hub computed (review/project.ts viewOfItem): its
2
+ // kind's facts, links, a text or its change as a diff, its parts (a card each: logo, media beside the text, its change),
3
+ // and « live », the kind's url in a frame, off until he opens it; and a pair's animation to download. The page never
4
+ // runs the repo's code
5
+ import { makeAutoObservable, runInAction } from 'mobx'
6
+ import { observer } from 'mobx-react-lite'
7
+ import { useEffect } from 'react'
8
+ import { isGallery, isPair, viewName, type ReviewItem } from '../../../review/model.ts'
9
+ import type { PartData } from '../../../review/project.ts'
10
+ import type { Fact, Link, PartTone, ViewText } from '../../../review/unit.ts'
11
+
12
+ /** where an item's files and routes answer under the repo's base: the review page's own url maker */
13
+ interface Urls {
14
+ fileUrl(id: string, name: string): string
15
+ }
16
+
17
+ export interface KindData {
18
+ facts: Fact[]
19
+ links: Link[]
20
+ live: string | null
21
+ text: ViewText | null
22
+ parts: PartData[]
23
+ }
24
+
25
+ /** each item's data view, read once an item opens and again when its thread moves; « live » stays as he left it */
26
+ export class KindViewSt {
27
+ views = new Map<string, KindData | { error: string }>()
28
+ live = false
29
+ anim = { view: 0, mode: 'fade', format: 'mp4', labels: 'en' }
30
+
31
+ constructor(private readonly st: Urls) {
32
+ try {
33
+ this.live = localStorage.getItem('rv.kindLive') === '1'
34
+ const a = JSON.parse(localStorage.getItem('rv.anim') ?? 'null') as Partial<KindViewSt['anim']> | null
35
+ if (a) this.anim = { ...this.anim, ...a, view: 0 }
36
+ } catch {
37
+ // a private window: the defaults
38
+ }
39
+ makeAutoObservable<this, 'st'>(this, { st: false })
40
+ }
41
+
42
+ /** the view of item `x`, read when its stamp (its last message) moved */
43
+ read(x: ReviewItem): void {
44
+ const key = `${x.item.id}@${x.thread.at(-1)?.id ?? ''}`
45
+ if (this.views.has(key)) return
46
+ this.views.set(key, { facts: [], links: [], live: null, text: null, parts: [] })
47
+ void fetch(this.st.fileUrl(x.item.id, 'view'))
48
+ .then(async (r) => (r.ok ? ((await r.json()) as KindData) : { error: ((await r.json().catch(() => ({}))) as { error?: string }).error ?? `HTTP ${r.status}` }))
49
+ .catch((e: Error) => ({ error: e.message }))
50
+ .then((v) => runInAction(() => this.views.set(key, v)))
51
+ }
52
+
53
+ of(x: ReviewItem): KindData | { error: string } | undefined {
54
+ return this.views.get(`${x.item.id}@${x.thread.at(-1)?.id ?? ''}`)
55
+ }
56
+
57
+ toggleLive(): void {
58
+ this.live = !this.live
59
+ try {
60
+ localStorage.setItem('rv.kindLive', this.live ? '1' : '0')
61
+ } catch {
62
+ // kept for this page only
63
+ }
64
+ }
65
+
66
+ setAnim(k: 'view' | 'mode' | 'format' | 'labels', v: string): void {
67
+ this.anim = { ...this.anim, [k]: k === 'view' ? Number(v) : v }
68
+ try {
69
+ localStorage.setItem('rv.anim', JSON.stringify(this.anim))
70
+ } catch {
71
+ // kept for this page only
72
+ }
73
+ }
74
+
75
+ animUrl(x: ReviewItem, view: number): string {
76
+ const a = this.anim
77
+ return `${this.st.fileUrl(x.item.id, 'anim')}?view=${view}&mode=${a.mode}&format=${a.format}&labels=${a.labels}`
78
+ }
79
+ }
80
+
81
+ const held = new WeakMap<Urls, KindViewSt>()
82
+ const kindViewOf = (st: Urls): KindViewSt => {
83
+ let k = held.get(st)
84
+ if (!k) held.set(st, (k = new KindViewSt(st)))
85
+ return k
86
+ }
87
+
88
+ /** a diff of two token lists: each token kept, gone or new */
89
+ function diffSeq(a: readonly string[], b: readonly string[]): { t: ' ' | '-' | '+'; line: string }[] {
90
+ const n = a.length
91
+ const m = b.length
92
+ const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0))
93
+ for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) lcs[i]![j] = a[i] === b[j] ? lcs[i + 1]![j + 1]! + 1 : Math.max(lcs[i + 1]![j]!, lcs[i]![j + 1]!)
94
+ const out: { t: ' ' | '-' | '+'; line: string }[] = []
95
+ let i = 0
96
+ let j = 0
97
+ while (i < n && j < m) {
98
+ if (a[i] === b[j]) out.push({ t: ' ', line: a[i++]! }), j++
99
+ else if (lcs[i + 1]![j]! >= lcs[i]![j + 1]!) out.push({ t: '-', line: a[i++]! })
100
+ else out.push({ t: '+', line: b[j++]! })
101
+ }
102
+ while (i < n) out.push({ t: '-', line: a[i++]! })
103
+ while (j < m) out.push({ t: '+', line: b[j++]! })
104
+ return out
105
+ }
106
+
107
+ /** a line diff of two texts: each line kept, gone or new */
108
+ const diffLines = (before: string, after: string) => diffSeq(before.split('\n'), after.split('\n'))
109
+
110
+ /** a word diff of two texts, the spaces kept as tokens so each side reads as written */
111
+ const diffWords = (before: string, after: string) => diffSeq(before.split(/(\s+)/), after.split(/(\s+)/))
112
+
113
+ const TONE = { ' ': 'var(--dim)', '-': 'var(--red, #e5484d)', '+': 'var(--green, #46a758)' } as const
114
+
115
+ /** the kind's data view of the open item, under its facts */
116
+ export const KindView = observer(({ app, x }: { app: Urls; x: ReviewItem }) => {
117
+ const k = kindViewOf(app)
118
+ const last = x.thread.at(-1)?.id
119
+ useEffect(() => k.read(x), [k, x, last])
120
+ const v = k.of(x)
121
+ if (v === undefined) return null
122
+ if ('error' in v) return <div className="rv-err rv-pre">the repo's view of this item: {v.error}</div>
123
+ const t = v.text
124
+ return (
125
+ <div className="rv-kindview">
126
+ {v.facts.length > 0 ? (
127
+ <div className="rv-facts">
128
+ {v.facts.map((f, i) => (
129
+ <div key={i} className="rv-fact"><span className="rv-factl">{f.label}</span><span className="rv-factv">{f.value}</span></div>
130
+ ))}
131
+ </div>
132
+ ) : null}
133
+ {v.links.length > 0 ? (
134
+ <div className="rv-fact">
135
+ {v.links.map((l) => <LinkTo key={l.url} l={l} />)}
136
+ </div>
137
+ ) : null}
138
+ {t !== null && 'text' in t ? <pre className="rv-pre rv-kindtext">{t.text}</pre> : null}
139
+ {t !== null && 'before' in t ? (
140
+ <pre className="rv-kindtext">
141
+ {diffLines(t.before, t.after).map((d, i) => (
142
+ <div key={i} style={{ color: TONE[d.t] }}>{d.t} {d.line}</div>
143
+ ))}
144
+ </pre>
145
+ ) : null}
146
+ {v.parts.map((p, i) => <PartCard key={i} app={app} x={x} n={i} p={p} />)}
147
+ {v.live !== null ? (
148
+ <div>
149
+ <button className="ghost" onClick={() => k.toggleLive()}>{k.live ? 'hide live' : 'live'}</button>
150
+ {k.live ? <iframe title="live" src={v.live} style={{ width: '100%', aspectRatio: '16 / 10', border: '1px solid var(--line)', borderRadius: 3, marginTop: 6 }} /> : null}
151
+ </div>
152
+ ) : null}
153
+ </div>
154
+ )
155
+ })
156
+
157
+ /** a link: a page of the tool that frames the hub is posted to it, any other opens in a new tab */
158
+ const LinkTo = ({ l }: { l: Link }) => {
159
+ const framed = l.host === true && window.parent !== window
160
+ return (
161
+ <a
162
+ href={l.url}
163
+ target="_blank"
164
+ rel="noreferrer"
165
+ onClick={(e) => {
166
+ if (!framed) return
167
+ e.preventDefault()
168
+ window.parent.postMessage({ shipkit: 'open', url: l.url }, location.origin)
169
+ }}
170
+ >
171
+ {l.label}
172
+ </a>
173
+ )
174
+ }
175
+
176
+ const BADGE: Record<PartTone, string> = { new: 'var(--green, #46a758)', changed: 'var(--orange, #e5932d)', gone: 'var(--red, #e5484d)', same: 'var(--dim)' }
177
+ const GONE = { background: 'color-mix(in srgb, var(--red, #e5484d) 18%, transparent)', textDecoration: 'line-through' }
178
+ const ADDED = { background: 'color-mix(in srgb, var(--green, #46a758) 20%, transparent)' }
179
+
180
+ /** one side of a word diff: what stayed and what that side alone has, marked */
181
+ const Side = ({ before, after, side }: { before: string; after: string; side: '-' | '+' }) => (
182
+ <div className="rv-pre rv-parttext">
183
+ {diffWords(before, after)
184
+ .filter((d) => d.t === ' ' || d.t === side)
185
+ .map((d, i) => <span key={i} style={d.t === ' ' ? undefined : side === '-' ? GONE : ADDED}>{d.line}</span>)}
186
+ </div>
187
+ )
188
+
189
+ /** a part of the subject as a card: its logo and name, its change, its media beside its text or the text's change */
190
+ const PartCard = ({ app, x, n, p }: { app: Urls; x: ReviewItem; n: number; p: PartData }) => {
191
+ const t = p.text
192
+ const src = app.fileUrl(x.item.id, `part-${n}`)
193
+ return (
194
+ <div className="rv-part">
195
+ <div className="rv-parthead">
196
+ {p.icon ? <img className="rv-particon" src={p.icon} alt="" /> : null}
197
+ <b>{p.label}</b>
198
+ {p.detail ? <span className="rv-dim"> · {p.detail}</span> : null}
199
+ {p.badge ? <span className="rv-partbadge" style={{ color: BADGE[p.badge.tone], borderColor: BADGE[p.badge.tone] }}>{p.badge.text}</span> : null}
200
+ </div>
201
+ <div className="rv-partbody">
202
+ {p.media === 'video' ? <video src={src} muted loop autoPlay playsInline controls /> : p.media === 'image' ? <img src={src} alt="" /> : null}
203
+ {'text' in t ? (
204
+ <div className="rv-pre rv-parttext">{t.text}</div>
205
+ ) : t.before === t.after ? (
206
+ <div className="rv-pre rv-parttext">{t.after}</div>
207
+ ) : (
208
+ <>
209
+ <div><div className="rv-factl">before</div><Side before={t.before} after={t.after} side="-" /></div>
210
+ <div><div className="rv-factl">after</div><Side before={t.before} after={t.after} side="+" /></div>
211
+ </>
212
+ )}
213
+ </div>
214
+ </div>
215
+ )
216
+ }
217
+
218
+ /** a pair of images as a looping animation to download: the mode, the format, the corner labels */
219
+ export const AnimPanel = observer(({ app, x }: { app: Urls; x: ReviewItem }) => {
220
+ const pairs = x.item.views.flatMap((v, i) => (!isGallery(v) && isPair(v.files) && !v.clip && !v.audio ? [{ i, name: viewName(v, i) }] : []))
221
+ if (pairs.length === 0) return null
222
+ const k = kindViewOf(app)
223
+ const a = k.anim
224
+ const view = pairs.some((p) => p.i === a.view) ? a.view : (pairs[0]?.i ?? 0)
225
+ const pick = (key: 'view' | 'mode' | 'format' | 'labels', options: [string, string][], value: string) => (
226
+ <select value={value} onChange={(e) => k.setAnim(key, e.currentTarget.value)}>
227
+ {options.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
228
+ </select>
229
+ )
230
+ return (
231
+ <div className="rv-fact rv-anim" style={{ flexWrap: 'wrap' }}>
232
+ <span className="rv-factl">animation</span>
233
+ {pairs.length > 1 ? pick('view', pairs.map((p) => [String(p.i), p.name]), String(view)) : null}
234
+ {pick('mode', [['cut', 'cut'], ['fade', 'fade'], ['wipe', 'wipe']], a.mode)}
235
+ {pick('format', [['mp4', 'MP4'], ['gif', 'GIF']], a.format)}
236
+ {pick('labels', [['en', 'EN'], ['fr', 'FR'], ['none', 'no labels']], a.labels)}
237
+ <a href={k.animUrl(x, view)} download>download</a>
238
+ </div>
239
+ )
240
+ })