@vibeframe/cli 0.113.0 → 0.113.2

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,297 @@ 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
+ "motion",
32484
+ "voice",
32485
+ "music",
32486
+ "asset",
32487
+ "characters"
32488
+ ];
32489
+ HEADING_RE2 = /^##\s+(.+?)\s*$/gm;
32490
+ LEADING_CUE_RE = /^(\s*)```ya?ml\s*\n([\s\S]*?)\n```\s*(?:\n|$)/;
32491
+ ALLOWED_CUE_KEYS = new Set(STORYBOARD_CUE_KEYS);
32492
+ STRING_CUE_KEYS = /* @__PURE__ */ new Set(["narration", "backdrop", "video", "motion", "voice", "music", "asset"]);
32493
+ MAX_RECOMMENDED_BEAT_SEC = 15;
32494
+ }
32495
+ });
32496
+
31654
32497
  // src/pipeline/renderers/chrome.ts
31655
32498
  import { existsSync as existsSync14 } from "node:fs";
31656
32499
  import { homedir as homedir5 } from "node:os";
@@ -32567,6 +33410,7 @@ var init_build_asset_reference = __esm({
32567
33410
  EXTENSIONS = {
32568
33411
  narration: [".mp3", ".wav", ".m4a"],
32569
33412
  backdrop: [".png", ".jpg", ".jpeg", ".webp"],
33413
+ character: [".png", ".jpg", ".jpeg", ".webp"],
32570
33414
  video: [".mp4", ".mov", ".webm"],
32571
33415
  music: [".mp3", ".wav", ".m4a"]
32572
33416
  };
@@ -32604,6 +33448,16 @@ function backdropCacheDescriptor(opts) {
32604
33448
  ext: "png"
32605
33449
  });
32606
33450
  }
33451
+ function characterCacheDescriptor(opts) {
33452
+ return cacheAssetDescriptor("character", {
33453
+ name: opts.name,
33454
+ cue: opts.cue,
33455
+ provider: opts.provider,
33456
+ quality: opts.quality,
33457
+ size: opts.size,
33458
+ ext: "png"
33459
+ });
33460
+ }
32607
33461
  function imageRatioForSize(size) {
32608
33462
  switch (size) {
32609
33463
  case "1024x1024":
@@ -32623,6 +33477,8 @@ function videoCacheDescriptor(opts) {
32623
33477
  provider: opts.provider,
32624
33478
  duration: normalizeVideoDuration(opts.duration),
32625
33479
  ratio: "16:9",
33480
+ // Omit when empty so existing (character-less) clips keep their cache key.
33481
+ characters: opts.characters && opts.characters.length > 0 ? opts.characters : void 0,
32626
33482
  ext: "mp4"
32627
33483
  });
32628
33484
  }
@@ -32675,208 +33531,6 @@ var init_build_backdrop_prompt = __esm({
32675
33531
  }
32676
33532
  });
32677
33533
 
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
33534
  // src/commands/_shared/build-plan.ts
32881
33535
  import { existsSync as existsSync17 } from "node:fs";
32882
33536
  import { readFile as readFile17 } from "node:fs/promises";
@@ -33052,7 +33706,11 @@ async function createBuildPlan(opts) {
33052
33706
  reference: videoReference,
33053
33707
  projectDir,
33054
33708
  force: opts.force,
33055
- cost: VIDEO_COST_USD,
33709
+ cost: resolved.video.resolved === "seedance" || resolved.video.resolved === "fal" ? estimateSeedanceVideoCostUsd({
33710
+ durationSec: normalizeVideoDuration(beat.duration),
33711
+ resolution: "720p",
33712
+ aspectRatio: "16:9"
33713
+ }) : VIDEO_COST_USD,
33056
33714
  active: includeAssets
33057
33715
  }) : null;
