rvlib-shipkit 2.2.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/package.json +1 -1
  3. package/src/agent-time/mask.ts +43 -0
  4. package/src/agent-time/render.ts +187 -0
  5. package/src/agent-time/report.ts +335 -0
  6. package/src/agent-time/shape.ts +271 -0
  7. package/src/agent-time/signals.ts +84 -0
  8. package/src/agent-time/store.ts +379 -0
  9. package/src/agent-time/timeline.ts +218 -0
  10. package/src/agent-time/turns.ts +135 -0
  11. package/src/assets/kits/identity-kit.ts +5 -2
  12. package/src/cli/cmd_Commands.ts +9 -0
  13. package/src/cli/cmd_Review.ts +974 -0
  14. package/src/cli/cmd_Sound.ts +209 -0
  15. package/src/cli/cmd_Time.ts +112 -0
  16. package/src/cli/cmd_Trailer.ts +264 -6
  17. package/src/cli/kit/verb.ts +22 -4
  18. package/src/cli.ts +8 -4
  19. package/src/collections/collection-types.ts +6 -1
  20. package/src/collections/providers.ts +3 -0
  21. package/src/collections/review.ts +70 -0
  22. package/src/core/app-icon.ts +10 -6
  23. package/src/core/layout.ts +2 -0
  24. package/src/reflect/prompt-corpus.ts +15 -119
  25. package/src/reflect/transcript.ts +254 -0
  26. package/src/repo-config.ts +3 -0
  27. package/src/review/anim.ts +228 -0
  28. package/src/review/auto.ts +141 -0
  29. package/src/review/format.ts +158 -0
  30. package/src/review/http.ts +23 -0
  31. package/src/review/marks.ts +43 -0
  32. package/src/review/media.ts +45 -0
  33. package/src/review/model.ts +896 -0
  34. package/src/review/project.ts +126 -0
  35. package/src/review/role.ts +48 -0
  36. package/src/review/routes.ts +239 -0
  37. package/src/review/store.ts +1011 -0
  38. package/src/review/threads.ts +237 -0
  39. package/src/review/trailer-desk.ts +385 -0
  40. package/src/review/trailer-model.ts +183 -0
  41. package/src/review/trailer-routes.ts +136 -0
  42. package/src/review/unit.ts +183 -0
  43. package/src/review/watchers.ts +54 -0
  44. package/src/serve/embed.ts +35 -0
  45. package/src/serve/hub-app/activity-view.tsx +1 -1
  46. package/src/serve/hub-app/activity.css +0 -4
  47. package/src/serve/hub-app/admin-svc.ts +1 -1
  48. package/src/serve/hub-app/admin-view.tsx +1 -1
  49. package/src/serve/hub-app/dock-view.tsx +5 -4
  50. package/src/serve/hub-app/hash-route.ts +36 -1
  51. package/src/serve/hub-app/header-bar.tsx +3 -1
  52. package/src/serve/hub-app/home-st.ts +17 -3
  53. package/src/serve/hub-app/home-strip.tsx +3 -2
  54. package/src/serve/hub-app/home.html +1 -0
  55. package/src/serve/hub-app/hub-app.tsx +80 -11
  56. package/src/serve/hub-app/hub.css +83 -14
  57. package/src/serve/hub-app/icons.tsx +15 -0
  58. package/src/serve/hub-app/index.html +1 -0
  59. package/src/serve/hub-app/insights-view.tsx +4 -3
  60. package/src/serve/hub-app/keys.ts +14 -10
  61. package/src/serve/hub-app/layout-st.ts +63 -2
  62. package/src/serve/hub-app/md.ts +32 -0
  63. package/src/serve/hub-app/planned-view.tsx +3 -2
  64. package/src/serve/hub-app/planned.css +0 -1
  65. package/src/serve/hub-app/project-list.css +26 -6
  66. package/src/serve/hub-app/project-list.tsx +46 -10
  67. package/src/serve/hub-app/projects-svc.ts +7 -2
  68. package/src/serve/hub-app/repo-sections.ts +15 -3
  69. package/src/serve/hub-app/repo-st.ts +61 -4
  70. package/src/serve/hub-app/review/clip-st.ts +258 -0
  71. package/src/serve/hub-app/review/kind-view.tsx +240 -0
  72. package/src/serve/hub-app/review/kit.tsx +113 -0
  73. package/src/serve/hub-app/review/review-answer.tsx +269 -0
  74. package/src/serve/hub-app/review/review-board.tsx +46 -0
  75. package/src/serve/hub-app/review/review-list.tsx +156 -0
  76. package/src/serve/hub-app/review/review-page.tsx +96 -0
  77. package/src/serve/hub-app/review/review-st.ts +902 -0
  78. package/src/serve/hub-app/review/review-views.tsx +648 -0
  79. package/src/serve/hub-app/review/trailer-page.tsx +416 -0
  80. package/src/serve/hub-app/review/trailer-st.ts +602 -0
  81. package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
  82. package/src/serve/hub-app/review.css +389 -0
  83. package/src/serve/hub-app/theme-st.ts +56 -0
  84. package/src/serve/hub-app/tree-build.ts +4 -0
  85. package/src/serve/hub-app/tree-row.tsx +1 -1
  86. package/src/serve/hub-app/tree-svc.ts +4 -0
  87. package/src/serve/hub-app/wiki-st.ts +190 -0
  88. package/src/serve/hub-app/wikis-view.tsx +134 -5
  89. package/src/serve/hub-runtime.ts +9 -7
  90. package/src/serve/project-gutter-route.ts +25 -1
  91. package/src/serve/project-gutter.ts +1 -1
  92. package/src/serve/same-origin.ts +5 -1
  93. package/src/serve/serve-hub.ts +15 -0
  94. package/src/shipkit-files.ts +9 -5
  95. package/src/shot/client.ts +17 -5
  96. package/src/shot/shooter.ts +19 -3
  97. package/src/shot/shot-ask.ts +9 -1
  98. package/src/shot/stepped-clip.ts +53 -0
  99. package/src/trailer/adapter.ts +90 -6
  100. package/src/trailer/build.ts +459 -59
  101. package/src/trailer/ffmpeg.ts +22 -0
  102. package/src/trailer/fonts.ts +55 -0
  103. package/src/trailer/footage.ts +112 -43
  104. package/src/trailer/grammar.ts +864 -127
  105. package/src/trailer/graph.ts +308 -49
  106. package/src/trailer/holes.ts +72 -0
  107. package/src/trailer/load-unit.ts +20 -3
  108. package/src/trailer/look.ts +20 -3
  109. package/src/trailer/motion.ts +207 -0
  110. package/src/trailer/passage.ts +129 -0
  111. package/src/trailer/plan.ts +82 -23
  112. package/src/trailer/prune.ts +204 -0
  113. package/src/trailer/score/compose.ts +256 -0
  114. package/src/trailer/score/dsp.ts +272 -0
  115. package/src/trailer/score/gm-render.ts +43 -0
  116. package/src/trailer/score/gm.ts +90 -0
  117. package/src/trailer/score/midi.ts +143 -0
  118. package/src/trailer/score/palette.ts +188 -0
  119. package/src/trailer/score/pattern.ts +98 -0
  120. package/src/trailer/score/score.ts +228 -0
  121. package/src/trailer/score/sound.ts +123 -0
  122. package/src/trailer/score/synth.ts +383 -0
  123. package/src/trailer/sound/audio.ts +148 -0
  124. package/src/trailer/sound/brand.ts +42 -0
  125. package/src/trailer/sound/comfy.ts +38 -0
  126. package/src/trailer/sound/eleven.ts +72 -0
  127. package/src/trailer/sound/fluid.ts +71 -0
  128. package/src/trailer/sound/gen.ts +139 -0
  129. package/src/trailer/sound/index.ts +60 -0
  130. package/src/trailer/sound/presets.ts +69 -0
  131. package/src/trailer/sound/run.ts +189 -0
  132. package/src/trailer/states.ts +76 -0
  133. package/src/trailer/stitch.ts +92 -9
  134. package/src/trailer/titles.ts +471 -150
  135. package/src/trailer/url.ts +18 -3
  136. package/src/trailer/vocab.ts +34 -4
  137. package/src/trailer/words.ts +275 -0
  138. package/src/wiki/graph-mount.ts +3 -0
  139. package/src/wiki/graph-page.ts +45 -9
  140. package/src/wiki/graph-pages.ts +128 -0
