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