@vibeframe/cli 0.113.2 → 0.113.4

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;
@@ -15,6 +15,7 @@ export interface BuildPlanBeat {
15
15
  assets: {
16
16
  narration: AssetPlan | null;
17
17
  backdrop: AssetPlan | null;
18
+ keyframe: AssetPlan | null;
18
19
  video: AssetPlan | null;
19
20
  music: AssetPlan | null;
20
21
  };
@@ -93,6 +94,7 @@ export interface CreateBuildPlanOptions {
93
94
  skipNarration?: boolean;
94
95
  skipBackdrop?: boolean;
95
96
  skipVideo?: boolean;
97
+ skipKeyframe?: boolean;
96
98
  skipMusic?: boolean;
97
99
  ttsProvider?: string;
98
100
  voice?: string;
@@ -81,6 +81,23 @@ export type SceneBuildProgressEvent = {
81
81
  type: "backdrop-skipped";
82
82
  beatId: string;
83
83
  reason: string;
84
+ } | {
85
+ type: "keyframe-cached";
86
+ beatId: string;
87
+ path: string;
88
+ } | {
89
+ type: "keyframe-generated";
90
+ beatId: string;
91
+ path: string;
92
+ provider: string;
93
+ } | {
94
+ type: "keyframe-failed";
95
+ beatId: string;
96
+ error: string;
97
+ } | {
98
+ type: "keyframe-skipped";
99
+ beatId: string;
100
+ reason: string;
84
101
  } | {
85
102
  type: "video-cached";
86
103
  beatId: string;
@@ -168,6 +185,9 @@ export interface BeatBuildOutcome {
168
185
  videoMetadataPath?: string;
169
186
  videoFreshness?: AssetFreshness;
170
187
  videoSourcePath?: string;
188
+ /** Keyframe still status when the beat uses keyframe → image-to-video mode. */
189
+ keyframeStatus?: PrimitiveStatus;
190
+ keyframePath?: string;
171
191
  musicStatus: PrimitiveStatus;
172
192
  musicPath?: string;
173
193
  musicJobId?: string;
@@ -230,6 +250,8 @@ export interface SceneBuildOptions {
230
250
  musicProvider?: BuildMusicProvider;
231
251
  /** Skip AI video generation even when beats declare video cues. */
232
252
  skipVideo?: boolean;
253
+ /** Skip keyframe-still generation even when beats declare keyframe cues. */
254
+ skipKeyframe?: boolean;
233
255
  /** Skip music generation even when beats declare music cues. */
234
256
  skipMusic?: boolean;
235
257
  /** OpenAI image quality — see `vibe generate image --quality`. */
@@ -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,20 @@ 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
60692
+ }) : null;
60693
+ const keyframeCache = keyframePrompt ? keyframeCacheDescriptor({
60694
+ beatId: beat.id,
60695
+ cue: keyframePrompt,
60696
+ provider: resolved.image.resolved,
60697
+ quality: imageQuality,
60698
+ size: imageSize2,
60699
+ ratio: imageRatio
60674
60700
  }) : null;
60675
60701
  const musicCache = musicPrompt && !musicReference ? musicCacheDescriptor({
60676
60702
  beatId: beat.id,
@@ -60707,6 +60733,19 @@ async function createBuildPlan(opts) {
60707
60733
  cost: backdropCostUsd(imageQuality),
60708
60734
  active: includeAssets
60709
60735
  }) : null;
60736
+ const keyframe = keyframePrompt && !opts.skipKeyframe ? assetPlan({
60737
+ kind: "keyframe",
60738
+ beatId: beat.id,
60739
+ cue: keyframePrompt,
60740
+ provider: resolved.image.resolved,
60741
+ path: `assets/keyframe-${beat.id}.png`,
60742
+ cache: keyframeCache,
60743
+ reference: null,
60744
+ projectDir,
60745
+ force: opts.force,
60746
+ cost: backdropCostUsd(imageQuality),
60747
+ active: includeAssets
60748
+ }) : null;
60710
60749
  const video = videoCue && !opts.skipVideo ? assetPlan({
60711
60750
  kind: "video",
60712
60751
  beatId: beat.id,
@@ -60739,7 +60778,7 @@ async function createBuildPlan(opts) {
60739
60778
  }) : null;
60740
60779
  const compositionPath = `compositions/scene-${beat.id}.html`;
60741
60780
  const compositionExists = existsSync47(join23(projectDir, compositionPath));
60742
- for (const asset of [narration, backdrop, video, music]) {
60781
+ for (const asset of [narration, backdrop, keyframe, video, music]) {
60743
60782
  if (!asset) continue;
60744
60783
  if (asset.referenceError) {
60745
60784
  missing.add("assets");
@@ -60764,6 +60803,8 @@ async function createBuildPlan(opts) {
60764
60803
  if (narration?.willGenerate) noteProviderNeed(resolved.narration, "Narration");
60765
60804
  if (backdrop?.willGenerate && isSupportedBuildImageProvider(resolved.image.resolved))
60766
60805
  noteProviderNeed(resolved.image, "Backdrop generation");
60806
+ if (keyframe?.willGenerate && isSupportedBuildImageProvider(resolved.image.resolved))
60807
+ noteProviderNeed(resolved.image, "Keyframe generation");
60767
60808
  if (video?.willGenerate) noteProviderNeed(resolved.video, "Video generation");
60768
60809
  if (music?.willGenerate) noteProviderNeed(resolved.music, "Music generation");
60769
60810
  if (!compositionExists) missing.add("compositions");
@@ -60777,7 +60818,7 @@ async function createBuildPlan(opts) {
60777
60818
  heading: beat.heading,
60778
60819
  durationSec: beat.duration ?? null,
60779
60820
  cues: cue,
60780
- assets: { narration, backdrop, video, music },
60821
+ assets: { narration, backdrop, keyframe, video, music },
60781
60822
  composition: {
60782
60823
  path: compositionPath,
60783
60824
  exists: compositionExists
@@ -60993,6 +61034,7 @@ function providerResolutionsForPlan(resolved, beats, opts, includeAssets) {
60993
61034
  return [
60994
61035
  includeAssets && !opts.skipNarration && needsProvider("narration") ? resolved.narration : null,
60995
61036
  includeAssets && !opts.skipBackdrop && needsProvider("backdrop") ? resolved.image : null,
61037
+ includeAssets && !opts.skipKeyframe && needsProvider("keyframe") ? resolved.image : null,
60996
61038
  includeAssets && !opts.skipVideo && needsProvider("video") ? resolved.video : null,
60997
61039
  includeAssets && !opts.skipMusic && needsProvider("music") ? resolved.music : null,
60998
61040
  resolved.composer ?? null
@@ -62062,6 +62104,7 @@ async function executeSceneBuild(opts) {
62062
62104
  skipNarration: opts.skipNarration,
62063
62105
  skipBackdrop: opts.skipBackdrop,
62064
62106
  skipVideo: opts.skipVideo,
62107
+ skipKeyframe: opts.skipKeyframe,
62065
62108
  skipMusic: opts.skipMusic,
62066
62109
  ttsProvider: opts.ttsProvider,
62067
62110
  voice: opts.voice,
@@ -62247,6 +62290,7 @@ async function executeSceneBuild(opts) {
62247
62290
  skipNarration: opts.skipNarration ?? false,
62248
62291
  skipBackdrop: opts.skipBackdrop ?? false,
62249
62292
  skipVideo: opts.skipVideo ?? false,
62293
+ skipKeyframe: opts.skipKeyframe ?? false,
62250
62294
  skipMusic: opts.skipMusic ?? false,
62251
62295
  force: opts.force ?? false,
62252
62296
  onProgress,
@@ -62623,10 +62667,11 @@ async function executeSceneBuild(opts) {
62623
62667
  });
62624
62668
  }
62625
62669
  async function buildBeatPrimitives(beat, ctx) {
62670
+ const keyframe = ctx.skipKeyframe ? await skipped("keyframe", beat.id, "--skip-keyframe", ctx) : await dispatchKeyframe(beat, ctx);
62626
62671
  const [narration, backdrop, video, music] = await Promise.all([
62627
62672
  ctx.skipNarration ? skipped("narration", beat.id, "--skip-narration", ctx) : dispatchNarration(beat, ctx),
62628
62673
  ctx.skipBackdrop ? skipped("backdrop", beat.id, "--skip-backdrop", ctx) : dispatchBackdrop(beat, ctx),
62629
- ctx.skipVideo ? skipped("video", beat.id, "--skip-video", ctx) : dispatchVideo(beat, ctx),
62674
+ ctx.skipVideo ? skipped("video", beat.id, "--skip-video", ctx) : dispatchVideo(beat, ctx, keyframe),
62630
62675
  ctx.skipMusic ? skipped("music", beat.id, "--skip-music", ctx) : dispatchMusic(beat, ctx)
62631
62676
  ]);
62632
62677
  return {
@@ -62670,6 +62715,8 @@ async function buildBeatPrimitives(beat, ctx) {
62670
62715
  videoMetadataPath: video.metadataPath,
62671
62716
  videoFreshness: video.freshness,
62672
62717
  videoSourcePath: video.sourcePath,
62718
+ keyframeStatus: keyframe.status,
62719
+ keyframePath: keyframe.path,
62673
62720
  musicStatus: music.status,
62674
62721
  musicPath: music.path,
62675
62722
  musicJobId: music.job?.id,
@@ -63114,6 +63161,112 @@ async function generateBackdropImage(prompt3, ctx, ratio) {
63114
63161
  });
63115
63162
  return imageBufferFromResult(result);
63116
63163
  }
63164
+ async function editKeyframeImage(prompt3, sheetBuffers, ctx, ratio) {
63165
+ loadSceneBuildEnv(ctx.projectDir);
63166
+ const keyInfo = imageProviderKeyInfo(ctx.imageProvider);
63167
+ const apiKey = await getApiKeyFromConfig(keyInfo.configKey, { cwd: ctx.projectDir }) ?? process.env[keyInfo.envVar] ?? "";
63168
+ if (!apiKey) {
63169
+ return {
63170
+ success: false,
63171
+ error: `${keyInfo.envVar} not set \u2014 cannot generate keyframe with ${ctx.imageProvider}`
63172
+ };
63173
+ }
63174
+ if (ctx.imageProvider === "openai") {
63175
+ const provider2 = new OpenAIImageProvider();
63176
+ await provider2.initialize({ apiKey });
63177
+ const result2 = await provider2.editImage(sheetBuffers, prompt3);
63178
+ return imageBufferFromResult(result2);
63179
+ }
63180
+ if (ctx.imageProvider === "gemini") {
63181
+ const provider2 = new GeminiProvider();
63182
+ await provider2.initialize({ apiKey });
63183
+ const result2 = await provider2.editImage(sheetBuffers, prompt3, {
63184
+ model: "flash",
63185
+ aspectRatio: ratio
63186
+ });
63187
+ return imageBufferFromResult(result2);
63188
+ }
63189
+ const provider = new GrokProvider();
63190
+ await provider.initialize({ apiKey });
63191
+ const result = await provider.editImage(sheetBuffers[0], prompt3, { aspectRatio: ratio });
63192
+ return imageBufferFromResult(result);
63193
+ }
63194
+ async function ensureKeyframe(beat, ctx, keyframeCue, characterRefsRel) {
63195
+ const rel = `assets/keyframe-${beat.id}.png`;
63196
+ const abs = join25(ctx.projectDir, rel);
63197
+ const size = ctx.imageSize ?? "1536x1024";
63198
+ const ratio = imageRatioForSize(size);
63199
+ const metadataOptions = { quality: ctx.imageQuality, size, ratio };
63200
+ const cache = keyframeCacheDescriptor({
63201
+ beatId: beat.id,
63202
+ cue: keyframeCue,
63203
+ provider: ctx.imageProvider,
63204
+ quality: ctx.imageQuality,
63205
+ size,
63206
+ ratio,
63207
+ characters: characterRefsRel
63208
+ });
63209
+ if (existsSync49(abs) && !ctx.force) {
63210
+ const metadata = readAssetMetadata(ctx.projectDir, "keyframe", beat.id);
63211
+ if (!metadata || isFreshCanonicalAsset({
63212
+ projectDir: ctx.projectDir,
63213
+ kind: "keyframe",
63214
+ beatId: beat.id,
63215
+ cue: keyframeCue,
63216
+ provider: ctx.imageProvider,
63217
+ options: metadataOptions,
63218
+ cacheKey: cache.key
63219
+ })) {
63220
+ return { status: "cached", rel };
63221
+ }
63222
+ }
63223
+ const cacheAbs = join25(ctx.projectDir, cache.path);
63224
+ if (existsSync49(cacheAbs) && !ctx.force) {
63225
+ await mkdir26(dirname24(abs), { recursive: true });
63226
+ await copyFile5(cacheAbs, abs);
63227
+ await writeAssetMetadata({
63228
+ projectDir: ctx.projectDir,
63229
+ kind: "keyframe",
63230
+ beatId: beat.id,
63231
+ cue: keyframeCue,
63232
+ provider: ctx.imageProvider,
63233
+ options: metadataOptions,
63234
+ cacheKey: cache.key,
63235
+ canonicalPath: rel,
63236
+ cachePath: cache.path
63237
+ });
63238
+ return { status: "cached", rel };
63239
+ }
63240
+ const sheetBuffers = [];
63241
+ for (const relPath of characterRefsRel) {
63242
+ const sheetAbs = join25(ctx.projectDir, relPath);
63243
+ if (existsSync49(sheetAbs)) sheetBuffers.push(await readFile46(sheetAbs));
63244
+ }
63245
+ const imgCtx = {
63246
+ projectDir: ctx.projectDir,
63247
+ imageProvider: ctx.imageProvider,
63248
+ imageSize: size,
63249
+ imageQuality: ctx.imageQuality
63250
+ };
63251
+ const generated = sheetBuffers.length > 0 ? await editKeyframeImage(keyframeCue, sheetBuffers, imgCtx, ratio) : await generateBackdropImage(keyframeCue, imgCtx, ratio);
63252
+ if (!generated.success) return { status: "failed", error: generated.error };
63253
+ await mkdir26(dirname24(abs), { recursive: true });
63254
+ await writeFile50(abs, generated.buffer);
63255
+ await mkdir26(dirname24(cacheAbs), { recursive: true });
63256
+ await writeFile50(cacheAbs, generated.buffer);
63257
+ await writeAssetMetadata({
63258
+ projectDir: ctx.projectDir,
63259
+ kind: "keyframe",
63260
+ beatId: beat.id,
63261
+ cue: keyframeCue,
63262
+ provider: ctx.imageProvider,
63263
+ options: metadataOptions,
63264
+ cacheKey: cache.key,
63265
+ canonicalPath: rel,
63266
+ cachePath: cache.path
63267
+ });
63268
+ return { status: "generated", rel };
63269
+ }
63117
63270
  async function imageBufferFromResult(result) {
63118
63271
  const image = result.images?.[0];
63119
63272
  if (!result.success || !image) {
@@ -63134,10 +63287,25 @@ function imageProviderKeyInfo(provider) {
63134
63287
  if (provider === "grok") return { configKey: "xai", envVar: "XAI_API_KEY" };
63135
63288
  return { configKey: "openai", envVar: "OPENAI_API_KEY" };
63136
63289
  }
63137
- async function dispatchVideo(beat, ctx) {
63290
+ async function dispatchKeyframe(beat, ctx) {
63291
+ const keyframeCue = stringOrUndefined4(beat.cues?.keyframe);
63292
+ if (!keyframeCue) return { status: "no-cue" };
63293
+ const characterRefsRel = beatCharacterNames(beat.cues).map((name) => ctx.characterPaths?.get(name)).filter((p) => typeof p === "string");
63294
+ const kf = await ensureKeyframe(beat, ctx, keyframeCue, characterRefsRel);
63295
+ if (kf.status === "failed") {
63296
+ ctx.onProgress({ type: "keyframe-failed", beatId: beat.id, error: kf.error });
63297
+ return { status: "failed", error: kf.error };
63298
+ }
63299
+ ctx.onProgress(
63300
+ kf.status === "cached" ? { type: "keyframe-cached", beatId: beat.id, path: kf.rel } : { type: "keyframe-generated", beatId: beat.id, path: kf.rel, provider: ctx.imageProvider }
63301
+ );
63302
+ return { status: kf.status, path: kf.rel, provider: ctx.imageProvider };
63303
+ }
63304
+ async function dispatchVideo(beat, ctx, keyframe) {
63138
63305
  const reference = assetReferenceForBeat(ctx.projectDir, "video", beat);
63139
63306
  if (reference) return referencePrimitiveOutcome("video", beat, ctx, reference);
63140
- const prompt3 = stringOrUndefined4(beat.cues?.video);
63307
+ const keyframeCue = stringOrUndefined4(beat.cues?.keyframe);
63308
+ const prompt3 = stringOrUndefined4(beat.cues?.video) ?? keyframeCue;
63141
63309
  if (!prompt3) return { status: "no-cue" };
63142
63310
  const characterRefsRel = beatCharacterNames(beat.cues).map((name) => ctx.characterPaths?.get(name)).filter((p) => typeof p === "string");
63143
63311
  const characterRefsAbs = characterRefsRel.map((p) => join25(ctx.projectDir, p));
@@ -63148,7 +63316,10 @@ async function dispatchVideo(beat, ctx) {
63148
63316
  cue: prompt3,
63149
63317
  provider: ctx.videoProvider,
63150
63318
  duration: normalizeVideoDuration(beat.duration),
63151
- characters: characterRefsRel
63319
+ // Keyframe mode keys the clip to the keyframe prompt; refs feed the keyframe,
63320
+ // not the clip directly.
63321
+ characters: keyframeCue ? void 0 : characterRefsRel,
63322
+ keyframe: keyframeCue
63152
63323
  });
63153
63324
  const metadataPath = assetMetadataPath("video", beat.id);
63154
63325
  if (existsSync49(abs) && !ctx.force) {
@@ -63200,6 +63371,13 @@ async function dispatchVideo(beat, ctx) {
63200
63371
  freshness: "fresh"
63201
63372
  };
63202
63373
  }
63374
+ const keyframeImageRel = keyframeCue && (keyframe.status === "generated" || keyframe.status === "cached") ? keyframe.path : void 0;
63375
+ if (keyframeCue && !keyframeImageRel) {
63376
+ const reason = keyframe.status === "failed" ? `keyframe unavailable: ${keyframe.error ?? "generation failed"}` : "keyframe not generated (build without --skip-keyframe first)";
63377
+ ctx.onProgress({ type: "video-skipped", beatId: beat.id, reason });
63378
+ return { status: "skipped" };
63379
+ }
63380
+ const keyframeImageAbs = keyframeImageRel ? join25(ctx.projectDir, keyframeImageRel) : void 0;
63203
63381
  loadSceneBuildEnv(ctx.projectDir);
63204
63382
  const result = await executeVideoGenerate({
63205
63383
  prompt: prompt3,
@@ -63208,7 +63386,10 @@ async function dispatchVideo(beat, ctx) {
63208
63386
  ratio: "16:9",
63209
63387
  output: abs,
63210
63388
  wait: false,
63211
- refImages: characterRefsAbs.length > 0 ? characterRefsAbs : void 0,
63389
+ // Keyframe mode → single init frame (image-to-video); otherwise character
63390
+ // reference-to-video.
63391
+ image: keyframeImageAbs,
63392
+ refImages: keyframeImageAbs ? void 0 : characterRefsAbs.length > 0 ? characterRefsAbs : void 0,
63212
63393
  apiKey: await apiKeyForVideoProvider(ctx.videoProvider, ctx.projectDir)
63213
63394
  });
63214
63395
  if (!result.success || !result.taskId) {
@@ -63250,7 +63431,7 @@ async function dispatchVideo(beat, ctx) {
63250
63431
  jobType: "generate-video",
63251
63432
  provider: result.provider ?? ctx.videoProvider,
63252
63433
  providerTaskId: result.taskId,
63253
- providerTaskType: "text2video",
63434
+ providerTaskType: keyframeImageAbs ? "image2video" : "text2video",
63254
63435
  status: "running",
63255
63436
  projectDir: ctx.projectDir,
63256
63437
  workingDirectory: ctx.projectDir,
@@ -63577,6 +63758,7 @@ function estimateActualAssetCost(outcomes, imageQuality) {
63577
63758
  for (const outcome of outcomes) {
63578
63759
  if (outcome.narrationStatus === "generated") cost += 0.05;
63579
63760
  if (outcome.backdropStatus === "generated") cost += backdropCostUsd(imageQuality);
63761
+ if (outcome.keyframeStatus === "generated") cost += backdropCostUsd(imageQuality);
63580
63762
  if (outcome.videoStatus === "generated" || outcome.videoStatus === "pending") {
63581
63763
  cost += outcome.videoProvider === "seedance" || outcome.videoProvider === "fal" ? estimateSeedanceVideoCostUsd({
63582
63764
  durationSec: normalizeVideoDuration(outcome.sceneDurationSec),
@@ -74662,7 +74844,7 @@ init_source();
74662
74844
  // package.json
74663
74845
  var package_default = {
74664
74846
  name: "@vibeframe/cli",
74665
- version: "0.113.2",
74847
+ version: "0.113.4",
74666
74848
  description: "VibeFrame CLI - agentic video workflow runtime",
74667
74849
  private: false,
74668
74850
  license: "MIT",
@@ -84948,7 +85130,10 @@ var VALID_STAGES2 = ["assets", "compose", "sync", "render", "all"];
84948
85130
  var VALID_IMAGE_PROVIDERS2 = ["openai", "gemini", "grok"];
84949
85131
  var VALID_VIDEO_PROVIDERS2 = ["seedance", "grok", "kling", "runway", "veo"];
84950
85132
  var VALID_MUSIC_PROVIDERS2 = ["elevenlabs", "replicate"];
84951
- var buildCommand = new Command("build").description("Build a VibeFrame video project from STORYBOARD.md").argument("[project-dir]", "Video project directory", ".").option("--stage <stage>", `Build stage: ${VALID_STAGES2.join("|")}`, "all").option("--beat <id>", "Restrict asset/compose work to one beat id").option("--mode <mode>", "Build mode: agent|batch|auto", "auto").option("--effort <level>", "Compose effort tier (batch mode only): low|medium|high", "medium").option("--composer <provider>", "Batch composer: claude|openai|gemini").option("--max-cost <usd>", "Fail before provider spend when estimated cost exceeds this USD cap").option("--skip-narration", "Don't dispatch TTS even when beats declare narration cues").option("--skip-backdrop", "Don't dispatch image-gen even when beats declare backdrop cues").option("--skip-video", "Don't dispatch video generation even when beats declare video cues").option("--skip-music", "Don't dispatch music generation even when beats declare music cues").option("--skip-render", "Compose only \u2014 don't render to MP4").option("--tts <provider>", "TTS provider: auto|elevenlabs|openai|kokoro").option("--voice <id>", "Voice id").option("--image-provider <name>", `Image provider: ${VALID_IMAGE_PROVIDERS2.join("|")}`).option("--video-provider <name>", `Video provider: ${VALID_VIDEO_PROVIDERS2.join("|")}`).option("--music-provider <name>", `Music provider: ${VALID_MUSIC_PROVIDERS2.join("|")}`).option("--quality <q>", "Image quality: standard|hd", "hd").option("--image-size <s>", "Image size: 1024x1024|1536x1024|1024x1536", "1536x1024").option("--force", "Re-dispatch primitives even when assets already exist").option("--dry-run", "Preview parameters without dispatching").addHelpText(
85133
+ var buildCommand = new Command("build").description("Build a VibeFrame video project from STORYBOARD.md").argument("[project-dir]", "Video project directory", ".").option("--stage <stage>", `Build stage: ${VALID_STAGES2.join("|")}`, "all").option("--beat <id>", "Restrict asset/compose work to one beat id").option("--mode <mode>", "Build mode: agent|batch|auto", "auto").option("--effort <level>", "Compose effort tier (batch mode only): low|medium|high", "medium").option("--composer <provider>", "Batch composer: claude|openai|gemini").option("--max-cost <usd>", "Fail before provider spend when estimated cost exceeds this USD cap").option("--skip-narration", "Don't dispatch TTS even when beats declare narration cues").option("--skip-backdrop", "Don't dispatch image-gen even when beats declare backdrop cues").option("--skip-video", "Don't dispatch video generation even when beats declare video cues").option(
85134
+ "--skip-keyframe",
85135
+ "Don't generate keyframe stills (review keyframes first with --skip-video, then build)"
85136
+ ).option("--skip-music", "Don't dispatch music generation even when beats declare music cues").option("--skip-render", "Compose only \u2014 don't render to MP4").option("--tts <provider>", "TTS provider: auto|elevenlabs|openai|kokoro").option("--voice <id>", "Voice id").option("--image-provider <name>", `Image provider: ${VALID_IMAGE_PROVIDERS2.join("|")}`).option("--video-provider <name>", `Video provider: ${VALID_VIDEO_PROVIDERS2.join("|")}`).option("--music-provider <name>", `Music provider: ${VALID_MUSIC_PROVIDERS2.join("|")}`).option("--quality <q>", "Image quality: standard|hd", "hd").option("--image-size <s>", "Image size: 1024x1024|1536x1024|1024x1536", "1536x1024").option("--force", "Re-dispatch primitives even when assets already exist").option("--dry-run", "Preview parameters without dispatching").addHelpText(
84952
85137
  "after",
84953
85138
  `
84954
85139
  Examples:
@@ -85007,6 +85192,7 @@ Advanced equivalent: \`vibe scene build\`.`
85007
85192
  skipNarration: options.skipNarration ?? false,
85008
85193
  skipBackdrop: options.skipBackdrop ?? false,
85009
85194
  skipVideo: options.skipVideo ?? false,
85195
+ skipKeyframe: options.skipKeyframe ?? false,
85010
85196
  skipMusic: options.skipMusic ?? false,
85011
85197
  skipRender: options.skipRender ?? false,
85012
85198
  ttsProvider: options.tts,
@@ -85027,6 +85213,7 @@ Advanced equivalent: \`vibe scene build\`.`
85027
85213
  skipNarration: options.skipNarration,
85028
85214
  skipBackdrop: options.skipBackdrop,
85029
85215
  skipVideo: options.skipVideo,
85216
+ skipKeyframe: options.skipKeyframe,
85030
85217
  skipMusic: options.skipMusic,
85031
85218
  ttsProvider: options.tts,
85032
85219
  voice: options.voice,
@@ -85117,6 +85304,7 @@ Advanced equivalent: \`vibe scene build\`.`
85117
85304
  skipNarration: options.skipNarration,
85118
85305
  skipBackdrop: options.skipBackdrop,
85119
85306
  skipVideo: options.skipVideo,
85307
+ skipKeyframe: options.skipKeyframe,
85120
85308
  skipMusic: options.skipMusic,
85121
85309
  skipRender: options.skipRender,
85122
85310
  ttsProvider: options.tts,
@@ -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,20 @@ 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
33681
+ }) : null;
33682
+ const keyframeCache = keyframePrompt ? keyframeCacheDescriptor({
33683
+ beatId: beat.id,
33684
+ cue: keyframePrompt,
33685
+ provider: resolved.image.resolved,
33686
+ quality: imageQuality,
33687
+ size: imageSize2,
33688
+ ratio: imageRatio
33663
33689
  }) : null;
33664
33690
  const musicCache = musicPrompt && !musicReference ? musicCacheDescriptor({
33665
33691
  beatId: beat.id,
@@ -33696,6 +33722,19 @@ async function createBuildPlan(opts) {
33696
33722
  cost: backdropCostUsd(imageQuality),
33697
33723
  active: includeAssets
33698
33724
  }) : null;
33725
+ const keyframe = keyframePrompt && !opts.skipKeyframe ? assetPlan({
33726
+ kind: "keyframe",
33727
+ beatId: beat.id,
33728
+ cue: keyframePrompt,
33729
+ provider: resolved.image.resolved,
33730
+ path: `assets/keyframe-${beat.id}.png`,
33731
+ cache: keyframeCache,
33732
+ reference: null,
33733
+ projectDir,
33734
+ force: opts.force,
33735
+ cost: backdropCostUsd(imageQuality),
33736
+ active: includeAssets
33737
+ }) : null;
33699
33738
  const video = videoCue && !opts.skipVideo ? assetPlan({
33700
33739
  kind: "video",
33701
33740
  beatId: beat.id,
@@ -33728,7 +33767,7 @@ async function createBuildPlan(opts) {
33728
33767
  }) : null;
33729
33768
  const compositionPath = `compositions/scene-${beat.id}.html`;
33730
33769
  const compositionExists = existsSync17(join15(projectDir, compositionPath));
33731
- for (const asset of [narration, backdrop, video, music]) {
33770
+ for (const asset of [narration, backdrop, keyframe, video, music]) {
33732
33771
  if (!asset) continue;
33733
33772
  if (asset.referenceError) {
33734
33773
  missing.add("assets");
@@ -33753,6 +33792,8 @@ async function createBuildPlan(opts) {
33753
33792
  if (narration?.willGenerate) noteProviderNeed(resolved.narration, "Narration");
33754
33793
  if (backdrop?.willGenerate && isSupportedBuildImageProvider(resolved.image.resolved))
33755
33794
  noteProviderNeed(resolved.image, "Backdrop generation");
33795
+ if (keyframe?.willGenerate && isSupportedBuildImageProvider(resolved.image.resolved))
33796
+ noteProviderNeed(resolved.image, "Keyframe generation");
33756
33797
  if (video?.willGenerate) noteProviderNeed(resolved.video, "Video generation");
33757
33798
  if (music?.willGenerate) noteProviderNeed(resolved.music, "Music generation");
33758
33799
  if (!compositionExists) missing.add("compositions");
@@ -33766,7 +33807,7 @@ async function createBuildPlan(opts) {
33766
33807
  heading: beat.heading,
33767
33808
  durationSec: beat.duration ?? null,
33768
33809
  cues: cue,
33769
- assets: { narration, backdrop, video, music },
33810
+ assets: { narration, backdrop, keyframe, video, music },
33770
33811
  composition: {
33771
33812
  path: compositionPath,
33772
33813
  exists: compositionExists
@@ -33982,6 +34023,7 @@ function providerResolutionsForPlan(resolved, beats, opts, includeAssets) {
33982
34023
  return [
33983
34024
  includeAssets && !opts.skipNarration && needsProvider("narration") ? resolved.narration : null,
33984
34025
  includeAssets && !opts.skipBackdrop && needsProvider("backdrop") ? resolved.image : null,
34026
+ includeAssets && !opts.skipKeyframe && needsProvider("keyframe") ? resolved.image : null,
33985
34027
  includeAssets && !opts.skipVideo && needsProvider("video") ? resolved.video : null,
33986
34028
  includeAssets && !opts.skipMusic && needsProvider("music") ? resolved.music : null,
33987
34029
  resolved.composer ?? null
@@ -34556,6 +34598,7 @@ async function executeSceneBuild(opts) {
34556
34598
  skipNarration: opts.skipNarration,
34557
34599
  skipBackdrop: opts.skipBackdrop,
34558
34600
  skipVideo: opts.skipVideo,
34601
+ skipKeyframe: opts.skipKeyframe,
34559
34602
  skipMusic: opts.skipMusic,
34560
34603
  ttsProvider: opts.ttsProvider,
34561
34604
  voice: opts.voice,
@@ -34741,6 +34784,7 @@ async function executeSceneBuild(opts) {
34741
34784
  skipNarration: opts.skipNarration ?? false,
34742
34785
  skipBackdrop: opts.skipBackdrop ?? false,
34743
34786
  skipVideo: opts.skipVideo ?? false,
34787
+ skipKeyframe: opts.skipKeyframe ?? false,
34744
34788
  skipMusic: opts.skipMusic ?? false,
34745
34789
  force: opts.force ?? false,
34746
34790
  onProgress,
@@ -35117,10 +35161,11 @@ async function executeSceneBuild(opts) {
35117
35161
  });
35118
35162
  }
35119
35163
  async function buildBeatPrimitives(beat, ctx) {
35164
+ const keyframe = ctx.skipKeyframe ? await skipped("keyframe", beat.id, "--skip-keyframe", ctx) : await dispatchKeyframe(beat, ctx);
35120
35165
  const [narration, backdrop, video, music] = await Promise.all([
35121
35166
  ctx.skipNarration ? skipped("narration", beat.id, "--skip-narration", ctx) : dispatchNarration(beat, ctx),
35122
35167
  ctx.skipBackdrop ? skipped("backdrop", beat.id, "--skip-backdrop", ctx) : dispatchBackdrop(beat, ctx),
35123
- ctx.skipVideo ? skipped("video", beat.id, "--skip-video", ctx) : dispatchVideo(beat, ctx),
35168
+ ctx.skipVideo ? skipped("video", beat.id, "--skip-video", ctx) : dispatchVideo(beat, ctx, keyframe),
35124
35169
  ctx.skipMusic ? skipped("music", beat.id, "--skip-music", ctx) : dispatchMusic(beat, ctx)
35125
35170
  ]);
35126
35171
  return {
@@ -35164,6 +35209,8 @@ async function buildBeatPrimitives(beat, ctx) {
35164
35209
  videoMetadataPath: video.metadataPath,
35165
35210
  videoFreshness: video.freshness,
35166
35211
  videoSourcePath: video.sourcePath,
35212
+ keyframeStatus: keyframe.status,
35213
+ keyframePath: keyframe.path,
35167
35214
  musicStatus: music.status,
35168
35215
  musicPath: music.path,
35169
35216
  musicJobId: music.job?.id,
@@ -35608,6 +35655,112 @@ async function generateBackdropImage(prompt3, ctx, ratio) {
35608
35655
  });
35609
35656
  return imageBufferFromResult(result);
35610
35657
  }
35658
+ async function editKeyframeImage(prompt3, sheetBuffers, ctx, ratio) {
35659
+ loadSceneBuildEnv(ctx.projectDir);
35660
+ const keyInfo = imageProviderKeyInfo(ctx.imageProvider);
35661
+ const apiKey = await getApiKeyFromConfig(keyInfo.configKey, { cwd: ctx.projectDir }) ?? process.env[keyInfo.envVar] ?? "";
35662
+ if (!apiKey) {
35663
+ return {
35664
+ success: false,
35665
+ error: `${keyInfo.envVar} not set \u2014 cannot generate keyframe with ${ctx.imageProvider}`
35666
+ };
35667
+ }
35668
+ if (ctx.imageProvider === "openai") {
35669
+ const provider2 = new OpenAIImageProvider();
35670
+ await provider2.initialize({ apiKey });
35671
+ const result2 = await provider2.editImage(sheetBuffers, prompt3);
35672
+ return imageBufferFromResult(result2);
35673
+ }
35674
+ if (ctx.imageProvider === "gemini") {
35675
+ const provider2 = new GeminiProvider();
35676
+ await provider2.initialize({ apiKey });
35677
+ const result2 = await provider2.editImage(sheetBuffers, prompt3, {
35678
+ model: "flash",
35679
+ aspectRatio: ratio
35680
+ });
35681
+ return imageBufferFromResult(result2);
35682
+ }
35683
+ const provider = new GrokProvider();
35684
+ await provider.initialize({ apiKey });
35685
+ const result = await provider.editImage(sheetBuffers[0], prompt3, { aspectRatio: ratio });
35686
+ return imageBufferFromResult(result);
35687
+ }
35688
+ async function ensureKeyframe(beat, ctx, keyframeCue, characterRefsRel) {
35689
+ const rel = `assets/keyframe-${beat.id}.png`;
35690
+ const abs = join16(ctx.projectDir, rel);
35691
+ const size = ctx.imageSize ?? "1536x1024";
35692
+ const ratio = imageRatioForSize(size);
35693
+ const metadataOptions = { quality: ctx.imageQuality, size, ratio };
35694
+ const cache = keyframeCacheDescriptor({
35695
+ beatId: beat.id,
35696
+ cue: keyframeCue,
35697
+ provider: ctx.imageProvider,
35698
+ quality: ctx.imageQuality,
35699
+ size,
35700
+ ratio,
35701
+ characters: characterRefsRel
35702
+ });
35703
+ if (existsSync19(abs) && !ctx.force) {
35704
+ const metadata = readAssetMetadata(ctx.projectDir, "keyframe", beat.id);
35705
+ if (!metadata || isFreshCanonicalAsset({
35706
+ projectDir: ctx.projectDir,
35707
+ kind: "keyframe",
35708
+ beatId: beat.id,
35709
+ cue: keyframeCue,
35710
+ provider: ctx.imageProvider,
35711
+ options: metadataOptions,
35712
+ cacheKey: cache.key
35713
+ })) {
35714
+ return { status: "cached", rel };
35715
+ }
35716
+ }
35717
+ const cacheAbs = join16(ctx.projectDir, cache.path);
35718
+ if (existsSync19(cacheAbs) && !ctx.force) {
35719
+ await mkdir10(dirname10(abs), { recursive: true });
35720
+ await copyFile3(cacheAbs, abs);
35721
+ await writeAssetMetadata({
35722
+ projectDir: ctx.projectDir,
35723
+ kind: "keyframe",
35724
+ beatId: beat.id,
35725
+ cue: keyframeCue,
35726
+ provider: ctx.imageProvider,
35727
+ options: metadataOptions,
35728
+ cacheKey: cache.key,
35729
+ canonicalPath: rel,
35730
+ cachePath: cache.path
35731
+ });
35732
+ return { status: "cached", rel };
35733
+ }
35734
+ const sheetBuffers = [];
35735
+ for (const relPath of characterRefsRel) {
35736
+ const sheetAbs = join16(ctx.projectDir, relPath);
35737
+ if (existsSync19(sheetAbs)) sheetBuffers.push(await readFile18(sheetAbs));
35738
+ }
35739
+ const imgCtx = {
35740
+ projectDir: ctx.projectDir,
35741
+ imageProvider: ctx.imageProvider,
35742
+ imageSize: size,
35743
+ imageQuality: ctx.imageQuality
35744
+ };
35745
+ const generated = sheetBuffers.length > 0 ? await editKeyframeImage(keyframeCue, sheetBuffers, imgCtx, ratio) : await generateBackdropImage(keyframeCue, imgCtx, ratio);
35746
+ if (!generated.success) return { status: "failed", error: generated.error };
35747
+ await mkdir10(dirname10(abs), { recursive: true });
35748
+ await writeFile18(abs, generated.buffer);
35749
+ await mkdir10(dirname10(cacheAbs), { recursive: true });
35750
+ await writeFile18(cacheAbs, generated.buffer);
35751
+ await writeAssetMetadata({
35752
+ projectDir: ctx.projectDir,
35753
+ kind: "keyframe",
35754
+ beatId: beat.id,
35755
+ cue: keyframeCue,
35756
+ provider: ctx.imageProvider,
35757
+ options: metadataOptions,
35758
+ cacheKey: cache.key,
35759
+ canonicalPath: rel,
35760
+ cachePath: cache.path
35761
+ });
35762
+ return { status: "generated", rel };
35763
+ }
35611
35764
  async function imageBufferFromResult(result) {
35612
35765
  const image = result.images?.[0];
35613
35766
  if (!result.success || !image) {
@@ -35628,10 +35781,25 @@ function imageProviderKeyInfo(provider) {
35628
35781
  if (provider === "grok") return { configKey: "xai", envVar: "XAI_API_KEY" };
35629
35782
  return { configKey: "openai", envVar: "OPENAI_API_KEY" };
35630
35783
  }
35631
- async function dispatchVideo(beat, ctx) {
35784
+ async function dispatchKeyframe(beat, ctx) {
35785
+ const keyframeCue = stringOrUndefined4(beat.cues?.keyframe);
35786
+ if (!keyframeCue) return { status: "no-cue" };
35787
+ const characterRefsRel = beatCharacterNames(beat.cues).map((name) => ctx.characterPaths?.get(name)).filter((p) => typeof p === "string");
35788
+ const kf = await ensureKeyframe(beat, ctx, keyframeCue, characterRefsRel);
35789
+ if (kf.status === "failed") {
35790
+ ctx.onProgress({ type: "keyframe-failed", beatId: beat.id, error: kf.error });
35791
+ return { status: "failed", error: kf.error };
35792
+ }
35793
+ ctx.onProgress(
35794
+ kf.status === "cached" ? { type: "keyframe-cached", beatId: beat.id, path: kf.rel } : { type: "keyframe-generated", beatId: beat.id, path: kf.rel, provider: ctx.imageProvider }
35795
+ );
35796
+ return { status: kf.status, path: kf.rel, provider: ctx.imageProvider };
35797
+ }
35798
+ async function dispatchVideo(beat, ctx, keyframe) {
35632
35799
  const reference = assetReferenceForBeat(ctx.projectDir, "video", beat);
35633
35800
  if (reference) return referencePrimitiveOutcome("video", beat, ctx, reference);
35634
- const prompt3 = stringOrUndefined4(beat.cues?.video);
35801
+ const keyframeCue = stringOrUndefined4(beat.cues?.keyframe);
35802
+ const prompt3 = stringOrUndefined4(beat.cues?.video) ?? keyframeCue;
35635
35803
  if (!prompt3) return { status: "no-cue" };
35636
35804
  const characterRefsRel = beatCharacterNames(beat.cues).map((name) => ctx.characterPaths?.get(name)).filter((p) => typeof p === "string");
35637
35805
  const characterRefsAbs = characterRefsRel.map((p) => join16(ctx.projectDir, p));
@@ -35642,7 +35810,10 @@ async function dispatchVideo(beat, ctx) {
35642
35810
  cue: prompt3,
35643
35811
  provider: ctx.videoProvider,
35644
35812
  duration: normalizeVideoDuration(beat.duration),
35645
- characters: characterRefsRel
35813
+ // Keyframe mode keys the clip to the keyframe prompt; refs feed the keyframe,
35814
+ // not the clip directly.
35815
+ characters: keyframeCue ? void 0 : characterRefsRel,
35816
+ keyframe: keyframeCue
35646
35817
  });
35647
35818
  const metadataPath = assetMetadataPath("video", beat.id);
35648
35819
  if (existsSync19(abs) && !ctx.force) {
@@ -35694,6 +35865,13 @@ async function dispatchVideo(beat, ctx) {
35694
35865
  freshness: "fresh"
35695
35866
  };
35696
35867
  }
35868
+ const keyframeImageRel = keyframeCue && (keyframe.status === "generated" || keyframe.status === "cached") ? keyframe.path : void 0;
35869
+ if (keyframeCue && !keyframeImageRel) {
35870
+ const reason = keyframe.status === "failed" ? `keyframe unavailable: ${keyframe.error ?? "generation failed"}` : "keyframe not generated (build without --skip-keyframe first)";
35871
+ ctx.onProgress({ type: "video-skipped", beatId: beat.id, reason });
35872
+ return { status: "skipped" };
35873
+ }
35874
+ const keyframeImageAbs = keyframeImageRel ? join16(ctx.projectDir, keyframeImageRel) : void 0;
35697
35875
  loadSceneBuildEnv(ctx.projectDir);
35698
35876
  const result = await executeVideoGenerate({
35699
35877
  prompt: prompt3,
@@ -35702,7 +35880,10 @@ async function dispatchVideo(beat, ctx) {
35702
35880
  ratio: "16:9",
35703
35881
  output: abs,
35704
35882
  wait: false,
35705
- refImages: characterRefsAbs.length > 0 ? characterRefsAbs : void 0,
35883
+ // Keyframe mode → single init frame (image-to-video); otherwise character
35884
+ // reference-to-video.
35885
+ image: keyframeImageAbs,
35886
+ refImages: keyframeImageAbs ? void 0 : characterRefsAbs.length > 0 ? characterRefsAbs : void 0,
35706
35887
  apiKey: await apiKeyForVideoProvider(ctx.videoProvider, ctx.projectDir)
35707
35888
  });
35708
35889
  if (!result.success || !result.taskId) {
@@ -35744,7 +35925,7 @@ async function dispatchVideo(beat, ctx) {
35744
35925
  jobType: "generate-video",
35745
35926
  provider: result.provider ?? ctx.videoProvider,
35746
35927
  providerTaskId: result.taskId,
35747
- providerTaskType: "text2video",
35928
+ providerTaskType: keyframeImageAbs ? "image2video" : "text2video",
35748
35929
  status: "running",
35749
35930
  projectDir: ctx.projectDir,
35750
35931
  workingDirectory: ctx.projectDir,
@@ -36071,6 +36252,7 @@ function estimateActualAssetCost(outcomes, imageQuality) {
36071
36252
  for (const outcome of outcomes) {
36072
36253
  if (outcome.narrationStatus === "generated") cost += 0.05;
36073
36254
  if (outcome.backdropStatus === "generated") cost += backdropCostUsd(imageQuality);
36255
+ if (outcome.keyframeStatus === "generated") cost += backdropCostUsd(imageQuality);
36074
36256
  if (outcome.videoStatus === "generated" || outcome.videoStatus === "pending") {
36075
36257
  cost += outcome.videoProvider === "seedance" || outcome.videoProvider === "fal" ? estimateSeedanceVideoCostUsd({
36076
36258
  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.4",
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/ai-providers": "0.113.4",
87
+ "@vibeframe/core": "0.113.4"
88
88
  },
89
89
  "engines": {
90
90
  "node": ">=20"