@erclx/canon 5.66.0 → 5.67.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 +11 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/app-drive/REQUIREMENT.md +35 -0
- package/claude/skills/app-drive/SKILL.md +112 -0
- package/claude/skills/app-drive/references/config.md +38 -0
- package/claude/skills/app-drive/references/drive-record.md +70 -0
- package/claude/skills/app-drive/references/isolate.md +46 -0
- package/claude/skills/canon-cli/SKILL.md +2 -0
- package/claude/skills/create-skill/references/skill.md +1 -1
- package/claude/skills/plan-feature/references/plan.md +1 -1
- package/claude/skills/plan-intake/references/intake.md +12 -2
- package/claude/skills/review-ui/SKILL.md +5 -4
- package/claude/skills/review-ui/references/drive-copy.md +4 -0
- package/claude/skills/review-ui/references/driving.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-handoff.md +15 -2
- package/claude/skills/role-planner/SKILL.md +2 -2
- package/claude/skills/role-worker/SKILL.md +4 -6
- package/claude/skills/session-map/REQUIREMENT.md +1 -1
- package/claude/skills/session-map/SKILL.md +8 -1
- package/claude/skills/session-map/references/session.md +19 -5
- package/claude/skills/ui-record/REQUIREMENT.md +34 -0
- package/claude/skills/ui-record/SKILL.md +103 -0
- package/claude/skills/ui-record/references/config.md +40 -0
- package/claude/skills/ui-record/references/handoff.md +48 -0
- package/claude/skills/ui-record/references/take-craft.md +92 -0
- package/docs/agents/records.md +1 -0
- package/docs/workflow/parallel-features.md +1 -1
- package/docs/workflow/skill-map-on-request.md +1 -0
- package/docs/workflow/skill-map.md +1 -0
- package/docs/workflow/visual-design-workflow.md +2 -1
- package/package.json +1 -1
- package/src/audits/catalog.ts +5 -1
- package/src/browser/agent-cli.ts +68 -0
- package/src/claude/cases/workflow.ts +20 -0
- package/src/cli/help.ts +3 -0
- package/src/cli.ts +4 -0
- package/src/commands/browser.ts +102 -0
- package/src/commands/intake.ts +181 -1
- package/src/commands/pr.ts +15 -6
- package/src/commands/records.ts +18 -1
- package/src/commands/take.ts +266 -0
- package/src/context/requirements.ts +1 -11
- package/src/git/status.ts +73 -0
- package/src/intake/drive-check.ts +355 -0
- package/src/intake/folder.ts +1 -1
- package/src/markdown/scan.ts +10 -0
- package/src/pr/drive-copy.ts +97 -84
- package/src/records/session-map.ts +75 -0
- package/src/records/validate.ts +64 -8
- package/src/take/camera.ts +309 -0
- package/src/take/encode.ts +170 -0
- package/src/take/film.ts +206 -0
- package/src/take/fixtures/fade.html +49 -0
- package/src/take/fixtures/fetch.html +38 -0
- package/src/take/log.ts +109 -0
- package/src/take/perform.ts +200 -0
- package/src/take/plan.ts +193 -0
- package/src/take/screencast.ts +94 -0
- package/src/take/shell.ts +287 -0
- package/src/take/stepper.ts +252 -0
- package/src/take/zoom/NOTICE.md +45 -0
- package/src/take/zoom/math-utils.ts +69 -0
- package/src/take/zoom/region-utils.ts +55 -0
- package/src/take/zoom/spring.ts +133 -0
- package/src/tasks/archive.ts +1 -3
- package/claude/skills/review-ui/scripts/pw.sh +0 -60
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { bodyLines, wordCount } from '@/markdown/scan'
|
|
2
|
+
|
|
3
|
+
/** The pre-compaction handoff takes one file per session, so its stems vary. */
|
|
4
|
+
export const SESSION_MAP_PREFIX = 'session-'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The most words a session map holds, which `session.md` states as
|
|
8
|
+
* `at most 350 words`. It lives here rather than being read off the standard
|
|
9
|
+
* because a consumer project loads skills from the plugin cache and carries no
|
|
10
|
+
* `session.md` to read, so a test pins the two to one number instead.
|
|
11
|
+
*/
|
|
12
|
+
export const SESSION_MAP_WORD_CAP = 350
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The closing block a role mandates, read by the resuming session rather than
|
|
16
|
+
* written from what this one learned, so it is left out of the count by name.
|
|
17
|
+
*/
|
|
18
|
+
const RESUME_HEADING = '## Resume'
|
|
19
|
+
|
|
20
|
+
const TITLE = /^#[ \t]+\S/
|
|
21
|
+
const H2 = /^##[ \t]+\S/
|
|
22
|
+
|
|
23
|
+
export interface SessionMapLength {
|
|
24
|
+
/** The map's filename inside the tasks folder. */
|
|
25
|
+
readonly record: string
|
|
26
|
+
readonly words: number
|
|
27
|
+
readonly cap: number
|
|
28
|
+
readonly over: boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function isSessionMap(name: string): boolean {
|
|
32
|
+
return name.startsWith(SESSION_MAP_PREFIX) && name.endsWith('.md')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Counts what a resuming reader has to read: the body below the title, with
|
|
37
|
+
* frontmatter and a `## Resume` section left out. A fenced block counts the way
|
|
38
|
+
* it does in the requirements record, since a reader reads it too, and a
|
|
39
|
+
* heading inside one neither opens nor closes a section.
|
|
40
|
+
*/
|
|
41
|
+
export function sessionMapWords(source: string): number {
|
|
42
|
+
const kept: string[] = []
|
|
43
|
+
let titled = false
|
|
44
|
+
let resuming = false
|
|
45
|
+
|
|
46
|
+
for (const line of bodyLines(source)) {
|
|
47
|
+
const text = line.text.trim()
|
|
48
|
+
|
|
49
|
+
if (!line.fenced && H2.test(text)) resuming = text === RESUME_HEADING
|
|
50
|
+
if (resuming) continue
|
|
51
|
+
|
|
52
|
+
if (!titled && !line.fenced && TITLE.test(text)) {
|
|
53
|
+
titled = true
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
kept.push(line.text)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return wordCount(kept.join('\n'))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function measureSessionMap(
|
|
64
|
+
record: string,
|
|
65
|
+
source: string,
|
|
66
|
+
): SessionMapLength {
|
|
67
|
+
const words = sessionMapWords(source)
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
record,
|
|
71
|
+
words,
|
|
72
|
+
cap: SESSION_MAP_WORD_CAP,
|
|
73
|
+
over: words > SESSION_MAP_WORD_CAP,
|
|
74
|
+
}
|
|
75
|
+
}
|
package/src/records/validate.ts
CHANGED
|
@@ -3,6 +3,11 @@ import { readdir, readFile } from 'node:fs/promises'
|
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
import { parseFrontmatter, readField } from '@/indexes/frontmatter'
|
|
5
5
|
import { linesOutsideFences } from '@/markdown/scan'
|
|
6
|
+
import {
|
|
7
|
+
isSessionMap,
|
|
8
|
+
measureSessionMap,
|
|
9
|
+
type SessionMapLength,
|
|
10
|
+
} from '@/records/session-map'
|
|
6
11
|
import {
|
|
7
12
|
intakeStructure,
|
|
8
13
|
type StructureEntry,
|
|
@@ -13,7 +18,12 @@ import {
|
|
|
13
18
|
recordDirs as resolveRecordDirs,
|
|
14
19
|
} from '@/roots/record'
|
|
15
20
|
|
|
16
|
-
export const RECORD_KINDS = [
|
|
21
|
+
export const RECORD_KINDS = [
|
|
22
|
+
'plans',
|
|
23
|
+
'groundwork',
|
|
24
|
+
'intake',
|
|
25
|
+
'sessions',
|
|
26
|
+
] as const
|
|
17
27
|
|
|
18
28
|
export type RecordKind = (typeof RECORD_KINDS)[number]
|
|
19
29
|
|
|
@@ -79,6 +89,12 @@ export interface ValidateReport {
|
|
|
79
89
|
readonly records: number
|
|
80
90
|
readonly findings: readonly Finding[]
|
|
81
91
|
readonly structure: readonly StructureFinding[]
|
|
92
|
+
/**
|
|
93
|
+
* Each session map's words against its cap, carried by the sessions kind
|
|
94
|
+
* alone. It never sets the exit, since a map refused near a compaction loses
|
|
95
|
+
* the reasoning it was written to save.
|
|
96
|
+
*/
|
|
97
|
+
readonly length?: readonly SessionMapLength[]
|
|
82
98
|
}
|
|
83
99
|
|
|
84
100
|
export interface ValidateRefused {
|
|
@@ -89,9 +105,20 @@ export interface ValidateRefused {
|
|
|
89
105
|
|
|
90
106
|
export type ValidateOutcome = ValidateReport | ValidateRefused
|
|
91
107
|
|
|
108
|
+
/**
|
|
109
|
+
* The record folder each kind reads. Session maps sit on the task board beside
|
|
110
|
+
* the tasks rather than in a folder of their own.
|
|
111
|
+
*/
|
|
112
|
+
const KIND_FOLDER: Readonly<Record<RecordKind, string>> = {
|
|
113
|
+
plans: 'plans',
|
|
114
|
+
groundwork: 'groundwork',
|
|
115
|
+
intake: 'intake',
|
|
116
|
+
sessions: 'tasks',
|
|
117
|
+
}
|
|
118
|
+
|
|
92
119
|
/** Every folder a kind would accept, whether or not it is on disk. */
|
|
93
120
|
export function recordDirs(root: string, kind: RecordKind): string[] {
|
|
94
|
-
return resolveRecordDirs(root, kind)
|
|
121
|
+
return resolveRecordDirs(root, KIND_FOLDER[kind])
|
|
95
122
|
}
|
|
96
123
|
|
|
97
124
|
/**
|
|
@@ -100,7 +127,7 @@ export function recordDirs(root: string, kind: RecordKind): string[] {
|
|
|
100
127
|
* the location the kind would be written to.
|
|
101
128
|
*/
|
|
102
129
|
export function recordsDir(root: string, kind: RecordKind): string {
|
|
103
|
-
return resolveRecordDir(root, kind)
|
|
130
|
+
return resolveRecordDir(root, KIND_FOLDER[kind])
|
|
104
131
|
}
|
|
105
132
|
|
|
106
133
|
export function isRecordKind(value: string): value is RecordKind {
|
|
@@ -758,7 +785,7 @@ async function checkDump(dir: string, slug: string): Promise<Finding[]> {
|
|
|
758
785
|
*/
|
|
759
786
|
const FOLDER_CHECK: Readonly<
|
|
760
787
|
Record<
|
|
761
|
-
Exclude<RecordKind, 'plans'>,
|
|
788
|
+
Exclude<RecordKind, 'plans' | 'sessions'>,
|
|
762
789
|
(dir: string, slug: string) => Promise<Finding[]>
|
|
763
790
|
>
|
|
764
791
|
> = {
|
|
@@ -836,6 +863,37 @@ async function validateFiles(
|
|
|
836
863
|
}
|
|
837
864
|
}
|
|
838
865
|
|
|
866
|
+
/**
|
|
867
|
+
* The walk for session maps, which measures length alone. Their shape is not
|
|
868
|
+
* checked, so `findings` stays empty and the exit stays clean whatever the
|
|
869
|
+
* count reads.
|
|
870
|
+
*/
|
|
871
|
+
async function validateSessionMaps(
|
|
872
|
+
dir: string,
|
|
873
|
+
files: readonly string[],
|
|
874
|
+
): Promise<ValidateReport> {
|
|
875
|
+
const length = await Promise.all(
|
|
876
|
+
files.map(async (file) =>
|
|
877
|
+
measureSessionMap(file, await readFile(join(dir, file), 'utf8')),
|
|
878
|
+
),
|
|
879
|
+
)
|
|
880
|
+
|
|
881
|
+
return {
|
|
882
|
+
ok: true,
|
|
883
|
+
kind: 'sessions',
|
|
884
|
+
records: files.length,
|
|
885
|
+
findings: [],
|
|
886
|
+
structure: [],
|
|
887
|
+
length,
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
async function listRecords(dir: string, kind: RecordKind): Promise<string[]> {
|
|
892
|
+
if (kind === 'plans') return listMarkdown(dir)
|
|
893
|
+
if (kind === 'sessions') return (await listMarkdown(dir)).filter(isSessionMap)
|
|
894
|
+
return listFolders(dir)
|
|
895
|
+
}
|
|
896
|
+
|
|
839
897
|
/**
|
|
840
898
|
* Narrows a listing to the one record a caller named, by a plan's stem or a
|
|
841
899
|
* folder's slug, so a writing skill reads its own record and not every old one.
|
|
@@ -867,16 +925,14 @@ export async function validateRecords(
|
|
|
867
925
|
)
|
|
868
926
|
}
|
|
869
927
|
|
|
870
|
-
const names = narrow(
|
|
871
|
-
kind === 'plans' ? await listMarkdown(dir) : await listFolders(dir),
|
|
872
|
-
record,
|
|
873
|
-
)
|
|
928
|
+
const names = narrow(await listRecords(dir, kind), record)
|
|
874
929
|
|
|
875
930
|
if (record !== undefined && names.length === 0) {
|
|
876
931
|
return refuse('no-record', `No ${kind} record named ${record} in ${dir}.`)
|
|
877
932
|
}
|
|
878
933
|
|
|
879
934
|
if (kind === 'plans') return validateFiles(dir, kind, names, checkPlan)
|
|
935
|
+
if (kind === 'sessions') return validateSessionMaps(dir, names)
|
|
880
936
|
|
|
881
937
|
const check = FOLDER_CHECK[kind]
|
|
882
938
|
const perFolder = await Promise.all(
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Box,
|
|
3
|
+
type LogEvent,
|
|
4
|
+
type Point,
|
|
5
|
+
type TakeLog,
|
|
6
|
+
cursorAt,
|
|
7
|
+
} from '@/take/log'
|
|
8
|
+
import {
|
|
9
|
+
type SpringState,
|
|
10
|
+
createSpringState,
|
|
11
|
+
stepSpring,
|
|
12
|
+
} from '@/take/zoom/spring'
|
|
13
|
+
import {
|
|
14
|
+
CHAINED_ZOOM_PAN_GAP_MS,
|
|
15
|
+
CONNECTED_ZOOM_PAN_DURATION_MS,
|
|
16
|
+
computeRegionStrength,
|
|
17
|
+
easeConnectedPan,
|
|
18
|
+
lerp,
|
|
19
|
+
} from '@/take/zoom/region-utils'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Turns a take's log into one camera per frame, preset O4 from groundwork
|
|
23
|
+
* track `193-ui-record`: OpenScreen's timing aimed at each click, a fixed
|
|
24
|
+
* depth, a focus that holds still until a click leaves the inner part of the
|
|
25
|
+
* view, and a dead zone that keeps the cursor in frame. HyperFrames renders the
|
|
26
|
+
* track as it stands, so it carries no curve a renderer would have to agree on.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export const CAMERA_VERSION = 1
|
|
30
|
+
|
|
31
|
+
/** The depth every click zooms to, unless its target already fills the view. */
|
|
32
|
+
const DEPTH = 1.8
|
|
33
|
+
|
|
34
|
+
/** How much of the zoomed view a target may fill, which caps the depth for a big one. */
|
|
35
|
+
const FILL = 0.8
|
|
36
|
+
|
|
37
|
+
/** A click whose depth comes out under this zooms nowhere, being nearly the whole page. */
|
|
38
|
+
const MIN_ZOOM = 1.05
|
|
39
|
+
|
|
40
|
+
/** How long a zoom holds past its last click or keystroke. */
|
|
41
|
+
const HOLD_MS = 300
|
|
42
|
+
|
|
43
|
+
/** A click inside this share of the current view keeps the focus where it is. */
|
|
44
|
+
const STICKY = 0.7
|
|
45
|
+
|
|
46
|
+
/** The cursor stays inside this share of the zoomed view. */
|
|
47
|
+
const SAFE = 0.75
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The cursor never passes this share of the rendered view. The dead zone above
|
|
51
|
+
* aims the camera, and the spring chasing it lags a long fast reach by more
|
|
52
|
+
* than the dead zone's margin, so the rendered view is pushed along with the
|
|
53
|
+
* cursor at this edge rather than letting it leave the frame.
|
|
54
|
+
*/
|
|
55
|
+
const EDGE = 0.9
|
|
56
|
+
|
|
57
|
+
/** How long the click ring the edit draws takes to expand. */
|
|
58
|
+
const RING_MS = 450
|
|
59
|
+
|
|
60
|
+
export interface CameraFrame {
|
|
61
|
+
readonly ms: number
|
|
62
|
+
/** How far the view is zoomed, 1 being the whole page. */
|
|
63
|
+
readonly scale: number
|
|
64
|
+
/** The view's top-left corner in CSS pixels of the page. */
|
|
65
|
+
readonly x: number
|
|
66
|
+
readonly y: number
|
|
67
|
+
/** Where the edit draws the cursor, in CSS pixels of the page. */
|
|
68
|
+
readonly cursor: Point
|
|
69
|
+
/** How far a click's ring has expanded, 0 to 1, or null with none showing. */
|
|
70
|
+
readonly ring: number | null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface CameraTrack {
|
|
74
|
+
readonly version: typeof CAMERA_VERSION
|
|
75
|
+
readonly viewport: { readonly width: number; readonly height: number }
|
|
76
|
+
readonly fps: number
|
|
77
|
+
readonly frames: readonly CameraFrame[]
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A click, or a typing burst read as a click on its field held to the last key. */
|
|
81
|
+
interface Aim {
|
|
82
|
+
readonly t: number
|
|
83
|
+
readonly until: number
|
|
84
|
+
readonly at: Point
|
|
85
|
+
readonly depth: number
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface Window {
|
|
89
|
+
start: number
|
|
90
|
+
end: number
|
|
91
|
+
readonly aims: Aim[]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function cameraTrack(log: TakeLog): CameraTrack {
|
|
95
|
+
const { width, height } = log.viewport
|
|
96
|
+
const depthFor = (box: Box): number =>
|
|
97
|
+
Math.min(
|
|
98
|
+
DEPTH,
|
|
99
|
+
(width * FILL) / Math.max(box.width, 1),
|
|
100
|
+
(height * FILL) / Math.max(box.height, 1),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
const aims: Aim[] = log.events
|
|
104
|
+
.flatMap((event): Aim[] => {
|
|
105
|
+
if (event.kind === 'click') {
|
|
106
|
+
return [
|
|
107
|
+
{
|
|
108
|
+
t: event.t,
|
|
109
|
+
until: event.t,
|
|
110
|
+
at: event.at,
|
|
111
|
+
depth: depthFor(event.box),
|
|
112
|
+
},
|
|
113
|
+
]
|
|
114
|
+
}
|
|
115
|
+
if (event.kind === 'type') {
|
|
116
|
+
const box = event.box
|
|
117
|
+
return [
|
|
118
|
+
{
|
|
119
|
+
t: event.t,
|
|
120
|
+
until: event.t + event.ms,
|
|
121
|
+
at: { x: box.x + box.width / 2, y: box.y + box.height / 2 },
|
|
122
|
+
depth: depthFor(box),
|
|
123
|
+
},
|
|
124
|
+
]
|
|
125
|
+
}
|
|
126
|
+
return []
|
|
127
|
+
})
|
|
128
|
+
.filter((aim) => aim.depth >= MIN_ZOOM)
|
|
129
|
+
.sort((a, b) => a.t - b.t)
|
|
130
|
+
|
|
131
|
+
const scrolls = log.events.filter((event) => event.kind === 'scroll')
|
|
132
|
+
// Read off every click rather than off the aims, which drop a click too large
|
|
133
|
+
// to zoom and add a typing burst that is no press.
|
|
134
|
+
const clicks = log.events.filter((event) => event.kind === 'click')
|
|
135
|
+
const windows = zoomWindows(aims, scrolls)
|
|
136
|
+
|
|
137
|
+
const targetAt = (ms: number) => {
|
|
138
|
+
let best = { strength: 0, scale: 1, x: width / 2, y: height / 2 }
|
|
139
|
+
for (const window of windows) {
|
|
140
|
+
const strength = computeRegionStrength(
|
|
141
|
+
{ startMs: window.aims[0]?.t ?? window.start, endMs: window.end },
|
|
142
|
+
ms,
|
|
143
|
+
)
|
|
144
|
+
if (strength <= best.strength) continue
|
|
145
|
+
|
|
146
|
+
const focus = focusAt(window.aims, ms, width, height)
|
|
147
|
+
const half = {
|
|
148
|
+
x: width / (2 * focus.depth),
|
|
149
|
+
y: height / (2 * focus.depth),
|
|
150
|
+
}
|
|
151
|
+
const cursor = cursorAt(log.events, ms)
|
|
152
|
+
const x = clamp(
|
|
153
|
+
focus.x,
|
|
154
|
+
cursor.x - half.x * SAFE,
|
|
155
|
+
cursor.x + half.x * SAFE,
|
|
156
|
+
)
|
|
157
|
+
const y = clamp(
|
|
158
|
+
focus.y,
|
|
159
|
+
cursor.y - half.y * SAFE,
|
|
160
|
+
cursor.y + half.y * SAFE,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
best = {
|
|
164
|
+
strength,
|
|
165
|
+
scale: lerp(1, focus.depth, strength),
|
|
166
|
+
x: lerp(width / 2, x, strength),
|
|
167
|
+
y: lerp(height / 2, y, strength),
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return best
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const frameMs = 1000 / log.fps
|
|
174
|
+
const springs: Record<'scale' | 'x' | 'y', SpringState> = {
|
|
175
|
+
scale: createSpringState(1),
|
|
176
|
+
x: createSpringState(width / 2),
|
|
177
|
+
y: createSpringState(height / 2),
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const frames: CameraFrame[] = []
|
|
181
|
+
for (let index = 0; index < log.frames; index += 1) {
|
|
182
|
+
const ms = index * frameMs
|
|
183
|
+
const target = targetAt(ms)
|
|
184
|
+
const scale = Math.max(1, stepSpring(springs.scale, target.scale, frameMs))
|
|
185
|
+
const half = { x: width / (2 * scale), y: height / (2 * scale) }
|
|
186
|
+
const cursor = cursorAt(log.events, ms)
|
|
187
|
+
// Written back so the spring carries on from where the view was pushed.
|
|
188
|
+
springs.x.value = clamp(
|
|
189
|
+
stepSpring(springs.x, target.x, frameMs),
|
|
190
|
+
cursor.x - half.x * EDGE,
|
|
191
|
+
cursor.x + half.x * EDGE,
|
|
192
|
+
)
|
|
193
|
+
springs.y.value = clamp(
|
|
194
|
+
stepSpring(springs.y, target.y, frameMs),
|
|
195
|
+
cursor.y - half.y * EDGE,
|
|
196
|
+
cursor.y + half.y * EDGE,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
const ring = clicks.find(
|
|
200
|
+
(click) => ms >= click.t && ms - click.t <= RING_MS,
|
|
201
|
+
)
|
|
202
|
+
frames.push({
|
|
203
|
+
ms: round(ms, 2),
|
|
204
|
+
scale: round(scale, 4),
|
|
205
|
+
x: round(clamp(springs.x.value, half.x, width - half.x) - half.x, 2),
|
|
206
|
+
y: round(clamp(springs.y.value, half.y, height - half.y) - half.y, 2),
|
|
207
|
+
cursor: roundPoint(cursor),
|
|
208
|
+
ring: ring ? round((ms - ring.t) / RING_MS, 3) : null,
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
version: CAMERA_VERSION,
|
|
214
|
+
viewport: log.viewport,
|
|
215
|
+
fps: log.fps,
|
|
216
|
+
frames,
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Groups aims into zooms. One holds from its first aim to the last plus the
|
|
222
|
+
* hold, a scroll ends it since the content moves under the camera, and the next
|
|
223
|
+
* aim joins it while the gap stays under OpenScreen's chained-pan gap.
|
|
224
|
+
*/
|
|
225
|
+
function zoomWindows(
|
|
226
|
+
aims: readonly Aim[],
|
|
227
|
+
scrolls: readonly LogEvent[],
|
|
228
|
+
): Window[] {
|
|
229
|
+
const windows: Window[] = []
|
|
230
|
+
for (const aim of aims) {
|
|
231
|
+
let end = aim.until + HOLD_MS
|
|
232
|
+
const cut = scrolls.find((scroll) => scroll.t > aim.t && scroll.t < end)
|
|
233
|
+
if (cut) end = cut.t
|
|
234
|
+
|
|
235
|
+
const last = windows.at(-1)
|
|
236
|
+
const scrolledBetween =
|
|
237
|
+
last !== undefined &&
|
|
238
|
+
scrolls.some((scroll) => scroll.t >= last.end && scroll.t <= aim.t)
|
|
239
|
+
if (
|
|
240
|
+
last &&
|
|
241
|
+
!scrolledBetween &&
|
|
242
|
+
aim.t - last.end < CHAINED_ZOOM_PAN_GAP_MS
|
|
243
|
+
) {
|
|
244
|
+
last.end = end
|
|
245
|
+
last.aims.push(aim)
|
|
246
|
+
} else {
|
|
247
|
+
windows.push({ start: aim.t, end, aims: [aim] })
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return windows
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Where a zoom aims at `ms`. The focus stays on a stop until an aim leaves the
|
|
255
|
+
* inner `STICKY` share of the view around it, which becomes the next stop, and
|
|
256
|
+
* it pans to each stop on OpenScreen's connected-pan curve, arriving as the
|
|
257
|
+
* click lands.
|
|
258
|
+
*/
|
|
259
|
+
function focusAt(
|
|
260
|
+
aims: readonly Aim[],
|
|
261
|
+
ms: number,
|
|
262
|
+
width: number,
|
|
263
|
+
height: number,
|
|
264
|
+
): Point & { depth: number } {
|
|
265
|
+
const [first, ...rest] = aims
|
|
266
|
+
if (!first) return { x: width / 2, y: height / 2, depth: 1 }
|
|
267
|
+
|
|
268
|
+
const stops = [first]
|
|
269
|
+
for (const aim of rest) {
|
|
270
|
+
const stop = stops.at(-1) ?? first
|
|
271
|
+
const inside =
|
|
272
|
+
Math.abs(aim.at.x - stop.at.x) < (width / (2 * stop.depth)) * STICKY &&
|
|
273
|
+
Math.abs(aim.at.y - stop.at.y) < (height / (2 * stop.depth)) * STICKY
|
|
274
|
+
if (!inside) stops.push(aim)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
let focus = { x: first.at.x, y: first.at.y, depth: first.depth }
|
|
278
|
+
for (let i = 1; i < stops.length; i += 1) {
|
|
279
|
+
const from = stops[i - 1] ?? first
|
|
280
|
+
const to = stops[i] ?? first
|
|
281
|
+
const panStart = to.t - CONNECTED_ZOOM_PAN_DURATION_MS
|
|
282
|
+
if (ms >= to.t) {
|
|
283
|
+
focus = { x: to.at.x, y: to.at.y, depth: to.depth }
|
|
284
|
+
} else if (ms > panStart) {
|
|
285
|
+
const k = easeConnectedPan(
|
|
286
|
+
(ms - panStart) / CONNECTED_ZOOM_PAN_DURATION_MS,
|
|
287
|
+
)
|
|
288
|
+
focus = {
|
|
289
|
+
x: lerp(from.at.x, to.at.x, k),
|
|
290
|
+
y: lerp(from.at.y, to.at.y, k),
|
|
291
|
+
depth: lerp(from.depth, to.depth, k),
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return focus
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function clamp(value: number, low: number, high: number): number {
|
|
299
|
+
return Math.min(Math.max(value, low), high)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function round(value: number, places: number): number {
|
|
303
|
+
const factor = 10 ** places
|
|
304
|
+
return Math.round(value * factor) / factor
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function roundPoint(point: Point): Point {
|
|
308
|
+
return { x: round(point.x, 1), y: round(point.y, 1) }
|
|
309
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { type ChildProcess, spawn } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Joins a take's frames into an H.264 clip through the system FFmpeg. Canon
|
|
5
|
+
* ships no encoder, so FFmpeg is a setup step a target runs, and its absence is
|
|
6
|
+
* refused up front rather than after a take has been filmed.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export class FfmpegMissing extends Error {}
|
|
10
|
+
|
|
11
|
+
/** H.264 in yuv420p plays in every browser and editor HyperFrames reads from. */
|
|
12
|
+
const OUTPUT = [
|
|
13
|
+
'-c:v',
|
|
14
|
+
'libx264',
|
|
15
|
+
'-preset',
|
|
16
|
+
'medium',
|
|
17
|
+
'-crf',
|
|
18
|
+
'16',
|
|
19
|
+
'-pix_fmt',
|
|
20
|
+
'yuv420p',
|
|
21
|
+
'-movflags',
|
|
22
|
+
'+faststart',
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
/** Resolves when `ffmpeg` runs, and throws `FfmpegMissing` when it does not. */
|
|
26
|
+
export async function requireFfmpeg(): Promise<void> {
|
|
27
|
+
const proc = spawn('ffmpeg', ['-version'], { stdio: 'ignore' })
|
|
28
|
+
try {
|
|
29
|
+
await finished(proc)
|
|
30
|
+
} catch (error) {
|
|
31
|
+
throw new FfmpegMissing(
|
|
32
|
+
`ffmpeg did not run: ${error instanceof Error ? error.message : String(error)}`,
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface FrameEncoder {
|
|
38
|
+
/** Appends one JPEG frame, waiting while FFmpeg catches up. */
|
|
39
|
+
write(jpeg: Buffer): Promise<void>
|
|
40
|
+
/** Ends the stream and resolves once the clip is written. */
|
|
41
|
+
close(): Promise<void>
|
|
42
|
+
/** Stops FFmpeg and leaves whatever it wrote. */
|
|
43
|
+
abort(): void
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Streams JPEG frames into one clip at `fps`, so a 4K take never lands on disk
|
|
48
|
+
* as a folder of stills.
|
|
49
|
+
*/
|
|
50
|
+
export function openFrameEncoder(out: string, fps: number): FrameEncoder {
|
|
51
|
+
const proc = spawn(
|
|
52
|
+
'ffmpeg',
|
|
53
|
+
[
|
|
54
|
+
'-y',
|
|
55
|
+
'-loglevel',
|
|
56
|
+
'error',
|
|
57
|
+
'-f',
|
|
58
|
+
'image2pipe',
|
|
59
|
+
'-framerate',
|
|
60
|
+
String(fps),
|
|
61
|
+
'-c:v',
|
|
62
|
+
'mjpeg',
|
|
63
|
+
'-i',
|
|
64
|
+
'-',
|
|
65
|
+
// A JPEG is full range and H.264 is read as TV range, so without this the
|
|
66
|
+
// levels pass through unscaled and a player crushes the blacks and whites.
|
|
67
|
+
'-vf',
|
|
68
|
+
'scale=in_range=full:out_range=tv',
|
|
69
|
+
...OUTPUT,
|
|
70
|
+
out,
|
|
71
|
+
],
|
|
72
|
+
{ stdio: ['pipe', 'ignore', 'pipe'] },
|
|
73
|
+
)
|
|
74
|
+
const done = finished(proc)
|
|
75
|
+
// Held so a failure while frames are still being written surfaces at close
|
|
76
|
+
// rather than as an unhandled rejection.
|
|
77
|
+
done.catch(() => {})
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
write: (jpeg) =>
|
|
81
|
+
new Promise((resolve, reject) => {
|
|
82
|
+
const stdin = proc.stdin
|
|
83
|
+
if (!stdin || stdin.destroyed) {
|
|
84
|
+
reject(new Error('ffmpeg stopped reading frames'))
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
if (stdin.write(jpeg)) resolve()
|
|
88
|
+
else stdin.once('drain', resolve)
|
|
89
|
+
}),
|
|
90
|
+
close: async () => {
|
|
91
|
+
proc.stdin?.end()
|
|
92
|
+
await done
|
|
93
|
+
},
|
|
94
|
+
abort: () => {
|
|
95
|
+
proc.kill('SIGKILL')
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Re-encodes a recorded clip to the same H.264 output at a constant `fps`. */
|
|
101
|
+
export async function transcode(
|
|
102
|
+
input: string,
|
|
103
|
+
out: string,
|
|
104
|
+
fps: number,
|
|
105
|
+
): Promise<void> {
|
|
106
|
+
const proc = spawn(
|
|
107
|
+
'ffmpeg',
|
|
108
|
+
[
|
|
109
|
+
'-y',
|
|
110
|
+
'-loglevel',
|
|
111
|
+
'error',
|
|
112
|
+
'-i',
|
|
113
|
+
input,
|
|
114
|
+
'-r',
|
|
115
|
+
String(fps),
|
|
116
|
+
...OUTPUT,
|
|
117
|
+
out,
|
|
118
|
+
],
|
|
119
|
+
{ stdio: ['ignore', 'ignore', 'pipe'] },
|
|
120
|
+
)
|
|
121
|
+
await finished(proc)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Counts a clip's frames, which a real-time take cannot know in advance and a
|
|
126
|
+
* camera track has to match one for one.
|
|
127
|
+
*/
|
|
128
|
+
export async function probeFrames(clip: string): Promise<number> {
|
|
129
|
+
const proc = spawn(
|
|
130
|
+
'ffprobe',
|
|
131
|
+
[
|
|
132
|
+
'-v',
|
|
133
|
+
'error',
|
|
134
|
+
'-select_streams',
|
|
135
|
+
'v:0',
|
|
136
|
+
'-count_packets',
|
|
137
|
+
'-show_entries',
|
|
138
|
+
'stream=nb_read_packets',
|
|
139
|
+
'-of',
|
|
140
|
+
'csv=p=0',
|
|
141
|
+
clip,
|
|
142
|
+
],
|
|
143
|
+
{ stdio: ['ignore', 'pipe', 'pipe'] },
|
|
144
|
+
)
|
|
145
|
+
let stdout = ''
|
|
146
|
+
proc.stdout?.on('data', (chunk: Buffer) => {
|
|
147
|
+
stdout += chunk.toString()
|
|
148
|
+
})
|
|
149
|
+
await finished(proc)
|
|
150
|
+
const frames = Number.parseInt(stdout.trim(), 10)
|
|
151
|
+
if (!Number.isFinite(frames)) {
|
|
152
|
+
throw new Error(`ffprobe read no frame count from ${clip}`)
|
|
153
|
+
}
|
|
154
|
+
return frames
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function finished(proc: ChildProcess): Promise<void> {
|
|
158
|
+
let stderr = ''
|
|
159
|
+
proc.stderr?.on('data', (chunk: Buffer) => {
|
|
160
|
+
stderr += chunk.toString()
|
|
161
|
+
})
|
|
162
|
+
return new Promise((resolve, reject) => {
|
|
163
|
+
proc.on('error', reject)
|
|
164
|
+
proc.on('close', (code) => {
|
|
165
|
+
if (code === 0) resolve()
|
|
166
|
+
else
|
|
167
|
+
reject(new Error(`ffmpeg exited ${code}: ${stderr.trim().slice(-400)}`))
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
}
|