({ args })
| 366 | // Full decomposition is tracked separately and out of scope for #1199. |
| 367 | // fallow-ignore-next-line complexity |
| 368 | async run({ args }) { |
| 369 | // ── Resolve project ──────────────────────────────────────────────────── |
| 370 | const project = resolveProject(args.dir); |
| 371 | |
| 372 | // ── Validate fps ─────────────────────────────────────────────────────── |
| 373 | // Accept either integer (`30`) or ffmpeg-style rational (`30000/1001`). |
| 374 | // The whitelist-based validator was replaced with a sane numeric range so |
| 375 | // legitimate framerates (NTSC trio, PAL, 120/240 slow-mo) work without |
| 376 | // CLI gymnastics. The exact rational survives end-to-end into FFmpeg's |
| 377 | // `-r` / `-framerate` flags via `fpsToFfmpegArg`. |
| 378 | const fpsParse = parseFps(args.fps ?? "30"); |
| 379 | if (!fpsParse.ok) { |
| 380 | errorBox("Invalid fps", formatFpsParseError(args.fps ?? "30", fpsParse.reason)); |
| 381 | process.exit(1); |
| 382 | } |
| 383 | let fps: Fps = fpsParse.value; |
| 384 | |
| 385 | // ── Validate quality ─────────────────────────────────────────────────── |
| 386 | const qualityRaw = args.quality ?? "standard"; |
| 387 | if (!VALID_QUALITY.has(qualityRaw)) { |
| 388 | errorBox("Invalid quality", `Got "${qualityRaw}". Must be draft, standard, or high.`); |
| 389 | process.exit(1); |
| 390 | } |
| 391 | const quality = qualityRaw as "draft" | "standard" | "high"; |
| 392 | |
| 393 | // ── Authoring skill (telemetry attribution) ──────────────────────────── |
| 394 | // Optional slug naming the workflow skill that drove this render (e.g. |
| 395 | // "product-launch-video"), tagged onto render telemetry for per-skill usage |
| 396 | // breakdowns. Slug-gated (shared with the `events` command) so a caller |
| 397 | // can't push high-cardinality or PII strings into the anonymous event |
| 398 | // stream; a missing/invalid value is omitted. |
| 399 | const authoringSkill = normalizeSkillSlug(args.skill); |
| 400 | if (typeof args.skill === "string" && args.skill.trim() !== "" && !authoringSkill) { |
| 401 | // Surface a typo (e.g. camelCase) instead of silently losing attribution. |
| 402 | // Warning only — never fails the render. |
| 403 | process.stderr.write( |
| 404 | `hyperframes: ignoring --skill="${args.skill}" — not a valid slug ` + |
| 405 | "(lowercase letters/digits/hyphens, max 64); this render will be unattributed.\n", |
| 406 | ); |
| 407 | } |
| 408 | |
| 409 | // ── Validate format ───────────────────────────────────────────────── |
| 410 | const formatRaw = args.format ?? "mp4"; |
| 411 | const format = parseRenderFormat(formatRaw); |
| 412 | if (!format) { |
| 413 | errorBox("Invalid format", `Got "${formatRaw}". Must be ${RENDER_FORMAT_LABEL}.`); |
| 414 | process.exit(1); |
| 415 | } |
| 416 | |
| 417 | let gifFpsCapped = false; |
| 418 | if (format === "gif" && fpsToNumber(fps) > 30) { |
| 419 | fps = { num: 30, den: 1 }; |
| 420 | gifFpsCapped = true; |
| 421 | } |
| 422 | |
| 423 | const gifLoopParse = parseGifLoopArg(args["gif-loop"]); |
| 424 | if (!gifLoopParse.ok) { |
| 425 | errorBox("Invalid gif-loop", gifLoopParse.message); |
nothing calls this directly
no test coverage detected