@erclx/canon 5.68.0 → 5.70.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 +36 -0
- package/README.md +33 -159
- package/assets/brand/mark.svg +17 -9
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/app-drive/REQUIREMENT.md +5 -5
- package/claude/skills/app-drive/SKILL.md +17 -6
- package/claude/skills/app-drive/references/drive-record.md +61 -8
- package/claude/skills/app-drive/references/isolate.md +1 -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 +19 -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/references/evidence.md +2 -2
- 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-craft/references/copy.md +4 -1
- package/claude/skills/{ui-record → ui-film}/REQUIREMENT.md +6 -4
- package/claude/skills/{ui-record → ui-film}/SKILL.md +26 -13
- package/claude/skills/ui-film/references/compose-craft.md +81 -0
- package/claude/skills/{ui-record → ui-film}/references/config.md +9 -4
- package/claude/skills/{ui-record → ui-film}/references/handoff.md +17 -7
- package/claude/skills/{ui-record → ui-film}/references/pr-clip.md +5 -5
- package/claude/skills/{ui-record → ui-film}/references/take-craft.md +28 -2
- 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/index.md +1 -0
- package/docs/migrations/by-hand.md +107 -0
- package/docs/migrations/by-verb.md +118 -0
- package/docs/migrations/index.md +14 -0
- package/docs/target/index.md +1 -2
- package/docs/target/projects.md +3 -3
- package/docs/target/sync.md +23 -3
- package/docs/workflow/ai-workflow.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/ledger/skills/ui-craft/copy.md +8 -4
- package/governance/rules/claude/background.md +19 -0
- package/governance/stacks/base.toml +1 -0
- package/package.json +6 -4
- package/scripts/ci/install-playwright-deps.sh +52 -0
- package/scripts/core/design-sync.ts +204 -0
- package/scripts/core/regen-web-favicon.ts +27 -12
- package/scripts/core/regen-web-previews.ts +8 -1
- package/scripts/core/render-film.ts +179 -0
- package/src/capture/frame-values.ts +12 -12
- 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/records.ts +3 -2
- package/src/commands/sandbox.ts +9 -2
- package/src/commands/take.ts +159 -11
- package/src/commands/tasks.ts +183 -0
- package/src/design/favicon.ts +85 -13
- package/src/design/reference.ts +98 -0
- package/src/design/render.ts +16 -14
- package/src/design/tokens.ts +4 -3
- package/src/gate/stages.ts +8 -2
- package/src/gov/audit.ts +48 -11
- package/src/gov/detect.ts +1 -1
- package/src/init/steps.ts +2 -2
- 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/migrate/status.ts +3 -3
- package/src/records/validate.ts +15 -2
- package/src/sandbox/expect.ts +32 -0
- package/src/sandbox/headless/dispatch-shim.sh +15 -0
- package/src/sandbox/headless/dispatch.ts +12 -14
- package/src/sandbox/headless/run.ts +6 -3
- 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 +100 -11
- package/src/take/film.ts +55 -4
- package/src/take/fixtures/input.html +138 -0
- package/src/take/keys.ts +193 -0
- package/src/take/log.ts +94 -1
- package/src/take/perform.ts +165 -10
- package/src/take/plan.ts +101 -6
- package/src/take/pointer.ts +2 -1
- package/src/take/screencast.ts +28 -3
- package/src/take/stepper.ts +141 -23
- 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/docs/target/migrations.md +0 -203
- /package/claude/skills/{pick-by-eye → design-pick}/references/live-arms.md +0 -0
package/src/records/validate.ts
CHANGED
|
@@ -250,6 +250,7 @@ const PLAN_SECTIONS = [
|
|
|
250
250
|
'Constraints',
|
|
251
251
|
'Files to touch',
|
|
252
252
|
'Design references',
|
|
253
|
+
'Reference images',
|
|
253
254
|
'Verification',
|
|
254
255
|
'Risks',
|
|
255
256
|
'Review focus',
|
|
@@ -806,6 +807,13 @@ const STRUCTURE_FILES: Readonly<
|
|
|
806
807
|
intake: (file) => file !== INTAKE_INDEX && file !== INTAKE_HANDOFF,
|
|
807
808
|
}
|
|
808
809
|
|
|
810
|
+
/**
|
|
811
|
+
* An app drive's log, which sits under an intake folder's `evidence/` and
|
|
812
|
+
* holds findings as headings and fields rather than items, so it reads on the
|
|
813
|
+
* plain criteria rather than the cluster exemptions.
|
|
814
|
+
*/
|
|
815
|
+
const DRIVE_LOG = 'evidence/drive-log.md'
|
|
816
|
+
|
|
809
817
|
async function folderStructure(
|
|
810
818
|
dir: string,
|
|
811
819
|
kind: RecordKind,
|
|
@@ -816,9 +824,14 @@ async function folderStructure(
|
|
|
816
824
|
|
|
817
825
|
const report = kind === 'intake' ? intakeStructure : structureFindings
|
|
818
826
|
const files = (await listMarkdown(dir)).filter(reads)
|
|
827
|
+
const reports = files.map((file) => ({ file, read: report }))
|
|
828
|
+
if (kind === 'intake' && existsSync(join(dir, DRIVE_LOG))) {
|
|
829
|
+
reports.push({ file: DRIVE_LOG, read: structureFindings })
|
|
830
|
+
}
|
|
831
|
+
|
|
819
832
|
const perFile = await Promise.all(
|
|
820
|
-
|
|
821
|
-
|
|
833
|
+
reports.map(async ({ file, read }) =>
|
|
834
|
+
read(await readFile(join(dir, file), 'utf8')).map((entry) => ({
|
|
822
835
|
...entry,
|
|
823
836
|
record: slug,
|
|
824
837
|
subject: file,
|
package/src/sandbox/expect.ts
CHANGED
|
@@ -44,7 +44,15 @@ export interface AssertionResult {
|
|
|
44
44
|
*/
|
|
45
45
|
export interface RunEnvelope {
|
|
46
46
|
readonly isError: boolean
|
|
47
|
+
/** The runner cap ended the run, read from `subtype` rather than `turns`. */
|
|
48
|
+
readonly isTruncated: boolean
|
|
47
49
|
readonly turns: number
|
|
50
|
+
/**
|
|
51
|
+
* The cap the run ran under. `num_turns` counts more than the cap does, so
|
|
52
|
+
* an untruncated run can report past it, and a ceiling at or above the cap
|
|
53
|
+
* is held by `isTruncated` instead. A non-finite value reads as absent.
|
|
54
|
+
*/
|
|
55
|
+
readonly turnCap?: number
|
|
48
56
|
readonly denials: number
|
|
49
57
|
readonly reply?: string
|
|
50
58
|
}
|
|
@@ -487,6 +495,23 @@ function checkReply(
|
|
|
487
495
|
}
|
|
488
496
|
}
|
|
489
497
|
|
|
498
|
+
function isFiniteCap(turnCap: number | undefined): turnCap is number {
|
|
499
|
+
return turnCap !== undefined && Number.isFinite(turnCap)
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function capped(turnCap: number | undefined): string {
|
|
503
|
+
return isFiniteCap(turnCap) ? ` of ${turnCap} turns` : ''
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* A ceiling below the cap was declared in `num_turns` and is compared in it. One
|
|
508
|
+
* at or above the cap skips with no result, since emitting a pass would let the
|
|
509
|
+
* envelope pass a run.
|
|
510
|
+
*/
|
|
511
|
+
function isBelowCap(maxTurns: number, turnCap: number | undefined): boolean {
|
|
512
|
+
return !isFiniteCap(turnCap) || maxTurns < turnCap
|
|
513
|
+
}
|
|
514
|
+
|
|
490
515
|
/**
|
|
491
516
|
* The envelope never determines a pass. It can only fail a run the expectations
|
|
492
517
|
* would otherwise have passed. Under `bypassPermissions` the denial count is
|
|
@@ -516,8 +541,15 @@ function checkEnvelope(
|
|
|
516
541
|
message: `envelope: ${envelope.denials} permission denials`,
|
|
517
542
|
})
|
|
518
543
|
}
|
|
544
|
+
if (envelope.isTruncated) {
|
|
545
|
+
results.push({
|
|
546
|
+
ok: false,
|
|
547
|
+
message: `envelope: truncated by the runner cap${capped(envelope.turnCap)}`,
|
|
548
|
+
})
|
|
549
|
+
}
|
|
519
550
|
if (
|
|
520
551
|
expectation.maxTurns !== undefined &&
|
|
552
|
+
isBelowCap(expectation.maxTurns, envelope.turnCap) &&
|
|
521
553
|
envelope.turns > expectation.maxTurns
|
|
522
554
|
) {
|
|
523
555
|
results.push({
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Run by the claude wrapper installDispatchShim writes in src/sandbox/headless/dispatch.ts.
|
|
3
|
+
# $1 is the real claude binary, and everything after it is the caller's arguments.
|
|
4
|
+
real="$1"
|
|
5
|
+
shift
|
|
6
|
+
|
|
7
|
+
for arg in "$@"; do
|
|
8
|
+
case "$arg" in
|
|
9
|
+
--bg | --background)
|
|
10
|
+
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
|
|
11
|
+
exit 64
|
|
12
|
+
;;
|
|
13
|
+
esac
|
|
14
|
+
done
|
|
15
|
+
exec "$real" "$@"
|
|
@@ -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
|
|
|
@@ -45,9 +45,12 @@ import {
|
|
|
45
45
|
*
|
|
46
46
|
* `maxTurns` is the only budget. `max_turns` in an arm's `expect.toml` is a
|
|
47
47
|
* ceiling asserted after the run, not a cap enforced during it, so a
|
|
48
|
-
* declaration cannot raise what it runs under.
|
|
49
|
-
*
|
|
50
|
-
* does
|
|
48
|
+
* declaration cannot raise what it runs under. A truncated run exits 1 with
|
|
49
|
+
* `subtype` `error_max_turns`, which `canon sandbox check` fails by name.
|
|
50
|
+
* `num_turns` counts more than this cap does, so a ceiling at or above it is
|
|
51
|
+
* held by the cap and compares nothing. The check reads the cap from its own
|
|
52
|
+
* environment, so a run under a raised cap, checked later by hand without the
|
|
53
|
+
* variable, reads the default.
|
|
51
54
|
*
|
|
52
55
|
* `bypassPermissions` is the only mode that lets a run write under `.claude/`,
|
|
53
56
|
* which most arms need. The scoping the permission layer would have given
|
|
@@ -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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type Box,
|
|
3
|
+
CLIP_SIZES,
|
|
3
4
|
type LogEvent,
|
|
4
5
|
type Point,
|
|
5
6
|
type TakeLog,
|
|
@@ -20,8 +21,8 @@ import {
|
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* 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
|
|
24
|
+
* track `193-ui-record`, a folder name kept as written (canon-keep-retired).
|
|
25
|
+
* The preset is OpenScreen's timing aimed at each click, a fixed depth the clip's resolution caps, a focus that holds still until a click leaves the inner part of the
|
|
25
26
|
* view, and a dead zone that keeps the cursor in frame. HyperFrames renders the
|
|
26
27
|
* track as it stands, so it carries no curve a renderer would have to agree on.
|
|
27
28
|
*/
|
|
@@ -31,6 +32,13 @@ export const CAMERA_VERSION = 1
|
|
|
31
32
|
/** The depth every click zooms to, unless its target already fills the view. */
|
|
32
33
|
const DEPTH = 1.8
|
|
33
34
|
|
|
35
|
+
/**
|
|
36
|
+
* How far a clip with one pixel per CSS pixel zooms before it goes soft, which
|
|
37
|
+
* caps a screencast's depth well under `DEPTH` and leaves the stepper's 4K
|
|
38
|
+
* clip at it.
|
|
39
|
+
*/
|
|
40
|
+
const SHARP_DEPTH = 1.35
|
|
41
|
+
|
|
34
42
|
/** How much of the zoomed view a target may fill, which caps the depth for a big one. */
|
|
35
43
|
const FILL = 0.8
|
|
36
44
|
|
|
@@ -57,6 +65,13 @@ const EDGE = 0.9
|
|
|
57
65
|
/** How long the click ring the edit draws takes to expand. */
|
|
58
66
|
const RING_MS = 450
|
|
59
67
|
|
|
68
|
+
/**
|
|
69
|
+
* How far a drag's ring grows before it holds for the drag, then runs on to 1
|
|
70
|
+
* from the release. A held value keeps the ring's one field, so every reader
|
|
71
|
+
* drawing a growing ring draws a held press with no change.
|
|
72
|
+
*/
|
|
73
|
+
const GRAB_RING = 0.3
|
|
74
|
+
|
|
60
75
|
export interface CameraFrame {
|
|
61
76
|
readonly ms: number
|
|
62
77
|
/** How far the view is zoomed, 1 being the whole page. */
|
|
@@ -91,16 +106,30 @@ interface Window {
|
|
|
91
106
|
readonly aims: Aim[]
|
|
92
107
|
}
|
|
93
108
|
|
|
94
|
-
export
|
|
109
|
+
export interface CameraOptions {
|
|
110
|
+
/**
|
|
111
|
+
* How much faster the edit plays the clip, so the track is built on the
|
|
112
|
+
* playback clock and a zoom settles at the speed a viewer sees. 1 by default.
|
|
113
|
+
*/
|
|
114
|
+
readonly rate?: number
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function cameraTrack(
|
|
118
|
+
source: TakeLog,
|
|
119
|
+
options: CameraOptions = {},
|
|
120
|
+
): CameraTrack {
|
|
121
|
+
const log = atRate(source, options.rate ?? 1)
|
|
95
122
|
const { width, height } = log.viewport
|
|
123
|
+
const clip = log.clip ?? CLIP_SIZES[log.route]
|
|
124
|
+
const deepest = Math.min(DEPTH, (SHARP_DEPTH * clip.width) / width)
|
|
96
125
|
const depthFor = (box: Box): number =>
|
|
97
126
|
Math.min(
|
|
98
|
-
|
|
127
|
+
deepest,
|
|
99
128
|
(width * FILL) / Math.max(box.width, 1),
|
|
100
129
|
(height * FILL) / Math.max(box.height, 1),
|
|
101
130
|
)
|
|
102
131
|
|
|
103
|
-
const aims: Aim[] = log.events
|
|
132
|
+
const aims: Aim[] = (log.push === false ? [] : log.events)
|
|
104
133
|
.flatMap((event): Aim[] => {
|
|
105
134
|
if (event.kind === 'click') {
|
|
106
135
|
return [
|
|
@@ -112,6 +141,19 @@ export function cameraTrack(log: TakeLog): CameraTrack {
|
|
|
112
141
|
},
|
|
113
142
|
]
|
|
114
143
|
}
|
|
144
|
+
if (event.kind === 'drag') {
|
|
145
|
+
// One aim over both ends, held to the release, so a short drag stays
|
|
146
|
+
// framed whole and a long one asks for a depth under MIN_ZOOM.
|
|
147
|
+
const box = union(event.box, event.drop)
|
|
148
|
+
return [
|
|
149
|
+
{
|
|
150
|
+
t: event.t,
|
|
151
|
+
until: event.t + event.ms,
|
|
152
|
+
at: { x: box.x + box.width / 2, y: box.y + box.height / 2 },
|
|
153
|
+
depth: depthFor(box),
|
|
154
|
+
},
|
|
155
|
+
]
|
|
156
|
+
}
|
|
115
157
|
if (event.kind === 'type') {
|
|
116
158
|
const box = event.box
|
|
117
159
|
return [
|
|
@@ -129,9 +171,26 @@ export function cameraTrack(log: TakeLog): CameraTrack {
|
|
|
129
171
|
.sort((a, b) => a.t - b.t)
|
|
130
172
|
|
|
131
173
|
const scrolls = log.events.filter((event) => event.kind === 'scroll')
|
|
132
|
-
// Read off every
|
|
174
|
+
// Read off every press rather than off the aims, which drop a click too large
|
|
133
175
|
// to zoom and add a typing burst that is no press.
|
|
134
|
-
const
|
|
176
|
+
const presses = log.events.flatMap((event) =>
|
|
177
|
+
event.kind === 'click'
|
|
178
|
+
? [{ t: event.t, release: event.t }]
|
|
179
|
+
: event.kind === 'drag'
|
|
180
|
+
? [{ t: event.t, release: event.t + event.ms }]
|
|
181
|
+
: [],
|
|
182
|
+
)
|
|
183
|
+
const ringAt = (ms: number): number | null => {
|
|
184
|
+
for (const press of presses) {
|
|
185
|
+
if (ms < press.t) continue
|
|
186
|
+
const grown = (ms - press.t) / RING_MS
|
|
187
|
+
if (ms <= press.release) return Math.min(GRAB_RING, grown)
|
|
188
|
+
const held = Math.min(GRAB_RING, (press.release - press.t) / RING_MS)
|
|
189
|
+
const ring = held + (ms - press.release) / RING_MS
|
|
190
|
+
if (ring <= 1) return ring
|
|
191
|
+
}
|
|
192
|
+
return null
|
|
193
|
+
}
|
|
135
194
|
const windows = zoomWindows(aims, scrolls)
|
|
136
195
|
|
|
137
196
|
const targetAt = (ms: number) => {
|
|
@@ -196,16 +255,14 @@ export function cameraTrack(log: TakeLog): CameraTrack {
|
|
|
196
255
|
cursor.y + half.y * EDGE,
|
|
197
256
|
)
|
|
198
257
|
|
|
199
|
-
const ring =
|
|
200
|
-
(click) => ms >= click.t && ms - click.t <= RING_MS,
|
|
201
|
-
)
|
|
258
|
+
const ring = ringAt(ms)
|
|
202
259
|
frames.push({
|
|
203
260
|
ms: round(ms, 2),
|
|
204
261
|
scale: round(scale, 4),
|
|
205
262
|
x: round(clamp(springs.x.value, half.x, width - half.x) - half.x, 2),
|
|
206
263
|
y: round(clamp(springs.y.value, half.y, height - half.y) - half.y, 2),
|
|
207
264
|
cursor: roundPoint(cursor),
|
|
208
|
-
ring: ring ? round(
|
|
265
|
+
ring: ring === null ? null : round(ring, 3),
|
|
209
266
|
})
|
|
210
267
|
}
|
|
211
268
|
|
|
@@ -217,6 +274,27 @@ export function cameraTrack(log: TakeLog): CameraTrack {
|
|
|
217
274
|
}
|
|
218
275
|
}
|
|
219
276
|
|
|
277
|
+
/** Reads a playback rate off a flag, which has to be a positive number. */
|
|
278
|
+
export function readRate(value: string): number | undefined {
|
|
279
|
+
const rate = Number(value)
|
|
280
|
+
return Number.isFinite(rate) && rate > 0 ? rate : undefined
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** The log as a clip played `rate` times faster shows it, every time divided. */
|
|
284
|
+
function atRate(log: TakeLog, rate: number): TakeLog {
|
|
285
|
+
if (rate === 1) return log
|
|
286
|
+
return {
|
|
287
|
+
...log,
|
|
288
|
+
frames: Math.round(log.frames / rate),
|
|
289
|
+
events: log.events.map((event): LogEvent => {
|
|
290
|
+
const t = event.t / rate
|
|
291
|
+
return 'ms' in event
|
|
292
|
+
? { ...event, t, ms: event.ms / rate }
|
|
293
|
+
: { ...event, t }
|
|
294
|
+
}),
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
220
298
|
/**
|
|
221
299
|
* Groups aims into zooms. One holds from its first aim to the last plus the
|
|
222
300
|
* hold, a scroll ends it since the content moves under the camera, and the next
|
|
@@ -295,6 +373,17 @@ function focusAt(
|
|
|
295
373
|
return focus
|
|
296
374
|
}
|
|
297
375
|
|
|
376
|
+
function union(a: Box, b: Box): Box {
|
|
377
|
+
const x = Math.min(a.x, b.x)
|
|
378
|
+
const y = Math.min(a.y, b.y)
|
|
379
|
+
return {
|
|
380
|
+
x,
|
|
381
|
+
y,
|
|
382
|
+
width: Math.max(a.x + a.width, b.x + b.width) - x,
|
|
383
|
+
height: Math.max(a.y + a.height, b.y + b.height) - y,
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
298
387
|
function clamp(value: number, low: number, high: number): number {
|
|
299
388
|
return Math.min(Math.max(value, low), high)
|
|
300
389
|
}
|
package/src/take/film.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { isBrowserMissing, isServerUnreachable } from '@/browser/engine'
|
|
|
4
4
|
import { cameraTrack } from '@/take/camera'
|
|
5
5
|
import { FfmpegMissing, requireFfmpeg } from '@/take/encode'
|
|
6
6
|
import {
|
|
7
|
+
CLIP_SIZES,
|
|
7
8
|
FPS,
|
|
8
9
|
LOG_VERSION,
|
|
9
10
|
type LogEvent,
|
|
@@ -60,6 +61,10 @@ export type TakeResult =
|
|
|
60
61
|
readonly reason: TakeRefusal
|
|
61
62
|
readonly message: string
|
|
62
63
|
readonly beat?: string
|
|
64
|
+
/** Why the refused beat had fallen back to a screencast, when it had. */
|
|
65
|
+
readonly fallback?: string
|
|
66
|
+
/** The page as the failing step met it, relative to the take's folder. */
|
|
67
|
+
readonly still?: string
|
|
63
68
|
}
|
|
64
69
|
|
|
65
70
|
export interface TakeRequest {
|
|
@@ -67,6 +72,8 @@ export interface TakeRequest {
|
|
|
67
72
|
readonly plan: TakePlan
|
|
68
73
|
readonly out: string
|
|
69
74
|
readonly env?: NodeJS.ProcessEnv
|
|
75
|
+
/** How long one stepped frame may take before its beat falls back to a screencast. */
|
|
76
|
+
readonly stallMs?: number
|
|
70
77
|
}
|
|
71
78
|
|
|
72
79
|
const REACH_TIMEOUT_MS = 5000
|
|
@@ -108,9 +115,26 @@ export async function take(request: TakeRequest): Promise<TakeResult> {
|
|
|
108
115
|
const dir = `${String(index + 1).padStart(2, '0')}-${slug(beat.name)}`
|
|
109
116
|
mkdirSync(join(out, dir), { recursive: true })
|
|
110
117
|
try {
|
|
111
|
-
beats.push(
|
|
118
|
+
beats.push(
|
|
119
|
+
await filmBeat(
|
|
120
|
+
request.url,
|
|
121
|
+
beat,
|
|
122
|
+
out,
|
|
123
|
+
dir,
|
|
124
|
+
executable,
|
|
125
|
+
request.stallMs,
|
|
126
|
+
),
|
|
127
|
+
)
|
|
112
128
|
} catch (error) {
|
|
113
|
-
|
|
129
|
+
const cause = error instanceof FellBack ? error.error : error
|
|
130
|
+
const shot = cause instanceof TargetMissing ? cause.still : undefined
|
|
131
|
+
const still = shot ? join(dir, 'still.jpg') : undefined
|
|
132
|
+
if (shot && still) writeFileSync(join(out, still), shot)
|
|
133
|
+
return {
|
|
134
|
+
...failed(classify(cause), cause, beat.name),
|
|
135
|
+
...(error instanceof FellBack ? { fallback: error.fallback } : {}),
|
|
136
|
+
...(still ? { still } : {}),
|
|
137
|
+
}
|
|
114
138
|
}
|
|
115
139
|
}
|
|
116
140
|
|
|
@@ -127,6 +151,7 @@ async function filmBeat(
|
|
|
127
151
|
out: string,
|
|
128
152
|
dir: string,
|
|
129
153
|
executable: string,
|
|
154
|
+
stallMs: number | undefined,
|
|
130
155
|
): Promise<FilmedBeat> {
|
|
131
156
|
const clip = join(dir, 'clip.mp4')
|
|
132
157
|
const target = join(out, clip)
|
|
@@ -136,12 +161,22 @@ async function filmBeat(
|
|
|
136
161
|
let filmed: { frames: number; events: readonly LogEvent[]; still: Buffer }
|
|
137
162
|
if (beat.route === 'step') {
|
|
138
163
|
try {
|
|
139
|
-
filmed = await filmStepped({
|
|
164
|
+
filmed = await filmStepped({
|
|
165
|
+
executable,
|
|
166
|
+
url,
|
|
167
|
+
beat,
|
|
168
|
+
clip: target,
|
|
169
|
+
stallMs,
|
|
170
|
+
})
|
|
140
171
|
} catch (error) {
|
|
141
172
|
if (!(error instanceof StepperStalled)) throw error
|
|
142
173
|
route = 'screencast'
|
|
143
174
|
fallback = error.message
|
|
144
|
-
|
|
175
|
+
try {
|
|
176
|
+
filmed = await filmScreencast({ url, beat, clip: target })
|
|
177
|
+
} catch (cause) {
|
|
178
|
+
throw new FellBack(cause, fallback)
|
|
179
|
+
}
|
|
145
180
|
}
|
|
146
181
|
} else {
|
|
147
182
|
filmed = await filmScreencast({ url, beat, clip: target })
|
|
@@ -152,6 +187,8 @@ async function filmBeat(
|
|
|
152
187
|
viewport: VIEWPORT,
|
|
153
188
|
fps: FPS,
|
|
154
189
|
route,
|
|
190
|
+
clip: CLIP_SIZES[route],
|
|
191
|
+
...(beat.push === false ? { push: false as const } : {}),
|
|
155
192
|
frames: filmed.frames,
|
|
156
193
|
events: filmed.events,
|
|
157
194
|
}
|
|
@@ -178,6 +215,20 @@ async function filmBeat(
|
|
|
178
215
|
}
|
|
179
216
|
}
|
|
180
217
|
|
|
218
|
+
/**
|
|
219
|
+
* A beat that failed in the screencast it fell back to, carrying why it fell
|
|
220
|
+
* back, since the fallback loads the page a second time and a single-use link
|
|
221
|
+
* is spent by then.
|
|
222
|
+
*/
|
|
223
|
+
class FellBack extends Error {
|
|
224
|
+
constructor(
|
|
225
|
+
readonly error: unknown,
|
|
226
|
+
readonly fallback: string,
|
|
227
|
+
) {
|
|
228
|
+
super(error instanceof Error ? error.message : String(error))
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
181
232
|
function classify(error: unknown): TakeRefusal {
|
|
182
233
|
if (error instanceof TargetMissing) return 'target-missing'
|
|
183
234
|
if (error instanceof ShellMissing || isBrowserMissing(error)) {
|