@erclx/canon 5.78.0 → 5.80.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 +40 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/app-drive/REQUIREMENT.md +12 -0
- package/claude/skills/app-drive/SKILL.md +44 -8
- package/claude/skills/app-drive/references/config.md +18 -8
- package/claude/skills/app-drive/references/drive-record.md +4 -0
- package/claude/skills/auto-ship/REQUIREMENT.md +1 -1
- package/claude/skills/auto-ship/SKILL.md +5 -4
- package/claude/skills/auto-ship/references/draft-mark.md +33 -15
- package/claude/skills/auto-ship/references/failure-recovery.md +4 -2
- package/claude/skills/canon-cli/SKILL.md +13 -10
- package/claude/skills/design-pick/SKILL.md +8 -0
- package/claude/skills/design-pick/references/design-config.md +9 -6
- package/claude/skills/design-references/REQUIREMENT.md +32 -0
- package/claude/skills/design-references/SKILL.md +120 -0
- package/claude/skills/design-references/references/films.md +90 -0
- package/claude/skills/design-references/references/sites.md +36 -0
- package/claude/skills/design-taste/SKILL.md +16 -4
- package/claude/skills/design-taste/references/adopted.md +5 -1
- package/claude/skills/design-taste/references/kinds.md +1 -0
- package/claude/skills/design-taste/references/preflight.md +27 -1
- package/claude/skills/design-taste/references/tells.md +2 -0
- package/claude/skills/git-followup/SKILL.md +1 -0
- package/claude/skills/git-pr/REQUIREMENT.md +2 -0
- package/claude/skills/git-pr/SKILL.md +2 -0
- package/claude/skills/git-pr/references/final-command.md +34 -2
- package/claude/skills/git-pr/references/pr.md +1 -1
- package/claude/skills/git-ship/REQUIREMENT.md +2 -2
- package/claude/skills/git-ship/SKILL.md +16 -11
- package/claude/skills/git-ship/references/ci-watch.md +28 -4
- package/claude/skills/markdown-craft/references/markdown.md +4 -4
- package/claude/skills/plan-feature/references/plan.md +2 -0
- package/claude/skills/review-address/SKILL.md +2 -1
- package/claude/skills/review-pr/references/key-changes.md +2 -1
- package/claude/skills/review-ui/references/driving.md +33 -1
- package/claude/skills/role-orchestrator/REQUIREMENT.md +1 -1
- package/claude/skills/role-orchestrator/SKILL.md +2 -2
- package/claude/skills/role-orchestrator/references/orchestrator-hand-launch.md +2 -0
- package/claude/skills/role-orchestrator/references/orchestrator-handback.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-limit-stops.md +47 -0
- package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +3 -1
- package/claude/skills/role-worker/SKILL.md +1 -1
- package/claude/skills/search-craft/REQUIREMENT.md +3 -3
- package/claude/skills/search-craft/SKILL.md +12 -1
- package/claude/skills/target-scaffold/REQUIREMENT.md +31 -0
- package/claude/skills/target-scaffold/SKILL.md +44 -0
- package/claude/skills/target-setup/SKILL.md +5 -1
- package/claude/skills/task-board/SKILL.md +1 -0
- package/claude/skills/task-board/references/tasks.md +1 -0
- package/claude/skills/ui-checklist/SKILL.md +5 -1
- package/claude/skills/ui-craft/SKILL.md +9 -9
- package/claude/skills/ui-craft/references/adopted.md +4 -2
- package/claude/skills/ui-craft/references/capture.md +8 -0
- package/claude/skills/ui-craft/references/controls.md +6 -0
- package/claude/skills/ui-craft/references/motion.md +6 -0
- package/claude/skills/ui-film/references/take-craft.md +2 -0
- package/docs/agents/output-shape.md +2 -1
- package/docs/target/projects.md +2 -2
- package/docs/workflow/ai-workflow.md +1 -1
- package/docs/workflow/parallel-features.md +2 -1
- package/docs/workflow/skill-map.md +7 -5
- package/docs/workflow/visual-design-workflow.md +4 -0
- package/governance/ledger/skills/design-references/SKILL.md +38 -0
- package/governance/ledger/skills/design-taste/SKILL.md +36 -30
- package/governance/ledger/skills/design-taste/kinds.md +1 -0
- package/governance/ledger/skills/design-taste/tells.md +22 -20
- package/governance/ledger/skills/search-craft/SKILL.md +6 -0
- package/governance/ledger/skills/ui-craft/capture.md +10 -0
- package/governance/ledger/skills/ui-craft/controls.md +8 -0
- package/governance/ledger/skills/ui-craft/motion.md +9 -1
- package/package.json +3 -3
- package/src/capture/render.ts +154 -2
- package/src/capture/sections.ts +257 -0
- package/src/claude/cases/misc.ts +5 -0
- package/src/claude/cases/setup.ts +7 -0
- package/src/commands/capture.ts +149 -4
- package/src/commands/pr.ts +138 -15
- package/src/commands/sessions.ts +88 -6
- package/src/commands/tasks.ts +41 -2
- package/src/commands/tooling.ts +72 -0
- package/src/git/files.ts +5 -2
- package/src/indexes/frontmatter.ts +27 -6
- package/src/indexes/render.ts +21 -3
- package/src/markdown/colons.ts +14 -4
- package/src/markdown/heading-case.ts +9 -2
- package/src/pr/bijection.ts +8 -0
- package/src/pr/evidence.ts +38 -9
- package/src/pr/health.ts +63 -15
- package/src/pr/links.ts +160 -0
- package/src/pr/local.ts +17 -4
- package/src/pr/paths.ts +20 -2
- package/src/sessions/stop.ts +145 -0
- package/src/sessions/transfer/bundle.ts +1 -1
- package/src/take/encode.ts +16 -42
- package/src/take/frame-clock.ts +46 -0
- package/src/take/perform.ts +29 -4
- package/src/take/screencast.ts +114 -11
- package/src/tasks/holders.ts +198 -0
- package/src/tasks/validate.ts +148 -0
- package/src/tooling/list.ts +3 -0
- package/src/tooling/scaffold.ts +193 -0
- package/tooling/cloudflare/manifest.toml +1 -1
- package/tooling/cloudflare/reference.md +8 -0
- /package/src/sessions/{transfer/transcript.ts → transcript.ts} +0 -0
package/src/pr/paths.ts
CHANGED
|
@@ -77,7 +77,7 @@ const HEADING = /^(#{1,6})\s+(.+?)\s*$/
|
|
|
77
77
|
* A `file.ts:42` or `file.ts:42-58` suffix, which is a reader's click target
|
|
78
78
|
* rather than part of the name.
|
|
79
79
|
*/
|
|
80
|
-
const LINE_SUFFIX = /:\d+(?:-\d+)?$/
|
|
80
|
+
export const LINE_SUFFIX = /:\d+(?:-\d+)?$/
|
|
81
81
|
|
|
82
82
|
/**
|
|
83
83
|
* A span that carries a line suffix and nothing else, such as `:642` beside
|
|
@@ -214,6 +214,21 @@ function disclaimsChange(bullet: string): boolean {
|
|
|
214
214
|
* reports rather than reading as an empty section.
|
|
215
215
|
*/
|
|
216
216
|
export function readSection(body: string, title: string): string | undefined {
|
|
217
|
+
return readSectionAt(body, title)?.text
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The section's text and the character offset it starts at, counted in the body
|
|
222
|
+
* with its line endings normalized to `\n`.
|
|
223
|
+
*
|
|
224
|
+
* The offset is what lets a caller rewrite the section in place. Searching the
|
|
225
|
+
* body for the text instead finds the first copy, so an identical block above
|
|
226
|
+
* the heading would take the edit.
|
|
227
|
+
*/
|
|
228
|
+
export function readSectionAt(
|
|
229
|
+
body: string,
|
|
230
|
+
title: string,
|
|
231
|
+
): { readonly text: string; readonly offset: number } | undefined {
|
|
217
232
|
const lines = body.replace(/\r\n/g, '\n').split('\n')
|
|
218
233
|
const wanted = title.toLowerCase()
|
|
219
234
|
|
|
@@ -246,7 +261,10 @@ export function readSection(body: string, title: string): string | undefined {
|
|
|
246
261
|
out.push(line)
|
|
247
262
|
}
|
|
248
263
|
|
|
249
|
-
|
|
264
|
+
const offset = lines
|
|
265
|
+
.slice(0, start)
|
|
266
|
+
.reduce((sum, line) => sum + line.length + 1, 0)
|
|
267
|
+
return { text: out.join('\n'), offset }
|
|
250
268
|
}
|
|
251
269
|
|
|
252
270
|
/**
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { closeSync, fstatSync, openSync, readSync } from 'node:fs'
|
|
2
|
+
import type { ResolvedSession } from '@/sessions/resolve'
|
|
3
|
+
import { locateTranscript, projectsDir } from '@/sessions/transcript'
|
|
4
|
+
|
|
5
|
+
export interface StopReading {
|
|
6
|
+
/**
|
|
7
|
+
* `usage-limit` means the last assistant entry is a limit stop, passing over
|
|
8
|
+
* a `<synthetic>` entry with no error, such as the one a client restart
|
|
9
|
+
* writes. `unknown` means the read failed or found a limit-worded entry of
|
|
10
|
+
* another shape, so the row may be stopped and nothing here can say.
|
|
11
|
+
*/
|
|
12
|
+
readonly stopped: 'usage-limit' | 'unknown' | null
|
|
13
|
+
readonly stopResets: string | null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const NOT_STOPPED: StopReading = { stopped: null, stopResets: null }
|
|
17
|
+
const UNREADABLE: StopReading = { stopped: 'unknown', stopResets: null }
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The largest measured distance from a final stop to the end of its file is
|
|
21
|
+
* under 4 KiB, while a live transcript reaches hundreds of megabytes, so the
|
|
22
|
+
* read stays positional and never loads the file.
|
|
23
|
+
*/
|
|
24
|
+
const WINDOW_BYTES = 1024 * 1024
|
|
25
|
+
|
|
26
|
+
function readTail(path: string): {
|
|
27
|
+
readonly text: string
|
|
28
|
+
readonly cut: boolean
|
|
29
|
+
} {
|
|
30
|
+
const fd = openSync(path, 'r')
|
|
31
|
+
try {
|
|
32
|
+
const { size } = fstatSync(fd)
|
|
33
|
+
const length = Math.min(size, WINDOW_BYTES)
|
|
34
|
+
const start = size - length
|
|
35
|
+
const buffer = Buffer.alloc(length)
|
|
36
|
+
let filled = 0
|
|
37
|
+
while (filled < length) {
|
|
38
|
+
const read = readSync(fd, buffer, filled, length - filled, start + filled)
|
|
39
|
+
if (read === 0) break
|
|
40
|
+
filled += read
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
text: buffer.subarray(0, filled).toString('utf8'),
|
|
44
|
+
cut: start > 0,
|
|
45
|
+
}
|
|
46
|
+
} finally {
|
|
47
|
+
closeSync(fd)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function parseEntry(line: string): Record<string, unknown> | null {
|
|
52
|
+
try {
|
|
53
|
+
const parsed: unknown = JSON.parse(line)
|
|
54
|
+
return typeof parsed === 'object' && parsed !== null
|
|
55
|
+
? (parsed as Record<string, unknown>)
|
|
56
|
+
: null
|
|
57
|
+
} catch {
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function entryText(message: Record<string, unknown>): string {
|
|
63
|
+
if (!Array.isArray(message.content)) return ''
|
|
64
|
+
return message.content
|
|
65
|
+
.map((block: { text?: unknown } | null) =>
|
|
66
|
+
typeof block?.text === 'string' ? block.text : '',
|
|
67
|
+
)
|
|
68
|
+
.join('\n')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Walks back to the entry that decides the row. No published contract describes
|
|
73
|
+
* the stop entry, so this rests on a measured shape: `error: "rate_limit"`, a
|
|
74
|
+
* `<synthetic>` model, `isApiErrorMessage`, and text naming the limit and its
|
|
75
|
+
* reset.
|
|
76
|
+
*
|
|
77
|
+
* A generated entry with limit wording and any other shape reads `unknown`,
|
|
78
|
+
* since a release renaming the field or the type still parses cleanly and would
|
|
79
|
+
* otherwise read as a session that never stopped. A `<synthetic>` entry with no
|
|
80
|
+
* error and no limit wording is passed over, since a client restart writes one
|
|
81
|
+
* reading `No response requested.` with no model turn behind it.
|
|
82
|
+
*/
|
|
83
|
+
function readEntries(text: string, cut: boolean): StopReading {
|
|
84
|
+
const lines = text.split('\n')
|
|
85
|
+
// The first line of a cut window usually starts mid-entry, and one that
|
|
86
|
+
// happens to parse cannot be told apart from it.
|
|
87
|
+
const first = cut ? 1 : 0
|
|
88
|
+
|
|
89
|
+
for (let index = lines.length - 1; index >= first; index -= 1) {
|
|
90
|
+
const entry = parseEntry(lines[index] ?? '')
|
|
91
|
+
if (entry === null || entry.isSidechain === true) continue
|
|
92
|
+
|
|
93
|
+
const message =
|
|
94
|
+
typeof entry.message === 'object' && entry.message !== null
|
|
95
|
+
? (entry.message as Record<string, unknown>)
|
|
96
|
+
: {}
|
|
97
|
+
const synthetic = message.model === '<synthetic>'
|
|
98
|
+
const generated = synthetic || entry.isApiErrorMessage === true
|
|
99
|
+
const entryWords = generated ? entryText(message) : ''
|
|
100
|
+
const worded = /hit your .* limit/.test(entryWords)
|
|
101
|
+
|
|
102
|
+
if (entry.type !== 'assistant') {
|
|
103
|
+
if (worded) return UNREADABLE
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (entry.error === 'rate_limit') {
|
|
108
|
+
return {
|
|
109
|
+
stopped: 'usage-limit',
|
|
110
|
+
stopResets: /resets (.+)/.exec(entryText(message))?.[1]?.trim() ?? null,
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (worded) return UNREADABLE
|
|
114
|
+
if (synthetic && entry.error === undefined) continue
|
|
115
|
+
|
|
116
|
+
return NOT_STOPPED
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return cut ? UNREADABLE : NOT_STOPPED
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Reads whether a usage limit stopped one roster row, from the tail of its
|
|
124
|
+
* transcript. A `busy` row reads nothing, since a turn is running and a resume
|
|
125
|
+
* may be in flight.
|
|
126
|
+
*/
|
|
127
|
+
export function readStop(
|
|
128
|
+
row: Pick<ResolvedSession, 'status' | 'sessionId'>,
|
|
129
|
+
dir: string = projectsDir(),
|
|
130
|
+
): StopReading {
|
|
131
|
+
if (row.status === 'busy') return NOT_STOPPED
|
|
132
|
+
if (row.sessionId === null) return UNREADABLE
|
|
133
|
+
|
|
134
|
+
const located = locateTranscript(row.sessionId, dir)
|
|
135
|
+
if (located === null) return UNREADABLE
|
|
136
|
+
|
|
137
|
+
let tail: ReturnType<typeof readTail>
|
|
138
|
+
try {
|
|
139
|
+
tail = readTail(located.transcript)
|
|
140
|
+
} catch {
|
|
141
|
+
return UNREADABLE
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return readEntries(tail.text, tail.cut)
|
|
145
|
+
}
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
readSideFolder,
|
|
22
22
|
readTranscript,
|
|
23
23
|
transcriptFacts,
|
|
24
|
-
} from '@/sessions/
|
|
24
|
+
} from '@/sessions/transcript'
|
|
25
25
|
|
|
26
26
|
/** Bumped when a field changes meaning, so an older import refuses a newer bundle. */
|
|
27
27
|
export const BUNDLE_FORMAT = 1
|
package/src/take/encode.ts
CHANGED
|
@@ -75,18 +75,24 @@ export function openFrameEncoder(out: string, fps: number): FrameEncoder {
|
|
|
75
75
|
// Held so a failure while frames are still being written surfaces at close
|
|
76
76
|
// rather than as an unhandled rejection.
|
|
77
77
|
done.catch(() => {})
|
|
78
|
+
// Rejects once FFmpeg is gone, since a full pipe with no reader never drains.
|
|
79
|
+
const stopped = done.then(() => {
|
|
80
|
+
throw new Error('ffmpeg stopped reading frames')
|
|
81
|
+
})
|
|
82
|
+
stopped.catch(() => {})
|
|
83
|
+
// A write to the dead process's pipe fails here, and `stopped` reports it.
|
|
84
|
+
proc.stdin?.on('error', () => {})
|
|
78
85
|
|
|
79
86
|
return {
|
|
80
|
-
write: (jpeg) =>
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
}),
|
|
87
|
+
write: async (jpeg) => {
|
|
88
|
+
const stdin = proc.stdin
|
|
89
|
+
if (!stdin || stdin.destroyed) return stopped
|
|
90
|
+
if (stdin.write(jpeg)) return
|
|
91
|
+
await Promise.race([
|
|
92
|
+
new Promise<void>((resolve) => stdin.once('drain', resolve)),
|
|
93
|
+
stopped,
|
|
94
|
+
])
|
|
95
|
+
},
|
|
90
96
|
close: async () => {
|
|
91
97
|
proc.stdin?.end()
|
|
92
98
|
await done
|
|
@@ -97,38 +103,6 @@ export function openFrameEncoder(out: string, fps: number): FrameEncoder {
|
|
|
97
103
|
}
|
|
98
104
|
}
|
|
99
105
|
|
|
100
|
-
/**
|
|
101
|
-
* Re-encodes a recorded clip to the same H.264 output at a constant `fps`, cut
|
|
102
|
-
* at `ms`. Playwright's recorder pads its webm past the last frame by at least
|
|
103
|
-
* a second, so the cut is what ends the clip where the take's clock stopped.
|
|
104
|
-
* A shorter input stays as it is, since `-t` never extends one.
|
|
105
|
-
*/
|
|
106
|
-
export async function transcode(
|
|
107
|
-
input: string,
|
|
108
|
-
out: string,
|
|
109
|
-
fps: number,
|
|
110
|
-
ms: number,
|
|
111
|
-
): Promise<void> {
|
|
112
|
-
const proc = spawn(
|
|
113
|
-
'ffmpeg',
|
|
114
|
-
[
|
|
115
|
-
'-y',
|
|
116
|
-
'-loglevel',
|
|
117
|
-
'error',
|
|
118
|
-
'-i',
|
|
119
|
-
input,
|
|
120
|
-
'-t',
|
|
121
|
-
(ms / 1000).toFixed(3),
|
|
122
|
-
'-r',
|
|
123
|
-
String(fps),
|
|
124
|
-
...OUTPUT,
|
|
125
|
-
out,
|
|
126
|
-
],
|
|
127
|
-
{ stdio: ['ignore', 'ignore', 'pipe'] },
|
|
128
|
-
)
|
|
129
|
-
await finished(proc)
|
|
130
|
-
}
|
|
131
|
-
|
|
132
106
|
/**
|
|
133
107
|
* Counts a clip's frames, which a real-time take cannot know in advance and a
|
|
134
108
|
* camera track has to match one for one.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Places a screencast's frames on the take's monotonic clock. Playwright stamps
|
|
3
|
+
* a frame in wall time, and a time service can step the wall clock mid-take, so
|
|
4
|
+
* a stamp is only ever read against the wall clock of the moment it arrives.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A frame's time on the monotonic clock. A step between the frame's swap and its
|
|
9
|
+
* arrival cannot be told apart from latency, so a forward one lands that frame
|
|
10
|
+
* early and a backward one is capped at the arrival, the latest it can be.
|
|
11
|
+
*/
|
|
12
|
+
export function frameStamp(reading: {
|
|
13
|
+
readonly presentedWall: number
|
|
14
|
+
readonly wallNow: number
|
|
15
|
+
readonly monoNow: number
|
|
16
|
+
}): number {
|
|
17
|
+
const { presentedWall, wallNow, monoNow } = reading
|
|
18
|
+
return Math.min(monoNow, presentedWall - (wallNow - monoNow))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Which frame fills each slot of a clip, as indexes into `stamps`, which holds
|
|
23
|
+
* the frames in arrival order. A slot takes the newest frame stamped at or
|
|
24
|
+
* before it, and slot 0 takes the first frame whenever it was presented. The
|
|
25
|
+
* indexes never fall, so an early stamp cannot reorder the clip. Empty when no
|
|
26
|
+
* frame arrived.
|
|
27
|
+
*/
|
|
28
|
+
export function slotFrames(
|
|
29
|
+
stamps: readonly number[],
|
|
30
|
+
clip: {
|
|
31
|
+
readonly startMs: number
|
|
32
|
+
readonly lengthMs: number
|
|
33
|
+
readonly frameMs: number
|
|
34
|
+
},
|
|
35
|
+
): number[] {
|
|
36
|
+
if (stamps.length === 0) return []
|
|
37
|
+
const slots = Math.round(clip.lengthMs / clip.frameMs)
|
|
38
|
+
const filled: number[] = []
|
|
39
|
+
let at = 0
|
|
40
|
+
for (let slot = 0; slot < slots; slot += 1) {
|
|
41
|
+
const clock = clip.startMs + slot * clip.frameMs
|
|
42
|
+
while ((stamps[at + 1] ?? Infinity) <= clock) at += 1
|
|
43
|
+
filled.push(at)
|
|
44
|
+
}
|
|
45
|
+
return filled
|
|
46
|
+
}
|
package/src/take/perform.ts
CHANGED
|
@@ -472,6 +472,12 @@ async function scrollPoint(stage: Stage, target: string): Promise<Point> {
|
|
|
472
472
|
* innermost element with a size whose whole text is those words, so a hidden
|
|
473
473
|
* copy earlier in the page cannot shadow a visible one, and anything else as a
|
|
474
474
|
* CSS selector.
|
|
475
|
+
* The document is searched first and every open shadow root after it in tree
|
|
476
|
+
* order, so a document-tree match wins, except that innermost reads containment
|
|
477
|
+
* through a shadow root to its host and a `text=` match holding a sized match
|
|
478
|
+
* inside its subtree's roots gives way to it. A selector matches within one
|
|
479
|
+
* root and never across a shadow boundary. A closed root and a frame stay
|
|
480
|
+
* unreachable.
|
|
475
481
|
* It runs inside the page so both routes resolve a target the same way, and a
|
|
476
482
|
* match with no size counts as missing, since a click on it would land on
|
|
477
483
|
* whatever sits behind.
|
|
@@ -479,19 +485,38 @@ async function scrollPoint(stage: Stage, target: string): Promise<Point> {
|
|
|
479
485
|
export function boxExpression(target: string): string {
|
|
480
486
|
return `(() => {
|
|
481
487
|
const target = ${JSON.stringify(target)}
|
|
488
|
+
const roots = []
|
|
489
|
+
const collect = (root) => {
|
|
490
|
+
roots.push(root)
|
|
491
|
+
for (const node of root.querySelectorAll('*')) {
|
|
492
|
+
if (node.shadowRoot) collect(node.shadowRoot)
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
collect(document)
|
|
496
|
+
const within = (outer, inner) => {
|
|
497
|
+
for (let node = inner; node; node = node.parentNode ?? node.host ?? null) {
|
|
498
|
+
if (node === outer) return true
|
|
499
|
+
}
|
|
500
|
+
return false
|
|
501
|
+
}
|
|
482
502
|
let element = null
|
|
483
503
|
if (target.startsWith('text=')) {
|
|
484
504
|
const want = target.slice(5).trim()
|
|
485
|
-
const matches =
|
|
486
|
-
(
|
|
505
|
+
const matches = roots.flatMap((root) =>
|
|
506
|
+
[...root.querySelectorAll(root === document ? 'body *' : '*')].filter(
|
|
507
|
+
(node) => node.textContent.trim() === want,
|
|
508
|
+
),
|
|
487
509
|
)
|
|
488
|
-
const innermost = matches.filter((node) => !matches.some((other) => other !== node && node
|
|
510
|
+
const innermost = matches.filter((node) => !matches.some((other) => other !== node && within(node, other)))
|
|
489
511
|
element = innermost.find((node) => {
|
|
490
512
|
const rect = node.getBoundingClientRect()
|
|
491
513
|
return rect.width > 0 && rect.height > 0
|
|
492
514
|
}) ?? null
|
|
493
515
|
} else {
|
|
494
|
-
|
|
516
|
+
for (const root of roots) {
|
|
517
|
+
element = root.querySelector(target)
|
|
518
|
+
if (element) break
|
|
519
|
+
}
|
|
495
520
|
}
|
|
496
521
|
if (!element) return null
|
|
497
522
|
const rect = element.getBoundingClientRect()
|
package/src/take/screencast.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { join } from 'node:path'
|
|
1
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
4
3
|
import { chromium } from 'playwright-core'
|
|
5
|
-
import { lastFrame,
|
|
4
|
+
import { lastFrame, openFrameEncoder, probeFrames } from '@/take/encode'
|
|
5
|
+
import { frameStamp, slotFrames } from '@/take/frame-clock'
|
|
6
6
|
import { FPS, type LogEvent, START, VIEWPORT } from '@/take/log'
|
|
7
7
|
import {
|
|
8
8
|
type PointerInput,
|
|
@@ -51,8 +51,13 @@ export async function filmScreencast(request: {
|
|
|
51
51
|
readonly beat: Beat
|
|
52
52
|
readonly clip: string
|
|
53
53
|
}): Promise<ScreencastTake> {
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
// Ahead of the launch, so a folder that refuses leaves no browser holding the
|
|
55
|
+
// process open past its refusal.
|
|
56
|
+
const scratch = openSpool(request.clip)
|
|
57
|
+
const browser = await chromium.launch().catch((error: unknown) => {
|
|
58
|
+
scratch.remove()
|
|
59
|
+
throw error
|
|
60
|
+
})
|
|
56
61
|
let still: (() => Promise<Buffer | undefined>) | undefined
|
|
57
62
|
try {
|
|
58
63
|
const page = await browser.newPage({ viewport: VIEWPORT })
|
|
@@ -97,9 +102,33 @@ export async function filmScreencast(request: {
|
|
|
97
102
|
if (request.beat.setup.length > 0) await live.hold(1000)
|
|
98
103
|
const lag = await calibrate(page)
|
|
99
104
|
|
|
100
|
-
const webm = join(scratch, 'take.webm')
|
|
101
105
|
const events: LogEvent[] = []
|
|
102
|
-
|
|
106
|
+
const spool: SpooledFrame[] = []
|
|
107
|
+
let spoolFailure: unknown
|
|
108
|
+
await page.screencast.start({
|
|
109
|
+
size: VIEWPORT,
|
|
110
|
+
// The docs tie this to `path`, but 1.64.1 still reads it here, where it
|
|
111
|
+
// doubles the frames Chromium may hold unacknowledged.
|
|
112
|
+
fps: FPS,
|
|
113
|
+
// Spooled rather than held, since a busy page sends about 19 MB a second.
|
|
114
|
+
// The write stays synchronous, so the acknowledgement Chromium paces its
|
|
115
|
+
// frames on waits for no encoder. Playwright calls this from a listener
|
|
116
|
+
// nothing awaits, so a throw here would surface as an unhandled rejection.
|
|
117
|
+
onFrame: ({ data, timestamp }) => {
|
|
118
|
+
const stamp = frameStamp({
|
|
119
|
+
presentedWall: timestamp,
|
|
120
|
+
wallNow: Date.now(),
|
|
121
|
+
monoNow: performance.now(),
|
|
122
|
+
})
|
|
123
|
+
const path = join(scratch.folder, `${spool.length}.jpg`)
|
|
124
|
+
try {
|
|
125
|
+
writeFileSync(path, data)
|
|
126
|
+
spool.push({ path, stamp })
|
|
127
|
+
} catch (error) {
|
|
128
|
+
spoolFailure ??= error
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
})
|
|
103
132
|
const started = performance.now()
|
|
104
133
|
const recording = stageOn(() => performance.now() - started)
|
|
105
134
|
await recording.hold(HOLD_MS)
|
|
@@ -110,8 +139,9 @@ export async function filmScreencast(request: {
|
|
|
110
139
|
await recording.hold(HOLD_MS)
|
|
111
140
|
const filmedMs = recording.now()
|
|
112
141
|
await page.screencast.stop()
|
|
142
|
+
if (spoolFailure !== undefined) throw spoolFailure
|
|
113
143
|
|
|
114
|
-
await
|
|
144
|
+
await encodeSpool(spool, request.clip, started, filmedMs)
|
|
115
145
|
const frames = await probeFrames(request.clip)
|
|
116
146
|
const closing = await lastFrame(request.clip, frames)
|
|
117
147
|
if (lag === undefined) return { frames, events, still: closing }
|
|
@@ -126,13 +156,86 @@ export async function filmScreencast(request: {
|
|
|
126
156
|
throw error
|
|
127
157
|
} finally {
|
|
128
158
|
await browser.close()
|
|
129
|
-
|
|
159
|
+
scratch.remove()
|
|
130
160
|
}
|
|
131
161
|
}
|
|
132
162
|
|
|
133
163
|
type Page = import('playwright-core').Page
|
|
134
164
|
type Mouse = import('playwright-core').Mouse
|
|
135
165
|
|
|
166
|
+
interface SpooledFrame {
|
|
167
|
+
readonly path: string
|
|
168
|
+
/** When the frame was presented, on the monotonic clock. */
|
|
169
|
+
readonly stamp: number
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Opens the folder holding one take's frames, beside its clip rather than in a
|
|
174
|
+
* temp folder nothing sweeps, since a 34 s beat on a busy page spools about
|
|
175
|
+
* 650 MB. A take killed outright runs no handler, so it leaves the folder where
|
|
176
|
+
* its owner looks, and the next take of that beat clears it.
|
|
177
|
+
*/
|
|
178
|
+
function openSpool(clip: string): { folder: string; remove: () => void } {
|
|
179
|
+
const folder = join(dirname(clip), 'spool')
|
|
180
|
+
rmSync(folder, { recursive: true, force: true })
|
|
181
|
+
mkdirSync(folder, { recursive: true })
|
|
182
|
+
const remove = (): void => {
|
|
183
|
+
process.off('exit', remove)
|
|
184
|
+
process.off('SIGINT', interrupt)
|
|
185
|
+
process.off('SIGTERM', interrupt)
|
|
186
|
+
rmSync(folder, { recursive: true, force: true })
|
|
187
|
+
}
|
|
188
|
+
// A signal skips the exit event, so it removes the folder and then re-raises
|
|
189
|
+
// itself with this handler gone, ending the process as it would have.
|
|
190
|
+
const interrupt = (signal: NodeJS.Signals): void => {
|
|
191
|
+
remove()
|
|
192
|
+
process.kill(process.pid, signal)
|
|
193
|
+
}
|
|
194
|
+
process.on('exit', remove)
|
|
195
|
+
process.once('SIGINT', interrupt)
|
|
196
|
+
process.once('SIGTERM', interrupt)
|
|
197
|
+
return { folder, remove }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Writes the clip from the spool, one frame per slot of the stage clock, so its
|
|
202
|
+
* length is the take's rather than whatever the frames span. Refuses when no
|
|
203
|
+
* frame arrived, since there is nothing true to show.
|
|
204
|
+
*/
|
|
205
|
+
async function encodeSpool(
|
|
206
|
+
spool: readonly SpooledFrame[],
|
|
207
|
+
clip: string,
|
|
208
|
+
startMs: number,
|
|
209
|
+
lengthMs: number,
|
|
210
|
+
): Promise<void> {
|
|
211
|
+
const slots = slotFrames(
|
|
212
|
+
spool.map((frame) => frame.stamp),
|
|
213
|
+
{ startMs, lengthMs, frameMs: FRAME_MS },
|
|
214
|
+
)
|
|
215
|
+
if (slots.length === 0) {
|
|
216
|
+
throw new Error(
|
|
217
|
+
`the screencast delivered no frame in ${(lengthMs / 1000).toFixed(2)} s of filming`,
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
const encoder = openFrameEncoder(clip, FPS)
|
|
221
|
+
try {
|
|
222
|
+
let shown = -1
|
|
223
|
+
let jpeg = Buffer.alloc(0)
|
|
224
|
+
for (const index of slots) {
|
|
225
|
+
const frame = spool[index]
|
|
226
|
+
if (frame && index !== shown) {
|
|
227
|
+
jpeg = readFileSync(frame.path)
|
|
228
|
+
shown = index
|
|
229
|
+
}
|
|
230
|
+
await encoder.write(jpeg)
|
|
231
|
+
}
|
|
232
|
+
await encoder.close()
|
|
233
|
+
} catch (error) {
|
|
234
|
+
encoder.abort()
|
|
235
|
+
throw error
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
136
239
|
/**
|
|
137
240
|
* The page time of its next animation frame, or undefined when none comes
|
|
138
241
|
* within `capMs`, as on a hidden page, or the page closed first.
|
|
@@ -155,7 +258,7 @@ const PAINTED = '__canonTakePainted'
|
|
|
155
258
|
* How long a move takes to show in the clip, measured by sending one aligned
|
|
156
259
|
* move to `START` and reading the animation frame after its handler against
|
|
157
260
|
* the frame it was aligned to. Both readings are page time, so no clock mapping
|
|
158
|
-
* is needed. The
|
|
261
|
+
* is needed. The screencast captures a frame once it is presented, one page
|
|
159
262
|
* frame after that frame begins, so the lag counts one page frame past the
|
|
160
263
|
* readings, read off two consecutive animation frames rather than assumed from
|
|
161
264
|
* the clip's rate. That held a dragged box within 7 px on every run where the
|