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
package/src/repo-config.ts
CHANGED
|
@@ -57,6 +57,9 @@ export type ServeConfig = {
|
|
|
57
57
|
* one failed, green when all passed. Default: the built-in md lint
|
|
58
58
|
* (orphans and dangling refs) over the repo root */
|
|
59
59
|
lint?: string[]
|
|
60
|
+
/** the folder its node ids are relative to, where the wikis page reads
|
|
61
|
+
* a page to search and preview (default: the repo root) */
|
|
62
|
+
dir?: string
|
|
60
63
|
}
|
|
61
64
|
/** agent/ graph entrypoint (default: index.md AND README.md) */
|
|
62
65
|
agentIndex?: string
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// a card's before and after as a looping animation to send (wiki build/shipkit/review.md, Verbs on the store): the one
|
|
2
|
+
// function behind the hub's « animation » and `sk review anim`. One cycle is drawn by ffmpeg's own filters (no per pixel
|
|
3
|
+
// expression: a card renders in a few seconds), an mp4 then repeats it with no second encode
|
|
4
|
+
import { createCanvas, GlobalFonts } from '@napi-rs/canvas'
|
|
5
|
+
import { existsSync, mkdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
6
|
+
import { basename, join, resolve } from 'node:path'
|
|
7
|
+
import { ffmpeg } from '../trailer/ffmpeg.ts'
|
|
8
|
+
import { sizeOf } from './media.ts'
|
|
9
|
+
import { isGallery, isPair } from './model.ts'
|
|
10
|
+
import { imageFile, readItem } from './store.ts'
|
|
11
|
+
|
|
12
|
+
export const ANIM_MODES = ['cut', 'fade', 'wipe'] as const
|
|
13
|
+
export type AnimMode = (typeof ANIM_MODES)[number]
|
|
14
|
+
export const ANIM_FORMATS = ['mp4', 'gif'] as const
|
|
15
|
+
export type AnimFormat = (typeof ANIM_FORMATS)[number]
|
|
16
|
+
/** the corner labels' language, or none */
|
|
17
|
+
export const ANIM_LABELS = ['en', 'fr', 'none'] as const
|
|
18
|
+
export type AnimLabels = (typeof ANIM_LABELS)[number]
|
|
19
|
+
const LABEL_WORDS: Record<Exclude<AnimLabels, 'none'>, [string, string]> = { en: ['before', 'after'], fr: ['avant', 'après'] }
|
|
20
|
+
|
|
21
|
+
export interface AnimAsk {
|
|
22
|
+
/** the view's index on the card, 0 the first */
|
|
23
|
+
view: number
|
|
24
|
+
mode: AnimMode
|
|
25
|
+
format: AnimFormat
|
|
26
|
+
labels: AnimLabels
|
|
27
|
+
/** a font file the labels are drawn in; the system sans serif without one */
|
|
28
|
+
font?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface Anim {
|
|
32
|
+
file: string
|
|
33
|
+
/** the name it is saved under: the card's title in ascii, the view past the first, the mode */
|
|
34
|
+
name: string
|
|
35
|
+
/** 0 when the cached file served */
|
|
36
|
+
ms: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** bump when a mode's look changes: every cached animation draws again */
|
|
40
|
+
const LOOK = 1
|
|
41
|
+
|
|
42
|
+
/** seconds: a hold on one image, the cross fade, the wipe */
|
|
43
|
+
const HOLD = 1.2
|
|
44
|
+
const FADE = 0.5
|
|
45
|
+
const WIPE_HOLD = 1.0
|
|
46
|
+
const WIPE = 1.2
|
|
47
|
+
const LOOPS = 3
|
|
48
|
+
const MP4 = { fps: 30, maxW: 1920, crf: 20 }
|
|
49
|
+
/** a gif stays a few MB: at most this wide, fewer frames (20 a second: a whole 5 cs each) */
|
|
50
|
+
const GIF = { fps: 20, maxW: 960 }
|
|
51
|
+
|
|
52
|
+
/** the cycle's length in seconds */
|
|
53
|
+
const cycleOf = (mode: AnimMode): number => (mode === 'cut' ? 2 * HOLD : mode === 'fade' ? 2 * (HOLD + FADE) : 2 * (WIPE_HOLD + WIPE))
|
|
54
|
+
|
|
55
|
+
export const isAnimMode = (s: unknown): s is AnimMode => ANIM_MODES.includes(s as AnimMode)
|
|
56
|
+
export const isAnimFormat = (s: unknown): s is AnimFormat => ANIM_FORMATS.includes(s as AnimFormat)
|
|
57
|
+
export const isAnimLabels = (s: unknown): s is AnimLabels => ANIM_LABELS.includes(s as AnimLabels)
|
|
58
|
+
|
|
59
|
+
/** the title as a file name: ascii, lowercase, words joined by a hyphen */
|
|
60
|
+
export const asciiName = (s: string, max = 60): string =>
|
|
61
|
+
s
|
|
62
|
+
.normalize('NFKD')
|
|
63
|
+
.replace(/[^\x20-\x7e]/g, '')
|
|
64
|
+
.toLowerCase()
|
|
65
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
66
|
+
.slice(0, max)
|
|
67
|
+
.replace(/^-+|-+$/g, '') || 'before-after'
|
|
68
|
+
|
|
69
|
+
const fonts = new Set<string>()
|
|
70
|
+
/** one corner label, cream on a soft dark pill, as a png of its own size */
|
|
71
|
+
function labelPng(text: string, px: number, out: string, font: string | undefined): void {
|
|
72
|
+
let face = 'sans-serif'
|
|
73
|
+
if (font) {
|
|
74
|
+
if (!fonts.has(font)) {
|
|
75
|
+
if (!existsSync(font)) throw new Error(`anim.font ${font}: no such file`)
|
|
76
|
+
GlobalFonts.registerFromPath(font, 'ReviewAnim')
|
|
77
|
+
fonts.add(font)
|
|
78
|
+
}
|
|
79
|
+
face = 'ReviewAnim'
|
|
80
|
+
}
|
|
81
|
+
const probe = createCanvas(4, 4).getContext('2d')
|
|
82
|
+
probe.font = `800 ${px}px ${face}`
|
|
83
|
+
const padX = Math.round(px * 0.7)
|
|
84
|
+
const padY = Math.round(px * 0.32)
|
|
85
|
+
const w = Math.ceil(probe.measureText(text).width) + 2 * padX
|
|
86
|
+
const h = Math.round(px * 1.25) + 2 * padY
|
|
87
|
+
const c = createCanvas(w, h)
|
|
88
|
+
const g = c.getContext('2d')
|
|
89
|
+
g.fillStyle = 'rgba(42, 27, 15, 0.62)'
|
|
90
|
+
g.beginPath()
|
|
91
|
+
g.roundRect(0, 0, w, h, h / 2)
|
|
92
|
+
g.fill()
|
|
93
|
+
g.font = `800 ${px}px ${face}`
|
|
94
|
+
g.fillStyle = '#FCEFD4'
|
|
95
|
+
g.textAlign = 'center'
|
|
96
|
+
g.textBaseline = 'middle'
|
|
97
|
+
g.fillText(text, w / 2, h / 2 + px * 0.04)
|
|
98
|
+
writeFileSync(out, c.toBuffer('image/png'))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** the share of the after shown at time `t` of a wipe, as an ffmpeg expression: hold, an eased wipe, hold, back */
|
|
102
|
+
function shownExpr(): string {
|
|
103
|
+
const u = `mod(t,${cycleOf('wipe')})`
|
|
104
|
+
const ease = (v: string): string => `(${v})*(${v})*(3-2*(${v}))`
|
|
105
|
+
const a = WIPE_HOLD
|
|
106
|
+
const b = a + WIPE
|
|
107
|
+
const c = b + WIPE_HOLD
|
|
108
|
+
return `if(lt(${u},${a}),0,if(lt(${u},${b}),${ease(`(${u}-${a})/${WIPE}`)},if(lt(${u},${c}),1,1-${ease(`(${u}-${c})/${WIPE}`)})))`
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** the filter graph of one cycle: [0] before and [1] after scaled, labelled ([2] and [3]), then the mode, out [v]; `pix`
|
|
112
|
+
* the frames' format through the mode: the mp4's own yuv420p (half the work of rgb at 1080p), rgb for a gif's palette */
|
|
113
|
+
function graph(mode: AnimMode, w: number, h: number, fps: number, labels: boolean, pix: 'yuv420p' | 'gbrp'): string {
|
|
114
|
+
const m = Math.round(w * 0.018)
|
|
115
|
+
const prep = (i: number, label: number, at: string, out: string): string =>
|
|
116
|
+
`[${i}:v]scale=${w}:${h}:flags=lanczos,setsar=1,format=rgba${labels ? `[p${i}];[p${i}][${label}:v]overlay=${at}` : ''},format=${pix}[${out}]`
|
|
117
|
+
// a wipe's after comes in from the left, so its label takes the top left and the before's the bottom right
|
|
118
|
+
const [early, late] = [`${m}:${m}`, `W-w-${m}:H-h-${m}`]
|
|
119
|
+
const parts = mode === 'wipe' ? [prep(0, 2, late, 'b'), prep(1, 3, early, 'a')] : [prep(0, 2, early, 'b'), prep(1, 3, late, 'a')]
|
|
120
|
+
const cut = (from: string, d: number, out: string): string => `[${from}]trim=duration=${d},setpts=PTS-STARTPTS[${out}]`
|
|
121
|
+
if (mode === 'cut') {
|
|
122
|
+
parts.push(cut('b', HOLD, 'x0'), cut('a', HOLD, 'x1'), '[x0][x1]concat=n=2:v=1:a=0[v]')
|
|
123
|
+
} else if (mode === 'fade') {
|
|
124
|
+
parts.push(
|
|
125
|
+
'[b]split[b1][b2]',
|
|
126
|
+
cut('b1', HOLD + FADE, 'x0'),
|
|
127
|
+
cut('a', 2 * FADE + HOLD, 'x1'),
|
|
128
|
+
cut('b2', FADE, 'x2'),
|
|
129
|
+
`[x0][x1]xfade=transition=fade:duration=${FADE}:offset=${HOLD}[y]`,
|
|
130
|
+
`[y][x2]xfade=transition=fade:duration=${FADE}:offset=${2 * HOLD + FADE}[v]`,
|
|
131
|
+
)
|
|
132
|
+
} else {
|
|
133
|
+
// the before keeps the right of the seam: a mask black then white, twice as wide, slid by a crop
|
|
134
|
+
const d = cycleOf('wipe')
|
|
135
|
+
const shown = shownExpr()
|
|
136
|
+
const seam = `${w}*${shown}`
|
|
137
|
+
const line = Math.max(2, Math.round(w / 640))
|
|
138
|
+
const glow = line + 4
|
|
139
|
+
// the line only while the seam moves: off screen on a hold
|
|
140
|
+
const at = (lw: number): string => `if(gt(${shown},0)*lt(${shown},1),${seam}-${lw / 2},-100)`
|
|
141
|
+
parts.push(
|
|
142
|
+
`color=c=white:s=${w}x${h}:r=${fps}:d=${d}[mw]`,
|
|
143
|
+
`color=c=black:s=${w}x${h}:r=${fps}:d=${d}[mb]`,
|
|
144
|
+
`[mb][mw]hstack,format=gray,crop=w=${w}:h=${h}:x='${w}-(${seam})':y=0[mask]`,
|
|
145
|
+
'[b][mask]alphamerge[ba]',
|
|
146
|
+
`[a][ba]overlay=0:0:shortest=1[c0]`,
|
|
147
|
+
`color=c=black@0.28:s=${glow}x${h}:r=${fps}:d=${d},format=rgba[g]`,
|
|
148
|
+
`color=c=0xFFF6DA:s=${line}x${h}:r=${fps}:d=${d},format=rgba[l]`,
|
|
149
|
+
`[c0][g]overlay=x='${at(glow)}':y=0:shortest=1[c1]`,
|
|
150
|
+
`[c1][l]overlay=x='${at(line)}':y=0:shortest=1[v]`,
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
return parts.join(';')
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const making = new Map<string, Promise<Anim>>()
|
|
157
|
+
|
|
158
|
+
/** the animation of view `ask.view` of card `id`, made once and kept under `<dir>/.anims/` (readAll skips dot names) */
|
|
159
|
+
export function pairAnimation(dir: string, id: string, ask: AnimAsk): Promise<Anim> {
|
|
160
|
+
const x = readItem(dir, id)
|
|
161
|
+
if (!x) return Promise.reject(new Error(`no card ${id}`))
|
|
162
|
+
const v = x.item.views[ask.view]
|
|
163
|
+
if (!v) return Promise.reject(new Error(`card ${id} has no view ${ask.view} (it has ${x.item.views.length})`))
|
|
164
|
+
if (isGallery(v) || !isPair(v.files)) return Promise.reject(new Error(`view ${ask.view} of ${id} is not a before and after`))
|
|
165
|
+
if (v.clip) return Promise.reject(new Error(`view ${ask.view} of ${id} is a pair of clips: no animation`))
|
|
166
|
+
if (v.audio) return Promise.reject(new Error(`view ${ask.view} of ${id} is a pair of sounds: no animation`))
|
|
167
|
+
const before = imageFile(dir, id, v.files.before)
|
|
168
|
+
const after = imageFile(dir, id, v.files.after)
|
|
169
|
+
if (!before || !after) return Promise.reject(new Error(`view ${ask.view} of ${id}: an image is missing`))
|
|
170
|
+
const viewPart = ask.view > 0 ? `-${asciiName(v.label || `view-${ask.view + 1}`, 30)}` : ''
|
|
171
|
+
const name = `${asciiName(x.item.title)}${viewPart}-${ask.mode}.${ask.format}`
|
|
172
|
+
const out = join(dir, '.anims', id, `L${LOOK}-v${ask.view}-${ask.mode}-${ask.labels}.${ask.format}`)
|
|
173
|
+
const newest = Math.max(statSync(before).mtimeMs, statSync(after).mtimeMs)
|
|
174
|
+
if (existsSync(out) && statSync(out).mtimeMs >= newest) return Promise.resolve({ file: out, name, ms: 0 })
|
|
175
|
+
const pending = making.get(out)
|
|
176
|
+
if (pending) return pending
|
|
177
|
+
const job = render(before, after, out, v.size, ask)
|
|
178
|
+
.then((ms) => ({ file: out, name, ms }))
|
|
179
|
+
.finally(() => making.delete(out))
|
|
180
|
+
making.set(out, job)
|
|
181
|
+
return job
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** the same animation from two images of one size, no card: drawn each time under `scratch`, named after the after */
|
|
185
|
+
export async function fileAnimation(before: string, after: string, ask: AnimAsk, scratch: string): Promise<Anim> {
|
|
186
|
+
const [b, a] = await Promise.all([sizeOf(before), sizeOf(after)])
|
|
187
|
+
if (b.w !== a.w || b.h !== a.h) throw new Error(`before ${b.w}x${b.h} and after ${a.w}x${a.h} differ in size`)
|
|
188
|
+
const name = `${asciiName(basename(after).replace(/\.[a-z]+$/i, ''))}-${ask.mode}.${ask.format}`
|
|
189
|
+
mkdirSync(scratch, { recursive: true })
|
|
190
|
+
const out = join(scratch, `${process.pid}-${name}`)
|
|
191
|
+
const ms = await render(resolve(before), resolve(after), out, a, ask)
|
|
192
|
+
return { file: out, name, ms }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** draws one animation into `out`; its time in ms */
|
|
196
|
+
async function render(before: string, after: string, out: string, size: { w: number; h: number }, ask: AnimAsk): Promise<number> {
|
|
197
|
+
const t0 = performance.now()
|
|
198
|
+
const spec = ask.format === 'mp4' ? MP4 : GIF
|
|
199
|
+
const w = Math.min(spec.maxW, size.w) & ~1
|
|
200
|
+
const h = Math.round((size.h * w) / size.w / 2) * 2
|
|
201
|
+
const work = `${out}.${process.pid}.tmp`
|
|
202
|
+
mkdirSync(work, { recursive: true })
|
|
203
|
+
try {
|
|
204
|
+
const d = cycleOf(ask.mode)
|
|
205
|
+
const still = (f: string): string[] => ['-loop', '1', '-framerate', String(spec.fps), '-t', String(d), '-i', f]
|
|
206
|
+
const inputs = [...still(before), ...still(after)]
|
|
207
|
+
if (ask.labels !== 'none') {
|
|
208
|
+
const px = Math.max(14, Math.round(w * 0.019))
|
|
209
|
+
const [was, now] = LABEL_WORDS[ask.labels]
|
|
210
|
+
labelPng(was, px, join(work, 'before.png'), ask.font)
|
|
211
|
+
labelPng(now, px, join(work, 'after.png'), ask.font)
|
|
212
|
+
inputs.push('-i', join(work, 'before.png'), '-i', join(work, 'after.png'))
|
|
213
|
+
}
|
|
214
|
+
const g = graph(ask.mode, w, h, spec.fps, ask.labels !== 'none', ask.format === 'mp4' ? 'yuv420p' : 'gbrp')
|
|
215
|
+
const done = join(work, `out.${ask.format}`)
|
|
216
|
+
if (ask.format === 'gif') {
|
|
217
|
+
await ffmpeg([...inputs, '-filter_complex', `${g};[v]split[s0][s1];[s0]palettegen=max_colors=256:stats_mode=full[pal];[s1][pal]paletteuse=dither=bayer:bayer_scale=4[o]`, '-map', '[o]', '-t', String(d), '-r', String(spec.fps), '-loop', '0', done])
|
|
218
|
+
} else {
|
|
219
|
+
const one = join(work, 'one.mp4')
|
|
220
|
+
await ffmpeg([...inputs, '-filter_complex', `${g};[v]format=yuv420p[o]`, '-map', '[o]', '-t', String(d), '-r', String(spec.fps), '-an', '-c:v', 'libx264', '-crf', String(MP4.crf), '-preset', 'veryfast', '-pix_fmt', 'yuv420p', one])
|
|
221
|
+
await ffmpeg(['-stream_loop', String(LOOPS - 1), '-i', one, '-c', 'copy', '-movflags', '+faststart', done])
|
|
222
|
+
}
|
|
223
|
+
renameSync(done, out)
|
|
224
|
+
return Math.round(performance.now() - t0)
|
|
225
|
+
} finally {
|
|
226
|
+
rmSync(work, { recursive: true, force: true })
|
|
227
|
+
}
|
|
228
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// auto rules (wiki build/shipkit/review.md, A project's parts): a rule of the repo's review unit answers on a clock, and
|
|
2
|
+
// its answer is raised as an item to a lane, open from the start. One open item a rule and key. The hub's daemon runs
|
|
3
|
+
// them (startAuto), `sk review auto` reads their logs and runs one now
|
|
4
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { stateOf, type ReviewItem } from './model.ts'
|
|
7
|
+
import { raiseItem } from './store.ts'
|
|
8
|
+
import { followedIds } from './threads.ts'
|
|
9
|
+
import type { AutoRule, Raise } from './unit.ts'
|
|
10
|
+
|
|
11
|
+
/** a `when` slower than this prints a loud line: it runs inside the daemon */
|
|
12
|
+
export const AUTO_BUDGET_MS = 50
|
|
13
|
+
|
|
14
|
+
/** what one raise came to: a new item, held by the open item of its rule and key, or a dry run's */
|
|
15
|
+
export type Outcome = { raise: Raise; key: string } & ({ raised: string } | { held: string } | { dry: true })
|
|
16
|
+
|
|
17
|
+
/** one rule's log, `.shipkit/review-auto/<id>.json`: its last run and answer, the last item it raised */
|
|
18
|
+
export interface AutoLog {
|
|
19
|
+
at: string
|
|
20
|
+
ms: number
|
|
21
|
+
answer: string
|
|
22
|
+
error?: string
|
|
23
|
+
lastRaised?: { id: string; at: string }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** the logs sit beside the store, out of its watch */
|
|
27
|
+
export const autoLogDir = (dir: string): string => join(dir, '..', 'review-auto')
|
|
28
|
+
|
|
29
|
+
export function readAutoLog(dir: string, id: string): AutoLog | null {
|
|
30
|
+
try {
|
|
31
|
+
return JSON.parse(readFileSync(join(autoLogDir(dir), `${id}.json`), 'utf8')) as AutoLog
|
|
32
|
+
} catch {
|
|
33
|
+
return null
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function writeAutoLog(dir: string, id: string, log: AutoLog): void {
|
|
38
|
+
const folder = autoLogDir(dir)
|
|
39
|
+
mkdirSync(folder, { recursive: true })
|
|
40
|
+
const file = join(folder, `${id}.json`)
|
|
41
|
+
writeFileSync(`${file}.tmp`, `${JSON.stringify(log, null, 2)}\n`)
|
|
42
|
+
renameSync(`${file}.tmp`, file)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function everyMs(every: string): number {
|
|
46
|
+
const m = /^(\d+(?:\.\d+)?)m$/.exec(every)
|
|
47
|
+
const n = m ? Number(m[1]) : Number.NaN
|
|
48
|
+
if (!(n > 0)) throw new Error(`every: « ${every} » is no period, give minutes like '10m'`)
|
|
49
|
+
return n * 60_000
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** the item of this rule and key that is not closed, the one that holds a new raise */
|
|
53
|
+
function openOf(items: readonly ReviewItem[], rule: string, key: string): ReviewItem | undefined {
|
|
54
|
+
const followed = followedIds(items)
|
|
55
|
+
return items.find((x) => {
|
|
56
|
+
const o = x.item.origin
|
|
57
|
+
return o.by === 'auto' && o.rule === rule && o.key === key && stateOf(x, followed.has(x.item.id)).state !== 'closed'
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const answerText = (outcomes: readonly Outcome[]): string =>
|
|
62
|
+
outcomes.length === 0 ? 'nothing' : outcomes.map((o) => ('raised' in o ? `raised ${o.raised}` : 'held' in o ? `held by ${o.held}` : `would raise « ${o.raise.title} »`)).join('; ')
|
|
63
|
+
|
|
64
|
+
/** one rule evaluated now: its raises written as items, unless an open one holds the key; `dry` writes nothing, the log
|
|
65
|
+
* included. A rule that throws is logged and rethrown */
|
|
66
|
+
export async function runRule(dir: string, rule: AutoRule, o: { items: readonly ReviewItem[]; now?: Date; dry?: boolean }): Promise<Outcome[]> {
|
|
67
|
+
const now = o.now ?? new Date()
|
|
68
|
+
const before = readAutoLog(dir, rule.id)
|
|
69
|
+
const t0 = performance.now()
|
|
70
|
+
let answer: Raise | readonly Raise[] | null
|
|
71
|
+
try {
|
|
72
|
+
answer = rule.when({ items: o.items, now })
|
|
73
|
+
} catch (e) {
|
|
74
|
+
if (!o.dry) writeAutoLog(dir, rule.id, { ...before, at: now.toISOString(), ms: performance.now() - t0, answer: 'threw', error: String(e) })
|
|
75
|
+
throw e
|
|
76
|
+
}
|
|
77
|
+
const ms = performance.now() - t0
|
|
78
|
+
if (ms > AUTO_BUDGET_MS) console.error(`FAIL: review auto: rule ${rule.id} took ${ms.toFixed(0)} ms, past ${AUTO_BUDGET_MS} ms: it runs inside the daemon`)
|
|
79
|
+
const raises: readonly Raise[] = answer === null ? [] : Array.isArray(answer) ? answer : [answer as Raise]
|
|
80
|
+
const outcomes: Outcome[] = []
|
|
81
|
+
const taken = new Map<string, string>()
|
|
82
|
+
for (const raise of raises) {
|
|
83
|
+
const key = raise.key ?? rule.id
|
|
84
|
+
const held = taken.get(key) ?? openOf(o.items, rule.id, key)?.item.id
|
|
85
|
+
if (held !== undefined) outcomes.push({ raise, key, held })
|
|
86
|
+
else if (o.dry) {
|
|
87
|
+
outcomes.push({ raise, key, dry: true })
|
|
88
|
+
taken.set(key, 'this run')
|
|
89
|
+
} else {
|
|
90
|
+
const x = await raiseItem(dir, { subject: raise.subject, origin: { by: 'auto', rule: rule.id, key, lane: raise.lane }, title: raise.title, what: raise.what, now })
|
|
91
|
+
taken.set(key, x.item.id)
|
|
92
|
+
outcomes.push({ raise, key, raised: x.item.id })
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (!o.dry) {
|
|
96
|
+
const raised = outcomes.flatMap((x) => ('raised' in x ? [x.raised] : []))
|
|
97
|
+
const last = raised.at(-1)
|
|
98
|
+
const lastRaised = last !== undefined ? { id: last, at: now.toISOString() } : before?.lastRaised
|
|
99
|
+
writeAutoLog(dir, rule.id, { at: now.toISOString(), ms, answer: answerText(outcomes), ...(lastRaised ? { lastRaised } : {}) })
|
|
100
|
+
}
|
|
101
|
+
return outcomes
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** when a rule runs next: once its last run is `every` old, never before `from` */
|
|
105
|
+
export const nextRun = (rule: AutoRule, log: AutoLog | null, from: number): number => Math.max(from, log ? Date.parse(log.at) + everyMs(rule.every) : from)
|
|
106
|
+
|
|
107
|
+
/** one scheduled run: what it raised printed, a rule that throws printed loud and skipped, never thrown on */
|
|
108
|
+
export async function autoTick(dir: string, rule: AutoRule, items: () => readonly ReviewItem[], say: (l: string) => void = console.log): Promise<void> {
|
|
109
|
+
try {
|
|
110
|
+
for (const o of await runRule(dir, rule, { items: items() })) if ('raised' in o) say(`review auto: ${rule.id} raised ${o.raised} to ${o.raise.lane}: ${o.raise.title}`)
|
|
111
|
+
} catch (e) {
|
|
112
|
+
console.error(`FAIL: review auto: rule ${rule.id} threw, skipped until its next run: ${String(e)}`)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** a schedule of a store's rules: each on its own timer, never holding the process open, the first run once its last is
|
|
117
|
+
* `every` old. `stop` clears every timer (the unit changed, the hub closed) */
|
|
118
|
+
export function startAuto(dir: string, rules: readonly AutoRule[], items: () => readonly ReviewItem[]): { stop(): void } {
|
|
119
|
+
const timers = new Set<ReturnType<typeof setTimeout>>()
|
|
120
|
+
let stopped = false
|
|
121
|
+
// a start pays its boot first: no rule runs in its first seconds
|
|
122
|
+
const from = Date.now() + 5_000
|
|
123
|
+
for (const rule of rules) {
|
|
124
|
+
const tick = (): void =>
|
|
125
|
+
void autoTick(dir, rule, items).then(() => {
|
|
126
|
+
if (stopped) return
|
|
127
|
+
const t = setTimeout(tick, everyMs(rule.every))
|
|
128
|
+
t.unref()
|
|
129
|
+
timers.add(t)
|
|
130
|
+
})
|
|
131
|
+
const t = setTimeout(tick, nextRun(rule, readAutoLog(dir, rule.id), from) - Date.now())
|
|
132
|
+
t.unref()
|
|
133
|
+
timers.add(t)
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
stop(): void {
|
|
137
|
+
stopped = true
|
|
138
|
+
for (const t of timers) clearTimeout(t)
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// what `sk review inbox`, `show`, `watch`, `next` and `board` print of an item and its answer (wiki build/shipkit/review.md):
|
|
2
|
+
// one record per thread, rectangles normalized and in pixels, a frame note's cut, shot and time into it, and the lines the
|
|
3
|
+
// repo's unit adds (a kind's facts, a source's), handed in as `more` so this module stays pure
|
|
4
|
+
import { aboutText, isFrame, isGallery, isPair, NO_SPEC, placeText, stateOf, unreadBy, verdictOf, type Message, type Rect, type ReviewItem, type Spec, type Subject } from './model.ts'
|
|
5
|
+
import { answererOf, conversationOf, openingOf, type Thread } from './threads.ts'
|
|
6
|
+
|
|
7
|
+
/** the lines a repo's unit adds under an item's head, and its spec for the words */
|
|
8
|
+
export interface Say {
|
|
9
|
+
spec: Spec
|
|
10
|
+
more: (x: ReviewItem) => string[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const PLAIN: Say = { spec: NO_SPEC, more: () => [] }
|
|
14
|
+
|
|
15
|
+
const flat = (t: string): string => t.replace(/\s+/g, ' ').trim()
|
|
16
|
+
|
|
17
|
+
/** a rectangle's clip time, ` at 1.25 s`; nothing on an image */
|
|
18
|
+
const atText = (t: number | undefined): string => (t === undefined ? '' : ` at ${t.toFixed(2)} s`)
|
|
19
|
+
|
|
20
|
+
/** each reaction to one view or tile: its place by label, its status, its comment */
|
|
21
|
+
const reactionTexts = (x: ReviewItem): string[] =>
|
|
22
|
+
(verdictOf(x)?.reactions ?? []).map((q) => `${placeText(q, x.item.views)}${q.status ? ` ${q.status}` : ''}${q.stars ? ` ★${q.stars}` : ''}${q.comment ? `: ${flat(q.comment)}` : ''}`)
|
|
23
|
+
|
|
24
|
+
/** the view he picked on a choice card, `picked [voice 1]`; null when he picked none */
|
|
25
|
+
const pickText = (x: ReviewItem): string | null => {
|
|
26
|
+
const p = verdictOf(x)?.pick
|
|
27
|
+
return p === undefined ? null : `picked ${placeText({ view: p }, x.item.views)}`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** one line per feedback, for a monitor: the status, the overall comment, each view's reaction, then each rectangle's comment */
|
|
31
|
+
export function line(x: ReviewItem): string {
|
|
32
|
+
const f = verdictOf(x)
|
|
33
|
+
if (!f) return x.item.id
|
|
34
|
+
const views = x.item.views
|
|
35
|
+
const many = views.length > 1
|
|
36
|
+
const rects = f.rects.map((q, i) => `#${i + 1}${many ? ` ${placeText(q, views)}` : ''}${q.side ? ` ${q.side}` : ''}${atText(q.t)} ${flat(q.comment) || '(no comment)'}`)
|
|
37
|
+
const pick = pickText(x)
|
|
38
|
+
const parts = [...(pick ? [pick] : []), ...reactionTexts(x), ...rects]
|
|
39
|
+
return `${x.item.id} ${f.status} "${x.item.title}": ${flat(f.comment) || '(no overall comment)'}${parts.length ? ` | ${parts.join(' | ')}` : ''}`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** a raised item as the owner names it: `live#85` for a player's report, else its id */
|
|
43
|
+
export const refOf = (x: ReviewItem): string => (x.item.origin.by === 'player' ? `${x.item.origin.server}#${x.item.origin.serverId}` : x.item.id)
|
|
44
|
+
|
|
45
|
+
/** one owner reply, for a monitor; on an item a card answers, it reads on the card's thread */
|
|
46
|
+
export const replyLine = (x: ReviewItem, m: Message, card: ReviewItem | null = null): string =>
|
|
47
|
+
`${(card ?? x).item.id} reply "${(card ?? x).item.title}"${card ? ` (on ${refOf(x)})` : ''}: ${flat(m.text ?? '')}${m.image ? ` [image thread/${m.image}]` : ''}`
|
|
48
|
+
|
|
49
|
+
/** what watch prints of an item, each with the key it prints once under: an unseen feedback (again when sent again), each unseen reply */
|
|
50
|
+
export function watchLines(x: ReviewItem, items: readonly ReviewItem[] = []): { key: string; text: string }[] {
|
|
51
|
+
const out: { key: string; text: string }[] = []
|
|
52
|
+
const f = verdictOf(x)
|
|
53
|
+
if (f && !f.seen) out.push({ key: `${x.item.id}@${f.at}`, text: line(x) })
|
|
54
|
+
const card = x.item.origin.by === 'agent' ? null : answererOf(x.item.id, items)
|
|
55
|
+
// the words that raised an item the card answers are what the card answers
|
|
56
|
+
const opening = card ? openingOf(x) : undefined
|
|
57
|
+
for (const m of unreadBy(x, 'agent')) if (m !== opening) out.push({ key: `${x.item.id}#${m.id}`, text: replyLine(x, m, card) })
|
|
58
|
+
return out
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** an auto item waiting on a taker, for its lane's watch: it carries no message to print (what he or a player raised
|
|
62
|
+
* prints through its words, an unread reply) */
|
|
63
|
+
export function raisedLine(x: ReviewItem): { key: string; text: string } | null {
|
|
64
|
+
const o = x.item.origin
|
|
65
|
+
if (o.by !== 'auto' || stateOf(x, false).state !== 'open') return null
|
|
66
|
+
return { key: `raised ${x.item.id}`, text: `${x.item.id} raised "${x.item.title}" (auto rule ${o.rule}, key ${o.key}) to ${o.lane}: take it: sk review next --lane ${o.lane}` }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** an item nobody takes, for a watch and the inbox */
|
|
70
|
+
export const untakenLine = (x: ReviewItem, why: string, lane: string | null): string =>
|
|
71
|
+
`${x.item.id} untaken "${x.item.title}" (${why}): ask the other sessions (ListAgents, then SendMessage) whether one of them wants it; if none answers, claim it: sk review claim ${x.item.id} --as ${lane ?? '<your lane>'}`
|
|
72
|
+
|
|
73
|
+
/** one thread as one record: each item the card answers, then the card, its older versions, and with `full` the whole conversation */
|
|
74
|
+
export function threadDetail(t: Thread, dir: string, full = false, say: Say = PLAIN): string {
|
|
75
|
+
if (t.answers.length === 0 && t.versions.length === 1 && !full) return detail(t.head, dir, false, false, say)
|
|
76
|
+
const out: string[] = []
|
|
77
|
+
for (const r of t.answers) out.push(`answers ${refOf(r)}:`, detail(r, dir, false, true, say), '')
|
|
78
|
+
out.push(detail(t.head, dir, full, false, say))
|
|
79
|
+
const older = t.versions.slice(1)
|
|
80
|
+
if (older.length > 0) out.push(` versions: ${older.map((x) => x.item.id).join(' ')}`)
|
|
81
|
+
if (full) {
|
|
82
|
+
const conv = conversationOf(t)
|
|
83
|
+
if (conv.length > 0) out.push(' thread:')
|
|
84
|
+
for (const m of conv) out.push(` ${m.who} ${m.at}${m.on === t.head.item.id ? '' : ` [on ${m.on}]`}: ${flat(m.text ?? '')}${m.image ? ` [image ${dir}/${m.on}/thread/${m.image}]` : ''}`)
|
|
85
|
+
}
|
|
86
|
+
return out.join('\n')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** `m:ss.cc` */
|
|
90
|
+
const clock = (t: number): string => `${Math.floor(t / 60)}:${(t % 60).toFixed(2).padStart(5, '0')}`
|
|
91
|
+
|
|
92
|
+
/** what the subject says beyond its kind: a frame's cut, shot and time into it */
|
|
93
|
+
function subjectText(s: Subject): string | null {
|
|
94
|
+
if (isFrame(s)) return ` frame: ${s.trailer}/${s.cut} at ${clock(s.time)} (frame ${s.frame}), shot ${s.shot} at ${s.shotTime.toFixed(2)} s${s.section ? `, section ${s.section}` : ''}`
|
|
95
|
+
return null
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const boxText = (b: { x0: number; y0: number; x1: number; y1: number }): string => `${b.x0},${b.y0}..${b.x1},${b.y1}`
|
|
99
|
+
|
|
100
|
+
/** a rectangle's world box and the nodes under its middle, when the tool that drew it knew them */
|
|
101
|
+
const rectPlace = (r: Rect): string => (r.world ? ` world ${boxText(r.world)}${r.nodes?.length ? ` nodes ${r.nodes.join(' < ')}` : ''}` : '')
|
|
102
|
+
|
|
103
|
+
/** a pin or rectangle drawn with the raising words, on the item's image */
|
|
104
|
+
const drawnText = (m: Message): string[] =>
|
|
105
|
+
(m.rects ?? []).map((q, i) =>
|
|
106
|
+
q.w === 0 && q.h === 0
|
|
107
|
+
? ` pin #${i + 1} ${Math.round(q.x * 100)}%,${Math.round(q.y * 100)}%`
|
|
108
|
+
: ` #${i + 1} ${q.x.toFixed(3)},${q.y.toFixed(3)} ${q.w.toFixed(3)}x${q.h.toFixed(3)}${rectPlace(q)}: ${q.comment || '(no comment)'}`,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
const raisedBy = (o: ReviewItem['item']['origin']): string =>
|
|
112
|
+
o.by === 'auto' ? `auto rule ${o.rule} (key ${o.key}) to ${o.lane}` : o.by === 'player' ? `player ${o.player} (${o.server}#${o.serverId})` : o.by === 'owner' ? `owner from ${o.from}${o.lane ? ` to ${o.lane}` : ''}` : o.by
|
|
113
|
+
|
|
114
|
+
/** one item: a raised one by its state and the raising words, a card by the owner's verdict; `followed`: a later card supersedes or answers it */
|
|
115
|
+
export function detail(x: ReviewItem, dir: string, full = false, followed = false, say: Say = PLAIN): string {
|
|
116
|
+
const f = verdictOf(x)
|
|
117
|
+
const r = x.item
|
|
118
|
+
const head = ` "${r.title}" (${aboutText(r, say.spec)})`
|
|
119
|
+
const frame = subjectText(r.subject)
|
|
120
|
+
const sub = [...(frame ? [frame] : []), ...say.more(x)]
|
|
121
|
+
const replies = unreadBy(x, 'agent').map((m) => ` reply ${m.at}: ${m.text}${m.image ? ` [image ${dir}/${r.id}/thread/${m.image}]` : ''}`)
|
|
122
|
+
if (r.origin.by !== 'agent') {
|
|
123
|
+
const st = stateOf(x, followed)
|
|
124
|
+
const said = x.thread.filter((m) => m.who === 'owner' || m.who === 'player')
|
|
125
|
+
const words = full ? x.thread.filter((m) => (m.text !== undefined || m.op !== undefined) && m.op !== 'seen') : said.filter((m) => m.text !== undefined && m.op === undefined)
|
|
126
|
+
const pins = said.filter((m) => m.rects !== undefined).at(-1)
|
|
127
|
+
const first = r.views[0]
|
|
128
|
+
const image = first && 'files' in first ? Object.values(first.files) : []
|
|
129
|
+
return [
|
|
130
|
+
`${r.id} ${st.state.toUpperCase()}${st.why ? ` (${st.why})` : ''} raised by ${raisedBy(r.origin)} ${r.created}${r.commit ? ` at ${r.commit}` : ''}`,
|
|
131
|
+
head,
|
|
132
|
+
...sub,
|
|
133
|
+
...(r.origin.by === 'auto' && r.what ? r.what.split('\n').map((l) => ` ${l}`) : []),
|
|
134
|
+
...(st.topic ? [` topic ${st.topic}${st.worker ? `, claimed by ${st.worker}` : ''}${r.origin.by === 'owner' && r.origin.kind ? `, ${r.origin.kind}` : ''}`] : []),
|
|
135
|
+
...words.map((m) => ` ${m.who} ${m.at}${m.op ? ` ${m.op}${m.topic ? ` ${m.topic}` : ''}` : ''}: ${m.text ?? m.note ?? ''}${m.fix ? ` (${m.fix.commit})` : ''}`),
|
|
136
|
+
...(pins ? drawnText(pins) : []),
|
|
137
|
+
...(image.length > 0 ? [` image: ${image.map((n) => `${dir}/${r.id}/${n}`).join(' ')}`] : []),
|
|
138
|
+
].join('\n')
|
|
139
|
+
}
|
|
140
|
+
if (!f) return [`${r.id} NO VERDICT YET`, head, ...sub, ...replies].join('\n')
|
|
141
|
+
const pick = pickText(x)
|
|
142
|
+
const out = [`${r.id} ${f.status.toUpperCase()} ${f.at}`, head, ...sub, ...(pick ? [` ${pick}`] : []), ` comment: ${f.comment || '(none)'}`]
|
|
143
|
+
for (const t of reactionTexts(x)) out.push(` view ${t}`)
|
|
144
|
+
const px = (v: number, of: number): number => Math.round(v * of)
|
|
145
|
+
const views = r.views
|
|
146
|
+
f.rects.forEach((q, i) => {
|
|
147
|
+
const v = views[q.view ?? 0] ?? views[0]
|
|
148
|
+
const size = v && !isGallery(v) ? v.size : { w: 0, h: 0 }
|
|
149
|
+
const on = q.view ? ` view ${q.view} "${v?.label ?? ''}"` : ''
|
|
150
|
+
const side = v && !isGallery(v) && isPair(v.files) ? ` ${q.side ?? '?'}` : ''
|
|
151
|
+
const norm = `${q.x.toFixed(3)},${q.y.toFixed(3)} ${q.w.toFixed(3)}x${q.h.toFixed(3)}`
|
|
152
|
+
const pix = `${px(q.x, size.w)},${px(q.y, size.h)} ${px(q.w, size.w)}x${px(q.h, size.h)} px`
|
|
153
|
+
out.push(` #${i + 1}${on}${side} ${norm} (${pix})${atText(q.t)}: ${q.comment || '(no comment)'}`)
|
|
154
|
+
})
|
|
155
|
+
const files = views.flatMap((v) => (isGallery(v) ? v.gallery.map((t) => t.file) : Object.values(v.files)))
|
|
156
|
+
out.push(` files: ${files.map((n) => `${dir}/${r.id}/${n}`).join(' ')}`, ...replies)
|
|
157
|
+
return out.join('\n')
|
|
158
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// the hub's http helpers the review and trailer routes share: an error answer, the cache headers, a file with byte ranges
|
|
2
|
+
import { extname } from 'node:path'
|
|
3
|
+
|
|
4
|
+
export const TYPES: Record<string, string> = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.webm': 'video/webm', '.mp4': 'video/mp4' }
|
|
5
|
+
export const NO_STORE = { 'Cache-Control': 'no-store' }
|
|
6
|
+
/** an item's files never change once written, nor a kept render under its version: a phone keeps what it fetched */
|
|
7
|
+
export const KEEP = { 'Cache-Control': 'private, max-age=86400' }
|
|
8
|
+
|
|
9
|
+
export const bad = (e: unknown, status = 400): Response => Response.json({ error: e instanceof Error ? e.message : String(e) }, { status, headers: NO_STORE })
|
|
10
|
+
|
|
11
|
+
/** a file with byte ranges: a player seeks through them */
|
|
12
|
+
export async function ranged(req: Request, path: string, headers: Record<string, string> = KEEP): Promise<Response> {
|
|
13
|
+
const file = Bun.file(path)
|
|
14
|
+
if (!(await file.exists())) return bad(`no ${path.split('/').pop()}`, 404)
|
|
15
|
+
const size = file.size
|
|
16
|
+
const head = { 'Content-Type': TYPES[extname(path).toLowerCase()] ?? 'application/octet-stream', 'Accept-Ranges': 'bytes', ...headers }
|
|
17
|
+
const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.get('range') ?? '')
|
|
18
|
+
if (!range) return new Response(file, { headers: { ...head, 'Content-Length': String(size) } })
|
|
19
|
+
const start = range[1] ? Number(range[1]) : Math.max(0, size - Number(range[2]))
|
|
20
|
+
const end = range[1] && range[2] ? Math.min(Number(range[2]), size - 1) : size - 1
|
|
21
|
+
if (start >= size || start > end) return new Response(null, { status: 416, headers: { 'Content-Range': `bytes */${size}` } })
|
|
22
|
+
return new Response(file.slice(start, end + 1), { status: 206, headers: { ...head, 'Content-Range': `bytes ${start}-${end}/${size}`, 'Content-Length': String(end - start + 1) } })
|
|
23
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// the owner's marks kept in the store (wiki build/shipkit/review.md, Verbs on the store): a mark is a `rate` message of his
|
|
2
|
+
// on a subject (stars, a mark word, a part's stars, a note); the latest is the mark, the older ones its history. Pure
|
|
3
|
+
import { partyOf, subjectKey, type Message, type ReviewItem, type Spec } from './model.ts'
|
|
4
|
+
|
|
5
|
+
/** one subject's mark now: its latest whole rate, each part's latest stars, the note said with it */
|
|
6
|
+
export interface SubjectMark {
|
|
7
|
+
key: string
|
|
8
|
+
kind: string
|
|
9
|
+
rate: { stars?: number; mark?: string }
|
|
10
|
+
parts: Record<string, number>
|
|
11
|
+
text?: string
|
|
12
|
+
at: string
|
|
13
|
+
/** how many marks it was given, the latest included */
|
|
14
|
+
history: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** every subject's mark, the latest first; a mark cleared (no stars, no mark) leaves its subject out */
|
|
18
|
+
export function marksOf(items: readonly ReviewItem[], spec: Spec): SubjectMark[] {
|
|
19
|
+
const rates: { key: string; kind: string; m: Message }[] = []
|
|
20
|
+
for (const x of items) {
|
|
21
|
+
const key = subjectKey(x.item.subject, spec)
|
|
22
|
+
if (key === null) continue
|
|
23
|
+
for (const m of x.thread) if (m.rate !== undefined && partyOf(m.who) === 'owner') rates.push({ key, kind: x.item.subject.kind, m })
|
|
24
|
+
}
|
|
25
|
+
rates.sort((a, b) => a.m.at.localeCompare(b.m.at) || a.m.id.localeCompare(b.m.id))
|
|
26
|
+
const out = new Map<string, SubjectMark>()
|
|
27
|
+
for (const { key, kind, m } of rates) {
|
|
28
|
+
const r = m.rate ?? {}
|
|
29
|
+
const e = out.get(key) ?? { key, kind, rate: {}, parts: {}, at: m.at, history: 0 }
|
|
30
|
+
e.history++
|
|
31
|
+
e.at = m.at
|
|
32
|
+
if (r.part) {
|
|
33
|
+
if (r.stars) e.parts[r.part] = r.stars
|
|
34
|
+
else delete e.parts[r.part]
|
|
35
|
+
} else {
|
|
36
|
+
e.rate = { ...(r.stars ? { stars: r.stars } : {}), ...(r.mark ? { mark: r.mark } : {}) }
|
|
37
|
+
if (m.text !== undefined) e.text = m.text
|
|
38
|
+
else delete e.text
|
|
39
|
+
}
|
|
40
|
+
out.set(key, e)
|
|
41
|
+
}
|
|
42
|
+
return [...out.values()].filter((e) => e.rate.stars !== undefined || e.rate.mark !== undefined || Object.keys(e.parts).length > 0).sort((a, b) => b.at.localeCompare(a.at))
|
|
43
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// the media facts the store and the trailer view read: a clip's length, size and rate (ffprobe), one still of a clip at
|
|
2
|
+
// a time (ffmpeg, through the trailer core's one spawn), an image's size (sharp, loaded only by a writer)
|
|
3
|
+
import { ffmpeg } from '../trailer/ffmpeg.ts'
|
|
4
|
+
|
|
5
|
+
const FFPROBE = Bun.which('ffprobe') ?? '/opt/homebrew/bin/ffprobe'
|
|
6
|
+
|
|
7
|
+
export interface Probe {
|
|
8
|
+
duration: number
|
|
9
|
+
video: { width: number; height: number; fps: number } | null
|
|
10
|
+
audio: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** `30000/1001` → 29.97 */
|
|
14
|
+
const rate = (r: string | undefined): number => {
|
|
15
|
+
const [a = 0, b = 1] = (r ?? '').split('/').map(Number)
|
|
16
|
+
return b > 0 && a > 0 ? a / b : 0
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** one ffprobe of a media file; throws when ffprobe cannot read it or finds no length */
|
|
20
|
+
export function probe(file: string): Probe {
|
|
21
|
+
const r = Bun.spawnSync([FFPROBE, '-v', 'error', '-show_entries', 'stream=codec_type,width,height,r_frame_rate:format=duration', '-of', 'json', file], { stdout: 'pipe', stderr: 'pipe' })
|
|
22
|
+
if (r.exitCode !== 0) throw new Error(`ffprobe ${file}: ${r.stderr.toString().trim() || `exit ${r.exitCode}`}`)
|
|
23
|
+
const j = JSON.parse(r.stdout.toString()) as { streams?: { codec_type?: string; width?: number; height?: number; r_frame_rate?: string }[]; format?: { duration?: string } }
|
|
24
|
+
const duration = Number(j.format?.duration)
|
|
25
|
+
if (!(duration > 0)) throw new Error(`ffprobe ${file}: no length`)
|
|
26
|
+
const v = j.streams?.find((s) => s.codec_type === 'video')
|
|
27
|
+
return {
|
|
28
|
+
duration,
|
|
29
|
+
video: v?.width && v.height ? { width: v.width, height: v.height, fps: rate(v.r_frame_rate) } : null,
|
|
30
|
+
audio: j.streams?.some((s) => s.codec_type === 'audio') ?? false,
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** the first frame at or after `seconds` of a clip, as a png */
|
|
35
|
+
export async function stillAt(video: string, seconds: number, png: string): Promise<void> {
|
|
36
|
+
await ffmpeg(['-ss', Math.max(0, seconds).toFixed(5), '-i', video, '-frames:v', '1', '-update', '1', '-f', 'image2', '-c:v', 'png', png])
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** an image's size in pixels */
|
|
40
|
+
export async function sizeOf(file: string): Promise<{ w: number; h: number }> {
|
|
41
|
+
const { default: sharp } = await import('sharp')
|
|
42
|
+
const m = await sharp(file).metadata()
|
|
43
|
+
if (!m.width || !m.height) throw new Error(`${file}: no image size`)
|
|
44
|
+
return { w: m.width, h: m.height }
|
|
45
|
+
}
|