@vibeframe/cli 0.113.2 → 0.113.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- export type BuildAssetKind = "narration" | "backdrop" | "character" | "video" | "music";
1
+ export type BuildAssetKind = "narration" | "backdrop" | "character" | "keyframe" | "video" | "music";
2
2
  export interface CacheAssetDescriptor {
3
3
  key: string;
4
4
  path: string;
@@ -27,6 +27,16 @@ export declare function characterCacheDescriptor(opts: {
27
27
  quality: "standard" | "hd";
28
28
  size: string;
29
29
  }): CacheAssetDescriptor;
30
+ export declare function keyframeCacheDescriptor(opts: {
31
+ beatId: string;
32
+ cue: string;
33
+ provider: string;
34
+ quality: "standard" | "hd";
35
+ size: string;
36
+ ratio?: string;
37
+ /** Character sheet paths used as edit references — changing them re-keys the keyframe. */
38
+ characters?: string[];
39
+ }): CacheAssetDescriptor;
30
40
  export declare function imageRatioForSize(size: string | undefined): string;
31
41
  export declare function videoCacheDescriptor(opts: {
32
42
  beatId: string;
@@ -35,6 +45,11 @@ export declare function videoCacheDescriptor(opts: {
35
45
  duration: number | undefined;
36
46
  /** Character reference image paths — changing them must invalidate the clip. */
37
47
  characters?: string[];
48
+ /**
49
+ * Keyframe still path for image-to-video mode — changing the keyframe must
50
+ * invalidate the clip. Omitted for text/reference-to-video clips.
51
+ */
52
+ keyframe?: string;
38
53
  }): CacheAssetDescriptor;
39
54
  export declare function musicCacheDescriptor(opts: {
40
55
  beatId: string;
@@ -168,6 +168,9 @@ export interface BeatBuildOutcome {
168
168
  videoMetadataPath?: string;
169
169
  videoFreshness?: AssetFreshness;
170
170
  videoSourcePath?: string;
171
+ /** Keyframe still status when the beat uses keyframe → image-to-video mode. */
172
+ keyframeStatus?: PrimitiveStatus;
173
+ keyframePath?: string;
171
174
  musicStatus: PrimitiveStatus;
172
175
  musicPath?: string;
173
176
  musicJobId?: string;
@@ -1,5 +1,5 @@
1
1
  import { type Beat } from "./storyboard-parse.js";
2
- export declare const STORYBOARD_CUE_KEYS: readonly ["duration", "narration", "backdrop", "video", "motion", "voice", "music", "asset", "characters"];
2
+ export declare const STORYBOARD_CUE_KEYS: readonly ["duration", "narration", "backdrop", "video", "keyframe", "motion", "voice", "music", "asset", "characters"];
3
3
  export type StoryboardCueKey = (typeof STORYBOARD_CUE_KEYS)[number];
4
4
  export interface StoryboardValidationIssue {
5
5
  severity: "error" | "warning";
@@ -113,6 +113,13 @@ export interface BeatCues {
113
113
  * reference-to-video generation. A single name or a list.
114
114
  */
115
115
  characters?: string | string[];
116
+ /**
117
+ * Keyframe still prompt. When set, the build generates a keyframe image for
118
+ * this beat (using the character sheet as an edit reference when `characters`
119
+ * is also present) and runs Seedance image-to-video on it. The `video` cue,
120
+ * if present, supplies the motion prompt; otherwise this text is used.
121
+ */
122
+ keyframe?: string;
116
123
  [key: string]: unknown;
117
124
  }
118
125
  /** Normalize a beat's `characters` cue to a clean string[] (single or list). */
package/dist/index.js CHANGED
@@ -31869,6 +31869,7 @@ var init_storyboard_edit = __esm({
31869
31869
  "narration",
31870
31870
  "backdrop",
31871
31871
  "video",
31872
+ "keyframe",
31872
31873
  "motion",
31873
31874
  "voice",
31874
31875
  "music",
@@ -31878,7 +31879,7 @@ var init_storyboard_edit = __esm({
31878
31879
  HEADING_RE2 = /^##\s+(.+?)\s*$/gm;
31879
31880
  LEADING_CUE_RE = /^(\s*)```ya?ml\s*\n([\s\S]*?)\n```\s*(?:\n|$)/;
31880
31881
  ALLOWED_CUE_KEYS = new Set(STORYBOARD_CUE_KEYS);
31881
- STRING_CUE_KEYS = /* @__PURE__ */ new Set(["narration", "backdrop", "video", "motion", "voice", "music", "asset"]);
31882
+ STRING_CUE_KEYS = /* @__PURE__ */ new Set(["narration", "backdrop", "video", "keyframe", "motion", "voice", "music", "asset"]);
31882
31883
  MAX_RECOMMENDED_BEAT_SEC = 15;
31883
31884
  }
31884
31885
  });
@@ -60422,6 +60423,7 @@ var init_build_asset_reference = __esm({
60422
60423
  narration: [".mp3", ".wav", ".m4a"],
60423
60424
  backdrop: [".png", ".jpg", ".jpeg", ".webp"],
60424
60425
  character: [".png", ".jpg", ".jpeg", ".webp"],
60426
+ keyframe: [".png", ".jpg", ".jpeg", ".webp"],
60425
60427
  video: [".mp4", ".mov", ".webm"],
60426
60428
  music: [".mp3", ".wav", ".m4a"]
60427
60429
  };
@@ -60469,6 +60471,18 @@ function characterCacheDescriptor(opts) {
60469
60471
  ext: "png"
60470
60472
  });
60471
60473
  }
60474
+ function keyframeCacheDescriptor(opts) {
60475
+ return cacheAssetDescriptor("keyframe", {
60476
+ beatId: opts.beatId,
60477
+ cue: opts.cue,
60478
+ provider: opts.provider,
60479
+ quality: opts.quality,
60480
+ size: opts.size,
60481
+ ratio: opts.ratio,
60482
+ characters: opts.characters && opts.characters.length > 0 ? opts.characters : void 0,
60483
+ ext: "png"
60484
+ });
60485
+ }
60472
60486
  function imageRatioForSize(size) {
60473
60487
  switch (size) {
60474
60488
  case "1024x1024":
@@ -60490,6 +60504,8 @@ function videoCacheDescriptor(opts) {
60490
60504
  ratio: "16:9",
60491
60505
  // Omit when empty so existing (character-less) clips keep their cache key.
60492
60506
  characters: opts.characters && opts.characters.length > 0 ? opts.characters : void 0,
60507
+ // Omit when absent so existing (non-keyframe) clips keep their cache key.
60508
+ keyframe: opts.keyframe || void 0,
60493
60509
  ext: "mp4"
60494
60510
  });
60495
60511
  }
@@ -60640,6 +60656,7 @@ async function createBuildPlan(opts) {
60640
60656
  const backdropPrompt = stringOrUndefined3(cue.backdrop);
60641
60657
  const augmentedBackdropPrompt = backdropPrompt ? augmentBackdropPrompt(backdropPrompt) : null;
60642
60658
  const videoPrompt = stringOrUndefined3(cue.video);
60659
+ const keyframePrompt = stringOrUndefined3(cue.keyframe);
60643
60660
  const musicPrompt = stringOrUndefined3(cue.music);
60644
60661
  const genericReference = resolveGenericAssetReference(projectDir, cue.asset);
60645
60662
  const narrationReference = resolveTypedAssetReference(projectDir, "narration", cue.narration);
@@ -60648,7 +60665,7 @@ async function createBuildPlan(opts) {
60648
60665
  const musicReference = resolveTypedAssetReference(projectDir, "music", cue.music) ?? (!musicPrompt && genericReference?.kind === "music" ? genericReference : null);
60649
60666
  const narrationCue = narrationText ?? narrationReference?.raw;
60650
60667
  const backdropCue = backdropPrompt ?? backdropReference?.raw;
60651
- const videoCue = videoPrompt ?? videoReference?.raw;
60668
+ const videoCue = videoPrompt ?? videoReference?.raw ?? keyframePrompt;
60652
60669
  const musicCue = musicPrompt ?? musicReference?.raw;
60653
60670
  const narrationCost = resolved.narration.resolved === "elevenlabs" ? ELEVENLABS_NARRATION_COST_USD : resolved.narration.resolved === "openai" ? OPENAI_NARRATION_COST_USD : 0;
60654
60671
  const narrationCache = narrationText && !narrationReference ? narrationCacheDescriptor({
@@ -60666,11 +60683,12 @@ async function createBuildPlan(opts) {
60666
60683
  size: imageSize2,
60667
60684
  ratio: imageRatio
60668
60685
  }) : null;
60669
- const videoCache = videoPrompt && !videoReference ? videoCacheDescriptor({
60686
+ const videoCache = (videoPrompt || keyframePrompt) && !videoReference ? videoCacheDescriptor({
60670
60687
  beatId: beat.id,
60671
- cue: videoPrompt,
60688
+ cue: videoPrompt ?? keyframePrompt,
60672
60689
  provider: resolved.video.resolved,
60673
- duration: beat.duration
60690
+ duration: beat.duration,
60691
+ keyframe: keyframePrompt
60674
60692
  }) : null;
60675
60693
  const musicCache = musicPrompt && !musicReference ? musicCacheDescriptor({
60676
60694
  beatId: beat.id,
@@ -60765,6 +60783,12 @@ async function createBuildPlan(opts) {
60765
60783
  if (backdrop?.willGenerate && isSupportedBuildImageProvider(resolved.image.resolved))
60766
60784
  noteProviderNeed(resolved.image, "Backdrop generation");
60767
60785
  if (video?.willGenerate) noteProviderNeed(resolved.video, "Video generation");
60786
+ if (keyframePrompt && video?.willGenerate) {
60787
+ estimatedCostUsd += backdropCostUsd(imageQuality);
60788
+ providers.add(resolved.image.resolved);
60789
+ if (isSupportedBuildImageProvider(resolved.image.resolved))
60790
+ noteProviderNeed(resolved.image, "Keyframe generation");
60791
+ }
60768
60792
  if (music?.willGenerate) noteProviderNeed(resolved.music, "Music generation");
60769
60793
  if (!compositionExists) missing.add("compositions");
60770
60794
  if (includeCompose && !compositionExists && mode !== "agent") {
@@ -62670,6 +62694,8 @@ async function buildBeatPrimitives(beat, ctx) {
62670
62694
  videoMetadataPath: video.metadataPath,
62671
62695
  videoFreshness: video.freshness,
62672
62696
  videoSourcePath: video.sourcePath,
62697
+ keyframeStatus: video.keyframeStatus,
62698
+ keyframePath: video.keyframePath,
62673
62699
  musicStatus: music.status,
62674
62700
  musicPath: music.path,
62675
62701
  musicJobId: music.job?.id,
@@ -63114,6 +63140,112 @@ async function generateBackdropImage(prompt3, ctx, ratio) {
63114
63140
  });
63115
63141
  return imageBufferFromResult(result);
63116
63142
  }
63143
+ async function editKeyframeImage(prompt3, sheetBuffers, ctx, ratio) {
63144
+ loadSceneBuildEnv(ctx.projectDir);
63145
+ const keyInfo = imageProviderKeyInfo(ctx.imageProvider);
63146
+ const apiKey = await getApiKeyFromConfig(keyInfo.configKey, { cwd: ctx.projectDir }) ?? process.env[keyInfo.envVar] ?? "";
63147
+ if (!apiKey) {
63148
+ return {
63149
+ success: false,
63150
+ error: `${keyInfo.envVar} not set \u2014 cannot generate keyframe with ${ctx.imageProvider}`
63151
+ };
63152
+ }
63153
+ if (ctx.imageProvider === "openai") {
63154
+ const provider2 = new OpenAIImageProvider();
63155
+ await provider2.initialize({ apiKey });
63156
+ const result2 = await provider2.editImage(sheetBuffers, prompt3);
63157
+ return imageBufferFromResult(result2);
63158
+ }
63159
+ if (ctx.imageProvider === "gemini") {
63160
+ const provider2 = new GeminiProvider();
63161
+ await provider2.initialize({ apiKey });
63162
+ const result2 = await provider2.editImage(sheetBuffers, prompt3, {
63163
+ model: "flash",
63164
+ aspectRatio: ratio
63165
+ });
63166
+ return imageBufferFromResult(result2);
63167
+ }
63168
+ const provider = new GrokProvider();
63169
+ await provider.initialize({ apiKey });
63170
+ const result = await provider.editImage(sheetBuffers[0], prompt3, { aspectRatio: ratio });
63171
+ return imageBufferFromResult(result);
63172
+ }
63173
+ async function ensureKeyframe(beat, ctx, keyframeCue, characterRefsRel) {
63174
+ const rel = `assets/keyframe-${beat.id}.png`;
63175
+ const abs = join25(ctx.projectDir, rel);
63176
+ const size = ctx.imageSize ?? "1536x1024";
63177
+ const ratio = imageRatioForSize(size);
63178
+ const metadataOptions = { quality: ctx.imageQuality, size, ratio };
63179
+ const cache = keyframeCacheDescriptor({
63180
+ beatId: beat.id,
63181
+ cue: keyframeCue,
63182
+ provider: ctx.imageProvider,
63183
+ quality: ctx.imageQuality,
63184
+ size,
63185
+ ratio,
63186
+ characters: characterRefsRel
63187
+ });
63188
+ if (existsSync49(abs) && !ctx.force) {
63189
+ const metadata = readAssetMetadata(ctx.projectDir, "keyframe", beat.id);
63190
+ if (!metadata || isFreshCanonicalAsset({
63191
+ projectDir: ctx.projectDir,
63192
+ kind: "keyframe",
63193
+ beatId: beat.id,
63194
+ cue: keyframeCue,
63195
+ provider: ctx.imageProvider,
63196
+ options: metadataOptions,
63197
+ cacheKey: cache.key
63198
+ })) {
63199
+ return { status: "cached", rel };
63200
+ }
63201
+ }
63202
+ const cacheAbs = join25(ctx.projectDir, cache.path);
63203
+ if (existsSync49(cacheAbs) && !ctx.force) {
63204
+ await mkdir26(dirname24(abs), { recursive: true });
63205
+ await copyFile5(cacheAbs, abs);
63206
+ await writeAssetMetadata({
63207
+ projectDir: ctx.projectDir,
63208
+ kind: "keyframe",
63209
+ beatId: beat.id,
63210
+ cue: keyframeCue,
63211
+ provider: ctx.imageProvider,
63212
+ options: metadataOptions,
63213
+ cacheKey: cache.key,
63214
+ canonicalPath: rel,
63215
+ cachePath: cache.path
63216
+ });
63217
+ return { status: "cached", rel };
63218
+ }
63219
+ const sheetBuffers = [];
63220
+ for (const relPath of characterRefsRel) {
63221
+ const sheetAbs = join25(ctx.projectDir, relPath);
63222
+ if (existsSync49(sheetAbs)) sheetBuffers.push(await readFile46(sheetAbs));
63223
+ }
63224
+ const imgCtx = {
63225
+ projectDir: ctx.projectDir,
63226
+ imageProvider: ctx.imageProvider,
63227
+ imageSize: size,
63228
+ imageQuality: ctx.imageQuality
63229
+ };
63230
+ const generated = sheetBuffers.length > 0 ? await editKeyframeImage(keyframeCue, sheetBuffers, imgCtx, ratio) : await generateBackdropImage(keyframeCue, imgCtx, ratio);
63231
+ if (!generated.success) return { status: "failed", error: generated.error };
63232
+ await mkdir26(dirname24(abs), { recursive: true });
63233
+ await writeFile50(abs, generated.buffer);
63234
+ await mkdir26(dirname24(cacheAbs), { recursive: true });
63235
+ await writeFile50(cacheAbs, generated.buffer);
63236
+ await writeAssetMetadata({
63237
+ projectDir: ctx.projectDir,
63238
+ kind: "keyframe",
63239
+ beatId: beat.id,
63240
+ cue: keyframeCue,
63241
+ provider: ctx.imageProvider,
63242
+ options: metadataOptions,
63243
+ cacheKey: cache.key,
63244
+ canonicalPath: rel,
63245
+ cachePath: cache.path
63246
+ });
63247
+ return { status: "generated", rel };
63248
+ }
63117
63249
  async function imageBufferFromResult(result) {
63118
63250
  const image = result.images?.[0];
63119
63251
  if (!result.success || !image) {
@@ -63137,7 +63269,8 @@ function imageProviderKeyInfo(provider) {
63137
63269
  async function dispatchVideo(beat, ctx) {
63138
63270
  const reference = assetReferenceForBeat(ctx.projectDir, "video", beat);
63139
63271
  if (reference) return referencePrimitiveOutcome("video", beat, ctx, reference);
63140
- const prompt3 = stringOrUndefined4(beat.cues?.video);
63272
+ const keyframeCue = stringOrUndefined4(beat.cues?.keyframe);
63273
+ const prompt3 = stringOrUndefined4(beat.cues?.video) ?? keyframeCue;
63141
63274
  if (!prompt3) return { status: "no-cue" };
63142
63275
  const characterRefsRel = beatCharacterNames(beat.cues).map((name) => ctx.characterPaths?.get(name)).filter((p) => typeof p === "string");
63143
63276
  const characterRefsAbs = characterRefsRel.map((p) => join25(ctx.projectDir, p));
@@ -63148,7 +63281,10 @@ async function dispatchVideo(beat, ctx) {
63148
63281
  cue: prompt3,
63149
63282
  provider: ctx.videoProvider,
63150
63283
  duration: normalizeVideoDuration(beat.duration),
63151
- characters: characterRefsRel
63284
+ // Keyframe mode keys the clip to the keyframe prompt; refs feed the keyframe,
63285
+ // not the clip directly.
63286
+ characters: keyframeCue ? void 0 : characterRefsRel,
63287
+ keyframe: keyframeCue
63152
63288
  });
63153
63289
  const metadataPath = assetMetadataPath("video", beat.id);
63154
63290
  if (existsSync49(abs) && !ctx.force) {
@@ -63201,6 +63337,19 @@ async function dispatchVideo(beat, ctx) {
63201
63337
  };
63202
63338
  }
63203
63339
  loadSceneBuildEnv(ctx.projectDir);
63340
+ let keyframeStatus;
63341
+ let keyframeRel;
63342
+ let keyframeImageAbs;
63343
+ if (keyframeCue) {
63344
+ const kf = await ensureKeyframe(beat, ctx, keyframeCue, characterRefsRel);
63345
+ if (kf.status === "failed") {
63346
+ ctx.onProgress({ type: "video-failed", beatId: beat.id, error: kf.error });
63347
+ return { status: "failed", error: kf.error };
63348
+ }
63349
+ keyframeStatus = kf.status;
63350
+ keyframeRel = kf.rel;
63351
+ keyframeImageAbs = join25(ctx.projectDir, kf.rel);
63352
+ }
63204
63353
  const result = await executeVideoGenerate({
63205
63354
  prompt: prompt3,
63206
63355
  provider: ctx.videoProvider,
@@ -63208,7 +63357,10 @@ async function dispatchVideo(beat, ctx) {
63208
63357
  ratio: "16:9",
63209
63358
  output: abs,
63210
63359
  wait: false,
63211
- refImages: characterRefsAbs.length > 0 ? characterRefsAbs : void 0,
63360
+ // Keyframe mode → single init frame (image-to-video); otherwise character
63361
+ // reference-to-video.
63362
+ image: keyframeImageAbs,
63363
+ refImages: keyframeImageAbs ? void 0 : characterRefsAbs.length > 0 ? characterRefsAbs : void 0,
63212
63364
  apiKey: await apiKeyForVideoProvider(ctx.videoProvider, ctx.projectDir)
63213
63365
  });
63214
63366
  if (!result.success || !result.taskId) {
@@ -63243,14 +63395,16 @@ async function dispatchVideo(beat, ctx) {
63243
63395
  cachePath: cache.path,
63244
63396
  cacheKey: cache.key,
63245
63397
  metadataPath,
63246
- freshness: "fresh"
63398
+ freshness: "fresh",
63399
+ keyframeStatus,
63400
+ keyframePath: keyframeRel
63247
63401
  };
63248
63402
  }
63249
63403
  const job = await createAndWriteJobRecord({
63250
63404
  jobType: "generate-video",
63251
63405
  provider: result.provider ?? ctx.videoProvider,
63252
63406
  providerTaskId: result.taskId,
63253
- providerTaskType: "text2video",
63407
+ providerTaskType: keyframeImageAbs ? "image2video" : "text2video",
63254
63408
  status: "running",
63255
63409
  projectDir: ctx.projectDir,
63256
63410
  workingDirectory: ctx.projectDir,
@@ -63275,7 +63429,9 @@ async function dispatchVideo(beat, ctx) {
63275
63429
  provider: job.provider,
63276
63430
  cachePath: cache.path,
63277
63431
  cacheKey: cache.key,
63278
- metadataPath
63432
+ metadataPath,
63433
+ keyframeStatus,
63434
+ keyframePath: keyframeRel
63279
63435
  };
63280
63436
  }
63281
63437
  async function dispatchMusic(beat, ctx) {
@@ -63577,6 +63733,7 @@ function estimateActualAssetCost(outcomes, imageQuality) {
63577
63733
  for (const outcome of outcomes) {
63578
63734
  if (outcome.narrationStatus === "generated") cost += 0.05;
63579
63735
  if (outcome.backdropStatus === "generated") cost += backdropCostUsd(imageQuality);
63736
+ if (outcome.keyframeStatus === "generated") cost += backdropCostUsd(imageQuality);
63580
63737
  if (outcome.videoStatus === "generated" || outcome.videoStatus === "pending") {
63581
63738
  cost += outcome.videoProvider === "seedance" || outcome.videoProvider === "fal" ? estimateSeedanceVideoCostUsd({
63582
63739
  durationSec: normalizeVideoDuration(outcome.sceneDurationSec),
@@ -74662,7 +74819,7 @@ init_source();
74662
74819
  // package.json
74663
74820
  var package_default = {
74664
74821
  name: "@vibeframe/cli",
74665
- version: "0.113.2",
74822
+ version: "0.113.3",
74666
74823
  description: "VibeFrame CLI - agentic video workflow runtime",
74667
74824
  private: false,
74668
74825
  license: "MIT",
@@ -32480,6 +32480,7 @@ var init_storyboard_edit = __esm({
32480
32480
  "narration",
32481
32481
  "backdrop",
32482
32482
  "video",
32483
+ "keyframe",
32483
32484
  "motion",
32484
32485
  "voice",
32485
32486
  "music",
@@ -32489,7 +32490,7 @@ var init_storyboard_edit = __esm({
32489
32490
  HEADING_RE2 = /^##\s+(.+?)\s*$/gm;
32490
32491
  LEADING_CUE_RE = /^(\s*)```ya?ml\s*\n([\s\S]*?)\n```\s*(?:\n|$)/;
32491
32492
  ALLOWED_CUE_KEYS = new Set(STORYBOARD_CUE_KEYS);
32492
- STRING_CUE_KEYS = /* @__PURE__ */ new Set(["narration", "backdrop", "video", "motion", "voice", "music", "asset"]);
32493
+ STRING_CUE_KEYS = /* @__PURE__ */ new Set(["narration", "backdrop", "video", "keyframe", "motion", "voice", "music", "asset"]);
32493
32494
  MAX_RECOMMENDED_BEAT_SEC = 15;
32494
32495
  }
32495
32496
  });
@@ -33411,6 +33412,7 @@ var init_build_asset_reference = __esm({
33411
33412
  narration: [".mp3", ".wav", ".m4a"],
33412
33413
  backdrop: [".png", ".jpg", ".jpeg", ".webp"],
33413
33414
  character: [".png", ".jpg", ".jpeg", ".webp"],
33415
+ keyframe: [".png", ".jpg", ".jpeg", ".webp"],
33414
33416
  video: [".mp4", ".mov", ".webm"],
33415
33417
  music: [".mp3", ".wav", ".m4a"]
33416
33418
  };
@@ -33458,6 +33460,18 @@ function characterCacheDescriptor(opts) {
33458
33460
  ext: "png"
33459
33461
  });
33460
33462
  }
33463
+ function keyframeCacheDescriptor(opts) {
33464
+ return cacheAssetDescriptor("keyframe", {
33465
+ beatId: opts.beatId,
33466
+ cue: opts.cue,
33467
+ provider: opts.provider,
33468
+ quality: opts.quality,
33469
+ size: opts.size,
33470
+ ratio: opts.ratio,
33471
+ characters: opts.characters && opts.characters.length > 0 ? opts.characters : void 0,
33472
+ ext: "png"
33473
+ });
33474
+ }
33461
33475
  function imageRatioForSize(size) {
33462
33476
  switch (size) {
33463
33477
  case "1024x1024":
@@ -33479,6 +33493,8 @@ function videoCacheDescriptor(opts) {
33479
33493
  ratio: "16:9",
33480
33494
  // Omit when empty so existing (character-less) clips keep their cache key.
33481
33495
  characters: opts.characters && opts.characters.length > 0 ? opts.characters : void 0,
33496
+ // Omit when absent so existing (non-keyframe) clips keep their cache key.
33497
+ keyframe: opts.keyframe || void 0,
33482
33498
  ext: "mp4"
33483
33499
  });
33484
33500
  }
@@ -33629,6 +33645,7 @@ async function createBuildPlan(opts) {
33629
33645
  const backdropPrompt = stringOrUndefined3(cue.backdrop);
33630
33646
  const augmentedBackdropPrompt = backdropPrompt ? augmentBackdropPrompt(backdropPrompt) : null;
33631
33647
  const videoPrompt = stringOrUndefined3(cue.video);
33648
+ const keyframePrompt = stringOrUndefined3(cue.keyframe);
33632
33649
  const musicPrompt = stringOrUndefined3(cue.music);
33633
33650
  const genericReference = resolveGenericAssetReference(projectDir, cue.asset);
33634
33651
  const narrationReference = resolveTypedAssetReference(projectDir, "narration", cue.narration);
@@ -33637,7 +33654,7 @@ async function createBuildPlan(opts) {
33637
33654
  const musicReference = resolveTypedAssetReference(projectDir, "music", cue.music) ?? (!musicPrompt && genericReference?.kind === "music" ? genericReference : null);
33638
33655
  const narrationCue = narrationText ?? narrationReference?.raw;
33639
33656
  const backdropCue = backdropPrompt ?? backdropReference?.raw;
33640
- const videoCue = videoPrompt ?? videoReference?.raw;
33657
+ const videoCue = videoPrompt ?? videoReference?.raw ?? keyframePrompt;
33641
33658
  const musicCue = musicPrompt ?? musicReference?.raw;
33642
33659
  const narrationCost = resolved.narration.resolved === "elevenlabs" ? ELEVENLABS_NARRATION_COST_USD : resolved.narration.resolved === "openai" ? OPENAI_NARRATION_COST_USD : 0;
33643
33660
  const narrationCache = narrationText && !narrationReference ? narrationCacheDescriptor({
@@ -33655,11 +33672,12 @@ async function createBuildPlan(opts) {
33655
33672
  size: imageSize2,
33656
33673
  ratio: imageRatio
33657
33674
  }) : null;
33658
- const videoCache = videoPrompt && !videoReference ? videoCacheDescriptor({
33675
+ const videoCache = (videoPrompt || keyframePrompt) && !videoReference ? videoCacheDescriptor({
33659
33676
  beatId: beat.id,
33660
- cue: videoPrompt,
33677
+ cue: videoPrompt ?? keyframePrompt,
33661
33678
  provider: resolved.video.resolved,
33662
- duration: beat.duration
33679
+ duration: beat.duration,
33680
+ keyframe: keyframePrompt
33663
33681
  }) : null;
33664
33682
  const musicCache = musicPrompt && !musicReference ? musicCacheDescriptor({
33665
33683
  beatId: beat.id,
@@ -33754,6 +33772,12 @@ async function createBuildPlan(opts) {
33754
33772
  if (backdrop?.willGenerate && isSupportedBuildImageProvider(resolved.image.resolved))
33755
33773
  noteProviderNeed(resolved.image, "Backdrop generation");
33756
33774
  if (video?.willGenerate) noteProviderNeed(resolved.video, "Video generation");
33775
+ if (keyframePrompt && video?.willGenerate) {
33776
+ estimatedCostUsd += backdropCostUsd(imageQuality);
33777
+ providers.add(resolved.image.resolved);
33778
+ if (isSupportedBuildImageProvider(resolved.image.resolved))
33779
+ noteProviderNeed(resolved.image, "Keyframe generation");
33780
+ }
33757
33781
  if (music?.willGenerate) noteProviderNeed(resolved.music, "Music generation");
33758
33782
  if (!compositionExists) missing.add("compositions");
33759
33783
  if (includeCompose && !compositionExists && mode !== "agent") {
@@ -35164,6 +35188,8 @@ async function buildBeatPrimitives(beat, ctx) {
35164
35188
  videoMetadataPath: video.metadataPath,
35165
35189
  videoFreshness: video.freshness,
35166
35190
  videoSourcePath: video.sourcePath,
35191
+ keyframeStatus: video.keyframeStatus,
35192
+ keyframePath: video.keyframePath,
35167
35193
  musicStatus: music.status,
35168
35194
  musicPath: music.path,
35169
35195
  musicJobId: music.job?.id,
@@ -35608,6 +35634,112 @@ async function generateBackdropImage(prompt3, ctx, ratio) {
35608
35634
  });
35609
35635
  return imageBufferFromResult(result);
35610
35636
  }
35637
+ async function editKeyframeImage(prompt3, sheetBuffers, ctx, ratio) {
35638
+ loadSceneBuildEnv(ctx.projectDir);
35639
+ const keyInfo = imageProviderKeyInfo(ctx.imageProvider);
35640
+ const apiKey = await getApiKeyFromConfig(keyInfo.configKey, { cwd: ctx.projectDir }) ?? process.env[keyInfo.envVar] ?? "";
35641
+ if (!apiKey) {
35642
+ return {
35643
+ success: false,
35644
+ error: `${keyInfo.envVar} not set \u2014 cannot generate keyframe with ${ctx.imageProvider}`
35645
+ };
35646
+ }
35647
+ if (ctx.imageProvider === "openai") {
35648
+ const provider2 = new OpenAIImageProvider();
35649
+ await provider2.initialize({ apiKey });
35650
+ const result2 = await provider2.editImage(sheetBuffers, prompt3);
35651
+ return imageBufferFromResult(result2);
35652
+ }
35653
+ if (ctx.imageProvider === "gemini") {
35654
+ const provider2 = new GeminiProvider();
35655
+ await provider2.initialize({ apiKey });
35656
+ const result2 = await provider2.editImage(sheetBuffers, prompt3, {
35657
+ model: "flash",
35658
+ aspectRatio: ratio
35659
+ });
35660
+ return imageBufferFromResult(result2);
35661
+ }
35662
+ const provider = new GrokProvider();
35663
+ await provider.initialize({ apiKey });
35664
+ const result = await provider.editImage(sheetBuffers[0], prompt3, { aspectRatio: ratio });
35665
+ return imageBufferFromResult(result);
35666
+ }
35667
+ async function ensureKeyframe(beat, ctx, keyframeCue, characterRefsRel) {
35668
+ const rel = `assets/keyframe-${beat.id}.png`;
35669
+ const abs = join16(ctx.projectDir, rel);
35670
+ const size = ctx.imageSize ?? "1536x1024";
35671
+ const ratio = imageRatioForSize(size);
35672
+ const metadataOptions = { quality: ctx.imageQuality, size, ratio };
35673
+ const cache = keyframeCacheDescriptor({
35674
+ beatId: beat.id,
35675
+ cue: keyframeCue,
35676
+ provider: ctx.imageProvider,
35677
+ quality: ctx.imageQuality,
35678
+ size,
35679
+ ratio,
35680
+ characters: characterRefsRel
35681
+ });
35682
+ if (existsSync19(abs) && !ctx.force) {
35683
+ const metadata = readAssetMetadata(ctx.projectDir, "keyframe", beat.id);
35684
+ if (!metadata || isFreshCanonicalAsset({
35685
+ projectDir: ctx.projectDir,
35686
+ kind: "keyframe",
35687
+ beatId: beat.id,
35688
+ cue: keyframeCue,
35689
+ provider: ctx.imageProvider,
35690
+ options: metadataOptions,
35691
+ cacheKey: cache.key
35692
+ })) {
35693
+ return { status: "cached", rel };
35694
+ }
35695
+ }
35696
+ const cacheAbs = join16(ctx.projectDir, cache.path);
35697
+ if (existsSync19(cacheAbs) && !ctx.force) {
35698
+ await mkdir10(dirname10(abs), { recursive: true });
35699
+ await copyFile3(cacheAbs, abs);
35700
+ await writeAssetMetadata({
35701
+ projectDir: ctx.projectDir,
35702
+ kind: "keyframe",
35703
+ beatId: beat.id,
35704
+ cue: keyframeCue,
35705
+ provider: ctx.imageProvider,
35706
+ options: metadataOptions,
35707
+ cacheKey: cache.key,
35708
+ canonicalPath: rel,
35709
+ cachePath: cache.path
35710
+ });
35711
+ return { status: "cached", rel };
35712
+ }
35713
+ const sheetBuffers = [];
35714
+ for (const relPath of characterRefsRel) {
35715
+ const sheetAbs = join16(ctx.projectDir, relPath);
35716
+ if (existsSync19(sheetAbs)) sheetBuffers.push(await readFile18(sheetAbs));
35717
+ }
35718
+ const imgCtx = {
35719
+ projectDir: ctx.projectDir,
35720
+ imageProvider: ctx.imageProvider,
35721
+ imageSize: size,
35722
+ imageQuality: ctx.imageQuality
35723
+ };
35724
+ const generated = sheetBuffers.length > 0 ? await editKeyframeImage(keyframeCue, sheetBuffers, imgCtx, ratio) : await generateBackdropImage(keyframeCue, imgCtx, ratio);
35725
+ if (!generated.success) return { status: "failed", error: generated.error };
35726
+ await mkdir10(dirname10(abs), { recursive: true });
35727
+ await writeFile18(abs, generated.buffer);
35728
+ await mkdir10(dirname10(cacheAbs), { recursive: true });
35729
+ await writeFile18(cacheAbs, generated.buffer);
35730
+ await writeAssetMetadata({
35731
+ projectDir: ctx.projectDir,
35732
+ kind: "keyframe",
35733
+ beatId: beat.id,
35734
+ cue: keyframeCue,
35735
+ provider: ctx.imageProvider,
35736
+ options: metadataOptions,
35737
+ cacheKey: cache.key,
35738
+ canonicalPath: rel,
35739
+ cachePath: cache.path
35740
+ });
35741
+ return { status: "generated", rel };
35742
+ }
35611
35743
  async function imageBufferFromResult(result) {
35612
35744
  const image = result.images?.[0];
35613
35745
  if (!result.success || !image) {
@@ -35631,7 +35763,8 @@ function imageProviderKeyInfo(provider) {
35631
35763
  async function dispatchVideo(beat, ctx) {
35632
35764
  const reference = assetReferenceForBeat(ctx.projectDir, "video", beat);
35633
35765
  if (reference) return referencePrimitiveOutcome("video", beat, ctx, reference);
35634
- const prompt3 = stringOrUndefined4(beat.cues?.video);
35766
+ const keyframeCue = stringOrUndefined4(beat.cues?.keyframe);
35767
+ const prompt3 = stringOrUndefined4(beat.cues?.video) ?? keyframeCue;
35635
35768
  if (!prompt3) return { status: "no-cue" };
35636
35769
  const characterRefsRel = beatCharacterNames(beat.cues).map((name) => ctx.characterPaths?.get(name)).filter((p) => typeof p === "string");
35637
35770
  const characterRefsAbs = characterRefsRel.map((p) => join16(ctx.projectDir, p));
@@ -35642,7 +35775,10 @@ async function dispatchVideo(beat, ctx) {
35642
35775
  cue: prompt3,
35643
35776
  provider: ctx.videoProvider,
35644
35777
  duration: normalizeVideoDuration(beat.duration),
35645
- characters: characterRefsRel
35778
+ // Keyframe mode keys the clip to the keyframe prompt; refs feed the keyframe,
35779
+ // not the clip directly.
35780
+ characters: keyframeCue ? void 0 : characterRefsRel,
35781
+ keyframe: keyframeCue
35646
35782
  });
35647
35783
  const metadataPath = assetMetadataPath("video", beat.id);
35648
35784
  if (existsSync19(abs) && !ctx.force) {
@@ -35695,6 +35831,19 @@ async function dispatchVideo(beat, ctx) {
35695
35831
  };
35696
35832
  }
35697
35833
  loadSceneBuildEnv(ctx.projectDir);
35834
+ let keyframeStatus;
35835
+ let keyframeRel;
35836
+ let keyframeImageAbs;
35837
+ if (keyframeCue) {
35838
+ const kf = await ensureKeyframe(beat, ctx, keyframeCue, characterRefsRel);
35839
+ if (kf.status === "failed") {
35840
+ ctx.onProgress({ type: "video-failed", beatId: beat.id, error: kf.error });
35841
+ return { status: "failed", error: kf.error };
35842
+ }
35843
+ keyframeStatus = kf.status;
35844
+ keyframeRel = kf.rel;
35845
+ keyframeImageAbs = join16(ctx.projectDir, kf.rel);
35846
+ }
35698
35847
  const result = await executeVideoGenerate({
35699
35848
  prompt: prompt3,
35700
35849
  provider: ctx.videoProvider,
@@ -35702,7 +35851,10 @@ async function dispatchVideo(beat, ctx) {
35702
35851
  ratio: "16:9",
35703
35852
  output: abs,
35704
35853
  wait: false,
35705
- refImages: characterRefsAbs.length > 0 ? characterRefsAbs : void 0,
35854
+ // Keyframe mode → single init frame (image-to-video); otherwise character
35855
+ // reference-to-video.
35856
+ image: keyframeImageAbs,
35857
+ refImages: keyframeImageAbs ? void 0 : characterRefsAbs.length > 0 ? characterRefsAbs : void 0,
35706
35858
  apiKey: await apiKeyForVideoProvider(ctx.videoProvider, ctx.projectDir)
35707
35859
  });
35708
35860
  if (!result.success || !result.taskId) {
@@ -35737,14 +35889,16 @@ async function dispatchVideo(beat, ctx) {
35737
35889
  cachePath: cache.path,
35738
35890
  cacheKey: cache.key,
35739
35891
  metadataPath,
35740
- freshness: "fresh"
35892
+ freshness: "fresh",
35893
+ keyframeStatus,
35894
+ keyframePath: keyframeRel
35741
35895
  };
35742
35896
  }
35743
35897
  const job = await createAndWriteJobRecord({
35744
35898
  jobType: "generate-video",
35745
35899
  provider: result.provider ?? ctx.videoProvider,
35746
35900
  providerTaskId: result.taskId,
35747
- providerTaskType: "text2video",
35901
+ providerTaskType: keyframeImageAbs ? "image2video" : "text2video",
35748
35902
  status: "running",
35749
35903
  projectDir: ctx.projectDir,
35750
35904
  workingDirectory: ctx.projectDir,
@@ -35769,7 +35923,9 @@ async function dispatchVideo(beat, ctx) {
35769
35923
  provider: job.provider,
35770
35924
  cachePath: cache.path,
35771
35925
  cacheKey: cache.key,
35772
- metadataPath
35926
+ metadataPath,
35927
+ keyframeStatus,
35928
+ keyframePath: keyframeRel
35773
35929
  };
35774
35930
  }
35775
35931
  async function dispatchMusic(beat, ctx) {
@@ -36071,6 +36227,7 @@ function estimateActualAssetCost(outcomes, imageQuality) {
36071
36227
  for (const outcome of outcomes) {
36072
36228
  if (outcome.narrationStatus === "generated") cost += 0.05;
36073
36229
  if (outcome.backdropStatus === "generated") cost += backdropCostUsd(imageQuality);
36230
+ if (outcome.keyframeStatus === "generated") cost += backdropCostUsd(imageQuality);
36074
36231
  if (outcome.videoStatus === "generated" || outcome.videoStatus === "pending") {
36075
36232
  cost += outcome.videoProvider === "seedance" || outcome.videoProvider === "fal" ? estimateSeedanceVideoCostUsd({
36076
36233
  durationSec: normalizeVideoDuration(outcome.sceneDurationSec),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibeframe/cli",
3
- "version": "0.113.2",
3
+ "version": "0.113.3",
4
4
  "description": "VibeFrame CLI - agentic video workflow runtime",
5
5
  "private": false,
6
6
  "license": "MIT",
@@ -83,8 +83,8 @@
83
83
  "tsx": "^4.21.0",
84
84
  "typescript": "^5.3.3",
85
85
  "vitest": "^1.2.2",
86
- "@vibeframe/ai-providers": "0.113.2",
87
- "@vibeframe/core": "0.113.2"
86
+ "@vibeframe/core": "0.113.3",
87
+ "@vibeframe/ai-providers": "0.113.3"
88
88
  },
89
89
  "engines": {
90
90
  "node": ">=20"