|
1 | | -import { defineCommand } from "citty"; |
2 | 1 | import { createWriteStream, existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs"; |
3 | 2 | import { resolve, join, basename } from "node:path"; |
4 | 3 | import { c } from "../../ui/colors.js"; |
5 | 4 | import { safeFetch } from "../../capture/assetDownloader.js"; |
6 | | -import type { Example } from "../_examples.js"; |
7 | | - |
8 | | -export const examples: Example[] = [ |
9 | | - [ |
10 | | - "Download the hero video (index 0) from a captured project's manifest", |
11 | | - "capture video ./my-project --index 0", |
12 | | - ], |
13 | | - [ |
14 | | - "Download a specific video by exact URL", |
15 | | - "capture video ./my-project --url https://cdn.example.com/hero.mp4", |
16 | | - ], |
17 | | - ["List entries in the manifest without downloading", "capture video ./my-project --list"], |
18 | | -]; |
19 | 5 |
|
20 | 6 | const MAX_VIDEO_BYTES = 250 * 1024 * 1024; |
21 | 7 | const VIDEO_CONTENT_TYPE_RE = /^(video\/|application\/(mp4|octet-stream|x-mpegurl))/i; |
@@ -185,127 +171,108 @@ export function pickManifestEntry( |
185 | 171 | }; |
186 | 172 | } |
187 | 173 |
|
188 | | -export default defineCommand({ |
189 | | - meta: { |
190 | | - name: "video", |
191 | | - description: |
192 | | - "Download a video referenced in capture/extracted/video-manifest.json (on-demand; the capture pipeline only writes the manifest + preview PNGs)", |
193 | | - }, |
194 | | - args: { |
195 | | - project: { |
196 | | - type: "positional", |
197 | | - description: "Path to the captured project directory", |
198 | | - required: true, |
199 | | - }, |
200 | | - index: { |
201 | | - type: "string", |
202 | | - description: "Manifest entry index to download (0-based)", |
203 | | - }, |
204 | | - url: { |
205 | | - type: "string", |
206 | | - description: "Exact video URL to download (must match a manifest entry)", |
207 | | - }, |
208 | | - list: { |
209 | | - type: "boolean", |
210 | | - description: "List manifest entries (index, dimensions, heading) and exit", |
211 | | - }, |
212 | | - }, |
213 | | - // fallow-ignore-next-line complexity |
214 | | - async run({ args }) { |
215 | | - const projectDir = resolve(String(args.project)); |
216 | | - // standalone capture writes `<dir>/extracted/…`; W2H project nests under `<dir>/capture/extracted/…`. |
217 | | - const directPath = join(projectDir, "extracted", "video-manifest.json"); |
218 | | - const w2hPath = join(projectDir, "capture", "extracted", "video-manifest.json"); |
219 | | - const manifestPath = existsSync(directPath) ? directPath : w2hPath; |
220 | | - const isW2hLayout = manifestPath === w2hPath; |
221 | | - if (!existsSync(manifestPath)) { |
222 | | - console.error( |
223 | | - `${c.error("✗")} no video-manifest.json at ${directPath} or ${w2hPath}\n` + |
224 | | - ` Was this directory produced by \`hyperframes capture\`?`, |
225 | | - ); |
226 | | - process.exitCode = 1; |
227 | | - return; |
228 | | - } |
229 | | - let manifest: ManifestEntry[]; |
230 | | - try { |
231 | | - manifest = JSON.parse(readFileSync(manifestPath, "utf-8")); |
232 | | - } catch (e) { |
233 | | - console.error(`${c.error("✗")} video-manifest.json is malformed: ${(e as Error).message}`); |
234 | | - process.exitCode = 1; |
| 174 | +export interface VideoModeArgs { |
| 175 | + project: string; |
| 176 | + index?: string | null; |
| 177 | + url?: string | null; |
| 178 | + list?: boolean; |
| 179 | +} |
| 180 | + |
| 181 | +// fallow-ignore-next-line complexity |
| 182 | +export async function runVideoMode(args: VideoModeArgs): Promise<void> { |
| 183 | + const projectDir = resolve(args.project); |
| 184 | + // standalone capture writes `<dir>/extracted/…`; W2H project nests under `<dir>/capture/extracted/…`. |
| 185 | + const directPath = join(projectDir, "extracted", "video-manifest.json"); |
| 186 | + const w2hPath = join(projectDir, "capture", "extracted", "video-manifest.json"); |
| 187 | + const manifestPath = existsSync(directPath) ? directPath : w2hPath; |
| 188 | + const isW2hLayout = manifestPath === w2hPath; |
| 189 | + if (!existsSync(manifestPath)) { |
| 190 | + console.error( |
| 191 | + `${c.error("✗")} no video-manifest.json at ${directPath} or ${w2hPath}\n` + |
| 192 | + ` Was this directory produced by \`hyperframes capture\`?`, |
| 193 | + ); |
| 194 | + process.exitCode = 1; |
| 195 | + return; |
| 196 | + } |
| 197 | + let manifest: ManifestEntry[]; |
| 198 | + try { |
| 199 | + manifest = JSON.parse(readFileSync(manifestPath, "utf-8")); |
| 200 | + } catch (e) { |
| 201 | + console.error(`${c.error("✗")} video-manifest.json is malformed: ${(e as Error).message}`); |
| 202 | + process.exitCode = 1; |
| 203 | + return; |
| 204 | + } |
| 205 | + |
| 206 | + if (args.list) { |
| 207 | + if (manifest.length === 0) { |
| 208 | + console.log(c.dim("(manifest is empty — no <video> elements on the captured page)")); |
235 | 209 | return; |
236 | 210 | } |
237 | | - |
238 | | - if (args.list) { |
239 | | - if (manifest.length === 0) { |
240 | | - console.log(c.dim("(manifest is empty — no <video> elements on the captured page)")); |
241 | | - return; |
242 | | - } |
| 211 | + console.log( |
| 212 | + `${manifest.length} video entr${manifest.length === 1 ? "y" : "ies"} in ${manifestPath}:`, |
| 213 | + ); |
| 214 | + for (const e of manifest) { |
243 | 215 | console.log( |
244 | | - `${manifest.length} video entr${manifest.length === 1 ? "y" : "ies"} in ${manifestPath}:`, |
| 216 | + ` ${c.bold(`[${e.index}]`)} ${e.filename} — ${e.width}×${e.height}` + |
| 217 | + (e.heading ? `\n heading: "${e.heading}"` : "") + |
| 218 | + `\n url: ${e.url}`, |
245 | 219 | ); |
246 | | - for (const e of manifest) { |
247 | | - console.log( |
248 | | - ` ${c.bold(`[${e.index}]`)} ${e.filename} — ${e.width}×${e.height}` + |
249 | | - (e.heading ? `\n heading: "${e.heading}"` : "") + |
250 | | - `\n url: ${e.url}`, |
251 | | - ); |
252 | | - } |
253 | | - return; |
254 | 220 | } |
| 221 | + return; |
| 222 | + } |
255 | 223 |
|
256 | | - const pick = pickManifestEntry(manifest, args); |
257 | | - if (!pick.ok) { |
258 | | - console.error( |
259 | | - `${c.error("✗")} ${pick.message}` + |
260 | | - (pick.code === "no-match-url" ? `\n Run with --list to see what's available.` : ""), |
261 | | - ); |
262 | | - process.exitCode = 1; |
263 | | - return; |
264 | | - } |
265 | | - const entry = pick.entry; |
| 224 | + const pick = pickManifestEntry(manifest, args); |
| 225 | + if (!pick.ok) { |
| 226 | + console.error( |
| 227 | + `${c.error("✗")} ${pick.message}` + |
| 228 | + (pick.code === "no-match-url" ? `\n Run with --list to see what's available.` : ""), |
| 229 | + ); |
| 230 | + process.exitCode = 1; |
| 231 | + return; |
| 232 | + } |
| 233 | + const entry = pick.entry; |
266 | 234 |
|
267 | | - const collisions = findFilenameCollision(manifest, entry); |
268 | | - if (collisions.length > 0) { |
269 | | - console.error( |
270 | | - `${c.error("✗")} filename "${safeFilename(entry.filename || basename(entry.url))}" ` + |
271 | | - `collides with manifest entr${collisions.length === 1 ? "y" : "ies"} ` + |
272 | | - `${collisions.map((co) => `[${co.index}]`).join(", ")}. ` + |
273 | | - `Refusing to download — the on-disk file's bytes would not match the requested entry.`, |
274 | | - ); |
275 | | - process.exitCode = 1; |
276 | | - return; |
277 | | - } |
| 235 | + const collisions = findFilenameCollision(manifest, entry); |
| 236 | + if (collisions.length > 0) { |
| 237 | + console.error( |
| 238 | + `${c.error("✗")} filename "${safeFilename(entry.filename || basename(entry.url))}" ` + |
| 239 | + `collides with manifest entr${collisions.length === 1 ? "y" : "ies"} ` + |
| 240 | + `${collisions.map((co) => `[${co.index}]`).join(", ")}. ` + |
| 241 | + `Refusing to download — the on-disk file's bytes would not match the requested entry.`, |
| 242 | + ); |
| 243 | + process.exitCode = 1; |
| 244 | + return; |
| 245 | + } |
278 | 246 |
|
279 | | - const outDir = isW2hLayout |
280 | | - ? join(projectDir, "capture", "assets", "videos") |
281 | | - : join(projectDir, "assets", "videos"); |
282 | | - mkdirSync(outDir, { recursive: true }); |
283 | | - const fname = safeFilename(entry.filename || basename(entry.url)); |
284 | | - const outPath = join(outDir, fname); |
285 | | - const relPath = isW2hLayout ? `capture/assets/videos/${fname}` : `assets/videos/${fname}`; |
| 247 | + const outDir = isW2hLayout |
| 248 | + ? join(projectDir, "capture", "assets", "videos") |
| 249 | + : join(projectDir, "assets", "videos"); |
| 250 | + mkdirSync(outDir, { recursive: true }); |
| 251 | + const fname = safeFilename(entry.filename || basename(entry.url)); |
| 252 | + const outPath = join(outDir, fname); |
| 253 | + const relPath = isW2hLayout ? `capture/assets/videos/${fname}` : `assets/videos/${fname}`; |
286 | 254 |
|
| 255 | + console.log( |
| 256 | + `${c.accent("▸")} downloading [${entry.index}] ${entry.filename} (${entry.width}×${entry.height})`, |
| 257 | + ); |
| 258 | + console.log(` from: ${entry.url}`); |
| 259 | + try { |
| 260 | + const bytes = await streamToFile(entry.url, outPath); |
| 261 | + const sizeKb = Math.round(bytes / 1024); |
| 262 | + const sizeStr = sizeKb > 1024 ? `${(sizeKb / 1024).toFixed(1)}MB` : `${sizeKb}KB`; |
| 263 | + console.log(`${c.success("◇")} wrote ${relPath} (${sizeStr})`); |
| 264 | + const snippetId = `video-${entry.index}`; |
287 | 265 | console.log( |
288 | | - `${c.accent("▸")} downloading [${entry.index}] ${entry.filename} (${entry.width}×${entry.height})`, |
| 266 | + ` Reference it from a beat composition as:\n` + |
| 267 | + ` <video id="${snippetId}" src="${relPath}" data-start="0" data-duration="${entry.width === entry.height ? 5 : 4}" data-track-index="0" autoplay muted loop></video>`, |
289 | 268 | ); |
290 | | - console.log(` from: ${entry.url}`); |
291 | | - try { |
292 | | - const bytes = await streamToFile(entry.url, outPath); |
293 | | - const sizeKb = Math.round(bytes / 1024); |
294 | | - const sizeStr = sizeKb > 1024 ? `${(sizeKb / 1024).toFixed(1)}MB` : `${sizeKb}KB`; |
295 | | - console.log(`${c.success("◇")} wrote ${relPath} (${sizeStr})`); |
296 | | - const snippetId = `video-${entry.index}`; |
297 | | - console.log( |
298 | | - ` Reference it from a beat composition as:\n` + |
299 | | - ` <video id="${snippetId}" src="${relPath}" data-start="0" data-duration="${entry.width === entry.height ? 5 : 4}" data-track-index="0" autoplay muted loop></video>`, |
300 | | - ); |
301 | | - } catch (e) { |
302 | | - if ((e as NodeJS.ErrnoException).code === "EEXIST") { |
303 | | - console.log(`${c.warn("⚠")} already downloaded: ${relPath} (skipping)`); |
304 | | - console.log(` Delete the file and re-run to refetch.`); |
305 | | - return; |
306 | | - } |
307 | | - console.error(`${c.error("✗")} download failed: ${(e as Error).message}`); |
308 | | - process.exitCode = 1; |
| 269 | + } catch (e) { |
| 270 | + if ((e as NodeJS.ErrnoException).code === "EEXIST") { |
| 271 | + console.log(`${c.warn("⚠")} already downloaded: ${relPath} (skipping)`); |
| 272 | + console.log(` Delete the file and re-run to refetch.`); |
| 273 | + return; |
309 | 274 | } |
310 | | - }, |
311 | | -}); |
| 275 | + console.error(`${c.error("✗")} download failed: ${(e as Error).message}`); |
| 276 | + process.exitCode = 1; |
| 277 | + } |
| 278 | +} |
0 commit comments