@@ -0,0 +1,648 @@
1
+ // the review page's middle column: every view of the open card stacked, each under its heading with the owner's reaction and
2
+ // its zoom; a view is an image, a clip, a pair (side by side, the curtain, the onion skin, the after alone) or a
3
+ // gallery of tiles. A drag on an image draws a rectangle, on a clip it pauses and keeps the frame's time. A choice, or
4
+ // views that each play alone, show as thin option rows; the card's header sits on top when the page reads in one column
5
+ import { observer } from 'mobx-react-lite'
6
+ import { useEffect, useRef, useState, type ReactNode } from 'react'
7
+ import { isGallery, isPair, onFrame, STARS, thumbFileOf, verdictOf, viewName, type GalleryView, type MediaView, type Rect, type ReviewClip, type ReviewItem, type ReviewMark, type ReviewView, type Side } from '../../../review/model.ts'
8
+ import { blurOnEscape, Empty, FIT_ROOM, keepFocus, PAIR_NAMES, REACTIONS, secs } from './kit.tsx'
9
+ import { CardHead } from './review-answer.tsx'
10
+ import type { ClipSt } from './clip-st.ts'
11
+ import { CARD_PLACES, COMPARE_MODES, drawnSide, ZOOMS, type CompareMode, type CompareSt, type PairSide, type ReviewSt, type Zoom } from './review-st.ts'
12
+
13
+ const ZOOM_LABEL: Record<Zoom, string> = { fit: 'Fit', '1': '100 %', '1.5': '150 %', '2': '200 %' }
14
+ const toneClass = (s: string | undefined): string => (s ? ` re-${s}` : '')
15
+
16
+ export const ReviewImage = observer(({ rv }: { rv: ReviewSt }) => {
17
+ const x = rv.open
18
+ if (!x) return <Empty text={rv.list ? 'No card. An agent adds one with sk review add.' : 'Reading the cards…'} />
19
+ const r = x.item
20
+ const views = r.views
21
+ const drawable = views.some((v) => !isGallery(v) && !v.clip && !v.audio)
22
+ const rows = rv.rows
23
+ return (
24
+ <div className="rv-views">
25
+ {rv.headInViews ? <CardHead rv={rv} x={x} verdicts={rv.stacked && !rv.phone.on} /> : null}
26
+ <div className="rv-viewsbar">
27
+ {views.length > 1 && !rows ? <JumpList rv={rv} /> : <span />}
28
+ <span className="rv-viewsctl">
29
+ {asOptionsCard(rv) ? (
30
+ <span className="seg" title="the options as thin rows, one below the other, or every view full">
31
+ <button className={rows ? 'on' : ''} onMouseDown={keepFocus} onClick={() => rv.setFull(false)}>rows</button>
32
+ <button className={rows ? '' : 'on'} onMouseDown={keepFocus} onClick={() => rv.setFull(true)}>full</button>
33
+ </span>
34
+ ) : null}
35
+ {rv.phone.on && drawable ? (
36
+ <button className={rv.phone.draw ? 'primary' : ''} onClick={() => rv.phone.toggleDraw()}>Circle</button>
37
+ ) : null}
38
+ {rv.phone.on ? null : (
39
+ <span className="seg" title="the card against its answer: on top of it, left of it, or auto (on top when the page is narrow)">
40
+ {CARD_PLACES.map((p) => <button key={p} className={rv.place === p ? 'on' : ''} onMouseDown={keepFocus} onClick={() => rv.setPlace(p)}>{p}</button>)}
41
+ </span>
42
+ )}
43
+ <span className="seg" title="the zoom every view starts at">
44
+ {ZOOMS.map((z) => <button key={z} className={rv.zoom === z ? 'on' : ''} onMouseDown={keepFocus} onClick={() => rv.setZoom(z)}>{ZOOM_LABEL[z]}</button>)}
45
+ </span>
46
+ </span>
47
+ </div>
48
+ {rv.answered.map((y) => <Raised key={y.item.id} rv={rv} x={y} />)}
49
+ {rows ? (
50
+ <div className="rv-opts">{views.map((v, i) => <OptionRow key={`${r.id}#${i}`} rv={rv} x={x} v={v} i={i} />)}</div>
51
+ ) : (
52
+ views.map((v, i) => <ViewBlock key={`${r.id}#${i}`} rv={rv} id={r.id} v={v} i={i} many={views.length > 1} />)
53
+ )}
54
+ {views.length === 0 ? <TextOnly x={x} /> : null}
55
+ </div>
56
+ )
57
+ })
58
+
59
+ /** the open card may show as rows: a choice, or views that each play alone (the switch shows while it shows full) */
60
+ const asOptionsCard = (rv: ReviewSt): boolean => rv.rows || rv.full[rv.open?.item.id ?? ''] === true
61
+
62
+ /** one option of a choice, or one view of a group, as a thin row: its number, ▶ and the clock, its label, a strip that
63
+ * scrubs it, its stars and « pick » (a choice) or OK, fix, no (a group), ✎ his word, ⤢ the full view under it */
64
+ const OptionRow = observer(({ rv, x, v, i }: { rv: ReviewSt; x: ReviewItem; v: ReviewView; i: number }) => {
65
+ const [word, setWord] = useState(false)
66
+ const id = x.item.id
67
+ const media = isGallery(v) ? null : v
68
+ const playable = media !== null && !isPair(media.files) && (media.clip !== undefined || media.audio !== undefined) ? media : null
69
+ const c = playable ? rv.clipOf(i) : null
70
+ const reaction = rv.reactionOf(i)
71
+ const choice = rv.choice
72
+ const picked = rv.draft.pick === i
73
+ const sent = verdictOf(x)?.pick === i
74
+ const unfolded = rv.openRows[i] === true
75
+ const file = playable ? ('video' in playable.files ? playable.files.video : 'audio' in playable.files ? playable.files.audio : '') : ''
76
+ const duration = playable?.clip?.duration ?? playable?.audio?.duration ?? 0
77
+ const showWord = word || (reaction?.comment ?? '') !== ''
78
+ return (
79
+ <div id={`review-view-${i}`} className={`rv-opt${rv.view === i ? ' active' : ''}${picked ? ' picked' : ''}${toneClass(reaction?.status)}`} onMouseDownCapture={() => rv.view !== i && rv.setView(i)}>
80
+ <div className="rv-optrow">
81
+ <span className="rv-optn">{i + 1}</span>
82
+ {c ? (
83
+ <button className={`rv-optplay${c.paused ? '' : ' on'}`} title={`${c.paused ? 'play' : 'pause'} (${i + 1})`} onMouseDown={keepFocus} onClick={() => rv.listen(i)}>
84
+ {c.paused ? '▶' : '❚❚'}
85
+ </button>
86
+ ) : null}
87
+ <b className="rv-optlabel" title={viewName(v, i)}>{viewName(v, i)}</b>
88
+ {c && playable?.clip && !unfolded ? (
89
+ <span className="rv-optthumb" style={{ aspectRatio: `${playable.size.w} / ${playable.size.h}` }} onClick={() => rv.toggleRow(i)} title="the full view">
90
+ <ClipVideo clip={c} src={rv.fileUrl(id, file)} poster={rv.fileUrl(id, playable.clip.poster)} loop={false} />
91
+ </span>
92
+ ) : null}
93
+ {c && playable ? (
94
+ <Strip clip={c} duration={duration} wave={playable.audio ? rv.fileUrl(id, playable.audio.wave) : undefined} />
95
+ ) : (
96
+ <span className="rv-optthumb img" onClick={() => rv.toggleRow(i)} title="the full view"><img src={rv.thumbUrl(x, thumbFileOf(v))} alt="" draggable={false} /></span>
97
+ )}
98
+ {c ? <span className="rv-mono rv-opttime">{clock(c.time)} / {clock(duration)}</span> : null}
99
+ <span className="rv-optacts">
100
+ {choice ? <Stars rv={rv} view={i} /> : <ReactionToggles rv={rv} view={i} small />}
101
+ <button className={`rv-optword${showWord ? ' on' : ''}`} title="a word on this one" onMouseDown={keepFocus} onClick={() => setWord(!word)}>✎</button>
102
+ {playable?.clip || !playable ? (
103
+ <button className={unfolded ? 'on' : ''} title={unfolded ? 'fold it back to its row' : 'the full view'} onMouseDown={keepFocus} onClick={() => rv.toggleRow(i)}>⤢</button>
104
+ ) : null}
105
+ {playable?.audio ? <a className="rv-dl" href={rv.fileUrl(id, file)} download={`${id}-${file}`} title="download the sound">⤓</a> : null}
106
+ {choice ? (
107
+ <button className={`rv-pick${picked ? ' on' : ''}`} title={`pick this one: it is your answer (P)${sent ? ', sent' : ''}`} disabled={rv.busy} onClick={() => void rv.choose(i)}>
108
+ {picked ? '✓ picked' : 'pick'}
109
+ </button>
110
+ ) : null}
111
+ </span>
112
+ </div>
113
+ {media?.note ? <div className="rv-optnote">{media.note}</div> : null}
114
+ {showWord ? (
115
+ <textarea className="rv-word" rows={1} placeholder="a word on this one" value={reaction?.comment ?? ''} autoFocus={word && !reaction?.comment}
116
+ onKeyDown={blurOnEscape} onChange={(e) => rv.setReactionComment(i, undefined, e.currentTarget.value)} />
117
+ ) : null}
118
+ {c && playable?.audio ? <SoundEl clip={c} src={rv.fileUrl(id, file)} /> : null}
119
+ {unfolded ? (
120
+ <div className="rv-optbody">
121
+ {isGallery(v) ? <Gallery rv={rv} id={id} v={v} view={i} /> : <MediaBody rv={rv} id={id} v={v} i={i} zoom={rv.zoomOf(i)} />}
122
+ </div>
123
+ ) : null}
124
+ </div>
125
+ )
126
+ })
127
+
128
+ /** `1:05`: a time on a row, short */
129
+ const clock = (t: number): string => `${Math.floor(t / 60)}:${String(Math.floor(t % 60)).padStart(2, '0')}`
130
+
131
+ /** an option's score: STARS stars, a click on the one given clears it */
132
+ const Stars = observer(({ rv, view }: { rv: ReviewSt; view: number }) => {
133
+ const n = rv.reactionOf(view)?.stars ?? 0
134
+ return (
135
+ <span className="rv-stars" title={`your score (⇧1 to ⇧${STARS} on the row playing)`}>
136
+ {Array.from({ length: STARS }, (_, k) => (
137
+ <button key={k} className={k < n ? 'on' : ''} aria-label={`${k + 1} of ${STARS}`} onMouseDown={keepFocus} onClick={() => rv.setStars(view, k + 1)}>{k < n ? '★' : '☆'}</button>
138
+ ))}
139
+ </span>
140
+ )
141
+ })
142
+
143
+ /** a thin track a row scrubs: a sound's waveform under what has played; a click or a drag moves the time */
144
+ const Strip = observer(({ clip, duration, wave }: { clip: ClipSt; duration: number; wave: string | undefined }) => {
145
+ const el = useRef<HTMLDivElement>(null)
146
+ const down = (e: React.PointerEvent): void => {
147
+ const b = el.current?.getBoundingClientRect()
148
+ if (!b || duration <= 0) return
149
+ e.preventDefault()
150
+ const at = (px: number): void => clip.jump(Math.max(0, Math.min(1, (px - b.left) / b.width)) * duration)
151
+ at(e.clientX)
152
+ track((m) => at(m.clientX))
153
+ }
154
+ const played = duration > 0 ? Math.min(1, clip.time / duration) : 0
155
+ return (
156
+ <div ref={el} className={`rv-strip${wave ? ' wave' : ''}`} onPointerDown={down}>
157
+ {wave ? (
158
+ <>
159
+ <Wave src={wave} className="rv-stripwave" />
160
+ <Wave src={wave} className="rv-stripwave done" style={{ clipPath: `inset(0 ${100 - played * 100}% 0 0)` }} />
161
+ </>
162
+ ) : (
163
+ <div className="rv-stripdone" style={{ width: `${played * 100}%` }} />
164
+ )}
165
+ <div className="rv-striphead" style={{ left: `${played * 100}%` }} />
166
+ </div>
167
+ )
168
+ })
169
+
170
+ /** a waveform png as a mask the page fills with its own colour, so it reads on either theme (any colour it was drawn in) */
171
+ const Wave = ({ src, className, style }: { src: string; className: string; style?: React.CSSProperties }) => {
172
+ const mask = `url("${src}") center / 100% 100% no-repeat`
173
+ return <div className={className} style={{ ...style, mask, WebkitMask: mask }} />
174
+ }
175
+
176
+ /** a sound row's player, no controls of its own: the row's clock drives it; at its end it waits at the start */
177
+ const SoundEl = ({ clip, src }: { clip: ClipSt; src: string }) => {
178
+ const ref = useRef<HTMLAudioElement>(null)
179
+ useEffect(() => {
180
+ const el = ref.current
181
+ if (!el) return
182
+ return clip.attach(el)
183
+ }, [clip, src])
184
+ return <audio ref={ref} src={src} preload="metadata" onEnded={() => clip.seek(0)} />
185
+ }
186
+
187
+ /** a raised item the open card answers, nested above its views: the owner's words, his still */
188
+ const Raised = observer(({ rv, x }: { rv: ReviewSt; x: ReviewItem }) => {
189
+ const opening = x.thread.find((m) => m.op === undefined && m.text !== undefined)
190
+ const first = x.item.views[0]
191
+ return (
192
+ <div className="rv-raised">
193
+ <div className="rv-dim">answers {x.item.origin.by === 'owner' ? 'your note' : 'a report'} · {x.item.title}</div>
194
+ {opening?.text ? <div className="rv-pre">{opening.text}</div> : null}
195
+ {first && !isGallery(first) && 'image' in first.files ? <img className="rv-raisedimg" src={rv.fileUrl(x.item.id, first.files.image)} alt="" /> : null}
196
+ </div>
197
+ )
198
+ })
199
+
200
+ /** an item with no view: its words, full width */
201
+ const TextOnly = ({ x }: { x: ReviewItem }) => {
202
+ const text = x.item.what ?? x.thread.find((m) => m.text !== undefined)?.text
203
+ return text ? <div className="rv-pre">{text}</div> : <Empty text="This item has no image and no text." />
204
+ }
205
+
206
+ /** the views' labels on top of a card of several: a click scrolls to that view; a dot says the owner's reaction to it */
207
+ const JumpList = observer(({ rv }: { rv: ReviewSt }) => {
208
+ const x = rv.open
209
+ if (!x) return null
210
+ return (
211
+ <span className="rv-jumps">
212
+ {x.item.views.map((v, i) => (
213
+ <button key={i} className={`rv-jump${rv.view === i ? ' on' : ''}${toneClass(rv.reactionOf(i)?.status)}`} onMouseDown={keepFocus}
214
+ onClick={() => {
215
+ rv.setView(i)
216
+ document.getElementById(`review-view-${i}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
217
+ }}>
218
+ {rv.reactionOf(i)?.status ? <span className="rv-jdot" /> : null}
219
+ {i + 1}. {viewName(v, i)}
220
+ </button>
221
+ ))}
222
+ </span>
223
+ )
224
+ })
225
+
226
+ /** one view: its label, its reaction and zoom, its image, pair, clip or gallery, then his word on it */
227
+ const ViewBlock = observer(({ rv, id, v, i, many }: { rv: ReviewSt; id: string; v: ReviewView; i: number; many: boolean }) => {
228
+ const reaction = rv.reactionOf(i)
229
+ const media = isGallery(v) ? null : v
230
+ const zoom = rv.zoomOf(i)
231
+ return (
232
+ <div id={`review-view-${i}`} className={`rv-view${toneClass(reaction?.status)}${rv.view === i && many ? ' active' : ''}`}>
233
+ <div className="rv-viewhead">
234
+ <b className="rv-viewname">{many ? `${i + 1}. ` : ''}{viewName(v, i)}</b>
235
+ <ReactionToggles rv={rv} view={i} />
236
+ <span className="sp" />
237
+ {media?.marks && media.marks.length > 0 ? (
238
+ <button className={rv.marksOn ? 'primary' : ''} onMouseDown={keepFocus} onClick={() => rv.toggleMarks()}>marks · M</button>
239
+ ) : null}
240
+ {media && isPair(media.files) && !media.audio && !rv.phone.on ? <CompareModes c={rv.compare} /> : null}
241
+ {media ? (
242
+ <button title={zoom === 'fit' ? 'see this image at its own size' : 'fit this image to the column'} onMouseDown={keepFocus}
243
+ onClick={() => rv.setViewZoom(i, zoom === 'fit' ? '1' : 'fit')}>{zoom === 'fit' ? '100 %' : 'Fit'}</button>
244
+ ) : null}
245
+ </div>
246
+ {isGallery(v) ? <Gallery rv={rv} id={id} v={v} view={i} /> : <MediaBody rv={rv} id={id} v={v} i={i} zoom={zoom} />}
247
+ <textarea className="rv-word" rows={1} placeholder={isGallery(v) ? 'a word on these images' : 'a word on this image'} value={reaction?.comment ?? ''}
248
+ onKeyDown={blurOnEscape} onChange={(e) => rv.setReactionComment(i, undefined, e.currentTarget.value)} />
249
+ </div>
250
+ )
251
+ })
252
+
253
+ /** the owner's reaction to one view or tile: OK, fix or no; a click on the chosen one clears it */
254
+ const ReactionToggles = observer(({ rv, view, tile, small }: { rv: ReviewSt; view: number; tile?: number; small?: boolean }) => {
255
+ const cur = rv.reactionOf(view, tile)?.status
256
+ return (
257
+ <span className="rv-reacts">
258
+ {REACTIONS.map((o) => (
259
+ <button key={o.status} title={o.tip} className={`rv-react re-${o.status}${cur === o.status ? ' on' : ''}${cur && cur !== o.status ? ' off' : ''}`}
260
+ onMouseDown={keepFocus} onClick={() => rv.react(view, tile, o.status)}>
261
+ {o.glyph}{small ? null : ` ${o.label}`}
262
+ </button>
263
+ ))}
264
+ </span>
265
+ )
266
+ })
267
+
268
+ /** a gallery tile's image height */
269
+ const TILE_H = 170
270
+
271
+ /** a gallery view: its images as a wrapping grid of small tiles, each with its label and its own reaction; a click
272
+ * shows it large */
273
+ const Gallery = observer(({ rv, id, v, view }: { rv: ReviewSt; id: string; v: GalleryView; view: number }) => (
274
+ <div className="rv-gallery">
275
+ {v.gallery.map((t, k) => {
276
+ const big = rv.bigTile === `${view}/${k}`
277
+ const r = rv.reactionOf(view, k)
278
+ const ratio = t.size.w / t.size.h
279
+ const w = big ? undefined : Math.round(Math.max(110, Math.min(260, TILE_H * ratio)))
280
+ return (
281
+ <div key={k} id={`review-tile-${view}-${k}`} className={`rv-tile${big ? ' big' : ''}${toneClass(r?.status)}`} style={big ? undefined : { width: w }}>
282
+ <div className="rv-tileimg" onClick={() => rv.toggleTile(view, k)} title={big ? 'click to shrink' : 'click to enlarge'}
283
+ style={big ? { width: `min(100%, calc((100vh - ${FIT_ROOM}px) * ${ratio}))` } : { height: TILE_H }}>
284
+ <img src={rv.fileUrl(id, t.file)} alt={t.label} draggable={false} />
285
+ </div>
286
+ <div className="rv-tilelabel" title={t.label}>{t.label}</div>
287
+ <ReactionToggles rv={rv} view={view} tile={k} small />
288
+ <textarea className="rv-word" rows={1} placeholder="a word" value={r?.comment ?? ''} onKeyDown={blurOnEscape}
289
+ onChange={(e) => rv.setReactionComment(view, k, e.currentTarget.value)} />
290
+ </div>
291
+ )
292
+ })}
293
+ </div>
294
+ ))
295
+
296
+ /** one media view: a single image or clip, or the pair in the compare component; fitted to the column on its own, or
297
+ * zoomed with its own scroll */
298
+ const MediaBody = observer(({ rv, id, v, i, zoom }: { rv: ReviewSt; id: string; v: MediaView; i: number; zoom: Zoom }) => {
299
+ if (v.audio) return <SoundBody rv={rv} id={id} v={v} />
300
+ const f = v.files
301
+ const ratio = v.size.w / v.size.h
302
+ const aspect = `${v.size.w} / ${v.size.h}`
303
+ const scale = zoom === 'fit' ? null : Number(zoom)
304
+ const room = FIT_ROOM + (v.clip ? 40 : 0)
305
+ const bar = v.clip ? <ClipBar clip={rv.clipOf(i)} info={v.clip} use={() => rv.setView(i)} /> : null
306
+ const scroll = scale ? { overflow: 'auto', maxHeight: `calc(100vh - ${room}px)` } : undefined
307
+ if (!isPair(f)) {
308
+ const name = 'image' in f ? f.image : 'video' in f ? f.video : f.audio
309
+ return (
310
+ <div style={scroll}>
311
+ <div style={{ width: scale ? `${v.size.w * scale}px` : `min(100%, calc((100vh - ${room}px) * ${ratio}))`, margin: '0 auto' }}>
312
+ <Compare c={rv.compare} base={rv.fileUrl(id, name)} mod={undefined} aspect={aspect} draw={(s) => <Marks rv={rv} src={s} side={undefined} view={i} />} />
313
+ {bar}
314
+ <Downloads rv={rv} id={id} files={[{ file: name }]} />
315
+ </div>
316
+ </div>
317
+ )
318
+ }
319
+ const before = rv.fileUrl(id, f.before)
320
+ const after = rv.fileUrl(id, f.after)
321
+ const saves = <Downloads rv={rv} id={id} files={[{ file: f.before, label: 'before' }, { file: f.after, label: 'after' }]} />
322
+ if (rv.phone.on)
323
+ return (
324
+ <>
325
+ <PhonePair rv={rv} view={i} bar={bar} before={before} after={after} aspect={aspect} size={v.size} scale={scale} />
326
+ {saves}
327
+ </>
328
+ )
329
+ const c = rv.compare
330
+ const cols = c.mode === 'side' ? 2 : 1
331
+ const fitW = `min(100%, calc((100vh - ${room}px) * ${ratio * cols}))`
332
+ return (
333
+ <>
334
+ <div style={scroll}>
335
+ <div className="rv-pairgrid" style={{ width: scale ? `${v.size.w * scale * cols + 8 * (cols - 1)}px` : fitW, gridTemplateColumns: `repeat(${cols}, 1fr)` }}>
336
+ <Compare c={c} base={before} mod={after} names={PAIR_NAMES} aspect={aspect}
337
+ draw={(s) => <Marks rv={rv} src={s} side={drawnSide(c.mode, s === before ? 'before' : 'after')} view={i} />} />
338
+ </div>
339
+ </div>
340
+ {bar ? <div style={{ width: fitW, margin: '0 auto' }}>{bar}</div> : null}
341
+ <div style={{ width: fitW, margin: '0 auto' }}>{saves}</div>
342
+ {c.mode === 'swipe' ? <div className="rv-dim rv-center">curtain: a drag moves the line; rectangles are drawn in the other modes</div> : null}
343
+ </>
344
+ )
345
+ })
346
+
347
+ /** a sound view: its waveform over the browser's player, one row a side on a pair, each to download */
348
+ const SoundBody = ({ rv, id, v }: { rv: ReviewSt; id: string; v: MediaView }) => {
349
+ const f = v.files
350
+ const a = v.audio
351
+ if (!a) return null
352
+ const rows = isPair(f) ? [{ label: 'before', file: f.before, wave: a.waveBefore ?? a.wave }, { label: 'after', file: f.after, wave: a.wave }] : [{ label: '', file: 'audio' in f ? f.audio : '', wave: a.wave }]
353
+ return (
354
+ <div className="rv-sound">
355
+ {rows.map((r) => (
356
+ <div key={r.file} className="rv-soundrow">
357
+ {r.label ? <div className="rv-dim">{r.label}</div> : null}
358
+ <div role="img" aria-label={`the waveform of ${r.file}`} className="rv-wavebox" style={{ aspectRatio: `${v.size.w} / ${v.size.h}` }}>
359
+ <Wave src={rv.fileUrl(id, r.wave)} className="rv-stripwave full" />
360
+ </div>
361
+ <audio controls preload="metadata" src={rv.fileUrl(id, r.file)} style={{ width: '100%' }} />
362
+ </div>
363
+ ))}
364
+ <div className="rv-dim">{secs(a.duration)}</div>
365
+ <Downloads rv={rv} id={id} files={rows.map((r) => ({ file: r.file, ...(r.label ? { label: r.label } : {}) }))} />
366
+ </div>
367
+ )
368
+ }
369
+
370
+ /** a view's files to save, each under the card's id and its own name */
371
+ const Downloads = observer(({ rv, id, files }: { rv: ReviewSt; id: string; files: readonly { file: string; label?: string }[] }) => (
372
+ <div className="rv-downloads">
373
+ {/* the Finder is on the desktop only: a phone has none */}
374
+ {rv.phone.on ? null : <button className="rv-dl ghost" title="show the card's folder in the Finder" onClick={() => void rv.reveal(id)}>📁</button>}
375
+ {files.map(({ file, label }) => (
376
+ <span key={file}>
377
+ <a className="rv-dl" href={rv.fileUrl(id, file)} download={`${id}-${file}`} title="download the file">⤓ {label ?? 'download'}</a>
378
+ {rv.phone.on ? null : <button className="rv-dl ghost" title={`show ${file} in the Finder`} onClick={() => void rv.reveal(id, file)}>📂</button>}
379
+ </span>
380
+ ))}
381
+ </div>
382
+ ))
383
+
384
+ const PAIR_SIDES: { value: PairSide; label: string }[] = [
385
+ { value: 'before', label: 'before' },
386
+ { value: 'after', label: 'after' },
387
+ { value: 'swipe', label: 'curtain' },
388
+ ]
389
+
390
+ /** a pair on a phone: one side at a time full width, or the curtain; the zoom scrolls */
391
+ const PhonePair = observer(({ rv, view, bar, before, after, aspect, size, scale }: {
392
+ rv: ReviewSt
393
+ view: number
394
+ bar: ReactNode
395
+ before: string
396
+ after: string
397
+ aspect: string
398
+ size: { w: number; h: number }
399
+ scale: number | null
400
+ }) => {
401
+ const ph = rv.phone
402
+ const c = rv.compare
403
+ const side = ph.pair === 'swipe' && c.mode !== 'swipe' ? 'after' : ph.pair
404
+ const pick = (s: PairSide): void => {
405
+ if (s === 'swipe') c.setMode('swipe')
406
+ ph.setPair(s)
407
+ }
408
+ const one = side === 'before' ? before : after
409
+ return (
410
+ <div className="rv-phonepair">
411
+ <span className="seg">{PAIR_SIDES.map((s) => <button key={s.value} className={side === s.value ? 'on' : ''} onClick={() => pick(s.value)}>{s.label}</button>)}</span>
412
+ <div style={scale ? { overflow: 'auto', maxHeight: '70vh' } : undefined}>
413
+ <div style={{ width: scale ? `${size.w * scale}px` : '100%' }}>
414
+ {side === 'swipe' ? (
415
+ <Compare c={c} base={before} mod={after} names={PAIR_NAMES} aspect={aspect} draw={(s) => <Marks rv={rv} src={s} side={s === before ? 'before' : 'after'} view={view} />} />
416
+ ) : (
417
+ <Compare key={side} c={c} base={one} mod={undefined} aspect={aspect} draw={(s) => <Marks rv={rv} src={s} side={side} view={view} />} />
418
+ )}
419
+ {bar}
420
+ </div>
421
+ </div>
422
+ </div>
423
+ )
424
+ })
425
+
426
+ // ── the pair ──
427
+
428
+ const MODES: { mode: CompareMode; glyph: string; tip: string }[] = [
429
+ { mode: 'side', glyph: '◫', tip: 'side by side' },
430
+ { mode: 'swipe', glyph: '⇹', tip: 'curtain: drag the line, the before on the left, the after on the right' },
431
+ { mode: 'onion', glyph: '◐', tip: 'onion skin: the after over the before' },
432
+ { mode: 'mod', glyph: '▣', tip: 'the after alone: hovering the image shows the before' },
433
+ ]
434
+
435
+ /** the compare modes as tiny buttons, the onion's opacity floating under them */
436
+ const CompareModes = observer(({ c }: { c: CompareSt }) => (
437
+ <span className="rv-modes">
438
+ <span className="seg">
439
+ {COMPARE_MODES.map((m) => {
440
+ const o = MODES.find((x) => x.mode === m)!
441
+ return <button key={m} title={o.tip} className={c.mode === m ? 'on' : ''} onMouseDown={keepFocus} onClick={() => c.setMode(m)}>{o.glyph}</button>
442
+ })}
443
+ </span>
444
+ {c.mode === 'onion' ? (
445
+ <input className="rv-onion" type="range" min={0} max={1} step={0.05} value={c.opacity} title={`after ${Math.round(c.opacity * 100)} %`} onChange={(e) => c.setOpacity(Number(e.currentTarget.value))} />
446
+ ) : null}
447
+ </span>
448
+ ))
449
+
450
+ /** follows the pointer until it is released */
451
+ function track(move: (e: PointerEvent) => void): void {
452
+ const up = (): void => {
453
+ window.removeEventListener('pointermove', move)
454
+ window.removeEventListener('pointerup', up)
455
+ }
456
+ window.addEventListener('pointermove', move)
457
+ window.addEventListener('pointerup', up)
458
+ }
459
+
460
+ /** THE pair: side by side, the curtain clipping both images, the after over the before as an onion skin, or the
461
+ * after alone (the before while the pointer is over it). One image alone when `mod` is undefined */
462
+ const Compare = observer(({ c, base, mod, names, aspect, draw }: { c: CompareSt; base: string; mod: string | undefined; names?: { base: string; mod: string }; aspect: string; draw: (src: string) => ReactNode }) => {
463
+ const [peek, setPeek] = useState(false)
464
+ if (mod === undefined) return <Cell aspect={aspect} caption={null}><div className="rv-fill">{draw(base)}</div></Cell>
465
+ const n = names ?? PAIR_NAMES
466
+ if (c.mode === 'side')
467
+ return (
468
+ <>
469
+ <Cell aspect={aspect} caption={n.base}><div className="rv-fill">{draw(base)}</div></Cell>
470
+ <Cell aspect={aspect} caption={n.mod} modCap><div className="rv-fill">{draw(mod)}</div></Cell>
471
+ </>
472
+ )
473
+ if (c.mode === 'mod')
474
+ return (
475
+ <Cell aspect={aspect} caption={peek ? n.base : n.mod} modCap={!peek} onPeek={setPeek}>
476
+ <div className="rv-fill">{draw(peek ? base : mod)}</div>
477
+ </Cell>
478
+ )
479
+ const pct = Math.round(c.split * 100)
480
+ return (
481
+ <Cell aspect={aspect} caption={<>{n.base} {c.mode === 'swipe' ? '◀ ▶' : '+'} <span className="rv-modcap">{n.mod}</span></>} swipe={c.mode === 'swipe' ? c : undefined}>
482
+ <div className="rv-fill" style={c.mode === 'swipe' ? { clipPath: `inset(0 ${100 - pct}% 0 0)` } : undefined}>{draw(base)}</div>
483
+ <div className="rv-fill" style={c.mode === 'swipe' ? { clipPath: `inset(0 0 0 ${pct}%)` } : { opacity: c.opacity }}>{draw(mod)}</div>
484
+ {c.mode === 'swipe' ? <div className="rv-curtain" style={{ left: `${pct}%` }} /> : null}
485
+ </Cell>
486
+ )
487
+ })
488
+
489
+ /** one frame and its caption; the curtain's line follows the pointer */
490
+ const Cell = ({ aspect, caption, modCap, swipe, onPeek, children }: { aspect: string; caption: ReactNode; modCap?: boolean; swipe?: CompareSt | undefined; onPeek?: (on: boolean) => void; children: ReactNode }) => {
491
+ const frame = useRef<HTMLDivElement>(null)
492
+ const down = (e: React.PointerEvent): void => {
493
+ const el = frame.current
494
+ if (!swipe || !el) return
495
+ e.preventDefault()
496
+ const at = (x: number): void => {
497
+ const r = el.getBoundingClientRect()
498
+ swipe.setSplit((x - r.left) / r.width)
499
+ }
500
+ at(e.clientX)
501
+ track((m) => at(m.clientX))
502
+ }
503
+ return (
504
+ <div className="rv-cell">
505
+ <div ref={frame} className={`rv-frame${swipe ? ' swipe' : ''}`} style={{ aspectRatio: aspect }} onPointerDown={down}
506
+ onMouseEnter={() => onPeek?.(true)} onMouseLeave={() => onPeek?.(false)}>
507
+ {children}
508
+ </div>
509
+ {caption != null ? <div className={`rv-caption${modCap ? ' mod' : ''}`}>{caption}</div> : null}
510
+ </div>
511
+ )
512
+ }
513
+
514
+ // ── drawing ──
515
+
516
+ const box = (a: { x0: number; y0: number; x1: number; y1: number }) => ({ x: Math.min(a.x0, a.x1), y: Math.min(a.y0, a.y1), w: Math.abs(a.x1 - a.x0), h: Math.abs(a.y1 - a.y0) })
517
+ const pct = (r: { x: number; y: number; w: number; h: number }) => ({ left: `${r.x * 100}%`, top: `${r.y * 100}%`, width: `${r.w * 100}%`, height: `${r.h * 100}%` })
518
+ const clamp01 = (v: number): number => Math.max(0, Math.min(1, v))
519
+ /** a drag shorter than this, on either axis, is a click: it picks nothing */
520
+ const MIN_DRAG = 0.01
521
+
522
+ /** one image of view `view` and its rectangles; a drag on it draws one (never in the curtain, whose drag moves the
523
+ * line). A clip view that is not the active one loops on its own: a click makes it active */
524
+ const Marks = observer(({ rv, src, side, view }: { rv: ReviewSt; src: string; side: Side | undefined; view: number }) => {
525
+ const el = useRef<HTMLDivElement>(null)
526
+ const [drag, setDrag] = useState<{ x0: number; y0: number; x1: number; y1: number } | null>(null)
527
+ const phone = rv.phone
528
+ const any = rv.open?.item.views[view]
529
+ const shown = any && !isGallery(any) ? any : undefined
530
+ const clip = shown?.clip
531
+ const active = rv.view === view
532
+ const drawing = phone.on ? phone.draw && (side === undefined || phone.pair !== 'swipe' || rv.compare.mode !== 'swipe') : side === undefined || rv.compare.mode !== 'swipe'
533
+ const c = rv.clipOf(view)
534
+ const at = (e: { clientX: number; clientY: number }): { x: number; y: number } => {
535
+ const b = el.current?.getBoundingClientRect()
536
+ if (!b) return { x: 0, y: 0 }
537
+ return { x: clamp01((e.clientX - b.left) / b.width), y: clamp01((e.clientY - b.top) / b.height) }
538
+ }
539
+ const down = (e: React.PointerEvent): void => {
540
+ if (e.button !== 0) return
541
+ if (clip && !active) {
542
+ e.preventDefault()
543
+ rv.setView(view)
544
+ return
545
+ }
546
+ if (!drawing) return
547
+ e.preventDefault()
548
+ e.stopPropagation()
549
+ // he draws on the paused frame: the rectangle keeps its time
550
+ if (clip && !c.paused) c.pause()
551
+ const a = at(e)
552
+ let cur = { x0: a.x, y0: a.y, x1: a.x, y1: a.y }
553
+ setDrag(cur)
554
+ const move = (m: PointerEvent): void => {
555
+ const b = at(m)
556
+ cur = { ...cur, x1: b.x, y1: b.y }
557
+ setDrag(cur)
558
+ }
559
+ const up = (): void => {
560
+ window.removeEventListener('pointermove', move)
561
+ window.removeEventListener('pointerup', up)
562
+ window.removeEventListener('pointercancel', up)
563
+ setDrag(null)
564
+ const r = box(cur)
565
+ if (r.w > MIN_DRAG && r.h > MIN_DRAG) rv.addRect(r, side, view)
566
+ else rv.pickRect(null)
567
+ }
568
+ window.addEventListener('pointermove', move)
569
+ window.addEventListener('pointerup', up)
570
+ window.addEventListener('pointercancel', up)
571
+ }
572
+ const rects = rv.draft.rects.map((r, i) => [r, i] as const).filter(([r]) => r.side === side && (r.view ?? 0) === view)
573
+ const onIts = (r: Rect): boolean => !clip || r.t === undefined || (c.paused && onFrame(r.t, c.time, clip.fps))
574
+ const rings = rv.marksOn ? (shown?.marks ?? []).filter((m) => m.side === side) : []
575
+ const live = drawing || (clip !== undefined && !active)
576
+ return (
577
+ <div ref={el} className={`rv-marks${clip && !active ? ' pick' : drawing ? ' draw' : ''}`} onPointerDown={down} style={{ pointerEvents: live ? 'auto' : 'none', touchAction: drawing ? 'none' : undefined }}>
578
+ {clip ? <ClipVideo key={src} clip={c} src={src} /> : <img src={src} alt="" draggable={false} className="rv-markimg" />}
579
+ {shown && rings.length > 0 ? <Rings marks={rings} w={shown.size.w} h={shown.size.h} /> : null}
580
+ {rects.map(([r, i]) => (
581
+ <RectMark key={i} r={r} n={i + 1} on={rv.rect === i} faint={!onIts(r)} at={clip && r.t !== undefined ? secs(r.t) : undefined} pick={() => rv.pickRect(i)} />
582
+ ))}
583
+ {drag ? <div className="rv-drag" style={pct(box(drag))} /> : null}
584
+ </div>
585
+ )
586
+ })
587
+
588
+ /** a repo's own rings over its shot; they never take a click */
589
+ const Rings = ({ marks, w, h }: { marks: readonly ReviewMark[]; w: number; h: number }) => (
590
+ <svg className="rv-rings" viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none">
591
+ {marks.map((m, i) => (
592
+ <g key={i}>
593
+ <circle className="rv-ringshade" cx={m.x * w} cy={m.y * h} r={m.r * w} />
594
+ <circle className="rv-ring" cx={m.x * w} cy={m.y * h} r={m.r * w} />
595
+ </g>
596
+ ))}
597
+ </svg>
598
+ )
599
+
600
+ /** a rectangle of his; on a clip, faint and dashed away from its frame, its time on its number */
601
+ const RectMark = ({ r, n, on, faint, at, pick }: { r: Rect; n: number; on: boolean; faint: boolean; at: string | undefined; pick: () => void }) => (
602
+ <div className={`rv-rect${on ? ' on' : ''}${faint ? ' faint' : ''}`} style={pct(r)} title={r.comment || `rectangle ${n}`}
603
+ onPointerDown={(e) => { e.stopPropagation(); pick() }}>
604
+ <span className="rv-rectn">{n}{at ? ` · ${at}` : ''}</span>
605
+ </div>
606
+ )
607
+
608
+ // ── a clip ──
609
+
610
+ /** one video of the open view, driven by the view's one clock; muted until the sound button, inline; looping, or at its
611
+ * end waiting at the start */
612
+ const ClipVideo = ({ clip, src, poster, loop = true }: { clip: ClipSt; src: string; poster?: string; loop?: boolean }) => {
613
+ const ref = useRef<HTMLVideoElement>(null)
614
+ useEffect(() => {
615
+ const el = ref.current
616
+ if (!el) return
617
+ return clip.attach(el)
618
+ }, [clip, src])
619
+ return <video ref={ref} className="rv-video" src={src} poster={poster} muted loop={loop} playsInline preload="auto" onEnded={loop ? undefined : () => clip.seek(0)} />
620
+ }
621
+
622
+ /** the sound button and the volume: a volume moved turns the sound on */
623
+ export const ClipSound = observer(({ clip, keyHint }: { clip: ClipSt; keyHint?: string }) => (
624
+ <span className="rv-sound">
625
+ <button className={clip.sound ? 'primary' : ''} title={`${clip.sound ? 'sound off' : 'sound on'}${keyHint ? ` (${keyHint})` : ''}`} onMouseDown={keepFocus} onClick={() => clip.toggleSound()}>
626
+ {clip.sound ? '🔊' : '🔇'}
627
+ </button>
628
+ <input type="range" min={0} max={1} step={0.05} value={clip.sound ? clip.volume : 0} aria-label="volume" onChange={(e) => clip.setVolume(Number(e.currentTarget.value))}
629
+ onMouseUp={(e) => e.currentTarget.blur()} />
630
+ </span>
631
+ ))
632
+
633
+ /** under a clip: play or pause, one frame back or on, the scrubber, the time and the frame */
634
+ const ClipBar = observer(({ clip, info, use }: { clip: ClipSt; info: ReviewClip; use: () => void }) => {
635
+ const frames = Math.max(1, Math.round(info.duration * info.fps))
636
+ return (
637
+ <div className="rv-clipbar" onMouseDownCapture={use} onTouchStartCapture={use}>
638
+ <button title={clip.paused ? 'play (Space)' : 'pause (Space)'} onMouseDown={keepFocus} onClick={() => clip.toggle()}>{clip.paused ? '▶' : '❚❚'}</button>
639
+ <ClipSound clip={clip} />
640
+ <button title="frame back (←)" onMouseDown={keepFocus} onClick={() => clip.step(-1)}>⏮</button>
641
+ <button title="frame on (→)" onMouseDown={keepFocus} onClick={() => clip.step(1)}>⏭</button>
642
+ <input className="rv-scrub" type="range" min={0} max={info.duration} step={1 / info.fps} value={Math.min(clip.time, info.duration)}
643
+ onChange={(e) => clip.seek(Number(e.currentTarget.value))} onMouseUp={(e) => e.currentTarget.blur()} />
644
+ <span className="rv-mono">{secs(clip.time)} · frame {clip.frameOf(clip.time) + 1}/{frames}</span>
645
+ {clip.error ? <span className="rv-err">{clip.error}</span> : null}
646
+ </div>
647
+ )
648
+ })