@vibeframe/cli 0.113.0 → 0.113.3

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.
@@ -10459,6 +10459,19 @@ var require_src = __commonJS({
10459
10459
  });
10460
10460
 
10461
10461
  // ../ai-providers/dist/fal/FalProvider.js
10462
+ function estimateSeedanceVideoCostUsd(opts) {
10463
+ const shortSide = SEEDANCE_SHORT_SIDE[opts.resolution ?? "720p"] ?? 720;
10464
+ const [a, b] = (opts.aspectRatio ?? "16:9").split(":").map(Number);
10465
+ const ratioFactor = a > 0 && b > 0 ? Math.max(a, b) / Math.min(a, b) : 16 / 9;
10466
+ const longSide = Math.round(shortSide * ratioFactor);
10467
+ const tokensPerSecond = shortSide * longSide * 24 / 1024;
10468
+ let usdPerSecond = tokensPerSecond / 1e3 * SEEDANCE_USD_PER_1K_TOKENS;
10469
+ if (opts.fast && shortSide <= 720)
10470
+ usdPerSecond *= SEEDANCE_FAST_FACTOR;
10471
+ if (opts.hasVideoReference)
10472
+ usdPerSecond *= SEEDANCE_VIDEO_REF_FACTOR;
10473
+ return Number((usdPerSecond * Math.max(0, opts.durationSec)).toFixed(2));
10474
+ }
10462
10475
  function formatFalError(error) {
10463
10476
  if (!(error instanceof Error))
10464
10477
  return String(error);
@@ -10568,7 +10581,7 @@ function normaliseDuration(value) {
10568
10581
  return "auto";
10569
10582
  return Math.max(4, Math.min(15, Math.round(value)));
10570
10583
  }
10571
- var import_client, ENDPOINT_TEXT_TO_VIDEO, ENDPOINT_IMAGE_TO_VIDEO, ENDPOINT_REFERENCE_TO_VIDEO, DEFAULT_VARIANT, VALID_RESOLUTIONS, VALID_ASPECTS, FalProvider, falProvider;
10584
+ var import_client, ENDPOINT_TEXT_TO_VIDEO, ENDPOINT_IMAGE_TO_VIDEO, ENDPOINT_REFERENCE_TO_VIDEO, DEFAULT_VARIANT, VALID_RESOLUTIONS, VALID_ASPECTS, SEEDANCE_USD_PER_1K_TOKENS, SEEDANCE_FAST_FACTOR, SEEDANCE_VIDEO_REF_FACTOR, SEEDANCE_SHORT_SIDE, FalProvider, falProvider;
10572
10585
  var init_FalProvider = __esm({
10573
10586
  "../ai-providers/dist/fal/FalProvider.js"() {
10574
10587
  "use strict";
@@ -10588,6 +10601,10 @@ var init_FalProvider = __esm({
10588
10601
  DEFAULT_VARIANT = "seedance-2.0";
10589
10602
  VALID_RESOLUTIONS = ["480p", "720p", "1080p"];
10590
10603
  VALID_ASPECTS = ["21:9", "16:9", "4:3", "1:1", "3:4", "9:16", "auto"];
10604
+ SEEDANCE_USD_PER_1K_TOKENS = 0.014;
10605
+ SEEDANCE_FAST_FACTOR = 0.8;
10606
+ SEEDANCE_VIDEO_REF_FACTOR = 0.6;
10607
+ SEEDANCE_SHORT_SIDE = { "480p": 480, "720p": 720, "1080p": 1080 };
10591
10608
  FalProvider = class {
10592
10609
  constructor() {
10593
10610
  this.id = "seedance";
@@ -11412,6 +11429,7 @@ __export(dist_exports2, {
11412
11429
  defineApiKey: () => defineApiKey,
11413
11430
  defineProvider: () => defineProvider,
11414
11431
  elevenLabsProvider: () => elevenLabsProvider,
11432
+ estimateSeedanceVideoCostUsd: () => estimateSeedanceVideoCostUsd,
11415
11433
  falProvider: () => falProvider,
11416
11434
  geminiProvider: () => geminiProvider,
11417
11435
  getAllApiKeys: () => getAllApiKeys,
@@ -24218,7 +24236,534 @@ var init_build_asset_metadata = __esm({
24218
24236
  }
24219
24237
  });
24220
24238
 
24239
+ // src/commands/_shared/review-report.ts
24240
+ import { writeFile as writeFile6 } from "node:fs/promises";
24241
+ import { join as join2, resolve as resolve5 } from "node:path";
24242
+ function statusFromIssues(issues) {
24243
+ if (issues.some((issue) => issue.severity === "error")) return "fail";
24244
+ if (issues.some((issue) => issue.severity === "warning")) return "warn";
24245
+ return "pass";
24246
+ }
24247
+ function scoreIssues(issues) {
24248
+ let score = 100;
24249
+ for (const issue of issues) {
24250
+ if (issue.severity === "error") score -= 25;
24251
+ else if (issue.severity === "warning") score -= 8;
24252
+ else score -= 2;
24253
+ }
24254
+ return Math.max(0, Math.min(100, score));
24255
+ }
24256
+ function uniqueRetryWith(items) {
24257
+ return [
24258
+ ...new Set(items.filter((item) => typeof item === "string" && item.length > 0))
24259
+ ];
24260
+ }
24261
+ function commandReviewAction(command, opts) {
24262
+ const classified = classifyReviewCommand(command);
24263
+ return stripUndefined({
24264
+ id: actionId("command", command),
24265
+ kind: "command",
24266
+ label: opts.label ?? command,
24267
+ command,
24268
+ fixOwner: opts.fixOwner ?? classified.fixOwner,
24269
+ costTier: opts.costTier ?? classified.costTier,
24270
+ safeToAutoRun: opts.safeToAutoRun ?? classified.safeToAutoRun,
24271
+ requiresConfirmation: opts.requiresConfirmation ?? classified.requiresConfirmation,
24272
+ reason: opts.reason,
24273
+ sourceIssueCodes: uniqueStrings(opts.sourceIssueCodes ?? [])
24274
+ });
24275
+ }
24276
+ function agentReviewAction(opts) {
24277
+ return stripUndefined({
24278
+ id: actionId("agent", opts.agentPrompt),
24279
+ kind: "agent",
24280
+ label: opts.label,
24281
+ agentPrompt: opts.agentPrompt,
24282
+ fixOwner: "host-agent",
24283
+ costTier: "unknown",
24284
+ safeToAutoRun: false,
24285
+ requiresConfirmation: false,
24286
+ reason: opts.reason,
24287
+ sourceIssueCodes: uniqueStrings(opts.sourceIssueCodes ?? [])
24288
+ });
24289
+ }
24290
+ function manualReviewAction(opts) {
24291
+ return stripUndefined({
24292
+ id: actionId("manual", opts.label),
24293
+ kind: "manual",
24294
+ label: opts.label,
24295
+ fixOwner: opts.fixOwner ?? "host-agent",
24296
+ costTier: "unknown",
24297
+ safeToAutoRun: false,
24298
+ requiresConfirmation: false,
24299
+ reason: opts.reason,
24300
+ sourceIssueCodes: uniqueStrings(opts.sourceIssueCodes ?? [])
24301
+ });
24302
+ }
24303
+ function reviewActionsFromRetryWith(retryWith, sourceIssueCodes = []) {
24304
+ return normalizeReviewActions(
24305
+ retryWith.map((item) => {
24306
+ if (/^codex\s+/i.test(item)) {
24307
+ return agentReviewAction({
24308
+ label: "Ask the host agent to fix review issues",
24309
+ agentPrompt: item,
24310
+ reason: "The review report contains host-agent-owned issues.",
24311
+ sourceIssueCodes
24312
+ });
24313
+ }
24314
+ return commandReviewAction(item, {
24315
+ reason: "Backward-compatible retryWith command.",
24316
+ sourceIssueCodes
24317
+ });
24318
+ })
24319
+ );
24320
+ }
24321
+ function normalizeReviewActions(actions) {
24322
+ const merged = /* @__PURE__ */ new Map();
24323
+ for (const action of actions) {
24324
+ const key = [action.kind, action.command ?? action.agentPrompt ?? action.label].join("\0");
24325
+ const existing = merged.get(key);
24326
+ if (!existing) {
24327
+ merged.set(key, {
24328
+ ...action,
24329
+ sourceIssueCodes: uniqueStrings(action.sourceIssueCodes ?? [])
24330
+ });
24331
+ continue;
24332
+ }
24333
+ merged.set(key, {
24334
+ ...existing,
24335
+ safeToAutoRun: existing.safeToAutoRun && action.safeToAutoRun,
24336
+ requiresConfirmation: existing.requiresConfirmation || action.requiresConfirmation,
24337
+ sourceIssueCodes: uniqueStrings([
24338
+ ...existing.sourceIssueCodes ?? [],
24339
+ ...action.sourceIssueCodes ?? []
24340
+ ])
24341
+ });
24342
+ }
24343
+ return [...merged.values()].map(
24344
+ (action) => stripUndefined({
24345
+ ...action,
24346
+ sourceIssueCodes: action.sourceIssueCodes && action.sourceIssueCodes.length > 0 ? action.sourceIssueCodes : void 0
24347
+ })
24348
+ );
24349
+ }
24350
+ function deriveNextReviewActions(opts) {
24351
+ return normalizeReviewActions([
24352
+ ...opts.issues.flatMap((issue) => issue.actions ?? []),
24353
+ ...reviewActionsFromRetryWith(opts.retryWith)
24354
+ ]);
24355
+ }
24356
+ function fixOwnerForIssue(issue) {
24357
+ if (issue.fixOwner) return issue.fixOwner;
24358
+ if (issue.code.startsWith("AI_REVIEW_")) return "host-agent";
24359
+ if (issue.code.startsWith("STORYBOARD_")) return "host-agent";
24360
+ if (issue.code === "PROJECT_NOT_FOUND" || issue.code === "MISSING_STORYBOARD" || issue.code === "BEAT_NOT_FOUND" || issue.code === "MISSING_DESIGN") {
24361
+ return "host-agent";
24362
+ }
24363
+ return "vibe";
24364
+ }
24365
+ function normalizeReviewIssues(issues, opts) {
24366
+ const fallbackOwner = typeof opts === "string" ? opts : opts?.fallbackOwner;
24367
+ const projectDir = typeof opts === "string" ? void 0 : opts?.projectDir;
24368
+ return issues.map((issue) => {
24369
+ const normalized = {
24370
+ ...issue,
24371
+ fixOwner: issue.fixOwner ?? fallbackOwner ?? fixOwnerForIssue(issue)
24372
+ };
24373
+ const actions = normalizeReviewActions([
24374
+ ...issue.actions ?? [],
24375
+ ...projectDir ? defaultActionsForIssue(normalized, projectDir) : []
24376
+ ]);
24377
+ return stripUndefined({
24378
+ ...normalized,
24379
+ actions: actions.length > 0 ? actions : void 0
24380
+ });
24381
+ });
24382
+ }
24383
+ function summarizeReviewIssues(issues) {
24384
+ const normalized = normalizeReviewIssues(issues);
24385
+ return {
24386
+ issueCount: normalized.length,
24387
+ errorCount: normalized.filter((issue) => issue.severity === "error").length,
24388
+ warningCount: normalized.filter((issue) => issue.severity === "warning").length,
24389
+ infoCount: normalized.filter((issue) => issue.severity === "info").length,
24390
+ fixOwners: {
24391
+ vibe: normalized.filter((issue) => issue.fixOwner === "vibe").length,
24392
+ hostAgent: normalized.filter((issue) => issue.fixOwner === "host-agent").length
24393
+ }
24394
+ };
24395
+ }
24396
+ function buildReviewReport(opts) {
24397
+ const retryWith = uniqueRetryWith(opts.retryWith);
24398
+ const issues = normalizeReviewIssues(opts.issues, {
24399
+ projectDir: opts.project,
24400
+ retryWith
24401
+ });
24402
+ return stripUndefined({
24403
+ schemaVersion: "1",
24404
+ kind: "review",
24405
+ project: resolve5(opts.project),
24406
+ mode: opts.mode,
24407
+ beat: opts.beat,
24408
+ status: opts.status,
24409
+ score: opts.score,
24410
+ issues,
24411
+ summary: summarizeReviewIssues(issues),
24412
+ nextActions: deriveNextReviewActions({ issues, retryWith, projectDir: opts.project }),
24413
+ retryWith,
24414
+ sourceReports: opts.sourceReports ?? [],
24415
+ reportPath: opts.reportPath
24416
+ });
24417
+ }
24418
+ function defaultReviewReportPath(projectDir) {
24419
+ return join2(resolve5(projectDir), "review-report.json");
24420
+ }
24421
+ async function writeReviewReport(path2, report) {
24422
+ await writeFile6(path2, JSON.stringify(report, null, 2) + "\n", "utf-8");
24423
+ }
24424
+ function stripUndefined(value) {
24425
+ return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== void 0));
24426
+ }
24427
+ function defaultActionsForIssue(issue, projectDir) {
24428
+ const code = issue.code;
24429
+ const beat = issue.beatId ?? issue.scene;
24430
+ const beatFlag = beat ? ` --beat ${beat}` : "";
24431
+ const sourceIssueCodes = [code];
24432
+ if (code === "PROJECT_NOT_FOUND" || code === "MISSING_STORYBOARD") {
24433
+ return [
24434
+ commandReviewAction(`vibe init ${projectDir} --from "<brief>" --json`, {
24435
+ label: "Initialize the project from a brief",
24436
+ fixOwner: "host-agent",
24437
+ reason: "The project cannot be inspected until the core project files exist.",
24438
+ sourceIssueCodes
24439
+ })
24440
+ ];
24441
+ }
24442
+ if (code === "MISSING_DESIGN" || code === "DESIGN_PLACEHOLDER_FIELD") {
24443
+ return [
24444
+ manualReviewAction({
24445
+ label: "Create or complete DESIGN.md",
24446
+ reason: "Visual direction needs host-agent or human input before VibeFrame can repair it.",
24447
+ sourceIssueCodes
24448
+ })
24449
+ ];
24450
+ }
24451
+ if (code === "BEAT_NOT_FOUND" || code.startsWith("STORYBOARD_")) {
24452
+ const actions = [
24453
+ commandReviewAction(`vibe storyboard validate ${projectDir} --json`, {
24454
+ label: "Validate the storyboard",
24455
+ fixOwner: "host-agent",
24456
+ reason: "The storyboard issue needs structured validation before editing.",
24457
+ sourceIssueCodes
24458
+ })
24459
+ ];
24460
+ if (code === "STORYBOARD_PLACEHOLDER_CUE") {
24461
+ actions.push(
24462
+ commandReviewAction(
24463
+ `vibe storyboard revise ${projectDir} --from "<make cues concrete>" --dry-run --json`,
24464
+ {
24465
+ label: "Preview a storyboard revision",
24466
+ fixOwner: "host-agent",
24467
+ reason: "Placeholder cues should be revised into concrete video intent.",
24468
+ sourceIssueCodes
24469
+ }
24470
+ )
24471
+ );
24472
+ }
24473
+ return actions;
24474
+ }
24475
+ if (code === "MISSING_ROOT_COMPOSITION" || code === "ROOT_SYNC_CHECK_FAILED") {
24476
+ return [
24477
+ commandReviewAction(`vibe build ${projectDir} --stage sync --json`, {
24478
+ label: "Sync the project root composition",
24479
+ reason: "The root composition is missing or could not be verified.",
24480
+ sourceIssueCodes
24481
+ })
24482
+ ];
24483
+ }
24484
+ if (code.startsWith("ROOT_")) {
24485
+ return issue.fixOwner === "host-agent" ? [
24486
+ agentReviewAction({
24487
+ label: "Repair the root composition shell",
24488
+ agentPrompt: "Fix host-agent-owned root composition issues from review-report.json",
24489
+ reason: "The root composition cannot be repaired deterministically.",
24490
+ sourceIssueCodes
24491
+ })
24492
+ ] : [
24493
+ commandReviewAction(`vibe scene repair ${projectDir} --json`, {
24494
+ label: "Repair deterministic scene/root issues",
24495
+ reason: "The root composition has deterministic sync issues.",
24496
+ sourceIssueCodes
24497
+ }),
24498
+ commandReviewAction(`vibe build ${projectDir} --stage sync --json`, {
24499
+ label: "Resync root composition after repair",
24500
+ reason: "The root composition should be synced after deterministic repair.",
24501
+ sourceIssueCodes
24502
+ })
24503
+ ];
24504
+ }
24505
+ if (code === "MISSING_COMPOSITION") {
24506
+ return [
24507
+ commandReviewAction(`vibe build ${projectDir}${beatFlag} --stage compose --json`, {
24508
+ label: beat ? `Compose beat ${beat}` : "Compose missing scenes",
24509
+ reason: "Missing scene compositions must be generated or authored before render.",
24510
+ sourceIssueCodes
24511
+ })
24512
+ ];
24513
+ }
24514
+ if (code === "MISSING_BUILD_REPORT") {
24515
+ return [
24516
+ commandReviewAction(`vibe build ${projectDir} --dry-run --json`, {
24517
+ label: "Preview the build plan",
24518
+ reason: "A dry-run build report is needed before choosing a paid or mutating build step.",
24519
+ sourceIssueCodes
24520
+ })
24521
+ ];
24522
+ }
24523
+ if (code === "BUILD_REPORT_BEAT_MISSING") {
24524
+ return [
24525
+ commandReviewAction(`vibe build ${projectDir}${beatFlag} --stage sync --json`, {
24526
+ label: beat ? `Sync build report for beat ${beat}` : "Sync the build report",
24527
+ reason: "The build report does not include the inspected beat.",
24528
+ sourceIssueCodes
24529
+ })
24530
+ ];
24531
+ }
24532
+ if (code === "MALFORMED_BUILD_REPORT") {
24533
+ return [
24534
+ commandReviewAction(`vibe build ${projectDir} --json`, {
24535
+ label: "Regenerate the build report",
24536
+ reason: "The existing build report cannot be parsed.",
24537
+ sourceIssueCodes
24538
+ })
24539
+ ];
24540
+ }
24541
+ if (code === "MISSING_REPORTED_ASSET" || code === "STALE_ASSET" || code === "UNKNOWN_ASSET_FRESHNESS") {
24542
+ return [
24543
+ commandReviewAction(`vibe build ${projectDir}${beatFlag} --stage assets --force --json`, {
24544
+ label: beat ? `Regenerate assets for beat ${beat}` : "Regenerate stale or missing assets",
24545
+ reason: "Asset generation may call providers, so the host should confirm first.",
24546
+ sourceIssueCodes
24547
+ })
24548
+ ];
24549
+ }
24550
+ if (code === "MUSIC_CUE_NOT_READY") {
24551
+ return [
24552
+ commandReviewAction(`vibe build ${projectDir}${beatFlag} --stage assets --json`, {
24553
+ label: beat ? `Generate music for beat ${beat}` : "Generate missing music assets",
24554
+ reason: "Music generation may call providers, so the host should confirm first.",
24555
+ sourceIssueCodes
24556
+ }),
24557
+ commandReviewAction(`vibe build ${projectDir} --stage sync --json`, {
24558
+ label: "Sync music into the root composition",
24559
+ reason: "Generated music needs root composition wiring.",
24560
+ sourceIssueCodes
24561
+ })
24562
+ ];
24563
+ }
24564
+ if (code.startsWith("SCENE_LINT_")) {
24565
+ return [
24566
+ commandReviewAction(`vibe scene repair ${projectDir} --json`, {
24567
+ label: "Repair deterministic scene lint issues",
24568
+ reason: "Scene lint reported an issue VibeFrame may be able to repair mechanically.",
24569
+ sourceIssueCodes
24570
+ })
24571
+ ];
24572
+ }
24573
+ if (code === "RENDER_NOT_FOUND" || code === "EMPTY_RENDER") {
24574
+ return [
24575
+ commandReviewAction(
24576
+ beat ? `vibe render ${projectDir} --beat ${beat} --json` : `vibe render ${projectDir} --json`,
24577
+ {
24578
+ label: beat ? `Render beat ${beat}` : "Render the project",
24579
+ reason: "A render file is required before render inspection can pass.",
24580
+ sourceIssueCodes
24581
+ }
24582
+ )
24583
+ ];
24584
+ }
24585
+ if (code === "NO_AUDIO_STREAM" || code === "DURATION_DRIFT") {
24586
+ return [
24587
+ commandReviewAction(`vibe build ${projectDir}${beatFlag} --stage sync --json`, {
24588
+ label: beat ? `Sync timing/audio for beat ${beat}` : "Sync timing and audio wiring",
24589
+ reason: "The render output no longer matches synced project state.",
24590
+ sourceIssueCodes
24591
+ }),
24592
+ commandReviewAction(
24593
+ beat ? `vibe render ${projectDir} --beat ${beat} --json` : `vibe render ${projectDir} --json`,
24594
+ {
24595
+ label: beat ? `Rerender beat ${beat}` : "Rerender the project",
24596
+ reason: "The render should be regenerated after sync repair.",
24597
+ sourceIssueCodes
24598
+ }
24599
+ )
24600
+ ];
24601
+ }
24602
+ if (code === "BLACK_FRAME_SEGMENT") {
24603
+ return issue.fixOwner === "host-agent" ? [
24604
+ agentReviewAction({
24605
+ label: beat ? `Fix black frames in beat ${beat}` : "Fix black frame segment",
24606
+ agentPrompt: "Fix black-frame issues from review-report.json, then rerender.",
24607
+ reason: "The issue likely needs scene, storyboard, or visual cue changes.",
24608
+ sourceIssueCodes
24609
+ })
24610
+ ] : [
24611
+ commandReviewAction(`vibe scene repair ${projectDir} --json`, {
24612
+ label: "Repair deterministic scene timing",
24613
+ reason: "Black frames outside a specific beat may be recoverable with scene repair.",
24614
+ sourceIssueCodes
24615
+ })
24616
+ ];
24617
+ }
24618
+ if (code === "STATIC_FRAME_SEGMENT") {
24619
+ return [
24620
+ agentReviewAction({
24621
+ label: beat ? `Add motion to beat ${beat}` : "Fix static visual hold",
24622
+ agentPrompt: "Fix static-frame issues from review-report.json, then rerender.",
24623
+ reason: "Static segments usually need creative scene, storyboard, or asset changes.",
24624
+ sourceIssueCodes
24625
+ })
24626
+ ];
24627
+ }
24628
+ if (code === "LONG_SILENCE") {
24629
+ return issue.fixOwner === "host-agent" ? [
24630
+ agentReviewAction({
24631
+ label: beat ? `Fix long silence in beat ${beat}` : "Fix long silence",
24632
+ agentPrompt: "Fix long-silence issues from review-report.json, then rerender.",
24633
+ reason: "The beat duration, narration, or music cue needs host-agent judgment.",
24634
+ sourceIssueCodes
24635
+ })
24636
+ ] : [
24637
+ commandReviewAction(`vibe build ${projectDir}${beatFlag} --stage sync --json`, {
24638
+ label: beat ? `Sync audio for beat ${beat}` : "Sync audio wiring",
24639
+ reason: "The silence may be caused by stale narration or music wiring.",
24640
+ sourceIssueCodes
24641
+ })
24642
+ ];
24643
+ }
24644
+ if (code.startsWith("AI_REVIEW_")) {
24645
+ return [
24646
+ agentReviewAction({
24647
+ label: "Fix AI review findings",
24648
+ agentPrompt: "Fix host-agent-owned AI review issues from review-report.json, then rerender.",
24649
+ reason: "AI review findings require storyboard, design, or composition judgment.",
24650
+ sourceIssueCodes
24651
+ })
24652
+ ];
24653
+ }
24654
+ if (code === "FFPROBE_UNAVAILABLE" || code === "FFMPEG_UNAVAILABLE") {
24655
+ return [
24656
+ manualReviewAction({
24657
+ label: "Install FFmpeg and rerun inspection",
24658
+ reason: "Local media inspection depends on ffmpeg/ffprobe being available.",
24659
+ fixOwner: "host-agent",
24660
+ sourceIssueCodes
24661
+ })
24662
+ ];
24663
+ }
24664
+ if (code === "FFPROBE_FAILED" || code === "NO_VIDEO_STREAM") {
24665
+ return [
24666
+ commandReviewAction(
24667
+ beat ? `vibe render ${projectDir} --beat ${beat} --json` : `vibe render ${projectDir} --json`,
24668
+ {
24669
+ label: beat ? `Rerender beat ${beat}` : "Rerender the project",
24670
+ reason: "The rendered media could not be probed as a valid video.",
24671
+ sourceIssueCodes
24672
+ }
24673
+ )
24674
+ ];
24675
+ }
24676
+ if (code === "ASPECT_MISMATCH") {
24677
+ return [
24678
+ manualReviewAction({
24679
+ label: "Check project aspect and render settings",
24680
+ reason: "Aspect mismatches can come from either config intent or render settings.",
24681
+ fixOwner: issue.fixOwner,
24682
+ sourceIssueCodes
24683
+ })
24684
+ ];
24685
+ }
24686
+ return [];
24687
+ }
24688
+ function classifyReviewCommand(command) {
24689
+ const lower = command.trim().toLowerCase();
24690
+ if (lower.startsWith("vibe status ")) return safe("free");
24691
+ if (lower.startsWith("vibe scene repair")) return safe("free");
24692
+ if (lower.startsWith("vibe storyboard validate") || lower.startsWith("vibe storyboard get") || lower.startsWith("vibe storyboard list")) {
24693
+ return safe("free", "host-agent");
24694
+ }
24695
+ if (lower.startsWith("vibe inspect project")) return safe("free");
24696
+ if (lower.startsWith("vibe inspect render")) {
24697
+ if (lower.includes("--ai")) return unsafe("low");
24698
+ return safe("free");
24699
+ }
24700
+ if (lower.startsWith("vibe render ")) return safe("free");
24701
+ if (lower.startsWith("vibe build ")) {
24702
+ if (lower.includes("--dry-run")) return safe("free");
24703
+ if (lower.includes("--stage sync") || lower.includes("--stage render")) return safe("free");
24704
+ return unsafe("unknown");
24705
+ }
24706
+ if (lower.startsWith("vibe generate video") || lower.startsWith("vibe edit fill-gaps") || lower.startsWith("vibe remix ")) {
24707
+ return unsafe("very-high");
24708
+ }
24709
+ if (lower.startsWith("vibe storyboard revise")) return unsafe("unknown", "host-agent");
24710
+ return unsafe("unknown");
24711
+ }
24712
+ function safe(costTier, fixOwner = "vibe") {
24713
+ return { fixOwner, costTier, safeToAutoRun: true, requiresConfirmation: false };
24714
+ }
24715
+ function unsafe(costTier, fixOwner = "vibe") {
24716
+ return { fixOwner, costTier, safeToAutoRun: false, requiresConfirmation: true };
24717
+ }
24718
+ function actionId(kind, value) {
24719
+ const slug = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 80);
24720
+ return `${kind}:${slug || "action"}`;
24721
+ }
24722
+ function uniqueStrings(items) {
24723
+ return [
24724
+ ...new Set(items.filter((item) => typeof item === "string" && item.length > 0))
24725
+ ];
24726
+ }
24727
+ var init_review_report = __esm({
24728
+ "src/commands/_shared/review-report.ts"() {
24729
+ "use strict";
24730
+ }
24731
+ });
24732
+
24221
24733
  // src/commands/_shared/storyboard-parse.ts
24734
+ function resolveCharacters(frontmatter) {
24735
+ const pool = frontmatter?.characters;
24736
+ if (!pool || typeof pool !== "object") return [];
24737
+ const resolved = [];
24738
+ for (const [name, spec] of Object.entries(pool)) {
24739
+ if (typeof spec === "string") {
24740
+ const prompt3 = spec.trim();
24741
+ if (prompt3) resolved.push({ name, prompt: prompt3 });
24742
+ continue;
24743
+ }
24744
+ if (spec && typeof spec === "object") {
24745
+ const image = typeof spec.image === "string" ? spec.image.trim() : "";
24746
+ if (image) {
24747
+ resolved.push({ name, imagePath: image });
24748
+ continue;
24749
+ }
24750
+ const description = typeof spec.description === "string" ? spec.description.trim() : "";
24751
+ if (description) resolved.push({ name, prompt: description });
24752
+ }
24753
+ }
24754
+ return resolved;
24755
+ }
24756
+ function beatCharacterNames(cues) {
24757
+ const raw = cues?.characters;
24758
+ if (typeof raw === "string") {
24759
+ const name = raw.trim();
24760
+ return name ? [name] : [];
24761
+ }
24762
+ if (Array.isArray(raw)) {
24763
+ return raw.filter((v) => typeof v === "string" && v.trim().length > 0).map((v) => v.trim());
24764
+ }
24765
+ return [];
24766
+ }
24222
24767
  function parseStoryboard(md) {
24223
24768
  const normalized = md.replace(/\r\n/g, "\n");
24224
24769
  const { frontmatter, remaining } = extractProjectFrontmatter(normalized);
@@ -24331,28 +24876,28 @@ var init_storyboard_parse = __esm({
24331
24876
  // src/commands/_shared/status-jobs.ts
24332
24877
  import { createHash as createHash2, randomUUID as randomUUID2 } from "node:crypto";
24333
24878
  import { existsSync as existsSync2, statSync } from "node:fs";
24334
- import { copyFile, mkdir as mkdir3, readFile as readFile4, readdir, stat, writeFile as writeFile6 } from "node:fs/promises";
24335
- import { dirname as dirname3, join as join2, parse as parse2, relative, resolve as resolve5 } from "node:path";
24879
+ import { copyFile, mkdir as mkdir3, readFile as readFile4, readdir, stat, writeFile as writeFile7 } from "node:fs/promises";
24880
+ import { dirname as dirname3, join as join3, parse as parse2, relative, resolve as resolve6 } from "node:path";
24336
24881
  function isLocalJob(record) {
24337
24882
  return record.jobType === "build" || record.jobType === "render";
24338
24883
  }
24339
24884
  function findProjectRoot(start = process.cwd()) {
24340
- let dir = resolve5(start);
24885
+ let dir = resolve6(start);
24341
24886
  try {
24342
24887
  if (existsSync2(dir) && statSync(dir).isFile()) dir = dirname3(dir);
24343
24888
  } catch {
24344
24889
  }
24345
24890
  for (; ; ) {
24346
- if (existsSync2(join2(dir, "vibe.config.json")) || existsSync2(join2(dir, "STORYBOARD.md"))) {
24891
+ if (existsSync2(join3(dir, "vibe.config.json")) || existsSync2(join3(dir, "STORYBOARD.md"))) {
24347
24892
  return dir;
24348
24893
  }
24349
24894
  const parent = dirname3(dir);
24350
- if (parent === dir) return resolve5(start);
24895
+ if (parent === dir) return resolve6(start);
24351
24896
  dir = parent;
24352
24897
  }
24353
24898
  }
24354
24899
  function jobsDir(projectDir) {
24355
- return join2(resolve5(projectDir), ".vibeframe", "jobs");
24900
+ return join3(resolve6(projectDir), ".vibeframe", "jobs");
24356
24901
  }
24357
24902
  function normalizeJobStatus(status) {
24358
24903
  const value = String(status ?? "").toLowerCase();
@@ -24366,10 +24911,10 @@ function normalizeJobStatus(status) {
24366
24911
  }
24367
24912
  function createJobRecord(opts) {
24368
24913
  const now = opts.now ?? /* @__PURE__ */ new Date();
24369
- const projectDir = resolve5(
24914
+ const projectDir = resolve6(
24370
24915
  opts.projectDir ?? findProjectRoot(opts.workingDirectory ?? process.cwd())
24371
24916
  );
24372
- const workingDirectory = resolve5(opts.workingDirectory ?? process.cwd());
24917
+ const workingDirectory = resolve6(opts.workingDirectory ?? process.cwd());
24373
24918
  const id = opts.id ?? makeJobId(opts.providerTaskId, now);
24374
24919
  const base = {
24375
24920
  schemaVersion: "1",
@@ -24404,7 +24949,7 @@ function createJobRecord(opts) {
24404
24949
  message: opts.message
24405
24950
  };
24406
24951
  base.retryWith = retryWithForJob(base);
24407
- return stripUndefined(base);
24952
+ return stripUndefined2(base);
24408
24953
  }
24409
24954
  async function createAndWriteJobRecord(opts) {
24410
24955
  const record = createJobRecord(opts);
@@ -24414,15 +24959,15 @@ async function createAndWriteJobRecord(opts) {
24414
24959
  async function writeJobRecord(record) {
24415
24960
  const dir = jobsDir(record.projectDir);
24416
24961
  await mkdir3(dir, { recursive: true });
24417
- await writeFile6(
24962
+ await writeFile7(
24418
24963
  jobRecordPath(record.id, record.projectDir),
24419
- JSON.stringify(stripUndefined(record), null, 2) + "\n",
24964
+ JSON.stringify(stripUndefined2(record), null, 2) + "\n",
24420
24965
  "utf-8"
24421
24966
  );
24422
24967
  }
24423
24968
  async function readJobRecord(jobId, projectDir) {
24424
24969
  const id = normalizeJobId(jobId);
24425
- const project = resolve5(projectDir ?? findProjectRoot());
24970
+ const project = resolve6(projectDir ?? findProjectRoot());
24426
24971
  const path2 = jobRecordPath(id, project);
24427
24972
  if (!existsSync2(path2)) return null;
24428
24973
  return parseJobRecord(await readFile4(path2, "utf-8"));
@@ -24435,7 +24980,7 @@ async function listJobRecords(projectDir) {
24435
24980
  for (const entry of entries) {
24436
24981
  if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
24437
24982
  try {
24438
- const parsed = parseJobRecord(await readFile4(join2(dir, entry.name), "utf-8"));
24983
+ const parsed = parseJobRecord(await readFile4(join3(dir, entry.name), "utf-8"));
24439
24984
  if (parsed) records.push(parsed);
24440
24985
  } catch {
24441
24986
  }
@@ -24529,9 +25074,9 @@ async function refreshLocalJobRecord(record, opts) {
24529
25074
  const heartbeatAge = record.heartbeatAt ? Date.now() - new Date(record.heartbeatAt).getTime() : Number.POSITIVE_INFINITY;
24530
25075
  const orphaned = !isPidAlive(record.pid) || heartbeatAge > LOCAL_JOB_ORPHAN_AFTER_MS;
24531
25076
  if (orphaned) {
24532
- const hint = `The process that ran this ${record.jobType} job is gone. Check ${join2(record.projectDir, record.jobType === "render" ? "render-report.json" : "build-report.json")} \u2014 the work may have completed before the process exited.`;
25077
+ const hint = `The process that ran this ${record.jobType} job is gone. Check ${join3(record.projectDir, record.jobType === "render" ? "render-report.json" : "build-report.json")} \u2014 the work may have completed before the process exited.`;
24533
25078
  warnings.push(hint);
24534
- updated = stripUndefined({
25079
+ updated = stripUndefined2({
24535
25080
  ...record,
24536
25081
  status: "unknown",
24537
25082
  error: record.error ?? hint,
@@ -24548,7 +25093,7 @@ async function refreshLocalJobRecord(record, opts) {
24548
25093
  });
24549
25094
  }
24550
25095
  async function inspectProjectStatus(projectDirArg, opts = {}) {
24551
- const project = resolve5(projectDirArg);
25096
+ const project = resolve6(projectDirArg);
24552
25097
  const warnings = [];
24553
25098
  let records = await listJobRecords(project);
24554
25099
  if (opts.refresh) {
@@ -24578,6 +25123,10 @@ async function inspectProjectStatus(projectDirArg, opts = {}) {
24578
25123
  ...review?.retryWith ?? [],
24579
25124
  ...workflow.retryWith
24580
25125
  ];
25126
+ const nextActions = normalizeReviewActions([
25127
+ ...review?.nextActions ?? [],
25128
+ ...reviewActionsFromRetryWith([...build?.retryWith ?? [], ...workflow.retryWith])
25129
+ ]);
24581
25130
  return {
24582
25131
  schemaVersion: "1",
24583
25132
  kind: "project",
@@ -24589,11 +25138,12 @@ async function inspectProjectStatus(projectDirArg, opts = {}) {
24589
25138
  review,
24590
25139
  jobs,
24591
25140
  warnings,
25141
+ nextActions,
24592
25142
  retryWith: unique(retryWith)
24593
25143
  };
24594
25144
  }
24595
25145
  function summarizeJob(record) {
24596
- return stripUndefined({
25146
+ return stripUndefined2({
24597
25147
  id: record.id,
24598
25148
  jobType: record.jobType,
24599
25149
  status: record.status,
@@ -24617,7 +25167,7 @@ function retryWithForJob(record) {
24617
25167
  ]);
24618
25168
  }
24619
25169
  function makeJobStatusResult(record, opts) {
24620
- return stripUndefined({
25170
+ return stripUndefined2({
24621
25171
  schemaVersion: "1",
24622
25172
  kind: "job",
24623
25173
  id: record.id,
@@ -24641,7 +25191,7 @@ function jobProgress(record) {
24641
25191
  if (record.status === "completed" || record.status === "failed" || record.status === "cancelled") {
24642
25192
  if (record.progress === void 0 && !record.providerStatus) return void 0;
24643
25193
  }
24644
- return stripUndefined({
25194
+ return stripUndefined2({
24645
25195
  percent: record.progress,
24646
25196
  phase: record.stage ?? record.providerStatus ?? statusPhase(record.status),
24647
25197
  providerStatus: record.providerStatus ?? record.message
@@ -24651,7 +25201,7 @@ function jobResult(record) {
24651
25201
  if (!record.resultUrl && !record.outputPath && !record.cachePath && !record.error && !record.resultPayload) {
24652
25202
  return null;
24653
25203
  }
24654
- return stripUndefined({
25204
+ return stripUndefined2({
24655
25205
  url: record.resultUrl,
24656
25206
  outputPath: record.outputPath,
24657
25207
  cachePath: record.cachePath,
@@ -24687,7 +25237,7 @@ function normalizeJobId(jobId) {
24687
25237
  return id;
24688
25238
  }
24689
25239
  function jobRecordPath(jobId, projectDir) {
24690
- return join2(jobsDir(projectDir), `${normalizeJobId(jobId)}.json`);
25240
+ return join3(jobsDir(projectDir), `${normalizeJobId(jobId)}.json`);
24691
25241
  }
24692
25242
  function parseJobRecord(raw) {
24693
25243
  const parsed = JSON.parse(raw);
@@ -24712,11 +25262,11 @@ function updateRecordFromProvider(record, result) {
24712
25262
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
24713
25263
  };
24714
25264
  next.retryWith = retryWithForJob(next);
24715
- return stripUndefined(next);
25265
+ return stripUndefined2(next);
24716
25266
  }
24717
25267
  function resolveRefreshOutput(record, opts) {
24718
- if (opts.output) return resolve5(opts.workingDirectory ?? process.cwd(), opts.output);
24719
- return record.outputPath ? resolve5(record.outputPath) : void 0;
25268
+ if (opts.output) return resolve6(opts.workingDirectory ?? process.cwd(), opts.output);
25269
+ return record.outputPath ? resolve6(record.outputPath) : void 0;
24720
25270
  }
24721
25271
  function liveSupport(record) {
24722
25272
  if (record.jobType === "generate-video" && (record.provider === "grok" || record.provider === "runway" || record.provider === "kling" || record.provider === "veo")) {
@@ -24741,7 +25291,7 @@ async function downloadUrl(url, outputPath) {
24741
25291
  if (!response.ok) throw new Error(`Download failed: HTTP ${response.status}`);
24742
25292
  const buffer = Buffer.from(await response.arrayBuffer());
24743
25293
  await mkdir3(dirname3(outputPath), { recursive: true });
24744
- await writeFile6(outputPath, buffer);
25294
+ await writeFile7(outputPath, buffer);
24745
25295
  return outputPath;
24746
25296
  }
24747
25297
  async function maybeCacheOutput(record) {
@@ -24773,7 +25323,7 @@ async function maybeWriteCompletedAssetMetadata(record) {
24773
25323
  }
24774
25324
  }
24775
25325
  async function refreshBuildReportFromJobs(projectDir, records) {
24776
- const reportPath = join2(projectDir, "build-report.json");
25326
+ const reportPath = join3(projectDir, "build-report.json");
24777
25327
  const report = await readJson(reportPath);
24778
25328
  if (!report || !Array.isArray(report.beats)) return;
24779
25329
  let changed = false;
@@ -24801,7 +25351,7 @@ async function refreshBuildReportFromJobs(projectDir, records) {
24801
25351
  if (kind === "music" && record.assetOptions && typeof record.assetOptions.duration === "number") {
24802
25352
  nested.durationSec = record.assetOptions.duration;
24803
25353
  }
24804
- beatRecord[kind] = stripUndefined(nested);
25354
+ beatRecord[kind] = stripUndefined2(nested);
24805
25355
  beatRecord[`${kind}Path`] = outputPath;
24806
25356
  beatRecord[`${kind}Status`] = "generated";
24807
25357
  beatRecord[`${kind}JobId`] = record.id;
@@ -24821,17 +25371,17 @@ async function refreshBuildReportFromJobs(projectDir, records) {
24821
25371
  }
24822
25372
  if (!changed) return;
24823
25373
  try {
24824
- await writeFile6(reportPath, JSON.stringify(stripUndefined(report), null, 2) + "\n", "utf-8");
25374
+ await writeFile7(reportPath, JSON.stringify(stripUndefined2(report), null, 2) + "\n", "utf-8");
24825
25375
  } catch {
24826
25376
  }
24827
25377
  }
24828
25378
  async function readBuildSummary(projectDir) {
24829
- const reportPath = join2(projectDir, "build-report.json");
25379
+ const reportPath = join3(projectDir, "build-report.json");
24830
25380
  const report = await readJson(reportPath);
24831
25381
  if (!report) return null;
24832
25382
  const info = await statOrNull(reportPath);
24833
25383
  const beatSummary = projectBeatReadinessFromUnknown(report.beatSummary);
24834
- return stripUndefined({
25384
+ return stripUndefined2({
24835
25385
  reportPath,
24836
25386
  kind: typeof report.kind === "string" ? report.kind : void 0,
24837
25387
  success: typeof report.success === "boolean" ? report.success : void 0,
@@ -24849,7 +25399,7 @@ async function readBuildSummary(projectDir) {
24849
25399
  });
24850
25400
  }
24851
25401
  async function readReviewSummary(projectDir) {
24852
- const reportPath = join2(projectDir, "review-report.json");
25402
+ const reportPath = join3(projectDir, "review-report.json");
24853
25403
  const report = await readJson(reportPath);
24854
25404
  if (!report) return null;
24855
25405
  const info = await statOrNull(reportPath);
@@ -24857,7 +25407,7 @@ async function readReviewSummary(projectDir) {
24857
25407
  const summary = report.summary && typeof report.summary === "object" ? report.summary : {};
24858
25408
  const fixOwners = summary.fixOwners && typeof summary.fixOwners === "object" ? summary.fixOwners : {};
24859
25409
  const computedFixOwners = countIssueFixOwners(issues);
24860
- return stripUndefined({
25410
+ return stripUndefined2({
24861
25411
  reportPath,
24862
25412
  kind: typeof report.kind === "string" ? report.kind : void 0,
24863
25413
  mode: typeof report.mode === "string" ? report.mode : void 0,
@@ -24882,9 +25432,44 @@ async function readReviewSummary(projectDir) {
24882
25432
  },
24883
25433
  sourceReports: stringArray(report.sourceReports),
24884
25434
  updatedAt: info?.mtime.toISOString(),
25435
+ nextActions: reviewActionsFromUnknown(report.nextActions),
24885
25436
  retryWith: stringArray(report.retryWith)
24886
25437
  });
24887
25438
  }
25439
+ function reviewActionsFromUnknown(value) {
25440
+ if (!Array.isArray(value)) return [];
25441
+ return value.flatMap((item) => {
25442
+ if (!item || typeof item !== "object") return [];
25443
+ const record = item;
25444
+ const kind = stringEnum(record.kind, ["command", "agent", "manual"]);
25445
+ const fixOwner = stringEnum(record.fixOwner, ["vibe", "host-agent"]);
25446
+ const costTier = stringEnum(record.costTier, [
25447
+ "free",
25448
+ "low",
25449
+ "high",
25450
+ "very-high",
25451
+ "unknown"
25452
+ ]);
25453
+ if (!kind || !fixOwner || !costTier || typeof record.id !== "string" || typeof record.label !== "string" || typeof record.safeToAutoRun !== "boolean" || typeof record.requiresConfirmation !== "boolean" || typeof record.reason !== "string") {
25454
+ return [];
25455
+ }
25456
+ return [
25457
+ stripUndefined2({
25458
+ id: record.id,
25459
+ kind,
25460
+ label: record.label,
25461
+ command: typeof record.command === "string" ? record.command : void 0,
25462
+ agentPrompt: typeof record.agentPrompt === "string" ? record.agentPrompt : void 0,
25463
+ fixOwner,
25464
+ costTier,
25465
+ safeToAutoRun: record.safeToAutoRun,
25466
+ requiresConfirmation: record.requiresConfirmation,
25467
+ reason: record.reason,
25468
+ sourceIssueCodes: stringArray(record.sourceIssueCodes)
25469
+ })
25470
+ ];
25471
+ });
25472
+ }
24888
25473
  function summarizeJobs(records) {
24889
25474
  return {
24890
25475
  total: records.length,
@@ -24903,7 +25488,7 @@ async function readProjectBeatReadiness(projectDir, build, records) {
24903
25488
  if (fromBeats) return fromBeats;
24904
25489
  if (build.beats) return augmentBeatReadinessFromJobs(build.beats, records, report?.jobs);
24905
25490
  }
24906
- const storyboardPath = join2(projectDir, "STORYBOARD.md");
25491
+ const storyboardPath = join3(projectDir, "STORYBOARD.md");
24907
25492
  if (!existsSync2(storyboardPath)) {
24908
25493
  return { total: 0, assetsReady: 0, compositionsReady: 0, needsAuthor: [] };
24909
25494
  }
@@ -24912,7 +25497,7 @@ async function readProjectBeatReadiness(projectDir, build, records) {
24912
25497
  const needsAuthor = [];
24913
25498
  let compositionsReady = 0;
24914
25499
  for (const beat of parsed.beats) {
24915
- if (existsSync2(join2(projectDir, "compositions", `scene-${beat.id}.html`))) {
25500
+ if (existsSync2(join3(projectDir, "compositions", `scene-${beat.id}.html`))) {
24916
25501
  compositionsReady += 1;
24917
25502
  } else {
24918
25503
  needsAuthor.push(beat.id);
@@ -24930,7 +25515,7 @@ async function readProjectBeatReadiness(projectDir, build, records) {
24930
25515
  }
24931
25516
  function deriveProjectWorkflow(opts) {
24932
25517
  const retryWith = [];
24933
- const hasStoryboard = existsSync2(join2(opts.project, "STORYBOARD.md"));
25518
+ const hasStoryboard = existsSync2(join3(opts.project, "STORYBOARD.md"));
24934
25519
  if (!hasStoryboard && !opts.build) {
24935
25520
  retryWith.push(`vibe init ${opts.project} --from "<brief>" --json`);
24936
25521
  return { status: "empty", currentStage: "init", retryWith };
@@ -25026,7 +25611,7 @@ function beatReadinessFromBuildBeats(projectDir, beats, records = [], reportJobs
25026
25611
  if (buildBeatAssetsReady(record) || (id ? buildBeatJobsReady(id, records, reportJobs) : false))
25027
25612
  assetsReady += 1;
25028
25613
  const compositionPath = typeof record.compositionPath === "string" ? record.compositionPath : id ? `compositions/scene-${id}.html` : void 0;
25029
- if (compositionPath && existsSync2(resolve5(projectDir, compositionPath))) {
25614
+ if (compositionPath && existsSync2(resolve6(projectDir, compositionPath))) {
25030
25615
  compositionsReady += 1;
25031
25616
  } else if (id) {
25032
25617
  needsAuthor.push(id);
@@ -25096,6 +25681,9 @@ function numberOr(fallback2, value) {
25096
25681
  function stringArray(value) {
25097
25682
  return Array.isArray(value) ? value.filter((item) => typeof item === "string" && item.length > 0) : [];
25098
25683
  }
25684
+ function stringEnum(value, allowed) {
25685
+ return typeof value === "string" && allowed.includes(value) ? value : void 0;
25686
+ }
25099
25687
  function isActiveStatus(status) {
25100
25688
  return status === "queued" || status === "running";
25101
25689
  }
@@ -25125,7 +25713,7 @@ function assetKindForJob(record) {
25125
25713
  return void 0;
25126
25714
  }
25127
25715
  function projectRelativePath(projectDir, path2) {
25128
- const rel = relative(resolve5(projectDir), resolve5(path2));
25716
+ const rel = relative(resolve6(projectDir), resolve6(path2));
25129
25717
  if (!rel || rel.startsWith("..")) return path2;
25130
25718
  return rel.replace(/\\/g, "/");
25131
25719
  }
@@ -25143,7 +25731,7 @@ async function statOrNull(path2) {
25143
25731
  return null;
25144
25732
  }
25145
25733
  }
25146
- function stripUndefined(value) {
25734
+ function stripUndefined2(value) {
25147
25735
  return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== void 0));
25148
25736
  }
25149
25737
  function unique(items) {
@@ -25161,6 +25749,7 @@ var init_status_jobs = __esm({
25161
25749
  init_ai_video();
25162
25750
  init_music_status();
25163
25751
  init_build_asset_metadata();
25752
+ init_review_report();
25164
25753
  init_storyboard_parse();
25165
25754
  LOCAL_JOB_ORPHAN_AFTER_MS = 9e4;
25166
25755
  }
@@ -25169,7 +25758,7 @@ var init_status_jobs = __esm({
25169
25758
  // src/commands/_shared/project-config.ts
25170
25759
  import { existsSync as existsSync3 } from "node:fs";
25171
25760
  import { readFile as readFile5 } from "node:fs/promises";
25172
- import { resolve as resolve6 } from "node:path";
25761
+ import { resolve as resolve7 } from "node:path";
25173
25762
  function defaultProjectConfig(opts) {
25174
25763
  return {
25175
25764
  schemaVersion: "1",
@@ -25220,9 +25809,9 @@ function projectConfigJson(opts) {
25220
25809
  return JSON.stringify(defaultProjectConfig(opts), null, 2) + "\n";
25221
25810
  }
25222
25811
  async function readProjectConfig(projectDir) {
25223
- const dir = resolve6(projectDir);
25224
- const canonicalPath = resolve6(dir, VIBE_CONFIG_FILENAME);
25225
- const legacyPath = resolve6(dir, LEGACY_VIBE_PROJECT_FILENAME);
25812
+ const dir = resolve7(projectDir);
25813
+ const canonicalPath = resolve7(dir, VIBE_CONFIG_FILENAME);
25814
+ const legacyPath = resolve7(dir, LEGACY_VIBE_PROJECT_FILENAME);
25226
25815
  if (existsSync3(canonicalPath)) {
25227
25816
  try {
25228
25817
  const raw = await readFile5(canonicalPath, "utf-8");
@@ -25285,8 +25874,8 @@ var init_project_config = __esm({
25285
25874
  });
25286
25875
 
25287
25876
  // src/commands/_shared/scene-project.ts
25288
- import { mkdir as mkdir4, readFile as readFile6, writeFile as writeFile7, access as access3 } from "node:fs/promises";
25289
- import { resolve as resolve7, basename as basename2 } from "node:path";
25877
+ import { mkdir as mkdir4, readFile as readFile6, writeFile as writeFile8, access as access3 } from "node:fs/promises";
25878
+ import { resolve as resolve8, basename as basename2 } from "node:path";
25290
25879
  function aspectToDims(aspect) {
25291
25880
  return ASPECT_DIMS[aspect];
25292
25881
  }
@@ -25584,6 +26173,43 @@ vibe host setup all --write # write project/app config
25584
26173
  vibe host doctor all --json # verify readiness
25585
26174
  \`\`\`
25586
26175
 
26176
+ ## Native host goal loop
26177
+
26178
+ For long-running work, use Codex Goal mode, Claude Code \`/goal\`, or the
26179
+ host's equivalent as the outer loop. VibeFrame should not run a competing
26180
+ project-level goal loop; it provides \`--json\` commands, dry runs, cost caps,
26181
+ \`build-report.json\`, \`review-report.json\`, \`nextActions\`, \`safeToAutoRun\`,
26182
+ \`requiresConfirmation\`, \`fixOwner\`, deterministic repair, and render
26183
+ inspection for the host agent to reason over.
26184
+
26185
+ Copy-paste examples:
26186
+
26187
+ \`\`\`text
26188
+ /goal Build this VibeFrame project into renders/final.mp4. Use --json for every
26189
+ vibe command, run --dry-run before paid operations, use --max-cost 5 for builds
26190
+ unless the user sets another budget, read build-report.json and
26191
+ review-report.json before deciding the next action, prefer nextActions before
26192
+ guessing, run only safeToAutoRun:true actions automatically, and ask before
26193
+ requiresConfirmation:true actions. Treat retryWith as the compatibility
26194
+ fallback, fixOwner:"vibe" as CLI repair work, and fixOwner:"host-agent" as
26195
+ STORYBOARD.md, DESIGN.md, or composition edits.
26196
+ Stop only when renders/final.mp4 exists, duration and aspect ratio match the
26197
+ brief, inspect render --cheap has no errors, any AI review score is >= 90 when AI review is requested, and every
26198
+ remaining host-agent issue is fixed, accepted with rationale, or reported as
26199
+ blocked.
26200
+ \`\`\`
26201
+
26202
+ \`\`\`text
26203
+ /goal Finish this VibeFrame render using Claude Code goal mode as the outer
26204
+ loop. Use vibe context/schema when unsure, --json everywhere, dry-run before
26205
+ paid operations, budget cap via --max-cost 5, nextActions before guessing,
26206
+ build-report.json/review-report.json as loop state, and safeToAutoRun,
26207
+ requiresConfirmation, and fixOwner to decide between vibe scene repair and
26208
+ host-agent edits. Stop only after final MP4,
26209
+ target duration/aspect ratio, clean render inspection, any AI review score >= 90 when AI review is requested, and
26210
+ no unresolved unacknowledged host-agent issues.
26211
+ \`\`\`
26212
+
25587
26213
  ## Skills \u2014 USE THESE FIRST
25588
26214
 
25589
26215
  @SKILL.md
@@ -25687,35 +26313,35 @@ tmp/
25687
26313
  `;
25688
26314
  }
25689
26315
  function describeSceneScaffold(opts) {
25690
- const dir = resolve7(opts.dir);
26316
+ const dir = resolve8(opts.dir);
25691
26317
  const profile = opts.profile ?? "full";
25692
26318
  const groups = {
25693
26319
  authoring: [
25694
- resolve7(dir, "STORYBOARD.md"),
25695
- resolve7(dir, "DESIGN.md"),
25696
- resolve7(dir, VIBE_CONFIG_FILENAME),
25697
- resolve7(dir, "vibe.project.yaml"),
25698
- resolve7(dir, ".gitignore")
26320
+ resolve8(dir, "STORYBOARD.md"),
26321
+ resolve8(dir, "DESIGN.md"),
26322
+ resolve8(dir, VIBE_CONFIG_FILENAME),
26323
+ resolve8(dir, "vibe.project.yaml"),
26324
+ resolve8(dir, ".gitignore")
25699
26325
  ],
25700
26326
  render: [],
25701
26327
  agent: []
25702
26328
  };
25703
26329
  if (profile === "full") {
25704
26330
  groups.render = [
25705
- resolve7(dir, "index.html"),
25706
- resolve7(dir, "compositions"),
25707
- resolve7(dir, "assets"),
25708
- resolve7(dir, "renders"),
25709
- resolve7(dir, "hyperframes.json"),
25710
- resolve7(dir, "meta.json")
26331
+ resolve8(dir, "index.html"),
26332
+ resolve8(dir, "compositions"),
26333
+ resolve8(dir, "assets"),
26334
+ resolve8(dir, "renders"),
26335
+ resolve8(dir, "hyperframes.json"),
26336
+ resolve8(dir, "meta.json")
25711
26337
  ];
25712
26338
  }
25713
26339
  if (profile === "agent" || profile === "full") {
25714
26340
  groups.agent = [
25715
- resolve7(dir, "AGENTS.md"),
25716
- resolve7(dir, "SKILL.md"),
25717
- resolve7(dir, "references"),
25718
- resolve7(dir, "CLAUDE.md")
26341
+ resolve8(dir, "AGENTS.md"),
26342
+ resolve8(dir, "SKILL.md"),
26343
+ resolve8(dir, "references"),
26344
+ resolve8(dir, "CLAUDE.md")
25719
26345
  ];
25720
26346
  }
25721
26347
  return groups;
@@ -25729,7 +26355,7 @@ async function pathExists(p) {
25729
26355
  }
25730
26356
  }
25731
26357
  async function scaffoldSceneProject(opts) {
25732
- const dir = resolve7(opts.dir);
26358
+ const dir = resolve8(opts.dir);
25733
26359
  const name = opts.name ?? basename2(dir);
25734
26360
  const aspect = opts.aspect ?? "16:9";
25735
26361
  const duration = opts.duration ?? 10;
@@ -25737,103 +26363,103 @@ async function scaffoldSceneProject(opts) {
25737
26363
  const profile = opts.profile ?? "full";
25738
26364
  await mkdir4(dir, { recursive: true });
25739
26365
  if (profile === "full") {
25740
- await mkdir4(resolve7(dir, "compositions"), { recursive: true });
25741
- await mkdir4(resolve7(dir, "assets"), { recursive: true });
25742
- await mkdir4(resolve7(dir, "renders"), { recursive: true });
26366
+ await mkdir4(resolve8(dir, "compositions"), { recursive: true });
26367
+ await mkdir4(resolve8(dir, "assets"), { recursive: true });
26368
+ await mkdir4(resolve8(dir, "renders"), { recursive: true });
25743
26369
  }
25744
26370
  const created = [];
25745
26371
  const skipped2 = [];
25746
26372
  const merged = [];
25747
26373
  if (profile === "full") {
25748
- const hfPath = resolve7(dir, "hyperframes.json");
26374
+ const hfPath = resolve8(dir, "hyperframes.json");
25749
26375
  const hfDefaults = buildHyperframesConfig();
25750
26376
  if (await pathExists(hfPath)) {
25751
26377
  const existingRaw = await readFile6(hfPath, "utf-8");
25752
26378
  const existing = JSON.parse(existingRaw);
25753
26379
  const mergedConfig = mergeHyperframesConfig(existing, hfDefaults);
25754
- await writeFile7(hfPath, JSON.stringify(mergedConfig, null, 2) + "\n", "utf-8");
26380
+ await writeFile8(hfPath, JSON.stringify(mergedConfig, null, 2) + "\n", "utf-8");
25755
26381
  merged.push(hfPath);
25756
26382
  } else {
25757
- await writeFile7(hfPath, JSON.stringify(hfDefaults, null, 2) + "\n", "utf-8");
26383
+ await writeFile8(hfPath, JSON.stringify(hfDefaults, null, 2) + "\n", "utf-8");
25758
26384
  created.push(hfPath);
25759
26385
  }
25760
- const metaPath = resolve7(dir, "meta.json");
26386
+ const metaPath = resolve8(dir, "meta.json");
25761
26387
  if (await pathExists(metaPath)) {
25762
26388
  skipped2.push(metaPath);
25763
26389
  } else {
25764
- await writeFile7(
26390
+ await writeFile8(
25765
26391
  metaPath,
25766
26392
  JSON.stringify(buildHyperframesMeta(name, now), null, 2) + "\n",
25767
26393
  "utf-8"
25768
26394
  );
25769
26395
  created.push(metaPath);
25770
26396
  }
25771
- const rootPath = resolve7(dir, "index.html");
26397
+ const rootPath = resolve8(dir, "index.html");
25772
26398
  if (await pathExists(rootPath)) {
25773
26399
  skipped2.push(rootPath);
25774
26400
  } else {
25775
- await writeFile7(rootPath, buildEmptyRootHtml({ aspect, duration }), "utf-8");
26401
+ await writeFile8(rootPath, buildEmptyRootHtml({ aspect, duration }), "utf-8");
25776
26402
  created.push(rootPath);
25777
26403
  }
25778
26404
  }
25779
- const vibeConfigJsonPath = resolve7(dir, VIBE_CONFIG_FILENAME);
26405
+ const vibeConfigJsonPath = resolve8(dir, VIBE_CONFIG_FILENAME);
25780
26406
  if (await pathExists(vibeConfigJsonPath)) {
25781
26407
  skipped2.push(vibeConfigJsonPath);
25782
26408
  } else {
25783
- await writeFile7(vibeConfigJsonPath, projectConfigJson({ name, aspect }), "utf-8");
26409
+ await writeFile8(vibeConfigJsonPath, projectConfigJson({ name, aspect }), "utf-8");
25784
26410
  created.push(vibeConfigJsonPath);
25785
26411
  }
25786
- const vibePath = resolve7(dir, "vibe.project.yaml");
26412
+ const vibePath = resolve8(dir, "vibe.project.yaml");
25787
26413
  if (await pathExists(vibePath)) {
25788
26414
  skipped2.push(vibePath);
25789
26415
  } else {
25790
26416
  const cfg = { ...defaultVibeProjectConfig(name), aspect };
25791
- await writeFile7(vibePath, (0, import_yaml4.stringify)(cfg), "utf-8");
26417
+ await writeFile8(vibePath, (0, import_yaml4.stringify)(cfg), "utf-8");
25792
26418
  created.push(vibePath);
25793
26419
  }
25794
26420
  if (profile === "agent" || profile === "full") {
25795
- const agentsPath = resolve7(dir, "AGENTS.md");
26421
+ const agentsPath = resolve8(dir, "AGENTS.md");
25796
26422
  if (await pathExists(agentsPath)) {
25797
26423
  skipped2.push(agentsPath);
25798
26424
  } else {
25799
- await writeFile7(agentsPath, buildProjectAgentsMd(name), "utf-8");
26425
+ await writeFile8(agentsPath, buildProjectAgentsMd(name), "utf-8");
25800
26426
  created.push(agentsPath);
25801
26427
  }
25802
- const claudePath = resolve7(dir, "CLAUDE.md");
26428
+ const claudePath = resolve8(dir, "CLAUDE.md");
25803
26429
  if (await pathExists(claudePath)) {
25804
26430
  const existing = await readFile6(claudePath, "utf-8");
25805
26431
  if (existing.includes("@AGENTS.md")) {
25806
26432
  skipped2.push(claudePath);
25807
26433
  } else {
25808
- await writeFile7(claudePath, `@AGENTS.md
26434
+ await writeFile8(claudePath, `@AGENTS.md
25809
26435
 
25810
26436
  ${existing}`, "utf-8");
25811
26437
  merged.push(claudePath);
25812
26438
  }
25813
26439
  } else {
25814
- await writeFile7(claudePath, buildProjectClaudeMd(name), "utf-8");
26440
+ await writeFile8(claudePath, buildProjectClaudeMd(name), "utf-8");
25815
26441
  created.push(claudePath);
25816
26442
  }
25817
26443
  }
25818
- const designPath = resolve7(dir, "DESIGN.md");
26444
+ const designPath = resolve8(dir, "DESIGN.md");
25819
26445
  if (await pathExists(designPath)) {
25820
26446
  skipped2.push(designPath);
25821
26447
  } else {
25822
- await writeFile7(designPath, buildDesignMd({ name, style: opts.visualStyle }), "utf-8");
26448
+ await writeFile8(designPath, buildDesignMd({ name, style: opts.visualStyle }), "utf-8");
25823
26449
  created.push(designPath);
25824
26450
  }
25825
- const storyboardPath = resolve7(dir, "STORYBOARD.md");
26451
+ const storyboardPath = resolve8(dir, "STORYBOARD.md");
25826
26452
  if (await pathExists(storyboardPath)) {
25827
26453
  skipped2.push(storyboardPath);
25828
26454
  } else {
25829
- await writeFile7(storyboardPath, buildStoryboardMd(name, duration), "utf-8");
26455
+ await writeFile8(storyboardPath, buildStoryboardMd(name, duration), "utf-8");
25830
26456
  created.push(storyboardPath);
25831
26457
  }
25832
- const gitignorePath = resolve7(dir, ".gitignore");
26458
+ const gitignorePath = resolve8(dir, ".gitignore");
25833
26459
  if (await pathExists(gitignorePath)) {
25834
26460
  skipped2.push(gitignorePath);
25835
26461
  } else {
25836
- await writeFile7(gitignorePath, buildSceneGitignore(), "utf-8");
26462
+ await writeFile8(gitignorePath, buildSceneGitignore(), "utf-8");
25837
26463
  created.push(gitignorePath);
25838
26464
  }
25839
26465
  return { created, skipped: skipped2, merged, groups: describeSceneScaffold({ dir, profile }) };
@@ -28990,17 +29616,17 @@ var init_cost_tier = __esm({
28990
29616
  });
28991
29617
 
28992
29618
  // src/commands/_shared/scene-lint.ts
28993
- import { readdir as readdir2, readFile as readFile7, writeFile as writeFile8, stat as stat2 } from "node:fs/promises";
29619
+ import { readdir as readdir2, readFile as readFile7, writeFile as writeFile9, stat as stat2 } from "node:fs/promises";
28994
29620
  import { existsSync as existsSync4 } from "node:fs";
28995
- import { resolve as resolve8, relative as relative2, join as join3 } from "node:path";
29621
+ import { resolve as resolve9, relative as relative2, join as join4 } from "node:path";
28996
29622
  import {
28997
29623
  runHyperframeLint
28998
29624
  } from "@hyperframes/producer";
28999
29625
  async function discoverSceneFiles(opts) {
29000
- const projectDir = resolve8(opts.projectDir);
29001
- const rootAbs = resolve8(projectDir, opts.rootRel ?? "index.html");
29626
+ const projectDir = resolve9(opts.projectDir);
29627
+ const rootAbs = resolve9(projectDir, opts.rootRel ?? "index.html");
29002
29628
  const root = existsSync4(rootAbs) ? rootAbs : null;
29003
- const compsDir = resolve8(projectDir, "compositions");
29629
+ const compsDir = resolve9(projectDir, "compositions");
29004
29630
  const subs = [];
29005
29631
  if (existsSync4(compsDir)) {
29006
29632
  await collectHtmlRecursive(compsDir, subs);
@@ -29011,7 +29637,7 @@ async function discoverSceneFiles(opts) {
29011
29637
  async function collectHtmlRecursive(dir, into) {
29012
29638
  const entries = await readdir2(dir, { withFileTypes: true });
29013
29639
  for (const entry of entries) {
29014
- const full = join3(dir, entry.name);
29640
+ const full = join4(dir, entry.name);
29015
29641
  if (entry.isDirectory()) {
29016
29642
  await collectHtmlRecursive(full, into);
29017
29643
  } else if (entry.isFile() && entry.name.toLowerCase().endsWith(".html")) {
@@ -29086,7 +29712,7 @@ function addClipClassToTimedElements(html) {
29086
29712
  });
29087
29713
  }
29088
29714
  async function runProjectLint(opts) {
29089
- const projectDir = resolve8(opts.projectDir);
29715
+ const projectDir = resolve9(opts.projectDir);
29090
29716
  const { root, subs } = await discoverSceneFiles({ projectDir, rootRel: opts.rootRel });
29091
29717
  const files = [];
29092
29718
  const fixed = [];
@@ -29110,7 +29736,7 @@ async function runProjectLint(opts) {
29110
29736
  if (opts.fix && findings.length > 0) {
29111
29737
  const { html: nextHtml, fixedCodes } = applyMechanicalFixes(html, findings);
29112
29738
  if (fixedCodes.length > 0) {
29113
- await writeFile8(abs, nextHtml, "utf-8");
29739
+ await writeFile9(abs, nextHtml, "utf-8");
29114
29740
  fixed.push({ file: rel, codes: fixedCodes });
29115
29741
  const reLinted = runHyperframeLint({ ...prepared, html: nextHtml });
29116
29742
  files.push({
@@ -29147,7 +29773,7 @@ async function runProjectLint(opts) {
29147
29773
  };
29148
29774
  }
29149
29775
  async function rootExists(projectDir, rootRel = "index.html") {
29150
- const abs = resolve8(projectDir, rootRel);
29776
+ const abs = resolve9(projectDir, rootRel);
29151
29777
  try {
29152
29778
  const s = await stat2(abs);
29153
29779
  return s.isFile();
@@ -29170,7 +29796,7 @@ var init_scene_lint = __esm({
29170
29796
  // src/utils/agent-host-detect.ts
29171
29797
  import { existsSync as existsSync5 } from "node:fs";
29172
29798
  import { homedir as homedir3 } from "node:os";
29173
- import { join as join4 } from "node:path";
29799
+ import { join as join5 } from "node:path";
29174
29800
  function detectAgentHosts(env2 = process.env) {
29175
29801
  const home = homedir3();
29176
29802
  return [
@@ -29230,7 +29856,7 @@ function detectAgentHosts(env2 = process.env) {
29230
29856
  }
29231
29857
  const configDirRel = HOST_CONFIG_DIRS[host.id];
29232
29858
  if (configDirRel) {
29233
- const path2 = join4(home, configDirRel);
29859
+ const path2 = join5(home, configDirRel);
29234
29860
  if (existsSync5(path2)) {
29235
29861
  signals2.push({ kind: "configDir", path: path2 });
29236
29862
  }
@@ -29255,8 +29881,8 @@ function isOnPath(binary, env2) {
29255
29881
  const sep2 = process.platform === "win32" ? ";" : ":";
29256
29882
  for (const dir of path2.split(sep2)) {
29257
29883
  if (!dir) continue;
29258
- if (existsSync5(join4(dir, binary))) return true;
29259
- if (process.platform === "win32" && existsSync5(join4(dir, `${binary}.exe`))) return true;
29884
+ if (existsSync5(join5(dir, binary))) return true;
29885
+ if (process.platform === "win32" && existsSync5(join5(dir, `${binary}.exe`))) return true;
29260
29886
  }
29261
29887
  return false;
29262
29888
  }
@@ -30093,11 +30719,11 @@ Avoid transitions that create visible repeating geometric patterns \u2014 grids
30093
30719
 
30094
30720
  // src/commands/_shared/hf-skill-bundle/bundle.ts
30095
30721
  import { readFileSync as readFileSync2, existsSync as existsSync6, statSync as statSync2 } from "node:fs";
30096
- import { join as join5 } from "node:path";
30722
+ import { join as join6 } from "node:path";
30097
30723
  import { homedir as homedir4 } from "node:os";
30098
30724
  import { createHash as createHash3 } from "node:crypto";
30099
30725
  function installedSkillPath() {
30100
- const candidate = join5(homedir4(), ".claude", "skills", "hyperframes");
30726
+ const candidate = join6(homedir4(), ".claude", "skills", "hyperframes");
30101
30727
  if (!existsSync6(candidate)) return null;
30102
30728
  if (!statSync2(candidate).isDirectory()) return null;
30103
30729
  return candidate;
@@ -30117,11 +30743,11 @@ function buildVendored() {
30117
30743
  }
30118
30744
  function buildInstalled(skillRoot) {
30119
30745
  const installedPaths = {
30120
- "hyperframes/SKILL.md": join5(skillRoot, "SKILL.md"),
30121
- "hyperframes/house-style.md": join5(skillRoot, "house-style.md"),
30122
- "hyperframes/motion-principles.md": join5(skillRoot, "references", "motion-principles.md"),
30123
- "hyperframes/typography.md": join5(skillRoot, "references", "typography.md"),
30124
- "hyperframes/transitions.md": join5(skillRoot, "references", "transitions.md")
30746
+ "hyperframes/SKILL.md": join6(skillRoot, "SKILL.md"),
30747
+ "hyperframes/house-style.md": join6(skillRoot, "house-style.md"),
30748
+ "hyperframes/motion-principles.md": join6(skillRoot, "references", "motion-principles.md"),
30749
+ "hyperframes/typography.md": join6(skillRoot, "references", "typography.md"),
30750
+ "hyperframes/transitions.md": join6(skillRoot, "references", "transitions.md")
30125
30751
  };
30126
30752
  const sections = [];
30127
30753
  for (const [label, path2] of Object.entries(installedPaths)) {
@@ -30175,12 +30801,12 @@ var init_bundle = __esm({
30175
30801
 
30176
30802
  // src/commands/_shared/root-sync.ts
30177
30803
  import { existsSync as existsSync8 } from "node:fs";
30178
- import { readFile as readFile8, writeFile as writeFile10 } from "node:fs/promises";
30179
- import { join as join7, resolve as resolve10 } from "node:path";
30804
+ import { readFile as readFile8, writeFile as writeFile11 } from "node:fs/promises";
30805
+ import { join as join8, resolve as resolve11 } from "node:path";
30180
30806
  async function createRootSyncPlan(opts) {
30181
- const projectDir = resolve10(opts.projectDir);
30807
+ const projectDir = resolve11(opts.projectDir);
30182
30808
  const rootRel = opts.rootRel ?? "index.html";
30183
- const rootPath = resolve10(projectDir, rootRel);
30809
+ const rootPath = resolve11(projectDir, rootRel);
30184
30810
  if (!existsSync8(rootPath)) {
30185
30811
  return { rootPath, rootRel, exists: false, changed: false, fixCodes: [], issues: [] };
30186
30812
  }
@@ -30238,12 +30864,12 @@ async function createProjectRootSyncPlan(opts) {
30238
30864
  }
30239
30865
  async function syncRootComposition(opts) {
30240
30866
  const plan = await createRootSyncPlan(opts);
30241
- if (plan.nextHtml) await writeFile10(plan.rootPath, plan.nextHtml, "utf-8");
30867
+ if (plan.nextHtml) await writeFile11(plan.rootPath, plan.nextHtml, "utf-8");
30242
30868
  return plan;
30243
30869
  }
30244
30870
  async function loadProjectRootSyncBeats(projectDir) {
30245
- const root = resolve10(projectDir);
30246
- const storyboardPath = join7(root, "STORYBOARD.md");
30871
+ const root = resolve11(projectDir);
30872
+ const storyboardPath = join8(root, "STORYBOARD.md");
30247
30873
  if (!existsSync8(storyboardPath)) return [];
30248
30874
  const parsed = parseStoryboard(await readFile8(storyboardPath, "utf-8"));
30249
30875
  const reportBeats = await readBuildReportBeats(root);
@@ -30312,14 +30938,14 @@ async function resolveSyncedBeatDuration(opts) {
30312
30938
  if (!opts.narrationPath) return Number((reported ?? storyboardMin).toFixed(2));
30313
30939
  const probe = opts.probeAudioDuration ?? getAudioDuration;
30314
30940
  try {
30315
- const audioDuration = await probe(join7(opts.projectDir, opts.narrationPath));
30941
+ const audioDuration = await probe(join8(opts.projectDir, opts.narrationPath));
30316
30942
  return Number(Math.max(storyboardMin, reported ?? 0, audioDuration + 0.5).toFixed(2));
30317
30943
  } catch {
30318
30944
  return Number(Math.max(storyboardMin, reported ?? 0).toFixed(2));
30319
30945
  }
30320
30946
  }
30321
30947
  async function resolveProjectBeatDurations(projectDir) {
30322
- const root = resolve10(projectDir);
30948
+ const root = resolve11(projectDir);
30323
30949
  const beats = await loadProjectRootSyncBeats(root);
30324
30950
  const durations = /* @__PURE__ */ new Map();
30325
30951
  for (const beat of beats) {
@@ -30450,7 +31076,7 @@ function hasExpectedAudioRef(html, ref) {
30450
31076
  return ref.volume === void 0 || tag.includes(`data-volume="${ref.volume}"`);
30451
31077
  }
30452
31078
  async function readBuildReportBeats(projectDir) {
30453
- const reportPath = join7(projectDir, "build-report.json");
31079
+ const reportPath = join8(projectDir, "build-report.json");
30454
31080
  if (!existsSync8(reportPath)) return [];
30455
31081
  try {
30456
31082
  const report = JSON.parse(await readFile8(reportPath, "utf-8"));
@@ -30460,7 +31086,7 @@ async function readBuildReportBeats(projectDir) {
30460
31086
  }
30461
31087
  }
30462
31088
  function firstExisting(projectDir, rels) {
30463
- return rels.find((rel) => existsSync8(join7(projectDir, rel)));
31089
+ return rels.find((rel) => existsSync8(join8(projectDir, rel)));
30464
31090
  }
30465
31091
  function stringOrUndefined(value) {
30466
31092
  return typeof value === "string" && value.length > 0 ? value : void 0;
@@ -30571,8 +31197,8 @@ import OpenAI from "openai";
30571
31197
  import { GoogleGenerativeAI } from "@google/generative-ai";
30572
31198
  import { createHash as createHash4 } from "node:crypto";
30573
31199
  import { existsSync as existsSync9, mkdirSync } from "node:fs";
30574
- import { mkdir as mkdir6, readFile as readFile9, writeFile as writeFile11 } from "node:fs/promises";
30575
- import { dirname as dirname5, join as join8, resolve as resolve11 } from "node:path";
31200
+ import { mkdir as mkdir6, readFile as readFile9, writeFile as writeFile12 } from "node:fs/promises";
31201
+ import { dirname as dirname5, join as join9, resolve as resolve12 } from "node:path";
30576
31202
  import { runHyperframeLint as runHyperframeLint2 } from "@hyperframes/producer";
30577
31203
  function buildSystemPrompt(ctx) {
30578
31204
  return `You are a Hyperframes composition author. The skill content below
@@ -30666,7 +31292,15 @@ ${finalDurationBullet}
30666
31292
  itself. For multi-phase beats, drive phase changes with GSAP \`autoAlpha\`
30667
31293
  inside full-window clips: animate phase A out (\`autoAlpha: 0\`), then
30668
31294
  phase B in, on the same timeline.
30669
- - If \`assets/backdrop-${ctx.beat.id}.png\` exists, use that local file as the
31295
+ - If \`assets/video-${ctx.beat.id}.mp4\` exists, it is an AI-generated clip for
31296
+ this beat \u2014 use it as the FULL-FRAME visual and prefer it over any backdrop
31297
+ image or CSS. Put one full-bleed \`<video>\` inside a full-window \`.clip\`:
31298
+ \`<video src="assets/video-${ctx.beat.id}.mp4" data-start="0" data-duration="${BEAT_DURATION}" data-media-start="0" muted playsinline style="width:100%;height:100%;object-fit:cover"></video>\`
31299
+ Use the exact path (no \`./\` or \`../\`). The framework seeks the clip to each
31300
+ timeline frame, so do NOT add transform tweens to the \`<video>\` or its
31301
+ ancestors; layer text/motion graphics on top instead.
31302
+ - If \`assets/backdrop-${ctx.beat.id}.png\` exists (and no video clip above),
31303
+ use that local file as the
30670
31304
  full-frame visual backdrop. The exact path string is
30671
31305
  \`assets/backdrop-${ctx.beat.id}.png\`; do NOT prefix it with \`../\` or \`./\`
30672
31306
  because fragments are mounted from the project root.
@@ -30773,7 +31407,7 @@ function computeCacheKey(parts) {
30773
31407
  return createHash4("sha256").update(parts.provider).update("\u241E").update(parts.model).update("\u241E").update(parts.systemPrompt).update("\u241E").update(parts.userPrompt).digest("hex");
30774
31408
  }
30775
31409
  function defaultCacheDir() {
30776
- return join8(USER_CACHE_DIR, "compose-scenes");
31410
+ return join9(USER_CACHE_DIR, "compose-scenes");
30777
31411
  }
30778
31412
  function extractHtml(responseText) {
30779
31413
  const fenced = responseText.match(/```html\s*\n([\s\S]*?)\n```/);
@@ -30797,7 +31431,7 @@ async function composeBeatHtml(ctx, overrides) {
30797
31431
  model: settings.model
30798
31432
  });
30799
31433
  const cacheDir = ctx.cacheDir ?? defaultCacheDir();
30800
- const cachePath = join8(cacheDir, `${cacheKey}.html`);
31434
+ const cachePath = join9(cacheDir, `${cacheKey}.html`);
30801
31435
  if (existsSync9(cachePath)) {
30802
31436
  const html2 = await readFile9(cachePath, "utf-8");
30803
31437
  return { html: html2, cached: true, cacheKey, model: settings.model, provider: ctx.provider };
@@ -30833,7 +31467,7 @@ async function composeBeatHtml(ctx, overrides) {
30833
31467
  const html = extractHtml(result.text);
30834
31468
  try {
30835
31469
  mkdirSync(cacheDir, { recursive: true });
30836
- await writeFile11(cachePath, html, "utf-8");
31470
+ await writeFile12(cachePath, html, "utf-8");
30837
31471
  } catch {
30838
31472
  }
30839
31473
  const costUsd = result.inputTokens / 1e6 * settings.costPerMTokIn + result.outputTokens / 1e6 * settings.costPerMTokOut;
@@ -30932,9 +31566,9 @@ async function resolveComposerConfigAware(explicit, cwd) {
30932
31566
  return resolveComposer();
30933
31567
  }
30934
31568
  async function executeComposeScenesWithSkills(params, outputDir, overrides) {
30935
- const projectRoot = params.project ? resolve11(outputDir, params.project) : resolve11(outputDir);
30936
- const designPath = resolve11(projectRoot, params.design ?? "DESIGN.md");
30937
- const storyboardPath = resolve11(projectRoot, params.storyboard ?? "STORYBOARD.md");
31569
+ const projectRoot = params.project ? resolve12(outputDir, params.project) : resolve12(outputDir);
31570
+ const designPath = resolve12(projectRoot, params.design ?? "DESIGN.md");
31571
+ const storyboardPath = resolve12(projectRoot, params.storyboard ?? "STORYBOARD.md");
30938
31572
  if (!existsSync9(designPath)) {
30939
31573
  return { success: false, error: `DESIGN.md not found at ${designPath}` };
30940
31574
  }
@@ -30962,7 +31596,7 @@ async function executeComposeScenesWithSkills(params, outputDir, overrides) {
30962
31596
  provider = params.composer ?? "claude";
30963
31597
  apiKey = "";
30964
31598
  }
30965
- const compositionsDir = join8(projectRoot, "compositions");
31599
+ const compositionsDir = join9(projectRoot, "compositions");
30966
31600
  await mkdir6(compositionsDir, { recursive: true });
30967
31601
  let finalDurations = /* @__PURE__ */ new Map();
30968
31602
  try {
@@ -30990,9 +31624,9 @@ async function executeComposeScenesWithSkills(params, outputDir, overrides) {
30990
31624
  },
30991
31625
  overrides
30992
31626
  );
30993
- const compositionPath = join8(compositionsDir, `scene-${beat.id}.html`);
31627
+ const compositionPath = join9(compositionsDir, `scene-${beat.id}.html`);
30994
31628
  await mkdir6(dirname5(compositionPath), { recursive: true });
30995
- await writeFile11(compositionPath, result.html, "utf-8");
31629
+ await writeFile12(compositionPath, result.html, "utf-8");
30996
31630
  if (result.cached) {
30997
31631
  onProgress({
30998
31632
  type: "beat-cached",
@@ -31191,13 +31825,13 @@ var init_compose_scenes_skills = __esm({
31191
31825
  // src/commands/_shared/compose-prompts.ts
31192
31826
  import { existsSync as existsSync10 } from "node:fs";
31193
31827
  import { readFile as readFile10 } from "node:fs/promises";
31194
- import { join as join9, relative as relative4, resolve as resolve12 } from "node:path";
31828
+ import { join as join10, relative as relative4, resolve as resolve13 } from "node:path";
31195
31829
  async function getComposePrompts(opts) {
31196
- const projectDir = resolve12(opts.projectDir);
31197
- const designPath = join9(projectDir, "DESIGN.md");
31198
- const storyboardPath = join9(projectDir, "STORYBOARD.md");
31199
- const skillPath = join9(projectDir, "SKILL.md");
31200
- const compositionsDir = join9(projectDir, "compositions");
31830
+ const projectDir = resolve13(opts.projectDir);
31831
+ const designPath = join10(projectDir, "DESIGN.md");
31832
+ const storyboardPath = join10(projectDir, "STORYBOARD.md");
31833
+ const skillPath = join10(projectDir, "SKILL.md");
31834
+ const compositionsDir = join10(projectDir, "compositions");
31201
31835
  const warnings = [];
31202
31836
  const baseError = (msg) => ({
31203
31837
  success: false,
@@ -31246,7 +31880,7 @@ async function getComposePrompts(opts) {
31246
31880
  } catch {
31247
31881
  }
31248
31882
  const result = beats.map((beat) => {
31249
- const outputPathAbs = join9(compositionsDir, `scene-${beat.id}.html`);
31883
+ const outputPathAbs = join10(compositionsDir, `scene-${beat.id}.html`);
31250
31884
  const outputPathRel = relative4(projectDir, outputPathAbs);
31251
31885
  const finalDurationSec = finalDurations.get(beat.id);
31252
31886
  const userPrompt = buildUserPrompt({
@@ -31320,7 +31954,7 @@ var init_compose_prompts = __esm({
31320
31954
  // src/commands/_shared/sub-comp-duration-sync.ts
31321
31955
  import { existsSync as existsSync12 } from "node:fs";
31322
31956
  import { readFile as readFile12 } from "node:fs/promises";
31323
- import { join as join11, resolve as resolve14 } from "node:path";
31957
+ import { join as join12, resolve as resolve15 } from "node:path";
31324
31958
  function syncSubCompositionDurationHtml(html, beatId, newDurationSec, fileRel) {
31325
31959
  const compositionId = `scene-${beatId}`;
31326
31960
  const file = fileRel ?? `compositions/${compositionId}.html`;
@@ -31408,11 +32042,11 @@ function rewriteTimelineConst(html, oldDurationSec, newDurationSec) {
31408
32042
  return { html, rewritten: false, needsManualFix };
31409
32043
  }
31410
32044
  async function createSubCompDurationSyncPlans(opts) {
31411
- const projectDir = resolve14(opts.projectDir);
32045
+ const projectDir = resolve15(opts.projectDir);
31412
32046
  const results = [];
31413
32047
  for (const beat of opts.beats) {
31414
32048
  const fileRel = `compositions/scene-${beat.id}.html`;
31415
- const filePath = join11(projectDir, fileRel);
32049
+ const filePath = join12(projectDir, fileRel);
31416
32050
  if (!existsSync12(filePath)) continue;
31417
32051
  const newDurationSec = await resolveSyncedBeatDuration({
31418
32052
  projectDir,
@@ -31443,88 +32077,6 @@ var init_sub_comp_duration_sync = __esm({
31443
32077
  }
31444
32078
  });
31445
32079
 
31446
- // src/commands/_shared/review-report.ts
31447
- import { writeFile as writeFile13 } from "node:fs/promises";
31448
- import { join as join12, resolve as resolve15 } from "node:path";
31449
- function statusFromIssues(issues) {
31450
- if (issues.some((issue) => issue.severity === "error")) return "fail";
31451
- if (issues.some((issue) => issue.severity === "warning")) return "warn";
31452
- return "pass";
31453
- }
31454
- function scoreIssues(issues) {
31455
- let score = 100;
31456
- for (const issue of issues) {
31457
- if (issue.severity === "error") score -= 25;
31458
- else if (issue.severity === "warning") score -= 8;
31459
- else score -= 2;
31460
- }
31461
- return Math.max(0, Math.min(100, score));
31462
- }
31463
- function uniqueRetryWith(items) {
31464
- return [
31465
- ...new Set(items.filter((item) => typeof item === "string" && item.length > 0))
31466
- ];
31467
- }
31468
- function fixOwnerForIssue(issue) {
31469
- if (issue.fixOwner) return issue.fixOwner;
31470
- if (issue.code.startsWith("AI_REVIEW_")) return "host-agent";
31471
- if (issue.code.startsWith("STORYBOARD_")) return "host-agent";
31472
- if (issue.code === "PROJECT_NOT_FOUND" || issue.code === "MISSING_STORYBOARD" || issue.code === "BEAT_NOT_FOUND" || issue.code === "MISSING_DESIGN") {
31473
- return "host-agent";
31474
- }
31475
- return "vibe";
31476
- }
31477
- function normalizeReviewIssues(issues, fallbackOwner) {
31478
- return issues.map((issue) => ({
31479
- ...issue,
31480
- fixOwner: issue.fixOwner ?? fallbackOwner ?? fixOwnerForIssue(issue)
31481
- }));
31482
- }
31483
- function summarizeReviewIssues(issues) {
31484
- const normalized = normalizeReviewIssues(issues);
31485
- return {
31486
- issueCount: normalized.length,
31487
- errorCount: normalized.filter((issue) => issue.severity === "error").length,
31488
- warningCount: normalized.filter((issue) => issue.severity === "warning").length,
31489
- infoCount: normalized.filter((issue) => issue.severity === "info").length,
31490
- fixOwners: {
31491
- vibe: normalized.filter((issue) => issue.fixOwner === "vibe").length,
31492
- hostAgent: normalized.filter((issue) => issue.fixOwner === "host-agent").length
31493
- }
31494
- };
31495
- }
31496
- function buildReviewReport(opts) {
31497
- const issues = normalizeReviewIssues(opts.issues);
31498
- return stripUndefined2({
31499
- schemaVersion: "1",
31500
- kind: "review",
31501
- project: resolve15(opts.project),
31502
- mode: opts.mode,
31503
- beat: opts.beat,
31504
- status: opts.status,
31505
- score: opts.score,
31506
- issues,
31507
- summary: summarizeReviewIssues(issues),
31508
- retryWith: uniqueRetryWith(opts.retryWith),
31509
- sourceReports: opts.sourceReports ?? [],
31510
- reportPath: opts.reportPath
31511
- });
31512
- }
31513
- function defaultReviewReportPath(projectDir) {
31514
- return join12(resolve15(projectDir), "review-report.json");
31515
- }
31516
- async function writeReviewReport(path2, report) {
31517
- await writeFile13(path2, JSON.stringify(report, null, 2) + "\n", "utf-8");
31518
- }
31519
- function stripUndefined2(value) {
31520
- return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== void 0));
31521
- }
31522
- var init_review_report = __esm({
31523
- "src/commands/_shared/review-report.ts"() {
31524
- "use strict";
31525
- }
31526
- });
31527
-
31528
32080
  // src/commands/_shared/scene-repair.ts
31529
32081
  import { readFile as readFile13, writeFile as writeFile14 } from "node:fs/promises";
31530
32082
  import { relative as relative5, resolve as resolve16 } from "node:path";
@@ -31651,6 +32203,298 @@ var init_scene_repair = __esm({
31651
32203
  }
31652
32204
  });
31653
32205
 
32206
+ // src/commands/_shared/storyboard-edit.ts
32207
+ function validateStoryboardMarkdown(markdown) {
32208
+ const parsed = parseStoryboard(markdown);
32209
+ const sections = splitBeatSections(markdown);
32210
+ const issues = [];
32211
+ if (parsed.beats.length === 0) {
32212
+ issues.push({
32213
+ severity: "error",
32214
+ code: "NO_BEATS",
32215
+ message: "STORYBOARD.md must contain at least one `## Beat ...` heading."
32216
+ });
32217
+ }
32218
+ const seen = /* @__PURE__ */ new Map();
32219
+ for (const beat of parsed.beats) {
32220
+ seen.set(beat.id, (seen.get(beat.id) ?? 0) + 1);
32221
+ }
32222
+ for (const [id, count] of seen.entries()) {
32223
+ if (count > 1) {
32224
+ issues.push({
32225
+ severity: "error",
32226
+ code: "DUPLICATE_BEAT_ID",
32227
+ beatId: id,
32228
+ message: `Beat id "${id}" appears ${count} times. Beat ids must be unique.`
32229
+ });
32230
+ }
32231
+ }
32232
+ for (const section of sections) {
32233
+ const cueBlock = readLeadingCueBlock(section.body);
32234
+ if (cueBlock?.error) {
32235
+ issues.push({
32236
+ severity: "error",
32237
+ code: "MALFORMED_CUE_YAML",
32238
+ beatId: section.id,
32239
+ message: `Beat "${section.id}" has malformed YAML cues: ${cueBlock.error}`
32240
+ });
32241
+ }
32242
+ }
32243
+ const characterPool = new Set(
32244
+ Object.keys(
32245
+ parsed.frontmatter?.characters ?? {}
32246
+ )
32247
+ );
32248
+ for (const beat of parsed.beats) {
32249
+ const cues = beat.cues ?? {};
32250
+ for (const [key, value] of Object.entries(cues)) {
32251
+ if (!ALLOWED_CUE_KEYS.has(key)) {
32252
+ issues.push({
32253
+ severity: "warning",
32254
+ code: "UNKNOWN_CUE",
32255
+ beatId: beat.id,
32256
+ message: `Beat "${beat.id}" uses unknown cue "${key}". Supported cues: ${STORYBOARD_CUE_KEYS.join(", ")}.`
32257
+ });
32258
+ continue;
32259
+ }
32260
+ if (key === "characters") {
32261
+ const validShape = typeof value === "string" || Array.isArray(value) && value.every((v) => typeof v === "string");
32262
+ if (!validShape) {
32263
+ issues.push({
32264
+ severity: "error",
32265
+ code: "INVALID_CHARACTERS_VALUE",
32266
+ beatId: beat.id,
32267
+ message: `Beat "${beat.id}" cue "characters" must be a character name or a list of names.`
32268
+ });
32269
+ continue;
32270
+ }
32271
+ for (const name of beatCharacterNames({ characters: value })) {
32272
+ if (!characterPool.has(name)) {
32273
+ issues.push({
32274
+ severity: "warning",
32275
+ code: "UNKNOWN_CHARACTER",
32276
+ beatId: beat.id,
32277
+ message: `Beat "${beat.id}" references character "${name}" not defined in the frontmatter \`characters:\` pool.`
32278
+ });
32279
+ }
32280
+ }
32281
+ continue;
32282
+ }
32283
+ if (key === "duration") {
32284
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
32285
+ issues.push({
32286
+ severity: "error",
32287
+ code: "INVALID_DURATION",
32288
+ beatId: beat.id,
32289
+ message: `Beat "${beat.id}" cue "duration" must be a positive number of seconds.`
32290
+ });
32291
+ }
32292
+ continue;
32293
+ }
32294
+ if (STRING_CUE_KEYS.has(key) && typeof value !== "string") {
32295
+ issues.push({
32296
+ severity: "error",
32297
+ code: "INVALID_CUE_VALUE",
32298
+ beatId: beat.id,
32299
+ message: `Beat "${beat.id}" cue "${key}" must be a string.`
32300
+ });
32301
+ }
32302
+ }
32303
+ }
32304
+ for (const beat of parsed.beats) {
32305
+ if (beat.duration !== void 0 && beat.duration > MAX_RECOMMENDED_BEAT_SEC) {
32306
+ issues.push({
32307
+ severity: "warning",
32308
+ code: "BEAT_DURATION_TOO_LONG",
32309
+ beatId: beat.id,
32310
+ message: `Beat "${beat.id}" is ${beat.duration}s \u2014 beats longer than ${MAX_RECOMMENDED_BEAT_SEC}s render static and overstuffed. Split it into 6-15s beats (a 90s video should have 6-8 beats).`
32311
+ });
32312
+ }
32313
+ }
32314
+ return {
32315
+ ok: !issues.some((i) => i.severity === "error"),
32316
+ beats: parsed.beats,
32317
+ issues
32318
+ };
32319
+ }
32320
+ function getStoryboardBeat(markdown, beatId) {
32321
+ return parseStoryboard(markdown).beats.find((beat) => beat.id === beatId) ?? null;
32322
+ }
32323
+ function upsertStoryboardBeat(markdown, opts) {
32324
+ const beatId = normalizeBeatId(opts.beatId);
32325
+ const sections = splitBeatSections(markdown);
32326
+ const existing = sections.find((section2) => section2.id === beatId);
32327
+ const cues = storyboardCuesFromInput(opts);
32328
+ if (existing) {
32329
+ let next = markdown;
32330
+ for (const [key, value] of Object.entries(cues)) {
32331
+ next = setStoryboardCue(next, { beatId, key, value });
32332
+ }
32333
+ return { markdown: next, action: "updated" };
32334
+ }
32335
+ const section = buildStoryboardBeatSection({ ...opts, beatId, cues });
32336
+ if (sections.length > 0 && isStarterStoryboard(markdown)) {
32337
+ const global2 = markdown.slice(0, sections[0].start).trimEnd();
32338
+ return {
32339
+ markdown: `${global2}
32340
+
32341
+ ${section}`,
32342
+ action: "replaced-starter"
32343
+ };
32344
+ }
32345
+ const prefix = markdown.trimEnd();
32346
+ return {
32347
+ markdown: `${prefix}${prefix ? "\n\n" : ""}${section}`,
32348
+ action: "appended"
32349
+ };
32350
+ }
32351
+ function setStoryboardCue(markdown, opts) {
32352
+ const section = findBeatSection(markdown, opts.beatId);
32353
+ if (!section) {
32354
+ throw new Error(`Beat "${opts.beatId}" not found.`);
32355
+ }
32356
+ if (!ALLOWED_CUE_KEYS.has(opts.key)) {
32357
+ throw new Error(`Unsupported cue "${opts.key}". Supported cues: ${STORYBOARD_CUE_KEYS.join(", ")}.`);
32358
+ }
32359
+ const cue = readLeadingCueBlock(section.body);
32360
+ if (cue?.error) {
32361
+ throw new Error(`Cannot edit malformed YAML cues for beat "${opts.beatId}": ${cue.error}`);
32362
+ }
32363
+ const cues = cue?.value ? { ...cue.value } : {};
32364
+ if (opts.unset) {
32365
+ delete cues[opts.key];
32366
+ } else {
32367
+ cues[opts.key] = normalizeCueValue(opts.key, opts.value);
32368
+ }
32369
+ const remainingBody = cue ? section.body.slice(cue.full.length).replace(/^\s*\n/, "") : section.body.replace(/^\s*\n/, "");
32370
+ const cueBlock = Object.keys(cues).length > 0 ? "```yaml\n" + (0, import_yaml5.stringify)(cues, { lineWidth: 0 }).trimEnd() + "\n```\n\n" : "";
32371
+ const nextBody = cueBlock + remainingBody.trimStart();
32372
+ const nextSection = section.raw.slice(0, section.headingEnd - section.start) + "\n\n" + nextBody.trimEnd() + "\n";
32373
+ return markdown.slice(0, section.start) + nextSection + markdown.slice(section.end);
32374
+ }
32375
+ function moveStoryboardBeat(markdown, opts) {
32376
+ if (opts.beatId === opts.afterBeatId) return markdown;
32377
+ const sections = splitBeatSections(markdown);
32378
+ const movingIndex = sections.findIndex((section) => section.id === opts.beatId);
32379
+ if (movingIndex === -1) throw new Error(`Beat "${opts.beatId}" not found.`);
32380
+ const afterIndex = sections.findIndex((section) => section.id === opts.afterBeatId);
32381
+ if (afterIndex === -1) throw new Error(`Beat "${opts.afterBeatId}" not found.`);
32382
+ const global2 = sections.length > 0 ? markdown.slice(0, sections[0].start) : markdown;
32383
+ const chunks = sections.map((section) => section.raw);
32384
+ const [moving] = chunks.splice(movingIndex, 1);
32385
+ const adjustedAfterIndex = movingIndex < afterIndex ? afterIndex - 1 : afterIndex;
32386
+ chunks.splice(adjustedAfterIndex + 1, 0, moving);
32387
+ return global2 + normalizeBeatChunks(chunks);
32388
+ }
32389
+ function normalizeBeatChunks(chunks) {
32390
+ return chunks.map((chunk) => chunk.trim()).filter(Boolean).join("\n\n") + "\n";
32391
+ }
32392
+ function normalizeBeatId(id) {
32393
+ return deriveBeatId(id);
32394
+ }
32395
+ function storyboardCuesFromInput(opts) {
32396
+ const cues = {};
32397
+ if (opts.duration !== void 0) cues.duration = opts.duration;
32398
+ if (opts.narration && opts.narration.trim()) cues.narration = opts.narration.trim();
32399
+ if (opts.backdrop && opts.backdrop.trim()) cues.backdrop = opts.backdrop.trim();
32400
+ return cues;
32401
+ }
32402
+ function buildStoryboardBeatSection(opts) {
32403
+ const title = normalizeTitle(opts.title ?? humanizeBeatId(opts.beatId));
32404
+ const cueBlock = Object.keys(opts.cues).length > 0 ? "```yaml\n" + (0, import_yaml5.stringify)(opts.cues, { lineWidth: 0 }).trimEnd() + "\n```\n\n" : "";
32405
+ const body = opts.body?.trim() || "Scene created with `vibe scene add`. Edit this body if the build flow should recompose it.";
32406
+ return `## Beat ${opts.beatId} - ${title}
32407
+
32408
+ ${cueBlock}${body.trimEnd()}
32409
+ `;
32410
+ }
32411
+ function normalizeTitle(value) {
32412
+ return value.replace(/\s+/g, " ").trim() || "Scene";
32413
+ }
32414
+ function humanizeBeatId(id) {
32415
+ return id.split(/[-_]+/).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ") || "Scene";
32416
+ }
32417
+ function isStarterStoryboard(markdown) {
32418
+ const parsed = parseStoryboard(markdown);
32419
+ if (parsed.beats.length !== 3) return false;
32420
+ const ids = parsed.beats.map((beat) => beat.id);
32421
+ if (ids.join(",") !== "hook,proof,close") return false;
32422
+ return markdown.includes("Introduce the promise in one crisp sentence.") && markdown.includes("Show the mechanism or proof point that makes the promise believable.") && markdown.includes("Close with the action the viewer should remember.");
32423
+ }
32424
+ function normalizeCueValue(key, value) {
32425
+ if (key === "duration") {
32426
+ const n = typeof value === "number" ? value : Number.parseFloat(String(value ?? ""));
32427
+ if (!Number.isFinite(n) || n <= 0) {
32428
+ throw new Error("Cue duration must be a positive number.");
32429
+ }
32430
+ return n;
32431
+ }
32432
+ if (typeof value !== "string") return value;
32433
+ return value.trim();
32434
+ }
32435
+ function splitBeatSections(markdown) {
32436
+ const normalized = markdown.replace(/\r\n/g, "\n");
32437
+ const headings = [];
32438
+ HEADING_RE2.lastIndex = 0;
32439
+ let match;
32440
+ while ((match = HEADING_RE2.exec(normalized)) !== null) {
32441
+ headings.push({ start: match.index, end: match.index + match[0].length, line: match[1].trim() });
32442
+ }
32443
+ return headings.map((heading, index) => {
32444
+ const end = index + 1 < headings.length ? headings[index + 1].start : normalized.length;
32445
+ return {
32446
+ id: deriveBeatId(heading.line),
32447
+ heading: heading.line,
32448
+ start: heading.start,
32449
+ headingEnd: heading.end,
32450
+ end,
32451
+ raw: normalized.slice(heading.start, end),
32452
+ body: normalized.slice(heading.end, end).trim()
32453
+ };
32454
+ });
32455
+ }
32456
+ function findBeatSection(markdown, beatId) {
32457
+ return splitBeatSections(markdown).find((section) => section.id === beatId) ?? null;
32458
+ }
32459
+ function readLeadingCueBlock(body) {
32460
+ const match = body.match(LEADING_CUE_RE);
32461
+ if (!match) return null;
32462
+ try {
32463
+ const parsed = (0, import_yaml5.parse)(match[2]);
32464
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
32465
+ return { full: match[0], error: "cue block must parse to a YAML object" };
32466
+ }
32467
+ return { full: match[0], value: parsed };
32468
+ } catch (error) {
32469
+ return { full: match[0], error: error instanceof Error ? error.message : String(error) };
32470
+ }
32471
+ }
32472
+ var import_yaml5, STORYBOARD_CUE_KEYS, HEADING_RE2, LEADING_CUE_RE, ALLOWED_CUE_KEYS, STRING_CUE_KEYS, MAX_RECOMMENDED_BEAT_SEC;
32473
+ var init_storyboard_edit = __esm({
32474
+ "src/commands/_shared/storyboard-edit.ts"() {
32475
+ "use strict";
32476
+ import_yaml5 = __toESM(require_dist2(), 1);
32477
+ init_storyboard_parse();
32478
+ STORYBOARD_CUE_KEYS = [
32479
+ "duration",
32480
+ "narration",
32481
+ "backdrop",
32482
+ "video",
32483
+ "keyframe",
32484
+ "motion",
32485
+ "voice",
32486
+ "music",
32487
+ "asset",
32488
+ "characters"
32489
+ ];
32490
+ HEADING_RE2 = /^##\s+(.+?)\s*$/gm;
32491
+ LEADING_CUE_RE = /^(\s*)```ya?ml\s*\n([\s\S]*?)\n```\s*(?:\n|$)/;
32492
+ ALLOWED_CUE_KEYS = new Set(STORYBOARD_CUE_KEYS);
32493
+ STRING_CUE_KEYS = /* @__PURE__ */ new Set(["narration", "backdrop", "video", "keyframe", "motion", "voice", "music", "asset"]);
32494
+ MAX_RECOMMENDED_BEAT_SEC = 15;
32495
+ }
32496
+ });
32497
+
31654
32498
  // src/pipeline/renderers/chrome.ts
31655
32499
  import { existsSync as existsSync14 } from "node:fs";
31656
32500
  import { homedir as homedir5 } from "node:os";
@@ -32567,6 +33411,8 @@ var init_build_asset_reference = __esm({
32567
33411
  EXTENSIONS = {
32568
33412
  narration: [".mp3", ".wav", ".m4a"],
32569
33413
  backdrop: [".png", ".jpg", ".jpeg", ".webp"],
33414
+ character: [".png", ".jpg", ".jpeg", ".webp"],
33415
+ keyframe: [".png", ".jpg", ".jpeg", ".webp"],
32570
33416
  video: [".mp4", ".mov", ".webm"],
32571
33417
  music: [".mp3", ".wav", ".m4a"]
32572
33418
  };
@@ -32604,6 +33450,28 @@ function backdropCacheDescriptor(opts) {
32604
33450
  ext: "png"
32605
33451
  });
32606
33452
  }
33453
+ function characterCacheDescriptor(opts) {
33454
+ return cacheAssetDescriptor("character", {
33455
+ name: opts.name,
33456
+ cue: opts.cue,
33457
+ provider: opts.provider,
33458
+ quality: opts.quality,
33459
+ size: opts.size,
33460
+ ext: "png"
33461
+ });
33462
+ }
33463
+ function keyframeCacheDescriptor(opts) {
33464
+ return cacheAssetDescriptor("keyframe", {
33465
+ beatId: opts.beatId,
33466
+ cue: opts.cue,
33467
+ provider: opts.provider,
33468
+ quality: opts.quality,
33469
+ size: opts.size,
33470
+ ratio: opts.ratio,
33471
+ characters: opts.characters && opts.characters.length > 0 ? opts.characters : void 0,
33472
+ ext: "png"
33473
+ });
33474
+ }
32607
33475
  function imageRatioForSize(size) {
32608
33476
  switch (size) {
32609
33477
  case "1024x1024":
@@ -32623,6 +33491,10 @@ function videoCacheDescriptor(opts) {
32623
33491
  provider: opts.provider,
32624
33492
  duration: normalizeVideoDuration(opts.duration),
32625
33493
  ratio: "16:9",
33494
+ // Omit when empty so existing (character-less) clips keep their cache key.
33495
+ characters: opts.characters && opts.characters.length > 0 ? opts.characters : void 0,
33496
+ // Omit when absent so existing (non-keyframe) clips keep their cache key.
33497
+ keyframe: opts.keyframe || void 0,
32626
33498
  ext: "mp4"
32627
33499
  });
32628
33500
  }
@@ -32675,208 +33547,6 @@ var init_build_backdrop_prompt = __esm({
32675
33547
  }
32676
33548
  });
32677
33549
 
32678
- // src/commands/_shared/storyboard-edit.ts
32679
- function validateStoryboardMarkdown(markdown) {
32680
- const parsed = parseStoryboard(markdown);
32681
- const sections = splitBeatSections(markdown);
32682
- const issues = [];
32683
- if (parsed.beats.length === 0) {
32684
- issues.push({
32685
- severity: "error",
32686
- code: "NO_BEATS",
32687
- message: "STORYBOARD.md must contain at least one `## Beat ...` heading."
32688
- });
32689
- }
32690
- const seen = /* @__PURE__ */ new Map();
32691
- for (const beat of parsed.beats) {
32692
- seen.set(beat.id, (seen.get(beat.id) ?? 0) + 1);
32693
- }
32694
- for (const [id, count] of seen.entries()) {
32695
- if (count > 1) {
32696
- issues.push({
32697
- severity: "error",
32698
- code: "DUPLICATE_BEAT_ID",
32699
- beatId: id,
32700
- message: `Beat id "${id}" appears ${count} times. Beat ids must be unique.`
32701
- });
32702
- }
32703
- }
32704
- for (const section of sections) {
32705
- const cueBlock = readLeadingCueBlock(section.body);
32706
- if (cueBlock?.error) {
32707
- issues.push({
32708
- severity: "error",
32709
- code: "MALFORMED_CUE_YAML",
32710
- beatId: section.id,
32711
- message: `Beat "${section.id}" has malformed YAML cues: ${cueBlock.error}`
32712
- });
32713
- }
32714
- }
32715
- for (const beat of parsed.beats) {
32716
- const cues = beat.cues ?? {};
32717
- for (const [key, value] of Object.entries(cues)) {
32718
- if (!ALLOWED_CUE_KEYS.has(key)) {
32719
- issues.push({
32720
- severity: "warning",
32721
- code: "UNKNOWN_CUE",
32722
- beatId: beat.id,
32723
- message: `Beat "${beat.id}" uses unknown cue "${key}". Supported cues: ${STORYBOARD_CUE_KEYS.join(", ")}.`
32724
- });
32725
- continue;
32726
- }
32727
- if (key === "duration") {
32728
- if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
32729
- issues.push({
32730
- severity: "error",
32731
- code: "INVALID_DURATION",
32732
- beatId: beat.id,
32733
- message: `Beat "${beat.id}" cue "duration" must be a positive number of seconds.`
32734
- });
32735
- }
32736
- continue;
32737
- }
32738
- if (STRING_CUE_KEYS.has(key) && typeof value !== "string") {
32739
- issues.push({
32740
- severity: "error",
32741
- code: "INVALID_CUE_VALUE",
32742
- beatId: beat.id,
32743
- message: `Beat "${beat.id}" cue "${key}" must be a string.`
32744
- });
32745
- }
32746
- }
32747
- }
32748
- for (const beat of parsed.beats) {
32749
- if (beat.duration !== void 0 && beat.duration > MAX_RECOMMENDED_BEAT_SEC) {
32750
- issues.push({
32751
- severity: "warning",
32752
- code: "BEAT_DURATION_TOO_LONG",
32753
- beatId: beat.id,
32754
- message: `Beat "${beat.id}" is ${beat.duration}s \u2014 beats longer than ${MAX_RECOMMENDED_BEAT_SEC}s render static and overstuffed. Split it into 6-15s beats (a 90s video should have 6-8 beats).`
32755
- });
32756
- }
32757
- }
32758
- return {
32759
- ok: !issues.some((i) => i.severity === "error"),
32760
- beats: parsed.beats,
32761
- issues
32762
- };
32763
- }
32764
- function getStoryboardBeat(markdown, beatId) {
32765
- return parseStoryboard(markdown).beats.find((beat) => beat.id === beatId) ?? null;
32766
- }
32767
- function setStoryboardCue(markdown, opts) {
32768
- const section = findBeatSection(markdown, opts.beatId);
32769
- if (!section) {
32770
- throw new Error(`Beat "${opts.beatId}" not found.`);
32771
- }
32772
- if (!ALLOWED_CUE_KEYS.has(opts.key)) {
32773
- throw new Error(`Unsupported cue "${opts.key}". Supported cues: ${STORYBOARD_CUE_KEYS.join(", ")}.`);
32774
- }
32775
- const cue = readLeadingCueBlock(section.body);
32776
- if (cue?.error) {
32777
- throw new Error(`Cannot edit malformed YAML cues for beat "${opts.beatId}": ${cue.error}`);
32778
- }
32779
- const cues = cue?.value ? { ...cue.value } : {};
32780
- if (opts.unset) {
32781
- delete cues[opts.key];
32782
- } else {
32783
- cues[opts.key] = normalizeCueValue(opts.key, opts.value);
32784
- }
32785
- const remainingBody = cue ? section.body.slice(cue.full.length).replace(/^\s*\n/, "") : section.body.replace(/^\s*\n/, "");
32786
- const cueBlock = Object.keys(cues).length > 0 ? "```yaml\n" + (0, import_yaml5.stringify)(cues, { lineWidth: 0 }).trimEnd() + "\n```\n\n" : "";
32787
- const nextBody = cueBlock + remainingBody.trimStart();
32788
- const nextSection = section.raw.slice(0, section.headingEnd - section.start) + "\n\n" + nextBody.trimEnd() + "\n";
32789
- return markdown.slice(0, section.start) + nextSection + markdown.slice(section.end);
32790
- }
32791
- function moveStoryboardBeat(markdown, opts) {
32792
- if (opts.beatId === opts.afterBeatId) return markdown;
32793
- const sections = splitBeatSections(markdown);
32794
- const movingIndex = sections.findIndex((section) => section.id === opts.beatId);
32795
- if (movingIndex === -1) throw new Error(`Beat "${opts.beatId}" not found.`);
32796
- const afterIndex = sections.findIndex((section) => section.id === opts.afterBeatId);
32797
- if (afterIndex === -1) throw new Error(`Beat "${opts.afterBeatId}" not found.`);
32798
- const global2 = sections.length > 0 ? markdown.slice(0, sections[0].start) : markdown;
32799
- const chunks = sections.map((section) => section.raw);
32800
- const [moving] = chunks.splice(movingIndex, 1);
32801
- const adjustedAfterIndex = movingIndex < afterIndex ? afterIndex - 1 : afterIndex;
32802
- chunks.splice(adjustedAfterIndex + 1, 0, moving);
32803
- return global2 + normalizeBeatChunks(chunks);
32804
- }
32805
- function normalizeBeatChunks(chunks) {
32806
- return chunks.map((chunk) => chunk.trim()).filter(Boolean).join("\n\n") + "\n";
32807
- }
32808
- function normalizeCueValue(key, value) {
32809
- if (key === "duration") {
32810
- const n = typeof value === "number" ? value : Number.parseFloat(String(value ?? ""));
32811
- if (!Number.isFinite(n) || n <= 0) {
32812
- throw new Error("Cue duration must be a positive number.");
32813
- }
32814
- return n;
32815
- }
32816
- if (typeof value !== "string") return value;
32817
- return value.trim();
32818
- }
32819
- function splitBeatSections(markdown) {
32820
- const normalized = markdown.replace(/\r\n/g, "\n");
32821
- const headings = [];
32822
- HEADING_RE2.lastIndex = 0;
32823
- let match;
32824
- while ((match = HEADING_RE2.exec(normalized)) !== null) {
32825
- headings.push({ start: match.index, end: match.index + match[0].length, line: match[1].trim() });
32826
- }
32827
- return headings.map((heading, index) => {
32828
- const end = index + 1 < headings.length ? headings[index + 1].start : normalized.length;
32829
- return {
32830
- id: deriveBeatId(heading.line),
32831
- heading: heading.line,
32832
- start: heading.start,
32833
- headingEnd: heading.end,
32834
- end,
32835
- raw: normalized.slice(heading.start, end),
32836
- body: normalized.slice(heading.end, end).trim()
32837
- };
32838
- });
32839
- }
32840
- function findBeatSection(markdown, beatId) {
32841
- return splitBeatSections(markdown).find((section) => section.id === beatId) ?? null;
32842
- }
32843
- function readLeadingCueBlock(body) {
32844
- const match = body.match(LEADING_CUE_RE);
32845
- if (!match) return null;
32846
- try {
32847
- const parsed = (0, import_yaml5.parse)(match[2]);
32848
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
32849
- return { full: match[0], error: "cue block must parse to a YAML object" };
32850
- }
32851
- return { full: match[0], value: parsed };
32852
- } catch (error) {
32853
- return { full: match[0], error: error instanceof Error ? error.message : String(error) };
32854
- }
32855
- }
32856
- var import_yaml5, STORYBOARD_CUE_KEYS, HEADING_RE2, LEADING_CUE_RE, ALLOWED_CUE_KEYS, STRING_CUE_KEYS, MAX_RECOMMENDED_BEAT_SEC;
32857
- var init_storyboard_edit = __esm({
32858
- "src/commands/_shared/storyboard-edit.ts"() {
32859
- "use strict";
32860
- import_yaml5 = __toESM(require_dist2(), 1);
32861
- init_storyboard_parse();
32862
- STORYBOARD_CUE_KEYS = [
32863
- "duration",
32864
- "narration",
32865
- "backdrop",
32866
- "video",
32867
- "motion",
32868
- "voice",
32869
- "music",
32870
- "asset"
32871
- ];
32872
- HEADING_RE2 = /^##\s+(.+?)\s*$/gm;
32873
- LEADING_CUE_RE = /^(\s*)```ya?ml\s*\n([\s\S]*?)\n```\s*(?:\n|$)/;
32874
- ALLOWED_CUE_KEYS = new Set(STORYBOARD_CUE_KEYS);
32875
- STRING_CUE_KEYS = /* @__PURE__ */ new Set(["narration", "backdrop", "video", "motion", "voice", "music", "asset"]);
32876
- MAX_RECOMMENDED_BEAT_SEC = 15;
32877
- }
32878
- });
32879
-
32880
33550
  // src/commands/_shared/build-plan.ts
32881
33551
  import { existsSync as existsSync17 } from "node:fs";
32882
33552
  import { readFile as readFile17 } from "node:fs/promises";
@@ -32975,6 +33645,7 @@ async function createBuildPlan(opts) {
32975
33645
  const backdropPrompt = stringOrUndefined3(cue.backdrop);
32976
33646
  const augmentedBackdropPrompt = backdropPrompt ? augmentBackdropPrompt(backdropPrompt) : null;
32977
33647
  const videoPrompt = stringOrUndefined3(cue.video);
33648
+ const keyframePrompt = stringOrUndefined3(cue.keyframe);
32978
33649
  const musicPrompt = stringOrUndefined3(cue.music);
32979
33650
  const genericReference = resolveGenericAssetReference(projectDir, cue.asset);
32980
33651
  const narrationReference = resolveTypedAssetReference(projectDir, "narration", cue.narration);
@@ -32983,7 +33654,7 @@ async function createBuildPlan(opts) {
32983
33654
  const musicReference = resolveTypedAssetReference(projectDir, "music", cue.music) ?? (!musicPrompt && genericReference?.kind === "music" ? genericReference : null);
32984
33655
  const narrationCue = narrationText ?? narrationReference?.raw;
32985
33656
  const backdropCue = backdropPrompt ?? backdropReference?.raw;
32986
- const videoCue = videoPrompt ?? videoReference?.raw;
33657
+ const videoCue = videoPrompt ?? videoReference?.raw ?? keyframePrompt;
32987
33658
  const musicCue = musicPrompt ?? musicReference?.raw;
32988
33659
  const narrationCost = resolved.narration.resolved === "elevenlabs" ? ELEVENLABS_NARRATION_COST_USD : resolved.narration.resolved === "openai" ? OPENAI_NARRATION_COST_USD : 0;
32989
33660
  const narrationCache = narrationText && !narrationReference ? narrationCacheDescriptor({
@@ -33001,11 +33672,12 @@ async function createBuildPlan(opts) {
33001
33672
  size: imageSize2,
33002
33673
  ratio: imageRatio
33003
33674
  }) : null;
33004
- const videoCache = videoPrompt && !videoReference ? videoCacheDescriptor({
33675
+ const videoCache = (videoPrompt || keyframePrompt) && !videoReference ? videoCacheDescriptor({
33005
33676
  beatId: beat.id,
33006
- cue: videoPrompt,
33677
+ cue: videoPrompt ?? keyframePrompt,
33007
33678
  provider: resolved.video.resolved,
33008
- duration: beat.duration
33679
+ duration: beat.duration,
33680
+ keyframe: keyframePrompt
33009
33681
  }) : null;
33010
33682
  const musicCache = musicPrompt && !musicReference ? musicCacheDescriptor({
33011
33683
  beatId: beat.id,
@@ -33052,7 +33724,11 @@ async function createBuildPlan(opts) {
33052
33724
  reference: videoReference,
33053
33725
  projectDir,
33054
33726
  force: opts.force,
33055
- cost: VIDEO_COST_USD,
33727
+ cost: resolved.video.resolved === "seedance" || resolved.video.resolved === "fal" ? estimateSeedanceVideoCostUsd({
33728
+ durationSec: normalizeVideoDuration(beat.duration),
33729
+ resolution: "720p",
33730
+ aspectRatio: "16:9"
33731
+ }) : VIDEO_COST_USD,
33056
33732
  active: includeAssets
33057
33733
  }) : null;
33058
33734
  const music = musicCue && !opts.skipMusic ? assetPlan({
@@ -33096,6 +33772,12 @@ async function createBuildPlan(opts) {
33096
33772
  if (backdrop?.willGenerate && isSupportedBuildImageProvider(resolved.image.resolved))
33097
33773
  noteProviderNeed(resolved.image, "Backdrop generation");
33098
33774
  if (video?.willGenerate) noteProviderNeed(resolved.video, "Video generation");
33775
+ if (keyframePrompt && video?.willGenerate) {
33776
+ estimatedCostUsd += backdropCostUsd(imageQuality);
33777
+ providers.add(resolved.image.resolved);
33778
+ if (isSupportedBuildImageProvider(resolved.image.resolved))
33779
+ noteProviderNeed(resolved.image, "Keyframe generation");
33780
+ }
33099
33781
  if (music?.willGenerate) noteProviderNeed(resolved.music, "Music generation");
33100
33782
  if (!compositionExists) missing.add("compositions");
33101
33783
  if (includeCompose && !compositionExists && mode !== "agent") {
@@ -33163,6 +33845,21 @@ async function createBuildPlan(opts) {
33163
33845
  retryWith.push(skipCommand);
33164
33846
  }
33165
33847
  }
33848
+ if (validationOk && includeAssets && !opts.skipVideo) {
33849
+ const referenced = /* @__PURE__ */ new Set();
33850
+ for (const beat of sourceBeats) {
33851
+ for (const name of beatCharacterNames(beat.cues)) referenced.add(name);
33852
+ }
33853
+ if (referenced.size > 0) {
33854
+ const toGenerate = resolveCharacters(parsed.frontmatter).filter(
33855
+ (c) => referenced.has(c.name) && c.prompt && (opts.force || !existsSync17(join15(projectDir, `assets/character-${c.name}.png`)))
33856
+ );
33857
+ if (toGenerate.length > 0) {
33858
+ estimatedCostUsd += toGenerate.length * backdropCostUsd(opts.imageQuality ?? "hd");
33859
+ missing.add("assets");
33860
+ }
33861
+ }
33862
+ }
33166
33863
  return finalizeBuildPlan({
33167
33864
  projectDir,
33168
33865
  config: config2,
@@ -33527,6 +34224,7 @@ var init_build_plan = __esm({
33527
34224
  init_schema();
33528
34225
  init_build_asset_reference();
33529
34226
  init_build_cache();
34227
+ init_dist2();
33530
34228
  init_build_asset_metadata();
33531
34229
  init_build_backdrop_prompt();
33532
34230
  init_composer_resolve();
@@ -33859,7 +34557,7 @@ __export(scene_build_exports, {
33859
34557
  executeSceneBuild: () => executeSceneBuild,
33860
34558
  resolveSceneBuildMode: () => resolveSceneBuildMode
33861
34559
  });
33862
- import { existsSync as existsSync19 } from "node:fs";
34560
+ import { existsSync as existsSync19, readFileSync as readFileSync3 } from "node:fs";
33863
34561
  import { copyFile as copyFile3, mkdir as mkdir10, readFile as readFile18, writeFile as writeFile18 } from "node:fs/promises";
33864
34562
  import { dirname as dirname10, join as join16, resolve as resolve25 } from "node:path";
33865
34563
  async function executeSceneBuild(opts) {
@@ -34026,8 +34724,32 @@ async function executeSceneBuild(opts) {
34026
34724
  const voice = opts.voice ?? parsed.frontmatter?.voice;
34027
34725
  let beatOutcomes = collectExistingBeatOutcomes(parsed.beats, projectDir);
34028
34726
  let pendingJobs = [];
34727
+ let characterCostUsd = 0;
34029
34728
  if (shouldRunStage(selectedStage, "assets")) {
34030
34729
  onProgress({ type: "phase-start", phase: "primitives" });
34730
+ const characterPaths = /* @__PURE__ */ new Map();
34731
+ if (!(opts.skipVideo ?? false)) {
34732
+ const referenced = /* @__PURE__ */ new Set();
34733
+ for (const beat of activeBeats) {
34734
+ for (const name of beatCharacterNames(beat.cues)) referenced.add(name);
34735
+ }
34736
+ if (referenced.size > 0) {
34737
+ const charResult = await buildCharacters(
34738
+ resolveCharacters(parsed.frontmatter).filter((c) => referenced.has(c.name)),
34739
+ {
34740
+ projectDir,
34741
+ imageProvider,
34742
+ imageSize: opts.imageSize ?? "1536x1024",
34743
+ imageQuality: opts.imageQuality ?? "hd",
34744
+ force: opts.force ?? false,
34745
+ onProgress
34746
+ }
34747
+ );
34748
+ for (const [name, path2] of charResult.paths) characterPaths.set(name, path2);
34749
+ characterCostUsd = charResult.costUsd;
34750
+ warnings.push(...charResult.failures);
34751
+ }
34752
+ }
34031
34753
  const primitiveResults = await mapWithConcurrency(
34032
34754
  activeBeats,
34033
34755
  BEAT_PRIMITIVES_CONCURRENCY,
@@ -34045,7 +34767,8 @@ async function executeSceneBuild(opts) {
34045
34767
  skipVideo: opts.skipVideo ?? false,
34046
34768
  skipMusic: opts.skipMusic ?? false,
34047
34769
  force: opts.force ?? false,
34048
- onProgress
34770
+ onProgress,
34771
+ characterPaths
34049
34772
  })
34050
34773
  );
34051
34774
  beatOutcomes = primitiveResults.map((result) => result.outcome);
@@ -34054,10 +34777,7 @@ async function executeSceneBuild(opts) {
34054
34777
  (beat) => beat.narrationStatus === "failed" || beat.backdropStatus === "failed" || beat.videoStatus === "failed" || beat.musicStatus === "failed"
34055
34778
  );
34056
34779
  stageReports.assets.status = assetFailed ? "failed" : pendingJobs.length > 0 ? "pending-jobs" : "done";
34057
- stageReports.assets.costUsd = estimateActualAssetCost(
34058
- beatOutcomes,
34059
- opts.imageQuality ?? "hd"
34060
- );
34780
+ stageReports.assets.costUsd = estimateActualAssetCost(beatOutcomes, opts.imageQuality ?? "hd") + characterCostUsd;
34061
34781
  stageReports.assets.retryWith = pendingJobs.map(
34062
34782
  (job) => `vibe status job ${job.id} --project ${projectDir} --json`
34063
34783
  );
@@ -34222,6 +34942,18 @@ async function executeSceneBuild(opts) {
34222
34942
  stageReports.compose.status = "skipped";
34223
34943
  }
34224
34944
  if (stageReports.compose.status === "done") {
34945
+ for (const beat of activeBeats) {
34946
+ const videoRel = `assets/video-${beat.id}.mp4`;
34947
+ const compositionAbs = join16(projectDir, "compositions", `scene-${beat.id}.html`);
34948
+ if (!existsSync19(join16(projectDir, videoRel)) || !existsSync19(compositionAbs)) continue;
34949
+ const html = readFileSync3(compositionAbs, "utf-8");
34950
+ if (!html.includes(videoRel)) {
34951
+ warnings.push(
34952
+ `Beat "${beat.id}" has a generated clip (${videoRel}) but its composition does not reference it, so the clip will not appear. Recompose the beat.`
34953
+ );
34954
+ retryWith.push(`vibe build ${projectDir} --beat ${beat.id} --stage compose --force --json`);
34955
+ }
34956
+ }
34225
34957
  const repair = await runBuildSceneRepair(projectDir, "compose", false);
34226
34958
  sceneRepair = mergeSceneRepairSummaries(sceneRepair, repair);
34227
34959
  applySceneRepairToStage(stageReports.compose, repair);
@@ -34456,6 +35188,8 @@ async function buildBeatPrimitives(beat, ctx) {
34456
35188
  videoMetadataPath: video.metadataPath,
34457
35189
  videoFreshness: video.freshness,
34458
35190
  videoSourcePath: video.sourcePath,
35191
+ keyframeStatus: video.keyframeStatus,
35192
+ keyframePath: video.keyframePath,
34459
35193
  musicStatus: music.status,
34460
35194
  musicPath: music.path,
34461
35195
  musicJobId: music.job?.id,
@@ -34642,6 +35376,120 @@ async function dispatchNarration(beat, ctx) {
34642
35376
  freshness: "fresh"
34643
35377
  };
34644
35378
  }
35379
+ function characterSheetPrompt(name, description) {
35380
+ return [
35381
+ `Character turnaround reference sheet of an original fictional character named ${name}: ${description}.`,
35382
+ "Show three full-body views side by side on one clean canvas \u2014 front view, side profile, and back view \u2014",
35383
+ "with identical outfit, hairstyle, hair color, and body proportions across all three views.",
35384
+ "Plain light-grey studio background, even neutral lighting, no props.",
35385
+ "Photorealistic digital character, professional concept-art character sheet layout."
35386
+ ].join(" ");
35387
+ }
35388
+ async function buildCharacters(characters, ctx) {
35389
+ const paths = /* @__PURE__ */ new Map();
35390
+ const failures = [];
35391
+ let costUsd = 0;
35392
+ const size = ctx.imageSize ?? "1536x1024";
35393
+ for (const character of characters) {
35394
+ if (character.imagePath) {
35395
+ if (existsSync19(join16(ctx.projectDir, character.imagePath))) {
35396
+ paths.set(character.name, character.imagePath);
35397
+ ctx.onProgress({ type: "character-cached", name: character.name, path: character.imagePath });
35398
+ } else {
35399
+ const error = `character "${character.name}" image not found: ${character.imagePath}`;
35400
+ failures.push(error);
35401
+ ctx.onProgress({ type: "character-failed", name: character.name, error });
35402
+ }
35403
+ continue;
35404
+ }
35405
+ if (!character.prompt) continue;
35406
+ const prompt3 = characterSheetPrompt(character.name, character.prompt);
35407
+ const rel = `assets/character-${character.name}.png`;
35408
+ const abs = join16(ctx.projectDir, rel);
35409
+ const metadataOptions = { quality: ctx.imageQuality, size };
35410
+ const cache = characterCacheDescriptor({
35411
+ name: character.name,
35412
+ cue: prompt3,
35413
+ provider: ctx.imageProvider,
35414
+ quality: ctx.imageQuality,
35415
+ size
35416
+ });
35417
+ if (existsSync19(abs) && !ctx.force) {
35418
+ const metadata = readAssetMetadata(ctx.projectDir, "character", character.name);
35419
+ if (!metadata || isFreshCanonicalAsset({
35420
+ projectDir: ctx.projectDir,
35421
+ kind: "character",
35422
+ beatId: character.name,
35423
+ cue: prompt3,
35424
+ provider: ctx.imageProvider,
35425
+ options: metadataOptions,
35426
+ cacheKey: cache.key
35427
+ })) {
35428
+ paths.set(character.name, rel);
35429
+ ctx.onProgress({ type: "character-cached", name: character.name, path: rel });
35430
+ continue;
35431
+ }
35432
+ }
35433
+ const cacheAbs = join16(ctx.projectDir, cache.path);
35434
+ if (existsSync19(cacheAbs) && !ctx.force) {
35435
+ await mkdir10(dirname10(abs), { recursive: true });
35436
+ await copyFile3(cacheAbs, abs);
35437
+ await writeAssetMetadata({
35438
+ projectDir: ctx.projectDir,
35439
+ kind: "character",
35440
+ beatId: character.name,
35441
+ cue: prompt3,
35442
+ provider: ctx.imageProvider,
35443
+ options: metadataOptions,
35444
+ cacheKey: cache.key,
35445
+ canonicalPath: rel,
35446
+ cachePath: cache.path
35447
+ });
35448
+ paths.set(character.name, rel);
35449
+ ctx.onProgress({ type: "character-cached", name: character.name, path: rel });
35450
+ continue;
35451
+ }
35452
+ const generated = await generateBackdropImage(
35453
+ prompt3,
35454
+ {
35455
+ projectDir: ctx.projectDir,
35456
+ imageProvider: ctx.imageProvider,
35457
+ imageSize: size,
35458
+ imageQuality: ctx.imageQuality
35459
+ },
35460
+ imageRatioForSize(size)
35461
+ );
35462
+ if (!generated.success) {
35463
+ failures.push(`character "${character.name}": ${generated.error}`);
35464
+ ctx.onProgress({ type: "character-failed", name: character.name, error: generated.error });
35465
+ continue;
35466
+ }
35467
+ await mkdir10(dirname10(abs), { recursive: true });
35468
+ await writeFile18(abs, generated.buffer);
35469
+ await mkdir10(dirname10(cacheAbs), { recursive: true });
35470
+ await writeFile18(cacheAbs, generated.buffer);
35471
+ await writeAssetMetadata({
35472
+ projectDir: ctx.projectDir,
35473
+ kind: "character",
35474
+ beatId: character.name,
35475
+ cue: prompt3,
35476
+ provider: ctx.imageProvider,
35477
+ options: metadataOptions,
35478
+ cacheKey: cache.key,
35479
+ canonicalPath: rel,
35480
+ cachePath: cache.path
35481
+ });
35482
+ costUsd += backdropCostUsd(ctx.imageQuality);
35483
+ paths.set(character.name, rel);
35484
+ ctx.onProgress({
35485
+ type: "character-generated",
35486
+ name: character.name,
35487
+ path: rel,
35488
+ provider: ctx.imageProvider
35489
+ });
35490
+ }
35491
+ return { paths, costUsd: Number(costUsd.toFixed(2)), failures };
35492
+ }
34645
35493
  async function dispatchBackdrop(beat, ctx) {
34646
35494
  const reference = assetReferenceForBeat(ctx.projectDir, "backdrop", beat);
34647
35495
  if (reference) return referencePrimitiveOutcome("backdrop", beat, ctx, reference);
@@ -34786,6 +35634,112 @@ async function generateBackdropImage(prompt3, ctx, ratio) {
34786
35634
  });
34787
35635
  return imageBufferFromResult(result);
34788
35636
  }
35637
+ async function editKeyframeImage(prompt3, sheetBuffers, ctx, ratio) {
35638
+ loadSceneBuildEnv(ctx.projectDir);
35639
+ const keyInfo = imageProviderKeyInfo(ctx.imageProvider);
35640
+ const apiKey = await getApiKeyFromConfig(keyInfo.configKey, { cwd: ctx.projectDir }) ?? process.env[keyInfo.envVar] ?? "";
35641
+ if (!apiKey) {
35642
+ return {
35643
+ success: false,
35644
+ error: `${keyInfo.envVar} not set \u2014 cannot generate keyframe with ${ctx.imageProvider}`
35645
+ };
35646
+ }
35647
+ if (ctx.imageProvider === "openai") {
35648
+ const provider2 = new OpenAIImageProvider();
35649
+ await provider2.initialize({ apiKey });
35650
+ const result2 = await provider2.editImage(sheetBuffers, prompt3);
35651
+ return imageBufferFromResult(result2);
35652
+ }
35653
+ if (ctx.imageProvider === "gemini") {
35654
+ const provider2 = new GeminiProvider();
35655
+ await provider2.initialize({ apiKey });
35656
+ const result2 = await provider2.editImage(sheetBuffers, prompt3, {
35657
+ model: "flash",
35658
+ aspectRatio: ratio
35659
+ });
35660
+ return imageBufferFromResult(result2);
35661
+ }
35662
+ const provider = new GrokProvider();
35663
+ await provider.initialize({ apiKey });
35664
+ const result = await provider.editImage(sheetBuffers[0], prompt3, { aspectRatio: ratio });
35665
+ return imageBufferFromResult(result);
35666
+ }
35667
+ async function ensureKeyframe(beat, ctx, keyframeCue, characterRefsRel) {
35668
+ const rel = `assets/keyframe-${beat.id}.png`;
35669
+ const abs = join16(ctx.projectDir, rel);
35670
+ const size = ctx.imageSize ?? "1536x1024";
35671
+ const ratio = imageRatioForSize(size);
35672
+ const metadataOptions = { quality: ctx.imageQuality, size, ratio };
35673
+ const cache = keyframeCacheDescriptor({
35674
+ beatId: beat.id,
35675
+ cue: keyframeCue,
35676
+ provider: ctx.imageProvider,
35677
+ quality: ctx.imageQuality,
35678
+ size,
35679
+ ratio,
35680
+ characters: characterRefsRel
35681
+ });
35682
+ if (existsSync19(abs) && !ctx.force) {
35683
+ const metadata = readAssetMetadata(ctx.projectDir, "keyframe", beat.id);
35684
+ if (!metadata || isFreshCanonicalAsset({
35685
+ projectDir: ctx.projectDir,
35686
+ kind: "keyframe",
35687
+ beatId: beat.id,
35688
+ cue: keyframeCue,
35689
+ provider: ctx.imageProvider,
35690
+ options: metadataOptions,
35691
+ cacheKey: cache.key
35692
+ })) {
35693
+ return { status: "cached", rel };
35694
+ }
35695
+ }
35696
+ const cacheAbs = join16(ctx.projectDir, cache.path);
35697
+ if (existsSync19(cacheAbs) && !ctx.force) {
35698
+ await mkdir10(dirname10(abs), { recursive: true });
35699
+ await copyFile3(cacheAbs, abs);
35700
+ await writeAssetMetadata({
35701
+ projectDir: ctx.projectDir,
35702
+ kind: "keyframe",
35703
+ beatId: beat.id,
35704
+ cue: keyframeCue,
35705
+ provider: ctx.imageProvider,
35706
+ options: metadataOptions,
35707
+ cacheKey: cache.key,
35708
+ canonicalPath: rel,
35709
+ cachePath: cache.path
35710
+ });
35711
+ return { status: "cached", rel };
35712
+ }
35713
+ const sheetBuffers = [];
35714
+ for (const relPath of characterRefsRel) {
35715
+ const sheetAbs = join16(ctx.projectDir, relPath);
35716
+ if (existsSync19(sheetAbs)) sheetBuffers.push(await readFile18(sheetAbs));
35717
+ }
35718
+ const imgCtx = {
35719
+ projectDir: ctx.projectDir,
35720
+ imageProvider: ctx.imageProvider,
35721
+ imageSize: size,
35722
+ imageQuality: ctx.imageQuality
35723
+ };
35724
+ const generated = sheetBuffers.length > 0 ? await editKeyframeImage(keyframeCue, sheetBuffers, imgCtx, ratio) : await generateBackdropImage(keyframeCue, imgCtx, ratio);
35725
+ if (!generated.success) return { status: "failed", error: generated.error };
35726
+ await mkdir10(dirname10(abs), { recursive: true });
35727
+ await writeFile18(abs, generated.buffer);
35728
+ await mkdir10(dirname10(cacheAbs), { recursive: true });
35729
+ await writeFile18(cacheAbs, generated.buffer);
35730
+ await writeAssetMetadata({
35731
+ projectDir: ctx.projectDir,
35732
+ kind: "keyframe",
35733
+ beatId: beat.id,
35734
+ cue: keyframeCue,
35735
+ provider: ctx.imageProvider,
35736
+ options: metadataOptions,
35737
+ cacheKey: cache.key,
35738
+ canonicalPath: rel,
35739
+ cachePath: cache.path
35740
+ });
35741
+ return { status: "generated", rel };
35742
+ }
34789
35743
  async function imageBufferFromResult(result) {
34790
35744
  const image = result.images?.[0];
34791
35745
  if (!result.success || !image) {
@@ -34809,15 +35763,22 @@ function imageProviderKeyInfo(provider) {
34809
35763
  async function dispatchVideo(beat, ctx) {
34810
35764
  const reference = assetReferenceForBeat(ctx.projectDir, "video", beat);
34811
35765
  if (reference) return referencePrimitiveOutcome("video", beat, ctx, reference);
34812
- const prompt3 = stringOrUndefined4(beat.cues?.video);
35766
+ const keyframeCue = stringOrUndefined4(beat.cues?.keyframe);
35767
+ const prompt3 = stringOrUndefined4(beat.cues?.video) ?? keyframeCue;
34813
35768
  if (!prompt3) return { status: "no-cue" };
35769
+ const characterRefsRel = beatCharacterNames(beat.cues).map((name) => ctx.characterPaths?.get(name)).filter((p) => typeof p === "string");
35770
+ const characterRefsAbs = characterRefsRel.map((p) => join16(ctx.projectDir, p));
34814
35771
  const rel = `assets/video-${beat.id}.mp4`;
34815
35772
  const abs = join16(ctx.projectDir, rel);
34816
35773
  const cache = videoCacheDescriptor({
34817
35774
  beatId: beat.id,
34818
35775
  cue: prompt3,
34819
35776
  provider: ctx.videoProvider,
34820
- duration: normalizeVideoDuration(beat.duration)
35777
+ duration: normalizeVideoDuration(beat.duration),
35778
+ // Keyframe mode keys the clip to the keyframe prompt; refs feed the keyframe,
35779
+ // not the clip directly.
35780
+ characters: keyframeCue ? void 0 : characterRefsRel,
35781
+ keyframe: keyframeCue
34821
35782
  });
34822
35783
  const metadataPath = assetMetadataPath("video", beat.id);
34823
35784
  if (existsSync19(abs) && !ctx.force) {
@@ -34870,6 +35831,19 @@ async function dispatchVideo(beat, ctx) {
34870
35831
  };
34871
35832
  }
34872
35833
  loadSceneBuildEnv(ctx.projectDir);
35834
+ let keyframeStatus;
35835
+ let keyframeRel;
35836
+ let keyframeImageAbs;
35837
+ if (keyframeCue) {
35838
+ const kf = await ensureKeyframe(beat, ctx, keyframeCue, characterRefsRel);
35839
+ if (kf.status === "failed") {
35840
+ ctx.onProgress({ type: "video-failed", beatId: beat.id, error: kf.error });
35841
+ return { status: "failed", error: kf.error };
35842
+ }
35843
+ keyframeStatus = kf.status;
35844
+ keyframeRel = kf.rel;
35845
+ keyframeImageAbs = join16(ctx.projectDir, kf.rel);
35846
+ }
34873
35847
  const result = await executeVideoGenerate({
34874
35848
  prompt: prompt3,
34875
35849
  provider: ctx.videoProvider,
@@ -34877,6 +35851,10 @@ async function dispatchVideo(beat, ctx) {
34877
35851
  ratio: "16:9",
34878
35852
  output: abs,
34879
35853
  wait: false,
35854
+ // Keyframe mode → single init frame (image-to-video); otherwise character
35855
+ // reference-to-video.
35856
+ image: keyframeImageAbs,
35857
+ refImages: keyframeImageAbs ? void 0 : characterRefsAbs.length > 0 ? characterRefsAbs : void 0,
34880
35858
  apiKey: await apiKeyForVideoProvider(ctx.videoProvider, ctx.projectDir)
34881
35859
  });
34882
35860
  if (!result.success || !result.taskId) {
@@ -34911,14 +35889,16 @@ async function dispatchVideo(beat, ctx) {
34911
35889
  cachePath: cache.path,
34912
35890
  cacheKey: cache.key,
34913
35891
  metadataPath,
34914
- freshness: "fresh"
35892
+ freshness: "fresh",
35893
+ keyframeStatus,
35894
+ keyframePath: keyframeRel
34915
35895
  };
34916
35896
  }
34917
35897
  const job = await createAndWriteJobRecord({
34918
35898
  jobType: "generate-video",
34919
35899
  provider: result.provider ?? ctx.videoProvider,
34920
35900
  providerTaskId: result.taskId,
34921
- providerTaskType: "text2video",
35901
+ providerTaskType: keyframeImageAbs ? "image2video" : "text2video",
34922
35902
  status: "running",
34923
35903
  projectDir: ctx.projectDir,
34924
35904
  workingDirectory: ctx.projectDir,
@@ -34943,7 +35923,9 @@ async function dispatchVideo(beat, ctx) {
34943
35923
  provider: job.provider,
34944
35924
  cachePath: cache.path,
34945
35925
  cacheKey: cache.key,
34946
- metadataPath
35926
+ metadataPath,
35927
+ keyframeStatus,
35928
+ keyframePath: keyframeRel
34947
35929
  };
34948
35930
  }
34949
35931
  async function dispatchMusic(beat, ctx) {
@@ -35245,7 +36227,14 @@ function estimateActualAssetCost(outcomes, imageQuality) {
35245
36227
  for (const outcome of outcomes) {
35246
36228
  if (outcome.narrationStatus === "generated") cost += 0.05;
35247
36229
  if (outcome.backdropStatus === "generated") cost += backdropCostUsd(imageQuality);
35248
- if (outcome.videoStatus === "generated" || outcome.videoStatus === "pending") cost += 5;
36230
+ if (outcome.keyframeStatus === "generated") cost += backdropCostUsd(imageQuality);
36231
+ if (outcome.videoStatus === "generated" || outcome.videoStatus === "pending") {
36232
+ cost += outcome.videoProvider === "seedance" || outcome.videoProvider === "fal" ? estimateSeedanceVideoCostUsd({
36233
+ durationSec: normalizeVideoDuration(outcome.sceneDurationSec),
36234
+ resolution: "720p",
36235
+ aspectRatio: "16:9"
36236
+ }) : 5;
36237
+ }
35249
36238
  if (outcome.musicStatus === "generated" || outcome.musicStatus === "pending") cost += 0.5;
35250
36239
  }
35251
36240
  return Number(cost.toFixed(2));
@@ -45366,10 +46355,18 @@ Examples:
45366
46355
  options.ratio = "16:9";
45367
46356
  }
45368
46357
  if (options.dryRun) {
46358
+ const costUsd = provider === "seedance" || provider === "fal" ? estimateSeedanceVideoCostUsd({
46359
+ durationSec: Number(options.duration) || 5,
46360
+ resolution: options.resolution,
46361
+ aspectRatio: options.ratio,
46362
+ fast: options.seedanceModel === "fast",
46363
+ hasVideoReference: Boolean(options.refVideos)
46364
+ }) : void 0;
45369
46365
  outputSuccess({
45370
46366
  command: "generate video",
45371
46367
  startedAt,
45372
46368
  dryRun: true,
46369
+ costUsd,
45373
46370
  data: {
45374
46371
  params: {
45375
46372
  prompt: prompt3,
@@ -51847,6 +52844,9 @@ function clean(text) {
51847
52844
  function humanise(id) {
51848
52845
  return id.split(/[-_]+/).filter(Boolean).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
51849
52846
  }
52847
+ function sceneCompositionId(beatId) {
52848
+ return `scene-${beatId}`;
52849
+ }
51850
52850
  function slugifySceneName(name) {
51851
52851
  const normalised = name.normalize("NFKD").replace(/[̀-ͯ]/g, "");
51852
52852
  const slug = normalised.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
@@ -51884,10 +52884,16 @@ function fitKineticFontSize(words) {
51884
52884
  function kenBurnsTween(scope, dur, endScale = 1.08) {
51885
52885
  return `tl.fromTo('${scope} .backdrop', { scale: 1.0 }, { scale: ${endScale}, duration: ${dur.toFixed(2)}, ease: 'none' }, 0);`;
51886
52886
  }
52887
+ function backdropMotionTweens(scope, dur, hasImage, endScale = 1.08) {
52888
+ const sweep = hasImage ? "" : `
52889
+ tl.fromTo('${scope} .ambient-sweep', { xPercent: -75, opacity: 0.18 }, { xPercent: 75, opacity: 0.62, duration: ${dur.toFixed(2)}, ease: 'sine.inOut' }, 0);`;
52890
+ return `${kenBurnsTween(scope, dur, endScale)}${sweep}`;
52891
+ }
51887
52892
  function buildPreset(input) {
51888
52893
  const id = input.id;
51889
52894
  const scope = `[data-composition-id="${id}"]`;
51890
- const headline = clean(input.headline) || humanise(id);
52895
+ const displayId = input.beatId ?? id;
52896
+ const headline = clean(input.headline) || humanise(displayId);
51891
52897
  const subhead = clean(input.subhead);
51892
52898
  const kicker = clean(input.kicker);
51893
52899
  const dur = input.duration;
@@ -51910,8 +52916,17 @@ function buildPreset(input) {
51910
52916
  position: absolute;
51911
52917
  inset: 0;
51912
52918
  background: radial-gradient(ellipse at center, #1a1a2e 0%, #000 70%);
52919
+ overflow: hidden;
52920
+ }
52921
+ ${scope} .ambient-sweep {
52922
+ position: absolute;
52923
+ inset: -22%;
52924
+ display: block;
52925
+ background: linear-gradient(115deg, rgba(255,255,255,0) 20%, rgba(0,201,255,0.34) 48%, rgba(142,45,226,0.26) 58%, rgba(255,255,255,0) 78%);
52926
+ filter: blur(12px);
52927
+ transform: rotate(8deg);
51913
52928
  }`;
51914
- const backdropMarkup = `<div class="backdrop"></div>`;
52929
+ const backdropMarkup = hasImage ? `<div class="backdrop"></div>` : `<div class="backdrop"><div class="ambient-sweep"></div></div>`;
51915
52930
  switch (input.preset) {
51916
52931
  case "simple": {
51917
52932
  const transcript = input.transcript;
@@ -51943,7 +52958,7 @@ function buildPreset(input) {
51943
52958
  body: `${backdropMarkup}
51944
52959
  <div class="caption" id="caption">${captionInner}</div>`,
51945
52960
  timeline: `${crossfadeTweens(scope, dur)}
51946
- ${kenBurnsTween(scope, dur)}
52961
+ ${backdropMotionTweens(scope, dur, hasImage)}
51947
52962
  ${captionTween}`
51948
52963
  };
51949
52964
  }
@@ -51982,12 +52997,12 @@ function buildPreset(input) {
51982
52997
  // scene's own fade-in handles the visual transition. Backdrop
51983
52998
  // Ken-Burns fills what used to be 80% dead static frame.
51984
52999
  timeline: `${crossfadeTweens(scope, dur)}
51985
- ${kenBurnsTween(scope, dur)}
53000
+ ${backdropMotionTweens(scope, dur, hasImage)}
51986
53001
  tl.from('${scope} #headline', { opacity: 0, scale: 0.92, duration: 0.55, ease: 'power3.out' }, 0.05);`
51987
53002
  };
51988
53003
  }
51989
53004
  case "explainer": {
51990
- const k = kicker || humanise(id).toUpperCase();
53005
+ const k = kicker || humanise(displayId).toUpperCase();
51991
53006
  const sub = subhead || "";
51992
53007
  const transcript = input.transcript;
51993
53008
  const useWordSync = !!(transcript && transcript.length > 0 && sub);
@@ -52029,7 +53044,7 @@ function buildPreset(input) {
52029
53044
  <div class="subtitle" id="subtitle">${subtitleInner}</div>` : ""}
52030
53045
  </div>`,
52031
53046
  timeline: `${crossfadeTweens(scope, dur)}
52032
- ${kenBurnsTween(scope, dur)}
53047
+ ${backdropMotionTweens(scope, dur, hasImage)}
52033
53048
  tl.from('${scope} #kicker', { opacity: 0, y: 16, duration: 0.4, ease: 'power2.out' }, 0.1);
52034
53049
  tl.from('${scope} #title', { opacity: 0, y: 60, duration: 0.7, ease: 'power3.out' }, 0.25);
52035
53050
  ${subtitleTween}`
@@ -52070,12 +53085,12 @@ function buildPreset(input) {
52070
53085
  body: `${backdropMarkup}
52071
53086
  <div class="kinetic">${wordSpans}</div>`,
52072
53087
  timeline: `${crossfadeTweens(scope, dur)}
52073
- ${kenBurnsTween(scope, dur)}
53088
+ ${backdropMotionTweens(scope, dur, hasImage)}
52074
53089
  ${tweens}`
52075
53090
  };
52076
53091
  }
52077
53092
  case "product-shot": {
52078
- const label = kicker || humanise(id);
53093
+ const label = kicker || humanise(displayId);
52079
53094
  const headlineFontPx = fitFontSize(headline, 72, 40, 50);
52080
53095
  return {
52081
53096
  css: `${scope} {
@@ -52109,7 +53124,7 @@ function buildPreset(input) {
52109
53124
  <div class="product-headline" id="headline">${esc(headline)}</div>${subhead ? `
52110
53125
  <div class="product-sub" id="subhead">${esc(subhead)}</div>` : ""}`,
52111
53126
  timeline: `${crossfadeTweens(scope, dur)}
52112
- tl.fromTo('${scope} .backdrop', { scale: 1.0 }, { scale: 1.12, duration: ${dur.toFixed(2)}, ease: 'none' }, 0);
53127
+ ${backdropMotionTweens(scope, dur, hasImage, 1.12)}
52113
53128
  tl.from('${scope} #label', { opacity: 0, x: -30, duration: 0.5, ease: 'power3.out' }, 0.2);
52114
53129
  tl.from('${scope} #headline', { opacity: 0, y: 40, duration: 0.6, ease: 'power3.out' }, 0.4);${subhead ? `
52115
53130
  tl.from('${scope} #subhead', { opacity: 0, y: 20, duration: 0.5, ease: 'power3.out' }, 0.65);` : ""}`
@@ -52174,9 +53189,10 @@ function emitSceneHtml(input) {
52174
53189
  if (input.width <= 0 || input.height <= 0) {
52175
53190
  throw new Error(`Invalid canvas dims: ${input.width}x${input.height}`);
52176
53191
  }
52177
- const id = input.id;
53192
+ const beatId = input.id;
53193
+ const id = sceneCompositionId(beatId);
52178
53194
  const dur = Number(input.duration.toFixed(3));
52179
- const parts = buildPreset({ ...input, id, duration: dur });
53195
+ const parts = buildPreset({ ...input, id, beatId, duration: dur });
52180
53196
  const audioBlock = input.audioPath ? `
52181
53197
  <audio
52182
53198
  id="narration"
@@ -52192,7 +53208,7 @@ function emitSceneHtml(input) {
52192
53208
  ${lottieLayer.markup}` : "";
52193
53209
  const lottieScript = lottieLayer ? `
52194
53210
  ${lottieLayer.script}` : "";
52195
- return `<template id="scene-${id}-template">
53211
+ return `<template id="${id}-template">
52196
53212
  <div data-composition-id="${id}" data-start="0" data-duration="${dur}" data-width="${input.width}" data-height="${input.height}">
52197
53213
  <style>
52198
53214
  ${parts.css}
@@ -52228,8 +53244,9 @@ function buildClipReference(opts) {
52228
53244
  const duration = Number(opts.duration.toFixed(3));
52229
53245
  const track = opts.trackIndex ?? 1;
52230
53246
  const src = opts.src ?? `compositions/scene-${opts.id}.html`;
53247
+ const compositionId = sceneCompositionId(opts.id);
52231
53248
  const zIndex = Math.max(1, 1e6 - Math.floor(start * 1e3));
52232
- return `<div class="clip" data-composition-id="${esc(opts.id)}" data-composition-src="${esc(src)}" data-start="${start}" data-duration="${duration}" data-track-index="${track}" style="z-index: ${zIndex};"></div>`;
53249
+ return `<div class="clip" data-composition-id="${esc(compositionId)}" data-composition-src="${esc(src)}" data-start="${start}" data-duration="${duration}" data-track-index="${track}" style="z-index: ${zIndex};"></div>`;
52233
53250
  }
52234
53251
  function insertClipIntoRoot(rootHtml, clip) {
52235
53252
  const clipDiv = buildClipReference(clip);
@@ -52273,9 +53290,9 @@ init_agent_host_detect();
52273
53290
  // src/commands/_shared/install-skill.ts
52274
53291
  init_bundle_content();
52275
53292
  init_bundle();
52276
- import { mkdir as mkdir5, writeFile as writeFile9 } from "node:fs/promises";
53293
+ import { mkdir as mkdir5, writeFile as writeFile10 } from "node:fs/promises";
52277
53294
  import { existsSync as existsSync7 } from "node:fs";
52278
- import { dirname as dirname4, join as join6, relative as relative3, resolve as resolve9 } from "node:path";
53295
+ import { dirname as dirname4, join as join7, relative as relative3, resolve as resolve10 } from "node:path";
52279
53296
  function universalFiles() {
52280
53297
  return [
52281
53298
  { relPath: "SKILL.md", content: SKILL_MD },
@@ -52325,7 +53342,7 @@ function selectHostFiles(hosts) {
52325
53342
  return out;
52326
53343
  }
52327
53344
  async function installHyperframesSkill(opts) {
52328
- const projectDir = resolve9(opts.projectDir);
53345
+ const projectDir = resolve10(opts.projectDir);
52329
53346
  const force = opts.force ?? false;
52330
53347
  const dryRun = opts.dryRun ?? false;
52331
53348
  const files = [...universalFiles(), ...selectHostFiles(opts.hosts)];
@@ -52334,7 +53351,7 @@ async function installHyperframesSkill(opts) {
52334
53351
  await mkdir5(projectDir, { recursive: true });
52335
53352
  }
52336
53353
  for (const file of files) {
52337
- const absPath = join6(projectDir, file.relPath);
53354
+ const absPath = join7(projectDir, file.relPath);
52338
53355
  const exists = existsSync7(absPath);
52339
53356
  const bytes = Buffer.byteLength(file.content, "utf-8");
52340
53357
  if (exists && !force) {
@@ -52350,7 +53367,7 @@ async function installHyperframesSkill(opts) {
52350
53367
  continue;
52351
53368
  }
52352
53369
  await mkdir5(dirname4(absPath), { recursive: true });
52353
- await writeFile9(absPath, file.content, "utf-8");
53370
+ await writeFile10(absPath, file.content, "utf-8");
52354
53371
  actions.push({ path: file.relPath, status: "wrote", bytes });
52355
53372
  }
52356
53373
  return {
@@ -52375,10 +53392,10 @@ init_scene_lint();
52375
53392
  init_storyboard_parse();
52376
53393
  init_root_sync();
52377
53394
  import { existsSync as existsSync11 } from "node:fs";
52378
- import { mkdir as mkdir7, readFile as readFile11, writeFile as writeFile12 } from "node:fs/promises";
52379
- import { join as join10, resolve as resolve13 } from "node:path";
53395
+ import { mkdir as mkdir7, readFile as readFile11, writeFile as writeFile13 } from "node:fs/promises";
53396
+ import { join as join11, resolve as resolve14 } from "node:path";
52380
53397
  async function executeSceneSubmit(opts) {
52381
- const projectDir = resolve13(opts.projectDir);
53398
+ const projectDir = resolve14(opts.projectDir);
52382
53399
  const beatId = opts.beatId.trim();
52383
53400
  const scenePath = `compositions/scene-${beatId}.html`;
52384
53401
  const base = {
@@ -52390,7 +53407,7 @@ async function executeSceneSubmit(opts) {
52390
53407
  warnings: [],
52391
53408
  retryWith: []
52392
53409
  };
52393
- const storyboardPath = join10(projectDir, "STORYBOARD.md");
53410
+ const storyboardPath = join11(projectDir, "STORYBOARD.md");
52394
53411
  if (!existsSync11(storyboardPath)) {
52395
53412
  return {
52396
53413
  ...base,
@@ -52468,9 +53485,9 @@ async function executeSceneSubmit(opts) {
52468
53485
  if (opts.validateOnly) {
52469
53486
  return { ...base, success: true };
52470
53487
  }
52471
- const absPath = join10(projectDir, scenePath);
52472
- await mkdir7(join10(projectDir, "compositions"), { recursive: true });
52473
- await writeFile12(absPath, html.endsWith("\n") ? html : `${html}
53488
+ const absPath = join11(projectDir, scenePath);
53489
+ await mkdir7(join11(projectDir, "compositions"), { recursive: true });
53490
+ await writeFile13(absPath, html.endsWith("\n") ? html : `${html}
52474
53491
  `, "utf-8");
52475
53492
  return {
52476
53493
  ...base,
@@ -52485,6 +53502,8 @@ async function executeSceneSubmit(opts) {
52485
53502
 
52486
53503
  // src/commands/scene.ts
52487
53504
  init_scene_repair();
53505
+ init_root_sync();
53506
+ init_storyboard_edit();
52488
53507
  function validateDuration(value) {
52489
53508
  const n = parseFloat(value);
52490
53509
  if (!Number.isFinite(n) || n <= 0 || n > 3600) {
@@ -52787,7 +53806,10 @@ sceneCommand.command("add").description("Add a new scene to a project: AI narrat
52787
53806
  ).option("-d, --duration <sec>", "Explicit scene duration in seconds (overrides narration audio)").option(
52788
53807
  "--visuals <prompt>",
52789
53808
  "Image prompt \u2014 generates assets/scene-<id>.png via the configured image provider"
52790
- ).option("--headline <text>", "Visible headline (defaults to the humanised scene name)").option("--kicker <text>", "Small label above the headline (explainer / product-shot)").option("--insert-into <path>", "Root composition file to update", "index.html").option("--project <dir>", "Project directory", ".").option("--image-provider <name>", "Image provider: gemini, openai", "gemini").option(
53809
+ ).option("--headline <text>", "Visible headline (defaults to the humanised scene name)").option("--kicker <text>", "Small label above the headline (explainer / product-shot)").option("--insert-into <path>", "Root composition file to update", "index.html").option("--project <dir>", "Project directory", ".").option(
53810
+ "--no-storyboard",
53811
+ "Do not sync STORYBOARD.md; insert this scene directly into the root composition only"
53812
+ ).option("--image-provider <name>", "Image provider: gemini, openai", "gemini").option(
52791
53813
  "--tts <provider>",
52792
53814
  "TTS provider: auto, elevenlabs, openai, kokoro (default auto \u2014 ElevenLabs key > OpenAI key > Kokoro local)",
52793
53815
  "auto"
@@ -52856,6 +53878,7 @@ sceneCommand.command("add").description("Add a new scene to a project: AI narrat
52856
53878
  kicker: options.kicker,
52857
53879
  project: options.project,
52858
53880
  insertInto: options.insertInto,
53881
+ syncStoryboard: options.storyboard !== false,
52859
53882
  imageProvider: options.imageProvider,
52860
53883
  tts,
52861
53884
  audio: options.audio,
@@ -52883,6 +53906,7 @@ sceneCommand.command("add").description("Add a new scene to a project: AI narrat
52883
53906
  kicker: options.kicker,
52884
53907
  projectDir: options.project,
52885
53908
  insertInto: options.insertInto,
53909
+ syncStoryboard: options.storyboard !== false,
52886
53910
  imageProvider: options.imageProvider,
52887
53911
  tts,
52888
53912
  voice: options.voice,
@@ -52922,6 +53946,13 @@ sceneCommand.command("add").description("Add a new scene to a project: AI narrat
52922
53946
  if (result.audioPath) console.log(source_default.green(" +"), result.audioPath);
52923
53947
  if (result.imagePath) console.log(source_default.green(" +"), result.imagePath);
52924
53948
  if (result.transcriptPath) console.log(source_default.green(" +"), result.transcriptPath);
53949
+ if (result.storyboardPath && result.storyboardSynced) {
53950
+ console.log(
53951
+ result.storyboardAction === "updated" ? source_default.yellow(" ~") : source_default.green(" +"),
53952
+ result.storyboardPath,
53953
+ source_default.dim(`(${result.storyboardAction})`)
53954
+ );
53955
+ }
52925
53956
  console.log(source_default.yellow(" ~"), result.rootPath, source_default.dim("(updated)"));
52926
53957
  console.log();
52927
53958
  console.log(source_default.bold.cyan("Composition"));
@@ -52968,6 +53999,21 @@ async function loadVibeProjectConfig(projectDir) {
52968
53999
  }
52969
54000
  };
52970
54001
  }
54002
+ function escapeRegExp3(value) {
54003
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
54004
+ }
54005
+ function readClipStart(rootHtml, beatId) {
54006
+ const compositionId = sceneCompositionId(beatId);
54007
+ const match = rootHtml.match(
54008
+ new RegExp(
54009
+ `<div\\s+class="clip"[^>]*\\bdata-composition-id="${escapeRegExp3(compositionId)}"[^>]*\\bdata-start="([\\d.]+)"`,
54010
+ "i"
54011
+ )
54012
+ );
54013
+ if (!match) return void 0;
54014
+ const start = Number.parseFloat(match[1]);
54015
+ return Number.isFinite(start) ? start : void 0;
54016
+ }
52971
54017
  async function resolveNarrationText(value) {
52972
54018
  if (!value) return void 0;
52973
54019
  const looksLikePath = /\.[a-z]{2,4}$/i.test(value) || value.includes("/") || value.includes("\\");
@@ -52994,6 +54040,7 @@ async function executeSceneAdd(opts) {
52994
54040
  const id = slugifySceneName(opts.name);
52995
54041
  const sceneRel = `compositions/scene-${id}.html`;
52996
54042
  const scenePath = resolve17(projectDir, sceneRel);
54043
+ const storyboardAbsPath = resolve17(projectDir, "STORYBOARD.md");
52997
54044
  const errResult = (error) => ({
52998
54045
  success: false,
52999
54046
  id,
@@ -53213,6 +54260,27 @@ async function executeSceneAdd(opts) {
53213
54260
  duration = fallback2;
53214
54261
  }
53215
54262
  duration = Number(duration.toFixed(2));
54263
+ let storyboardPath;
54264
+ let storyboardSynced = false;
54265
+ let storyboardAction = "skipped";
54266
+ const shouldSyncStoryboard = opts.syncStoryboard !== false && await pathExists2(storyboardAbsPath);
54267
+ if (shouldSyncStoryboard) {
54268
+ opts.onProgress?.("Syncing STORYBOARD.md...");
54269
+ const before = await readFile14(storyboardAbsPath, "utf-8");
54270
+ const synced = upsertStoryboardBeat(before, {
54271
+ beatId: id,
54272
+ title: opts.headline,
54273
+ duration,
54274
+ narration: narrationText && !opts.skipAudio ? narrationText : void 0,
54275
+ backdrop: opts.visuals && !opts.skipImage ? opts.visuals : void 0
54276
+ });
54277
+ if (synced.markdown !== before) {
54278
+ await writeFile15(storyboardAbsPath, synced.markdown, "utf-8");
54279
+ }
54280
+ storyboardPath = relative6(process.cwd(), storyboardAbsPath) || storyboardAbsPath;
54281
+ storyboardSynced = true;
54282
+ storyboardAction = synced.action;
54283
+ }
53216
54284
  opts.onProgress?.("Emitting scene HTML...");
53217
54285
  const sceneHtml = emitSceneHtml({
53218
54286
  id,
@@ -53231,11 +54299,24 @@ async function executeSceneAdd(opts) {
53231
54299
  await mkdir8(dirname6(scenePath), { recursive: true });
53232
54300
  await writeFile15(scenePath, sceneHtml, "utf-8");
53233
54301
  opts.onProgress?.("Updating root composition...");
53234
- const start = nextSceneStart(rootHtmlBefore, SCENE_OVERLAP_SECONDS);
53235
- const existingClipCount = (rootHtmlBefore.match(/<div\s+class="clip"/g) || []).length;
53236
- const trackIndex = existingClipCount % 2 + 1;
53237
- const updated = insertClipIntoRoot(rootHtmlBefore, { id, start, duration, trackIndex });
53238
- await writeFile15(rootPath, updated, "utf-8");
54302
+ let start = 0;
54303
+ if (storyboardSynced) {
54304
+ const syncPlan = await syncRootComposition({
54305
+ projectDir,
54306
+ rootRel,
54307
+ beats: await loadProjectRootSyncBeats(projectDir)
54308
+ });
54309
+ const blockingIssue = syncPlan.issues.find((issue) => issue.severity === "error");
54310
+ if (blockingIssue) return errResult(blockingIssue.message);
54311
+ const rootHtmlAfterSync = await readFile14(rootPath, "utf-8");
54312
+ start = readClipStart(rootHtmlAfterSync, id) ?? 0;
54313
+ } else {
54314
+ start = nextSceneStart(rootHtmlBefore, SCENE_OVERLAP_SECONDS);
54315
+ const existingClipCount = (rootHtmlBefore.match(/<div\s+class="clip"/g) || []).length;
54316
+ const trackIndex = existingClipCount % 2 + 1;
54317
+ const updated = insertClipIntoRoot(rootHtmlBefore, { id, start, duration, trackIndex });
54318
+ await writeFile15(rootPath, updated, "utf-8");
54319
+ }
53239
54320
  const transcriptAbsPath = transcriptRelPath ? resolve17(projectDir, transcriptRelPath) : void 0;
53240
54321
  return {
53241
54322
  success: true,
@@ -53248,6 +54329,9 @@ async function executeSceneAdd(opts) {
53248
54329
  audioPath: audioAbsPath ? relative6(process.cwd(), audioAbsPath) || audioAbsPath : void 0,
53249
54330
  imagePath: imageAbsPath ? relative6(process.cwd(), imageAbsPath) || imageAbsPath : void 0,
53250
54331
  lottiePath: lottieAbsPath ? relative6(process.cwd(), lottieAbsPath) || lottieAbsPath : void 0,
54332
+ storyboardPath,
54333
+ storyboardSynced,
54334
+ storyboardAction,
53251
54335
  transcriptPath: transcriptAbsPath ? relative6(process.cwd(), transcriptAbsPath) || transcriptAbsPath : void 0,
53252
54336
  transcriptWordCount
53253
54337
  };
@@ -53545,6 +54629,9 @@ var sceneAddSchema = external_exports.object({
53545
54629
  kicker: external_exports.string().optional().describe("Small label above the headline (used by 'explainer' and 'product-shot' presets)."),
53546
54630
  projectDir: external_exports.string().optional().describe(PROJECT_DIR_DESCRIPTION),
53547
54631
  insertInto: external_exports.string().optional().describe("Root composition file (relative to projectDir). Default 'index.html'."),
54632
+ syncStoryboard: external_exports.boolean().optional().describe(
54633
+ "Sync STORYBOARD.md when present, then rebuild root refs from it. Default true; set false for root-only insertion."
54634
+ ),
53548
54635
  imageProvider: external_exports.enum(["gemini", "openai"]).optional().describe("Image provider for visuals. Default 'gemini'."),
53549
54636
  voice: external_exports.string().optional().describe("ElevenLabs voice id or name."),
53550
54637
  skipAudio: external_exports.boolean().optional().describe("Skip TTS even if narration is provided."),
@@ -53557,7 +54644,7 @@ var sceneAddTool = defineTool({
53557
54644
  cost: "low",
53558
54645
  title: "Add Scene from Preset",
53559
54646
  annotations: { readOnly: false, openWorld: true },
53560
- description: "Add a single scene to an existing scene project. Optionally generates narration audio (ElevenLabs) and/or a backdrop image (Gemini/OpenAI), then emits compositions/scene-<id>.html with a paused GSAP timeline and splices a clip reference into the root index.html. Use skipAudio:true and skipImage:true for text-only scenes that need no API calls.",
54647
+ description: "Add a single scene to an existing scene project. Optionally generates narration audio (ElevenLabs) and/or a backdrop image (Gemini/OpenAI), then emits compositions/scene-<id>.html with a paused GSAP timeline. By default, syncs STORYBOARD.md and rebuilds root refs from it; set syncStoryboard:false for root-only insertion. Use skipAudio:true and skipImage:true for text-only scenes that need no API calls.",
53561
54648
  schema: sceneAddSchema,
53562
54649
  async execute(args, ctx) {
53563
54650
  const projectDir = args.projectDir ? resolve26(ctx.workingDirectory, args.projectDir) : ctx.workingDirectory;
@@ -53571,6 +54658,7 @@ var sceneAddTool = defineTool({
53571
54658
  kicker: args.kicker,
53572
54659
  projectDir,
53573
54660
  insertInto: args.insertInto,
54661
+ syncStoryboard: args.syncStoryboard,
53574
54662
  imageProvider: args.imageProvider,
53575
54663
  voice: args.voice,
53576
54664
  skipAudio: args.skipAudio,
@@ -53589,6 +54677,9 @@ var sceneAddTool = defineTool({
53589
54677
  ];
53590
54678
  if (result.audioPath) lines.push(` audio: ${result.audioPath}`);
53591
54679
  if (result.imagePath) lines.push(` image: ${result.imagePath}`);
54680
+ if (result.storyboardPath && result.storyboardSynced) {
54681
+ lines.push(` story: ${result.storyboardPath} (${result.storyboardAction})`);
54682
+ }
53592
54683
  return {
53593
54684
  success: true,
53594
54685
  data: {
@@ -53599,7 +54690,10 @@ var sceneAddTool = defineTool({
53599
54690
  scenePath: result.scenePath,
53600
54691
  rootPath: result.rootPath,
53601
54692
  audioPath: result.audioPath,
53602
- imagePath: result.imagePath
54693
+ imagePath: result.imagePath,
54694
+ storyboardPath: result.storyboardPath,
54695
+ storyboardSynced: result.storyboardSynced,
54696
+ storyboardAction: result.storyboardAction
53603
54697
  },
53604
54698
  humanLines: lines
53605
54699
  };
@@ -55260,7 +56354,11 @@ async function inspectProject(opts) {
55260
56354
  );
55261
56355
  }
55262
56356
  function makeProjectResult(projectDir, issues, checks, retryWith, beatId) {
55263
- const normalizedIssues = normalizeReviewIssues(issues);
56357
+ const normalizedRetryWith = uniqueRetryWith(retryWith);
56358
+ const normalizedIssues = normalizeReviewIssues(issues, {
56359
+ projectDir,
56360
+ retryWith: normalizedRetryWith
56361
+ });
55264
56362
  const status = statusFromIssues(normalizedIssues);
55265
56363
  return {
55266
56364
  schemaVersion: "1",
@@ -55272,9 +56370,14 @@ function makeProjectResult(projectDir, issues, checks, retryWith, beatId) {
55272
56370
  score: scoreIssues(normalizedIssues),
55273
56371
  issues: normalizedIssues,
55274
56372
  summary: summarizeReviewIssues(normalizedIssues),
56373
+ nextActions: deriveNextReviewActions({
56374
+ issues: normalizedIssues,
56375
+ retryWith: normalizedRetryWith,
56376
+ projectDir
56377
+ }),
55275
56378
  sourceReports: projectSourceReports(checks),
55276
56379
  checks,
55277
- retryWith: uniqueRetryWith(retryWith)
56380
+ retryWith: normalizedRetryWith
55278
56381
  };
55279
56382
  }
55280
56383
  function storyboardPlaceholderIssues(storyboard) {
@@ -55826,6 +56929,8 @@ async function inspectRender(opts) {
55826
56929
  severity: "error",
55827
56930
  code: "RENDER_NOT_FOUND",
55828
56931
  message: opts.beatId ? `No rendered video was found for beat "${opts.beatId}". Pass --video or render the beat first.` : "No rendered video was found. Pass --video or render the project first.",
56932
+ beatId: opts.beatId,
56933
+ scene: opts.beatId,
55829
56934
  suggestedFix: opts.beatId ? "Run `vibe render <project> --beat <id> --json`." : "Run `vibe build --stage render --json` or `vibe render --json`."
55830
56935
  });
55831
56936
  retryWith.push(
@@ -55848,6 +56953,8 @@ async function inspectRender(opts) {
55848
56953
  code: "EMPTY_RENDER",
55849
56954
  message: "Rendered video file is empty.",
55850
56955
  file: displayPath(videoPath),
56956
+ beatId: opts.beatId,
56957
+ scene: opts.beatId,
55851
56958
  suggestedFix: opts.beatId ? "Render again with `vibe render --beat <id> --json`." : "Render again with `vibe render --json`."
55852
56959
  });
55853
56960
  retryWith.push(
@@ -55857,6 +56964,8 @@ async function inspectRender(opts) {
55857
56964
  const beatTimings = await beatTimingsFromBuildReport(projectDir, opts.beatId);
55858
56965
  const expectedDurationSec = beatTimings.length > 0 ? Number(beatTimings.reduce((sum, beat) => sum + beat.sceneDurationSec, 0).toFixed(3)) : await expectedDurationFromBuildReport(projectDir, opts.beatId);
55859
56966
  if (expectedDurationSec !== void 0) checks.expectedDurationSec = expectedDurationSec;
56967
+ const expectedAudio = await renderExpectsAudio(projectDir, opts.beatId);
56968
+ checks.expectedAudio = expectedAudio;
55860
56969
  if (!commandExists("ffprobe")) {
55861
56970
  issues.push({
55862
56971
  severity: "error",
@@ -55882,7 +56991,7 @@ async function inspectRender(opts) {
55882
56991
  file: displayPath(videoPath)
55883
56992
  });
55884
56993
  }
55885
- if (!audioStream) {
56994
+ if (!audioStream && expectedAudio) {
55886
56995
  issues.push({
55887
56996
  severity: "warning",
55888
56997
  code: "NO_AUDIO_STREAM",
@@ -56087,8 +57196,86 @@ async function inspectRender(opts) {
56087
57196
  );
56088
57197
  return maybeWriteRenderReport(projectDir, opts, result);
56089
57198
  }
57199
+ async function renderExpectsAudio(projectDir, beatId) {
57200
+ const root = resolve42(projectDir);
57201
+ const rootHtml = await readTextIfExists(join22(root, "index.html"));
57202
+ if (rootHtml && rootHtmlExpectsAudio(rootHtml, beatId)) return true;
57203
+ const buildReport = await readJsonIfExists(join22(root, "build-report.json"));
57204
+ if (buildReportExpectsAudio(buildReport, beatId)) return true;
57205
+ const storyboard = await readTextIfExists(join22(root, "STORYBOARD.md"));
57206
+ if (!storyboard) return false;
57207
+ try {
57208
+ const parsed = parseStoryboard(storyboard);
57209
+ const beats = beatId ? parsed.beats.filter((beat) => beat.id === beatId) : parsed.beats;
57210
+ return beats.some(
57211
+ (beat) => [
57212
+ `assets/narration-${beat.id}.mp3`,
57213
+ `assets/narration-${beat.id}.wav`,
57214
+ `assets/music-${beat.id}.mp3`,
57215
+ `assets/music-${beat.id}.wav`
57216
+ ].some((asset) => existsSync39(join22(root, asset)))
57217
+ );
57218
+ } catch {
57219
+ return false;
57220
+ }
57221
+ }
57222
+ function rootHtmlExpectsAudio(html, beatId) {
57223
+ const audioTags = html.match(/<audio\b[^>]*>/gi) ?? [];
57224
+ if (!beatId) return audioTags.length > 0;
57225
+ const escaped = escapeRegExp4(beatId);
57226
+ const beatAudioRe = new RegExp(
57227
+ `(id="(?:narration|music)-${escaped}"|src="[^"]*(?:narration|music)-${escaped}\\.(?:mp3|wav)")`,
57228
+ "i"
57229
+ );
57230
+ return audioTags.some((tag) => beatAudioRe.test(tag));
57231
+ }
57232
+ function buildReportExpectsAudio(report, beatId) {
57233
+ if (!report || typeof report !== "object") return false;
57234
+ const beats = Array.isArray(report.beats) ? report.beats ?? [] : [];
57235
+ const selected = beatId === void 0 ? beats : beats.filter((beat) => {
57236
+ if (!beat || typeof beat !== "object") return false;
57237
+ return beat.id === beatId;
57238
+ });
57239
+ return selected.some((beat) => beatReportExpectsAudio(beat));
57240
+ }
57241
+ function beatReportExpectsAudio(beat) {
57242
+ if (!beat || typeof beat !== "object") return false;
57243
+ const record = beat;
57244
+ if (typeof record.narrationPath === "string" && record.narrationPath.length > 0) return true;
57245
+ if (typeof record.musicPath === "string" && record.musicPath.length > 0) return true;
57246
+ if (parseOptionalNumber2(record.narrationDurationSec) !== void 0) return true;
57247
+ const narration = record.narration;
57248
+ if (narration && typeof narration === "object") {
57249
+ const n = narration;
57250
+ if (typeof n.path === "string" && n.path.length > 0) return true;
57251
+ if (parseOptionalNumber2(n.durationSec) !== void 0) return true;
57252
+ }
57253
+ const music = record.music;
57254
+ if (music && typeof music === "object") {
57255
+ const m = music;
57256
+ if (typeof m.path === "string" && m.path.length > 0) return true;
57257
+ if (parseOptionalNumber2(m.durationSec) !== void 0) return true;
57258
+ }
57259
+ return false;
57260
+ }
57261
+ async function readJsonIfExists(path2) {
57262
+ const text = await readTextIfExists(path2);
57263
+ if (!text) return null;
57264
+ try {
57265
+ return JSON.parse(text);
57266
+ } catch {
57267
+ return null;
57268
+ }
57269
+ }
57270
+ function escapeRegExp4(value) {
57271
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
57272
+ }
56090
57273
  function makeRenderResult(projectDir, opts, videoPath, issues, checks, retryWith, score) {
56091
- const normalizedIssues = normalizeReviewIssues(issues);
57274
+ const normalizedRetryWith = uniqueRetryWith(retryWith);
57275
+ const normalizedIssues = normalizeReviewIssues(issues, {
57276
+ projectDir,
57277
+ retryWith: normalizedRetryWith
57278
+ });
56092
57279
  const status = statusFromIssues(normalizedIssues);
56093
57280
  return {
56094
57281
  schemaVersion: "1",
@@ -56101,9 +57288,14 @@ function makeRenderResult(projectDir, opts, videoPath, issues, checks, retryWith
56101
57288
  score,
56102
57289
  issues: normalizedIssues,
56103
57290
  summary: summarizeReviewIssues(normalizedIssues),
57291
+ nextActions: deriveNextReviewActions({
57292
+ issues: normalizedIssues,
57293
+ retryWith: normalizedRetryWith,
57294
+ projectDir
57295
+ }),
56104
57296
  sourceReports: renderSourceReports(projectDir, videoPath, checks),
56105
57297
  checks,
56106
- retryWith: uniqueRetryWith(retryWith)
57298
+ retryWith: normalizedRetryWith
56107
57299
  };
56108
57300
  }
56109
57301
  async function maybeWriteRenderReport(projectDir, opts, result) {