@erclx/canon 5.67.0 → 5.69.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 +31 -0
- package/README.md +1 -1
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/app-drive/REQUIREMENT.md +5 -5
- package/claude/skills/app-drive/SKILL.md +14 -4
- package/claude/skills/app-drive/references/drive-record.md +12 -1
- package/claude/skills/canon-cli/SKILL.md +2 -2
- package/claude/skills/canon-feedback/REQUIREMENT.md +1 -1
- package/claude/skills/canon-feedback/SKILL.md +1 -1
- package/claude/skills/design-identity/REQUIREMENT.md +4 -4
- package/claude/skills/design-identity/SKILL.md +7 -7
- package/claude/skills/{pick-by-eye → design-pick}/REQUIREMENT.md +6 -6
- package/claude/skills/{pick-by-eye → design-pick}/SKILL.md +18 -10
- package/claude/skills/{pick-by-eye → design-pick}/references/board.md +17 -11
- package/claude/skills/design-pick/references/design-config.md +33 -0
- package/claude/skills/design-record/REQUIREMENT.md +2 -2
- package/claude/skills/design-record/SKILL.md +1 -1
- package/claude/skills/design-taste/REQUIREMENT.md +2 -2
- package/claude/skills/design-taste/SKILL.md +3 -3
- package/claude/skills/design-taste/references/preflight.md +1 -1
- package/claude/skills/git-issue/REQUIREMENT.md +1 -1
- package/claude/skills/git-issue/references/issue.md +1 -1
- package/claude/skills/plan-feature/SKILL.md +1 -1
- package/claude/skills/plan-feature/references/plan.md +21 -6
- package/claude/skills/plan-groundwork/SKILL.md +1 -1
- package/claude/skills/pr-evidence/REQUIREMENT.md +1 -1
- package/claude/skills/pr-evidence/SKILL.md +7 -1
- package/claude/skills/pr-evidence/references/evidence.md +33 -3
- package/claude/skills/review-craft/references/ui.md +30 -10
- package/claude/skills/role-orchestrator/references/orchestrator-launch.md +3 -3
- package/claude/skills/role-orchestrator/references/orchestrator-parked.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-planning-wait.md +24 -0
- package/claude/skills/role-planner/SKILL.md +4 -2
- package/claude/skills/role-worker/SKILL.md +5 -6
- package/claude/skills/task-board/references/tasks.md +1 -1
- package/claude/skills/task-complete/references/receipt-sweep.md +1 -1
- package/claude/skills/{ux-audit → ui-audit}/REQUIREMENT.md +3 -3
- package/claude/skills/{ux-audit → ui-audit}/SKILL.md +7 -7
- package/claude/skills/ui-checklist/REQUIREMENT.md +1 -1
- package/claude/skills/ui-craft/SKILL.md +1 -1
- package/claude/skills/{ui-record → ui-film}/REQUIREMENT.md +7 -5
- package/claude/skills/{ui-record → ui-film}/SKILL.md +23 -12
- package/claude/skills/{ui-record → ui-film}/references/config.md +3 -3
- package/claude/skills/{ui-record → ui-film}/references/handoff.md +28 -9
- package/claude/skills/ui-film/references/pr-clip.md +84 -0
- package/claude/skills/{ui-record → ui-film}/references/take-craft.md +8 -1
- package/claude/skills/{ux-measure → ui-measure}/REQUIREMENT.md +3 -3
- package/claude/skills/{ux-measure → ui-measure}/SKILL.md +6 -6
- package/docs/agents/audits.md +1 -1
- package/docs/agents/migrate.md +13 -13
- package/docs/agents/records.md +1 -0
- package/docs/target/migrations.md +12 -10
- package/docs/target/projects.md +1 -1
- package/docs/target/sync.md +1 -1
- package/docs/workflow/skill-map-on-request.md +1 -2
- package/docs/workflow/skill-map.md +9 -10
- package/docs/workflow/visual-design-workflow.md +6 -1
- package/governance/ledger/claude/background.md +26 -0
- package/governance/rules/claude/background.md +19 -0
- package/governance/stacks/base.toml +1 -0
- package/package.json +8 -5
- package/scripts/ci/install-playwright-deps.sh +52 -0
- package/src/claude/cases/authoring.ts +2 -2
- package/src/claude/cases/misc.ts +1 -1
- package/src/claude/cases/setup.ts +0 -5
- package/src/claude/cases/workflow.ts +6 -6
- package/src/commands/fixtures/gh.sh +64 -0
- package/src/commands/gov.ts +33 -3
- package/src/commands/migrate.ts +12 -2
- package/src/commands/pr.ts +237 -79
- package/src/commands/take.ts +44 -8
- package/src/commands/tasks.ts +183 -0
- package/src/gate/stages.ts +2 -2
- package/src/gov/audit.ts +48 -11
- package/src/gov/detect.ts +1 -1
- package/src/markdown/scope.ts +9 -14
- package/src/migrate/record-layout.ts +5 -3
- package/src/migrate/skill-names.ts +21 -4
- package/src/pr/evidence.ts +208 -78
- package/src/pr/frames.ts +42 -4
- package/src/pr/pixels.ts +44 -0
- package/src/records/backup.ts +28 -4
- package/src/records/validate.ts +1 -0
- package/src/sandbox/headless/dispatch-shim.sh +15 -0
- package/src/sandbox/headless/dispatch.ts +12 -14
- package/src/sandbox/stub-gh.sh +26 -0
- package/src/sandbox/stub-remote.ts +8 -29
- package/src/sync/engine.ts +1 -1
- package/src/take/camera.ts +2 -2
- package/src/take/encode.ts +38 -1
- package/src/take/film.ts +26 -3
- package/src/take/pointer.ts +169 -0
- package/src/take/reel.ts +215 -0
- package/src/take/screencast.ts +5 -2
- package/src/take/stepper.ts +23 -6
- package/src/take/zoom/NOTICE.md +1 -1
- package/src/tasks/launch-wait.ts +160 -0
- package/tooling/claude/reference.md +1 -1
- package/claude/skills/canon-feedback-triage/REQUIREMENT.md +0 -34
- package/claude/skills/canon-feedback-triage/SKILL.md +0 -98
- package/claude/skills/plan-ready/REQUIREMENT.md +0 -35
- package/claude/skills/plan-ready/SKILL.md +0 -136
- package/claude/skills/plan-ready/references/assembly.md +0 -75
- package/claude/skills/plan-ready/references/ready.md +0 -128
- /package/claude/skills/{pick-by-eye → design-pick}/references/live-arms.md +0 -0
|
@@ -8,6 +8,11 @@ import {
|
|
|
8
8
|
} from 'node:fs'
|
|
9
9
|
import { homedir } from 'node:os'
|
|
10
10
|
import { join } from 'node:path'
|
|
11
|
+
import { fileURLToPath } from 'node:url'
|
|
12
|
+
|
|
13
|
+
const DISPATCH_SHIM = fileURLToPath(
|
|
14
|
+
new URL('dispatch-shim.sh', import.meta.url),
|
|
15
|
+
)
|
|
11
16
|
|
|
12
17
|
/*
|
|
13
18
|
* The bound on a nested background dispatch, and the recording behind it.
|
|
@@ -31,9 +36,11 @@ import { join } from 'node:path'
|
|
|
31
36
|
* The prevention. Writes a `claude` that refuses a background dispatch by flag
|
|
32
37
|
* name and delegates everything else, for the runner to place first on the PATH
|
|
33
38
|
* of the session it spawns. It stays a bash file, since it stands in for a
|
|
34
|
-
* binary any shell in the session may exec.
|
|
39
|
+
* binary any shell in the session may exec. The refusal lives in
|
|
40
|
+
* `dispatch-shim.sh`, so the shell gate lints it, and the `claude` written here
|
|
41
|
+
* is a two-line wrapper that runs it with the real binary as its first argument.
|
|
35
42
|
*
|
|
36
|
-
* The real binary is baked into the
|
|
43
|
+
* The real binary is baked into the wrapper rather than passed through the
|
|
37
44
|
* environment. An env var carrying it would sit in the spawned session's own
|
|
38
45
|
* environment, which hands any arm the exact string that walks around the shim.
|
|
39
46
|
*
|
|
@@ -46,22 +53,13 @@ import { join } from 'node:path'
|
|
|
46
53
|
* backstops.
|
|
47
54
|
*/
|
|
48
55
|
export function installDispatchShim(dir: string, real: string): void {
|
|
49
|
-
const
|
|
56
|
+
const wrapper = [
|
|
50
57
|
'#!/usr/bin/env bash',
|
|
51
|
-
|
|
52
|
-
'for arg in "$@"; do',
|
|
53
|
-
' case "$arg" in',
|
|
54
|
-
' --bg | --background)',
|
|
55
|
-
' echo "canon sandbox harness: refusing $arg. A sandbox run may not dispatch a background session, so this shim stands first on PATH in place of the real claude binary. Drive the refusal the fixture asks for, or run this outside the harness." >&2',
|
|
56
|
-
' exit 64',
|
|
57
|
-
' ;;',
|
|
58
|
-
' esac',
|
|
59
|
-
'done',
|
|
60
|
-
`exec ${shellQuote(real)} "$@"`,
|
|
58
|
+
`exec bash ${shellQuote(DISPATCH_SHIM)} ${shellQuote(real)} "$@"`,
|
|
61
59
|
'',
|
|
62
60
|
].join('\n')
|
|
63
61
|
|
|
64
|
-
writeFileSync(join(dir, 'claude'),
|
|
62
|
+
writeFileSync(join(dir, 'claude'), wrapper, 'utf8')
|
|
65
63
|
chmodSync(join(dir, 'claude'), 0o755)
|
|
66
64
|
}
|
|
67
65
|
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# stub gh
|
|
3
|
+
dir="$STUB_GH_DIR"
|
|
4
|
+
line="$(printf '%q ' "$@")"
|
|
5
|
+
line="${line% }"
|
|
6
|
+
printf '%s\n' "$line" >>"$dir/gh.log"
|
|
7
|
+
|
|
8
|
+
case "$1" in
|
|
9
|
+
api) exit 0 ;;
|
|
10
|
+
pr)
|
|
11
|
+
case "$2" in
|
|
12
|
+
create)
|
|
13
|
+
count="$(cat "$dir/pr-count" 2>/dev/null || echo 0)"
|
|
14
|
+
count=$((count + 1))
|
|
15
|
+
echo "$count" >"$dir/pr-count"
|
|
16
|
+
echo "https://github.com/$GITHUB_ORG/$STUB_GH_REPO/pull/$count"
|
|
17
|
+
exit 0
|
|
18
|
+
;;
|
|
19
|
+
close | list) exit 0 ;;
|
|
20
|
+
esac
|
|
21
|
+
;;
|
|
22
|
+
esac
|
|
23
|
+
|
|
24
|
+
printf '%s\n' "$line" >>"$dir/gh-unknown.log"
|
|
25
|
+
echo "stub gh: unknown call: $line" >&2
|
|
26
|
+
exit 1
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process'
|
|
2
2
|
import {
|
|
3
3
|
chmodSync,
|
|
4
|
+
copyFileSync,
|
|
4
5
|
existsSync,
|
|
5
6
|
mkdirSync,
|
|
6
7
|
readdirSync,
|
|
7
8
|
readFileSync,
|
|
8
9
|
rmSync,
|
|
9
|
-
writeFileSync,
|
|
10
10
|
} from 'node:fs'
|
|
11
11
|
import { join } from 'node:path'
|
|
12
|
+
import { fileURLToPath } from 'node:url'
|
|
12
13
|
|
|
13
14
|
const STUB_ORG = 'stub-org'
|
|
14
15
|
const ANCHOR_REPO = 'canon-sandbox'
|
|
@@ -26,34 +27,11 @@ const PINNED_DATE = '2026-01-01T00:00:00Z'
|
|
|
26
27
|
* Answers the `gh` calls the git scenarios make and fails on any other, so a new
|
|
27
28
|
* call reaches a reader as a red arm rather than a silent pass. Every call is
|
|
28
29
|
* logged as one line, quoted so an argument holding a space stays one argument.
|
|
30
|
+
* The pull request URL it prints reads `GITHUB_ORG` and `STUB_GH_REPO` from the
|
|
31
|
+
* environment `createStubRemote` returns, so it follows `STUB_ORG` and
|
|
32
|
+
* `ANCHOR_REPO`.
|
|
29
33
|
*/
|
|
30
|
-
const STUB_GH =
|
|
31
|
-
# stub gh
|
|
32
|
-
dir="$STUB_GH_DIR"
|
|
33
|
-
line="$(printf '%q ' "$@")"
|
|
34
|
-
line="\${line% }"
|
|
35
|
-
printf '%s\\n' "$line" >>"$dir/gh.log"
|
|
36
|
-
|
|
37
|
-
case "$1" in
|
|
38
|
-
api) exit 0 ;;
|
|
39
|
-
pr)
|
|
40
|
-
case "$2" in
|
|
41
|
-
create)
|
|
42
|
-
count="$(cat "$dir/pr-count" 2>/dev/null || echo 0)"
|
|
43
|
-
count=$((count + 1))
|
|
44
|
-
echo "$count" >"$dir/pr-count"
|
|
45
|
-
echo "https://github.com/${STUB_ORG}/${ANCHOR_REPO}/pull/$count"
|
|
46
|
-
exit 0
|
|
47
|
-
;;
|
|
48
|
-
close | list) exit 0 ;;
|
|
49
|
-
esac
|
|
50
|
-
;;
|
|
51
|
-
esac
|
|
52
|
-
|
|
53
|
-
printf '%s\\n' "$line" >>"$dir/gh-unknown.log"
|
|
54
|
-
echo "stub gh: unknown call: $line" >&2
|
|
55
|
-
exit 1
|
|
56
|
-
`
|
|
34
|
+
const STUB_GH = fileURLToPath(new URL('stub-gh.sh', import.meta.url))
|
|
57
35
|
|
|
58
36
|
/**
|
|
59
37
|
* Builds a local bare repository and a stub `gh` under `stubDir` and returns the
|
|
@@ -75,13 +53,14 @@ export function createStubRemote(stubDir: string): Record<string, string> {
|
|
|
75
53
|
)
|
|
76
54
|
|
|
77
55
|
const gh = join(bin, 'gh')
|
|
78
|
-
|
|
56
|
+
copyFileSync(STUB_GH, gh)
|
|
79
57
|
chmodSync(gh, 0o755)
|
|
80
58
|
|
|
81
59
|
return {
|
|
82
60
|
PATH: `${bin}:${process.env.PATH ?? ''}`,
|
|
83
61
|
STUB_GH_DIR: stubDir,
|
|
84
62
|
GITHUB_ORG: STUB_ORG,
|
|
63
|
+
STUB_GH_REPO: ANCHOR_REPO,
|
|
85
64
|
GIT_CONFIG_COUNT: '1',
|
|
86
65
|
GIT_CONFIG_KEY_0: `url.${remotes}/.insteadOf`,
|
|
87
66
|
GIT_CONFIG_VALUE_0: `https://github.com/${STUB_ORG}/`,
|
package/src/sync/engine.ts
CHANGED
|
@@ -545,7 +545,7 @@ function reportCoverage(coverage: readonly RuleCoverage[]): void {
|
|
|
545
545
|
for (const entry of coverage) {
|
|
546
546
|
logInfo(describeCoverage(entry))
|
|
547
547
|
if (entry.ui) {
|
|
548
|
-
logInfo(' Audit those files with /canon:
|
|
548
|
+
logInfo(' Audit those files with /canon:ui-audit.')
|
|
549
549
|
}
|
|
550
550
|
}
|
|
551
551
|
}
|
package/src/take/camera.ts
CHANGED
|
@@ -20,8 +20,8 @@ import {
|
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
22
|
* Turns a take's log into one camera per frame, preset O4 from groundwork
|
|
23
|
-
* track `193-ui-record
|
|
24
|
-
* depth, a focus that holds still until a click leaves the inner part of the
|
|
23
|
+
* track `193-ui-record`, a folder name kept as written (canon-keep-retired).
|
|
24
|
+
* The preset is OpenScreen's timing aimed at each click, a fixed depth, a focus that holds still until a click leaves the inner part of the
|
|
25
25
|
* view, and a dead zone that keeps the cursor in frame. HyperFrames renders the
|
|
26
26
|
* track as it stands, so it carries no curve a renderer would have to agree on.
|
|
27
27
|
*/
|
package/src/take/encode.ts
CHANGED
|
@@ -154,7 +154,44 @@ export async function probeFrames(clip: string): Promise<number> {
|
|
|
154
154
|
return frames
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
-
|
|
157
|
+
/**
|
|
158
|
+
* Reads the clip's closing frame as one JPEG. It selects the frame by index
|
|
159
|
+
* rather than seeking from the end, since a seek lands on whichever frame
|
|
160
|
+
* the container's timing puts nearest, which was measured missing the last.
|
|
161
|
+
*/
|
|
162
|
+
export async function lastFrame(clip: string, frames: number): Promise<Buffer> {
|
|
163
|
+
const proc = spawn(
|
|
164
|
+
'ffmpeg',
|
|
165
|
+
[
|
|
166
|
+
'-v',
|
|
167
|
+
'error',
|
|
168
|
+
'-i',
|
|
169
|
+
clip,
|
|
170
|
+
'-vf',
|
|
171
|
+
`select=eq(n\\,${frames - 1})`,
|
|
172
|
+
'-frames:v',
|
|
173
|
+
'1',
|
|
174
|
+
'-q:v',
|
|
175
|
+
'2',
|
|
176
|
+
'-f',
|
|
177
|
+
'image2pipe',
|
|
178
|
+
'-c:v',
|
|
179
|
+
'mjpeg',
|
|
180
|
+
'-',
|
|
181
|
+
],
|
|
182
|
+
{ stdio: ['ignore', 'pipe', 'pipe'] },
|
|
183
|
+
)
|
|
184
|
+
const chunks: Buffer[] = []
|
|
185
|
+
proc.stdout?.on('data', (chunk: Buffer) => chunks.push(chunk))
|
|
186
|
+
await finished(proc)
|
|
187
|
+
const jpeg = Buffer.concat(chunks)
|
|
188
|
+
if (jpeg.length === 0) {
|
|
189
|
+
throw new Error(`ffmpeg read no frame ${frames - 1} from ${clip}`)
|
|
190
|
+
}
|
|
191
|
+
return jpeg
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function finished(proc: ChildProcess): Promise<void> {
|
|
158
195
|
let stderr = ''
|
|
159
196
|
proc.stderr?.on('data', (chunk: Buffer) => {
|
|
160
197
|
stderr += chunk.toString()
|
package/src/take/film.ts
CHANGED
|
@@ -44,6 +44,8 @@ export interface FilmedBeat {
|
|
|
44
44
|
readonly clip: string
|
|
45
45
|
readonly log: string
|
|
46
46
|
readonly camera: string
|
|
47
|
+
/** The clip's closing frame, as a JPEG. */
|
|
48
|
+
readonly still: string
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
export type TakeResult =
|
|
@@ -65,6 +67,8 @@ export interface TakeRequest {
|
|
|
65
67
|
readonly plan: TakePlan
|
|
66
68
|
readonly out: string
|
|
67
69
|
readonly env?: NodeJS.ProcessEnv
|
|
70
|
+
/** How long one stepped frame may take before its beat falls back to a screencast. */
|
|
71
|
+
readonly stallMs?: number
|
|
68
72
|
}
|
|
69
73
|
|
|
70
74
|
const REACH_TIMEOUT_MS = 5000
|
|
@@ -106,7 +110,16 @@ export async function take(request: TakeRequest): Promise<TakeResult> {
|
|
|
106
110
|
const dir = `${String(index + 1).padStart(2, '0')}-${slug(beat.name)}`
|
|
107
111
|
mkdirSync(join(out, dir), { recursive: true })
|
|
108
112
|
try {
|
|
109
|
-
beats.push(
|
|
113
|
+
beats.push(
|
|
114
|
+
await filmBeat(
|
|
115
|
+
request.url,
|
|
116
|
+
beat,
|
|
117
|
+
out,
|
|
118
|
+
dir,
|
|
119
|
+
executable,
|
|
120
|
+
request.stallMs,
|
|
121
|
+
),
|
|
122
|
+
)
|
|
110
123
|
} catch (error) {
|
|
111
124
|
return failed(classify(error), error, beat.name)
|
|
112
125
|
}
|
|
@@ -125,16 +138,23 @@ async function filmBeat(
|
|
|
125
138
|
out: string,
|
|
126
139
|
dir: string,
|
|
127
140
|
executable: string,
|
|
141
|
+
stallMs: number | undefined,
|
|
128
142
|
): Promise<FilmedBeat> {
|
|
129
143
|
const clip = join(dir, 'clip.mp4')
|
|
130
144
|
const target = join(out, clip)
|
|
131
145
|
|
|
132
146
|
let route: Route = beat.route
|
|
133
147
|
let fallback: string | undefined
|
|
134
|
-
let filmed: { frames: number; events: readonly LogEvent[] }
|
|
148
|
+
let filmed: { frames: number; events: readonly LogEvent[]; still: Buffer }
|
|
135
149
|
if (beat.route === 'step') {
|
|
136
150
|
try {
|
|
137
|
-
filmed = await filmStepped({
|
|
151
|
+
filmed = await filmStepped({
|
|
152
|
+
executable,
|
|
153
|
+
url,
|
|
154
|
+
beat,
|
|
155
|
+
clip: target,
|
|
156
|
+
stallMs,
|
|
157
|
+
})
|
|
138
158
|
} catch (error) {
|
|
139
159
|
if (!(error instanceof StepperStalled)) throw error
|
|
140
160
|
route = 'screencast'
|
|
@@ -158,6 +178,8 @@ async function filmBeat(
|
|
|
158
178
|
// One line, since it carries a row per frame and nobody reads it by eye.
|
|
159
179
|
const cameraPath = join(dir, 'camera.json')
|
|
160
180
|
writeFileSync(join(out, cameraPath), `${JSON.stringify(cameraTrack(log))}\n`)
|
|
181
|
+
const stillPath = join(dir, 'still.jpg')
|
|
182
|
+
writeFileSync(join(out, stillPath), filmed.still)
|
|
161
183
|
|
|
162
184
|
return {
|
|
163
185
|
name: beat.name,
|
|
@@ -170,6 +192,7 @@ async function filmBeat(
|
|
|
170
192
|
clip,
|
|
171
193
|
log: logPath,
|
|
172
194
|
camera: cameraPath,
|
|
195
|
+
still: stillPath,
|
|
173
196
|
}
|
|
174
197
|
}
|
|
175
198
|
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type { Point } from '@/take/log'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Draws the cursor arrow and the click ring onto one decoded frame, in the
|
|
5
|
+
* shape groundwork track `193-ui-record` rendered them (canon-keep-retired):
|
|
6
|
+
* a dark arrow outlined
|
|
7
|
+
* in white, and a blue ring that grows from its tip and fades out. Both stay
|
|
8
|
+
* one size whatever the frame shows, so the pointer reads the same in every
|
|
9
|
+
* beat of a reel. Drawn in TypeScript rather than a browser, so the reel needs
|
|
10
|
+
* no page and no seek, and a test can read the pixels it leaves.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface Frame {
|
|
14
|
+
/** RGBA, four bytes a pixel, row by row. */
|
|
15
|
+
readonly data: Uint8Array
|
|
16
|
+
readonly width: number
|
|
17
|
+
readonly height: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The arrow, relative to its tip, from the groundwork's 24 px cursor path. */
|
|
21
|
+
const ARROW: readonly Point[] = [
|
|
22
|
+
{ x: 0, y: 0 },
|
|
23
|
+
{ x: 0, y: 17 },
|
|
24
|
+
{ x: 4.5, y: 12.5 },
|
|
25
|
+
{ x: 7.5, y: 19 },
|
|
26
|
+
{ x: 10, y: 18 },
|
|
27
|
+
{ x: 7, y: 11.5 },
|
|
28
|
+
{ x: 13.5, y: 11.5 },
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
const ARROW_FILL = [17, 17, 17] as const
|
|
32
|
+
const ARROW_STROKE = [255, 255, 255] as const
|
|
33
|
+
const STROKE_HALF = 0.75
|
|
34
|
+
|
|
35
|
+
/** The ring's outer radius at full size, and its stroke at that size. */
|
|
36
|
+
export const RING_RADIUS = 18
|
|
37
|
+
const RING_STROKE = 2
|
|
38
|
+
const RING_COLOR = [37, 99, 235] as const
|
|
39
|
+
/** The ring starts at this share of its size and grows by the expansion. */
|
|
40
|
+
const RING_START = 0.4
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Draws the pointer with its tip at `at`, in the frame's own pixels, and the
|
|
44
|
+
* ring around it when `ring` is a 0 to 1 expansion. The ring sits under the
|
|
45
|
+
* arrow, and anything past the frame's edge is clipped.
|
|
46
|
+
*/
|
|
47
|
+
export function drawPointer(
|
|
48
|
+
frame: Frame,
|
|
49
|
+
at: Point,
|
|
50
|
+
ring: number | null,
|
|
51
|
+
): void {
|
|
52
|
+
if (ring !== null) drawRing(frame, at, ring)
|
|
53
|
+
drawArrow(frame, at)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function drawRing(frame: Frame, at: Point, ring: number): void {
|
|
57
|
+
const scale = RING_START + ring
|
|
58
|
+
const stroke = RING_STROKE * scale
|
|
59
|
+
const middle = RING_RADIUS * scale - stroke / 2
|
|
60
|
+
const opacity = 1 - ring
|
|
61
|
+
if (opacity <= 0) return
|
|
62
|
+
|
|
63
|
+
const reach = RING_RADIUS * scale + 1
|
|
64
|
+
forEachPixel(frame, at, reach, reach, reach, reach, (px, py) => {
|
|
65
|
+
const distance = Math.hypot(px - at.x, py - at.y)
|
|
66
|
+
const coverage = clamp01(stroke / 2 + 0.5 - Math.abs(distance - middle))
|
|
67
|
+
return coverage > 0
|
|
68
|
+
? { color: RING_COLOR, alpha: coverage * opacity }
|
|
69
|
+
: null
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function drawArrow(frame: Frame, at: Point): void {
|
|
74
|
+
const reach = STROKE_HALF + 1
|
|
75
|
+
forEachPixel(frame, at, reach, 13.5 + reach, reach, 19 + reach, (px, py) => {
|
|
76
|
+
const d = signedDistance({ x: px - at.x, y: py - at.y })
|
|
77
|
+
// Coverage of the outlined shape, then of the fill inside the outline,
|
|
78
|
+
// each antialiased across one pixel.
|
|
79
|
+
const outer = clamp01(0.5 - (d - STROKE_HALF))
|
|
80
|
+
if (outer <= 0) return null
|
|
81
|
+
const inner = clamp01(0.5 - (d + STROKE_HALF))
|
|
82
|
+
return {
|
|
83
|
+
color: ARROW_STROKE,
|
|
84
|
+
alpha: outer,
|
|
85
|
+
over: ARROW_FILL,
|
|
86
|
+
overAlpha: inner,
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface Paint {
|
|
92
|
+
readonly color: readonly [number, number, number]
|
|
93
|
+
readonly alpha: number
|
|
94
|
+
/** A second color laid over the first, for the fill inside the outline. */
|
|
95
|
+
readonly over?: readonly [number, number, number]
|
|
96
|
+
readonly overAlpha?: number
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Calls `paint` for each pixel center inside the box around `at`, clipped to
|
|
101
|
+
* the frame, and blends whatever it returns onto that pixel.
|
|
102
|
+
*/
|
|
103
|
+
function forEachPixel(
|
|
104
|
+
frame: Frame,
|
|
105
|
+
at: Point,
|
|
106
|
+
left: number,
|
|
107
|
+
right: number,
|
|
108
|
+
up: number,
|
|
109
|
+
down: number,
|
|
110
|
+
paint: (x: number, y: number) => Paint | null,
|
|
111
|
+
): void {
|
|
112
|
+
const x0 = Math.max(0, Math.floor(at.x - left))
|
|
113
|
+
const x1 = Math.min(frame.width - 1, Math.ceil(at.x + right))
|
|
114
|
+
const y0 = Math.max(0, Math.floor(at.y - up))
|
|
115
|
+
const y1 = Math.min(frame.height - 1, Math.ceil(at.y + down))
|
|
116
|
+
for (let y = y0; y <= y1; y += 1) {
|
|
117
|
+
for (let x = x0; x <= x1; x += 1) {
|
|
118
|
+
const result = paint(x + 0.5, y + 0.5)
|
|
119
|
+
if (!result) continue
|
|
120
|
+
const i = (y * frame.width + x) * 4
|
|
121
|
+
blend(frame.data, i, result.color, result.alpha)
|
|
122
|
+
if (result.over && result.overAlpha) {
|
|
123
|
+
blend(frame.data, i, result.over, result.overAlpha)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function blend(
|
|
130
|
+
data: Uint8Array,
|
|
131
|
+
i: number,
|
|
132
|
+
color: readonly [number, number, number],
|
|
133
|
+
alpha: number,
|
|
134
|
+
): void {
|
|
135
|
+
for (let c = 0; c < 3; c += 1) {
|
|
136
|
+
const base = data[i + c] ?? 0
|
|
137
|
+
data[i + c] = Math.round(base + ((color[c] ?? 0) - base) * alpha)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Distance from `p` to the arrow's outline, negative inside it. */
|
|
142
|
+
function signedDistance(p: Point): number {
|
|
143
|
+
let nearest = Number.POSITIVE_INFINITY
|
|
144
|
+
let inside = false
|
|
145
|
+
for (let i = 0, j = ARROW.length - 1; i < ARROW.length; j = i, i += 1) {
|
|
146
|
+
const a = ARROW[j] ?? ARROW[0]
|
|
147
|
+
const b = ARROW[i] ?? ARROW[0]
|
|
148
|
+
if (!a || !b) continue
|
|
149
|
+
nearest = Math.min(nearest, segmentDistance(p, a, b))
|
|
150
|
+
if (
|
|
151
|
+
a.y > p.y !== b.y > p.y &&
|
|
152
|
+
p.x < ((b.x - a.x) * (p.y - a.y)) / (b.y - a.y) + a.x
|
|
153
|
+
) {
|
|
154
|
+
inside = !inside
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return inside ? -nearest : nearest
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function segmentDistance(p: Point, a: Point, b: Point): number {
|
|
161
|
+
const dx = b.x - a.x
|
|
162
|
+
const dy = b.y - a.y
|
|
163
|
+
const k = clamp01(((p.x - a.x) * dx + (p.y - a.y) * dy) / (dx * dx + dy * dy))
|
|
164
|
+
return Math.hypot(p.x - (a.x + k * dx), p.y - (a.y + k * dy))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function clamp01(value: number): number {
|
|
168
|
+
return Math.min(Math.max(value, 0), 1)
|
|
169
|
+
}
|
package/src/take/reel.ts
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { readFileSync, statSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import type { CameraTrack } from '@/take/camera'
|
|
5
|
+
import { finished } from '@/take/encode'
|
|
6
|
+
import type { FilmedBeat } from '@/take/film'
|
|
7
|
+
import { FPS } from '@/take/log'
|
|
8
|
+
import { type Frame, drawPointer } from '@/take/pointer'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Joins a take's beats into one short reel for a pull request: each beat's
|
|
12
|
+
* clip shrunk to one width, every third frame kept, the pointer drawn from its
|
|
13
|
+
* camera track, encoded as an animated WebP that GitHub plays inline. It
|
|
14
|
+
* draws the pointer and nothing else, since the zoom, captions, and cuts are
|
|
15
|
+
* the edit's work for a launch video.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export const REEL_WIDTH = 960
|
|
19
|
+
|
|
20
|
+
/** How many take frames each reel frame stands for, 60 fps down to 20. */
|
|
21
|
+
export const REEL_EVERY = 3
|
|
22
|
+
|
|
23
|
+
export const REEL_FPS = FPS / REEL_EVERY
|
|
24
|
+
|
|
25
|
+
/** WebP quality, measured at about 1.4 MB for a 9 s reel at this width. */
|
|
26
|
+
const QUALITY = 60
|
|
27
|
+
|
|
28
|
+
export interface ReelFrame extends Frame {
|
|
29
|
+
/** Which beat of the take, in plan order. */
|
|
30
|
+
readonly beat: number
|
|
31
|
+
/** The frame's place in its beat, which draws camera frame `index * REEL_EVERY`. */
|
|
32
|
+
readonly index: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface Reel {
|
|
36
|
+
readonly file: string
|
|
37
|
+
readonly frames: number
|
|
38
|
+
readonly seconds: number
|
|
39
|
+
readonly bytes: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Yields every reel frame in order with the pointer drawn on it. The decoder
|
|
44
|
+
* keeps frames by index rather than by timestamp, since an `fps` filter that
|
|
45
|
+
* rounds timestamps drifts the pointer off what it pressed.
|
|
46
|
+
*/
|
|
47
|
+
export async function* reelFrames(
|
|
48
|
+
out: string,
|
|
49
|
+
beats: readonly FilmedBeat[],
|
|
50
|
+
): AsyncGenerator<ReelFrame> {
|
|
51
|
+
for (const [beat, filmed] of beats.entries()) {
|
|
52
|
+
const camera = JSON.parse(
|
|
53
|
+
readFileSync(join(out, filmed.camera), 'utf8'),
|
|
54
|
+
) as CameraTrack
|
|
55
|
+
const { width, height } = reelSize(camera)
|
|
56
|
+
// The camera is in CSS pixels of the page, and every route's clip shrinks
|
|
57
|
+
// to the same width, so one factor places the pointer on either.
|
|
58
|
+
const factor = width / camera.viewport.width
|
|
59
|
+
|
|
60
|
+
let index = 0
|
|
61
|
+
for await (const data of decode(join(out, filmed.clip), width, height)) {
|
|
62
|
+
const at =
|
|
63
|
+
camera.frames[Math.min(index * REEL_EVERY, camera.frames.length - 1)]
|
|
64
|
+
const frame = { data, width, height, beat, index }
|
|
65
|
+
if (at) {
|
|
66
|
+
drawPointer(
|
|
67
|
+
frame,
|
|
68
|
+
{ x: at.cursor.x * factor, y: at.cursor.y * factor },
|
|
69
|
+
at.ring,
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
yield frame
|
|
73
|
+
index += 1
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Renders the reel of a filmed take to `file`, an animated WebP. */
|
|
79
|
+
export async function renderReel(
|
|
80
|
+
out: string,
|
|
81
|
+
beats: readonly FilmedBeat[],
|
|
82
|
+
file: string,
|
|
83
|
+
): Promise<Reel> {
|
|
84
|
+
const first = beats[0]
|
|
85
|
+
if (!first) throw new Error('a reel needs at least one filmed beat')
|
|
86
|
+
const { width, height } = reelSize(
|
|
87
|
+
JSON.parse(readFileSync(join(out, first.camera), 'utf8')) as CameraTrack,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
const proc = spawn(
|
|
91
|
+
'ffmpeg',
|
|
92
|
+
[
|
|
93
|
+
'-y',
|
|
94
|
+
'-loglevel',
|
|
95
|
+
'error',
|
|
96
|
+
'-f',
|
|
97
|
+
'rawvideo',
|
|
98
|
+
'-pix_fmt',
|
|
99
|
+
'rgba',
|
|
100
|
+
'-s',
|
|
101
|
+
`${width}x${height}`,
|
|
102
|
+
'-framerate',
|
|
103
|
+
String(REEL_FPS),
|
|
104
|
+
'-i',
|
|
105
|
+
'-',
|
|
106
|
+
'-c:v',
|
|
107
|
+
'libwebp_anim',
|
|
108
|
+
'-quality',
|
|
109
|
+
String(QUALITY),
|
|
110
|
+
'-loop',
|
|
111
|
+
'0',
|
|
112
|
+
'-f',
|
|
113
|
+
'webp',
|
|
114
|
+
file,
|
|
115
|
+
],
|
|
116
|
+
{ stdio: ['pipe', 'ignore', 'pipe'] },
|
|
117
|
+
)
|
|
118
|
+
const done = finished(proc)
|
|
119
|
+
// Held so an encoder that dies mid-stream surfaces through `done` rather than
|
|
120
|
+
// as an unhandled rejection or a broken pipe thrown out of the stream.
|
|
121
|
+
done.catch(() => {})
|
|
122
|
+
proc.stdin?.on('error', () => {})
|
|
123
|
+
|
|
124
|
+
let frames = 0
|
|
125
|
+
try {
|
|
126
|
+
for await (const frame of reelFrames(out, beats)) {
|
|
127
|
+
// Raced against the exit, since a write waiting on a drain from an
|
|
128
|
+
// encoder that already died would wait forever.
|
|
129
|
+
await Promise.race([write(proc.stdin, frame.data), done])
|
|
130
|
+
frames += 1
|
|
131
|
+
}
|
|
132
|
+
} catch (error) {
|
|
133
|
+
proc.kill('SIGKILL')
|
|
134
|
+
throw error
|
|
135
|
+
}
|
|
136
|
+
proc.stdin?.end()
|
|
137
|
+
await done
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
file,
|
|
141
|
+
frames,
|
|
142
|
+
seconds: Number((frames / REEL_FPS).toFixed(2)),
|
|
143
|
+
bytes: statSync(file).size,
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function reelSize(camera: CameraTrack): { width: number; height: number } {
|
|
148
|
+
const height =
|
|
149
|
+
Math.round(
|
|
150
|
+
(REEL_WIDTH * camera.viewport.height) / camera.viewport.width / 2,
|
|
151
|
+
) * 2
|
|
152
|
+
return { width: REEL_WIDTH, height }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Decodes every `REEL_EVERY`th frame of a clip as RGBA at the reel's size. */
|
|
156
|
+
async function* decode(
|
|
157
|
+
clip: string,
|
|
158
|
+
width: number,
|
|
159
|
+
height: number,
|
|
160
|
+
): AsyncGenerator<Uint8Array> {
|
|
161
|
+
const proc = spawn(
|
|
162
|
+
'ffmpeg',
|
|
163
|
+
[
|
|
164
|
+
'-loglevel',
|
|
165
|
+
'error',
|
|
166
|
+
'-i',
|
|
167
|
+
clip,
|
|
168
|
+
'-vf',
|
|
169
|
+
`select='not(mod(n\\,${REEL_EVERY}))',scale=${width}:${height}:flags=area`,
|
|
170
|
+
'-fps_mode',
|
|
171
|
+
'passthrough',
|
|
172
|
+
'-f',
|
|
173
|
+
'rawvideo',
|
|
174
|
+
'-pix_fmt',
|
|
175
|
+
'rgba',
|
|
176
|
+
'-',
|
|
177
|
+
],
|
|
178
|
+
{ stdio: ['ignore', 'pipe', 'pipe'] },
|
|
179
|
+
)
|
|
180
|
+
const done = finished(proc)
|
|
181
|
+
done.catch(() => {})
|
|
182
|
+
|
|
183
|
+
const size = width * height * 4
|
|
184
|
+
let pending: Buffer = Buffer.alloc(0)
|
|
185
|
+
let drained = false
|
|
186
|
+
try {
|
|
187
|
+
for await (const chunk of proc.stdout as AsyncIterable<Buffer>) {
|
|
188
|
+
pending = pending.length ? Buffer.concat([pending, chunk]) : chunk
|
|
189
|
+
while (pending.length >= size) {
|
|
190
|
+
// Copied out, since the frame is drawn on and outlives the chunk.
|
|
191
|
+
yield new Uint8Array(pending.subarray(0, size))
|
|
192
|
+
pending = pending.subarray(size)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
drained = true
|
|
196
|
+
} finally {
|
|
197
|
+
// A reader that stopped early leaves FFmpeg decoding into a full pipe.
|
|
198
|
+
if (!drained) proc.kill('SIGKILL')
|
|
199
|
+
}
|
|
200
|
+
await done
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function write(
|
|
204
|
+
stdin: NodeJS.WritableStream | null,
|
|
205
|
+
data: Uint8Array,
|
|
206
|
+
): Promise<void> {
|
|
207
|
+
return new Promise((resolve, reject) => {
|
|
208
|
+
if (!stdin) {
|
|
209
|
+
reject(new Error('ffmpeg stopped reading frames'))
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
if (stdin.write(data)) resolve()
|
|
213
|
+
else stdin.once('drain', () => resolve())
|
|
214
|
+
})
|
|
215
|
+
}
|