33058
33716
  const music = musicCue && !opts.skipMusic ? assetPlan({
@@ -33163,6 +33821,21 @@ async function createBuildPlan(opts) {
33163
33821
  retryWith.push(skipCommand);
33164
33822
  }
33165
33823
  }
33824
+ if (validationOk && includeAssets && !opts.skipVideo) {
33825
+ const referenced = /* @__PURE__ */ new Set();
33826
+ for (const beat of sourceBeats) {
33827
+ for (const name of beatCharacterNames(beat.cues)) referenced.add(name);
33828
+ }
33829
+ if (referenced.size > 0) {
33830
+ const toGenerate = resolveCharacters(parsed.frontmatter).filter(
33831
+ (c) => referenced.has(c.name) && c.prompt && (opts.force || !existsSync17(join15(projectDir, `assets/character-${c.name}.png`)))
33832
+ );
33833
+ if (toGenerate.length > 0) {
33834
+ estimatedCostUsd += toGenerate.length * backdropCostUsd(opts.imageQuality ?? "hd");
33835
+ missing.add("assets");
33836
+ }
33837
+ }
33838
+ }
33166
33839
  return finalizeBuildPlan({
33167
33840
  projectDir,
33168
33841
  config: config2,
@@ -33527,6 +34200,7 @@ var init_build_plan = __esm({
33527
34200
  init_schema();
33528
34201
  init_build_asset_reference();
33529
34202
  init_build_cache();
34203
+ init_dist2();
33530
34204
  init_build_asset_metadata();
33531
34205
  init_build_backdrop_prompt();
33532
34206
  init_composer_resolve();
@@ -33859,7 +34533,7 @@ __export(scene_build_exports, {
33859
34533
  executeSceneBuild: () => executeSceneBuild,
33860
34534
  resolveSceneBuildMode: () => resolveSceneBuildMode
33861
34535
  });
33862
- import { existsSync as existsSync19 } from "node:fs";
34536
+ import { existsSync as existsSync19, readFileSync as readFileSync3 } from "node:fs";
33863
34537
  import { copyFile as copyFile3, mkdir as mkdir10, readFile as readFile18, writeFile as writeFile18 } from "node:fs/promises";
33864
34538
  import { dirname as dirname10, join as join16, resolve as resolve25 } from "node:path";
33865
34539
  async function executeSceneBuild(opts) {
@@ -34026,8 +34700,32 @@ async function executeSceneBuild(opts) {
34026
34700
  const voice = opts.voice ?? parsed.frontmatter?.voice;
34027
34701
  let beatOutcomes = collectExistingBeatOutcomes(parsed.beats, projectDir);
34028
34702
  let pendingJobs = [];
34703
+ let characterCostUsd = 0;
34029
34704
  if (shouldRunStage(selectedStage, "assets")) {
34030
34705
  onProgress({ type: "phase-start", phase: "primitives" });
34706
+ const characterPaths = /* @__PURE__ */ new Map();
34707
+ if (!(opts.skipVideo ?? false)) {
34708
+ const referenced = /* @__PURE__ */ new Set();
34709
+ for (const beat of activeBeats) {
34710
+ for (const name of beatCharacterNames(beat.cues)) referenced.add(name);
34711
+ }
34712
+ if (referenced.size > 0) {
34713
+ const charResult = await buildCharacters(
34714
+ resolveCharacters(parsed.frontmatter).filter((c) => referenced.has(c.name)),
34715
+ {
34716
+ projectDir,
34717
+ imageProvider,
34718
+ imageSize: opts.imageSize ?? "1536x1024",
34719
+ imageQuality: opts.imageQuality ?? "hd",
34720
+ force: opts.force ?? false,
34721
+ onProgress
34722
+ }
34723
+ );
34724
+ for (const [name, path2] of charResult.paths) characterPaths.set(name, path2);
34725
+ characterCostUsd = charResult.costUsd;
34726
+ warnings.push(...charResult.failures);
34727
+ }
34728
+ }
34031
34729
  const primitiveResults = await mapWithConcurrency(
34032
34730
  activeBeats,
34033
34731
  BEAT_PRIMITIVES_CONCURRENCY,
@@ -34045,7 +34743,8 @@ async function executeSceneBuild(opts) {
34045
34743
  skipVideo: opts.skipVideo ?? false,
34046
34744
  skipMusic: opts.skipMusic ?? false,
34047
34745
  force: opts.force ?? false,
34048
- onProgress
34746
+ onProgress,
34747
+ characterPaths
34049
34748
  })
34050
34749
  );
34051
34750
  beatOutcomes = primitiveResults.map((result) => result.outcome);
@@ -34054,10 +34753,7 @@ async function executeSceneBuild(opts) {
34054
34753
  (beat) => beat.narrationStatus === "failed" || beat.backdropStatus === "failed" || beat.videoStatus === "failed" || beat.musicStatus === "failed"
34055
34754
  );
34056
34755
  stageReports.assets.status = assetFailed ? "failed" : pendingJobs.length > 0 ? "pending-jobs" : "done";
34057
- stageReports.assets.costUsd = estimateActualAssetCost(
34058
- beatOutcomes,
34059
- opts.imageQuality ?? "hd"
34060
- );
34756
+ stageReports.assets.costUsd = estimateActualAssetCost(beatOutcomes, opts.imageQuality ?? "hd") + characterCostUsd;
34061
34757
  stageReports.assets.retryWith = pendingJobs.map(
34062
34758
  (job) => `vibe status job ${job.id} --project ${projectDir} --json`
34063
34759
  );
@@ -34222,6 +34918,18 @@ async function executeSceneBuild(opts) {
34222
34918
  stageReports.compose.status = "skipped";
34223
34919
  }
34224
34920
  if (stageReports.compose.status === "done") {
34921
+ for (const beat of activeBeats) {
34922
+ const videoRel = `assets/video-${beat.id}.mp4`;
34923
+ const compositionAbs = join16(projectDir, "compositions", `scene-${beat.id}.html`);
34924
+ if (!existsSync19(join16(projectDir, videoRel)) || !existsSync19(compositionAbs)) continue;
34925
+ const html = readFileSync3(compositionAbs, "utf-8");
34926
+ if (!html.includes(videoRel)) {
34927
+ warnings.push(
34928
+ `Beat "${beat.id}" has a generated clip (${videoRel}) but its composition does not reference it, so the clip will not appear. Recompose the beat.`
34929
+ );
34930
+ retryWith.push(`vibe build ${projectDir} --beat ${beat.id} --stage compose --force --json`);
34931
+ }
34932
+ }
34225
34933
  const repair = await runBuildSceneRepair(projectDir, "compose", false);
34226
34934
  sceneRepair = mergeSceneRepairSummaries(sceneRepair, repair);
34227
34935
  applySceneRepairToStage(stageReports.compose, repair);
@@ -34642,6 +35350,120 @@ async function dispatchNarration(beat, ctx) {
34642
35350
  freshness: "fresh"
34643
35351
  };
34644
35352
  }
35353
+ function characterSheetPrompt(name, description) {
35354
+ return [
35355
+ `Character turnaround reference sheet of an original fictional character named ${name}: ${description}.`,
35356
+ "Show three full-body views side by side on one clean canvas \u2014 front view, side profile, and back view \u2014",
35357
+ "with identical outfit, hairstyle, hair color, and body proportions across all three views.",
35358
+ "Plain light-grey studio background, even neutral lighting, no props.",
35359
+ "Photorealistic digital character, professional concept-art character sheet layout."
35360
+ ].join(" ");
35361
+ }
35362
+ async function buildCharacters(characters, ctx) {
35363
+ const paths = /* @__PURE__ */ new Map();
35364
+ const failures = [];
35365
+ let costUsd = 0;
35366
+ const size = ctx.imageSize ?? "1536x1024";
35367
+ for (const character of characters) {
35368
+ if (character.imagePath) {
35369
+ if (existsSync19(join16(ctx.projectDir, character.imagePath))) {
35370
+ paths.set(character.name, character.imagePath);
35371
+ ctx.onProgress({ type: "character-cached", name: character.name, path: character.imagePath });
35372
+ } else {
35373
+ const error = `character "${character.name}" image not found: ${character.imagePath}`;
35374
+ failures.push(error);
35375
+ ctx.onProgress({ type: "character-failed", name: character.name, error });
35376
+ }
35377
+ continue;
35378
+ }
35379
+ if (!character.prompt) continue;
35380
+ const prompt3 = characterSheetPrompt(character.name, character.prompt);
35381
+ const rel = `assets/character-${character.name}.png`;
35382
+ const abs = join16(ctx.projectDir, rel);
35383
+ const metadataOptions = { quality: ctx.imageQuality, size };
35384
+ const cache = characterCacheDescriptor({
35385
+ name: character.name,
35386
+ cue: prompt3,
35387
+ provider: ctx.imageProvider,
35388
+ quality: ctx.imageQuality,
35389
+ size
35390
+ });
35391
+ if (existsSync19(abs) && !ctx.force) {
35392
+ const metadata = readAssetMetadata(ctx.projectDir, "character", character.name);
35393
+ if (!metadata || isFreshCanonicalAsset({
35394
+ projectDir: ctx.projectDir,
35395
+ kind: "character",
35396
+ beatId: character.name,
35397
+ cue: prompt3,
35398
+ provider: ctx.imageProvider,
35399
+ options: metadataOptions,
35400
+ cacheKey: cache.key
35401
+ })) {
35402
+ paths.set(character.name, rel);
35403
+ ctx.onProgress({ type: "character-cached", name: character.name, path: rel });
35404
+ continue;
35405
+ }
35406
+ }
35407
+ const cacheAbs = join16(ctx.projectDir, cache.path);
35408
+ if (existsSync19(cacheAbs) && !ctx.force) {
35409
+ await mkdir10(dirname10(abs), { recursive: true });
35410
+ await copyFile3(cacheAbs, abs);
35411
+ await writeAssetMetadata({
35412
+ projectDir: ctx.projectDir,
35413
+ kind: "character",
35414
+ beatId: character.name,
35415
+ cue: prompt3,
35416
+ provider: ctx.imageProvider,
35417
+ options: metadataOptions,
35418
+ cacheKey: cache.key,
35419
+ canonicalPath: rel,
35420
+ cachePath: cache.path
35421
+ });
35422
+ paths.set(character.name, rel);
35423
+ ctx.onProgress({ type: "character-cached", name: character.name, path: rel });
35424
+ continue;
35425
+ }
35426
+ const generated = await generateBackdropImage(
35427
+ prompt3,
35428
+ {
35429
+ projectDir: ctx.projectDir,
35430
+ imageProvider: ctx.imageProvider,
35431
+ imageSize: size,
35432
+ imageQuality: ctx.imageQuality
35433
+ },
35434
+ imageRatioForSize(size)
35435
+ );
35436
+ if (!generated.success) {
35437
+ failures.push(`character "${character.name}": ${generated.error}`);
35438
+ ctx.onProgress({ type: "character-failed", name: character.name, error: generated.error });
35439
+ continue;
35440
+ }
35441
+ await mkdir10(dirname10(abs), { recursive: true });
35442
+ await writeFile18(abs, generated.buffer);
35443
+ await mkdir10(dirname10(cacheAbs), { recursive: true });
35444
+ await writeFile18(cacheAbs, generated.buffer);
35445
+ await writeAssetMetadata({
35446
+ projectDir: ctx.projectDir,
35447
+ kind: "character",
35448
+ beatId: character.name,
35449
+ cue: prompt3,
35450
+ provider: ctx.imageProvider,
35451
+ options: metadataOptions,
35452
+ cacheKey: cache.key,
35453
+ canonicalPath: rel,
35454
+ cachePath: cache.path
35455
+ });
35456
+ costUsd += backdropCostUsd(ctx.imageQuality);
35457
+ paths.set(character.name, rel);
35458
+ ctx.onProgress({
35459
+ type: "character-generated",
35460
+ name: character.name,
35461
+ path: rel,
35462
+ provider: ctx.imageProvider
35463
+ });
35464
+ }
35465
+ return { paths, costUsd: Number(costUsd.toFixed(2)), failures };
35466
+ }
34645
35467
  async function dispatchBackdrop(beat, ctx) {
34646
35468
  const reference = assetReferenceForBeat(ctx.projectDir, "backdrop", beat);
34647
35469
  if (reference) return referencePrimitiveOutcome("backdrop", beat, ctx, reference);
@@ -34811,13 +35633,16 @@ async function dispatchVideo(beat, ctx) {
34811
35633
  if (reference) return referencePrimitiveOutcome("video", beat, ctx, reference);
34812
35634
  const prompt3 = stringOrUndefined4(beat.cues?.video);
34813
35635
  if (!prompt3) return { status: "no-cue" };
35636
+ const characterRefsRel = beatCharacterNames(beat.cues).map((name) => ctx.characterPaths?.get(name)).filter((p) => typeof p === "string");
35637
+ const characterRefsAbs = characterRefsRel.map((p) => join16(ctx.projectDir, p));
34814
35638
  const rel = `assets/video-${beat.id}.mp4`;
34815
35639
  const abs = join16(ctx.projectDir, rel);
34816
35640
  const cache = videoCacheDescriptor({
34817
35641
  beatId: beat.id,
34818
35642
  cue: prompt3,
34819
35643
  provider: ctx.videoProvider,
34820
- duration: normalizeVideoDuration(beat.duration)
35644
+ duration: normalizeVideoDuration(beat.duration),
35645
+ characters: characterRefsRel
34821
35646
  });
34822
35647
  const metadataPath = assetMetadataPath("video", beat.id);
34823
35648
  if (existsSync19(abs) && !ctx.force) {
@@ -34877,6 +35702,7 @@ async function dispatchVideo(beat, ctx) {
34877
35702
  ratio: "16:9",
34878
35703
  output: abs,
34879
35704
  wait: false,
35705
+ refImages: characterRefsAbs.length > 0 ? characterRefsAbs : void 0,
34880
35706
  apiKey: await apiKeyForVideoProvider(ctx.videoProvider, ctx.projectDir)
34881
35707
  });
34882
35708
  if (!result.success || !result.taskId) {
@@ -35245,7 +36071,13 @@ function estimateActualAssetCost(outcomes, imageQuality) {
35245
36071
  for (const outcome of outcomes) {
35246
36072
  if (outcome.narrationStatus === "generated") cost += 0.05;
35247
36073
  if (outcome.backdropStatus === "generated") cost += backdropCostUsd(imageQuality);
35248
- if (outcome.videoStatus === "generated" || outcome.videoStatus === "pending") cost += 5;
36074
+ if (outcome.videoStatus === "generated" || outcome.videoStatus === "pending") {
36075
+ cost += outcome.videoProvider === "seedance" || outcome.videoProvider === "fal" ? estimateSeedanceVideoCostUsd({
36076
+ durationSec: normalizeVideoDuration(outcome.sceneDurationSec),
36077
+ resolution: "720p",
36078
+ aspectRatio: "16:9"
36079
+ }) : 5;
36080
+ }
35249
36081
  if (outcome.musicStatus === "generated" || outcome.musicStatus === "pending") cost += 0.5;
35250
36082
  }
35251
36083
  return Number(cost.toFixed(2));
@@ -45366,10 +46198,18 @@ Examples:
45366
46198
  options.ratio = "16:9";
45367
46199
  }
45368
46200
  if (options.dryRun) {
46201
+ const costUsd = provider === "seedance" || provider === "fal" ? estimateSeedanceVideoCostUsd({
46202
+ durationSec: Number(options.duration) || 5,
46203
+ resolution: options.resolution,
46204
+ aspectRatio: options.ratio,
46205
+ fast: options.seedanceModel === "fast",
46206
+ hasVideoReference: Boolean(options.refVideos)
46207
+ }) : void 0;
45369
46208
  outputSuccess({
45370
46209
  command: "generate video",
45371
46210
  startedAt,
45372
46211
  dryRun: true,
46212
+ costUsd,
45373
46213
  data: {
45374
46214
  params: {
45375
46215
  prompt: prompt3,
@@ -51847,6 +52687,9 @@ function clean(text) {
51847
52687
  function humanise(id) {
51848
52688
  return id.split(/[-_]+/).filter(Boolean).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
51849
52689
  }
52690
+ function sceneCompositionId(beatId) {
52691
+ return `scene-${beatId}`;
52692
+ }
51850
52693
  function slugifySceneName(name) {
51851
52694
  const normalised = name.normalize("NFKD").replace(/[̀-ͯ]/g, "");
51852
52695
  const slug = normalised.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
@@ -51884,10 +52727,16 @@ function fitKineticFontSize(words) {
51884
52727
  function kenBurnsTween(scope, dur, endScale = 1.08) {
51885
52728
  return `tl.fromTo('${scope} .backdrop', { scale: 1.0 }, { scale: ${endScale}, duration: ${dur.toFixed(2)}, ease: 'none' }, 0);`;
51886
52729
  }
52730
+ function backdropMotionTweens(scope, dur, hasImage, endScale = 1.08) {
52731
+ const sweep = hasImage ? "" : `
52732
+ tl.fromTo('${scope} .ambient-sweep', { xPercent: -75, opacity: 0.18 }, { xPercent: 75, opacity: 0.62, duration: ${dur.toFixed(2)}, ease: 'sine.inOut' }, 0);`;
52733
+ return `${kenBurnsTween(scope, dur, endScale)}${sweep}`;
52734
+ }
51887
52735
  function buildPreset(input) {
51888
52736
  const id = input.id;
51889
52737
  const scope = `[data-composition-id="${id}"]`;
51890
- const headline = clean(input.headline) || humanise(id);
52738
+ const displayId = input.beatId ?? id;
52739
+ const headline = clean(input.headline) || humanise(displayId);
51891
52740
  const subhead = clean(input.subhead);
51892
52741
  const kicker = clean(input.kicker);
51893
52742
  const dur = input.duration;
@@ -51910,8 +52759,17 @@ function buildPreset(input) {
51910
52759
  position: absolute;
51911
52760
  inset: 0;
51912
52761
  background: radial-gradient(ellipse at center, #1a1a2e 0%, #000 70%);
52762
+ overflow: hidden;
52763
+ }
52764
+ ${scope} .ambient-sweep {
52765
+ position: absolute;
52766
+ inset: -22%;
52767
+ display: block;
52768
+ 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%);
52769
+ filter: blur(12px);
52770
+ transform: rotate(8deg);
51913
52771
  }`;
51914
- const backdropMarkup = `<div class="backdrop"></div>`;
52772
+ const backdropMarkup = hasImage ? `<div class="backdrop"></div>` : `<div class="backdrop"><div class="ambient-sweep"></div></div>`;
51915
52773
  switch (input.preset) {
51916
52774
  case "simple": {
51917
52775
  const transcript = input.transcript;
@@ -51943,7 +52801,7 @@ function buildPreset(input) {
51943
52801
  body: `${backdropMarkup}
51944
52802
  <div class="caption" id="caption">${captionInner}</div>`,
51945
52803
  timeline: `${crossfadeTweens(scope, dur)}
51946
- ${kenBurnsTween(scope, dur)}
52804
+ ${backdropMotionTweens(scope, dur, hasImage)}
51947
52805
  ${captionTween}`
51948
52806
  };
51949
52807
  }
@@ -51982,12 +52840,12 @@ function buildPreset(input) {
51982
52840
  // scene's own fade-in handles the visual transition. Backdrop
51983
52841
  // Ken-Burns fills what used to be 80% dead static frame.
51984
52842
  timeline: `${crossfadeTweens(scope, dur)}
51985
- ${kenBurnsTween(scope, dur)}
52843
+ ${backdropMotionTweens(scope, dur, hasImage)}
51986
52844
  tl.from('${scope} #headline', { opacity: 0, scale: 0.92, duration: 0.55, ease: 'power3.out' }, 0.05);`
51987
52845
  };
51988
52846
  }
51989
52847
  case "explainer": {
51990
- const k = kicker || humanise(id).toUpperCase();
52848
+ const k = kicker || humanise(displayId).toUpperCase();
51991
52849
  const sub = subhead || "";
51992
52850
  const transcript = input.transcript;
51993
52851
  const useWordSync = !!(transcript && transcript.length > 0 && sub);
@@ -52029,7 +52887,7 @@ function buildPreset(input) {
52029
52887
  <div class="subtitle" id="subtitle">${subtitleInner}</div>` : ""}
52030
52888
  </div>`,
52031
52889
  timeline: `${crossfadeTweens(scope, dur)}
52032
- ${kenBurnsTween(scope, dur)}
52890
+ ${backdropMotionTweens(scope, dur, hasImage)}
52033
52891
  tl.from('${scope} #kicker', { opacity: 0, y: 16, duration: 0.4, ease: 'power2.out' }, 0.1);
52034
52892
  tl.from('${scope} #title', { opacity: 0, y: 60, duration: 0.7, ease: 'power3.out' }, 0.25);
52035
52893
  ${subtitleTween}`
@@ -52070,12 +52928,12 @@ function buildPreset(input) {
52070
52928
  body: `${backdropMarkup}
52071
52929
  <div class="kinetic">${wordSpans}</div>`,
52072
52930
  timeline: `${crossfadeTweens(scope, dur)}
52073
- ${kenBurnsTween(scope, dur)}
52931
+ ${backdropMotionTweens(scope, dur, hasImage)}
52074
52932
  ${tweens}`
52075
52933
  };
52076
52934
  }
52077
52935
  case "product-shot": {
52078
- const label = kicker || humanise(id);
52936
+ const label = kicker || humanise(displayId);
52079
52937
  const headlineFontPx = fitFontSize(headline, 72, 40, 50);
52080
52938
  return {
52081
52939
  css: `${scope} {
@@ -52109,7 +52967,7 @@ function buildPreset(input) {
52109
52967
  <div class="product-headline" id="headline">${esc(headline)}</div>${subhead ? `
52110
52968
  <div class="product-sub" id="subhead">${esc(subhead)}</div>` : ""}`,
52111
52969
  timeline: `${crossfadeTweens(scope, dur)}
52112
- tl.fromTo('${scope} .backdrop', { scale: 1.0 }, { scale: 1.12, duration: ${dur.toFixed(2)}, ease: 'none' }, 0);
52970
+ ${backdropMotionTweens(scope, dur, hasImage, 1.12)}
52113
52971
  tl.from('${scope} #label', { opacity: 0, x: -30, duration: 0.5, ease: 'power3.out' }, 0.2);
52114
52972
  tl.from('${scope} #headline', { opacity: 0, y: 40, duration: 0.6, ease: 'power3.out' }, 0.4);${subhead ? `
52115
52973
  tl.from('${scope} #subhead', { opacity: 0, y: 20, duration: 0.5, ease: 'power3.out' }, 0.65);` : ""}`
@@ -52174,9 +53032,10 @@ function emitSceneHtml(input) {
52174
53032
  if (input.width <= 0 || input.height <= 0) {
52175
53033
  throw new Error(`Invalid canvas dims: ${input.width}x${input.height}`);
52176
53034
  }
52177
- const id = input.id;
53035
+ const beatId = input.id;
53036
+ const id = sceneCompositionId(beatId);
52178
53037
  const dur = Number(input.duration.toFixed(3));
52179
- const parts = buildPreset({ ...input, id, duration: dur });
53038
+ const parts = buildPreset({ ...input, id, beatId, duration: dur });
52180
53039
  const audioBlock = input.audioPath ? `
52181
53040
  <audio
52182
53041
  id="narration"
@@ -52192,7 +53051,7 @@ function emitSceneHtml(input) {
52192
53051
  ${lottieLayer.markup}` : "";
52193
53052
  const lottieScript = lottieLayer ? `
52194
53053
  ${lottieLayer.script}` : "";
52195
- return `<template id="scene-${id}-template">
53054
+ return `<template id="${id}-template">
52196
53055
  <div data-composition-id="${id}" data-start="0" data-duration="${dur}" data-width="${input.width}" data-height="${input.height}">
52197
53056
  <style>
52198
53057
  ${parts.css}
@@ -52228,8 +53087,9 @@ function buildClipReference(opts) {
52228
53087
  const duration = Number(opts.duration.toFixed(3));
52229
53088
  const track = opts.trackIndex ?? 1;
52230
53089
  const src = opts.src ?? `compositions/scene-${opts.id}.html`;
53090
+ const compositionId = sceneCompositionId(opts.id);
52231
53091
  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>`;
53092
+ 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
53093
  }
52234
53094
  function insertClipIntoRoot(rootHtml, clip) {
52235
53095
  const clipDiv = buildClipReference(clip);
@@ -52273,9 +53133,9 @@ init_agent_host_detect();
52273
53133
  // src/commands/_shared/install-skill.ts
52274
53134
  init_bundle_content();
52275
53135
  init_bundle();
52276
- import { mkdir as mkdir5, writeFile as writeFile9 } from "node:fs/promises";
53136
+ import { mkdir as mkdir5, writeFile as writeFile10 } from "node:fs/promises";
52277
53137
  import { existsSync as existsSync7 } from "node:fs";
52278
- import { dirname as dirname4, join as join6, relative as relative3, resolve as resolve9 } from "node:path";
53138
+ import { dirname as dirname4, join as join7, relative as relative3, resolve as resolve10 } from "node:path";
52279
53139
  function universalFiles() {
52280
53140
  return [
52281
53141
  { relPath: "SKILL.md", content: SKILL_MD },
@@ -52325,7 +53185,7 @@ function selectHostFiles(hosts) {
52325
53185
  return out;
52326
53186
  }
52327
53187
  async function installHyperframesSkill(opts) {
52328
- const projectDir = resolve9(opts.projectDir);
53188
+ const projectDir = resolve10(opts.projectDir);
52329
53189
  const force = opts.force ?? false;
52330
53190
  const dryRun = opts.dryRun ?? false;
52331
53191
  const files = [...universalFiles(), ...selectHostFiles(opts.hosts)];
@@ -52334,7 +53194,7 @@ async function installHyperframesSkill(opts) {
52334
53194
  await mkdir5(projectDir, { recursive: true });
52335
53195
  }
52336
53196
  for (const file of files) {
52337
- const absPath = join6(projectDir, file.relPath);
53197
+ const absPath = join7(projectDir, file.relPath);
52338
53198
  const exists = existsSync7(absPath);
52339
53199
  const bytes = Buffer.byteLength(file.content, "utf-8");
52340
53200
  if (exists && !force) {
@@ -52350,7 +53210,7 @@ async function installHyperframesSkill(opts) {
52350
53210
  continue;
52351
53211
  }
52352
53212
  await mkdir5(dirname4(absPath), { recursive: true });
52353
- await writeFile9(absPath, file.content, "utf-8");
53213
+ await writeFile10(absPath, file.content, "utf-8");
52354
53214
  actions.push({ path: file.relPath, status: "wrote", bytes });
52355
53215
  }
52356
53216
  return {
@@ -52375,10 +53235,10 @@ init_scene_lint();
52375
53235
  init_storyboard_parse();
52376
53236
  init_root_sync();
52377
53237
  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";
53238
+ import { mkdir as mkdir7, readFile as readFile11, writeFile as writeFile13 } from "node:fs/promises";
53239
+ import { join as join11, resolve as resolve14 } from "node:path";
52380
53240
  async function executeSceneSubmit(opts) {
52381
- const projectDir = resolve13(opts.projectDir);
53241
+ const projectDir = resolve14(opts.projectDir);
52382
53242
  const beatId = opts.beatId.trim();
52383
53243
  const scenePath = `compositions/scene-${beatId}.html`;
52384
53244
  const base = {
@@ -52390,7 +53250,7 @@ async function executeSceneSubmit(opts) {
52390
53250
  warnings: [],
52391
53251
  retryWith: []
52392
53252
  };
52393
- const storyboardPath = join10(projectDir, "STORYBOARD.md");
53253
+ const storyboardPath = join11(projectDir, "STORYBOARD.md");
52394
53254
  if (!existsSync11(storyboardPath)) {
52395
53255
  return {
52396
53256
  ...base,
@@ -52468,9 +53328,9 @@ async function executeSceneSubmit(opts) {
52468
53328
  if (opts.validateOnly) {
52469
53329
  return { ...base, success: true };
52470
53330
  }
52471
- const absPath = join10(projectDir, scenePath);
52472
- await mkdir7(join10(projectDir, "compositions"), { recursive: true });
52473
- await writeFile12(absPath, html.endsWith("\n") ? html : `${html}
53331
+ const absPath = join11(projectDir, scenePath);
53332
+ await mkdir7(join11(projectDir, "compositions"), { recursive: true });
53333
+ await writeFile13(absPath, html.endsWith("\n") ? html : `${html}
52474
53334
  `, "utf-8");
52475
53335
  return {
52476
53336
  ...base,
@@ -52485,6 +53345,8 @@ async function executeSceneSubmit(opts) {
52485
53345
 
52486
53346
  // src/commands/scene.ts
52487
53347
  init_scene_repair();
53348
+ init_root_sync();
53349
+ init_storyboard_edit();
52488
53350
  function validateDuration(value) {
52489
53351
  const n = parseFloat(value);
52490
53352
  if (!Number.isFinite(n) || n <= 0 || n > 3600) {
@@ -52787,7 +53649,10 @@ sceneCommand.command("add").description("Add a new scene to a project: AI narrat
52787
53649
  ).option("-d, --duration <sec>", "Explicit scene duration in seconds (overrides narration audio)").option(
52788
53650
  "--visuals <prompt>",
52789
53651
  "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(
53652
+ ).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(
53653
+ "--no-storyboard",
53654
+ "Do not sync STORYBOARD.md; insert this scene directly into the root composition only"
53655
+ ).option("--image-provider <name>", "Image provider: gemini, openai", "gemini").option(
52791
53656
  "--tts <provider>",
52792
53657
  "TTS provider: auto, elevenlabs, openai, kokoro (default auto \u2014 ElevenLabs key > OpenAI key > Kokoro local)",
52793
53658
  "auto"
@@ -52856,6 +53721,7 @@ sceneCommand.command("add").description("Add a new scene to a project: AI narrat
52856
53721
  kicker: options.kicker,
52857
53722
  project: options.project,
52858
53723
  insertInto: options.insertInto,
53724
+ syncStoryboard: options.storyboard !== false,
52859
53725
  imageProvider: options.imageProvider,
52860
53726
  tts,
52861
53727
  audio: options.audio,
@@ -52883,6 +53749,7 @@ sceneCommand.command("add").description("Add a new scene to a project: AI narrat
52883
53749
  kicker: options.kicker,
52884
53750
  projectDir: options.project,
52885
53751
  insertInto: options.insertInto,
53752
+ syncStoryboard: options.storyboard !== false,
52886
53753
  imageProvider: options.imageProvider,
52887
53754
  tts,
52888
53755
  voice: options.voice,
@@ -52922,6 +53789,13 @@ sceneCommand.command("add").description("Add a new scene to a project: AI narrat
52922
53789
  if (result.audioPath) console.log(source_default.green(" +"), result.audioPath);
52923
53790
  if (result.imagePath) console.log(source_default.green(" +"), result.imagePath);
52924
53791
  if (result.transcriptPath) console.log(source_default.green(" +"), result.transcriptPath);
53792
+ if (result.storyboardPath && result.storyboardSynced) {
53793
+ console.log(
53794
+ result.storyboardAction === "updated" ? source_default.yellow(" ~") : source_default.green(" +"),
53795
+ result.storyboardPath,
53796
+ source_default.dim(`(${result.storyboardAction})`)
53797
+ );
53798
+ }
52925
53799
  console.log(source_default.yellow(" ~"), result.rootPath, source_default.dim("(updated)"));
52926
53800
  console.log();
52927
53801
  console.log(source_default.bold.cyan("Composition"));
@@ -52968,6 +53842,21 @@ async function loadVibeProjectConfig(projectDir) {
52968
53842
  }
52969
53843
  };
52970
53844
  }
53845
+ function escapeRegExp3(value) {
53846
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
53847
+ }
53848
+ function readClipStart(rootHtml, beatId) {
53849
+ const compositionId = sceneCompositionId(beatId);
53850
+ const match = rootHtml.match(
53851
+ new RegExp(
53852
+ `<div\\s+class="clip"[^>]*\\bdata-composition-id="${escapeRegExp3(compositionId)}"[^>]*\\bdata-start="([\\d.]+)"`,
53853
+ "i"
53854
+ )
53855
+ );
53856
+ if (!match) return void 0;
53857
+ const start = Number.parseFloat(match[1]);
53858
+ return Number.isFinite(start) ? start : void 0;
53859
+ }
52971
53860
  async function resolveNarrationText(value) {
52972
53861
  if (!value) return void 0;
52973
53862
  const looksLikePath = /\.[a-z]{2,4}$/i.test(value) || value.includes("/") || value.includes("\\");
@@ -52994,6 +53883,7 @@ async function executeSceneAdd(opts) {
52994
53883
  const id = slugifySceneName(opts.name);
52995
53884
  const sceneRel = `compositions/scene-${id}.html`;
52996
53885
  const scenePath = resolve17(projectDir, sceneRel);
53886
+ const storyboardAbsPath = resolve17(projectDir, "STORYBOARD.md");
52997
53887
  const errResult = (error) => ({
52998
53888
  success: false,
52999
53889
  id,
@@ -53213,6 +54103,27 @@ async function executeSceneAdd(opts) {
53213
54103
  duration = fallback2;
53214
54104
  }
53215
54105
  duration = Number(duration.toFixed(2));
54106
+ let storyboardPath;
54107
+ let storyboardSynced = false;
54108
+ let storyboardAction = "skipped";
54109
+ const shouldSyncStoryboard = opts.syncStoryboard !== false && await pathExists2(storyboardAbsPath);
54110
+ if (shouldSyncStoryboard) {
54111
+ opts.onProgress?.("Syncing STORYBOARD.md...");
54112
+ const before = await readFile14(storyboardAbsPath, "utf-8");
54113
+ const synced = upsertStoryboardBeat(before, {
54114
+ beatId: id,
54115
+ title: opts.headline,
54116
+ duration,
54117
+ narration: narrationText && !opts.skipAudio ? narrationText : void 0,
54118
+ backdrop: opts.visuals && !opts.skipImage ? opts.visuals : void 0
54119
+ });
54120
+ if (synced.markdown !== before) {
54121
+ await writeFile15(storyboardAbsPath, synced.markdown, "utf-8");
54122
+ }
54123
+ storyboardPath = relative6(process.cwd(), storyboardAbsPath) || storyboardAbsPath;
54124
+ storyboardSynced = true;
54125
+ storyboardAction = synced.action;
54126
+ }
53216
54127
  opts.onProgress?.("Emitting scene HTML...");
53217
54128
  const sceneHtml = emitSceneHtml({
53218
54129
  id,
@@ -53231,11 +54142,24 @@ async function executeSceneAdd(opts) {
53231
54142
  await mkdir8(dirname6(scenePath), { recursive: true });
53232
54143
  await writeFile15(scenePath, sceneHtml, "utf-8");
53233
54144
  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");
54145
+ let start = 0;
54146
+ if (storyboardSynced) {
54147
+ const syncPlan = await syncRootComposition({
54148
+ projectDir,
54149
+ rootRel,
54150
+ beats: await loadProjectRootSyncBeats(projectDir)
54151
+ });
54152
+ const blockingIssue = syncPlan.issues.find((issue) => issue.severity === "error");
54153
+ if (blockingIssue) return errResult(blockingIssue.message);
54154
+ const rootHtmlAfterSync = await readFile14(rootPath, "utf-8");
54155
+ start = readClipStart(rootHtmlAfterSync, id) ?? 0;
54156
+ } else {
54157
+ start = nextSceneStart(rootHtmlBefore, SCENE_OVERLAP_SECONDS);
54158
+ const existingClipCount = (rootHtmlBefore.match(/<div\s+class="clip"/g) || []).length;
54159
+ const trackIndex = existingClipCount % 2 + 1;
54160
+ const updated = insertClipIntoRoot(rootHtmlBefore, { id, start, duration, trackIndex });
54161
+ await writeFile15(rootPath, updated, "utf-8");
54162
+ }
53239
54163
  const transcriptAbsPath = transcriptRelPath ? resolve17(projectDir, transcriptRelPath) : void 0;
53240
54164
  return {
53241
54165
  success: true,
@@ -53248,6 +54172,9 @@ async function executeSceneAdd(opts) {
53248
54172
  audioPath: audioAbsPath ? relative6(process.cwd(), audioAbsPath) || audioAbsPath : void 0,
53249
54173
  imagePath: imageAbsPath ? relative6(process.cwd(), imageAbsPath) || imageAbsPath : void 0,
53250
54174
  lottiePath: lottieAbsPath ? relative6(process.cwd(), lottieAbsPath) || lottieAbsPath : void 0,
54175
+ storyboardPath,
54176
+ storyboardSynced,
54177
+ storyboardAction,
53251
54178
  transcriptPath: transcriptAbsPath ? relative6(process.cwd(), transcriptAbsPath) || transcriptAbsPath : void 0,
53252
54179
  transcriptWordCount
53253
54180
  };
@@ -53545,6 +54472,9 @@ var sceneAddSchema = external_exports.object({
53545
54472
  kicker: external_exports.string().optional().describe("Small label above the headline (used by 'explainer' and 'product-shot' presets)."),
53546
54473
  projectDir: external_exports.string().optional().describe(PROJECT_DIR_DESCRIPTION),
53547
54474
  insertInto: external_exports.string().optional().describe("Root composition file (relative to projectDir). Default 'index.html'."),
54475
+ syncStoryboard: external_exports.boolean().optional().describe(
54476
+ "Sync STORYBOARD.md when present, then rebuild root refs from it. Default true; set false for root-only insertion."
54477
+ ),
53548
54478
  imageProvider: external_exports.enum(["gemini", "openai"]).optional().describe("Image provider for visuals. Default 'gemini'."),
53549
54479
  voice: external_exports.string().optional().describe("ElevenLabs voice id or name."),
53550
54480
  skipAudio: external_exports.boolean().optional().describe("Skip TTS even if narration is provided."),
@@ -53557,7 +54487,7 @@ var sceneAddTool = defineTool({
53557
54487
  cost: "low",
53558
54488
  title: "Add Scene from Preset",
53559
54489
  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.",
54490
+ 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
54491
  schema: sceneAddSchema,
53562
54492
  async execute(args, ctx) {
53563
54493
  const projectDir = args.projectDir ? resolve26(ctx.workingDirectory, args.projectDir) : ctx.workingDirectory;
@@ -53571,6 +54501,7 @@ var sceneAddTool = defineTool({
53571
54501
  kicker: args.kicker,
53572
54502
  projectDir,
53573
54503
  insertInto: args.insertInto,
54504
+ syncStoryboard: args.syncStoryboard,
53574
54505
  imageProvider: args.imageProvider,
53575
54506
  voice: args.voice,
53576
54507
  skipAudio: args.skipAudio,
@@ -53589,6 +54520,9 @@ var sceneAddTool = defineTool({
53589
54520
  ];
53590
54521
  if (result.audioPath) lines.push(` audio: ${result.audioPath}`);
53591
54522
  if (result.imagePath) lines.push(` image: ${result.imagePath}`);
54523
+ if (result.storyboardPath && result.storyboardSynced) {
54524
+ lines.push(` story: ${result.storyboardPath} (${result.storyboardAction})`);
54525
+ }
53592
54526
  return {
53593
54527
  success: true,
53594
54528
  data: {
@@ -53599,7 +54533,10 @@ var sceneAddTool = defineTool({
53599
54533
  scenePath: result.scenePath,
53600
54534
  rootPath: result.rootPath,
53601
54535
  audioPath: result.audioPath,
53602
- imagePath: result.imagePath
54536
+ imagePath: result.imagePath,
54537
+ storyboardPath: result.storyboardPath,
54538
+ storyboardSynced: result.storyboardSynced,
54539
+ storyboardAction: result.storyboardAction
53603
54540
  },
53604
54541
  humanLines: lines
53605
54542
  };
@@ -55260,7 +56197,11 @@ async function inspectProject(opts) {
55260
56197
  );
55261
56198
  }
55262
56199
  function makeProjectResult(projectDir, issues, checks, retryWith, beatId) {
55263
- const normalizedIssues = normalizeReviewIssues(issues);
56200
+ const normalizedRetryWith = uniqueRetryWith(retryWith);
56201
+ const normalizedIssues = normalizeReviewIssues(issues, {
56202
+ projectDir,
56203
+ retryWith: normalizedRetryWith
56204
+ });
55264
56205
  const status = statusFromIssues(normalizedIssues);
55265
56206
  return {
55266
56207
  schemaVersion: "1",
@@ -55272,9 +56213,14 @@ function makeProjectResult(projectDir, issues, checks, retryWith, beatId) {
55272
56213
  score: scoreIssues(normalizedIssues),
55273
56214
  issues: normalizedIssues,
55274
56215
  summary: summarizeReviewIssues(normalizedIssues),
56216
+ nextActions: deriveNextReviewActions({
56217
+ issues: normalizedIssues,
56218
+ retryWith: normalizedRetryWith,
56219
+ projectDir
56220
+ }),
55275
56221
  sourceReports: projectSourceReports(checks),
55276
56222
  checks,
55277
- retryWith: uniqueRetryWith(retryWith)
56223
+ retryWith: normalizedRetryWith
55278
56224
  };
55279
56225
  }
55280
56226
  function storyboardPlaceholderIssues(storyboard) {
@@ -55826,6 +56772,8 @@ async function inspectRender(opts) {
55826
56772
  severity: "error",
55827
56773
  code: "RENDER_NOT_FOUND",
55828
56774
  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.",
56775
+ beatId: opts.beatId,
56776
+ scene: opts.beatId,
55829
56777
  suggestedFix: opts.beatId ? "Run `vibe render <project> --beat <id> --json`." : "Run `vibe build --stage render --json` or `vibe render --json`."
55830
56778
  });
55831
56779
  retryWith.push(
@@ -55848,6 +56796,8 @@ async function inspectRender(opts) {
55848
56796
  code: "EMPTY_RENDER",
55849
56797
  message: "Rendered video file is empty.",
55850
56798
  file: displayPath(videoPath),
56799
+ beatId: opts.beatId,
56800
+ scene: opts.beatId,
55851
56801
  suggestedFix: opts.beatId ? "Render again with `vibe render --beat <id> --json`." : "Render again with `vibe render --json`."
55852
56802
  });
55853
56803
  retryWith.push(
@@ -55857,6 +56807,8 @@ async function inspectRender(opts) {
55857
56807
  const beatTimings = await beatTimingsFromBuildReport(projectDir, opts.beatId);
55858
56808
  const expectedDurationSec = beatTimings.length > 0 ? Number(beatTimings.reduce((sum, beat) => sum + beat.sceneDurationSec, 0).toFixed(3)) : await expectedDurationFromBuildReport(projectDir, opts.beatId);
55859
56809
  if (expectedDurationSec !== void 0) checks.expectedDurationSec = expectedDurationSec;
56810
+ const expectedAudio = await renderExpectsAudio(projectDir, opts.beatId);
56811
+ checks.expectedAudio = expectedAudio;
55860
56812
  if (!commandExists("ffprobe")) {
55861
56813
  issues.push({
55862
56814
  severity: "error",
@@ -55882,7 +56834,7 @@ async function inspectRender(opts) {
55882
56834
  file: displayPath(videoPath)
55883
56835
  });
55884
56836
  }
55885
- if (!audioStream) {
56837
+ if (!audioStream && expectedAudio) {
55886
56838
  issues.push({
55887
56839
  severity: "warning",
55888
56840
  code: "NO_AUDIO_STREAM",
@@ -56087,8 +57039,86 @@ async function inspectRender(opts) {
56087
57039
  );
56088
57040
  return maybeWriteRenderReport(projectDir, opts, result);
56089
57041
  }
57042
+ async function renderExpectsAudio(projectDir, beatId) {
57043
+ const root = resolve42(projectDir);
57044
+ const rootHtml = await readTextIfExists(join22(root, "index.html"));
57045
+ if (rootHtml && rootHtmlExpectsAudio(rootHtml, beatId)) return true;
57046
+ const buildReport = await readJsonIfExists(join22(root, "build-report.json"));
57047
+ if (buildReportExpectsAudio(buildReport, beatId)) return true;
57048
+ const storyboard = await readTextIfExists(join22(root, "STORYBOARD.md"));
57049
+ if (!storyboard) return false;
57050
+ try {
57051
+ const parsed = parseStoryboard(storyboard);
57052
+ const beats = beatId ? parsed.beats.filter((beat) => beat.id === beatId) : parsed.beats;
57053
+ return beats.some(
57054
+ (beat) => [
57055
+ `assets/narration-${beat.id}.mp3`,
57056
+ `assets/narration-${beat.id}.wav`,
57057
+ `assets/music-${beat.id}.mp3`,
57058
+ `assets/music-${beat.id}.wav`
57059
+ ].some((asset) => existsSync39(join22(root, asset)))
57060
+ );
57061
+ } catch {
57062
+ return false;
57063
+ }
57064
+ }
57065
+ function rootHtmlExpectsAudio(html, beatId) {
57066
+ const audioTags = html.match(/<audio\b[^>]*>/gi) ?? [];
57067
+ if (!beatId) return audioTags.length > 0;
57068
+ const escaped = escapeRegExp4(beatId);
57069
+ const beatAudioRe = new RegExp(
57070
+ `(id="(?:narration|music)-${escaped}"|src="[^"]*(?:narration|music)-${escaped}\\.(?:mp3|wav)")`,
57071
+ "i"
57072
+ );
57073
+ return audioTags.some((tag) => beatAudioRe.test(tag));
57074
+ }
57075
+ function buildReportExpectsAudio(report, beatId) {
57076
+ if (!report || typeof report !== "object") return false;
57077
+ const beats = Array.isArray(report.beats) ? report.beats ?? [] : [];
57078
+ const selected = beatId === void 0 ? beats : beats.filter((beat) => {
57079
+ if (!beat || typeof beat !== "object") return false;
57080
+ return beat.id === beatId;
57081
+ });
57082
+ return selected.some((beat) => beatReportExpectsAudio(beat));
57083
+ }
57084
+ function beatReportExpectsAudio(beat) {
57085
+ if (!beat || typeof beat !== "object") return false;
57086
+ const record = beat;
57087
+ if (typeof record.narrationPath === "string" && record.narrationPath.length > 0) return true;
57088
+ if (typeof record.musicPath === "string" && record.musicPath.length > 0) return true;
57089
+ if (parseOptionalNumber2(record.narrationDurationSec) !== void 0) return true;
57090
+ const narration = record.narration;
57091
+ if (narration && typeof narration === "object") {
57092
+ const n = narration;
57093
+ if (typeof n.path === "string" && n.path.length > 0) return true;
57094
+ if (parseOptionalNumber2(n.durationSec) !== void 0) return true;
57095
+ }
57096
+ const music = record.music;
57097
+ if (music && typeof music === "object") {
57098
+ const m = music;
57099
+ if (typeof m.path === "string" && m.path.length > 0) return true;
57100
+ if (parseOptionalNumber2(m.durationSec) !== void 0) return true;
57101
+ }
57102
+ return false;
57103
+ }
57104
+ async function readJsonIfExists(path2) {
57105
+ const text = await readTextIfExists(path2);
57106
+ if (!text) return null;
57107
+ try {
57108
+ return JSON.parse(text);
57109
+ } catch {
57110
+ return null;
57111
+ }
57112
+ }
57113
+ function escapeRegExp4(value) {
57114
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
57115
+ }
56090
57116
  function makeRenderResult(projectDir, opts, videoPath, issues, checks, retryWith, score) {
56091
- const normalizedIssues = normalizeReviewIssues(issues);
57117
+ const normalizedRetryWith = uniqueRetryWith(retryWith);
57118
+ const normalizedIssues = normalizeReviewIssues(issues, {
57119
+ projectDir,
57120
+ retryWith: normalizedRetryWith
57121
+ });
56092
57122
  const status = statusFromIssues(normalizedIssues);
56093
57123
  return {
56094
57124
  schemaVersion: "1",
@@ -56101,9 +57131,14 @@ function makeRenderResult(projectDir, opts, videoPath, issues, checks, retryWith
56101
57131
  score,
56102
57132
  issues: normalizedIssues,
56103
57133
  summary: summarizeReviewIssues(normalizedIssues),
57134
+ nextActions: deriveNextReviewActions({
57135
+ issues: normalizedIssues,
57136
+ retryWith: normalizedRetryWith,
57137
+ projectDir
57138
+ }),
56104
57139
  sourceReports: renderSourceReports(projectDir, videoPath, checks),
56105
57140
  checks,
56106
- retryWith: uniqueRetryWith(retryWith)
57141
+ retryWith: normalizedRetryWith
56107
57142
  };
56108
57143
  }
56109
57144
  async function maybeWriteRenderReport(projectDir, opts, result) {