@erclx/canon 4.65.0 → 4.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/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/canon-frames-read/REQUIREMENT.md +34 -0
- package/claude/skills/canon-frames-read/SKILL.md +46 -0
- package/claude/skills/claude-worktree/SKILL.md +18 -4
- package/docs/agents/commands.md +4 -1
- package/docs/agents/demo.md +17 -1
- package/docs/agents/index.md +1 -1
- package/docs/target-projects.md +19 -0
- package/docs/workflow/ai-workflow.md +9 -5
- package/package.json +1 -1
- package/src/claude/cases/claude-workflow.ts +5 -0
- package/src/commands/claude.ts +15 -1
- package/src/commands/demo.ts +97 -1
- package/src/commands/design.ts +12 -3
- package/src/commands/docs.ts +4 -1
- package/src/commands/gov.ts +24 -4
- package/src/commands/migrate.ts +94 -14
- package/src/commands/sandbox.ts +7 -1
- package/src/commands/snippets.ts +8 -2
- package/src/commands/tooling.ts +10 -8
- package/src/demo/container.ts +95 -0
- package/src/design/components.ts +1157 -4
- package/src/design/css.ts +44 -17
- package/src/exec.ts +5 -1
- package/src/migrate/plan.ts +13 -5
- package/src/migrate/rename.ts +209 -94
- package/src/migrate/skill-names.ts +89 -0
- package/src/project-root.ts +17 -0
- package/src/sync/engine.ts +4 -0
- package/src/target.ts +5 -1
- package/src/teach/fonts.ts +28 -0
- package/src/teach/nav.ts +11 -1
- package/src/teach/workspace.ts +4 -1
package/src/commands/migrate.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { listRepositoryFiles } from '@/git-files'
|
|
|
5
5
|
import { indexSourceRules } from '@/gov/adapter'
|
|
6
6
|
import { applyRecordsMove, applyRename, readSources } from '@/migrate/apply'
|
|
7
7
|
import { isToolkitOwned, planRename, type RenamePlan } from '@/migrate/plan'
|
|
8
|
+
import { AITK_RULES, type RenameRules } from '@/migrate/rename'
|
|
8
9
|
import {
|
|
9
10
|
applyRecordTree,
|
|
10
11
|
planRecordTree,
|
|
@@ -24,6 +25,7 @@ import {
|
|
|
24
25
|
type RuleLayoutPlan,
|
|
25
26
|
walkFlatRules,
|
|
26
27
|
} from '@/migrate/rule-layout'
|
|
28
|
+
import { SKILL_NAME_MAP, SKILL_NAME_RULES } from '@/migrate/skill-names'
|
|
27
29
|
import {
|
|
28
30
|
applyScratchEvidence,
|
|
29
31
|
planScratchEvidence,
|
|
@@ -31,14 +33,17 @@ import {
|
|
|
31
33
|
type ScratchEvidencePlan,
|
|
32
34
|
walkScratchEvidenceCorpus,
|
|
33
35
|
} from '@/migrate/scratch-evidence'
|
|
34
|
-
import { PROJECT_ROOT } from '@/project-root'
|
|
36
|
+
import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
|
|
35
37
|
import { readStamp, stampedHashes } from '@/sync/stamp'
|
|
36
|
-
import { logError, logInfo, logStep, logWarn,
|
|
38
|
+
import { logError, logInfo, logStep, logWarn, plural } from '@/ui'
|
|
37
39
|
|
|
38
|
-
interface
|
|
40
|
+
interface SweepOptions {
|
|
39
41
|
readonly json?: boolean
|
|
40
42
|
readonly write?: boolean
|
|
41
43
|
readonly root?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface RenameOptions extends SweepOptions {
|
|
42
47
|
readonly scope?: string
|
|
43
48
|
}
|
|
44
49
|
|
|
@@ -51,12 +56,11 @@ function isScope(value: string): value is Scope {
|
|
|
51
56
|
}
|
|
52
57
|
|
|
53
58
|
/**
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
59
|
+
* The `aitk` sweep, which is the one rename with a second population to name.
|
|
60
|
+
* A target holds toolkit-owned folders whose content came from here beside
|
|
61
|
+
* prose that project wrote, and the scope is what separates them.
|
|
57
62
|
*/
|
|
58
63
|
async function runRename(opts: RenameOptions): Promise<number> {
|
|
59
|
-
const root = opts.root ?? process.cwd()
|
|
60
64
|
const scope = opts.scope ?? 'self'
|
|
61
65
|
|
|
62
66
|
if (!isScope(scope)) {
|
|
@@ -64,6 +68,33 @@ async function runRename(opts: RenameOptions): Promise<number> {
|
|
|
64
68
|
return 1
|
|
65
69
|
}
|
|
66
70
|
|
|
71
|
+
return runSweep(opts, AITK_RULES, scope)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The skill rename owns no scope. Every folder it moves is authored here, and
|
|
76
|
+
* no target holds a copy of this repository's skill catalog, so there is no
|
|
77
|
+
* second population for a caller to name.
|
|
78
|
+
*/
|
|
79
|
+
async function runSkillNames(opts: SweepOptions): Promise<number> {
|
|
80
|
+
return runSweep(opts, SKILL_NAME_RULES, undefined)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* One sweep over a repository's tracked files under whichever rules it was
|
|
85
|
+
* handed.
|
|
86
|
+
*
|
|
87
|
+
* Reports rather than writes without `--write`, matching `canon records
|
|
88
|
+
* migrate`. A rename touching this many files has no undo short of the branch
|
|
89
|
+
* it ran on, so the safe outcome sits on the default path.
|
|
90
|
+
*/
|
|
91
|
+
async function runSweep(
|
|
92
|
+
opts: SweepOptions,
|
|
93
|
+
rules: RenameRules,
|
|
94
|
+
scope: Scope | undefined,
|
|
95
|
+
): Promise<number> {
|
|
96
|
+
const root = opts.root ?? process.cwd()
|
|
97
|
+
|
|
67
98
|
const files = await listRepositoryFiles(root)
|
|
68
99
|
if (files === undefined) {
|
|
69
100
|
logError(`Could not list files under ${root}. Is it a git repository?`)
|
|
@@ -72,7 +103,7 @@ async function runRename(opts: RenameOptions): Promise<number> {
|
|
|
72
103
|
|
|
73
104
|
const scoped = scope === 'target' ? files.filter(isToolkitOwned) : files
|
|
74
105
|
const sources = await readSources(root, scoped)
|
|
75
|
-
const plan = planRename(sources)
|
|
106
|
+
const plan = planRename(sources, rules)
|
|
76
107
|
|
|
77
108
|
// A target scope reports the citations it did not rewrite, since prose the
|
|
78
109
|
// project wrote is theirs to change and a sweep editing it underneath them
|
|
@@ -84,12 +115,15 @@ async function runRename(opts: RenameOptions): Promise<number> {
|
|
|
84
115
|
root,
|
|
85
116
|
files.filter((f) => !isToolkitOwned(f)),
|
|
86
117
|
),
|
|
118
|
+
rules,
|
|
87
119
|
).entries.length
|
|
88
120
|
: 0
|
|
89
121
|
|
|
122
|
+
// stdout, so the record pipes clean. `pipeOutput` frames to stderr, which is
|
|
123
|
+
// where this command's report belongs and where a JSON record does not.
|
|
90
124
|
if (opts.json) {
|
|
91
|
-
|
|
92
|
-
JSON.stringify(toRecord(plan, scope, citations, opts.write), null, 2)
|
|
125
|
+
process.stdout.write(
|
|
126
|
+
`${JSON.stringify(toRecord(plan, scope, citations, opts.write), null, 2)}\n`,
|
|
93
127
|
)
|
|
94
128
|
}
|
|
95
129
|
|
|
@@ -115,8 +149,12 @@ async function runRename(opts: RenameOptions): Promise<number> {
|
|
|
115
149
|
return 0
|
|
116
150
|
}
|
|
117
151
|
|
|
118
|
-
function report(
|
|
119
|
-
|
|
152
|
+
function report(
|
|
153
|
+
plan: RenamePlan,
|
|
154
|
+
scope: Scope | undefined,
|
|
155
|
+
citations: number,
|
|
156
|
+
): void {
|
|
157
|
+
if (scope !== undefined) logInfo(`Scope ${scope}.`)
|
|
120
158
|
logInfo(
|
|
121
159
|
`${plural(plan.entries.length, 'file')} to change, ${plural(plan.renamed, 'occurrence')} to rewrite.`,
|
|
122
160
|
)
|
|
@@ -138,12 +176,12 @@ function report(plan: RenamePlan, scope: Scope, citations: number): void {
|
|
|
138
176
|
|
|
139
177
|
function toRecord(
|
|
140
178
|
plan: RenamePlan,
|
|
141
|
-
scope: Scope,
|
|
179
|
+
scope: Scope | undefined,
|
|
142
180
|
citations: number,
|
|
143
181
|
wrote: boolean | undefined,
|
|
144
182
|
): unknown {
|
|
145
183
|
return {
|
|
146
|
-
scope,
|
|
184
|
+
...(scope === undefined ? {} : { scope }),
|
|
147
185
|
wrote: wrote === true,
|
|
148
186
|
files: plan.entries.length,
|
|
149
187
|
renamed: plan.renamed,
|
|
@@ -584,6 +622,9 @@ interface RuleLayoutOptions {
|
|
|
584
622
|
async function runRuleLayout(opts: RuleLayoutOptions): Promise<number> {
|
|
585
623
|
const root = opts.root ?? process.cwd()
|
|
586
624
|
|
|
625
|
+
const mismatch = checkoutMismatchWarning(process.cwd())
|
|
626
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
627
|
+
|
|
587
628
|
const files = await walkFlatRules(root)
|
|
588
629
|
const hashes = stampedHashes(readStamp(root), 'governance')
|
|
589
630
|
const catalog = new Set(indexSourceRules(PROJECT_ROOT).keys())
|
|
@@ -848,6 +889,45 @@ export function register(program: Command): void {
|
|
|
848
889
|
process.exitCode = await runRename(opts)
|
|
849
890
|
})
|
|
850
891
|
|
|
892
|
+
migrate
|
|
893
|
+
.command('skill-names')
|
|
894
|
+
.description('Move the prefixed skill folders onto their two-word names')
|
|
895
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
896
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
897
|
+
.option('--write', 'Apply the plan rather than reporting it')
|
|
898
|
+
.option(
|
|
899
|
+
'--root <path>',
|
|
900
|
+
'Project root, defaulting to the working directory',
|
|
901
|
+
)
|
|
902
|
+
.addHelpText(
|
|
903
|
+
'after',
|
|
904
|
+
[
|
|
905
|
+
'',
|
|
906
|
+
`Rewrites ${Object.keys(SKILL_NAME_MAP).length} skill names and moves the folders that carry them.`,
|
|
907
|
+
'It takes no scope. The skill folders it moves are authored in the',
|
|
908
|
+
'toolkit and no target holds a copy of that catalog, so in a target it',
|
|
909
|
+
'rewrites citations of a renamed skill and moves nothing.',
|
|
910
|
+
'',
|
|
911
|
+
'Exit codes:',
|
|
912
|
+
' 0 nothing to rewrite, or --write applied the whole plan',
|
|
913
|
+
' 1 refused, or a move failed',
|
|
914
|
+
' 2 a plan exists and --write was not passed',
|
|
915
|
+
'',
|
|
916
|
+
'The changelog is never rewritten, and neither is an eval transcript.',
|
|
917
|
+
'Each records what shipped or what a session ran under the name that',
|
|
918
|
+
'was current then.',
|
|
919
|
+
'',
|
|
920
|
+
'Examples:',
|
|
921
|
+
' canon migrate skill-names',
|
|
922
|
+
' canon migrate skill-names --write',
|
|
923
|
+
' canon migrate skill-names --json',
|
|
924
|
+
'',
|
|
925
|
+
].join('\n'),
|
|
926
|
+
)
|
|
927
|
+
.action(async (opts: SweepOptions) => {
|
|
928
|
+
process.exitCode = await runSkillNames(opts)
|
|
929
|
+
})
|
|
930
|
+
|
|
851
931
|
migrate
|
|
852
932
|
.command('rule-layout')
|
|
853
933
|
.description('Move installed rules from the flat layout onto canon/')
|
package/src/commands/sandbox.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import type { Command } from 'commander'
|
|
4
4
|
import { execScript } from '@/exec'
|
|
5
|
-
import { PROJECT_ROOT } from '@/project-root'
|
|
5
|
+
import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
|
|
6
6
|
import {
|
|
7
7
|
assertedPercent,
|
|
8
8
|
collectCensus,
|
|
@@ -286,6 +286,9 @@ function runCoverage(options: CoverageOptions): void {
|
|
|
286
286
|
|
|
287
287
|
intro('canon sandbox coverage')
|
|
288
288
|
|
|
289
|
+
const mismatch = checkoutMismatchWarning(process.cwd())
|
|
290
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
291
|
+
|
|
289
292
|
const report = collectCoverage(PROJECT_ROOT)
|
|
290
293
|
|
|
291
294
|
// A broken `exempt.toml` reads as a caller error, not as a crash. The parser
|
|
@@ -341,6 +344,9 @@ function runCheck(
|
|
|
341
344
|
): void {
|
|
342
345
|
intro('canon sandbox check')
|
|
343
346
|
|
|
347
|
+
const mismatch = checkoutMismatchWarning(process.cwd())
|
|
348
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
349
|
+
|
|
344
350
|
const parsed = parseTarget(target)
|
|
345
351
|
if (parsed === undefined) {
|
|
346
352
|
logError('Invalid target. Use <category>:<command>, e.g. claude:docs.')
|
package/src/commands/snippets.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { Command } from 'commander'
|
|
2
2
|
import { registerPassThroughVerbs } from '@/commands/pass-through'
|
|
3
|
-
import { PROJECT_ROOT } from '@/project-root'
|
|
3
|
+
import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
|
|
4
4
|
import { BASE_CATEGORY } from '@/snippets/categories'
|
|
5
5
|
import { buildSnippetsCatalog } from '@/snippets/list'
|
|
6
|
-
import { intro, logInfo, logStep, outro } from '@/ui'
|
|
6
|
+
import { intro, logInfo, logStep, logWarn, outro } from '@/ui'
|
|
7
7
|
|
|
8
8
|
const PASS_THROUGH_VERBS = ['create'] as const
|
|
9
9
|
|
|
@@ -42,14 +42,20 @@ export function register(program: Command): void {
|
|
|
42
42
|
* emitted section headers and the pass-through above it owned the `┌`.
|
|
43
43
|
*/
|
|
44
44
|
function runList(opts: ListOptions): number {
|
|
45
|
+
// The warning is a frame-interior line, so it goes out after `intro` on the
|
|
46
|
+
// path that opens one and bare on the `--json` path, which returns first and
|
|
47
|
+
// opens none. One position cannot serve both.
|
|
48
|
+
const mismatch = checkoutMismatchWarning(process.cwd())
|
|
45
49
|
const catalog = buildSnippetsCatalog(PROJECT_ROOT)
|
|
46
50
|
|
|
47
51
|
if (opts.json) {
|
|
52
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
48
53
|
process.stdout.write(`${JSON.stringify(catalog)}\n`)
|
|
49
54
|
return 0
|
|
50
55
|
}
|
|
51
56
|
|
|
52
57
|
intro('canon snippets list')
|
|
58
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
53
59
|
|
|
54
60
|
const showCategories = opts.entries !== true
|
|
55
61
|
const showEntries = opts.categories !== true
|
package/src/commands/tooling.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { resolve } from 'node:path'
|
|
2
2
|
import type { Command } from 'commander'
|
|
3
3
|
import { execScript } from '@/exec'
|
|
4
|
-
import {
|
|
4
|
+
import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
|
|
5
5
|
import {
|
|
6
6
|
injectConfigs,
|
|
7
7
|
injectGitignore,
|
|
@@ -180,14 +180,20 @@ export function register(program: Command): void {
|
|
|
180
180
|
* pass-through loop below skips the `intro` the shared helper carries.
|
|
181
181
|
*/
|
|
182
182
|
function runList(opts: ListOptions): number {
|
|
183
|
+
// The warning is a frame-interior line, so it goes out after `intro` on the
|
|
184
|
+
// path that opens one and bare on the `--json` path, which returns first and
|
|
185
|
+
// opens none. One position cannot serve both.
|
|
186
|
+
const mismatch = checkoutMismatchWarning(process.cwd())
|
|
183
187
|
const stacks = buildStackSummaries(PROJECT_ROOT)
|
|
184
188
|
|
|
185
189
|
if (opts.json) {
|
|
190
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
186
191
|
process.stdout.write(`${JSON.stringify({ stacks })}\n`)
|
|
187
192
|
return 0
|
|
188
193
|
}
|
|
189
194
|
|
|
190
195
|
intro('canon tooling list')
|
|
196
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
191
197
|
logStep('Stacks')
|
|
192
198
|
for (const summary of stacks) logInfo(describeStack(summary))
|
|
193
199
|
outro()
|
|
@@ -229,6 +235,9 @@ function printReference(stack: string): number {
|
|
|
229
235
|
* stack the way it drove `merge_gitignore` before.
|
|
230
236
|
*/
|
|
231
237
|
function prepare(stack: string, target: string, skip?: string): Prepared {
|
|
238
|
+
const mismatch = checkoutMismatchWarning(process.cwd())
|
|
239
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
240
|
+
|
|
232
241
|
if (!stackExists(PROJECT_ROOT, stack)) {
|
|
233
242
|
return { ok: false, error: `Stack not found: ${stack}` }
|
|
234
243
|
}
|
|
@@ -268,13 +277,6 @@ async function runSync(
|
|
|
268
277
|
): Promise<number> {
|
|
269
278
|
intro('canon tooling sync')
|
|
270
279
|
|
|
271
|
-
const mismatch = findCheckoutMismatch(process.cwd())
|
|
272
|
-
if (mismatch !== undefined) {
|
|
273
|
-
logWarn(
|
|
274
|
-
`Resolved via ${PROJECT_ROOT}, not the checkout at ${mismatch}. Run \`bun ${mismatch}/src/cli.ts tooling sync ...\` to sync against that checkout instead.`,
|
|
275
|
-
)
|
|
276
|
-
}
|
|
277
|
-
|
|
278
280
|
if (opts.check === true && opts.write === true) {
|
|
279
281
|
logWarn('Pass --check or --write, not both.')
|
|
280
282
|
outro()
|
package/src/demo/container.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, rmSync } from 'node:fs'
|
|
1
2
|
import { join, parse } from 'node:path'
|
|
2
3
|
import { execa } from 'execa'
|
|
3
4
|
|
|
@@ -20,6 +21,15 @@ export type GifResult =
|
|
|
20
21
|
| { status: 'skipped'; reason: 'converter-missing' }
|
|
21
22
|
| { status: 'failed'; reason: string }
|
|
22
23
|
|
|
24
|
+
export type FramesResult =
|
|
25
|
+
| { status: 'extracted'; framePaths: string[] }
|
|
26
|
+
| { status: 'skipped'; reason: 'converter-missing' }
|
|
27
|
+
| { status: 'failed'; reason: string }
|
|
28
|
+
|
|
29
|
+
export interface ExtractFramesOptions {
|
|
30
|
+
readonly fps?: number
|
|
31
|
+
}
|
|
32
|
+
|
|
23
33
|
/**
|
|
24
34
|
* Writes mp4 beside the webm rather than instead of it, since both stated use
|
|
25
35
|
* cases are a `<video>` tag on a page the operator controls, where webm
|
|
@@ -122,3 +132,88 @@ export async function convertToGif(
|
|
|
122
132
|
}
|
|
123
133
|
return { status: 'converted', gifPath }
|
|
124
134
|
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Written beside the video by default, so frames fall under the same
|
|
138
|
+
* `demos/*.png` gitignore entry the still already uses with no new rule
|
|
139
|
+
* needed. One frame a second by default, matched to the seconds-to-tens-of-
|
|
140
|
+
* seconds length a tuned recording runs, without a smarter sampling strategy
|
|
141
|
+
* nobody has asked for.
|
|
142
|
+
*
|
|
143
|
+
* The missing-binary and failure handling matches `convertToMp4` exactly: the
|
|
144
|
+
* recording already succeeded by the time this runs, so an optional step
|
|
145
|
+
* never fails the run.
|
|
146
|
+
*
|
|
147
|
+
* A prior extraction's frames are cleared before ffmpeg runs, not left for
|
|
148
|
+
* `-y` to overwrite. ffmpeg's `-y` only overwrites the indices this run
|
|
149
|
+
* produces, so a shorter re-extraction against the same video's stable
|
|
150
|
+
* output path (the plan's declared `output.video`, unchanged across
|
|
151
|
+
* re-records) would otherwise leave the previous run's tail in place,
|
|
152
|
+
* sorted into the returned list as if it were still current.
|
|
153
|
+
*/
|
|
154
|
+
export async function extractFrames(
|
|
155
|
+
videoPath: string,
|
|
156
|
+
outDir?: string,
|
|
157
|
+
opts: ExtractFramesOptions = {},
|
|
158
|
+
bin: string = CONVERTER_BIN,
|
|
159
|
+
): Promise<FramesResult> {
|
|
160
|
+
const { dir, name } = parse(videoPath)
|
|
161
|
+
const targetDir = outDir ?? dir
|
|
162
|
+
const fps = opts.fps ?? 1
|
|
163
|
+
if (outDir) mkdirSync(outDir, { recursive: true })
|
|
164
|
+
|
|
165
|
+
if (existsSync(targetDir)) {
|
|
166
|
+
for (const stale of new Bun.Glob(`${name}-frame-*.png`).scanSync({
|
|
167
|
+
cwd: targetDir,
|
|
168
|
+
onlyFiles: true,
|
|
169
|
+
})) {
|
|
170
|
+
rmSync(join(targetDir, stale))
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const pattern = join(targetDir, `${name}-frame-%03d.png`)
|
|
175
|
+
const result = await execa(
|
|
176
|
+
bin,
|
|
177
|
+
['-y', '-i', videoPath, '-vf', `fps=${fps}`, pattern],
|
|
178
|
+
{ reject: false },
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
if (result.failed && result.code === 'ENOENT') {
|
|
182
|
+
return { status: 'skipped', reason: 'converter-missing' }
|
|
183
|
+
}
|
|
184
|
+
if (result.exitCode !== 0) {
|
|
185
|
+
return {
|
|
186
|
+
status: 'failed',
|
|
187
|
+
reason: result.stderr?.trim() || `ffmpeg exited ${result.exitCode}`,
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return { status: 'extracted', framePaths: collectFrames(targetDir, name) }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The glob-and-sort step `extractFrames` reads its result from, pulled out so
|
|
196
|
+
* a test can drive it against a directory it wrote by hand rather than
|
|
197
|
+
* through a real extraction, which is the only way to reach the four-digit
|
|
198
|
+
* boundary below without an ffmpeg run long enough to produce one.
|
|
199
|
+
*
|
|
200
|
+
* ffmpeg's `%03d` pads to three characters and then widens rather than
|
|
201
|
+
* truncating, so a run producing 1000 or more frames writes `-1000.png`
|
|
202
|
+
* beside `-999.png`, and a lexicographic sort reads the wider name first.
|
|
203
|
+
* `frameIndex` sorts on the parsed number instead.
|
|
204
|
+
*/
|
|
205
|
+
export function collectFrames(targetDir: string, name: string): string[] {
|
|
206
|
+
return [
|
|
207
|
+
...new Bun.Glob(`${name}-frame-*.png`).scanSync({
|
|
208
|
+
cwd: targetDir,
|
|
209
|
+
onlyFiles: true,
|
|
210
|
+
}),
|
|
211
|
+
]
|
|
212
|
+
.sort((a, b) => frameIndex(a) - frameIndex(b))
|
|
213
|
+
.map((frame) => join(targetDir, frame))
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function frameIndex(filename: string): number {
|
|
217
|
+
const match = filename.match(/-frame-(\d+)\.png$/)
|
|
218
|
+
return match ? Number(match[1]) : 0
|
|
219
|
+
}
|