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,416 @@
1
+ // a repo's trailer page (`#page:trailer`, wiki build/trailer.md, Frame notes): the cuts on the left, the player over
2
+ // its timeline and its shot cards in the middle, the notes on the right. A note pins a frame of the render on screen
3
+ // and becomes an item of the review store. On a phone the three stacked
4
+ import { observer } from 'mobx-react-lite'
5
+ import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react'
6
+ import { Group, Panel, Separator } from 'react-resizable-panels'
7
+ import { clock, cutsByRecent, shotAt, type CutInfo, type TrailerNote } from '../../../review/trailer-model.ts'
8
+ import { editorHref } from '../check-words.ts'
9
+ import { renderMd } from '../md.ts'
10
+ import type { RepoSt } from '../repo-st.ts'
11
+ import { keepFocus } from './kit.tsx'
12
+ import { PANEL, PhoneTop } from './review-page.tsx'
13
+ import { ClipSound } from './review-views.tsx'
14
+ import { hueOf, ShotCards, TrailerTimeline } from './trailer-timeline.tsx'
15
+ import { captionText, type PlacedNote, type TrailerSt } from './trailer-st.ts'
16
+
17
+ export const TrailerPage = observer(({ app }: { app: RepoSt }) => {
18
+ const tr = app.trailer
19
+ useEffect(() => {
20
+ tr.start()
21
+ return () => tr.stop()
22
+ }, [tr])
23
+ if (app.review.phone.on)
24
+ return (
25
+ <div className="tr-phone">
26
+ <PhoneTop app={app} />
27
+ <PlayerColumn tr={tr} app={app} />
28
+ <NotesColumn tr={tr} />
29
+ <CutList tr={tr} />
30
+ </div>
31
+ )
32
+ return (
33
+ <Group className="rv-cols" orientation="horizontal">
34
+ <Panel id="trailer-cuts" defaultSize="15%" minSize={190} className="rv-pane" style={PANEL}>
35
+ <div className="rv-scroll"><CutList tr={tr} /></div>
36
+ </Panel>
37
+ <Separator className="rsep" />
38
+ <Panel id="trailer-player" defaultSize="57%" minSize={420} className="rv-pane" style={PANEL}>
39
+ <div className="rv-scroll"><PlayerColumn tr={tr} app={app} /></div>
40
+ </Panel>
41
+ <Separator className="rsep" />
42
+ <Panel id="trailer-notes" defaultSize="28%" minSize={300} className="rv-pane" style={PANEL}>
43
+ <NotesColumn tr={tr} />
44
+ </Panel>
45
+ </Group>
46
+ )
47
+ })
48
+
49
+ // ── the cuts ──
50
+
51
+ /** `m:ss`, the seconds rounded */
52
+ const mmss = (t: number): string => {
53
+ const s = Math.round(Math.max(0, t))
54
+ return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
55
+ }
56
+
57
+ const ago = (ms: number): string => {
58
+ const s = Math.max(0, Math.round((Date.now() - ms) / 1000))
59
+ return s < 60 ? `${s}s` : s < 3600 ? `${Math.round(s / 60)}m` : s < 86400 ? `${Math.round(s / 3600)}h` : `${Math.round(s / 86400)}d`
60
+ }
61
+
62
+ const CutList = observer(({ tr }: { tr: TrailerSt }) => (
63
+ <div className="tr-cuts">
64
+ {tr.deskError ? <div className="rv-err">{tr.deskError}</div> : null}
65
+ {tr.trailers.length > 1 ? (
66
+ <select value={tr.trailer?.id ?? ''} onChange={(e) => tr.pick(e.currentTarget.value, null)}>
67
+ {tr.trailers.map((t) => <option key={t.id} value={t.id}>{t.id}</option>)}
68
+ </select>
69
+ ) : (
70
+ <div className="tr-trailername">🎬 {tr.trailer?.id ?? 'trailer'}</div>
71
+ )}
72
+ {!tr.desk && !tr.deskError ? <div className="rv-empty"><span className="spin" /> reading the cuts…</div> : null}
73
+ {tr.desk && tr.trailers.length === 0 ? <div className="rv-empty">No trailer under trailer/: write trailer/&lt;name&gt;/main.trailer, then sk trailer.</div> : null}
74
+ <div className="tr-cutrows">
75
+ {cutsByRecent(tr.trailer?.cuts ?? []).map((c, i, all) => (
76
+ <Fragment key={c.cut}>
77
+ {c.version && !all[i - 1]?.version ? <div className="tr-cuthead" style={{ margin: '8px 0 2px', fontSize: 11, fontWeight: 700, color: 'var(--dim)' }}>Kept versions</div> : null}
78
+ <CutRow tr={tr} c={c} />
79
+ </Fragment>
80
+ ))}
81
+ </div>
82
+ <Keys />
83
+ </div>
84
+ ))
85
+
86
+ /** one row a cut: the name on the left, its length and render age on the right; the rest in marks and tooltips */
87
+ const CutRow = observer(({ tr, c }: { tr: TrailerSt; c: CutInfo }) => {
88
+ const t = tr.trailer
89
+ const on = tr.cut?.cut === c.cut
90
+ const open = tr.openOn(c)
91
+ const v = c.video
92
+ const shots = c.timeline?.shots.length ?? 0
93
+ const age = v ? (c.version ? `kept, rendered ${new Date(v.mtime).toLocaleDateString()}` : `rendered ${ago(v.mtime)} ago`) : 'not rendered yet'
94
+ return (
95
+ <button className={`tr-cutrow${on ? ' on' : ''}${v ? '' : ' unrendered'}`} disabled={!v} onClick={() => t && tr.pick(t.id, c.cut)}
96
+ title={v ? `${clock(v.duration)} · ${shots} shot${shots > 1 ? 's' : ''} · ${age} · ${c.files.video}` : `${age}: sk trailer ${c.files.trailer ?? ''}`}>
97
+ <span className="tr-cutname">{c.cut}</span>
98
+ {c.version ? <span className="tr-kept" style={{ color: 'var(--accent)', fontSize: 11 }} title="a kept version: versions/ holds its text and its render, it never changes">▣</span> : null}
99
+ {c.rendering ? <span className="spin" title="a render is being written" /> : null}
100
+ {c.edited && !c.rendering ? <span className="tr-edited" title="the .trailer changed since this render">✎</span> : null}
101
+ {c.errors.length > 0 ? <span className="tr-cuterr" title={c.errors.join('\n')}>!</span> : null}
102
+ <span className="sp" />
103
+ {open > 0 ? <span className="tr-opencount" title={`${open} open note${open > 1 ? 's' : ''} on its shots`}>{open}</span> : null}
104
+ <span className="tr-cutage">{v ? `${mmss(v.duration)} · ${c.version ? new Date(v.mtime).toLocaleDateString(undefined, { day: 'numeric', month: 'numeric' }) : ago(v.mtime)}` : '—'}</span>
105
+ </button>
106
+ )
107
+ })
108
+
109
+ const KEYS: [string, string][] = [
110
+ ['Space', 'play, pause'],
111
+ [', .', 'frame back, on'],
112
+ ['← →', 'one second (Shift: five)'],
113
+ ['↑ ↓', 'shot back, on'],
114
+ ['⇧↑ ⇧↓', 'note back, on'],
115
+ ['N', 'a note on this frame'],
116
+ ['click', 'a note pinned to that point'],
117
+ ['M', 'sound'],
118
+ ['- +', 'timeline zoom'],
119
+ ['Home', 'to the start'],
120
+ ]
121
+
122
+ const Keys = () => (
123
+ <div className="tr-keys">
124
+ <div className="tr-keyshead">Keys</div>
125
+ {KEYS.map(([k, what]) => <div key={k} className="tr-keyrow"><kbd>{k}</kbd><span>{what}</span></div>)}
126
+ </div>
127
+ )
128
+
129
+ // ── the player ──
130
+
131
+ const PlayerColumn = observer(({ tr, app }: { tr: TrailerSt; app: RepoSt }) => {
132
+ const c = tr.cut
133
+ if (!tr.desk) return <div className="rv-empty">{tr.deskError ?? 'Reading the cuts…'}</div>
134
+ if (!c) return <div className="rv-empty">No cut. A trailer is trailer/&lt;name&gt;/&lt;cut&gt;.trailer, rendered by sk trailer.</div>
135
+ if (!tr.shown) return <div className="rv-empty">{c.video ? `${c.cut}: its timeline does not read (${c.errors[0] ?? '?'})` : `${c.cut} is not rendered yet: sk trailer ${c.files.trailer ?? ''}`}</div>
136
+ return (
137
+ <div className="tr-player">
138
+ {tr.newer ? (
139
+ <div className="tr-banner">
140
+ <span>A new render of {c.cut} is ready ({c.video ? `${ago(c.video.mtime)} ago` : ''}).</span>
141
+ <button className="primary" onClick={() => tr.loadNewer()}>Load it, on the same shot</button>
142
+ </div>
143
+ ) : null}
144
+ {!tr.newer && c.edited && !c.timelineFromRender ? (
145
+ <div className="tr-banner warn">The .trailer changed since this render: the timeline comes from the file, its shots may be off until the next sk trailer.</div>
146
+ ) : null}
147
+ <Player tr={tr} />
148
+ <Transport tr={tr} app={app} />
149
+ <NowLine tr={tr} />
150
+ <TrailerTimeline st={tr} />
151
+ <ShotCards st={tr} />
152
+ <Montage tr={tr} />
153
+ </div>
154
+ )
155
+ })
156
+
157
+ const Player = observer(({ tr }: { tr: TrailerSt }) => {
158
+ const shown = tr.shown
159
+ const ref = useRef<HTMLVideoElement>(null)
160
+ const src = tr.videoUrl
161
+ useEffect(() => {
162
+ const el = ref.current
163
+ if (!el || !src) return
164
+ return tr.clock.attach(el)
165
+ }, [tr, src])
166
+ if (!shown || !src) return null
167
+ const [w, h] = shown.timeline.size
168
+ return (
169
+ <div className="tr-screen">
170
+ <div className="tr-frame" style={{ width: `min(100%, calc(64vh * ${w / h}))`, aspectRatio: `${w} / ${h}` }}>
171
+ <video ref={ref} src={src} playsInline preload="auto" onEnded={() => tr.clock.pause()} />
172
+ <div className="tr-clickzone" title="click: a note pinned to this point"
173
+ onClick={(e) => {
174
+ const r = e.currentTarget.getBoundingClientRect()
175
+ tr.clickFrame({ x: (e.clientX - r.left) / r.width, y: (e.clientY - r.top) / r.height })
176
+ }}>
177
+ <FramePins tr={tr} />
178
+ </div>
179
+ {tr.clock.paused ? <div className="tr-paused">❚❚ frame {tr.frame + 1}</div> : null}
180
+ </div>
181
+ </div>
182
+ )
183
+ })
184
+
185
+ /** the pins of the notes on this frame, and the draft's */
186
+ const FramePins = observer(({ tr }: { tr: TrailerSt }) => {
187
+ const d = tr.draft
188
+ return (
189
+ <>
190
+ {tr.pinsNow.map((n) => <Pin key={n.note.id} x={n.note.pin!.x} y={n.note.pin!.y} state={n.note.state} label={String(n.note.n)} on={tr.selected === n.note.id} />)}
191
+ {d?.pin ? <Pin x={d.pin.x} y={d.pin.y} state="open" label="+" on pulse /> : null}
192
+ </>
193
+ )
194
+ })
195
+
196
+ const Pin = ({ x, y, state, label, on, pulse }: { x: number; y: number; state: string; label: string; on: boolean; pulse?: boolean }) => (
197
+ <div className="tr-pin" style={{ left: `${x * 100}%`, top: `${y * 100}%` }}>
198
+ {pulse ? <div className="tr-pulse" /> : null}
199
+ <div className={`tr-pinhead ns-${state}${on ? ' on' : ''}`}><span>{label}</span></div>
200
+ </div>
201
+ )
202
+
203
+ const RATES = [0.25, 0.5, 1, 1.25, 1.5, 2]
204
+
205
+ const Transport = observer(({ tr, app }: { tr: TrailerSt; app: RepoSt }) => {
206
+ const c = tr.clock
207
+ const btn = (glyph: ReactNode, label: string, go: () => void, big = false): ReactNode => (
208
+ <button className={`tr-tbtn${big ? ' big' : ''}`} title={label} aria-label={label} onMouseDown={keepFocus} onClick={go}>{glyph}</button>
209
+ )
210
+ const file = tr.cut?.files.trailer ?? tr.cut?.files.video
211
+ const href = editorHref(app.root, file)
212
+ return (
213
+ <div className="tr-transport">
214
+ {btn('⏮', 'shot back (↑)', () => tr.shotBy(-1))}
215
+ {btn('‹', 'frame back (,)', () => tr.step(-1))}
216
+ {btn(c.paused ? '▶' : '❚❚', c.paused ? 'play (Space)' : 'pause (Space)', () => c.toggle(), true)}
217
+ {btn('›', 'frame on (.)', () => tr.step(1))}
218
+ {btn('⏭', 'shot on (↓)', () => tr.shotBy(1))}
219
+ <div className="tr-time">
220
+ <div className="tr-timebig">{clock(tr.time)} <span className="tr-dim">/ {clock(tr.shown?.duration ?? 0)}</span></div>
221
+ <div className="tr-dim tr-small">frame {tr.frame + 1} / {tr.frames} · {tr.fps} fps</div>
222
+ </div>
223
+ <span className="sp" />
224
+ <span className="seg">{RATES.map((r) => <button key={r} className={c.rate === r ? 'on' : ''} onMouseDown={keepFocus} onClick={() => c.setRate(r)}>×{r}</button>)}</span>
225
+ <ClipSound clip={c} keyHint="M" />
226
+ {href && file ? <a className="tr-file" href={href} title={`open ${file} in the editor`}>{file.split('/').pop()}</a> : null}
227
+ {tr.shown ? <button className="tr-tbtn" title={`show ${tr.cut?.files.video ?? 'the render'} in the Finder`} aria-label="show the render in the Finder" onMouseDown={keepFocus} onClick={() => void tr.reveal()}>📂</button> : null}
228
+ <button className="tr-notebtn" onMouseDown={keepFocus} onClick={() => tr.newNote()}>✚ Note (N)</button>
229
+ </div>
230
+ )
231
+ })
232
+
233
+ /** what is on screen: the section, the shot and the time into it, the caption shown */
234
+ const NowLine = observer(({ tr }: { tr: TrailerSt }) => {
235
+ const s = tr.shot
236
+ const tl = tr.timeline
237
+ if (!s || !tl) return null
238
+ return (
239
+ <div className="tr-now">
240
+ <span className={`tr-secbadge ${hueOf(tl, s.section)}`}>{s.section ?? 'opening'}</span>
241
+ <b className="tr-shotnow">{s.name}</b>
242
+ <span className="tr-dim">shot {tr.shotIndex + 1}/{tl.shots.length} · {(tr.time - s.start).toFixed(2)} s of {s.length} s · line {s.line}</span>
243
+ {tr.captions.map((c) => <span key={`${c.start}${c.text}`} className="tr-capnow">« {captionText(c)} »</span>)}
244
+ </div>
245
+ )
246
+ })
247
+
248
+ /** the cut's montage page (out/<name>.montage.md), folded until asked */
249
+ const Montage = observer(({ tr }: { tr: TrailerSt }) => {
250
+ const m = tr.montage
251
+ return (
252
+ <div className="tr-montage">
253
+ <button className="ghost" onClick={() => void tr.toggleMontage()}>{tr.montageOpen ? '▾' : '▸'} montage</button>
254
+ {tr.montageOpen ? (m == null ? <span className="spin" /> : m.md == null ? <div className="rv-dim">This render has no montage page.</div> : <div className="md tr-montagebody" dangerouslySetInnerHTML={{ __html: renderMd(m.md) }} />) : null}
255
+ </div>
256
+ )
257
+ })
258
+
259
+ const DraftBox = observer(({ tr }: { tr: TrailerSt }) => {
260
+ const d = tr.draft
261
+ const tl = tr.timeline
262
+ if (!d || !tl) return null
263
+ const s = shotAt(tl, d.time)
264
+ return (
265
+ <div className="tr-draft">
266
+ <div className="tr-drafthead">
267
+ <b>New note · {clock(d.time)} · {s?.name}</b>
268
+ {d.pin ? (
269
+ <span><span className="tr-pinned">📍 pinned</span> <button className="ghost" onClick={() => tr.unpinDraft()}>remove</button></span>
270
+ ) : (
271
+ <span className="tr-dim tr-small">click the image to pin a point</span>
272
+ )}
273
+ </div>
274
+ <textarea autoFocus rows={3} placeholder="What is wrong, what you want instead…" value={d.text} onChange={(e) => tr.setDraftText(e.currentTarget.value)}
275
+ onKeyDown={(e) => {
276
+ if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
277
+ e.preventDefault()
278
+ void tr.saveDraft()
279
+ } else if (e.key === 'Escape') {
280
+ e.preventDefault()
281
+ tr.cancelDraft()
282
+ }
283
+ }} />
284
+ {tr.error ? <div className="rv-err">{tr.error}</div> : null}
285
+ <div className="tr-dim tr-small">Enter saves · Shift Enter a new line · Escape drops it</div>
286
+ <div className="tr-draftbtns">
287
+ <button onClick={() => tr.cancelDraft()}>Cancel</button>
288
+ <button className="primary" disabled={tr.saving || d.text.trim() === ''} onClick={() => void tr.saveDraft()}>Save</button>
289
+ </div>
290
+ </div>
291
+ )
292
+ })
293
+
294
+ // ── the notes ──
295
+
296
+ const NotesColumn = observer(({ tr }: { tr: TrailerSt }) => {
297
+ const here = tr.here
298
+ const elsewhere = tr.elsewhere
299
+ const total = tr.notes.length
300
+ return (
301
+ <div className="tr-notescol">
302
+ <div className="tr-noteshead">
303
+ <b>Notes</b>
304
+ <span className={`tr-openbadge${tr.openCount > 0 ? ' on' : ''}`}>{tr.openCount} open</span>
305
+ <span className="tr-dim tr-small">of {total}</span>
306
+ <span className="sp" />
307
+ <span className="seg">
308
+ <button className={tr.filter === 'open' ? 'on' : ''} onClick={() => tr.setFilter('open')}>open</button>
309
+ <button className={tr.filter === 'all' ? 'on' : ''} onClick={() => tr.setFilter('all')}>all</button>
310
+ </span>
311
+ </div>
312
+ <div className="tr-notesscroll">
313
+ {tr.draft ? <DraftBox tr={tr} /> : null}
314
+ {total === 0 && !tr.draft ? <div className="rv-empty">No note. N, or a click on the image, writes one on the frame on screen.</div> : null}
315
+ {here.map((n) => <NoteCard key={n.note.id} tr={tr} n={n} />)}
316
+ {elsewhere.length > 0 ? <div className="tr-elsewhere">On shots outside this cut ({elsewhere.length})</div> : null}
317
+ {elsewhere.map((n) => <NoteCard key={n.note.id} tr={tr} n={n} />)}
318
+ {!tr.draft && tr.error ? <div className="rv-err">{tr.error}</div> : null}
319
+ </div>
320
+ </div>
321
+ )
322
+ })
323
+
324
+ const STATE_LABEL: Record<string, string> = { open: 'open', done: 'done', wontfix: 'not doing' }
325
+
326
+ const NoteCard = observer(({ tr, n: p }: { tr: TrailerSt; n: PlacedNote }) => {
327
+ const n = p.note
328
+ const on = tr.selected === n.id
329
+ const editing = tr.editing?.id === n.id ? tr.editing : null
330
+ const closing = tr.closing?.id === n.id ? tr.closing : null
331
+ const [sure, setSure] = useState(false)
332
+ const ref = useRef<HTMLDivElement>(null)
333
+ useEffect(() => {
334
+ if (on) ref.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
335
+ }, [on])
336
+ const go = (): void => (p.at !== null ? tr.goTo(p) : tr.openOnItsCut(n))
337
+ const enterSaves = (save: () => void, cancel: () => void) => (e: React.KeyboardEvent<HTMLTextAreaElement>): void => {
338
+ if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
339
+ e.preventDefault()
340
+ save()
341
+ } else if (e.key === 'Escape') {
342
+ e.preventDefault()
343
+ cancel()
344
+ }
345
+ }
346
+ return (
347
+ <div ref={ref} className={`tr-note ns-${n.state}${on ? ' on' : ''}`} onClick={(e) => { if (!(e.target as Element).closest('button, textarea, input')) go() }}>
348
+ <div className="tr-notetop">
349
+ {!on ? <Still tr={tr} n={n} w={112} /> : null}
350
+ <div className="tr-notebody">
351
+ <div className="tr-notemeta">
352
+ <span className="tr-mono tr-dim" title={n.id}>#{n.n}</span>
353
+ <b className="tr-mono">{p.at !== null ? clock(p.at) : `${n.cut} ${clock(n.time)}`}</b>
354
+ <span className={`tr-secbadge small ${tr.timeline ? hueOf(tr.timeline, n.section) : 'hue-none'}`}>{n.shot}</span>
355
+ {n.state !== 'open' ? <span className={`tr-statebadge ns-${n.state}`}>{n.state === 'done' ? '✓' : '⊘'} {STATE_LABEL[n.state]}</span> : null}
356
+ </div>
357
+ {p.moved && p.at !== null ? <div className="tr-moved">shot moved: written at {clock(n.time)}, this shot now starts at {clock(p.at - n.shotTime)}</div> : null}
358
+ {p.at === null ? <div className="tr-dim tr-small">written on {n.cut} · this shot is not in {tr.shown?.cut} (click: open {n.cut})</div> : null}
359
+ {!editing ? <div className={`tr-notetext${n.state === 'wontfix' ? ' struck' : ''}`}>{n.text}</div> : null}
360
+ </div>
361
+ </div>
362
+ {on ? <Still tr={tr} n={n} w="100%" /> : null}
363
+ {editing ? (
364
+ <div className="tr-edit">
365
+ <textarea autoFocus rows={3} value={editing.text} onChange={(e) => tr.setEditText(e.currentTarget.value)} onKeyDown={enterSaves(() => void tr.saveEdit(), () => tr.cancelEdit())} />
366
+ <div className="tr-dim tr-small">Enter saves · Escape drops it · on its frame, a click moves the pin</div>
367
+ </div>
368
+ ) : null}
369
+ {n.reply && !closing ? (
370
+ <div className={`tr-reply ns-${n.state}`}>
371
+ <div className="rv-pre">{n.reply}</div>
372
+ {n.closed ? <div className="tr-dim tr-small">{ago(Date.parse(n.closed))} ago</div> : null}
373
+ </div>
374
+ ) : null}
375
+ {closing ? (
376
+ <div className="tr-edit">
377
+ <textarea autoFocus rows={2} placeholder={closing.state === 'done' ? 'The commit, or what was done (optional)' : 'Why (optional)'} value={closing.reply}
378
+ onChange={(e) => tr.setReply(e.currentTarget.value)} onKeyDown={enterSaves(() => void tr.confirmClose(), () => tr.cancelClose())} />
379
+ <div className="tr-draftbtns">
380
+ <button onClick={() => tr.cancelClose()}>Cancel</button>
381
+ <button className="primary" onClick={() => void tr.confirmClose()}>{closing.state === 'done' ? 'Mark done' : 'Not doing it'}</button>
382
+ </div>
383
+ </div>
384
+ ) : null}
385
+ {on && !editing && !closing ? (
386
+ <div className="tr-noteacts">
387
+ <button onClick={() => tr.startEdit(n)}>✎ Edit</button>
388
+ {n.state === 'open' ? (
389
+ <>
390
+ <button className="tr-done" onClick={() => tr.askClose(n, 'done')}>✓ Done</button>
391
+ <button onClick={() => tr.askClose(n, 'wontfix')}>⊘ Not doing</button>
392
+ </>
393
+ ) : (
394
+ <button onClick={() => void tr.reopen(n)}>↺ Reopen</button>
395
+ )}
396
+ {n.pin ? <button className="ghost" onClick={() => void tr.unpin(n)}>📍 Unpin</button> : null}
397
+ <span className="sp" />
398
+ <button className={`tr-delete${sure ? ' sure' : ''}`} onClick={() => (sure ? void tr.remove(n) : setSure(true))} onMouseLeave={() => setSure(false)}>
399
+ {sure ? 'Sure?' : 'Delete'}
400
+ </button>
401
+ </div>
402
+ ) : null}
403
+ </div>
404
+ )
405
+ })
406
+
407
+ /** a note's still, as cut when he wrote it, with its pin */
408
+ const Still = ({ tr, n, w }: { tr: TrailerSt; n: TrailerNote; w: number | string }) => {
409
+ const [sw, sh] = tr.shown?.timeline.size ?? [16, 9]
410
+ return (
411
+ <div className={`tr-still${typeof w === 'string' ? ' wide' : ''}`} style={{ width: w, aspectRatio: `${sw} / ${sh}` }}>
412
+ <img src={tr.stillUrl(n)} alt="" loading="lazy" />
413
+ {n.pin ? <div className={`tr-stillpin ns-${n.state}`} style={{ left: `${n.pin.x * 100}%`, top: `${n.pin.y * 100}%` }} /> : null}
414
+ </div>
415
+ )
416
+ }