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.
- package/CHANGELOG.md +38 -0
- package/package.json +1 -1
- package/src/agent-time/mask.ts +43 -0
- package/src/agent-time/render.ts +187 -0
- package/src/agent-time/report.ts +335 -0
- package/src/agent-time/shape.ts +271 -0
- package/src/agent-time/signals.ts +84 -0
- package/src/agent-time/store.ts +379 -0
- package/src/agent-time/timeline.ts +218 -0
- package/src/agent-time/turns.ts +135 -0
- package/src/cli/cmd_Commands.ts +5 -0
- package/src/cli/cmd_Review.ts +948 -0
- package/src/cli/cmd_Time.ts +112 -0
- package/src/cli/cmd_Trailer.ts +259 -6
- package/src/cli/kit/verb.ts +14 -4
- package/src/cli.ts +6 -4
- package/src/collections/collection-types.ts +6 -1
- package/src/collections/providers.ts +3 -0
- package/src/collections/review.ts +70 -0
- package/src/core/layout.ts +2 -0
- package/src/reflect/prompt-corpus.ts +15 -119
- package/src/reflect/transcript.ts +254 -0
- package/src/review/anim.ts +228 -0
- package/src/review/auto.ts +141 -0
- package/src/review/format.ts +150 -0
- package/src/review/http.ts +23 -0
- package/src/review/marks.ts +43 -0
- package/src/review/media.ts +45 -0
- package/src/review/model.ts +854 -0
- package/src/review/project.ts +110 -0
- package/src/review/role.ts +48 -0
- package/src/review/routes.ts +228 -0
- package/src/review/store.ts +1002 -0
- package/src/review/threads.ts +237 -0
- package/src/review/trailer-desk.ts +385 -0
- package/src/review/trailer-model.ts +183 -0
- package/src/review/trailer-routes.ts +136 -0
- package/src/review/unit.ts +165 -0
- package/src/review/watchers.ts +54 -0
- package/src/serve/embed.ts +35 -0
- package/src/serve/hub-app/activity-view.tsx +1 -1
- package/src/serve/hub-app/activity.css +0 -4
- package/src/serve/hub-app/dock-view.tsx +5 -4
- package/src/serve/hub-app/hash-route.ts +28 -1
- package/src/serve/hub-app/header-bar.tsx +3 -1
- package/src/serve/hub-app/home-st.ts +10 -2
- package/src/serve/hub-app/home-strip.tsx +3 -2
- package/src/serve/hub-app/home.html +1 -0
- package/src/serve/hub-app/hub-app.tsx +80 -11
- package/src/serve/hub-app/hub.css +45 -13
- package/src/serve/hub-app/icons.tsx +9 -0
- package/src/serve/hub-app/index.html +1 -0
- package/src/serve/hub-app/insights-view.tsx +4 -3
- package/src/serve/hub-app/keys.ts +14 -10
- package/src/serve/hub-app/layout-st.ts +63 -2
- package/src/serve/hub-app/planned-view.tsx +3 -2
- package/src/serve/hub-app/planned.css +0 -1
- package/src/serve/hub-app/project-list.css +22 -4
- package/src/serve/hub-app/project-list.tsx +41 -8
- package/src/serve/hub-app/projects-svc.ts +4 -0
- package/src/serve/hub-app/repo-sections.ts +15 -3
- package/src/serve/hub-app/repo-st.ts +45 -3
- package/src/serve/hub-app/review/clip-st.ts +208 -0
- package/src/serve/hub-app/review/kind-view.tsx +171 -0
- package/src/serve/hub-app/review/kit.tsx +113 -0
- package/src/serve/hub-app/review/review-answer.tsx +239 -0
- package/src/serve/hub-app/review/review-board.tsx +46 -0
- package/src/serve/hub-app/review/review-list.tsx +156 -0
- package/src/serve/hub-app/review/review-page.tsx +101 -0
- package/src/serve/hub-app/review/review-st.ts +779 -0
- package/src/serve/hub-app/review/review-views.tsx +503 -0
- package/src/serve/hub-app/review/trailer-page.tsx +416 -0
- package/src/serve/hub-app/review/trailer-st.ts +602 -0
- package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
- package/src/serve/hub-app/review.css +329 -0
- package/src/serve/hub-app/theme-st.ts +56 -0
- package/src/serve/hub-app/tree-build.ts +4 -0
- package/src/serve/hub-app/tree-row.tsx +1 -1
- package/src/serve/hub-app/tree-svc.ts +4 -0
- package/src/serve/hub-runtime.ts +5 -3
- package/src/serve/same-origin.ts +5 -1
- package/src/serve/serve-hub.ts +8 -0
- package/src/shipkit-files.ts +9 -5
- package/src/trailer/adapter.ts +58 -3
- package/src/trailer/build.ts +300 -41
- package/src/trailer/ffmpeg.ts +22 -0
- package/src/trailer/footage.ts +109 -40
- package/src/trailer/grammar.ts +444 -69
- package/src/trailer/graph.ts +199 -14
- package/src/trailer/holes.ts +72 -0
- package/src/trailer/load-unit.ts +20 -3
- package/src/trailer/look.ts +17 -3
- package/src/trailer/passage.ts +129 -0
- package/src/trailer/plan.ts +80 -21
- package/src/trailer/prune.ts +193 -0
- package/src/trailer/score/compose.ts +240 -0
- package/src/trailer/score/dsp.ts +272 -0
- package/src/trailer/score/palette.ts +188 -0
- package/src/trailer/score/pattern.ts +91 -0
- package/src/trailer/score/score.ts +214 -0
- package/src/trailer/score/sound.ts +112 -0
- package/src/trailer/score/synth.ts +361 -0
- package/src/trailer/states.ts +76 -0
- package/src/trailer/stitch.ts +92 -9
- package/src/trailer/titles.ts +123 -18
- package/src/trailer/vocab.ts +23 -4
- package/src/trailer/words.ts +254 -0
|
@@ -0,0 +1,503 @@
|
|
|
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
|
|
4
|
+
import { observer } from 'mobx-react-lite'
|
|
5
|
+
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
|
6
|
+
import { isGallery, isPair, onFrame, viewName, type GalleryView, type MediaView, type Rect, type ReviewClip, type ReviewItem, type ReviewMark, type ReviewView, type Side } from '../../../review/model.ts'
|
|
7
|
+
import { blurOnEscape, Empty, FIT_ROOM, keepFocus, PAIR_NAMES, REACTIONS, secs } from './kit.tsx'
|
|
8
|
+
import type { ClipSt } from './clip-st.ts'
|
|
9
|
+
import { CARD_PLACES, COMPARE_MODES, drawnSide, ZOOMS, type CompareMode, type CompareSt, type PairSide, type ReviewSt, type Zoom } from './review-st.ts'
|
|
10
|
+
|
|
11
|
+
const ZOOM_LABEL: Record<Zoom, string> = { fit: 'Fit', '1': '100 %', '1.5': '150 %', '2': '200 %' }
|
|
12
|
+
const toneClass = (s: string | undefined): string => (s ? ` re-${s}` : '')
|
|
13
|
+
|
|
14
|
+
export const ReviewImage = observer(({ rv }: { rv: ReviewSt }) => {
|
|
15
|
+
const x = rv.open
|
|
16
|
+
if (!x) return <Empty text={rv.list ? 'No card. An agent adds one with sk review add.' : 'Reading the cards…'} />
|
|
17
|
+
const r = x.item
|
|
18
|
+
const views = r.views
|
|
19
|
+
const drawable = views.some((v) => !isGallery(v) && !v.clip)
|
|
20
|
+
return (
|
|
21
|
+
<div className="rv-views">
|
|
22
|
+
<div className="rv-viewsbar">
|
|
23
|
+
{views.length > 1 ? <JumpList rv={rv} /> : <span />}
|
|
24
|
+
<span className="rv-viewsctl">
|
|
25
|
+
{rv.phone.on && drawable ? (
|
|
26
|
+
<button className={rv.phone.draw ? 'primary' : ''} onClick={() => rv.phone.toggleDraw()}>Circle</button>
|
|
27
|
+
) : null}
|
|
28
|
+
{rv.phone.on ? null : (
|
|
29
|
+
<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)">
|
|
30
|
+
{CARD_PLACES.map((p) => <button key={p} className={rv.place === p ? 'on' : ''} onMouseDown={keepFocus} onClick={() => rv.setPlace(p)}>{p}</button>)}
|
|
31
|
+
</span>
|
|
32
|
+
)}
|
|
33
|
+
<span className="seg" title="the zoom every view starts at">
|
|
34
|
+
{ZOOMS.map((z) => <button key={z} className={rv.zoom === z ? 'on' : ''} onMouseDown={keepFocus} onClick={() => rv.setZoom(z)}>{ZOOM_LABEL[z]}</button>)}
|
|
35
|
+
</span>
|
|
36
|
+
</span>
|
|
37
|
+
</div>
|
|
38
|
+
{rv.answered.map((y) => <Raised key={y.item.id} rv={rv} x={y} />)}
|
|
39
|
+
{views.map((v, i) => <ViewBlock key={`${r.id}#${i}`} rv={rv} id={r.id} v={v} i={i} many={views.length > 1} />)}
|
|
40
|
+
{views.length === 0 ? <TextOnly x={x} /> : null}
|
|
41
|
+
</div>
|
|
42
|
+
)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
/** a raised item the open card answers, nested above its views: the owner's words, his still */
|
|
46
|
+
const Raised = observer(({ rv, x }: { rv: ReviewSt; x: ReviewItem }) => {
|
|
47
|
+
const opening = x.thread.find((m) => m.op === undefined && m.text !== undefined)
|
|
48
|
+
const first = x.item.views[0]
|
|
49
|
+
return (
|
|
50
|
+
<div className="rv-raised">
|
|
51
|
+
<div className="rv-dim">answers {x.item.origin.by === 'owner' ? 'your note' : 'a report'} · {x.item.title}</div>
|
|
52
|
+
{opening?.text ? <div className="rv-pre">{opening.text}</div> : null}
|
|
53
|
+
{first && !isGallery(first) && 'image' in first.files ? <img className="rv-raisedimg" src={rv.fileUrl(x.item.id, first.files.image)} alt="" /> : null}
|
|
54
|
+
</div>
|
|
55
|
+
)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
/** an item with no view: its words, full width */
|
|
59
|
+
const TextOnly = ({ x }: { x: ReviewItem }) => {
|
|
60
|
+
const text = x.item.what ?? x.thread.find((m) => m.text !== undefined)?.text
|
|
61
|
+
return text ? <div className="rv-pre">{text}</div> : <Empty text="This item has no image and no text." />
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 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 */
|
|
65
|
+
const JumpList = observer(({ rv }: { rv: ReviewSt }) => {
|
|
66
|
+
const x = rv.open
|
|
67
|
+
if (!x) return null
|
|
68
|
+
return (
|
|
69
|
+
<span className="rv-jumps">
|
|
70
|
+
{x.item.views.map((v, i) => (
|
|
71
|
+
<button key={i} className={`rv-jump${rv.view === i ? ' on' : ''}${toneClass(rv.reactionOf(i)?.status)}`} onMouseDown={keepFocus}
|
|
72
|
+
onClick={() => {
|
|
73
|
+
rv.setView(i)
|
|
74
|
+
document.getElementById(`review-view-${i}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
75
|
+
}}>
|
|
76
|
+
{rv.reactionOf(i)?.status ? <span className="rv-jdot" /> : null}
|
|
77
|
+
{i + 1}. {viewName(v, i)}
|
|
78
|
+
</button>
|
|
79
|
+
))}
|
|
80
|
+
</span>
|
|
81
|
+
)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
/** one view: its label, its reaction and zoom, its image, pair, clip or gallery, then his word on it */
|
|
85
|
+
const ViewBlock = observer(({ rv, id, v, i, many }: { rv: ReviewSt; id: string; v: ReviewView; i: number; many: boolean }) => {
|
|
86
|
+
const reaction = rv.reactionOf(i)
|
|
87
|
+
const media = isGallery(v) ? null : v
|
|
88
|
+
const zoom = rv.zoomOf(i)
|
|
89
|
+
return (
|
|
90
|
+
<div id={`review-view-${i}`} className={`rv-view${toneClass(reaction?.status)}${rv.view === i && many ? ' active' : ''}`}>
|
|
91
|
+
<div className="rv-viewhead">
|
|
92
|
+
<b className="rv-viewname">{many ? `${i + 1}. ` : ''}{viewName(v, i)}</b>
|
|
93
|
+
<ReactionToggles rv={rv} view={i} />
|
|
94
|
+
<span className="sp" />
|
|
95
|
+
{media?.marks && media.marks.length > 0 ? (
|
|
96
|
+
<button className={rv.marksOn ? 'primary' : ''} onMouseDown={keepFocus} onClick={() => rv.toggleMarks()}>marks · M</button>
|
|
97
|
+
) : null}
|
|
98
|
+
{media && isPair(media.files) && !media.audio && !rv.phone.on ? <CompareModes c={rv.compare} /> : null}
|
|
99
|
+
{media ? (
|
|
100
|
+
<button title={zoom === 'fit' ? 'see this image at its own size' : 'fit this image to the column'} onMouseDown={keepFocus}
|
|
101
|
+
onClick={() => rv.setViewZoom(i, zoom === 'fit' ? '1' : 'fit')}>{zoom === 'fit' ? '100 %' : 'Fit'}</button>
|
|
102
|
+
) : null}
|
|
103
|
+
</div>
|
|
104
|
+
{isGallery(v) ? <Gallery rv={rv} id={id} v={v} view={i} /> : <MediaBody rv={rv} id={id} v={v} i={i} zoom={zoom} />}
|
|
105
|
+
<textarea className="rv-word" rows={1} placeholder={isGallery(v) ? 'a word on these images' : 'a word on this image'} value={reaction?.comment ?? ''}
|
|
106
|
+
onKeyDown={blurOnEscape} onChange={(e) => rv.setReactionComment(i, undefined, e.currentTarget.value)} />
|
|
107
|
+
</div>
|
|
108
|
+
)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
/** the owner's reaction to one view or tile: OK, fix or no; a click on the chosen one clears it */
|
|
112
|
+
const ReactionToggles = observer(({ rv, view, tile, small }: { rv: ReviewSt; view: number; tile?: number; small?: boolean }) => {
|
|
113
|
+
const cur = rv.reactionOf(view, tile)?.status
|
|
114
|
+
return (
|
|
115
|
+
<span className="rv-reacts">
|
|
116
|
+
{REACTIONS.map((o) => (
|
|
117
|
+
<button key={o.status} title={o.tip} className={`rv-react re-${o.status}${cur === o.status ? ' on' : ''}${cur && cur !== o.status ? ' off' : ''}`}
|
|
118
|
+
onMouseDown={keepFocus} onClick={() => rv.react(view, tile, o.status)}>
|
|
119
|
+
{o.glyph}{small ? null : ` ${o.label}`}
|
|
120
|
+
</button>
|
|
121
|
+
))}
|
|
122
|
+
</span>
|
|
123
|
+
)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
/** a gallery tile's image height */
|
|
127
|
+
const TILE_H = 170
|
|
128
|
+
|
|
129
|
+
/** a gallery view: its images as a wrapping grid of small tiles, each with its label and its own reaction; a click
|
|
130
|
+
* shows it large */
|
|
131
|
+
const Gallery = observer(({ rv, id, v, view }: { rv: ReviewSt; id: string; v: GalleryView; view: number }) => (
|
|
132
|
+
<div className="rv-gallery">
|
|
133
|
+
{v.gallery.map((t, k) => {
|
|
134
|
+
const big = rv.bigTile === `${view}/${k}`
|
|
135
|
+
const r = rv.reactionOf(view, k)
|
|
136
|
+
const ratio = t.size.w / t.size.h
|
|
137
|
+
const w = big ? undefined : Math.round(Math.max(110, Math.min(260, TILE_H * ratio)))
|
|
138
|
+
return (
|
|
139
|
+
<div key={k} id={`review-tile-${view}-${k}`} className={`rv-tile${big ? ' big' : ''}${toneClass(r?.status)}`} style={big ? undefined : { width: w }}>
|
|
140
|
+
<div className="rv-tileimg" onClick={() => rv.toggleTile(view, k)} title={big ? 'click to shrink' : 'click to enlarge'}
|
|
141
|
+
style={big ? { width: `min(100%, calc((100vh - ${FIT_ROOM}px) * ${ratio}))` } : { height: TILE_H }}>
|
|
142
|
+
<img src={rv.fileUrl(id, t.file)} alt={t.label} draggable={false} />
|
|
143
|
+
</div>
|
|
144
|
+
<div className="rv-tilelabel" title={t.label}>{t.label}</div>
|
|
145
|
+
<ReactionToggles rv={rv} view={view} tile={k} small />
|
|
146
|
+
<textarea className="rv-word" rows={1} placeholder="a word" value={r?.comment ?? ''} onKeyDown={blurOnEscape}
|
|
147
|
+
onChange={(e) => rv.setReactionComment(view, k, e.currentTarget.value)} />
|
|
148
|
+
</div>
|
|
149
|
+
)
|
|
150
|
+
})}
|
|
151
|
+
</div>
|
|
152
|
+
))
|
|
153
|
+
|
|
154
|
+
/** one media view: a single image or clip, or the pair in the compare component; fitted to the column on its own, or
|
|
155
|
+
* zoomed with its own scroll */
|
|
156
|
+
const MediaBody = observer(({ rv, id, v, i, zoom }: { rv: ReviewSt; id: string; v: MediaView; i: number; zoom: Zoom }) => {
|
|
157
|
+
if (v.audio) return <SoundBody rv={rv} id={id} v={v} />
|
|
158
|
+
const f = v.files
|
|
159
|
+
const ratio = v.size.w / v.size.h
|
|
160
|
+
const aspect = `${v.size.w} / ${v.size.h}`
|
|
161
|
+
const scale = zoom === 'fit' ? null : Number(zoom)
|
|
162
|
+
const room = FIT_ROOM + (v.clip ? 40 : 0)
|
|
163
|
+
const bar = v.clip ? <ClipBar clip={rv.clipOf(i)} info={v.clip} use={() => rv.setView(i)} /> : null
|
|
164
|
+
const scroll = scale ? { overflow: 'auto', maxHeight: `calc(100vh - ${room}px)` } : undefined
|
|
165
|
+
if (!isPair(f)) {
|
|
166
|
+
const name = 'image' in f ? f.image : 'video' in f ? f.video : f.audio
|
|
167
|
+
return (
|
|
168
|
+
<div style={scroll}>
|
|
169
|
+
<div style={{ width: scale ? `${v.size.w * scale}px` : `min(100%, calc((100vh - ${room}px) * ${ratio}))`, margin: '0 auto' }}>
|
|
170
|
+
<Compare c={rv.compare} base={rv.fileUrl(id, name)} mod={undefined} aspect={aspect} draw={(s) => <Marks rv={rv} src={s} side={undefined} view={i} />} />
|
|
171
|
+
{bar}
|
|
172
|
+
<Downloads rv={rv} id={id} files={[{ file: name }]} />
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
const before = rv.fileUrl(id, f.before)
|
|
178
|
+
const after = rv.fileUrl(id, f.after)
|
|
179
|
+
const saves = <Downloads rv={rv} id={id} files={[{ file: f.before, label: 'before' }, { file: f.after, label: 'after' }]} />
|
|
180
|
+
if (rv.phone.on)
|
|
181
|
+
return (
|
|
182
|
+
<>
|
|
183
|
+
<PhonePair rv={rv} view={i} bar={bar} before={before} after={after} aspect={aspect} size={v.size} scale={scale} />
|
|
184
|
+
{saves}
|
|
185
|
+
</>
|
|
186
|
+
)
|
|
187
|
+
const c = rv.compare
|
|
188
|
+
const cols = c.mode === 'side' ? 2 : 1
|
|
189
|
+
const fitW = `min(100%, calc((100vh - ${room}px) * ${ratio * cols}))`
|
|
190
|
+
return (
|
|
191
|
+
<>
|
|
192
|
+
<div style={scroll}>
|
|
193
|
+
<div className="rv-pairgrid" style={{ width: scale ? `${v.size.w * scale * cols + 8 * (cols - 1)}px` : fitW, gridTemplateColumns: `repeat(${cols}, 1fr)` }}>
|
|
194
|
+
<Compare c={c} base={before} mod={after} names={PAIR_NAMES} aspect={aspect}
|
|
195
|
+
draw={(s) => <Marks rv={rv} src={s} side={drawnSide(c.mode, s === before ? 'before' : 'after')} view={i} />} />
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
{bar ? <div style={{ width: fitW, margin: '0 auto' }}>{bar}</div> : null}
|
|
199
|
+
<div style={{ width: fitW, margin: '0 auto' }}>{saves}</div>
|
|
200
|
+
{c.mode === 'swipe' ? <div className="rv-dim rv-center">curtain: a drag moves the line; rectangles are drawn in the other modes</div> : null}
|
|
201
|
+
</>
|
|
202
|
+
)
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
/** a sound view: its waveform over the browser's player, one row a side on a pair, each to download */
|
|
206
|
+
const SoundBody = ({ rv, id, v }: { rv: ReviewSt; id: string; v: MediaView }) => {
|
|
207
|
+
const f = v.files
|
|
208
|
+
const a = v.audio
|
|
209
|
+
if (!a) return null
|
|
210
|
+
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 }]
|
|
211
|
+
return (
|
|
212
|
+
<div className="rv-sound">
|
|
213
|
+
{rows.map((r) => (
|
|
214
|
+
<div key={r.file} className="rv-soundrow">
|
|
215
|
+
{r.label ? <div className="rv-dim">{r.label}</div> : null}
|
|
216
|
+
<img src={rv.fileUrl(id, r.wave)} alt={`the waveform of ${r.file}`} draggable={false} style={{ width: '100%', aspectRatio: `${v.size.w} / ${v.size.h}`, display: 'block' }} />
|
|
217
|
+
<audio controls preload="metadata" src={rv.fileUrl(id, r.file)} style={{ width: '100%' }} />
|
|
218
|
+
</div>
|
|
219
|
+
))}
|
|
220
|
+
<div className="rv-dim">{secs(a.duration)}</div>
|
|
221
|
+
<Downloads rv={rv} id={id} files={rows.map((r) => ({ file: r.file, ...(r.label ? { label: r.label } : {}) }))} />
|
|
222
|
+
</div>
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** a view's files to save, each under the card's id and its own name */
|
|
227
|
+
const Downloads = observer(({ rv, id, files }: { rv: ReviewSt; id: string; files: readonly { file: string; label?: string }[] }) => (
|
|
228
|
+
<div className="rv-downloads">
|
|
229
|
+
{/* the Finder is on the desktop only: a phone has none */}
|
|
230
|
+
{rv.phone.on ? null : <button className="rv-dl ghost" title="show the card's folder in the Finder" onClick={() => void rv.reveal(id)}>📁</button>}
|
|
231
|
+
{files.map(({ file, label }) => (
|
|
232
|
+
<span key={file}>
|
|
233
|
+
<a className="rv-dl" href={rv.fileUrl(id, file)} download={`${id}-${file}`} title="download the file">⤓ {label ?? 'download'}</a>
|
|
234
|
+
{rv.phone.on ? null : <button className="rv-dl ghost" title={`show ${file} in the Finder`} onClick={() => void rv.reveal(id, file)}>📂</button>}
|
|
235
|
+
</span>
|
|
236
|
+
))}
|
|
237
|
+
</div>
|
|
238
|
+
))
|
|
239
|
+
|
|
240
|
+
const PAIR_SIDES: { value: PairSide; label: string }[] = [
|
|
241
|
+
{ value: 'before', label: 'before' },
|
|
242
|
+
{ value: 'after', label: 'after' },
|
|
243
|
+
{ value: 'swipe', label: 'curtain' },
|
|
244
|
+
]
|
|
245
|
+
|
|
246
|
+
/** a pair on a phone: one side at a time full width, or the curtain; the zoom scrolls */
|
|
247
|
+
const PhonePair = observer(({ rv, view, bar, before, after, aspect, size, scale }: {
|
|
248
|
+
rv: ReviewSt
|
|
249
|
+
view: number
|
|
250
|
+
bar: ReactNode
|
|
251
|
+
before: string
|
|
252
|
+
after: string
|
|
253
|
+
aspect: string
|
|
254
|
+
size: { w: number; h: number }
|
|
255
|
+
scale: number | null
|
|
256
|
+
}) => {
|
|
257
|
+
const ph = rv.phone
|
|
258
|
+
const c = rv.compare
|
|
259
|
+
const side = ph.pair === 'swipe' && c.mode !== 'swipe' ? 'after' : ph.pair
|
|
260
|
+
const pick = (s: PairSide): void => {
|
|
261
|
+
if (s === 'swipe') c.setMode('swipe')
|
|
262
|
+
ph.setPair(s)
|
|
263
|
+
}
|
|
264
|
+
const one = side === 'before' ? before : after
|
|
265
|
+
return (
|
|
266
|
+
<div className="rv-phonepair">
|
|
267
|
+
<span className="seg">{PAIR_SIDES.map((s) => <button key={s.value} className={side === s.value ? 'on' : ''} onClick={() => pick(s.value)}>{s.label}</button>)}</span>
|
|
268
|
+
<div style={scale ? { overflow: 'auto', maxHeight: '70vh' } : undefined}>
|
|
269
|
+
<div style={{ width: scale ? `${size.w * scale}px` : '100%' }}>
|
|
270
|
+
{side === 'swipe' ? (
|
|
271
|
+
<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} />} />
|
|
272
|
+
) : (
|
|
273
|
+
<Compare key={side} c={c} base={one} mod={undefined} aspect={aspect} draw={(s) => <Marks rv={rv} src={s} side={side} view={view} />} />
|
|
274
|
+
)}
|
|
275
|
+
{bar}
|
|
276
|
+
</div>
|
|
277
|
+
</div>
|
|
278
|
+
</div>
|
|
279
|
+
)
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
// ── the pair ──
|
|
283
|
+
|
|
284
|
+
const MODES: { mode: CompareMode; glyph: string; tip: string }[] = [
|
|
285
|
+
{ mode: 'side', glyph: '◫', tip: 'side by side' },
|
|
286
|
+
{ mode: 'swipe', glyph: '⇹', tip: 'curtain: drag the line, the before on the left, the after on the right' },
|
|
287
|
+
{ mode: 'onion', glyph: '◐', tip: 'onion skin: the after over the before' },
|
|
288
|
+
{ mode: 'mod', glyph: '▣', tip: 'the after alone: hovering the image shows the before' },
|
|
289
|
+
]
|
|
290
|
+
|
|
291
|
+
/** the compare modes as tiny buttons, the onion's opacity floating under them */
|
|
292
|
+
const CompareModes = observer(({ c }: { c: CompareSt }) => (
|
|
293
|
+
<span className="rv-modes">
|
|
294
|
+
<span className="seg">
|
|
295
|
+
{COMPARE_MODES.map((m) => {
|
|
296
|
+
const o = MODES.find((x) => x.mode === m)!
|
|
297
|
+
return <button key={m} title={o.tip} className={c.mode === m ? 'on' : ''} onMouseDown={keepFocus} onClick={() => c.setMode(m)}>{o.glyph}</button>
|
|
298
|
+
})}
|
|
299
|
+
</span>
|
|
300
|
+
{c.mode === 'onion' ? (
|
|
301
|
+
<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))} />
|
|
302
|
+
) : null}
|
|
303
|
+
</span>
|
|
304
|
+
))
|
|
305
|
+
|
|
306
|
+
/** follows the pointer until it is released */
|
|
307
|
+
function track(move: (e: PointerEvent) => void): void {
|
|
308
|
+
const up = (): void => {
|
|
309
|
+
window.removeEventListener('pointermove', move)
|
|
310
|
+
window.removeEventListener('pointerup', up)
|
|
311
|
+
}
|
|
312
|
+
window.addEventListener('pointermove', move)
|
|
313
|
+
window.addEventListener('pointerup', up)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** THE pair: side by side, the curtain clipping both images, the after over the before as an onion skin, or the
|
|
317
|
+
* after alone (the before while the pointer is over it). One image alone when `mod` is undefined */
|
|
318
|
+
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 }) => {
|
|
319
|
+
const [peek, setPeek] = useState(false)
|
|
320
|
+
if (mod === undefined) return <Cell aspect={aspect} caption={null}><div className="rv-fill">{draw(base)}</div></Cell>
|
|
321
|
+
const n = names ?? PAIR_NAMES
|
|
322
|
+
if (c.mode === 'side')
|
|
323
|
+
return (
|
|
324
|
+
<>
|
|
325
|
+
<Cell aspect={aspect} caption={n.base}><div className="rv-fill">{draw(base)}</div></Cell>
|
|
326
|
+
<Cell aspect={aspect} caption={n.mod} modCap><div className="rv-fill">{draw(mod)}</div></Cell>
|
|
327
|
+
</>
|
|
328
|
+
)
|
|
329
|
+
if (c.mode === 'mod')
|
|
330
|
+
return (
|
|
331
|
+
<Cell aspect={aspect} caption={peek ? n.base : n.mod} modCap={!peek} onPeek={setPeek}>
|
|
332
|
+
<div className="rv-fill">{draw(peek ? base : mod)}</div>
|
|
333
|
+
</Cell>
|
|
334
|
+
)
|
|
335
|
+
const pct = Math.round(c.split * 100)
|
|
336
|
+
return (
|
|
337
|
+
<Cell aspect={aspect} caption={<>{n.base} {c.mode === 'swipe' ? '◀ ▶' : '+'} <span className="rv-modcap">{n.mod}</span></>} swipe={c.mode === 'swipe' ? c : undefined}>
|
|
338
|
+
<div className="rv-fill" style={c.mode === 'swipe' ? { clipPath: `inset(0 ${100 - pct}% 0 0)` } : undefined}>{draw(base)}</div>
|
|
339
|
+
<div className="rv-fill" style={c.mode === 'swipe' ? { clipPath: `inset(0 0 0 ${pct}%)` } : { opacity: c.opacity }}>{draw(mod)}</div>
|
|
340
|
+
{c.mode === 'swipe' ? <div className="rv-curtain" style={{ left: `${pct}%` }} /> : null}
|
|
341
|
+
</Cell>
|
|
342
|
+
)
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
/** one frame and its caption; the curtain's line follows the pointer */
|
|
346
|
+
const Cell = ({ aspect, caption, modCap, swipe, onPeek, children }: { aspect: string; caption: ReactNode; modCap?: boolean; swipe?: CompareSt | undefined; onPeek?: (on: boolean) => void; children: ReactNode }) => {
|
|
347
|
+
const frame = useRef<HTMLDivElement>(null)
|
|
348
|
+
const down = (e: React.PointerEvent): void => {
|
|
349
|
+
const el = frame.current
|
|
350
|
+
if (!swipe || !el) return
|
|
351
|
+
e.preventDefault()
|
|
352
|
+
const at = (x: number): void => {
|
|
353
|
+
const r = el.getBoundingClientRect()
|
|
354
|
+
swipe.setSplit((x - r.left) / r.width)
|
|
355
|
+
}
|
|
356
|
+
at(e.clientX)
|
|
357
|
+
track((m) => at(m.clientX))
|
|
358
|
+
}
|
|
359
|
+
return (
|
|
360
|
+
<div className="rv-cell">
|
|
361
|
+
<div ref={frame} className={`rv-frame${swipe ? ' swipe' : ''}`} style={{ aspectRatio: aspect }} onPointerDown={down}
|
|
362
|
+
onMouseEnter={() => onPeek?.(true)} onMouseLeave={() => onPeek?.(false)}>
|
|
363
|
+
{children}
|
|
364
|
+
</div>
|
|
365
|
+
{caption != null ? <div className={`rv-caption${modCap ? ' mod' : ''}`}>{caption}</div> : null}
|
|
366
|
+
</div>
|
|
367
|
+
)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ── drawing ──
|
|
371
|
+
|
|
372
|
+
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) })
|
|
373
|
+
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}%` })
|
|
374
|
+
const clamp01 = (v: number): number => Math.max(0, Math.min(1, v))
|
|
375
|
+
/** a drag shorter than this, on either axis, is a click: it picks nothing */
|
|
376
|
+
const MIN_DRAG = 0.01
|
|
377
|
+
|
|
378
|
+
/** one image of view `view` and its rectangles; a drag on it draws one (never in the curtain, whose drag moves the
|
|
379
|
+
* line). A clip view that is not the active one loops on its own: a click makes it active */
|
|
380
|
+
const Marks = observer(({ rv, src, side, view }: { rv: ReviewSt; src: string; side: Side | undefined; view: number }) => {
|
|
381
|
+
const el = useRef<HTMLDivElement>(null)
|
|
382
|
+
const [drag, setDrag] = useState<{ x0: number; y0: number; x1: number; y1: number } | null>(null)
|
|
383
|
+
const phone = rv.phone
|
|
384
|
+
const any = rv.open?.item.views[view]
|
|
385
|
+
const shown = any && !isGallery(any) ? any : undefined
|
|
386
|
+
const clip = shown?.clip
|
|
387
|
+
const active = rv.view === view
|
|
388
|
+
const drawing = phone.on ? phone.draw && (side === undefined || phone.pair !== 'swipe' || rv.compare.mode !== 'swipe') : side === undefined || rv.compare.mode !== 'swipe'
|
|
389
|
+
const c = rv.clipOf(view)
|
|
390
|
+
const at = (e: { clientX: number; clientY: number }): { x: number; y: number } => {
|
|
391
|
+
const b = el.current?.getBoundingClientRect()
|
|
392
|
+
if (!b) return { x: 0, y: 0 }
|
|
393
|
+
return { x: clamp01((e.clientX - b.left) / b.width), y: clamp01((e.clientY - b.top) / b.height) }
|
|
394
|
+
}
|
|
395
|
+
const down = (e: React.PointerEvent): void => {
|
|
396
|
+
if (e.button !== 0) return
|
|
397
|
+
if (clip && !active) {
|
|
398
|
+
e.preventDefault()
|
|
399
|
+
rv.setView(view)
|
|
400
|
+
return
|
|
401
|
+
}
|
|
402
|
+
if (!drawing) return
|
|
403
|
+
e.preventDefault()
|
|
404
|
+
e.stopPropagation()
|
|
405
|
+
// he draws on the paused frame: the rectangle keeps its time
|
|
406
|
+
if (clip && !c.paused) c.pause()
|
|
407
|
+
const a = at(e)
|
|
408
|
+
let cur = { x0: a.x, y0: a.y, x1: a.x, y1: a.y }
|
|
409
|
+
setDrag(cur)
|
|
410
|
+
const move = (m: PointerEvent): void => {
|
|
411
|
+
const b = at(m)
|
|
412
|
+
cur = { ...cur, x1: b.x, y1: b.y }
|
|
413
|
+
setDrag(cur)
|
|
414
|
+
}
|
|
415
|
+
const up = (): void => {
|
|
416
|
+
window.removeEventListener('pointermove', move)
|
|
417
|
+
window.removeEventListener('pointerup', up)
|
|
418
|
+
window.removeEventListener('pointercancel', up)
|
|
419
|
+
setDrag(null)
|
|
420
|
+
const r = box(cur)
|
|
421
|
+
if (r.w > MIN_DRAG && r.h > MIN_DRAG) rv.addRect(r, side, view)
|
|
422
|
+
else rv.pickRect(null)
|
|
423
|
+
}
|
|
424
|
+
window.addEventListener('pointermove', move)
|
|
425
|
+
window.addEventListener('pointerup', up)
|
|
426
|
+
window.addEventListener('pointercancel', up)
|
|
427
|
+
}
|
|
428
|
+
const rects = rv.draft.rects.map((r, i) => [r, i] as const).filter(([r]) => r.side === side && (r.view ?? 0) === view)
|
|
429
|
+
const onIts = (r: Rect): boolean => !clip || r.t === undefined || (c.paused && onFrame(r.t, c.time, clip.fps))
|
|
430
|
+
const rings = rv.marksOn ? (shown?.marks ?? []).filter((m) => m.side === side) : []
|
|
431
|
+
const live = drawing || (clip !== undefined && !active)
|
|
432
|
+
return (
|
|
433
|
+
<div ref={el} className={`rv-marks${clip && !active ? ' pick' : drawing ? ' draw' : ''}`} onPointerDown={down} style={{ pointerEvents: live ? 'auto' : 'none', touchAction: drawing ? 'none' : undefined }}>
|
|
434
|
+
{clip ? <ClipVideo key={src} clip={c} src={src} /> : <img src={src} alt="" draggable={false} className="rv-markimg" />}
|
|
435
|
+
{shown && rings.length > 0 ? <Rings marks={rings} w={shown.size.w} h={shown.size.h} /> : null}
|
|
436
|
+
{rects.map(([r, i]) => (
|
|
437
|
+
<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)} />
|
|
438
|
+
))}
|
|
439
|
+
{drag ? <div className="rv-drag" style={pct(box(drag))} /> : null}
|
|
440
|
+
</div>
|
|
441
|
+
)
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
/** a repo's own rings over its shot; they never take a click */
|
|
445
|
+
const Rings = ({ marks, w, h }: { marks: readonly ReviewMark[]; w: number; h: number }) => (
|
|
446
|
+
<svg className="rv-rings" viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none">
|
|
447
|
+
{marks.map((m, i) => (
|
|
448
|
+
<g key={i}>
|
|
449
|
+
<circle className="rv-ringshade" cx={m.x * w} cy={m.y * h} r={m.r * w} />
|
|
450
|
+
<circle className="rv-ring" cx={m.x * w} cy={m.y * h} r={m.r * w} />
|
|
451
|
+
</g>
|
|
452
|
+
))}
|
|
453
|
+
</svg>
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
/** a rectangle of his; on a clip, faint and dashed away from its frame, its time on its number */
|
|
457
|
+
const RectMark = ({ r, n, on, faint, at, pick }: { r: Rect; n: number; on: boolean; faint: boolean; at: string | undefined; pick: () => void }) => (
|
|
458
|
+
<div className={`rv-rect${on ? ' on' : ''}${faint ? ' faint' : ''}`} style={pct(r)} title={r.comment || `rectangle ${n}`}
|
|
459
|
+
onPointerDown={(e) => { e.stopPropagation(); pick() }}>
|
|
460
|
+
<span className="rv-rectn">{n}{at ? ` · ${at}` : ''}</span>
|
|
461
|
+
</div>
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
// ── a clip ──
|
|
465
|
+
|
|
466
|
+
/** one video of the open view, driven by the view's one clock; muted until the sound button, looping, inline */
|
|
467
|
+
const ClipVideo = ({ clip, src }: { clip: ClipSt; src: string }) => {
|
|
468
|
+
const ref = useRef<HTMLVideoElement>(null)
|
|
469
|
+
useEffect(() => {
|
|
470
|
+
const el = ref.current
|
|
471
|
+
if (!el) return
|
|
472
|
+
return clip.attach(el)
|
|
473
|
+
}, [clip, src])
|
|
474
|
+
return <video ref={ref} className="rv-video" src={src} muted loop playsInline preload="auto" />
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** the sound button and the volume: a volume moved turns the sound on */
|
|
478
|
+
export const ClipSound = observer(({ clip, keyHint }: { clip: ClipSt; keyHint?: string }) => (
|
|
479
|
+
<span className="rv-sound">
|
|
480
|
+
<button className={clip.sound ? 'primary' : ''} title={`${clip.sound ? 'sound off' : 'sound on'}${keyHint ? ` (${keyHint})` : ''}`} onMouseDown={keepFocus} onClick={() => clip.toggleSound()}>
|
|
481
|
+
{clip.sound ? '🔊' : '🔇'}
|
|
482
|
+
</button>
|
|
483
|
+
<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))}
|
|
484
|
+
onMouseUp={(e) => e.currentTarget.blur()} />
|
|
485
|
+
</span>
|
|
486
|
+
))
|
|
487
|
+
|
|
488
|
+
/** under a clip: play or pause, one frame back or on, the scrubber, the time and the frame */
|
|
489
|
+
const ClipBar = observer(({ clip, info, use }: { clip: ClipSt; info: ReviewClip; use: () => void }) => {
|
|
490
|
+
const frames = Math.max(1, Math.round(info.duration * info.fps))
|
|
491
|
+
return (
|
|
492
|
+
<div className="rv-clipbar" onMouseDownCapture={use} onTouchStartCapture={use}>
|
|
493
|
+
<button title={clip.paused ? 'play (Space)' : 'pause (Space)'} onMouseDown={keepFocus} onClick={() => clip.toggle()}>{clip.paused ? '▶' : '❚❚'}</button>
|
|
494
|
+
<ClipSound clip={clip} />
|
|
495
|
+
<button title="frame back (←)" onMouseDown={keepFocus} onClick={() => clip.step(-1)}>⏮</button>
|
|
496
|
+
<button title="frame on (→)" onMouseDown={keepFocus} onClick={() => clip.step(1)}>⏭</button>
|
|
497
|
+
<input className="rv-scrub" type="range" min={0} max={info.duration} step={1 / info.fps} value={Math.min(clip.time, info.duration)}
|
|
498
|
+
onChange={(e) => clip.seek(Number(e.currentTarget.value))} onMouseUp={(e) => e.currentTarget.blur()} />
|
|
499
|
+
<span className="rv-mono">{secs(clip.time)} · frame {clip.frameOf(clip.time) + 1}/{frames}</span>
|
|
500
|
+
{clip.error ? <span className="rv-err">{clip.error}</span> : null}
|
|
501
|
+
</div>
|
|
502
|
+
)
|
|
503
|
+
})
|