@1agh/maude 0.42.0 → 0.43.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/apps/studio/.ai/cache/_stats.json +7 -0
- package/apps/studio/acp/bootstrap-brief.ts +12 -2
- package/apps/studio/annotations-layer.tsx +1055 -278
- package/apps/studio/annotations-model.ts +58 -0
- package/apps/studio/api.ts +173 -22
- package/apps/studio/bin/_ingest-footage.mjs +386 -0
- package/apps/studio/bin/_probe-footage-playwright.mjs +269 -0
- package/apps/studio/bin/_video-playwright.mjs +141 -71
- package/apps/studio/bin/ingest-footage.sh +27 -0
- package/apps/studio/bin/photo-adjust.sh +163 -0
- package/apps/studio/bin/photo-bg-remove.sh +160 -0
- package/apps/studio/bin/probe-footage.sh +28 -0
- package/apps/studio/bin/read-annotations.mjs +125 -2
- package/apps/studio/canvas-edit.ts +459 -10
- package/apps/studio/canvas-lib.tsx +523 -5
- package/apps/studio/canvas-pipeline.ts +65 -7
- package/apps/studio/canvas-shell.tsx +156 -16
- package/apps/studio/client/app.jsx +451 -34
- package/apps/studio/client/export-center.jsx +35 -1
- package/apps/studio/client/github.js +4 -0
- package/apps/studio/client/panels/GitPanel.jsx +8 -0
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +133 -21
- package/apps/studio/client/panels/StickerPicker.jsx +160 -0
- package/apps/studio/client/panels/TimelinePanel.jsx +15 -2
- package/apps/studio/client/photo-knobs.jsx +437 -0
- package/apps/studio/client/styles/3-shell-maude.css +42 -0
- package/apps/studio/commands/edit-source-command.ts +31 -4
- package/apps/studio/context-menu.tsx +31 -6
- package/apps/studio/dist/client.bundle.js +5561 -19
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/runtime/.min-sizes.json +1 -0
- package/apps/studio/dist/runtime/@imgly_background-removal.js +5543 -0
- package/apps/studio/dist/runtime/pixi-js.js +519 -519
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/dom-selection.ts +25 -0
- package/apps/studio/exporters/jobs.ts +47 -5
- package/apps/studio/exporters/video.ts +24 -4
- package/apps/studio/footage/schema.test.ts +195 -0
- package/apps/studio/footage/schema.ts +484 -0
- package/apps/studio/footage-store.ts +235 -0
- package/apps/studio/fs-watch.ts +10 -0
- package/apps/studio/git/endpoints.ts +68 -7
- package/apps/studio/git/service.ts +196 -20
- package/apps/studio/github/service.ts +67 -0
- package/apps/studio/handoff.ts +3 -2
- package/apps/studio/http.ts +173 -14
- package/apps/studio/input-router.tsx +10 -0
- package/apps/studio/inspect.ts +53 -1
- package/apps/studio/paths.ts +10 -0
- package/apps/studio/photo/filters.ts +285 -0
- package/apps/studio/photo/pipeline.ts +607 -0
- package/apps/studio/photo/schema.ts +367 -0
- package/apps/studio/photo-store.ts +154 -0
- package/apps/studio/runtime-bundle.ts +29 -6
- package/apps/studio/server.ts +5 -1
- package/apps/studio/stickers/figjam-doodle/manifest.json +104 -0
- package/apps/studio/stickers/figjam-doodle/slice1.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice10.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice11.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice12.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice13.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice14.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice15.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice16.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice17.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice18.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice19.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice2.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice20.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice21.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice22.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice23.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice24.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice3.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice4.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice5.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice6.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice7.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice8.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice9.png +0 -0
- package/apps/studio/stickers/life-style/best.png +0 -0
- package/apps/studio/stickers/life-style/come-on.png +0 -0
- package/apps/studio/stickers/life-style/cut.png +0 -0
- package/apps/studio/stickers/life-style/detox.png +0 -0
- package/apps/studio/stickers/life-style/just.png +0 -0
- package/apps/studio/stickers/life-style/manifest.json +68 -0
- package/apps/studio/stickers/life-style/mirror.png +0 -0
- package/apps/studio/stickers/life-style/nice.png +0 -0
- package/apps/studio/stickers/life-style/objects.png +0 -0
- package/apps/studio/stickers/life-style/ok.png +0 -0
- package/apps/studio/stickers/life-style/queen.png +0 -0
- package/apps/studio/stickers/life-style/star.png +0 -0
- package/apps/studio/stickers/life-style/sun.png +0 -0
- package/apps/studio/stickers/life-style/water.png +0 -0
- package/apps/studio/stickers/life-style/yeah.png +0 -0
- package/apps/studio/stickers/life-style/you-can.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/check-the-deets.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/cut-it.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/dope.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/fresh.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/hot.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/idea.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/keep-exploring.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/killed-it.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/love-it.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/manifest.json +88 -0
- package/apps/studio/stickers/opposing-thoughts/not-sure.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/ok.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/pure-gold.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/save-for-later.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/steal-this.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/take-a-peek.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/this-or-that.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/thoughts.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/vibes.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/winner.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/wip.png +0 -0
- package/apps/studio/stickers/project-status/group-100.png +0 -0
- package/apps/studio/stickers/project-status/group-101.png +0 -0
- package/apps/studio/stickers/project-status/group-102.png +0 -0
- package/apps/studio/stickers/project-status/group-103.png +0 -0
- package/apps/studio/stickers/project-status/group-104.png +0 -0
- package/apps/studio/stickers/project-status/group-105.png +0 -0
- package/apps/studio/stickers/project-status/group-106.png +0 -0
- package/apps/studio/stickers/project-status/group-107.png +0 -0
- package/apps/studio/stickers/project-status/group-108.png +0 -0
- package/apps/studio/stickers/project-status/group-109.png +0 -0
- package/apps/studio/stickers/project-status/group-110.png +0 -0
- package/apps/studio/stickers/project-status/group-111.png +0 -0
- package/apps/studio/stickers/project-status/group-112.png +0 -0
- package/apps/studio/stickers/project-status/group-113.png +0 -0
- package/apps/studio/stickers/project-status/group-114.png +0 -0
- package/apps/studio/stickers/project-status/group-115.png +0 -0
- package/apps/studio/stickers/project-status/group-117.png +0 -0
- package/apps/studio/stickers/project-status/group-118.png +0 -0
- package/apps/studio/stickers/project-status/group-119.png +0 -0
- package/apps/studio/stickers/project-status/group-120.png +0 -0
- package/apps/studio/stickers/project-status/group-121.png +0 -0
- package/apps/studio/stickers/project-status/group-122.png +0 -0
- package/apps/studio/stickers/project-status/group-41.png +0 -0
- package/apps/studio/stickers/project-status/group-42.png +0 -0
- package/apps/studio/stickers/project-status/group-43.png +0 -0
- package/apps/studio/stickers/project-status/group-44.png +0 -0
- package/apps/studio/stickers/project-status/group-45.png +0 -0
- package/apps/studio/stickers/project-status/group-46.png +0 -0
- package/apps/studio/stickers/project-status/group-47.png +0 -0
- package/apps/studio/stickers/project-status/group-48.png +0 -0
- package/apps/studio/stickers/project-status/group-49.png +0 -0
- package/apps/studio/stickers/project-status/group-50.png +0 -0
- package/apps/studio/stickers/project-status/group-51.png +0 -0
- package/apps/studio/stickers/project-status/group-52.png +0 -0
- package/apps/studio/stickers/project-status/group-53.png +0 -0
- package/apps/studio/stickers/project-status/group-54.png +0 -0
- package/apps/studio/stickers/project-status/group-55.png +0 -0
- package/apps/studio/stickers/project-status/group-56.png +0 -0
- package/apps/studio/stickers/project-status/group-57.png +0 -0
- package/apps/studio/stickers/project-status/group-58.png +0 -0
- package/apps/studio/stickers/project-status/group-59.png +0 -0
- package/apps/studio/stickers/project-status/group-60.png +0 -0
- package/apps/studio/stickers/project-status/group-61.png +0 -0
- package/apps/studio/stickers/project-status/group-62.png +0 -0
- package/apps/studio/stickers/project-status/group-63.png +0 -0
- package/apps/studio/stickers/project-status/group-64.png +0 -0
- package/apps/studio/stickers/project-status/group-65.png +0 -0
- package/apps/studio/stickers/project-status/group-66.png +0 -0
- package/apps/studio/stickers/project-status/group-67.png +0 -0
- package/apps/studio/stickers/project-status/group-68.png +0 -0
- package/apps/studio/stickers/project-status/group-69.png +0 -0
- package/apps/studio/stickers/project-status/group-70.png +0 -0
- package/apps/studio/stickers/project-status/group-71.png +0 -0
- package/apps/studio/stickers/project-status/group-72.png +0 -0
- package/apps/studio/stickers/project-status/group-73.png +0 -0
- package/apps/studio/stickers/project-status/group-74.png +0 -0
- package/apps/studio/stickers/project-status/group-75.png +0 -0
- package/apps/studio/stickers/project-status/group-76.png +0 -0
- package/apps/studio/stickers/project-status/group-77.png +0 -0
- package/apps/studio/stickers/project-status/group-78.png +0 -0
- package/apps/studio/stickers/project-status/group-79.png +0 -0
- package/apps/studio/stickers/project-status/group-80.png +0 -0
- package/apps/studio/stickers/project-status/group-81.png +0 -0
- package/apps/studio/stickers/project-status/group-82.png +0 -0
- package/apps/studio/stickers/project-status/group-83.png +0 -0
- package/apps/studio/stickers/project-status/group-84.png +0 -0
- package/apps/studio/stickers/project-status/group-85.png +0 -0
- package/apps/studio/stickers/project-status/group-86.png +0 -0
- package/apps/studio/stickers/project-status/group-87.png +0 -0
- package/apps/studio/stickers/project-status/group-88.png +0 -0
- package/apps/studio/stickers/project-status/group-89.png +0 -0
- package/apps/studio/stickers/project-status/group-90.png +0 -0
- package/apps/studio/stickers/project-status/group-91.png +0 -0
- package/apps/studio/stickers/project-status/group-92.png +0 -0
- package/apps/studio/stickers/project-status/group-93.png +0 -0
- package/apps/studio/stickers/project-status/group-94.png +0 -0
- package/apps/studio/stickers/project-status/group-96.png +0 -0
- package/apps/studio/stickers/project-status/group-97.png +0 -0
- package/apps/studio/stickers/project-status/group-98.png +0 -0
- package/apps/studio/stickers/project-status/group-99.png +0 -0
- package/apps/studio/stickers/project-status/manifest.json +328 -0
- package/apps/studio/test/acp-bootstrap-brief.test.ts +11 -0
- package/apps/studio/test/acp-commands.test.ts +2 -1
- package/apps/studio/test/asset-api.test.ts +3 -3
- package/apps/studio/test/canvas-edit.test.ts +17 -6
- package/apps/studio/test/canvas-origin-gate.test.ts +13 -0
- package/apps/studio/test/dynamic-text-edit.test.ts +162 -0
- package/apps/studio/test/edit-source-command.test.ts +23 -0
- package/apps/studio/test/element-structural-edit.test.ts +46 -0
- package/apps/studio/test/footage-store.test.ts +100 -0
- package/apps/studio/test/git-branches.test.ts +97 -0
- package/apps/studio/test/github-api.test.ts +56 -1
- package/apps/studio/test/input-router.test.ts +33 -0
- package/apps/studio/test/inspect-script-syntax.test.ts +53 -0
- package/apps/studio/test/photo-canvas-bundle.test.ts +61 -0
- package/apps/studio/test/photo-edit-api.test.ts +201 -0
- package/apps/studio/test/photo-filters.test.ts +149 -0
- package/apps/studio/test/photo-pipeline.test.ts +106 -0
- package/apps/studio/test/photo-taxonomy.test.ts +96 -0
- package/apps/studio/test/read-annotations.test.ts +164 -0
- package/apps/studio/test/shell-importmap.test.ts +49 -0
- package/apps/studio/test/text-editability-stamp.test.ts +131 -0
- package/apps/studio/test/timeline-parse.test.ts +57 -0
- package/apps/studio/test/tool-palette-insert-anchor.test.ts +5 -3
- package/apps/studio/test/undo-stack.test.ts +33 -0
- package/apps/studio/test/video-asset.test.ts +5 -5
- package/apps/studio/test/video-render-bridge.test.ts +23 -1
- package/apps/studio/text-caret.ts +239 -0
- package/apps/studio/tool-palette.tsx +43 -20
- package/apps/studio/undo-stack.ts +94 -2
- package/apps/studio/use-annotation-resize.tsx +4 -4
- package/apps/studio/use-canvas-media-drop.tsx +38 -1
- package/apps/studio/use-selection-set.tsx +21 -0
- package/apps/studio/video-comp.tsx +48 -7
- package/apps/studio/whats-new.json +53 -0
- package/cli/commands/design.mjs +34 -3
- package/cli/lib/gitignore-block.mjs +1 -0
- package/package.json +10 -10
- package/plugins/design/templates/_shell.html +1 -0
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// _ingest-footage.mjs — ingest a DIRECTORY of raw footage into the design root's
|
|
3
|
+
// content-addressed asset store, behind `maude design ingest-footage` (DDR-062
|
|
4
|
+
// dispatch). The folder-scale counterpart to `_fetch-asset.mjs` (single URL).
|
|
5
|
+
//
|
|
6
|
+
// feature-footage-analysis-director, Task 3. WHY THIS EXISTS: the `/design:reel`
|
|
7
|
+
// director pipeline wants to start from a folder of client clips
|
|
8
|
+
// (`/…/podklady/video`). This verb copies each recognised video (and any image/
|
|
9
|
+
// audio) into `<designRoot>/assets/<sha8>.<ext>` — the SAME content-addressed
|
|
10
|
+
// convention `/_api/asset` + `_fetch-asset.mjs` use, so a clip references
|
|
11
|
+
// uniformly as `assets/<sha8>.mp4` on both the main and the canvas origin.
|
|
12
|
+
//
|
|
13
|
+
// SECURITY / DISCIPLINE (mirrors _fetch-asset.mjs):
|
|
14
|
+
// • magic-byte sniff decides the category + extension — the source name /
|
|
15
|
+
// extension is NEVER trusted (a `.mp4` that is really an HTML polyglot →
|
|
16
|
+
// skipped). Mirrors api.ts sniffAssetType (mp4/mov/m4v/webm video · mp3/wav/
|
|
17
|
+
// m4a audio · png/jpg/gif/webp image); SVG/script → null → skipped.
|
|
18
|
+
// • content-addressed `<sha8>.<ext>` name, `[a-z0-9._-]` only, written FLAT
|
|
19
|
+
// under assets/ with a realpath containment assertion.
|
|
20
|
+
// • per-file size cap (default 100 MB — the DDR-148 video ceiling); oversized
|
|
21
|
+
// files are SKIPPED LOUDLY (listed in the manifest), never silently dropped.
|
|
22
|
+
// • the source directory is READ-ONLY — this never writes back into it.
|
|
23
|
+
// • no network, no shell interpolation, no container parsing (magic bytes only).
|
|
24
|
+
//
|
|
25
|
+
// Reached via `maude design ingest-footage <dir> --root <repo>`, never a raw path.
|
|
26
|
+
// stdout (default) = a JSON manifest { clips:[{asset,src,bytes,category}],
|
|
27
|
+
// skipped:[{src,why}], assetsDir }. Exit: 0 ok (even with skips) · 2 usage ·
|
|
28
|
+
// 6 write/containment error · 1 other.
|
|
29
|
+
|
|
30
|
+
import { createHash } from 'node:crypto';
|
|
31
|
+
import {
|
|
32
|
+
closeSync,
|
|
33
|
+
copyFileSync,
|
|
34
|
+
createReadStream,
|
|
35
|
+
existsSync,
|
|
36
|
+
mkdirSync,
|
|
37
|
+
openSync,
|
|
38
|
+
readdirSync,
|
|
39
|
+
readSync,
|
|
40
|
+
statSync,
|
|
41
|
+
} from 'node:fs';
|
|
42
|
+
import { extname, join, resolve, sep } from 'node:path';
|
|
43
|
+
import { pathToFileURL } from 'node:url';
|
|
44
|
+
|
|
45
|
+
// The DDR-148 video ceiling (100 MB). Images/audio also ride this generous cap
|
|
46
|
+
// here — the reel pipeline's inputs are dominated by video. Override per-file
|
|
47
|
+
// via --max-bytes.
|
|
48
|
+
const DEFAULT_MAX_BYTES = 100 * 1024 * 1024;
|
|
49
|
+
|
|
50
|
+
class IngestError extends Error {
|
|
51
|
+
constructor(code, message) {
|
|
52
|
+
super(message);
|
|
53
|
+
this.code = code;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── magic-byte sniff (mirrors apps/studio/api.ts sniffAssetType) ──────────────
|
|
58
|
+
|
|
59
|
+
/** Sniff → { ext, category } or null. Bytes decide; name/extension untrusted. */
|
|
60
|
+
export function sniffAsset(b) {
|
|
61
|
+
// Images (mirrors sniffImageType).
|
|
62
|
+
if (
|
|
63
|
+
b.length >= 8 &&
|
|
64
|
+
b[0] === 0x89 &&
|
|
65
|
+
b[1] === 0x50 &&
|
|
66
|
+
b[2] === 0x4e &&
|
|
67
|
+
b[3] === 0x47 &&
|
|
68
|
+
b[4] === 0x0d &&
|
|
69
|
+
b[5] === 0x0a &&
|
|
70
|
+
b[6] === 0x1a &&
|
|
71
|
+
b[7] === 0x0a
|
|
72
|
+
)
|
|
73
|
+
return { ext: 'png', category: 'image' };
|
|
74
|
+
if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff)
|
|
75
|
+
return { ext: 'jpg', category: 'image' };
|
|
76
|
+
if (
|
|
77
|
+
b.length >= 6 &&
|
|
78
|
+
b[0] === 0x47 &&
|
|
79
|
+
b[1] === 0x49 &&
|
|
80
|
+
b[2] === 0x46 &&
|
|
81
|
+
b[3] === 0x38 &&
|
|
82
|
+
(b[4] === 0x37 || b[4] === 0x39) &&
|
|
83
|
+
b[5] === 0x61
|
|
84
|
+
)
|
|
85
|
+
return { ext: 'gif', category: 'image' };
|
|
86
|
+
if (
|
|
87
|
+
b.length >= 12 &&
|
|
88
|
+
b[0] === 0x52 &&
|
|
89
|
+
b[1] === 0x49 &&
|
|
90
|
+
b[2] === 0x46 &&
|
|
91
|
+
b[3] === 0x46 &&
|
|
92
|
+
b[8] === 0x57 &&
|
|
93
|
+
b[9] === 0x45 &&
|
|
94
|
+
b[10] === 0x42 &&
|
|
95
|
+
b[11] === 0x50
|
|
96
|
+
)
|
|
97
|
+
return { ext: 'webp', category: 'image' };
|
|
98
|
+
// ISO-BMFF (mp4 / mov / m4a / m4v): "ftyp" at offset 4, brand at 8.
|
|
99
|
+
if (b.length >= 12 && b[4] === 0x66 && b[5] === 0x74 && b[6] === 0x79 && b[7] === 0x70) {
|
|
100
|
+
const brand = String.fromCharCode(b[8] ?? 0, b[9] ?? 0, b[10] ?? 0, b[11] ?? 0);
|
|
101
|
+
if (brand === 'qt ') return { ext: 'mov', category: 'video' };
|
|
102
|
+
if (brand.startsWith('M4A')) return { ext: 'm4a', category: 'audio' };
|
|
103
|
+
if (brand.startsWith('M4V')) return { ext: 'm4v', category: 'video' };
|
|
104
|
+
return { ext: 'mp4', category: 'video' };
|
|
105
|
+
}
|
|
106
|
+
// Matroska / WebM — EBML header.
|
|
107
|
+
if (b.length >= 4 && b[0] === 0x1a && b[1] === 0x45 && b[2] === 0xdf && b[3] === 0xa3)
|
|
108
|
+
return { ext: 'webm', category: 'video' };
|
|
109
|
+
// MP3 — "ID3" or frame-sync.
|
|
110
|
+
if (b.length >= 3 && b[0] === 0x49 && b[1] === 0x44 && b[2] === 0x33)
|
|
111
|
+
return { ext: 'mp3', category: 'audio' };
|
|
112
|
+
if (b.length >= 2 && b[0] === 0xff && ((b[1] ?? 0) & 0xe0) === 0xe0)
|
|
113
|
+
return { ext: 'mp3', category: 'audio' };
|
|
114
|
+
// WAV.
|
|
115
|
+
if (
|
|
116
|
+
b.length >= 12 &&
|
|
117
|
+
b[0] === 0x52 &&
|
|
118
|
+
b[1] === 0x49 &&
|
|
119
|
+
b[2] === 0x46 &&
|
|
120
|
+
b[3] === 0x46 &&
|
|
121
|
+
b[8] === 0x57 &&
|
|
122
|
+
b[9] === 0x41 &&
|
|
123
|
+
b[10] === 0x56 &&
|
|
124
|
+
b[11] === 0x45
|
|
125
|
+
)
|
|
126
|
+
return { ext: 'wav', category: 'audio' };
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── path helpers (mirror _fetch-asset.mjs containment) ───────────────────────
|
|
131
|
+
|
|
132
|
+
/** Resolve `<root>/<designRootRel>/assets` with a containment assertion. */
|
|
133
|
+
export function assetsDirFor(root, designRootRel) {
|
|
134
|
+
const rootAbs = resolve(root);
|
|
135
|
+
const assetsDir = resolve(rootAbs, designRootRel, 'assets');
|
|
136
|
+
if (assetsDir !== rootAbs && !assetsDir.startsWith(rootAbs + sep))
|
|
137
|
+
throw new IngestError(6, `assets dir escapes root: ${assetsDir}`);
|
|
138
|
+
return assetsDir;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Assert a content-addressed name and resolve it inside assets/ (no traversal). */
|
|
142
|
+
export function containedName(assetsDir, name) {
|
|
143
|
+
if (!/^[a-z0-9]{8}\.(png|jpg|gif|webp|mp4|mov|m4v|webm|mp3|wav|m4a)$/.test(name))
|
|
144
|
+
throw new IngestError(6, `generated name failed the charset contract: ${name}`);
|
|
145
|
+
const fileAbs = resolve(assetsDir, name);
|
|
146
|
+
if (fileAbs !== join(assetsDir, name) || !fileAbs.startsWith(assetsDir + sep))
|
|
147
|
+
throw new IngestError(6, `resolved asset path escapes assets dir: ${fileAbs}`);
|
|
148
|
+
return fileAbs;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── sniff head + full-file hash (streamed — clips can be 100 MB) ─────────────
|
|
152
|
+
|
|
153
|
+
/** Read the first `n` bytes of a file for the magic-byte sniff. */
|
|
154
|
+
function readHead(file, n = 16) {
|
|
155
|
+
const fd = openSync(file, 'r');
|
|
156
|
+
try {
|
|
157
|
+
const buf = Buffer.alloc(n);
|
|
158
|
+
const bytesRead = readSync(fd, buf, 0, n, 0);
|
|
159
|
+
return buf.subarray(0, bytesRead);
|
|
160
|
+
} finally {
|
|
161
|
+
closeSync(fd);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Full-file sha256 (streamed) → first 8 hex chars. */
|
|
166
|
+
function hashFile(file) {
|
|
167
|
+
return new Promise((res, rej) => {
|
|
168
|
+
const h = createHash('sha256');
|
|
169
|
+
createReadStream(file)
|
|
170
|
+
.on('error', rej)
|
|
171
|
+
.on('data', (d) => h.update(d))
|
|
172
|
+
.on('end', () => res(h.digest('hex').slice(0, 8)));
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ── directory walk + ingest ──────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
function listFiles(dir, recursive) {
|
|
179
|
+
const out = [];
|
|
180
|
+
const walk = (d) => {
|
|
181
|
+
let entries;
|
|
182
|
+
try {
|
|
183
|
+
entries = readdirSync(d, { withFileTypes: true });
|
|
184
|
+
} catch (e) {
|
|
185
|
+
throw new IngestError(2, `cannot read directory ${d}: ${e?.message ?? e}`);
|
|
186
|
+
}
|
|
187
|
+
for (const ent of entries) {
|
|
188
|
+
if (ent.name.startsWith('.')) continue; // skip dotfiles / .DS_Store
|
|
189
|
+
const abs = join(d, ent.name);
|
|
190
|
+
if (ent.isDirectory()) {
|
|
191
|
+
if (recursive) walk(abs);
|
|
192
|
+
} else if (ent.isFile()) {
|
|
193
|
+
out.push(abs);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
walk(dir);
|
|
198
|
+
return out.sort();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Ingest every recognised media file under `dir` into assets/. Returns
|
|
203
|
+
* { clips, skipped, assetsDir }. Never throws on a per-file problem (that file
|
|
204
|
+
* is skipped with a reason); throws IngestError only for whole-run failures
|
|
205
|
+
* (unreadable dir, containment breach).
|
|
206
|
+
*/
|
|
207
|
+
export async function ingestFootage({
|
|
208
|
+
dir,
|
|
209
|
+
root,
|
|
210
|
+
designRootRel = '.design',
|
|
211
|
+
maxBytes = DEFAULT_MAX_BYTES,
|
|
212
|
+
recursive = false,
|
|
213
|
+
}) {
|
|
214
|
+
const srcDir = resolve(dir);
|
|
215
|
+
if (!existsSync(srcDir) || !statSync(srcDir).isDirectory())
|
|
216
|
+
throw new IngestError(2, `not a directory: ${srcDir}`);
|
|
217
|
+
|
|
218
|
+
const assetsDir = assetsDirFor(root, designRootRel);
|
|
219
|
+
mkdirSync(assetsDir, { recursive: true });
|
|
220
|
+
|
|
221
|
+
const clips = [];
|
|
222
|
+
const skipped = [];
|
|
223
|
+
const seen = new Set(); // sha8 dedupe within this run
|
|
224
|
+
|
|
225
|
+
for (const file of listFiles(srcDir, recursive)) {
|
|
226
|
+
let size;
|
|
227
|
+
try {
|
|
228
|
+
size = statSync(file).size;
|
|
229
|
+
} catch (e) {
|
|
230
|
+
skipped.push({ src: file, why: `stat failed: ${e?.message ?? e}` });
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (size === 0) {
|
|
234
|
+
skipped.push({ src: file, why: 'empty file' });
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (size > maxBytes) {
|
|
238
|
+
skipped.push({
|
|
239
|
+
src: file,
|
|
240
|
+
why: `exceeds the ${Math.round(maxBytes / (1024 * 1024))} MB per-file cap (${Math.round(size / (1024 * 1024))} MB)`,
|
|
241
|
+
});
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
let info;
|
|
245
|
+
try {
|
|
246
|
+
info = sniffAsset(readHead(file));
|
|
247
|
+
} catch (e) {
|
|
248
|
+
skipped.push({ src: file, why: `read failed: ${e?.message ?? e}` });
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
if (!info) {
|
|
252
|
+
skipped.push({
|
|
253
|
+
src: file,
|
|
254
|
+
why: `unrecognised media (not video/image/audio); ext ${extname(file) || '(none)'}`,
|
|
255
|
+
});
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
let sha8;
|
|
259
|
+
try {
|
|
260
|
+
sha8 = await hashFile(file);
|
|
261
|
+
} catch (e) {
|
|
262
|
+
skipped.push({ src: file, why: `hash failed: ${e?.message ?? e}` });
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
const name = `${sha8}.${info.ext}`;
|
|
266
|
+
let fileAbs;
|
|
267
|
+
try {
|
|
268
|
+
fileAbs = containedName(assetsDir, name);
|
|
269
|
+
} catch (e) {
|
|
270
|
+
skipped.push({ src: file, why: e?.message ?? 'containment error' });
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
// Content-addressed dedupe: identical bytes → identical name → copy once.
|
|
274
|
+
if (!existsSync(fileAbs)) {
|
|
275
|
+
try {
|
|
276
|
+
copyFileSync(file, fileAbs);
|
|
277
|
+
} catch (e) {
|
|
278
|
+
skipped.push({ src: file, why: `copy failed: ${e?.message ?? e}` });
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (!seen.has(sha8)) {
|
|
283
|
+
seen.add(sha8);
|
|
284
|
+
clips.push({ asset: `assets/${name}`, src: file, bytes: size, category: info.category });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return { clips, skipped, assetsDir };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ── CLI ──────────────────────────────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
function parseArgv(argv) {
|
|
294
|
+
const out = {
|
|
295
|
+
dir: null,
|
|
296
|
+
root: null,
|
|
297
|
+
designRoot: '.design',
|
|
298
|
+
maxBytes: DEFAULT_MAX_BYTES,
|
|
299
|
+
recursive: false,
|
|
300
|
+
json: true,
|
|
301
|
+
};
|
|
302
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
303
|
+
const a = argv[i];
|
|
304
|
+
switch (a) {
|
|
305
|
+
case '--root':
|
|
306
|
+
out.root = argv[++i];
|
|
307
|
+
break;
|
|
308
|
+
case '--design-root':
|
|
309
|
+
out.designRoot = argv[++i];
|
|
310
|
+
break;
|
|
311
|
+
case '--max-bytes':
|
|
312
|
+
out.maxBytes = Number(argv[++i]);
|
|
313
|
+
break;
|
|
314
|
+
case '--recursive':
|
|
315
|
+
out.recursive = true;
|
|
316
|
+
break;
|
|
317
|
+
case '--json':
|
|
318
|
+
out.json = true;
|
|
319
|
+
break;
|
|
320
|
+
case '--help':
|
|
321
|
+
case '-h':
|
|
322
|
+
out.help = true;
|
|
323
|
+
break;
|
|
324
|
+
default:
|
|
325
|
+
if (a.startsWith('-')) throw new IngestError(2, `unknown flag ${a}`);
|
|
326
|
+
if (out.dir === null) out.dir = a;
|
|
327
|
+
else throw new IngestError(2, `unexpected extra arg ${a}`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return out;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const HELP = `ingest-footage — copy a folder of raw footage into the design asset store
|
|
334
|
+
(reached via \`maude design ingest-footage\`)
|
|
335
|
+
|
|
336
|
+
Usage:
|
|
337
|
+
maude design ingest-footage <dir> --root <repo> [--design-root .design]
|
|
338
|
+
[--recursive] [--max-bytes N]
|
|
339
|
+
|
|
340
|
+
Walks <dir>, magic-byte-sniffs each file (mp4/mov/m4v/webm video · mp3/wav/m4a
|
|
341
|
+
audio · png/jpg/gif/webp image; SVG/script/unrecognised skipped), content-
|
|
342
|
+
addresses each to <designRoot>/assets/<sha8>.<ext>, and prints a JSON manifest.
|
|
343
|
+
Oversized / unrecognised files are SKIPPED and listed under "skipped" — never
|
|
344
|
+
silently dropped. The source directory is read-only.
|
|
345
|
+
|
|
346
|
+
Exit: 0 ok (even with skips) · 2 usage · 6 write/containment error · 1 other.`;
|
|
347
|
+
|
|
348
|
+
async function main() {
|
|
349
|
+
let opts;
|
|
350
|
+
try {
|
|
351
|
+
opts = parseArgv(process.argv.slice(2));
|
|
352
|
+
} catch (err) {
|
|
353
|
+
process.stderr.write(`ingest-footage: ${err.message}\n`);
|
|
354
|
+
process.exit(err instanceof IngestError ? err.code : 2);
|
|
355
|
+
}
|
|
356
|
+
if (opts.help) {
|
|
357
|
+
process.stdout.write(`${HELP}\n`);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (!opts.dir) {
|
|
361
|
+
process.stderr.write('ingest-footage: <dir> required\n');
|
|
362
|
+
process.exit(2);
|
|
363
|
+
}
|
|
364
|
+
if (!Number.isFinite(opts.maxBytes) || opts.maxBytes <= 0) {
|
|
365
|
+
process.stderr.write('ingest-footage: --max-bytes must be a positive number\n');
|
|
366
|
+
process.exit(2);
|
|
367
|
+
}
|
|
368
|
+
const root = opts.root || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
369
|
+
try {
|
|
370
|
+
const r = await ingestFootage({
|
|
371
|
+
dir: opts.dir,
|
|
372
|
+
root,
|
|
373
|
+
designRootRel: opts.designRoot,
|
|
374
|
+
maxBytes: opts.maxBytes,
|
|
375
|
+
recursive: opts.recursive,
|
|
376
|
+
});
|
|
377
|
+
process.stdout.write(`${JSON.stringify(r, null, 2)}\n`);
|
|
378
|
+
} catch (err) {
|
|
379
|
+
process.stderr.write(`ingest-footage: ${err.message}\n`);
|
|
380
|
+
process.exit(err instanceof IngestError ? err.code : 1);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
385
|
+
main();
|
|
386
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// _probe-footage-playwright.mjs — keyframe extraction for the footage director
|
|
2
|
+
// (feature-footage-analysis-director, Task 4). The READ counterpart to
|
|
3
|
+
// _ingest-footage.mjs: given ONE content-addressed clip, decode it in headless
|
|
4
|
+
// Chromium and dump N evenly-spaced keyframe PNGs the `footage-analyst` agent
|
|
5
|
+
// then WATCHES (vision) to characterize the clip.
|
|
6
|
+
//
|
|
7
|
+
// WHY A BROWSER, NOT ffmpeg: DDR-148's posture is "no native renderer binaries".
|
|
8
|
+
// Chromium already decodes video (it's the capture spine's engine). We don't even
|
|
9
|
+
// need Remotion here — a plain HTML5 <video>, seeked frame-by-frame and
|
|
10
|
+
// screenshotted, is the whole mechanism. No dev server is required either: the
|
|
11
|
+
// page is navigated as file:// and the clip is a file:// subresource (same
|
|
12
|
+
// scheme; `--allow-file-access-from-files` permits the load), so probing works
|
|
13
|
+
// offline / pre-server, unlike the export capture (which drives the live Player).
|
|
14
|
+
//
|
|
15
|
+
// Determinism note (DDR-094 — freeze-frames lie): the frames are evenly spaced
|
|
16
|
+
// midpoints across the clip, so a downstream reader gets genuine motion samples,
|
|
17
|
+
// not one repeated frame.
|
|
18
|
+
//
|
|
19
|
+
// OUTPUT: keyframe PNGs are THROWAWAY SCRATCH → written under --out-dir (a /tmp
|
|
20
|
+
// scratch path, DDR-115: capture scratch never gets a new `_*` dir, never lands
|
|
21
|
+
// in the VERSIONED assets/). stdout = a JSON manifest
|
|
22
|
+
// { asset, durationSec, width, height, frames:[{index,t,png}] }
|
|
23
|
+
//
|
|
24
|
+
// Reached via `maude design probe-footage`, never a raw path.
|
|
25
|
+
// Exit: 0 ok · 2 usage · 3 no browser (via _pw-launch) · 4 decode/probe error · 1 other.
|
|
26
|
+
|
|
27
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
28
|
+
import { isAbsolute, join, resolve, sep } from 'node:path';
|
|
29
|
+
import { pathToFileURL } from 'node:url';
|
|
30
|
+
|
|
31
|
+
import { launchChromium } from './_pw-launch.mjs';
|
|
32
|
+
|
|
33
|
+
const DEFAULT_FRAMES = 12;
|
|
34
|
+
// Cap the longest side of a keyframe PNG — vision only needs legible frames, and
|
|
35
|
+
// small PNGs keep the analyst's context lean. 960px is ample.
|
|
36
|
+
const MAX_FRAME_EDGE = 960;
|
|
37
|
+
|
|
38
|
+
function parseArgv(argv) {
|
|
39
|
+
const out = {
|
|
40
|
+
asset: null,
|
|
41
|
+
root: null,
|
|
42
|
+
designRoot: '.design',
|
|
43
|
+
frames: DEFAULT_FRAMES,
|
|
44
|
+
outDir: null,
|
|
45
|
+
};
|
|
46
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
47
|
+
const a = argv[i];
|
|
48
|
+
switch (a) {
|
|
49
|
+
case '--asset':
|
|
50
|
+
out.asset = argv[++i];
|
|
51
|
+
break;
|
|
52
|
+
case '--root':
|
|
53
|
+
out.root = argv[++i];
|
|
54
|
+
break;
|
|
55
|
+
case '--design-root':
|
|
56
|
+
out.designRoot = argv[++i];
|
|
57
|
+
break;
|
|
58
|
+
case '--frames':
|
|
59
|
+
out.frames = Number(argv[++i]);
|
|
60
|
+
break;
|
|
61
|
+
case '--out-dir':
|
|
62
|
+
out.outDir = argv[++i];
|
|
63
|
+
break;
|
|
64
|
+
case '--help':
|
|
65
|
+
case '-h':
|
|
66
|
+
out.help = true;
|
|
67
|
+
break;
|
|
68
|
+
default:
|
|
69
|
+
if (a.startsWith('-')) throw usage(`unknown flag ${a}`);
|
|
70
|
+
if (out.asset === null) out.asset = a;
|
|
71
|
+
else throw usage(`unexpected extra arg ${a}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function usage(msg) {
|
|
78
|
+
const e = new Error(msg);
|
|
79
|
+
e.code = 2;
|
|
80
|
+
return e;
|
|
81
|
+
}
|
|
82
|
+
function probeError(msg) {
|
|
83
|
+
const e = new Error(msg);
|
|
84
|
+
e.code = 4;
|
|
85
|
+
return e;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const HELP = `probe-footage — extract keyframe PNGs from one clip for vision analysis
|
|
89
|
+
(reached via \`maude design probe-footage\`)
|
|
90
|
+
|
|
91
|
+
Usage:
|
|
92
|
+
maude design probe-footage <assets/<sha8>.<ext>> --root <repo>
|
|
93
|
+
[--design-root .design] [--frames N] [--out-dir DIR]
|
|
94
|
+
|
|
95
|
+
Decodes the clip in headless Chromium, seeks to N evenly-spaced frames, writes a
|
|
96
|
+
PNG per frame to --out-dir (a /tmp scratch dir — NOT assets/), and prints a JSON
|
|
97
|
+
manifest { asset, durationSec, width, height, frames }.
|
|
98
|
+
|
|
99
|
+
Exit: 0 ok · 2 usage · 3 no browser · 4 decode/probe error · 1 other.`;
|
|
100
|
+
|
|
101
|
+
/** Resolve the clip to an absolute path inside <designRoot>/assets and assert containment. */
|
|
102
|
+
function resolveAsset(root, designRootRel, asset) {
|
|
103
|
+
const rel = String(asset).replace(/^\/+/, ''); // tolerate a leading slash
|
|
104
|
+
if (isAbsolute(rel) || rel.includes('..'))
|
|
105
|
+
throw usage(`asset must be a relative assets/ path: ${asset}`);
|
|
106
|
+
const assetsDir = resolve(root, designRootRel, 'assets');
|
|
107
|
+
// Accept both `assets/<name>` and a bare `<name>`.
|
|
108
|
+
const name = rel.startsWith('assets/') ? rel.slice('assets/'.length) : rel;
|
|
109
|
+
if (!/^[a-z0-9]{8}\.[a-z0-9]{2,4}$/.test(name))
|
|
110
|
+
throw usage(`asset name must be content-addressed <sha8>.<ext>: ${name}`);
|
|
111
|
+
const abs = resolve(assetsDir, name);
|
|
112
|
+
if (abs !== join(assetsDir, name) || !abs.startsWith(assetsDir + sep))
|
|
113
|
+
throw usage(`asset path escapes assets dir: ${abs}`);
|
|
114
|
+
return { abs, name, rel: `assets/${name}` };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function probe({ asset, root, designRootRel, frames, outDir }) {
|
|
118
|
+
const { abs, name, rel } = resolveAsset(root, designRootRel, asset);
|
|
119
|
+
const nFrames = Number.isInteger(frames) && frames > 0 ? Math.min(frames, 64) : DEFAULT_FRAMES;
|
|
120
|
+
const dir =
|
|
121
|
+
outDir || join(process.env.TMPDIR || '/tmp', `maude-footage-${name.replace(/\./g, '_')}`);
|
|
122
|
+
mkdirSync(dir, { recursive: true });
|
|
123
|
+
|
|
124
|
+
const browser = await launchChromium({
|
|
125
|
+
headless: true,
|
|
126
|
+
// file:// page loading a file:// video subresource needs this flag.
|
|
127
|
+
args: ['--allow-file-access-from-files', '--autoplay-policy=no-user-gesture-required'],
|
|
128
|
+
});
|
|
129
|
+
try {
|
|
130
|
+
const page = await browser.newPage();
|
|
131
|
+
// A real file:// host page (NOT about:blank — its opaque origin can't load a
|
|
132
|
+
// file:// subresource even with --allow-file-access-from-files). With a
|
|
133
|
+
// file:// page + the flag, the clip loads as a same-scheme cross-dir resource.
|
|
134
|
+
const hostHtml = join(dir, '_probe.html');
|
|
135
|
+
writeFileSync(hostHtml, '<!doctype html><meta charset="utf-8"><body style="margin:0"></body>');
|
|
136
|
+
await page.goto(pathToFileURL(hostHtml).href);
|
|
137
|
+
|
|
138
|
+
// Load metadata: duration + intrinsic dimensions.
|
|
139
|
+
const fileUrl = pathToFileURL(abs).href;
|
|
140
|
+
const meta = await page.evaluate(async (url) => {
|
|
141
|
+
const v = document.createElement('video');
|
|
142
|
+
v.id = 'probe-v';
|
|
143
|
+
v.muted = true;
|
|
144
|
+
v.playsInline = true;
|
|
145
|
+
v.preload = 'auto';
|
|
146
|
+
v.style.position = 'fixed';
|
|
147
|
+
v.style.left = '0';
|
|
148
|
+
v.style.top = '0';
|
|
149
|
+
v.style.margin = '0';
|
|
150
|
+
document.body.style.margin = '0';
|
|
151
|
+
document.body.appendChild(v);
|
|
152
|
+
const done = new Promise((res, rej) => {
|
|
153
|
+
v.addEventListener('loadedmetadata', () => res(true), { once: true });
|
|
154
|
+
v.addEventListener('error', () => rej(new Error('video error while loading metadata')), {
|
|
155
|
+
once: true,
|
|
156
|
+
});
|
|
157
|
+
setTimeout(() => rej(new Error('timed out loading metadata (15s)')), 15000);
|
|
158
|
+
});
|
|
159
|
+
v.src = url;
|
|
160
|
+
await done;
|
|
161
|
+
return { duration: v.duration, width: v.videoWidth, height: v.videoHeight };
|
|
162
|
+
}, fileUrl);
|
|
163
|
+
|
|
164
|
+
if (!Number.isFinite(meta.duration) || meta.duration <= 0 || !meta.width || !meta.height) {
|
|
165
|
+
throw probeError(
|
|
166
|
+
`clip has no seekable video track (duration=${meta.duration}, ${meta.width}x${meta.height})`
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Size the <video> element to its intrinsic size, capped, and set the viewport
|
|
171
|
+
// to match so an element screenshot captures the whole frame at 1:1-ish.
|
|
172
|
+
const scale = Math.min(1, MAX_FRAME_EDGE / Math.max(meta.width, meta.height));
|
|
173
|
+
const drawW = Math.max(1, Math.round(meta.width * scale));
|
|
174
|
+
const drawH = Math.max(1, Math.round(meta.height * scale));
|
|
175
|
+
await page.setViewportSize({ width: drawW, height: drawH });
|
|
176
|
+
await page.evaluate(
|
|
177
|
+
({ w, h }) => {
|
|
178
|
+
const v = document.getElementById('probe-v');
|
|
179
|
+
v.style.width = `${w}px`;
|
|
180
|
+
v.style.height = `${h}px`;
|
|
181
|
+
// Expose a promise-returning seek helper for the frame loop.
|
|
182
|
+
window.__seekTo = (t) =>
|
|
183
|
+
new Promise((res, rej) => {
|
|
184
|
+
const onSeeked = () => {
|
|
185
|
+
v.removeEventListener('seeked', onSeeked);
|
|
186
|
+
res(true);
|
|
187
|
+
};
|
|
188
|
+
v.addEventListener('seeked', onSeeked);
|
|
189
|
+
setTimeout(() => {
|
|
190
|
+
v.removeEventListener('seeked', onSeeked);
|
|
191
|
+
rej(new Error(`seek to ${t}s timed out`));
|
|
192
|
+
}, 8000);
|
|
193
|
+
v.currentTime = t;
|
|
194
|
+
});
|
|
195
|
+
},
|
|
196
|
+
{ w: drawW, h: drawH }
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
const video = page.locator('#probe-v');
|
|
200
|
+
const outFrames = [];
|
|
201
|
+
for (let i = 0; i < nFrames; i += 1) {
|
|
202
|
+
// Even midpoints across (0, duration) — avoids the exact-0 / exact-end
|
|
203
|
+
// frames some codecs won't decode cleanly.
|
|
204
|
+
const t = (meta.duration * (i + 0.5)) / nFrames;
|
|
205
|
+
try {
|
|
206
|
+
await page.evaluate((tt) => window.__seekTo(tt), t);
|
|
207
|
+
} catch (e) {
|
|
208
|
+
// A single flaky seek shouldn't abort the whole probe — skip this frame.
|
|
209
|
+
outFrames.push({
|
|
210
|
+
index: i,
|
|
211
|
+
t: Number(t.toFixed(3)),
|
|
212
|
+
png: null,
|
|
213
|
+
error: String(e?.message ?? e),
|
|
214
|
+
});
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
const png = join(dir, `frame-${String(i).padStart(2, '0')}.png`);
|
|
218
|
+
await video.screenshot({ path: png });
|
|
219
|
+
outFrames.push({ index: i, t: Number(t.toFixed(3)), png });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
asset: rel,
|
|
224
|
+
durationSec: Number(meta.duration.toFixed(3)),
|
|
225
|
+
width: meta.width,
|
|
226
|
+
height: meta.height,
|
|
227
|
+
outDir: dir,
|
|
228
|
+
frames: outFrames,
|
|
229
|
+
};
|
|
230
|
+
} finally {
|
|
231
|
+
await browser.close();
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function main() {
|
|
236
|
+
let opts;
|
|
237
|
+
try {
|
|
238
|
+
opts = parseArgv(process.argv.slice(2));
|
|
239
|
+
} catch (err) {
|
|
240
|
+
process.stderr.write(`probe-footage: ${err.message}\n`);
|
|
241
|
+
process.exit(err.code ?? 2);
|
|
242
|
+
}
|
|
243
|
+
if (opts.help) {
|
|
244
|
+
process.stdout.write(`${HELP}\n`);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (!opts.asset) {
|
|
248
|
+
process.stderr.write('probe-footage: <assets/<sha8>.<ext>> required\n');
|
|
249
|
+
process.exit(2);
|
|
250
|
+
}
|
|
251
|
+
const root = opts.root || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
252
|
+
try {
|
|
253
|
+
const r = await probe({
|
|
254
|
+
asset: opts.asset,
|
|
255
|
+
root,
|
|
256
|
+
designRootRel: opts.designRoot,
|
|
257
|
+
frames: opts.frames,
|
|
258
|
+
outDir: opts.outDir,
|
|
259
|
+
});
|
|
260
|
+
process.stdout.write(`${JSON.stringify(r, null, 2)}\n`);
|
|
261
|
+
} catch (err) {
|
|
262
|
+
process.stderr.write(`probe-footage: ${err.message}\n`);
|
|
263
|
+
process.exit(err.code ?? 1);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
268
|
+
main();
|
|
269
|
+
}
|