rvlib-shipkit 2.1.1 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -0
- package/package.json +2 -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 +7 -3
- package/src/assets/run-assets.ts +18 -4
- package/src/checks/SK004-gitignore-policy.check.ts +11 -6
- package/src/checks/SK013-one-way-in-policy.check.ts +13 -8
- package/src/cli/cmd_Calls.ts +53 -0
- package/src/cli/cmd_Commands.ts +163 -43
- package/src/cli/cmd_Review.ts +948 -0
- package/src/cli/cmd_Shot.ts +63 -0
- package/src/cli/cmd_Start.ts +8 -4
- package/src/cli/cmd_Time.ts +112 -0
- package/src/cli/cmd_Trailer.ts +361 -0
- package/src/cli/kit/builtins.ts +40 -0
- package/src/cli/kit/calls.ts +177 -0
- package/src/cli/kit/families.ts +67 -0
- package/src/cli/kit/index.ts +8 -0
- package/src/cli/kit/pages-check.ts +36 -0
- package/src/cli/kit/project-cli.ts +73 -0
- package/src/cli/kit/verb.ts +167 -0
- package/src/cli.ts +15 -2
- package/src/collections/collection-types.ts +6 -1
- package/src/collections/providers.ts +3 -0
- package/src/collections/review.ts +70 -0
- package/src/core/layout.ts +12 -2
- package/src/core/migrate-layout.ts +31 -8
- package/src/core/one-way-in-policy.ts +12 -4
- package/src/reflect/prompt-corpus.ts +15 -114
- package/src/reflect/transcript.ts +254 -0
- package/src/review/anim.ts +228 -0
- package/src/review/auto.ts +141 -0
- package/src/review/format.ts +150 -0
- package/src/review/http.ts +23 -0
- package/src/review/marks.ts +43 -0
- package/src/review/media.ts +45 -0
- package/src/review/model.ts +854 -0
- package/src/review/project.ts +110 -0
- package/src/review/role.ts +48 -0
- package/src/review/routes.ts +228 -0
- package/src/review/store.ts +1002 -0
- package/src/review/threads.ts +237 -0
- package/src/review/trailer-desk.ts +385 -0
- package/src/review/trailer-model.ts +183 -0
- package/src/review/trailer-routes.ts +136 -0
- package/src/review/unit.ts +165 -0
- package/src/review/watchers.ts +54 -0
- package/src/scaffold/cli-skeleton.ts +149 -0
- package/src/scaffold/files/agent-index.md +2 -2
- package/src/scaffold/files/tsconfig.json +3 -0
- package/src/scaffold/files/verify-skill.md +1 -1
- package/src/scaffold/project-scaffold.ts +90 -12
- package/src/serve/embed.ts +35 -0
- package/src/serve/hub-app/activity-view.tsx +1 -1
- package/src/serve/hub-app/activity.css +0 -4
- package/src/serve/hub-app/dock-view.tsx +5 -4
- package/src/serve/hub-app/hash-route.ts +28 -1
- package/src/serve/hub-app/header-bar.tsx +3 -1
- package/src/serve/hub-app/home-st.ts +10 -2
- package/src/serve/hub-app/home-strip.tsx +3 -2
- package/src/serve/hub-app/home.html +1 -0
- package/src/serve/hub-app/hub-app.tsx +80 -11
- package/src/serve/hub-app/hub.css +45 -13
- package/src/serve/hub-app/icons.tsx +9 -0
- package/src/serve/hub-app/index.html +1 -0
- package/src/serve/hub-app/insights-view.tsx +4 -3
- package/src/serve/hub-app/keys.ts +14 -10
- package/src/serve/hub-app/layout-st.ts +63 -2
- package/src/serve/hub-app/planned-view.tsx +3 -2
- package/src/serve/hub-app/planned.css +0 -1
- package/src/serve/hub-app/project-list.css +22 -4
- package/src/serve/hub-app/project-list.tsx +41 -8
- package/src/serve/hub-app/projects-svc.ts +4 -0
- package/src/serve/hub-app/repo-sections.ts +15 -3
- package/src/serve/hub-app/repo-st.ts +45 -3
- package/src/serve/hub-app/review/clip-st.ts +208 -0
- package/src/serve/hub-app/review/kind-view.tsx +171 -0
- package/src/serve/hub-app/review/kit.tsx +113 -0
- package/src/serve/hub-app/review/review-answer.tsx +239 -0
- package/src/serve/hub-app/review/review-board.tsx +46 -0
- package/src/serve/hub-app/review/review-list.tsx +156 -0
- package/src/serve/hub-app/review/review-page.tsx +101 -0
- package/src/serve/hub-app/review/review-st.ts +779 -0
- package/src/serve/hub-app/review/review-views.tsx +503 -0
- package/src/serve/hub-app/review/trailer-page.tsx +416 -0
- package/src/serve/hub-app/review/trailer-st.ts +602 -0
- package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
- package/src/serve/hub-app/review.css +329 -0
- package/src/serve/hub-app/theme-st.ts +56 -0
- package/src/serve/hub-app/tree-build.ts +4 -0
- package/src/serve/hub-app/tree-row.tsx +1 -1
- package/src/serve/hub-app/tree-svc.ts +4 -0
- package/src/serve/hub-runtime.ts +5 -3
- package/src/serve/same-origin.ts +5 -1
- package/src/serve/serve-hub.ts +8 -0
- package/src/shipkit-files.ts +12 -4
- package/src/shot/client.ts +67 -0
- package/src/shot/clip-encoder.ts +146 -0
- package/src/shot/dom-clip.ts +124 -0
- package/src/shot/serve-shot.ts +54 -0
- package/src/shot/shooter.ts +343 -0
- package/src/shot/shot-ask.ts +189 -0
- package/src/shot/shot.shipkit.ts +14 -0
- package/src/smoke/run-smoke.ts +2 -2
- package/src/start-repo.ts +29 -14
- package/src/suite/judge-suites.ts +56 -10
- package/src/suite/select.ts +2 -5
- package/src/suite/suite-cli.ts +3 -2
- package/src/trailer/adapter.ts +78 -0
- package/src/trailer/build.ts +603 -0
- package/src/trailer/ffmpeg.ts +72 -0
- package/src/trailer/footage.ts +173 -0
- package/src/trailer/grammar.ts +1290 -0
- package/src/trailer/graph.ts +508 -0
- package/src/trailer/holes.ts +72 -0
- package/src/trailer/load-unit.ts +31 -0
- package/src/trailer/look.ts +124 -0
- package/src/trailer/passage.ts +129 -0
- package/src/trailer/plan.ts +192 -0
- package/src/trailer/prune.ts +193 -0
- package/src/trailer/score/compose.ts +240 -0
- package/src/trailer/score/dsp.ts +272 -0
- package/src/trailer/score/palette.ts +188 -0
- package/src/trailer/score/pattern.ts +91 -0
- package/src/trailer/score/score.ts +214 -0
- package/src/trailer/score/sound.ts +112 -0
- package/src/trailer/score/synth.ts +361 -0
- package/src/trailer/states.ts +76 -0
- package/src/trailer/stitch.ts +233 -0
- package/src/trailer/titles.ts +379 -0
- package/src/trailer/url.ts +108 -0
- package/src/trailer/vertical-draw.ts +286 -0
- package/src/trailer/vertical.ts +148 -0
- package/src/trailer/vocab.ts +59 -0
- package/src/trailer/words.ts +254 -0
|
@@ -13,7 +13,10 @@
|
|
|
13
13
|
// labeled sorts (a pick sorts once, the button names the order the list is
|
|
14
14
|
// in), archived shows or hides the archived repos just above the foot
|
|
15
15
|
// (names only, no ⌘ number, a row opens the archived page, nothing loads),
|
|
16
|
-
// the foot never moving.
|
|
16
|
+
// the foot never moving. ⌘B (or the foot's fold button, or a drag of the
|
|
17
|
+
// separator below the list's min) folds the list to its icon rail: the
|
|
18
|
+
// names, the ⌘ hints and the step hint hide, the badge rides the icon's
|
|
19
|
+
// corner, the hover card still names the row. On the home a section of four lines on top, in the
|
|
17
20
|
// repo rows' style, opens the picker, the overview, the activity and the
|
|
18
21
|
// plan, each with its shortcut at the line's end, the page shown lit. It
|
|
19
22
|
// renders from the first paint with the list this browser kept, so a load
|
|
@@ -24,14 +27,17 @@ import { useEffect, type CSSProperties } from 'react'
|
|
|
24
27
|
import { middleware, OPEN_MS } from './floating.tsx'
|
|
25
28
|
import { Icon, ICON } from './icons.tsx'
|
|
26
29
|
import { plainClick } from './home-st.ts'
|
|
27
|
-
import { PICKER_KEY, PLACE_KEY, STEP_KEYS } from './keys.ts'
|
|
28
|
-
import { cardModel, faceOf, hueOf, SORTS, type Place, type Project, type ProjectsSvc } from './projects-svc.ts'
|
|
30
|
+
import { PICKER_KEY, PLACE_KEY, RAIL_KEY, STEP_KEYS } from './keys.ts'
|
|
31
|
+
import { cardModel, faceOf, hueOf, idVars, SORTS, type Place, type Project, type ProjectsSvc } from './projects-svc.ts'
|
|
32
|
+
import { THEMES, type ThemePick } from './theme-st.ts'
|
|
33
|
+
import { RAIL_ICONS, type RailIcon } from './layout-st.ts'
|
|
29
34
|
|
|
30
|
-
/** what the list needs of its page: the hub and the home both carry one
|
|
31
|
-
|
|
35
|
+
/** what the list needs of its page: the hub and the home both carry one;
|
|
36
|
+
* the layout, when the page can fold the list to its rail */
|
|
37
|
+
export type ListHost = { projects: ProjectsSvc; layout?: { listRail: boolean; toggleRail(): void; railIcon: RailIcon; cycleRailIcon(): void }; theme?: { pick: ThemePick; cycle(): void } }
|
|
32
38
|
|
|
33
39
|
/** the lines on top of the list, in their order: the picker, the overview,
|
|
34
|
-
* then the places,
|
|
40
|
+
* then the places, each with its shortcut (keys.ts) */
|
|
35
41
|
const PLACES: { id: Place | 'picker'; label: string; title: string }[] = [
|
|
36
42
|
{ id: 'picker', label: 'jump to…', title: 'jump to a page, a repo or one of its resources' },
|
|
37
43
|
{ id: 'overview', label: 'overview', title: 'the overview: shipkit, every global process and every repo on this machine' },
|
|
@@ -65,7 +71,7 @@ export const Row = observer(({ app, p, i }: { app: ListHost; p: Project; i: numb
|
|
|
65
71
|
const dot = s.dotOf(p.name)
|
|
66
72
|
const warn = s.warnOf(p.name)
|
|
67
73
|
// the shown repo's row is marked in its own colour
|
|
68
|
-
const mark = cur ? (
|
|
74
|
+
const mark = cur ? (idVars(s.identityOf(p.name)) as CSSProperties) : undefined
|
|
69
75
|
return (
|
|
70
76
|
<>
|
|
71
77
|
<button ref={f.refs.setReference} className={cls} aria-label={p.name} style={mark} draggable
|
|
@@ -155,8 +161,10 @@ export const Card = ({ p, now }: { p: Project; now: number }) => {
|
|
|
155
161
|
export const ProjectList = observer(({ app }: { app: ListHost }) => {
|
|
156
162
|
useEffect(() => app.projects.start(), [app])
|
|
157
163
|
const s = app.projects
|
|
164
|
+
const lay = app.layout
|
|
165
|
+
const rail = lay?.listRail === true
|
|
158
166
|
return (
|
|
159
|
-
<nav className=
|
|
167
|
+
<nav className={`pjlist${rail ? ' rail' : ''}`} aria-label="projects" style={rail ? ({ '--ri': `${RAIL_ICONS[lay!.railIcon]}px` } as CSSProperties) : undefined}>
|
|
160
168
|
{s.onPlace == null && s.onPicker == null && s.error === '' ? null : (
|
|
161
169
|
<div className="pjhead">
|
|
162
170
|
{PLACES.map((pl) => {
|
|
@@ -195,6 +203,17 @@ export const ProjectList = observer(({ app }: { app: ListHost }) => {
|
|
|
195
203
|
</div>
|
|
196
204
|
) : null}
|
|
197
205
|
<div className="pjfoot">
|
|
206
|
+
{lay == null ? null : (
|
|
207
|
+
<button className="pjfold" aria-pressed={rail} title={`${rail ? 'show the names' : 'fold the list to its icons'} (${RAIL_KEY})`} onClick={() => lay.toggleRail()}>
|
|
208
|
+
<Icon d={ICON.rail} />
|
|
209
|
+
</button>
|
|
210
|
+
)}
|
|
211
|
+
{lay == null || !rail ? null : (
|
|
212
|
+
<button className="pjfold pjsize" aria-label={`icon size: ${lay.railIcon}`} title={`the icons: ${SIZE_WORDS[lay.railIcon]}; click for the next size`} onClick={() => lay.cycleRailIcon()}>
|
|
213
|
+
{lay.railIcon.toUpperCase()}
|
|
214
|
+
</button>
|
|
215
|
+
)}
|
|
216
|
+
{app.theme == null ? null : <ThemeButton t={app.theme} />}
|
|
198
217
|
<SortButton s={s} />
|
|
199
218
|
<span className="pjstep" title={`${STEP_KEYS[0]} and ${STEP_KEYS[1]} open the repo above or below, from any page`}>
|
|
200
219
|
{STEP_KEYS.map((k) => <kbd key={k}>{k}</kbd>)}
|
|
@@ -211,6 +230,20 @@ export const ProjectList = observer(({ app }: { app: ListHost }) => {
|
|
|
211
230
|
)
|
|
212
231
|
})
|
|
213
232
|
|
|
233
|
+
const SIZE_WORDS: Record<RailIcon, string> = { s: 'small', m: 'medium', l: 'large' }
|
|
234
|
+
|
|
235
|
+
const THEME_ICON: Record<ThemePick, keyof typeof ICON> = { dark: 'moon', light: 'sun', auto: 'auto' }
|
|
236
|
+
|
|
237
|
+
/** the theme: dark, light or auto (the system's), one click to the next */
|
|
238
|
+
const ThemeButton = observer(({ t }: { t: { pick: ThemePick; cycle(): void } }) => {
|
|
239
|
+
const next = THEMES[(THEMES.indexOf(t.pick) + 1) % THEMES.length]
|
|
240
|
+
return (
|
|
241
|
+
<button className="pjfold" aria-label={`theme: ${t.pick}`} title={`theme: ${t.pick}${t.pick === 'auto' ? ' (follows the system)' : ''}; click for ${next}`} onClick={() => t.cycle()}>
|
|
242
|
+
<Icon d={ICON[THEME_ICON[t.pick]]} />
|
|
243
|
+
</button>
|
|
244
|
+
)
|
|
245
|
+
})
|
|
246
|
+
|
|
214
247
|
/** the words for the order the list is in */
|
|
215
248
|
const orderWords = (s: ProjectsSvc): string => {
|
|
216
249
|
const m = s.sortMode
|
|
@@ -7,6 +7,10 @@ import type { ArchivedRow, ProjectRow as Project, Release } from '../project-gut
|
|
|
7
7
|
import { identityOfName, type Identity } from '../../core/identity-color.ts'
|
|
8
8
|
import { ownTrouble, reach } from './reach.ts'
|
|
9
9
|
|
|
10
|
+
/** a repo's two colours as the vars a `.idc` element or a list row reads,
|
|
11
|
+
* hub.css picking the one for the page's theme */
|
|
12
|
+
export const idVars = (id: Pick<Identity, 'colorDark' | 'colorLight'>): Record<'--idd' | '--idl', string> => ({ '--idd': id.colorDark, '--idl': id.colorLight })
|
|
13
|
+
|
|
10
14
|
/** the list never says the page's server is gone, even where no machine
|
|
11
15
|
* card is (a hub on its own): that hub's trouble card says it */
|
|
12
16
|
const SAID_ELSEWHERE = true
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// own has none of the three. The counts read the severity filter, like everything under them,
|
|
9
9
|
// and come from what the page already holds (the kept screen on a reload), so
|
|
10
10
|
// none arrives late and moves the tree.
|
|
11
|
-
import { RUNS_ID } from '../../collections/collection-types.ts'
|
|
11
|
+
import { REVIEW_COL, RUNS_ID, TRAILER_COL } from '../../collections/collection-types.ts'
|
|
12
12
|
import type { CheckOwner, HashSel } from './hash-route.ts'
|
|
13
13
|
import { sectionLabel, type SectionId } from './keys.ts'
|
|
14
14
|
import type { RepoSt } from './repo-st.ts'
|
|
@@ -45,6 +45,8 @@ export function sectionOfSel(sel: HashSel | null, ownerOf: (checkItemId: string)
|
|
|
45
45
|
case 'when': return 'when'
|
|
46
46
|
case 'checks': return sel.from
|
|
47
47
|
case 'admin': return 'admin'
|
|
48
|
+
case 'review': return 'review'
|
|
49
|
+
case 'trailer': return 'trailer'
|
|
48
50
|
case 'tests': return 'tests'
|
|
49
51
|
case 'asset': case 'cat': return 'media'
|
|
50
52
|
case 'status': return null
|
|
@@ -54,6 +56,8 @@ export function sectionOfSel(sel: HashSel | null, ownerOf: (checkItemId: string)
|
|
|
54
56
|
if (col === WIKIS) return 'wikis'
|
|
55
57
|
if (col === 'contracts') return 'contracts'
|
|
56
58
|
if (col === 'creds') return 'secrets'
|
|
59
|
+
if (col === REVIEW_COL) return 'review'
|
|
60
|
+
if (col === TRAILER_COL) return 'trailer'
|
|
57
61
|
if (col === 'checks') return sel.t === 'item' ? ownerOf(sel.id) : null
|
|
58
62
|
return col === 'asks' || col === 'tests' || col === 'reflections' ? col : null
|
|
59
63
|
}
|
|
@@ -87,11 +91,14 @@ export function openSection(app: RepoSt, id: SectionId): void {
|
|
|
87
91
|
}
|
|
88
92
|
case 'wikis': return openWikis(app)
|
|
89
93
|
case 'admin': return app.showAdmin()
|
|
94
|
+
case 'review': return app.showReview(app.review.selectedId)
|
|
95
|
+
case 'trailer': return app.showTrailer(app.trailer.trailerId, app.trailer.cutId)
|
|
90
96
|
}
|
|
91
97
|
}
|
|
92
98
|
|
|
93
|
-
/** the sections whose page says it is empty, so an empty row still opens it
|
|
94
|
-
|
|
99
|
+
/** the sections whose page says it is empty, so an empty row still opens it: the review and trailer pages say how a
|
|
100
|
+
* first card or a first cut comes */
|
|
101
|
+
const EMPTY_PAGES = new Set<SectionId>(['processes', 'review', 'trailer'])
|
|
95
102
|
|
|
96
103
|
export function repoSections(app: RepoSt): RepoSection[] {
|
|
97
104
|
const has = (col: string): boolean => app.cols.colMeta.some((c) => c.id === col)
|
|
@@ -106,6 +113,11 @@ export function repoSections(app: RepoSt): RepoSection[] {
|
|
|
106
113
|
const out: RepoSection[] = [line('readme', 'the README, what the repo is', none)]
|
|
107
114
|
const ownReds = (from: CheckOwner): number => res.filter((r) => r.check?.from === from && r.state === 'red').length
|
|
108
115
|
const unless = (on: boolean, why: string): string | null => (on ? null : why)
|
|
116
|
+
// review and trailer sit right under the readme: the pages he answers
|
|
117
|
+
out.push(line('review', 'the review cards agents send you: approve, fix, reject, circle a detail', counted(res.filter((r) => r.type === 'card').length, 'warn'),
|
|
118
|
+
unless(has(REVIEW_COL), 'no review card yet: an agent sends one with sk review add')))
|
|
119
|
+
out.push(line('trailer', 'the trailer cuts: play one with its timeline, pin a note on any frame', none,
|
|
120
|
+
unless(has(TRAILER_COL), 'no trailer: write trailer/<name>/main.trailer, then sk trailer')))
|
|
109
121
|
out.push(line('contracts', 'what a release venue asks of this repo, and what it still lacks', counted(reds('contracts'), 'red'), unless(has('contracts'), 'no contract: no release venue applies to this repo')))
|
|
110
122
|
const noChecks = unless(has('checks'), 'no checks yet: its hub has not answered')
|
|
111
123
|
out.push(line('shipkit', 'the checks shipkit ships to every repo, one page each', counted(ownReds('shipkit'), 'red'), noChecks))
|
|
@@ -21,8 +21,10 @@ 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'
|
|
24
26
|
import { repoSections } from './repo-sections.ts'
|
|
25
|
-
import { sectionKeyLabel } from './keys.ts'
|
|
27
|
+
import { isTreeWalk, sectionKeyLabel } from './keys.ts'
|
|
26
28
|
import type { Resource } from './hub-types.ts'
|
|
27
29
|
import type { HomeSt } from './home-st.ts'
|
|
28
30
|
|
|
@@ -66,6 +68,10 @@ export class RepoSt {
|
|
|
66
68
|
tests: TestsSvc
|
|
67
69
|
/** the admin page: archive behind its confirmation, reload, keep active */
|
|
68
70
|
admin: AdminSvc
|
|
71
|
+
/** the review page: the cards of the one store and his answers (review/review-st.ts) */
|
|
72
|
+
review: ReviewSt
|
|
73
|
+
/** the trailer page: a cut played with its timeline and his frame notes (review/trailer-st.ts) */
|
|
74
|
+
trailer: TrailerSt
|
|
69
75
|
sel: Sel = null
|
|
70
76
|
filter: Filter = 'all'
|
|
71
77
|
/** sidebar search — live substring filter over asset + collection rows */
|
|
@@ -103,7 +109,9 @@ export class RepoSt {
|
|
|
103
109
|
this.git = new GitSt(this)
|
|
104
110
|
this.tests = new TestsSvc(this)
|
|
105
111
|
this.admin = new AdminSvc(this)
|
|
106
|
-
|
|
112
|
+
this.review = new ReviewSt(this)
|
|
113
|
+
this.trailer = new TrailerSt(this)
|
|
114
|
+
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, searchEl: false, beat: false })
|
|
107
115
|
}
|
|
108
116
|
|
|
109
117
|
/** an absolute hub path ('/api/…', '/media/…', '/hub.json') as this
|
|
@@ -175,6 +183,8 @@ export class RepoSt {
|
|
|
175
183
|
this.media.start()
|
|
176
184
|
this.cols.start()
|
|
177
185
|
this.live.start()
|
|
186
|
+
if (this.sel?.t === 'review') this.review.start()
|
|
187
|
+
if (this.sel?.t === 'trailer') this.trailer.start()
|
|
178
188
|
}
|
|
179
189
|
|
|
180
190
|
/** stop polling, keep every state: a resume shows the same page */
|
|
@@ -185,6 +195,8 @@ export class RepoSt {
|
|
|
185
195
|
this.media.stop()
|
|
186
196
|
this.cols.stop()
|
|
187
197
|
this.live.stop()
|
|
198
|
+
this.review.stop()
|
|
199
|
+
this.trailer.stop()
|
|
188
200
|
}
|
|
189
201
|
|
|
190
202
|
dispose(): void {
|
|
@@ -285,7 +297,11 @@ export class RepoSt {
|
|
|
285
297
|
const next = parseHash(h)
|
|
286
298
|
// the tool set rides after the asset id (media-svc syncHash), so an
|
|
287
299
|
// address is the same page whatever tools it carries
|
|
288
|
-
if (cur.kind === next.kind && cur.col === next.col && cur.id === next.id)
|
|
300
|
+
if (cur.kind === next.kind && cur.col === next.col && cur.id === next.id) {
|
|
301
|
+
// the trailer's note and filter ride on its page, as the tool set does on an asset: no history entry
|
|
302
|
+
if (next.kind === 'trailer' && currentHash() !== h) history.replaceState(null, '', `#${h}`)
|
|
303
|
+
return
|
|
304
|
+
}
|
|
289
305
|
if (this.walking && this.walked) history.replaceState(null, '', `#${h}`)
|
|
290
306
|
else history.pushState(null, '', `#${h}`)
|
|
291
307
|
this.walked = this.walking
|
|
@@ -295,6 +311,11 @@ export class RepoSt {
|
|
|
295
311
|
* lands on the raw wall of text unless it is asked for by name */
|
|
296
312
|
openResource(r: Resource): void {
|
|
297
313
|
if (r.type === 'process') return this.showProcesses(r.id.replace(/^process:/, ''))
|
|
314
|
+
if (r.type === 'card') return this.showReview(r.id.replace(/^card:/, ''))
|
|
315
|
+
if (r.type === 'cut') {
|
|
316
|
+
const [trailer = null, cut = null] = r.id.replace(/^cut:/, '').split('/')
|
|
317
|
+
return this.showTrailer(trailer, cut)
|
|
318
|
+
}
|
|
298
319
|
const assetId = r.id.replace(/^media:/, '')
|
|
299
320
|
if (r.collection === 'media' && this.media.assets.some((a) => a.id === assetId)) {
|
|
300
321
|
this.selectAsset(assetId)
|
|
@@ -339,6 +360,25 @@ export class RepoSt {
|
|
|
339
360
|
showProcesses(id: string | null): void {
|
|
340
361
|
this.setSel({ t: 'processes', id })
|
|
341
362
|
}
|
|
363
|
+
/** the review page, `id` the card it opens, null for the first the list shows */
|
|
364
|
+
showReview(id: string | null): void {
|
|
365
|
+
this.review.select(id)
|
|
366
|
+
this.setSel({ t: 'review', id })
|
|
367
|
+
if (this.polling) this.review.start()
|
|
368
|
+
}
|
|
369
|
+
/** 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
|
|
370
|
+
* the trailer page (its player keys), as the atelier takes its roll keys. True when the page used it. ↑ ↓ stay
|
|
371
|
+
* the tree's while its cursor walks it, so a walk crosses these sections; a click in the page hands them back */
|
|
372
|
+
pageKey(e: Pick<KeyboardEvent, 'key' | 'shiftKey'>): boolean {
|
|
373
|
+
if (isTreeWalk(e.key) && !e.shiftKey && this.tree.cursor != null) return false
|
|
374
|
+
return (this.sel?.t === 'review' && this.review.key(e)) || (this.sel?.t === 'trailer' && this.trailer.key(e))
|
|
375
|
+
}
|
|
376
|
+
/** the trailer page on a trailer folder and one of its cuts, null for the first */
|
|
377
|
+
showTrailer(trailer: string | null, cut: string | null, note: string | null = null, filter: 'open' | 'all' | null = null): void {
|
|
378
|
+
this.trailer.select(trailer, cut, note, filter)
|
|
379
|
+
this.setSel({ t: 'trailer', trailer, cut, note, filter })
|
|
380
|
+
if (this.polling) this.trailer.start()
|
|
381
|
+
}
|
|
342
382
|
|
|
343
383
|
/** collection view — one card per instance */
|
|
344
384
|
showCol(id: string): void {
|
|
@@ -360,6 +400,8 @@ export class RepoSt {
|
|
|
360
400
|
case 'git': return this.showGit()
|
|
361
401
|
case 'when': return this.showWhen()
|
|
362
402
|
case 'checks': return this.showChecks(sel.from)
|
|
403
|
+
case 'review': return this.showReview(sel.id)
|
|
404
|
+
case 'trailer': return this.showTrailer(sel.trailer, sel.cut, sel.note ?? null, sel.filter ?? null)
|
|
363
405
|
}
|
|
364
406
|
}
|
|
365
407
|
/** the url decides the page: a reload, back, forward and a pasted link
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// the one clock of a clip on screen (a card's clip view, a trailer cut): it plays, pauses, seeks and steps every video
|
|
2
|
+
// of the view at once, so a pair of clips stays in sync. A paused time is always the middle of a frame, so both videos
|
|
3
|
+
// of a pair and every rectangle drawn on it agree on the frame. The sound choice and the volume are the page's, kept
|
|
4
|
+
// in the browser
|
|
5
|
+
import { makeAutoObservable, runInAction } from 'mobx'
|
|
6
|
+
|
|
7
|
+
/** the part of a video element the clock drives */
|
|
8
|
+
interface ClipEl {
|
|
9
|
+
currentTime: number
|
|
10
|
+
muted: boolean
|
|
11
|
+
volume: number
|
|
12
|
+
playbackRate: number
|
|
13
|
+
readonly paused: boolean
|
|
14
|
+
play(): Promise<void> | void
|
|
15
|
+
pause(): void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** a follower further than this from the first video, in seconds, is put back on it */
|
|
19
|
+
const DRIFT = 0.06
|
|
20
|
+
const KEY = 'shipkit.review.sound'
|
|
21
|
+
|
|
22
|
+
/** the page's one sound choice and volume, kept for the next visit */
|
|
23
|
+
class Heard {
|
|
24
|
+
on = false
|
|
25
|
+
level = 0.8
|
|
26
|
+
constructor() {
|
|
27
|
+
try {
|
|
28
|
+
const v = JSON.parse(localStorage.getItem(KEY) ?? '{}') as { on?: boolean; level?: number }
|
|
29
|
+
if (typeof v.on === 'boolean') this.on = v.on
|
|
30
|
+
if (typeof v.level === 'number' && v.level > 0 && v.level <= 1) this.level = v.level
|
|
31
|
+
} catch {
|
|
32
|
+
// a blocked storage only loses the kept choice
|
|
33
|
+
}
|
|
34
|
+
makeAutoObservable(this)
|
|
35
|
+
}
|
|
36
|
+
private save(): void {
|
|
37
|
+
try {
|
|
38
|
+
localStorage.setItem(KEY, JSON.stringify({ on: this.on, level: this.level }))
|
|
39
|
+
} catch {
|
|
40
|
+
// as above
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
setOn(on: boolean): void {
|
|
44
|
+
this.on = on
|
|
45
|
+
this.save()
|
|
46
|
+
}
|
|
47
|
+
set(level: number): void {
|
|
48
|
+
this.level = Math.max(0, Math.min(1, level))
|
|
49
|
+
this.save()
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const heard = new Heard()
|
|
54
|
+
|
|
55
|
+
export class ClipSt {
|
|
56
|
+
/** which clip is on screen, empty when none */
|
|
57
|
+
key = ''
|
|
58
|
+
/** the clip's length in seconds, its frames a second */
|
|
59
|
+
duration = 0
|
|
60
|
+
fps = 20
|
|
61
|
+
paused = false
|
|
62
|
+
/** the audio track on: only the first video is heard */
|
|
63
|
+
sound: boolean
|
|
64
|
+
/** the time on screen, in seconds: exact while paused, the first video's while it plays */
|
|
65
|
+
time = 0
|
|
66
|
+
/** a play the browser refused, shown under the clip */
|
|
67
|
+
error: string | null = null
|
|
68
|
+
/** the playback speed, 1 the clip's own */
|
|
69
|
+
rate = 1
|
|
70
|
+
private els: ClipEl[] = []
|
|
71
|
+
private raf = 0
|
|
72
|
+
|
|
73
|
+
constructor() {
|
|
74
|
+
this.sound = heard.on
|
|
75
|
+
makeAutoObservable<this, 'els' | 'raf' | 'follow'>(this, { els: false, raf: false, follow: false })
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
get volume(): number {
|
|
79
|
+
return heard.level
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** another clip on screen: from the start, playing; the same one again changes nothing */
|
|
83
|
+
load(key: string, duration: number, fps: number): void {
|
|
84
|
+
if (key === this.key) return
|
|
85
|
+
this.key = key
|
|
86
|
+
this.duration = duration
|
|
87
|
+
this.fps = fps > 0 ? fps : 20
|
|
88
|
+
this.time = 0
|
|
89
|
+
this.paused = false
|
|
90
|
+
this.error = null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** the frame index of a time */
|
|
94
|
+
frameOf(t: number): number {
|
|
95
|
+
const last = Math.max(0, Math.round(this.duration * this.fps) - 1)
|
|
96
|
+
return Math.max(0, Math.min(last, Math.floor(t * this.fps + 1e-6)))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** the middle of frame `k`, where a seek lands on that frame for sure */
|
|
100
|
+
private centerOf(k: number): number {
|
|
101
|
+
return (k + 0.5) / this.fps
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** a video of the view on screen; it returns its release */
|
|
105
|
+
attach(el: ClipEl): () => void {
|
|
106
|
+
this.els.push(el)
|
|
107
|
+
el.muted = !this.sound || this.els[0] !== el
|
|
108
|
+
el.volume = heard.level
|
|
109
|
+
el.playbackRate = this.rate
|
|
110
|
+
el.currentTime = this.paused ? this.time : (this.els[0]?.currentTime ?? this.time)
|
|
111
|
+
if (this.paused) el.pause()
|
|
112
|
+
else this.start(el)
|
|
113
|
+
if (this.raf === 0 && typeof requestAnimationFrame === 'function') this.raf = requestAnimationFrame(this.follow)
|
|
114
|
+
return () => {
|
|
115
|
+
this.els = this.els.filter((e) => e !== el)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
toggleSound(): void {
|
|
120
|
+
this.hear(!this.sound)
|
|
121
|
+
heard.setOn(this.sound)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** this clip's sound on or off, the kept choice left as it is */
|
|
125
|
+
hear(on: boolean): void {
|
|
126
|
+
this.sound = on
|
|
127
|
+
this.els.forEach((el, i) => (el.muted = !this.sound || i > 0))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** a volume asked is a sound wanted, and none is the sound off */
|
|
131
|
+
setVolume(v: number): void {
|
|
132
|
+
if (v > 0) heard.set(v)
|
|
133
|
+
for (const el of this.els) el.volume = heard.level
|
|
134
|
+
if (this.sound !== v > 0) this.toggleSound()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
toggle(): void {
|
|
138
|
+
if (this.paused) this.play()
|
|
139
|
+
else this.pause()
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
play(): void {
|
|
143
|
+
this.paused = false
|
|
144
|
+
this.error = null
|
|
145
|
+
for (const el of this.els) this.start(el)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** stops on the frame the first video shows */
|
|
149
|
+
pause(): void {
|
|
150
|
+
this.seek(this.els[0]?.currentTime ?? this.time)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** paused on the frame at `t` */
|
|
154
|
+
seek(t: number): void {
|
|
155
|
+
this.paused = true
|
|
156
|
+
this.time = this.centerOf(this.frameOf(t))
|
|
157
|
+
for (const el of this.els) {
|
|
158
|
+
el.pause()
|
|
159
|
+
el.currentTime = this.time
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** to `t`, playing on when it played: a jump while it plays never stops it */
|
|
164
|
+
jump(t: number): void {
|
|
165
|
+
if (this.paused) return this.seek(t)
|
|
166
|
+
const to = Math.max(0, Math.min(this.duration, t))
|
|
167
|
+
this.time = to
|
|
168
|
+
for (const el of this.els) el.currentTime = to
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
setRate(r: number): void {
|
|
172
|
+
this.rate = r
|
|
173
|
+
for (const el of this.els) el.playbackRate = r
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** `n` frames on (or back), paused, wrapping round the loop */
|
|
177
|
+
step(n: number): void {
|
|
178
|
+
const count = Math.max(1, Math.round(this.duration * this.fps))
|
|
179
|
+
const from = this.paused ? this.time : (this.els[0]?.currentTime ?? this.time)
|
|
180
|
+
this.seek(this.centerOf((((this.frameOf(from) + n) % count) + count) % count))
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private start(el: ClipEl): void {
|
|
184
|
+
const p = el.play()
|
|
185
|
+
if (p)
|
|
186
|
+
p.catch((e: Error) => {
|
|
187
|
+
// a pause that lands before the play resolves aborts it: that is the pause working
|
|
188
|
+
if (e.name === 'AbortError') return
|
|
189
|
+
runInAction(() => {
|
|
190
|
+
this.paused = true
|
|
191
|
+
this.error = `play refused: ${e.message}`
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** each frame while it plays: the time read from the first video, the others put back on it */
|
|
197
|
+
private follow = (): void => {
|
|
198
|
+
this.raf = 0
|
|
199
|
+
if (this.els.length === 0) return
|
|
200
|
+
const lead = this.els[0]
|
|
201
|
+
if (lead && !this.paused) {
|
|
202
|
+
const t = lead.currentTime
|
|
203
|
+
runInAction(() => (this.time = t))
|
|
204
|
+
for (const el of this.els.slice(1)) if (Math.abs(el.currentTime - t) > DRIFT) el.currentTime = t
|
|
205
|
+
}
|
|
206
|
+
this.raf = requestAnimationFrame(this.follow)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
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, and « live », the kind's url in a frame, off until he opens it;
|
|
3
|
+
// and a pair's animation to download. The page never runs the repo's code
|
|
4
|
+
import { makeAutoObservable, runInAction } from 'mobx'
|
|
5
|
+
import { observer } from 'mobx-react-lite'
|
|
6
|
+
import { useEffect } from 'react'
|
|
7
|
+
import { isGallery, isPair, viewName, type ReviewItem } from '../../../review/model.ts'
|
|
8
|
+
import type { Fact, Link, ViewText } from '../../../review/unit.ts'
|
|
9
|
+
|
|
10
|
+
/** where an item's files and routes answer under the repo's base: the review page's own url maker */
|
|
11
|
+
interface Urls {
|
|
12
|
+
fileUrl(id: string, name: string): string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface KindData {
|
|
16
|
+
facts: Fact[]
|
|
17
|
+
links: Link[]
|
|
18
|
+
live: string | null
|
|
19
|
+
text: ViewText | null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** each item's data view, read once an item opens and again when its thread moves; « live » stays as he left it */
|
|
23
|
+
export class KindViewSt {
|
|
24
|
+
views = new Map<string, KindData | { error: string }>()
|
|
25
|
+
live = false
|
|
26
|
+
anim = { view: 0, mode: 'fade', format: 'mp4', labels: 'en' }
|
|
27
|
+
|
|
28
|
+
constructor(private readonly st: Urls) {
|
|
29
|
+
try {
|
|
30
|
+
this.live = localStorage.getItem('rv.kindLive') === '1'
|
|
31
|
+
const a = JSON.parse(localStorage.getItem('rv.anim') ?? 'null') as Partial<KindViewSt['anim']> | null
|
|
32
|
+
if (a) this.anim = { ...this.anim, ...a, view: 0 }
|
|
33
|
+
} catch {
|
|
34
|
+
// a private window: the defaults
|
|
35
|
+
}
|
|
36
|
+
makeAutoObservable<this, 'st'>(this, { st: false })
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** the view of item `x`, read when its stamp (its last message) moved */
|
|
40
|
+
read(x: ReviewItem): void {
|
|
41
|
+
const key = `${x.item.id}@${x.thread.at(-1)?.id ?? ''}`
|
|
42
|
+
if (this.views.has(key)) return
|
|
43
|
+
this.views.set(key, { facts: [], links: [], live: null, text: null })
|
|
44
|
+
void fetch(this.st.fileUrl(x.item.id, 'view'))
|
|
45
|
+
.then(async (r) => (r.ok ? ((await r.json()) as KindData) : { error: ((await r.json().catch(() => ({}))) as { error?: string }).error ?? `HTTP ${r.status}` }))
|
|
46
|
+
.catch((e: Error) => ({ error: e.message }))
|
|
47
|
+
.then((v) => runInAction(() => this.views.set(key, v)))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
of(x: ReviewItem): KindData | { error: string } | undefined {
|
|
51
|
+
return this.views.get(`${x.item.id}@${x.thread.at(-1)?.id ?? ''}`)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
toggleLive(): void {
|
|
55
|
+
this.live = !this.live
|
|
56
|
+
try {
|
|
57
|
+
localStorage.setItem('rv.kindLive', this.live ? '1' : '0')
|
|
58
|
+
} catch {
|
|
59
|
+
// kept for this page only
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
setAnim(k: 'view' | 'mode' | 'format' | 'labels', v: string): void {
|
|
64
|
+
this.anim = { ...this.anim, [k]: k === 'view' ? Number(v) : v }
|
|
65
|
+
try {
|
|
66
|
+
localStorage.setItem('rv.anim', JSON.stringify(this.anim))
|
|
67
|
+
} catch {
|
|
68
|
+
// kept for this page only
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
animUrl(x: ReviewItem, view: number): string {
|
|
73
|
+
const a = this.anim
|
|
74
|
+
return `${this.st.fileUrl(x.item.id, 'anim')}?view=${view}&mode=${a.mode}&format=${a.format}&labels=${a.labels}`
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const held = new WeakMap<Urls, KindViewSt>()
|
|
79
|
+
const kindViewOf = (st: Urls): KindViewSt => {
|
|
80
|
+
let k = held.get(st)
|
|
81
|
+
if (!k) held.set(st, (k = new KindViewSt(st)))
|
|
82
|
+
return k
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** a line diff of two texts: each line kept, gone or new */
|
|
86
|
+
function diffLines(before: string, after: string): { t: ' ' | '-' | '+'; line: string }[] {
|
|
87
|
+
const a = before.split('\n')
|
|
88
|
+
const b = after.split('\n')
|
|
89
|
+
const n = a.length
|
|
90
|
+
const m = b.length
|
|
91
|
+
const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0))
|
|
92
|
+
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]!)
|
|
93
|
+
const out: { t: ' ' | '-' | '+'; line: string }[] = []
|
|
94
|
+
let i = 0
|
|
95
|
+
let j = 0
|
|
96
|
+
while (i < n && j < m) {
|
|
97
|
+
if (a[i] === b[j]) out.push({ t: ' ', line: a[i++]! }), j++
|
|
98
|
+
else if (lcs[i + 1]![j]! >= lcs[i]![j + 1]!) out.push({ t: '-', line: a[i++]! })
|
|
99
|
+
else out.push({ t: '+', line: b[j++]! })
|
|
100
|
+
}
|
|
101
|
+
while (i < n) out.push({ t: '-', line: a[i++]! })
|
|
102
|
+
while (j < m) out.push({ t: '+', line: b[j++]! })
|
|
103
|
+
return out
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const TONE = { ' ': 'var(--dim)', '-': 'var(--red, #e5484d)', '+': 'var(--green, #46a758)' } as const
|
|
107
|
+
|
|
108
|
+
/** the kind's data view of the open item, under its facts */
|
|
109
|
+
export const KindView = observer(({ app, x }: { app: Urls; x: ReviewItem }) => {
|
|
110
|
+
const k = kindViewOf(app)
|
|
111
|
+
const last = x.thread.at(-1)?.id
|
|
112
|
+
useEffect(() => k.read(x), [k, x, last])
|
|
113
|
+
const v = k.of(x)
|
|
114
|
+
if (v === undefined) return null
|
|
115
|
+
if ('error' in v) return <div className="rv-err rv-pre">the repo's view of this item: {v.error}</div>
|
|
116
|
+
const t = v.text
|
|
117
|
+
return (
|
|
118
|
+
<div className="rv-kindview">
|
|
119
|
+
{v.facts.length > 0 ? (
|
|
120
|
+
<div className="rv-facts">
|
|
121
|
+
{v.facts.map((f, i) => (
|
|
122
|
+
<div key={i} className="rv-fact"><span className="rv-factl">{f.label}</span><span className="rv-factv">{f.value}</span></div>
|
|
123
|
+
))}
|
|
124
|
+
</div>
|
|
125
|
+
) : null}
|
|
126
|
+
{v.links.length > 0 ? (
|
|
127
|
+
<div className="rv-fact">
|
|
128
|
+
{v.links.map((l) => <a key={l.url} href={l.url} target="_blank" rel="noreferrer">{l.label}</a>)}
|
|
129
|
+
</div>
|
|
130
|
+
) : null}
|
|
131
|
+
{t !== null && 'text' in t ? <pre className="rv-pre rv-kindtext">{t.text}</pre> : null}
|
|
132
|
+
{t !== null && 'before' in t ? (
|
|
133
|
+
<pre className="rv-kindtext">
|
|
134
|
+
{diffLines(t.before, t.after).map((d, i) => (
|
|
135
|
+
<div key={i} style={{ color: TONE[d.t] }}>{d.t} {d.line}</div>
|
|
136
|
+
))}
|
|
137
|
+
</pre>
|
|
138
|
+
) : null}
|
|
139
|
+
{v.live !== null ? (
|
|
140
|
+
<div>
|
|
141
|
+
<button className="ghost" onClick={() => k.toggleLive()}>{k.live ? 'hide live' : 'live'}</button>
|
|
142
|
+
{k.live ? <iframe title="live" src={v.live} style={{ width: '100%', aspectRatio: '16 / 10', border: '1px solid var(--line)', borderRadius: 3, marginTop: 6 }} /> : null}
|
|
143
|
+
</div>
|
|
144
|
+
) : null}
|
|
145
|
+
</div>
|
|
146
|
+
)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
/** a pair of images as a looping animation to download: the mode, the format, the corner labels */
|
|
150
|
+
export const AnimPanel = observer(({ app, x }: { app: Urls; x: ReviewItem }) => {
|
|
151
|
+
const pairs = x.item.views.flatMap((v, i) => (!isGallery(v) && isPair(v.files) && !v.clip && !v.audio ? [{ i, name: viewName(v, i) }] : []))
|
|
152
|
+
if (pairs.length === 0) return null
|
|
153
|
+
const k = kindViewOf(app)
|
|
154
|
+
const a = k.anim
|
|
155
|
+
const view = pairs.some((p) => p.i === a.view) ? a.view : (pairs[0]?.i ?? 0)
|
|
156
|
+
const pick = (key: 'view' | 'mode' | 'format' | 'labels', options: [string, string][], value: string) => (
|
|
157
|
+
<select value={value} onChange={(e) => k.setAnim(key, e.currentTarget.value)}>
|
|
158
|
+
{options.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
|
159
|
+
</select>
|
|
160
|
+
)
|
|
161
|
+
return (
|
|
162
|
+
<div className="rv-fact rv-anim" style={{ flexWrap: 'wrap' }}>
|
|
163
|
+
<span className="rv-factl">animation</span>
|
|
164
|
+
{pairs.length > 1 ? pick('view', pairs.map((p) => [String(p.i), p.name]), String(view)) : null}
|
|
165
|
+
{pick('mode', [['cut', 'cut'], ['fade', 'fade'], ['wipe', 'wipe']], a.mode)}
|
|
166
|
+
{pick('format', [['mp4', 'MP4'], ['gif', 'GIF']], a.format)}
|
|
167
|
+
{pick('labels', [['en', 'EN'], ['fr', 'FR'], ['none', 'no labels']], a.labels)}
|
|
168
|
+
<a href={k.animUrl(x, view)} download>download</a>
|
|
169
|
+
</div>
|
|
170
|
+
)
|
|
171
|
+
})
|