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