feedeas 0.1.0-alpha.15 → 0.1.0-alpha.18

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.
package/dist/cli/index.js CHANGED
@@ -1,11 +1,5 @@
1
1
  var __defProp = Object.defineProperty;
2
2
  var __getOwnPropNames = Object.getOwnPropertyNames;
3
- var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
4
- get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
5
- }) : x)(function(x) {
6
- if (typeof require !== "undefined") return require.apply(this, arguments);
7
- throw Error('Dynamic require of "' + x + '" is not supported');
8
- });
9
3
  var __esm = (fn, res) => function __init() {
10
4
  return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
11
5
  };
@@ -92,14 +86,14 @@ var init_playwright_installer = __esm({
92
86
  });
93
87
 
94
88
  // src/cli/index.ts
95
- import { Command as Command14 } from "commander";
89
+ import { Command as Command16 } from "commander";
96
90
 
97
91
  // src/cli/commands/record.ts
98
92
  import { Command } from "commander";
99
93
  import { chromium } from "playwright-core";
100
94
  import { spawn as spawn4, spawnSync } from "child_process";
101
- import fs5 from "fs";
102
- import path6 from "path";
95
+ import fs8 from "fs";
96
+ import path8 from "path";
103
97
  import { fileURLToPath as fileURLToPath3 } from "url";
104
98
 
105
99
  // src/cli/services/ffprobe.ts
@@ -165,10 +159,91 @@ var FFprobeService = class {
165
159
  }
166
160
  };
167
161
 
162
+ // src/cli/services/assets/resolver.ts
163
+ import fs2 from "node:fs";
164
+ import path from "node:path";
165
+ function looksLikeScheme(uri) {
166
+ return /^[a-z][a-z0-9+.-]*:/.test(uri);
167
+ }
168
+ var RelativeFsResolver = class {
169
+ supports(uri) {
170
+ if (!uri) return false;
171
+ if (uri.startsWith("http://") || uri.startsWith("https://") || uri.startsWith("data:")) return false;
172
+ if (uri.startsWith("gcp://")) return false;
173
+ return !looksLikeScheme(uri) || uri.startsWith("file://") || uri.startsWith("/") || uri.startsWith("./") || uri.startsWith("../");
174
+ }
175
+ resolve(uri, rootPath) {
176
+ if (uri.startsWith("file://")) {
177
+ const localPath2 = decodeURIComponent(new URL(uri).pathname);
178
+ return { type: "local", uri, localPath: localPath2 };
179
+ }
180
+ const localPath = path.isAbsolute(uri) ? uri : path.resolve(rootPath, uri);
181
+ return { type: "local", uri, localPath };
182
+ }
183
+ };
184
+ var HttpResolver = class {
185
+ supports(uri) {
186
+ return uri.startsWith("http://") || uri.startsWith("https://");
187
+ }
188
+ resolve(uri) {
189
+ return { type: "remote", uri, remoteUrl: uri };
190
+ }
191
+ };
192
+ var GcpResolver = class {
193
+ supports(uri) {
194
+ return uri.startsWith("gcp://");
195
+ }
196
+ resolve(uri) {
197
+ return {
198
+ type: "unsupported",
199
+ uri,
200
+ reason: "gcp:// resolver is configured as a scaffold. Add a concrete GCP resolver integration to enable this scheme."
201
+ };
202
+ }
203
+ };
204
+ var AssetResolverRegistry = class {
205
+ constructor(resolvers) {
206
+ this.resolvers = resolvers;
207
+ }
208
+ resolve(uri, rootPath) {
209
+ const resolver = this.resolvers.find((entry) => entry.supports(uri));
210
+ if (!resolver) {
211
+ return {
212
+ type: "unsupported",
213
+ uri,
214
+ reason: `Unsupported URI scheme for asset: ${uri}`
215
+ };
216
+ }
217
+ return resolver.resolve(uri, rootPath);
218
+ }
219
+ assertUsable(uri, rootPath) {
220
+ const resolved = this.resolve(uri, rootPath);
221
+ if (resolved.type === "unsupported") {
222
+ return { ok: false, reason: resolved.reason };
223
+ }
224
+ if (resolved.type === "local" && !fs2.existsSync(resolved.localPath)) {
225
+ return { ok: false, reason: `Asset not found at ${resolved.localPath}` };
226
+ }
227
+ return { ok: true };
228
+ }
229
+ };
230
+ function createDefaultAssetResolverRegistry() {
231
+ return new AssetResolverRegistry([
232
+ new HttpResolver(),
233
+ new GcpResolver(),
234
+ new RelativeFsResolver()
235
+ ]);
236
+ }
237
+ function toBrowserAssetUrl(uri) {
238
+ if (!uri) return "";
239
+ if (uri.startsWith("http://") || uri.startsWith("https://") || uri.startsWith("data:")) return uri;
240
+ return `/api/v1/assets/content?uri=${encodeURIComponent(uri)}`;
241
+ }
242
+
168
243
  // src/cli/services/scene-resolver.ts
169
- import path from "path";
170
244
  var SceneResolver = class {
171
245
  static async resolve(scene, projectRoot) {
246
+ const assetRegistry2 = createDefaultAssetResolverRegistry();
172
247
  const resolvedScene = JSON.parse(JSON.stringify(scene));
173
248
  const entities = resolvedScene.entities;
174
249
  for (const entity of entities) {
@@ -176,9 +251,13 @@ var SceneResolver = class {
176
251
  if (entity.type === "audio" || entity.type === "video") {
177
252
  if (entity.src) {
178
253
  try {
179
- const assetPath = path.resolve(projectRoot, entity.src);
180
- const metadata = await FFprobeService.getMetadata(assetPath);
181
- entity.duration = metadata.duration;
254
+ const resolved = assetRegistry2.resolve(entity.src, projectRoot);
255
+ if (resolved.type === "local") {
256
+ const metadata = await FFprobeService.getMetadata(resolved.localPath);
257
+ entity.duration = metadata.duration;
258
+ } else {
259
+ entity.duration = 5;
260
+ }
182
261
  } catch (e) {
183
262
  console.warn(`\u26A0\uFE0F Could not resolve auto duration for ${entity.src}: ${e}`);
184
263
  entity.duration = 5;
@@ -253,13 +332,13 @@ import { cors } from "hono/cors";
253
332
  // src/cli/server/api.ts
254
333
  import { Hono } from "hono";
255
334
  import { readdir, readFile, writeFile, mkdir } from "node:fs/promises";
256
- import { join, dirname } from "node:path";
335
+ import { join, dirname, relative } from "node:path";
257
336
  import { existsSync } from "node:fs";
258
337
 
259
338
  // src/cli/server/cli-runner.ts
260
339
  import { spawn as spawn2 } from "child_process";
261
340
  import path2 from "path";
262
- import fs2 from "fs";
341
+ import fs3 from "fs";
263
342
  import { fileURLToPath } from "url";
264
343
  var __filename = fileURLToPath(import.meta.url);
265
344
  var __dirname = path2.dirname(__filename);
@@ -269,7 +348,7 @@ async function runCliCommand(args, cwd) {
269
348
  let spawnArgs;
270
349
  if (isCompiled) {
271
350
  const binPath = path2.resolve(__dirname, "../../../bin/feedeas.js");
272
- if (!fs2.existsSync(binPath)) {
351
+ if (!fs3.existsSync(binPath)) {
273
352
  throw new Error(`Could not find compiled CLI binary at ${binPath}`);
274
353
  }
275
354
  command = "node";
@@ -342,6 +421,60 @@ ${stderrData || stdoutData}`));
342
421
 
343
422
  // src/cli/services/taste.ts
344
423
  import path3 from "node:path";
424
+ var SAMPLE_SCENE_HEADER = "## Sample Scene (Starter)";
425
+ var DEFAULT_SAMPLE_SCENE_JSON = {
426
+ meta: {
427
+ width: 1080,
428
+ height: 1350,
429
+ duration: 5
430
+ },
431
+ entities: [
432
+ {
433
+ id: "text-1",
434
+ type: "text",
435
+ name: "Hook",
436
+ text: "Your Hook Goes Here",
437
+ startTime: 0,
438
+ duration: 5,
439
+ visible: true,
440
+ x: 540,
441
+ y: 520,
442
+ fontSize: 72,
443
+ fontFamily: "Inter, system-ui",
444
+ fontWeight: "bold",
445
+ color: "#ffffff",
446
+ bgColor: "transparent",
447
+ maxWidth: 880,
448
+ lineHeight: 1.2,
449
+ padding: 0,
450
+ textAlign: "center",
451
+ enter: { type: "scale", duration: 0.4 }
452
+ }
453
+ ]
454
+ };
455
+ function isObject(value) {
456
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
457
+ }
458
+ function isValidSampleScene(value) {
459
+ if (!isObject(value)) return false;
460
+ if (!isObject(value.meta)) return false;
461
+ if (!Array.isArray(value.entities)) return false;
462
+ return value.entities.length > 0;
463
+ }
464
+ function formatSampleSceneSection(scene) {
465
+ const normalized = isValidSampleScene(scene) ? scene : DEFAULT_SAMPLE_SCENE_JSON;
466
+ const json = JSON.stringify(normalized, null, 2);
467
+ return `${SAMPLE_SCENE_HEADER}
468
+ Use this as a copy-ready baseline and adapt per concept.
469
+
470
+ \`\`\`json
471
+ ${json}
472
+ \`\`\``;
473
+ }
474
+ var SAMPLE_SCENE_SECTION = formatSampleSceneSection(DEFAULT_SAMPLE_SCENE_JSON);
475
+ function escapeRegExp(value) {
476
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
477
+ }
345
478
  var DEFAULT_TASTE_FILE_CONTENT = `# Taste File v1
346
479
  ## Brand Identity
347
480
  Describe brand voice, tone, and core promise.
@@ -367,6 +500,8 @@ Describe visual and narrative constraints.
367
500
  ## Freshness Rules
368
501
  State how each new idea should stay fresh.
369
502
 
503
+ ${SAMPLE_SCENE_SECTION}
504
+
370
505
  ## Generation Instructions
371
506
  Give concrete instructions for generating content ideas.
372
507
  `;
@@ -425,6 +560,28 @@ function safeResolveFromCwd(relativePath) {
425
560
  }
426
561
  return fullPath;
427
562
  }
563
+ function ensureSampleSceneSection(content, sampleScene) {
564
+ const current = (content || "").trim();
565
+ if (!current) return DEFAULT_TASTE_FILE_CONTENT;
566
+ const section = formatSampleSceneSection(sampleScene);
567
+ if (current.includes(SAMPLE_SCENE_HEADER)) {
568
+ const escapedHeader = escapeRegExp(SAMPLE_SCENE_HEADER);
569
+ return current.replace(
570
+ new RegExp(`${escapedHeader}[\\s\\S]*?(?=\\n##\\s|$)`),
571
+ section
572
+ ).trimEnd() + "\n";
573
+ }
574
+ const generationHeader = "## Generation Instructions";
575
+ if (current.includes(generationHeader)) {
576
+ return current.replace(generationHeader, `${section}
577
+
578
+ ${generationHeader}`).trimEnd() + "\n";
579
+ }
580
+ return `${current}
581
+
582
+ ${section}
583
+ `;
584
+ }
428
585
  function parseListValue(value) {
429
586
  const trimmed = value.trim();
430
587
  if (!trimmed) return [];
@@ -641,6 +798,115 @@ function extractJson(text) {
641
798
  }
642
799
  throw new Error("Could not parse JSON from model response");
643
800
  }
801
+ function tryExtractJson(text) {
802
+ try {
803
+ return extractJson(text);
804
+ } catch {
805
+ return null;
806
+ }
807
+ }
808
+ function tryExtractLooseJson(text) {
809
+ const cleaned = (text || "").trim();
810
+ if (!cleaned) return null;
811
+ const withoutPrefix = cleaned.startsWith("json") ? cleaned.replace(/^json\s*/i, "").trim() : cleaned;
812
+ const candidate = tryExtractJson(withoutPrefix);
813
+ if (candidate) return candidate;
814
+ const firstBrace = withoutPrefix.indexOf("{");
815
+ const lastBrace = withoutPrefix.lastIndexOf("}");
816
+ if (firstBrace !== -1 && lastBrace > firstBrace) {
817
+ const slice = withoutPrefix.slice(firstBrace, lastBrace + 1);
818
+ try {
819
+ return JSON.parse(slice);
820
+ } catch {
821
+ return null;
822
+ }
823
+ }
824
+ if (withoutPrefix.startsWith('"') && withoutPrefix.endsWith('"')) {
825
+ try {
826
+ const parsed = JSON.parse(withoutPrefix);
827
+ if (typeof parsed === "string") {
828
+ return tryExtractLooseJson(parsed);
829
+ }
830
+ return parsed;
831
+ } catch {
832
+ return null;
833
+ }
834
+ }
835
+ return null;
836
+ }
837
+ function extractReplyFromRaw(raw) {
838
+ const match = raw.match(/"reply"\s*:\s*([\s\S]*?)(?:,"proposedTasteContent"|,"sampleScene"|,"citationIds"|,"askUser"|}\s*$)/i);
839
+ if (!match?.[1]) return void 0;
840
+ let value = match[1].trim();
841
+ if (value.startsWith('"')) {
842
+ const endQuote = value.lastIndexOf('"');
843
+ if (endQuote > 0) {
844
+ value = value.slice(1, endQuote);
845
+ }
846
+ value = value.replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\t/g, " ").replace(/\\"/g, '"');
847
+ }
848
+ return value.trim() || void 0;
849
+ }
850
+ function extractProposedTasteContentFromRaw(raw) {
851
+ const fenced = raw.match(/```(?:md|markdown)?\s*([\s\S]*?)```/i);
852
+ if (fenced?.[1]?.includes("# Taste File")) {
853
+ return fenced[1].trim();
854
+ }
855
+ const jsonBlock = raw.match(/\{[\s\S]*\}/);
856
+ if (jsonBlock?.[0]) {
857
+ try {
858
+ const parsed = JSON.parse(jsonBlock[0]);
859
+ if (parsed && typeof parsed.proposedTasteContent === "string") {
860
+ return parsed.proposedTasteContent.trim();
861
+ }
862
+ } catch {
863
+ }
864
+ }
865
+ const fieldMatch = raw.match(/"proposedTasteContent"\s*:\s*([\s\S]*?)(?:,"citationIds"|,"sampleScene"|,"askUser"|}\s*$)/i);
866
+ if (fieldMatch?.[1]) {
867
+ let value = fieldMatch[1].trim();
868
+ if (value.startsWith("```")) {
869
+ const fencedValue = value.match(/```(?:md|markdown|json)?\s*([\s\S]*?)```/i);
870
+ if (fencedValue?.[1]) {
871
+ return fencedValue[1].trim();
872
+ }
873
+ }
874
+ if (value.startsWith('"')) {
875
+ const endQuote = value.lastIndexOf('"');
876
+ if (endQuote > 0) {
877
+ value = value.slice(1, endQuote);
878
+ }
879
+ value = value.replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\t/g, " ").replace(/\\"/g, '"');
880
+ }
881
+ if (value.includes("# Taste File")) {
882
+ const markerIndex = value.indexOf("# Taste File");
883
+ return value.slice(markerIndex).trim();
884
+ }
885
+ return value.trim() || void 0;
886
+ }
887
+ const marker = "# Taste File";
888
+ const idx = raw.indexOf(marker);
889
+ if (idx === -1) return void 0;
890
+ const candidate = raw.slice(idx).trim();
891
+ return candidate.length > marker.length ? candidate : void 0;
892
+ }
893
+ function normalizeProposedTasteContent(content) {
894
+ const trimmed = (content || "").trim();
895
+ if (!trimmed) return "";
896
+ if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
897
+ try {
898
+ const parsed = JSON.parse(trimmed);
899
+ if (typeof parsed === "string") {
900
+ return parsed.trim();
901
+ }
902
+ } catch {
903
+ }
904
+ }
905
+ if (!trimmed.includes("\n") && trimmed.includes("\\n")) {
906
+ return trimmed.replace(/\\n/g, "\n").replace(/\\t/g, " ").replace(/\\"/g, '"').trim();
907
+ }
908
+ return trimmed;
909
+ }
644
910
  function coerceString(value) {
645
911
  if (value == null) return "";
646
912
  if (typeof value === "string") return value.trim();
@@ -667,6 +933,140 @@ function coerceStringArray(value) {
667
933
  }
668
934
  return coerceString(value).split(/[,\n]/g).map((item) => item.trim()).filter(Boolean);
669
935
  }
936
+ function normalizedId(prefix, idx) {
937
+ return `${prefix}_${Date.now()}_${idx + 1}`;
938
+ }
939
+ function normalizeBullets(value) {
940
+ const arr = coerceStringArray(value).map((item) => item.replace(/^[-*]\s*/, "").trim()).filter(Boolean);
941
+ if (arr.length >= 3) return arr.slice(0, 5);
942
+ const fallback = [...arr];
943
+ const defaults = [
944
+ "Opening hook scene and visual direction",
945
+ "Main beat that demonstrates the core value",
946
+ "Closing beat with CTA or payoff"
947
+ ];
948
+ for (const item of defaults) {
949
+ if (fallback.length >= 3) break;
950
+ fallback.push(item);
951
+ }
952
+ return fallback.slice(0, 5);
953
+ }
954
+ function extractPlanCard(value, idx) {
955
+ const objectValue = typeof value === "object" && value !== null ? value : {};
956
+ const title = coerceString(
957
+ objectValue.title || objectValue.name || objectValue.idea || objectValue.heading || `Plan ${idx + 1}`
958
+ ) || `Plan ${idx + 1}`;
959
+ const bullets = normalizeBullets(
960
+ objectValue.bullets || objectValue.plannedScenes || objectValue.scenes || objectValue.points || objectValue.items
961
+ ).map((text, bulletIdx) => ({
962
+ id: `${normalizedId("bullet", idx)}_${bulletIdx + 1}`,
963
+ text
964
+ }));
965
+ return {
966
+ id: normalizedId("card", idx),
967
+ title,
968
+ bullets,
969
+ freshnessScore: void 0
970
+ };
971
+ }
972
+ function parsePlanCardsFromModel(raw, count) {
973
+ let parsed = null;
974
+ try {
975
+ parsed = extractJson(raw);
976
+ } catch {
977
+ }
978
+ const source = parsed?.cards || parsed?.ideas || parsed?.plans || parsed;
979
+ const list = Array.isArray(source) ? source : [source].filter(Boolean);
980
+ const cards = list.map((item, idx) => extractPlanCard(item, idx)).filter((item) => item.title && item.bullets.length >= 3);
981
+ if (cards.length >= count) return cards.slice(0, count);
982
+ const padded = [...cards];
983
+ while (padded.length < count) {
984
+ const idx = padded.length;
985
+ padded.push(extractPlanCard({ title: `Plan ${idx + 1}`, bullets: [] }, idx));
986
+ }
987
+ return padded;
988
+ }
989
+ function parseFollowupPromptsFromModel(raw, bulletId, defaultCount = 4) {
990
+ let parsed = null;
991
+ try {
992
+ parsed = extractJson(raw);
993
+ } catch {
994
+ }
995
+ const source = parsed?.prompts || parsed?.images || parsed?.variants || parsed;
996
+ const values = Array.isArray(source) ? source : source ? [source] : [];
997
+ const prompts = values.map((value) => {
998
+ const item = typeof value === "object" && value !== null ? value : {};
999
+ return {
1000
+ prompt: coerceString(item.prompt || item.text || value),
1001
+ style: coerceString(item.style || item.look),
1002
+ shotType: coerceString(item.shotType || item.shot_type || item.shot),
1003
+ negativePrompt: coerceString(item.negativePrompt || item.negative_prompt || item.negative)
1004
+ };
1005
+ }).filter((item) => item.prompt);
1006
+ const normalized = prompts.length > 0 ? prompts.slice(0, 6) : [{
1007
+ prompt: coerceString(raw) || "Cinematic portrait scene matching the selected bullet with clear subject and composition.",
1008
+ style: "",
1009
+ shotType: "medium",
1010
+ negativePrompt: "blurry, overexposed, cluttered frame"
1011
+ }];
1012
+ while (normalized.length < Math.min(defaultCount, 6)) {
1013
+ normalized.push({
1014
+ prompt: `${normalized[0].prompt} (variant ${normalized.length + 1})`,
1015
+ style: normalized[0].style,
1016
+ shotType: normalized[0].shotType,
1017
+ negativePrompt: normalized[0].negativePrompt
1018
+ });
1019
+ }
1020
+ return {
1021
+ bulletId,
1022
+ prompts: normalized.slice(0, 6)
1023
+ };
1024
+ }
1025
+ function normalizeAskUser(source, maxPrompts = 3) {
1026
+ if (!source) return void 0;
1027
+ const entries = Array.isArray(source) ? source : [source];
1028
+ const prompts = entries.map((entry, promptIndex) => {
1029
+ const record = typeof entry === "object" && entry !== null ? entry : {};
1030
+ const prompt = coerceString(record.prompt || record.question || record.title);
1031
+ if (!prompt) return null;
1032
+ const id = coerceString(record.id) || `ask_${Date.now()}_${promptIndex}`;
1033
+ const helper = coerceString(record.helper);
1034
+ const allowFreeText = Boolean(record.allowFreeText ?? record.allow_free_text);
1035
+ const maxSelectionsRaw = Number(record.maxSelections ?? record.max_selections);
1036
+ const maxSelections = Number.isFinite(maxSelectionsRaw) && maxSelectionsRaw > 0 ? maxSelectionsRaw : void 0;
1037
+ const optionSource = Array.isArray(record.options) ? record.options : Array.isArray(record.choices) ? record.choices : [];
1038
+ const options = optionSource.map((opt, optionIndex) => {
1039
+ const optRecord = typeof opt === "object" && opt !== null ? opt : {};
1040
+ const label = coerceString(optRecord.label || optRecord.title || optRecord.text || optRecord.value);
1041
+ const value = coerceString(optRecord.value || optRecord.label || optRecord.text || label);
1042
+ if (!label && !value) return null;
1043
+ const imagePrompt = coerceString(optRecord.imagePrompt || optRecord.image_prompt);
1044
+ const imageUrl = coerceString(optRecord.imageUrl || optRecord.image_url);
1045
+ const imageUri = coerceString(optRecord.imageUri || optRecord.image_uri);
1046
+ const type = coerceString(optRecord.type) === "image" || imagePrompt || imageUrl || imageUri ? "image" : "text";
1047
+ return {
1048
+ id: coerceString(optRecord.id) || `${id}_opt_${optionIndex}`,
1049
+ label: label || value,
1050
+ value: value || label,
1051
+ type,
1052
+ imagePrompt: imagePrompt || void 0,
1053
+ imageUrl: imageUrl || void 0,
1054
+ imageUri: imageUri || void 0
1055
+ };
1056
+ }).filter(Boolean);
1057
+ if (options.length === 0) return null;
1058
+ return {
1059
+ id,
1060
+ prompt,
1061
+ helper: helper || void 0,
1062
+ options: options.slice(0, 6),
1063
+ allowFreeText,
1064
+ maxSelections
1065
+ };
1066
+ }).filter(Boolean);
1067
+ if (prompts.length === 0) return void 0;
1068
+ return prompts.slice(0, Math.min(maxPrompts, 3));
1069
+ }
670
1070
  async function callGeminiText(prompt, apiKey, model = "gemini-2.5-flash") {
671
1071
  const endpoint = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`;
672
1072
  const response = await fetch(`${endpoint}?key=${apiKey}`, {
@@ -691,11 +1091,31 @@ async function callGeminiText(prompt, apiKey, model = "gemini-2.5-flash") {
691
1091
  }
692
1092
  async function runTasteChat(params) {
693
1093
  const sampleMemories = params.memories.slice(0, 10).map((m) => `${m.id}: ${m.title}`).join("\n");
1094
+ const history = (params.chatHistory || []).slice(-8).map((entry) => `${entry.role === "user" ? "User" : "Assistant"}: ${entry.text}`).join("\n");
1095
+ const askUserCount = Number(params.askUserCount || 0);
1096
+ const askUserMax = Number(params.askUserMax || 3);
1097
+ const interactionCount = Number(params.interactionCount || 0);
1098
+ const interactionMin = Number(params.interactionMin || 3);
1099
+ const interactionMax = Number(params.interactionMax || 6);
694
1100
  const prompt = [
695
1101
  "You are a taste-file assistant for social content strategy.",
696
1102
  "Given user message and taste file, respond with practical guidance.",
1103
+ "When offering choices, include AskUser prompts with clickable options; otherwise ask open questions in reply.",
1104
+ "Prefer text choices. Use image choices only when visual style or theme selection is best.",
1105
+ `You have used ${askUserCount}/${askUserMax} AskUser interactions. If count >= max, draft proposedTasteContent now.`,
1106
+ `You have used ${interactionCount}/${interactionMax} total interactions. Do not draft before ${interactionMin} unless user explicitly asks.`,
1107
+ "Do NOT draft proposedTasteContent before the limit unless the user explicitly asks for a rewrite or a draft.",
1108
+ "If you are NOT drafting, do NOT include proposedTasteContent and do NOT include raw JSON in reply.",
697
1109
  "If rewrite is needed, return full updated taste markdown in proposedTasteContent.",
698
- 'Return strict JSON: {"reply": "...", "proposedTasteContent": "... optional ...", "citationIds": ["mem_id"]}',
1110
+ "Taste files are reusable generators, NOT one-off idea drafts or simulations.",
1111
+ "When drafting proposedTasteContent, do NOT include specific content ideas, scripts, or single-topic writeups.",
1112
+ "Instead, define reusable guidance: brand voice, audience, focus areas, negatives, content formats, style constraints, freshness rules, and explicit generation instructions.",
1113
+ "Generation Instructions must be procedural and reusable: define step-by-step idea generation, required output format, and hook/delivery constraints.",
1114
+ "If rewrite is needed, also return sampleScene as a valid feedeas scene JSON object (with meta + entities).",
1115
+ 'Return strict JSON: {"reply": "...", "proposedTasteContent": "... optional ...", "sampleScene": {... optional ...}, "citationIds": ["mem_id"], "askUser": [{"id":"...","prompt":"...","helper":"... optional","allowFreeText":true,"maxSelections":1,"options":[{"id":"...","label":"...","value":"...","type":"text|image","imagePrompt":"... optional"}]}]}',
1116
+ "",
1117
+ `Chat history:
1118
+ ${history || "(none)"}`,
699
1119
  "",
700
1120
  `User message:
701
1121
  ${params.message}`,
@@ -707,14 +1127,41 @@ ${params.tasteContent}`,
707
1127
  ${sampleMemories || "(none)"}`
708
1128
  ].join("\n");
709
1129
  const raw = await callGeminiText(prompt, params.apiKey, params.model);
710
- const parsed = extractJson(raw);
1130
+ const userRequestedRewrite = /\b(rewrite|draft|update|apply|revise|refresh|create\s+the\s+taste|generate\s+the\s+taste)\b/i.test(params.message);
1131
+ const allowDraft = userRequestedRewrite || interactionCount >= interactionMax || askUserCount >= askUserMax && interactionCount >= interactionMin;
1132
+ const parsed = tryExtractJson(raw) ?? tryExtractLooseJson(raw);
1133
+ if (!parsed) {
1134
+ const recovered = allowDraft ? extractProposedTasteContentFromRaw(raw) : void 0;
1135
+ const normalized = recovered ? normalizeProposedTasteContent(recovered) : void 0;
1136
+ const extractedReply = extractReplyFromRaw(raw);
1137
+ const fallbackReply = extractedReply || coerceString(raw);
1138
+ const reply = normalized ? "Drafted a rewrite. Review and apply if it looks right." : fallbackReply.trim().startsWith("{") || fallbackReply.includes('"proposedTasteContent"') ? "Got it. To narrow this down, what specific angle or constraint should I prioritize next?" : fallbackReply;
1139
+ return {
1140
+ reply,
1141
+ proposedTasteContent: normalized ? ensureSampleSceneSection(normalized) : void 0,
1142
+ citations: []
1143
+ };
1144
+ }
711
1145
  const citationIds = Array.isArray(parsed.citationIds) ? parsed.citationIds : [];
712
1146
  const citationMap = new Map(params.memories.map((m) => [m.id, m.title]));
713
1147
  const citations = citationIds.filter((id) => citationMap.has(id)).map((id) => ({ memoryId: id, title: citationMap.get(id) || id }));
1148
+ const sampleScene = parsed.sampleScene;
1149
+ const askUser = askUserCount >= askUserMax ? void 0 : normalizeAskUser(parsed.askUser || parsed.ask_user, askUserMax);
1150
+ const proposedTasteContent = allowDraft && parsed.proposedTasteContent ? ensureSampleSceneSection(normalizeProposedTasteContent(coerceString(parsed.proposedTasteContent)), sampleScene) : (() => {
1151
+ if (!allowDraft) return void 0;
1152
+ const recovered = extractProposedTasteContentFromRaw(raw);
1153
+ const normalized = recovered ? normalizeProposedTasteContent(recovered) : void 0;
1154
+ return normalized ? ensureSampleSceneSection(normalized, sampleScene) : void 0;
1155
+ })();
1156
+ const sanitizedProposed = proposedTasteContent?.trim().startsWith("{") && proposedTasteContent.includes('"reply"') ? (() => {
1157
+ const recovered = extractProposedTasteContentFromRaw(proposedTasteContent);
1158
+ return recovered ? normalizeProposedTasteContent(recovered) : proposedTasteContent;
1159
+ })() : proposedTasteContent;
714
1160
  return {
715
1161
  reply: coerceString(parsed.reply || raw),
716
- proposedTasteContent: parsed.proposedTasteContent ? coerceString(parsed.proposedTasteContent) : void 0,
717
- citations
1162
+ proposedTasteContent: sanitizedProposed,
1163
+ citations,
1164
+ askUser
718
1165
  };
719
1166
  }
720
1167
  async function generateIdea(tasteContent, memories, apiKey, model) {
@@ -732,16 +1179,133 @@ ${tasteContent}`,
732
1179
  ${recent || "(none)"}`
733
1180
  ].join("\n");
734
1181
  const raw = await callGeminiText(prompt, apiKey, model);
735
- const parsed = extractJson(raw);
1182
+ const parsed = tryExtractJson(raw) || {};
736
1183
  return {
737
1184
  title: coerceString(parsed.title || "Untitled idea"),
738
1185
  format: coerceString(parsed.format || "daily-reel") || "daily-reel",
739
1186
  tags: coerceStringArray(parsed.tags),
740
1187
  freshnessTerms: coerceStringArray(parsed.freshnessTerms || parsed.freshness_terms),
741
- summary: coerceString(parsed.summary),
742
- content: coerceString(parsed.content)
1188
+ summary: coerceString(parsed.summary || raw.split("\n").slice(0, 2).join(" ").slice(0, 220)),
1189
+ content: coerceString(parsed.content || raw)
1190
+ };
1191
+ }
1192
+ async function generatePlanCard(tasteContent, memories, apiKey, model) {
1193
+ const recent = memories.slice(0, 20).map((m) => `- ${m.title}: ${m.summary}`).join("\n");
1194
+ const prompt = [
1195
+ "Generate one simple simulation card from this taste file.",
1196
+ "Keep it coach-like and steerable.",
1197
+ "Return strict JSON with keys: title, bullets (array of 3 to 5 concise planned scenes).",
1198
+ "",
1199
+ `Taste file:
1200
+ ${tasteContent}`,
1201
+ "",
1202
+ `Recent ideas:
1203
+ ${recent || "(none)"}`
1204
+ ].join("\n");
1205
+ const raw = await callGeminiText(prompt, apiKey, model);
1206
+ return parsePlanCardsFromModel(raw, 1)[0];
1207
+ }
1208
+ function computePlanCardOverlap(candidate, existing) {
1209
+ const candidateText = `${candidate.title} ${candidate.bullets.map((b) => b.text).join(" ")}`;
1210
+ const candidateTokens = tokenize(candidateText);
1211
+ let maxScore = 0;
1212
+ for (const prev of existing) {
1213
+ const prevText = `${prev.title} ${prev.bullets.map((b) => b.text).join(" ")}`;
1214
+ const prevTokens = tokenize(prevText);
1215
+ maxScore = Math.max(maxScore, jaccard(candidateTokens, prevTokens));
1216
+ }
1217
+ return maxScore;
1218
+ }
1219
+ async function simulatePlanCards(params) {
1220
+ const model = params.model || "gemini-2.5-flash";
1221
+ const existingCards = params.memories.slice(0, 40).map((memory, idx) => ({
1222
+ id: `mem_card_${idx + 1}`,
1223
+ title: memory.title,
1224
+ bullets: normalizeBullets(memory.summary || memory.content).map((text, bulletIdx) => ({
1225
+ id: `mem_bullet_${idx + 1}_${bulletIdx + 1}`,
1226
+ text
1227
+ }))
1228
+ }));
1229
+ const cards = [];
1230
+ const rejected = [];
1231
+ const targetCount = Math.max(1, params.count);
1232
+ const threshold = 0.6;
1233
+ const generateWithChecks = async () => {
1234
+ for (let attempt = 0; attempt < 4; attempt += 1) {
1235
+ const candidate = await generatePlanCard(params.tasteContent, params.memories, params.apiKey, model);
1236
+ const overlap = computePlanCardOverlap(candidate, [...existingCards, ...cards]);
1237
+ if (overlap <= threshold) {
1238
+ candidate.freshnessScore = Number((1 - overlap).toFixed(3));
1239
+ return candidate;
1240
+ }
1241
+ rejected.push({
1242
+ title: candidate.title,
1243
+ reason: "overlap too high",
1244
+ overlapScore: Number(overlap.toFixed(3))
1245
+ });
1246
+ }
1247
+ return null;
1248
+ };
1249
+ if (params.mode === "parallel") {
1250
+ const generated = await Promise.all(
1251
+ Array.from({ length: targetCount }, () => generatePlanCard(params.tasteContent, params.memories, params.apiKey, model))
1252
+ );
1253
+ for (const candidate of generated) {
1254
+ const overlap = computePlanCardOverlap(candidate, [...existingCards, ...cards]);
1255
+ if (overlap <= threshold) {
1256
+ candidate.freshnessScore = Number((1 - overlap).toFixed(3));
1257
+ cards.push(candidate);
1258
+ } else {
1259
+ rejected.push({
1260
+ title: candidate.title,
1261
+ reason: "parallel overlap filter",
1262
+ overlapScore: Number(overlap.toFixed(3))
1263
+ });
1264
+ }
1265
+ }
1266
+ }
1267
+ while (cards.length < targetCount) {
1268
+ const card = await generateWithChecks();
1269
+ if (!card) break;
1270
+ cards.push(card);
1271
+ }
1272
+ return {
1273
+ ideas: [],
1274
+ planCards: cards.slice(0, targetCount),
1275
+ rejected,
1276
+ mode: params.mode,
1277
+ stats: {
1278
+ requested: targetCount,
1279
+ accepted: cards.length,
1280
+ rejected: rejected.length
1281
+ }
743
1282
  };
744
1283
  }
1284
+ async function generateBulletFollowupPrompts(params) {
1285
+ const model = params.model || "gemini-2.5-flash";
1286
+ const target = Math.max(3, Math.min(6, params.count || 4));
1287
+ const memoryHints = params.memories.slice(0, 10).map((m) => `- ${m.title}: ${m.summary}`).join("\n");
1288
+ const prompt = [
1289
+ "Generate image prompt variants for a selected planned scene bullet.",
1290
+ "Return strict JSON with key prompts (array of objects).",
1291
+ "Each object keys: prompt, style, shotType, negativePrompt.",
1292
+ `Return ${target} to 6 variants.`,
1293
+ "",
1294
+ `Taste file:
1295
+ ${params.tasteContent}`,
1296
+ "",
1297
+ `Card title:
1298
+ ${params.cardTitle}`,
1299
+ "",
1300
+ `Selected bullet:
1301
+ ${params.bulletText}`,
1302
+ "",
1303
+ `Memory hints:
1304
+ ${memoryHints || "(none)"}`
1305
+ ].join("\n");
1306
+ const raw = await callGeminiText(prompt, params.apiKey, model);
1307
+ return parseFollowupPromptsFromModel(raw, params.bulletId, target);
1308
+ }
745
1309
  async function simulateIdeas(params) {
746
1310
  const model = params.model || "gemini-2.5-flash";
747
1311
  const existing = [...params.memories];
@@ -801,7 +1365,7 @@ async function simulateIdeas(params) {
801
1365
  }
802
1366
 
803
1367
  // src/cli/services/taste-store.ts
804
- import fs3 from "node:fs";
1368
+ import fs4 from "node:fs";
805
1369
  import path4 from "node:path";
806
1370
  import { createHash } from "node:crypto";
807
1371
  var DEFAULT_SUGGESTIONS_FILE_CONTENT = "# Taste Suggestions v1\n";
@@ -925,45 +1489,46 @@ var MarkdownTasteStore = class {
925
1489
  }
926
1490
  async ensureWorkspace() {
927
1491
  const { tastePath, memoryPath, suggestionsPath } = this.resolveAll();
928
- fs3.mkdirSync(path4.dirname(tastePath), { recursive: true });
929
- fs3.mkdirSync(path4.dirname(memoryPath), { recursive: true });
930
- fs3.mkdirSync(path4.dirname(suggestionsPath), { recursive: true });
931
- if (!fs3.existsSync(tastePath)) {
932
- fs3.writeFileSync(tastePath, DEFAULT_TASTE_FILE_CONTENT, "utf-8");
1492
+ fs4.mkdirSync(path4.dirname(tastePath), { recursive: true });
1493
+ fs4.mkdirSync(path4.dirname(memoryPath), { recursive: true });
1494
+ fs4.mkdirSync(path4.dirname(suggestionsPath), { recursive: true });
1495
+ if (!fs4.existsSync(tastePath)) {
1496
+ fs4.writeFileSync(tastePath, DEFAULT_TASTE_FILE_CONTENT, "utf-8");
933
1497
  }
934
- if (!fs3.existsSync(memoryPath)) {
935
- fs3.writeFileSync(memoryPath, DEFAULT_MEMORY_FILE_CONTENT, "utf-8");
1498
+ if (!fs4.existsSync(memoryPath)) {
1499
+ fs4.writeFileSync(memoryPath, DEFAULT_MEMORY_FILE_CONTENT, "utf-8");
936
1500
  }
937
- if (!fs3.existsSync(suggestionsPath)) {
938
- fs3.writeFileSync(suggestionsPath, DEFAULT_SUGGESTIONS_FILE_CONTENT, "utf-8");
1501
+ if (!fs4.existsSync(suggestionsPath)) {
1502
+ fs4.writeFileSync(suggestionsPath, DEFAULT_SUGGESTIONS_FILE_CONTENT, "utf-8");
939
1503
  }
940
1504
  }
941
1505
  async readTaste() {
942
1506
  await this.ensureWorkspace();
943
1507
  const { tastePath } = this.resolveAll();
944
- const content = fs3.readFileSync(tastePath, "utf-8");
1508
+ const content = fs4.readFileSync(tastePath, "utf-8");
945
1509
  return { content, version: hashContent(content) };
946
1510
  }
947
1511
  async writeTaste(content, expectedVersion) {
948
1512
  await this.ensureWorkspace();
949
1513
  const { tastePath } = this.resolveAll();
950
- const current = fs3.existsSync(tastePath) ? fs3.readFileSync(tastePath, "utf-8") : "";
1514
+ const current = fs4.existsSync(tastePath) ? fs4.readFileSync(tastePath, "utf-8") : "";
951
1515
  const currentVersion = hashContent(current);
952
1516
  if (expectedVersion && expectedVersion !== currentVersion) {
953
1517
  throw new Error("Taste file changed since proposal creation. Re-run suggest before apply.");
954
1518
  }
955
- fs3.writeFileSync(tastePath, content, "utf-8");
956
- return { version: hashContent(content) };
1519
+ const normalized = ensureSampleSceneSection(content);
1520
+ fs4.writeFileSync(tastePath, normalized, "utf-8");
1521
+ return { version: hashContent(normalized) };
957
1522
  }
958
1523
  async appendMemory(entry) {
959
1524
  await this.ensureWorkspace();
960
1525
  const { memoryPath } = this.resolveAll();
961
- const current = fs3.existsSync(memoryPath) ? fs3.readFileSync(memoryPath, "utf-8") : "";
1526
+ const current = fs4.existsSync(memoryPath) ? fs4.readFileSync(memoryPath, "utf-8") : "";
962
1527
  const id = buildId("mem");
963
1528
  const markdown = `${(current || DEFAULT_MEMORY_FILE_CONTENT).trimEnd()}
964
1529
 
965
1530
  ${formatMemoryEntry(entry, id)}`;
966
- fs3.writeFileSync(memoryPath, markdown, "utf-8");
1531
+ fs4.writeFileSync(memoryPath, markdown, "utf-8");
967
1532
  const parsed = parseTasteMemoryMarkdown(markdown).find((item) => item.id === id);
968
1533
  if (!parsed) {
969
1534
  throw new Error("Failed to append memory entry");
@@ -973,14 +1538,14 @@ ${formatMemoryEntry(entry, id)}`;
973
1538
  async queryMemory(options) {
974
1539
  await this.ensureWorkspace();
975
1540
  const { memoryPath } = this.resolveAll();
976
- const content = fs3.existsSync(memoryPath) ? fs3.readFileSync(memoryPath, "utf-8") : "";
1541
+ const content = fs4.existsSync(memoryPath) ? fs4.readFileSync(memoryPath, "utf-8") : "";
977
1542
  const items = parseTasteMemoryMarkdown(content);
978
1543
  return queryMemoryItems(items, options);
979
1544
  }
980
1545
  async appendSuggestion(suggestion) {
981
1546
  await this.ensureWorkspace();
982
1547
  const { suggestionsPath } = this.resolveAll();
983
- const current = fs3.existsSync(suggestionsPath) ? fs3.readFileSync(suggestionsPath, "utf-8") : "";
1548
+ const current = fs4.existsSync(suggestionsPath) ? fs4.readFileSync(suggestionsPath, "utf-8") : "";
984
1549
  const entry = {
985
1550
  id: buildId("sug"),
986
1551
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -997,13 +1562,13 @@ ${formatMemoryEntry(entry, id)}`;
997
1562
  };
998
1563
  const existing = parseSuggestionMarkdown(current);
999
1564
  const markdown = formatSuggestionsMarkdown([...existing, entry]);
1000
- fs3.writeFileSync(suggestionsPath, markdown, "utf-8");
1565
+ fs4.writeFileSync(suggestionsPath, markdown, "utf-8");
1001
1566
  return entry;
1002
1567
  }
1003
1568
  async listSuggestions(status) {
1004
1569
  await this.ensureWorkspace();
1005
1570
  const { suggestionsPath } = this.resolveAll();
1006
- const current = fs3.existsSync(suggestionsPath) ? fs3.readFileSync(suggestionsPath, "utf-8") : "";
1571
+ const current = fs4.existsSync(suggestionsPath) ? fs4.readFileSync(suggestionsPath, "utf-8") : "";
1007
1572
  const entries = parseSuggestionMarkdown(current).sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
1008
1573
  if (!status) {
1009
1574
  return entries;
@@ -1013,7 +1578,7 @@ ${formatMemoryEntry(entry, id)}`;
1013
1578
  async updateSuggestionStatus(id, status, metadata) {
1014
1579
  await this.ensureWorkspace();
1015
1580
  const { suggestionsPath } = this.resolveAll();
1016
- const current = fs3.existsSync(suggestionsPath) ? fs3.readFileSync(suggestionsPath, "utf-8") : "";
1581
+ const current = fs4.existsSync(suggestionsPath) ? fs4.readFileSync(suggestionsPath, "utf-8") : "";
1017
1582
  const entries = parseSuggestionMarkdown(current);
1018
1583
  const idx = entries.findIndex((entry) => entry.id === id);
1019
1584
  if (idx === -1) {
@@ -1028,7 +1593,7 @@ ${formatMemoryEntry(entry, id)}`;
1028
1593
  reviewNote: metadata?.reviewNote
1029
1594
  };
1030
1595
  entries[idx] = updated;
1031
- fs3.writeFileSync(suggestionsPath, formatSuggestionsMarkdown(entries), "utf-8");
1596
+ fs4.writeFileSync(suggestionsPath, formatSuggestionsMarkdown(entries), "utf-8");
1032
1597
  return updated;
1033
1598
  }
1034
1599
  };
@@ -1081,9 +1646,216 @@ function createTasteStore(params) {
1081
1646
  return new MarkdownTasteStore(paths);
1082
1647
  }
1083
1648
 
1649
+ // src/cli/services/scenes/file-store.ts
1650
+ import fs5 from "node:fs";
1651
+ import path5 from "node:path";
1652
+ import { randomUUID } from "node:crypto";
1653
+
1654
+ // src/cli/services/scenes/types.ts
1655
+ var NotFoundError = class extends Error {
1656
+ };
1657
+ var ConflictError = class extends Error {
1658
+ };
1659
+
1660
+ // src/cli/services/scenes/file-store.ts
1661
+ var DEFAULT_SCENE = {
1662
+ meta: {
1663
+ width: 1080,
1664
+ height: 1920,
1665
+ duration: 10
1666
+ },
1667
+ entities: []
1668
+ };
1669
+ function nowIso() {
1670
+ return (/* @__PURE__ */ new Date()).toISOString();
1671
+ }
1672
+ function getStorePath(cwd) {
1673
+ return path5.resolve(cwd, ".feedeas/store.json");
1674
+ }
1675
+ function ensureStore(pathname) {
1676
+ const dir = path5.dirname(pathname);
1677
+ if (!fs5.existsSync(dir)) {
1678
+ fs5.mkdirSync(dir, { recursive: true });
1679
+ }
1680
+ if (!fs5.existsSync(pathname)) {
1681
+ const initial = { projects: [], scenes: [] };
1682
+ fs5.writeFileSync(pathname, JSON.stringify(initial, null, 2), "utf-8");
1683
+ return initial;
1684
+ }
1685
+ const parsed = JSON.parse(fs5.readFileSync(pathname, "utf-8"));
1686
+ return {
1687
+ projects: parsed.projects || [],
1688
+ scenes: parsed.scenes || []
1689
+ };
1690
+ }
1691
+ function saveStore(pathname, data) {
1692
+ fs5.writeFileSync(pathname, JSON.stringify(data, null, 2), "utf-8");
1693
+ }
1694
+ var FileProjectStore = class {
1695
+ constructor(cwd) {
1696
+ this.cwd = cwd;
1697
+ }
1698
+ load() {
1699
+ return ensureStore(getStorePath(this.cwd));
1700
+ }
1701
+ save(data) {
1702
+ saveStore(getStorePath(this.cwd), data);
1703
+ }
1704
+ async createProject(input) {
1705
+ const data = this.load();
1706
+ const now = nowIso();
1707
+ const project = {
1708
+ id: randomUUID(),
1709
+ name: input.name,
1710
+ defaultSceneId: null,
1711
+ rootPath: input.rootPath || this.cwd,
1712
+ ownerId: input.ownerId || null,
1713
+ createdAt: now,
1714
+ updatedAt: now
1715
+ };
1716
+ data.projects.push(project);
1717
+ this.save(data);
1718
+ return project;
1719
+ }
1720
+ async listProjects() {
1721
+ const data = this.load();
1722
+ return [...data.projects].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
1723
+ }
1724
+ async getProject(projectId) {
1725
+ const data = this.load();
1726
+ return data.projects.find((p) => p.id === projectId) || null;
1727
+ }
1728
+ async updateProject(projectId, updates) {
1729
+ const data = this.load();
1730
+ const idx = data.projects.findIndex((p) => p.id === projectId);
1731
+ if (idx === -1) {
1732
+ throw new NotFoundError(`Project not found: ${projectId}`);
1733
+ }
1734
+ const next = {
1735
+ ...data.projects[idx],
1736
+ ...updates,
1737
+ updatedAt: nowIso()
1738
+ };
1739
+ data.projects[idx] = next;
1740
+ this.save(data);
1741
+ return next;
1742
+ }
1743
+ };
1744
+ var FileSceneStore = class {
1745
+ constructor(cwd) {
1746
+ this.cwd = cwd;
1747
+ }
1748
+ load() {
1749
+ return ensureStore(getStorePath(this.cwd));
1750
+ }
1751
+ save(data) {
1752
+ saveStore(getStorePath(this.cwd), data);
1753
+ }
1754
+ ensureProject(data, projectId) {
1755
+ const found = data.projects.some((p) => p.id === projectId);
1756
+ if (!found) {
1757
+ throw new NotFoundError(`Project not found: ${projectId}`);
1758
+ }
1759
+ }
1760
+ async listScenes(projectId) {
1761
+ const data = this.load();
1762
+ this.ensureProject(data, projectId);
1763
+ return data.scenes.filter((s) => s.projectId === projectId).sort((a, b) => a.orderIndex - b.orderIndex || a.createdAt.localeCompare(b.createdAt));
1764
+ }
1765
+ async getScene(projectId, sceneId) {
1766
+ const data = this.load();
1767
+ this.ensureProject(data, projectId);
1768
+ return data.scenes.find((s) => s.projectId === projectId && s.id === sceneId) || null;
1769
+ }
1770
+ async createScene(projectId, input) {
1771
+ const data = this.load();
1772
+ this.ensureProject(data, projectId);
1773
+ const projectScenes = data.scenes.filter((s) => s.projectId === projectId);
1774
+ const nextOrder = input.orderIndex ?? projectScenes.length;
1775
+ const now = nowIso();
1776
+ const record = {
1777
+ id: randomUUID(),
1778
+ projectId,
1779
+ name: input.name || `Scene ${projectScenes.length + 1}`,
1780
+ orderIndex: nextOrder,
1781
+ version: 1,
1782
+ meta: input.scene?.meta || DEFAULT_SCENE.meta,
1783
+ entities: input.scene?.entities || DEFAULT_SCENE.entities,
1784
+ createdAt: now,
1785
+ updatedAt: now
1786
+ };
1787
+ data.scenes.push(record);
1788
+ const projectIdx = data.projects.findIndex((p) => p.id === projectId);
1789
+ if (projectIdx >= 0 && !data.projects[projectIdx].defaultSceneId) {
1790
+ data.projects[projectIdx] = {
1791
+ ...data.projects[projectIdx],
1792
+ defaultSceneId: record.id,
1793
+ updatedAt: now
1794
+ };
1795
+ }
1796
+ this.save(data);
1797
+ return record;
1798
+ }
1799
+ async updateScene(projectId, sceneId, updates, expectedVersion) {
1800
+ const data = this.load();
1801
+ this.ensureProject(data, projectId);
1802
+ const idx = data.scenes.findIndex((s) => s.projectId === projectId && s.id === sceneId);
1803
+ if (idx === -1) {
1804
+ throw new NotFoundError(`Scene not found: ${sceneId}`);
1805
+ }
1806
+ const current = data.scenes[idx];
1807
+ if (typeof expectedVersion === "number" && current.version !== expectedVersion) {
1808
+ throw new ConflictError(`Version conflict for scene ${sceneId}`);
1809
+ }
1810
+ const next = {
1811
+ ...current,
1812
+ name: updates.name ?? current.name,
1813
+ meta: updates.meta ?? current.meta,
1814
+ entities: updates.entities ?? current.entities,
1815
+ version: current.version + 1,
1816
+ updatedAt: nowIso()
1817
+ };
1818
+ data.scenes[idx] = next;
1819
+ this.save(data);
1820
+ return next;
1821
+ }
1822
+ async deleteScene(projectId, sceneId) {
1823
+ const data = this.load();
1824
+ this.ensureProject(data, projectId);
1825
+ const before = data.scenes.length;
1826
+ data.scenes = data.scenes.filter((s) => !(s.projectId === projectId && s.id === sceneId));
1827
+ if (data.scenes.length === before) {
1828
+ throw new NotFoundError(`Scene not found: ${sceneId}`);
1829
+ }
1830
+ const projectIdx = data.projects.findIndex((p) => p.id === projectId);
1831
+ if (projectIdx >= 0 && data.projects[projectIdx].defaultSceneId === sceneId) {
1832
+ const replacement = data.scenes.find((s) => s.projectId === projectId)?.id || null;
1833
+ data.projects[projectIdx] = {
1834
+ ...data.projects[projectIdx],
1835
+ defaultSceneId: replacement,
1836
+ updatedAt: nowIso()
1837
+ };
1838
+ }
1839
+ this.save(data);
1840
+ }
1841
+ };
1842
+
1843
+ // src/cli/services/scenes/store-factory.ts
1844
+ function normalizeSceneStorageBackend(value) {
1845
+ return "file";
1846
+ }
1847
+ function createSceneStoreBundle(backendInput) {
1848
+ normalizeSceneStorageBackend(backendInput);
1849
+ const cwd = process.cwd();
1850
+ const projects = new FileProjectStore(cwd);
1851
+ const scenes = new FileSceneStore(cwd);
1852
+ return { projects, scenes };
1853
+ }
1854
+
1084
1855
  // src/cli/server/api.ts
1085
1856
  var api = new Hono();
1086
1857
  var resolveSafePath = (relativePath) => safeResolveFromCwd(relativePath);
1858
+ var assetRegistry = createDefaultAssetResolverRegistry();
1087
1859
  function createStoreFromBody(body) {
1088
1860
  return createTasteStore({
1089
1861
  backend: body.storage ? String(body.storage) : void 0,
@@ -1092,7 +1864,16 @@ function createStoreFromBody(body) {
1092
1864
  suggestionsFilePath: body.suggestionsFilePath ? String(body.suggestionsFilePath) : "taste/suggestions.md"
1093
1865
  });
1094
1866
  }
1095
- api.get("/fs/list", async (c) => {
1867
+ async function getProjectRoot(projectId) {
1868
+ if (!projectId) return process.cwd();
1869
+ const bundle = createSceneStoreBundle();
1870
+ const project = await bundle.projects.getProject(projectId);
1871
+ if (!project) {
1872
+ throw new NotFoundError(`Project not found: ${projectId}`);
1873
+ }
1874
+ return project.rootPath;
1875
+ }
1876
+ api.get("/v1/files/list", async (c) => {
1096
1877
  const cwd = process.cwd();
1097
1878
  try {
1098
1879
  const files = await readdir(cwd, { withFileTypes: true });
@@ -1106,36 +1887,23 @@ api.get("/fs/list", async (c) => {
1106
1887
  return c.json({ error: e.message }, 500);
1107
1888
  }
1108
1889
  });
1109
- api.get("/fs/read", async (c) => {
1110
- const path19 = c.req.query("path");
1111
- if (!path19) return c.json({ error: "Path required" }, 400);
1890
+ api.get("/v1/files/read", async (c) => {
1891
+ const filePath = c.req.query("path");
1892
+ if (!filePath) return c.json({ error: "Path required" }, 400);
1112
1893
  try {
1113
- const fullPath = resolveSafePath(path19);
1894
+ const fullPath = resolveSafePath(filePath);
1114
1895
  const content = await readFile(fullPath, "utf-8");
1115
- if (path19.endsWith(".json")) {
1116
- try {
1117
- const json = JSON.parse(content);
1118
- if (json.meta && Array.isArray(json.entities)) {
1119
- console.debug(`[API] Resolving scene: ${path19}`);
1120
- const resolved = await SceneResolver.resolve(json, process.cwd());
1121
- return c.json({ content: JSON.stringify(resolved, null, 2) });
1122
- }
1123
- } catch (e) {
1124
- console.error(`[API] Scene resolution failed for ${path19}:`, e.message);
1125
- }
1126
- }
1127
1896
  return c.json({ content });
1128
1897
  } catch (e) {
1129
- console.error(`[API] Error reading file ${path19}:`, e.message, e.stack);
1130
1898
  return c.json({ error: e.message }, 500);
1131
1899
  }
1132
1900
  });
1133
- api.post("/fs/write", async (c) => {
1901
+ api.post("/v1/files/write", async (c) => {
1134
1902
  const body = await c.req.json();
1135
- const { path: path19, content } = body;
1136
- if (!path19 || content === void 0) return c.json({ error: "Path and content required" }, 400);
1903
+ const { path: path22, content } = body;
1904
+ if (!path22 || content === void 0) return c.json({ error: "Path and content required" }, 400);
1137
1905
  try {
1138
- const fullPath = resolveSafePath(path19);
1906
+ const fullPath = resolveSafePath(path22);
1139
1907
  const dir = dirname(fullPath);
1140
1908
  if (!existsSync(dir)) {
1141
1909
  await mkdir(dir, { recursive: true });
@@ -1146,14 +1914,139 @@ api.post("/fs/write", async (c) => {
1146
1914
  return c.json({ error: e.message }, 500);
1147
1915
  }
1148
1916
  });
1149
- api.post("/fs/upload", async (c) => {
1917
+ api.post("/v1/projects", async (c) => {
1918
+ try {
1919
+ const body = await c.req.json();
1920
+ const bundle = createSceneStoreBundle(body.storage ? String(body.storage) : void 0);
1921
+ const project = await bundle.projects.createProject({
1922
+ name: body.name ? String(body.name) : "Untitled Project",
1923
+ rootPath: body.rootPath ? String(body.rootPath) : process.cwd(),
1924
+ ownerId: body.ownerId ? String(body.ownerId) : null
1925
+ });
1926
+ const defaultScene = await bundle.scenes.createScene(project.id, {
1927
+ name: body.defaultSceneName ? String(body.defaultSceneName) : "Scene 1"
1928
+ });
1929
+ const updated = await bundle.projects.updateProject(project.id, { defaultSceneId: defaultScene.id });
1930
+ return c.json({ project: updated, defaultScene }, 201);
1931
+ } catch (e) {
1932
+ return c.json({ error: e.message }, 500);
1933
+ }
1934
+ });
1935
+ api.get("/v1/projects", async (c) => {
1936
+ try {
1937
+ const bundle = createSceneStoreBundle();
1938
+ const projects = await bundle.projects.listProjects();
1939
+ return c.json({ projects });
1940
+ } catch (e) {
1941
+ return c.json({ error: e.message }, 500);
1942
+ }
1943
+ });
1944
+ api.get("/v1/projects/:projectId", async (c) => {
1945
+ try {
1946
+ const projectId = c.req.param("projectId");
1947
+ const bundle = createSceneStoreBundle();
1948
+ const project = await bundle.projects.getProject(projectId);
1949
+ if (!project) return c.json({ error: "Project not found" }, 404);
1950
+ return c.json({ project });
1951
+ } catch (e) {
1952
+ return c.json({ error: e.message }, 500);
1953
+ }
1954
+ });
1955
+ api.get("/v1/projects/:projectId/scenes", async (c) => {
1956
+ try {
1957
+ const projectId = c.req.param("projectId");
1958
+ const bundle = createSceneStoreBundle();
1959
+ const scenes = await bundle.scenes.listScenes(projectId);
1960
+ return c.json({ scenes });
1961
+ } catch (e) {
1962
+ if (e instanceof NotFoundError) return c.json({ error: e.message }, 404);
1963
+ return c.json({ error: e.message }, 500);
1964
+ }
1965
+ });
1966
+ api.post("/v1/projects/:projectId/scenes", async (c) => {
1967
+ try {
1968
+ const projectId = c.req.param("projectId");
1969
+ const body = await c.req.json();
1970
+ const bundle = createSceneStoreBundle(body.storage ? String(body.storage) : void 0);
1971
+ const scene = await bundle.scenes.createScene(projectId, {
1972
+ name: body.name ? String(body.name) : void 0,
1973
+ orderIndex: body.orderIndex !== void 0 ? Number(body.orderIndex) : void 0,
1974
+ scene: body.scene
1975
+ });
1976
+ return c.json({ scene }, 201);
1977
+ } catch (e) {
1978
+ if (e instanceof NotFoundError) return c.json({ error: e.message }, 404);
1979
+ return c.json({ error: e.message }, 500);
1980
+ }
1981
+ });
1982
+ api.get("/v1/projects/:projectId/scenes/:sceneId", async (c) => {
1983
+ try {
1984
+ const { projectId, sceneId } = c.req.param();
1985
+ const bundle = createSceneStoreBundle();
1986
+ const scene = await bundle.scenes.getScene(projectId, sceneId);
1987
+ if (!scene) return c.json({ error: "Scene not found" }, 404);
1988
+ const resolved = await SceneResolver.resolve(
1989
+ {
1990
+ meta: scene.meta,
1991
+ entities: scene.entities
1992
+ },
1993
+ (await bundle.projects.getProject(projectId))?.rootPath || process.cwd()
1994
+ );
1995
+ return c.json({
1996
+ scene: {
1997
+ ...scene,
1998
+ meta: resolved.meta,
1999
+ entities: resolved.entities
2000
+ }
2001
+ });
2002
+ } catch (e) {
2003
+ if (e instanceof NotFoundError) return c.json({ error: e.message }, 404);
2004
+ return c.json({ error: e.message }, 500);
2005
+ }
2006
+ });
2007
+ api.put("/v1/projects/:projectId/scenes/:sceneId", async (c) => {
2008
+ try {
2009
+ const { projectId, sceneId } = c.req.param();
2010
+ const body = await c.req.json();
2011
+ const bundle = createSceneStoreBundle(body.storage ? String(body.storage) : void 0);
2012
+ const scene = await bundle.scenes.updateScene(
2013
+ projectId,
2014
+ sceneId,
2015
+ {
2016
+ name: body.name ? String(body.name) : void 0,
2017
+ meta: body.meta,
2018
+ entities: body.entities
2019
+ },
2020
+ body.version !== void 0 ? Number(body.version) : void 0
2021
+ );
2022
+ return c.json({ scene });
2023
+ } catch (e) {
2024
+ if (e instanceof NotFoundError) return c.json({ error: e.message }, 404);
2025
+ if (e instanceof ConflictError || e?.name === "ConflictError") return c.json({ error: e.message }, 409);
2026
+ return c.json({ error: e.message }, 500);
2027
+ }
2028
+ });
2029
+ api.delete("/v1/projects/:projectId/scenes/:sceneId", async (c) => {
2030
+ try {
2031
+ const { projectId, sceneId } = c.req.param();
2032
+ const bundle = createSceneStoreBundle();
2033
+ await bundle.scenes.deleteScene(projectId, sceneId);
2034
+ return c.body(null, 204);
2035
+ } catch (e) {
2036
+ if (e instanceof NotFoundError) return c.json({ error: e.message }, 404);
2037
+ return c.json({ error: e.message }, 500);
2038
+ }
2039
+ });
2040
+ api.post("/v1/assets/upload", async (c) => {
1150
2041
  try {
1151
2042
  const body = await c.req.parseBody();
1152
2043
  const file = body.file;
1153
2044
  if (!file || !(file instanceof File)) {
1154
2045
  return c.json({ error: "File required" }, 400);
1155
2046
  }
1156
- const assetsDir = join(process.cwd(), "assets");
2047
+ const projectId = body.projectId ? String(body.projectId) : null;
2048
+ const rootPath = await getProjectRoot(projectId);
2049
+ const assetsDir = join(rootPath, "assets");
1157
2050
  if (!existsSync(assetsDir)) {
1158
2051
  await mkdir(assetsDir, { recursive: true });
1159
2052
  }
@@ -1161,57 +2054,58 @@ api.post("/fs/upload", async (c) => {
1161
2054
  const filePath = join(assetsDir, fileName);
1162
2055
  const bytes = Buffer.from(await file.arrayBuffer());
1163
2056
  await writeFile(filePath, bytes);
2057
+ const uri = `assets/${fileName}`;
1164
2058
  return c.json({
1165
2059
  success: true,
1166
- path: `assets/${fileName}`,
1167
- url: `/api/fs/assets/assets/${fileName}`
2060
+ uri,
2061
+ url: `/api/v1/assets/content?uri=${encodeURIComponent(uri)}${projectId ? `&projectId=${encodeURIComponent(projectId)}` : ""}`
1168
2062
  });
1169
2063
  } catch (e) {
1170
- console.error(e);
1171
2064
  return c.json({ error: e.message }, 500);
1172
2065
  }
1173
2066
  });
1174
- api.get("/fs/assets/*", async (c) => {
1175
- const reqPath = c.req.path;
1176
- const marker = "/fs/assets/";
1177
- const index = reqPath.lastIndexOf(marker);
1178
- const relativePath = reqPath.substring(index + marker.length);
1179
- if (relativePath.includes("..")) {
1180
- console.error(`[Asset Server] Blocked directory traversal attempt: ${relativePath}`);
1181
- return c.json({ error: "Invalid path" }, 403);
1182
- }
1183
- let fullPath = "";
2067
+ api.get("/v1/assets/content", async (c) => {
1184
2068
  try {
1185
- fullPath = resolveSafePath(relativePath);
1186
- } catch {
1187
- return c.json({ error: "Invalid path" }, 403);
1188
- }
1189
- if (!existsSync(fullPath)) {
1190
- try {
1191
- fullPath = resolveSafePath(join("assets", relativePath));
1192
- } catch {
1193
- return c.json({ error: "Invalid path" }, 403);
2069
+ const uri = c.req.query("uri");
2070
+ const projectId = c.req.query("projectId");
2071
+ if (!uri) return c.json({ error: "uri is required" }, 400);
2072
+ const rootPath = await getProjectRoot(projectId || null);
2073
+ const resolved = assetRegistry.resolve(uri, rootPath);
2074
+ if (resolved.type === "unsupported") {
2075
+ return c.json({ error: resolved.reason }, 400);
2076
+ }
2077
+ if (resolved.type === "remote") {
2078
+ const response = await fetch(resolved.remoteUrl);
2079
+ if (!response.ok) {
2080
+ return c.json({ error: `Failed to fetch remote asset: ${response.status}` }, 502);
2081
+ }
2082
+ const bytes = await response.arrayBuffer();
2083
+ const contentType = response.headers.get("content-type") || "application/octet-stream";
2084
+ return new Response(bytes, {
2085
+ headers: {
2086
+ "Content-Type": contentType,
2087
+ "Cache-Control": "public, max-age=300"
2088
+ }
2089
+ });
1194
2090
  }
1195
- }
1196
- if (!existsSync(fullPath)) {
1197
- console.error(`[Asset Server] File not found: ${fullPath}`);
1198
- return c.notFound();
1199
- }
1200
- const ext = fullPath.split(".").pop()?.toLowerCase();
1201
- const mimeTypes = {
1202
- png: "image/png",
1203
- jpg: "image/jpeg",
1204
- jpeg: "image/jpeg",
1205
- gif: "image/gif",
1206
- webp: "image/webp",
1207
- svg: "image/svg+xml",
1208
- mp3: "audio/mpeg",
1209
- mp4: "video/mp4",
1210
- webm: "video/webm"
1211
- };
1212
- const mimeType = mimeTypes[ext || ""] || "application/octet-stream";
1213
- try {
1214
- const file = await readFile(fullPath);
2091
+ if (!existsSync(resolved.localPath)) {
2092
+ return c.json({ error: `Asset not found at ${resolved.localPath}` }, 404);
2093
+ }
2094
+ const ext = resolved.localPath.split(".").pop()?.toLowerCase();
2095
+ const mimeTypes = {
2096
+ png: "image/png",
2097
+ jpg: "image/jpeg",
2098
+ jpeg: "image/jpeg",
2099
+ gif: "image/gif",
2100
+ webp: "image/webp",
2101
+ svg: "image/svg+xml",
2102
+ mp3: "audio/mpeg",
2103
+ mp4: "video/mp4",
2104
+ webm: "video/webm",
2105
+ wav: "audio/wav"
2106
+ };
2107
+ const mimeType = mimeTypes[ext || ""] || "application/octet-stream";
2108
+ const file = await readFile(resolved.localPath);
1215
2109
  return new Response(file, {
1216
2110
  headers: {
1217
2111
  "Content-Type": mimeType,
@@ -1219,8 +2113,22 @@ api.get("/fs/assets/*", async (c) => {
1219
2113
  }
1220
2114
  });
1221
2115
  } catch (e) {
1222
- console.error(`[Asset Server] Error serving ${fullPath}:`, e.message);
1223
- return c.notFound();
2116
+ if (e instanceof NotFoundError) return c.json({ error: e.message }, 404);
2117
+ return c.json({ error: e.message }, 500);
2118
+ }
2119
+ });
2120
+ api.post("/v1/assets/resolve", async (c) => {
2121
+ try {
2122
+ const body = await c.req.json();
2123
+ const uri = String(body.uri || "").trim();
2124
+ const projectId = body.projectId ? String(body.projectId) : null;
2125
+ if (!uri) return c.json({ error: "uri is required" }, 400);
2126
+ const rootPath = await getProjectRoot(projectId);
2127
+ const resolved = assetRegistry.resolve(uri, rootPath);
2128
+ return c.json({ resolved });
2129
+ } catch (e) {
2130
+ if (e instanceof NotFoundError) return c.json({ error: e.message }, 404);
2131
+ return c.json({ error: e.message }, 500);
1224
2132
  }
1225
2133
  });
1226
2134
  api.post("/taste/chat", async (c) => {
@@ -1229,6 +2137,15 @@ api.post("/taste/chat", async (c) => {
1229
2137
  const message = String(body.message || "").trim();
1230
2138
  const model = body.model ? String(body.model) : void 0;
1231
2139
  const apiKey = body.apiKey || process.env.GEMINI_API_KEY;
2140
+ const chatHistory = Array.isArray(body.chatHistory) ? body.chatHistory.map((entry) => ({
2141
+ role: entry.role === "assistant" ? "assistant" : "user",
2142
+ text: String(entry.text || "")
2143
+ })) : void 0;
2144
+ const askUserCount = body.askUserCount ? Number(body.askUserCount) : void 0;
2145
+ const askUserMax = body.askUserMax ? Number(body.askUserMax) : void 0;
2146
+ const interactionCount = body.interactionCount ? Number(body.interactionCount) : void 0;
2147
+ const interactionMin = body.interactionMin ? Number(body.interactionMin) : void 0;
2148
+ const interactionMax = body.interactionMax ? Number(body.interactionMax) : void 0;
1232
2149
  if (!message) return c.json({ error: "message is required" }, 400);
1233
2150
  if (!apiKey) return c.json({ error: "Missing GEMINI_API_KEY" }, 400);
1234
2151
  const store = createStoreFromBody(body);
@@ -1240,13 +2157,47 @@ api.post("/taste/chat", async (c) => {
1240
2157
  tasteContent: body.tasteContent ? String(body.tasteContent) : taste.content,
1241
2158
  memories,
1242
2159
  apiKey: String(apiKey),
1243
- model
2160
+ model,
2161
+ chatHistory,
2162
+ askUserCount,
2163
+ askUserMax,
2164
+ interactionCount,
2165
+ interactionMin,
2166
+ interactionMax
1244
2167
  });
1245
2168
  return c.json(result);
1246
2169
  } catch (e) {
1247
2170
  return c.json({ error: e.message }, 500);
1248
2171
  }
1249
2172
  });
2173
+ api.post("/taste/askuser/imagine", async (c) => {
2174
+ try {
2175
+ const body = await c.req.json();
2176
+ const options = Array.isArray(body.options) ? body.options : [];
2177
+ if (options.length === 0) return c.json({ error: "options array is required" }, 400);
2178
+ const results = [];
2179
+ const now = Date.now();
2180
+ for (let i = 0; i < options.length; i++) {
2181
+ const entry = options[i] || {};
2182
+ const optionId = String(entry.optionId || "").trim();
2183
+ const prompt = String(entry.prompt || "").trim();
2184
+ const aspectRatio = String(entry.aspectRatio || "9:16");
2185
+ if (!optionId || !prompt) continue;
2186
+ const safeId = optionId.replace(/[^a-zA-Z0-9_-]/g, "-");
2187
+ const fileName = `askuser/${now}_${safeId}.png`;
2188
+ const args = ["imagine", prompt, "--aspect-ratio", aspectRatio, "-o", fileName, "--json"];
2189
+ const output = await runCliCommand(args, process.cwd());
2190
+ const files = Array.isArray(output.files) ? output.files : [];
2191
+ const first = files[0] ? String(files[0]) : "";
2192
+ if (!first) continue;
2193
+ const uri = relative(process.cwd(), first).replace(/\\/g, "/");
2194
+ results.push({ optionId, uri, url: toBrowserAssetUrl(uri) });
2195
+ }
2196
+ return c.json({ results });
2197
+ } catch (e) {
2198
+ return c.json({ error: e.message }, 500);
2199
+ }
2200
+ });
1250
2201
  api.post("/taste/simulate", async (c) => {
1251
2202
  try {
1252
2203
  const body = await c.req.json();
@@ -1268,14 +2219,53 @@ api.post("/taste/simulate", async (c) => {
1268
2219
  apiKey: String(apiKey),
1269
2220
  model
1270
2221
  });
1271
- let appendedIds = [];
2222
+ const planResult = await simulatePlanCards({
2223
+ tasteContent: body.tasteContent ? String(body.tasteContent) : taste.content,
2224
+ memories,
2225
+ count,
2226
+ mode,
2227
+ apiKey: String(apiKey),
2228
+ model
2229
+ });
2230
+ const appendedIds = [];
1272
2231
  if (saveToMemory && result.ideas.length > 0) {
1273
2232
  for (const idea of result.ideas) {
1274
2233
  const saved = await store.appendMemory({ ...idea, status: "generated" });
1275
2234
  appendedIds.push(saved.id);
1276
2235
  }
1277
2236
  }
1278
- return c.json({ ...result, appendedIds });
2237
+ return c.json({ ...result, planCards: planResult.planCards || [], appendedIds });
2238
+ } catch (e) {
2239
+ return c.json({ error: e.message }, 500);
2240
+ }
2241
+ });
2242
+ api.post("/taste/simulate/followup", async (c) => {
2243
+ try {
2244
+ const body = await c.req.json();
2245
+ const cardTitle = String(body.cardTitle || "").trim();
2246
+ const bulletText = String(body.bulletText || "").trim();
2247
+ const bulletId = String(body.bulletId || "").trim() || `bullet_${Date.now()}`;
2248
+ const model = body.model ? String(body.model) : void 0;
2249
+ const apiKey = body.apiKey || process.env.GEMINI_API_KEY;
2250
+ const count = Math.max(3, Math.min(6, Number(body.count || 4)));
2251
+ if (!cardTitle) return c.json({ error: "cardTitle is required" }, 400);
2252
+ if (!bulletText) return c.json({ error: "bulletText is required" }, 400);
2253
+ if (!apiKey) return c.json({ error: "Missing GEMINI_API_KEY" }, 400);
2254
+ const store = createStoreFromBody(body);
2255
+ await store.ensureWorkspace();
2256
+ const taste = await store.readTaste();
2257
+ const memories = body.memoryContent ? parseTasteMemoryMarkdown(String(body.memoryContent)).slice(0, 40) : await store.queryMemory({ limit: 40 });
2258
+ const result = await generateBulletFollowupPrompts({
2259
+ tasteContent: body.tasteContent ? String(body.tasteContent) : taste.content,
2260
+ memories,
2261
+ cardTitle,
2262
+ bulletText,
2263
+ bulletId,
2264
+ apiKey: String(apiKey),
2265
+ model,
2266
+ count
2267
+ });
2268
+ return c.json(result);
1279
2269
  } catch (e) {
1280
2270
  return c.json({ error: e.message }, 500);
1281
2271
  }
@@ -1410,21 +2400,20 @@ api.post("/cli/run", async (c) => {
1410
2400
  const result = await runCliCommand(args, cwd);
1411
2401
  return c.json(result);
1412
2402
  } catch (e) {
1413
- console.error("[API] /cli/run error:", e.message);
1414
2403
  return c.json({ error: e.message }, 500);
1415
2404
  }
1416
2405
  });
1417
2406
  var api_default = api;
1418
2407
 
1419
2408
  // src/cli/server/index.ts
1420
- import path5 from "path";
2409
+ import path6 from "path";
1421
2410
  import { fileURLToPath as fileURLToPath2 } from "url";
1422
- import fs4 from "fs";
2411
+ import fs6 from "fs";
1423
2412
  var app = new Hono2();
1424
2413
  app.use("/*", cors());
1425
2414
  app.route("/api", api_default);
1426
2415
  var __filename2 = fileURLToPath2(import.meta.url);
1427
- var __dirname2 = path5.dirname(__filename2);
2416
+ var __dirname2 = path6.dirname(__filename2);
1428
2417
  var createServer = (staticRoot) => {
1429
2418
  const app2 = new Hono2();
1430
2419
  app2.use("/*", cors());
@@ -1455,10 +2444,10 @@ var createServer = (staticRoot) => {
1455
2444
  };
1456
2445
  app2.get("/*", async (c) => {
1457
2446
  const requestPath = c.req.path === "/" ? "/index.html" : c.req.path;
1458
- const filePath = path5.join(staticRoot, requestPath);
2447
+ const filePath = path6.join(staticRoot, requestPath);
1459
2448
  try {
1460
- if (fs4.existsSync(filePath)) {
1461
- const file = await fs4.promises.readFile(filePath);
2449
+ if (fs6.existsSync(filePath)) {
2450
+ const file = await fs6.promises.readFile(filePath);
1462
2451
  const mimeType = getMimeType(filePath);
1463
2452
  return new Response(file, {
1464
2453
  headers: {
@@ -1470,9 +2459,9 @@ var createServer = (staticRoot) => {
1470
2459
  }
1471
2460
  if (!requestPath.includes(".")) {
1472
2461
  try {
1473
- const indexPath = path5.join(staticRoot, "index.html");
1474
- if (fs4.existsSync(indexPath)) {
1475
- const indexFile = await fs4.promises.readFile(indexPath);
2462
+ const indexPath = path6.join(staticRoot, "index.html");
2463
+ if (fs6.existsSync(indexPath)) {
2464
+ const indexFile = await fs6.promises.readFile(indexPath);
1476
2465
  return new Response(indexFile, {
1477
2466
  headers: {
1478
2467
  "Content-Type": "text/html"
@@ -1499,9 +2488,150 @@ function startServer(app2, port) {
1499
2488
  };
1500
2489
  }
1501
2490
 
2491
+ // src/cli/services/scenes/api-client.ts
2492
+ function normalizeApiBaseUrl(input) {
2493
+ const base = (input || process.env.FEEDEAS_API_URL || "http://localhost:3331").replace(/\/$/, "");
2494
+ if (base.endsWith("/api/v1")) return base;
2495
+ if (base.endsWith("/api")) return `${base}/v1`;
2496
+ return `${base}/api/v1`;
2497
+ }
2498
+ async function readJson(res) {
2499
+ const data = await res.json().catch(() => ({}));
2500
+ if (!res.ok) {
2501
+ throw new Error(data.error || `API request failed (${res.status})`);
2502
+ }
2503
+ return data;
2504
+ }
2505
+ var SceneApiClient = class {
2506
+ baseUrl;
2507
+ constructor(apiBaseUrl) {
2508
+ this.baseUrl = normalizeApiBaseUrl(apiBaseUrl);
2509
+ }
2510
+ async createProject(input) {
2511
+ const res = await fetch(`${this.baseUrl}/projects`, {
2512
+ method: "POST",
2513
+ headers: { "Content-Type": "application/json" },
2514
+ body: JSON.stringify(input)
2515
+ });
2516
+ return readJson(res);
2517
+ }
2518
+ async listProjects() {
2519
+ const res = await fetch(`${this.baseUrl}/projects`);
2520
+ const data = await readJson(res);
2521
+ return data.projects || [];
2522
+ }
2523
+ async getProject(projectId) {
2524
+ const res = await fetch(`${this.baseUrl}/projects/${encodeURIComponent(projectId)}`);
2525
+ if (res.status === 404) return null;
2526
+ const data = await readJson(res);
2527
+ return data.project || null;
2528
+ }
2529
+ async listScenes(projectId) {
2530
+ const res = await fetch(`${this.baseUrl}/projects/${encodeURIComponent(projectId)}/scenes`);
2531
+ const data = await readJson(res);
2532
+ return data.scenes || [];
2533
+ }
2534
+ async createScene(projectId, input) {
2535
+ const res = await fetch(`${this.baseUrl}/projects/${encodeURIComponent(projectId)}/scenes`, {
2536
+ method: "POST",
2537
+ headers: { "Content-Type": "application/json" },
2538
+ body: JSON.stringify(input)
2539
+ });
2540
+ const data = await readJson(res);
2541
+ return data.scene;
2542
+ }
2543
+ async getScene(projectId, sceneId) {
2544
+ const res = await fetch(`${this.baseUrl}/projects/${encodeURIComponent(projectId)}/scenes/${encodeURIComponent(sceneId)}`);
2545
+ if (res.status === 404) return null;
2546
+ const data = await readJson(res);
2547
+ return data.scene || null;
2548
+ }
2549
+ async updateScene(projectId, sceneId, input) {
2550
+ const res = await fetch(`${this.baseUrl}/projects/${encodeURIComponent(projectId)}/scenes/${encodeURIComponent(sceneId)}`, {
2551
+ method: "PUT",
2552
+ headers: { "Content-Type": "application/json" },
2553
+ body: JSON.stringify(input)
2554
+ });
2555
+ const data = await readJson(res);
2556
+ return data.scene;
2557
+ }
2558
+ async deleteScene(projectId, sceneId) {
2559
+ const res = await fetch(`${this.baseUrl}/projects/${encodeURIComponent(projectId)}/scenes/${encodeURIComponent(sceneId)}`, {
2560
+ method: "DELETE"
2561
+ });
2562
+ if (!res.ok && res.status !== 204) {
2563
+ const data = await res.json().catch(() => ({}));
2564
+ throw new Error(data.error || `Delete scene failed (${res.status})`);
2565
+ }
2566
+ }
2567
+ async resolveAsset(uri, projectId) {
2568
+ const res = await fetch(`${this.baseUrl}/assets/resolve`, {
2569
+ method: "POST",
2570
+ headers: { "Content-Type": "application/json" },
2571
+ body: JSON.stringify({ uri, projectId })
2572
+ });
2573
+ const data = await readJson(res);
2574
+ return data.resolved;
2575
+ }
2576
+ assetContentUrl(uri, projectId) {
2577
+ const q = new URLSearchParams({ uri });
2578
+ if (projectId) q.set("projectId", projectId);
2579
+ return `${this.baseUrl}/assets/content?${q.toString()}`;
2580
+ }
2581
+ };
2582
+
2583
+ // src/cli/services/scenes/flow-state.ts
2584
+ import fs7 from "node:fs";
2585
+ import path7 from "node:path";
2586
+ function sessionPath(cwd) {
2587
+ return path7.resolve(cwd, ".feedeas/session.json");
2588
+ }
2589
+ function ensureDir(filePath) {
2590
+ const dir = path7.dirname(filePath);
2591
+ if (!fs7.existsSync(dir)) fs7.mkdirSync(dir, { recursive: true });
2592
+ }
2593
+ function readFlowState(cwd = process.cwd()) {
2594
+ const file = sessionPath(cwd);
2595
+ if (!fs7.existsSync(file)) return null;
2596
+ try {
2597
+ return JSON.parse(fs7.readFileSync(file, "utf-8"));
2598
+ } catch {
2599
+ return null;
2600
+ }
2601
+ }
2602
+ function writeFlowState(updates, cwd = process.cwd()) {
2603
+ const existing = readFlowState(cwd) || { updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
2604
+ const next = {
2605
+ ...existing,
2606
+ ...updates,
2607
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
2608
+ };
2609
+ const file = sessionPath(cwd);
2610
+ ensureDir(file);
2611
+ fs7.writeFileSync(file, JSON.stringify(next, null, 2), "utf-8");
2612
+ return next;
2613
+ }
2614
+ function resolveProjectId(explicit) {
2615
+ if (explicit) return explicit;
2616
+ const state = readFlowState();
2617
+ if (state?.projectId) return state.projectId;
2618
+ throw new Error("Missing project ID. Pass --project-id or set flow state by running project create / scene use.");
2619
+ }
2620
+ function resolveSceneId(explicit) {
2621
+ if (explicit) return explicit;
2622
+ const state = readFlowState();
2623
+ if (state?.sceneId) return state.sceneId;
2624
+ throw new Error("Missing scene ID. Pass --scene-id or set flow state by running scene create/import/use.");
2625
+ }
2626
+ function resolveApiUrl(explicit) {
2627
+ if (explicit) return explicit;
2628
+ const state = readFlowState();
2629
+ return state?.apiUrl;
2630
+ }
2631
+
1502
2632
  // src/cli/commands/record.ts
1503
2633
  var __filename3 = fileURLToPath3(import.meta.url);
1504
- var __dirname3 = path6.dirname(__filename3);
2634
+ var __dirname3 = path8.dirname(__filename3);
1505
2635
  function formatBytes(bytes) {
1506
2636
  if (bytes === 0) return "0 B";
1507
2637
  const k = 1024;
@@ -1510,21 +2640,21 @@ function formatBytes(bytes) {
1510
2640
  return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
1511
2641
  }
1512
2642
  function getMissingUiAssets(staticRoot) {
1513
- const indexPath = path6.join(staticRoot, "index.html");
1514
- if (!fs5.existsSync(indexPath)) {
2643
+ const indexPath = path8.join(staticRoot, "index.html");
2644
+ if (!fs8.existsSync(indexPath)) {
1515
2645
  return {
1516
2646
  missingFiles: [indexPath],
1517
2647
  expectedUrls: []
1518
2648
  };
1519
2649
  }
1520
- const indexHtml = fs5.readFileSync(indexPath, "utf-8");
2650
+ const indexHtml = fs8.readFileSync(indexPath, "utf-8");
1521
2651
  const assetMatches = Array.from(indexHtml.matchAll(/(?:src|href)=["'](\/assets\/[^"']+)["']/g));
1522
2652
  const assetPaths = [...new Set(assetMatches.map((match) => match[1]))];
1523
2653
  const missingFiles = [];
1524
2654
  const expectedUrls = [];
1525
2655
  for (const assetPath of assetPaths) {
1526
- const localPath = path6.join(staticRoot, assetPath.replace(/^\//, ""));
1527
- if (!fs5.existsSync(localPath)) {
2656
+ const localPath = path8.join(staticRoot, assetPath.replace(/^\//, ""));
2657
+ if (!fs8.existsSync(localPath)) {
1528
2658
  missingFiles.push(localPath);
1529
2659
  expectedUrls.push(assetPath);
1530
2660
  }
@@ -1612,21 +2742,28 @@ async function ensurePlaywrightReadyOrExit() {
1612
2742
  process.exit(1);
1613
2743
  }
1614
2744
  }
1615
- var recordCommand = new Command("record").description("Record the project to a video file with audio").option("-o, --output <path>", "Output file path", "output.mp4").option("-u, --url <url>", "URL of the running server", "http://localhost:3331").option("-w, --width <number>", "Viewport width", "1920").option("-h, --height <number>", "Viewport height", "1080").option("-f, --fps <number>", "Frames per second", "30").option("--project <path>", "Path to project file", "scene_1.json").option("--dry-run", "Validate scene and show estimates without rendering").option("--debug", "Enable verbose logging (FFmpeg, browser console)").action(async (options) => {
1616
- const { output, url, width, height, fps, project: projectOption, debug, dryRun } = options;
1617
- const projectPath = path6.resolve(process.cwd(), projectOption);
1618
- if (!fs5.existsSync(projectPath)) {
1619
- console.error(`Project file not found at ${projectPath}`);
2745
+ var recordCommand = new Command("record").description("Record the project to a video file with audio").option("-o, --output <path>", "Output file path", "output.mp4").option("-u, --url <url>", "URL of the running server", "http://localhost:3331").option("-w, --width <number>", "Viewport width", "1920").option("-h, --height <number>", "Viewport height", "1080").option("-f, --fps <number>", "Frames per second", "30").option("--project-id <id>", "Project ID").option("--scene-id <id>", "Scene ID").option("--api-url <url>", "API base URL for scene/project metadata").option("--dry-run", "Validate scene and show estimates without rendering").option("--debug", "Enable verbose logging (FFmpeg, browser console)").action(async (options) => {
2746
+ const { output, url, width, height, fps, debug, dryRun } = options;
2747
+ const projectId = resolveProjectId(options.projectId);
2748
+ const sceneId = resolveSceneId(options.sceneId);
2749
+ const apiUrl = resolveApiUrl(options.apiUrl);
2750
+ const apiClient = apiUrl ? new SceneApiClient(apiUrl) : null;
2751
+ const localBundle = createSceneStoreBundle("file");
2752
+ const assetRegistry2 = createDefaultAssetResolverRegistry();
2753
+ const projectRecord = apiClient ? await apiClient.getProject(projectId) : await localBundle.projects.getProject(projectId);
2754
+ if (!projectRecord) {
2755
+ console.error(`Project not found: ${projectId}`);
1620
2756
  process.exit(1);
1621
2757
  }
1622
- const projectDir = path6.dirname(projectPath);
1623
- process.chdir(projectDir);
1624
- console.log(`Working in project directory: ${projectDir}`);
1625
2758
  let project;
1626
2759
  try {
1627
- const rawProject = JSON.parse(fs5.readFileSync(projectPath, "utf-8"));
1628
- const resolvedCwd = process.cwd();
1629
- project = await SceneResolver.resolve(rawProject, resolvedCwd);
2760
+ const sceneRecord = apiClient ? await apiClient.getScene(projectId, sceneId) : await localBundle.scenes.getScene(projectId, sceneId);
2761
+ if (!sceneRecord) {
2762
+ console.error(`Scene not found: ${sceneId}`);
2763
+ process.exit(1);
2764
+ }
2765
+ const rawProject = { meta: sceneRecord.meta, entities: sceneRecord.entities };
2766
+ project = await SceneResolver.resolve(rawProject, projectRecord.rootPath || process.cwd());
1630
2767
  } catch (e) {
1631
2768
  console.error(`Error loading project: ${e.message}`);
1632
2769
  process.exit(1);
@@ -1639,18 +2776,24 @@ var recordCommand = new Command("record").description("Record the project to a v
1639
2776
  const imageEntities = state.entities.filter((e) => e.type === "image" && e.src);
1640
2777
  const allAssets = [...audioEntities, ...imageEntities];
1641
2778
  const missingAssets = [];
1642
- allAssets.forEach((entity) => {
1643
- if (!entity.src) return;
1644
- let assetPath = "";
1645
- if (entity.src.startsWith("/")) {
1646
- assetPath = entity.src;
2779
+ for (const entity of allAssets) {
2780
+ if (!entity.src) continue;
2781
+ if (apiClient) {
2782
+ try {
2783
+ const resolved = await apiClient.resolveAsset(entity.src, projectId);
2784
+ if (resolved.type === "unsupported") {
2785
+ missingAssets.push(entity.src);
2786
+ }
2787
+ } catch {
2788
+ missingAssets.push(entity.src);
2789
+ }
1647
2790
  } else {
1648
- assetPath = path6.resolve(process.cwd(), entity.src);
1649
- }
1650
- if (!fs5.existsSync(assetPath)) {
1651
- missingAssets.push(entity.src);
2791
+ const validity = assetRegistry2.assertUsable(entity.src, projectRecord.rootPath || process.cwd());
2792
+ if (!validity.ok) {
2793
+ missingAssets.push(entity.src);
2794
+ }
1652
2795
  }
1653
- });
2796
+ }
1654
2797
  if (missingAssets.length > 0) {
1655
2798
  console.error("\nMissing assets:");
1656
2799
  missingAssets.forEach((asset) => console.error(` \u2717 ${asset}`));
@@ -1658,7 +2801,7 @@ var recordCommand = new Command("record").description("Record the project to a v
1658
2801
  }
1659
2802
  if (dryRun) {
1660
2803
  console.log("\n=== Validation Results ===");
1661
- console.log(`\u2713 Scene: ${projectOption}`);
2804
+ console.log(`\u2713 Scene: ${sceneId}`);
1662
2805
  console.log(`\u2713 Duration: ${duration}s @${state.meta.width}\xD7${state.meta.height}`);
1663
2806
  const counts = {};
1664
2807
  state.entities.forEach((e) => {
@@ -1671,12 +2814,21 @@ var recordCommand = new Command("record").description("Record the project to a v
1671
2814
  Assets(${allAssets.length}):`);
1672
2815
  allAssets.forEach((entity) => {
1673
2816
  if (!entity.src) return;
1674
- let assetPath = path6.resolve(process.cwd(), "assets", entity.src);
1675
- if (!fs5.existsSync(assetPath)) assetPath = path6.resolve(process.cwd(), entity.src);
1676
- if (fs5.existsSync(assetPath)) {
1677
- const stats = fs5.statSync(assetPath);
1678
- const sizeStr = formatBytes(stats.size);
1679
- console.log(` \u2713 ${entity.src} (${sizeStr})`);
2817
+ const src = entity.src;
2818
+ if (apiClient) {
2819
+ const looksRemote = src.startsWith("http://") || src.startsWith("https://") || src.startsWith("data:");
2820
+ console.log(` \u2713 ${src} (${looksRemote ? "remote" : "resolved via API"})`);
2821
+ } else {
2822
+ const resolved = assetRegistry2.resolve(src, projectRecord.rootPath || process.cwd());
2823
+ if (resolved.type === "local" && fs8.existsSync(resolved.localPath)) {
2824
+ const stats = fs8.statSync(resolved.localPath);
2825
+ const sizeStr = formatBytes(stats.size);
2826
+ console.log(` \u2713 ${src} (${sizeStr})`);
2827
+ } else if (resolved.type === "remote") {
2828
+ console.log(` \u2713 ${src} (remote)`);
2829
+ } else {
2830
+ console.log(` \u2713 ${src}`);
2831
+ }
1680
2832
  }
1681
2833
  });
1682
2834
  }
@@ -1702,17 +2854,18 @@ Warnings:`);
1702
2854
  Estimate: ${totalFrames} frames, ~${estimatedMinutes}m ${remainingSeconds}s @${estimatedFps} fps`);
1703
2855
  console.log(`
1704
2856
  Ready to record. Run without --dry-run to start rendering.`);
2857
+ writeFlowState({ projectId, sceneId, apiUrl });
1705
2858
  process.exit(0);
1706
2859
  }
1707
2860
  console.log("Running runtime preflight checks...");
1708
2861
  ensureFfmpegAvailableOrExit();
1709
2862
  await ensurePlaywrightReadyOrExit();
1710
2863
  console.log("Starting temporary server...");
1711
- let staticRoot = path6.resolve(__dirname3, "../../dist/ui");
1712
- if (!fs5.existsSync(staticRoot)) {
1713
- staticRoot = path6.resolve(__dirname3, "../ui");
2864
+ let staticRoot = path8.resolve(__dirname3, "../../dist/ui");
2865
+ if (!fs8.existsSync(staticRoot)) {
2866
+ staticRoot = path8.resolve(__dirname3, "../ui");
1714
2867
  }
1715
- if (!fs5.existsSync(staticRoot)) {
2868
+ if (!fs8.existsSync(staticRoot)) {
1716
2869
  console.error(`Error: UI assets not found at ${staticRoot}. Please run 'bun run build' first.`);
1717
2870
  process.exit(1);
1718
2871
  }
@@ -1761,27 +2914,31 @@ Ready to record. Run without --dry-run to start rendering.`);
1761
2914
  const widthNum = parseInt(width);
1762
2915
  const heightNum = parseInt(height);
1763
2916
  console.log("Starting recording...");
1764
- console.log(`Project: ${projectOption}`);
2917
+ console.log(`Project: ${projectId}`);
2918
+ console.log(`Scene: ${sceneId}`);
1765
2919
  console.log(`Duration: ${duration}s, Frames: ${totalFrames}`);
1766
2920
  const validAudio = audioEntities.filter((e) => {
1767
2921
  if (!e.src) return false;
1768
- let assetPath = "";
1769
- if (e.src.startsWith("/")) {
1770
- if (e.src.startsWith("/api/fs/assets/")) {
1771
- assetPath = path6.resolve(process.cwd(), "assets", e.src.replace("/api/fs/assets/", ""));
1772
- } else {
1773
- assetPath = e.src;
1774
- }
1775
- } else {
1776
- assetPath = path6.resolve(process.cwd(), e.src);
2922
+ const src = e.src;
2923
+ if (src.startsWith("http://") || src.startsWith("https://") || src.startsWith("data:")) {
2924
+ e.src = src;
2925
+ return true;
1777
2926
  }
1778
- if (fs5.existsSync(assetPath)) {
1779
- e.src = assetPath;
2927
+ if (apiClient) {
2928
+ e.src = apiClient.assetContentUrl(src, projectId);
2929
+ return true;
2930
+ }
2931
+ const resolved = assetRegistry2.resolve(src, projectRecord.rootPath || process.cwd());
2932
+ if (resolved.type === "local" && fs8.existsSync(resolved.localPath)) {
2933
+ e.src = resolved.localPath;
1780
2934
  return true;
1781
- } else {
1782
- console.warn(`Asset not found: ${assetPath} (orig: ${e.src})`);
1783
- return false;
1784
2935
  }
2936
+ if (resolved.type === "remote") {
2937
+ e.src = resolved.remoteUrl;
2938
+ return true;
2939
+ }
2940
+ console.warn(`Asset not found or unsupported: ${e.src}`);
2941
+ return false;
1785
2942
  });
1786
2943
  const ffmpegArgs = [
1787
2944
  "-y",
@@ -1885,8 +3042,7 @@ Ready to record. Run without --dry-run to start rendering.`);
1885
3042
  try {
1886
3043
  for (let i = 0; i < totalFrames; i++) {
1887
3044
  const time = i / fpsNum;
1888
- const projectRelativePath = path6.relative(process.cwd(), projectPath);
1889
- const targetUrl = `${url}?mode=render&time=${time}&project=${encodeURIComponent(projectRelativePath)}`;
3045
+ const targetUrl = `${url}?mode=render&time=${time}&projectId=${encodeURIComponent(projectId)}&sceneId=${encodeURIComponent(sceneId)}`;
1890
3046
  await page.goto(targetUrl, { waitUntil: "load" });
1891
3047
  await page.waitForSelector('canvas[data-ready="true"]', { timeout: 3e4 });
1892
3048
  const buffer = await page.locator("canvas").screenshot({ type: "png" });
@@ -1919,16 +3075,17 @@ Ready to record. Run without --dry-run to start rendering.`);
1919
3075
  console.log("Stopping temporary server...");
1920
3076
  server.stop();
1921
3077
  }
3078
+ writeFlowState({ projectId, sceneId, apiUrl });
1922
3079
  });
1923
3080
 
1924
3081
  // src/cli/commands/inspect.ts
1925
3082
  import { Command as Command2 } from "commander";
1926
- import fs6 from "fs";
1927
- import path7 from "path";
3083
+ import fs9 from "fs";
3084
+ import path9 from "path";
1928
3085
  var inspectCommand = new Command2("inspect");
1929
3086
  inspectCommand.description("Inspect an asset file to get metadata (duration, format, etc.)").argument("<file>", "Path to the asset file").option("--json", "Output in JSON format").action(async (file, options) => {
1930
- const filePath = path7.resolve(process.cwd(), file);
1931
- if (!fs6.existsSync(filePath)) {
3087
+ const filePath = path9.resolve(process.cwd(), file);
3088
+ if (!fs9.existsSync(filePath)) {
1932
3089
  console.error(`File not found: ${filePath}`);
1933
3090
  process.exit(1);
1934
3091
  }
@@ -1957,23 +3114,33 @@ inspectCommand.description("Inspect an asset file to get metadata (duration, for
1957
3114
 
1958
3115
  // src/cli/commands/validate.ts
1959
3116
  import { Command as Command3 } from "commander";
1960
- import fs7 from "fs";
1961
- import path8 from "path";
3117
+ import fs10 from "fs";
1962
3118
  var validateCommand = new Command3("validate");
1963
3119
  function resolveRuntimeFit(fit, imageRatio, canvasRatio) {
1964
3120
  if (fit === "cover" || fit === "contain") return fit;
1965
3121
  const ratioDelta = Math.abs(imageRatio / canvasRatio - 1);
1966
3122
  return ratioDelta > 0.2 ? "contain" : "cover";
1967
3123
  }
1968
- validateCommand.description("Validate a scene file").argument("<file>", "Path to the scene file").action(async (file) => {
1969
- const filePath = path8.resolve(process.cwd(), file);
1970
- if (!fs7.existsSync(filePath)) {
1971
- console.error(`File not found: ${filePath}`);
1972
- process.exit(1);
1973
- }
3124
+ validateCommand.description("Validate a scene by project and scene ID").option("--project-id <id>", "Project ID").option("--scene-id <id>", "Scene ID").option("--api-url <url>", "API base URL (e.g. http://localhost:3331)").action(async (options) => {
1974
3125
  try {
1975
- const rawScene = JSON.parse(fs7.readFileSync(filePath, "utf-8"));
1976
- const data = await SceneResolver.resolve(rawScene, process.cwd());
3126
+ const projectId = resolveProjectId(options.projectId);
3127
+ const sceneId = resolveSceneId(options.sceneId);
3128
+ const apiUrl = resolveApiUrl(options.apiUrl);
3129
+ const apiClient = apiUrl ? new SceneApiClient(apiUrl) : null;
3130
+ const bundle = createSceneStoreBundle("file");
3131
+ const project = apiClient ? await apiClient.getProject(projectId) : await bundle.projects.getProject(projectId);
3132
+ if (!project) {
3133
+ console.error(JSON.stringify({ valid: false, errors: [`Project not found: ${projectId}`] }, null, 2));
3134
+ process.exit(1);
3135
+ }
3136
+ const sceneRecord = apiClient ? await apiClient.getScene(projectId, sceneId) : await bundle.scenes.getScene(projectId, sceneId);
3137
+ if (!sceneRecord) {
3138
+ console.error(JSON.stringify({ valid: false, errors: [`Scene not found: ${sceneId}`] }, null, 2));
3139
+ process.exit(1);
3140
+ }
3141
+ const registry = createDefaultAssetResolverRegistry();
3142
+ const rawScene = { meta: sceneRecord.meta, entities: sceneRecord.entities };
3143
+ const data = await SceneResolver.resolve(rawScene, project.rootPath || process.cwd());
1977
3144
  const errors = [];
1978
3145
  const warnings = [];
1979
3146
  if (!data.meta) {
@@ -2009,36 +3176,32 @@ validateCommand.description("Validate a scene file").argument("<file>", "Path to
2009
3176
  if (typeof entity.src !== "string") {
2010
3177
  errors.push(`${prefix} "src" must be a string`);
2011
3178
  } else {
2012
- let fullPath = "";
2013
- if (entity.src.startsWith("/api/fs/assets/")) {
2014
- const stripped = entity.src.replace("/api/fs/assets/", "");
2015
- fullPath = path8.resolve(process.cwd(), stripped);
2016
- } else if (entity.src.startsWith("/")) {
2017
- fullPath = entity.src;
2018
- } else {
2019
- fullPath = path8.resolve(process.cwd(), entity.src);
2020
- }
2021
- if (!fs7.existsSync(fullPath)) {
2022
- errors.push(`${prefix} asset not found at ${fullPath} (derived from src: "${entity.src}")`);
2023
- } else {
2024
- try {
2025
- const metadata = await FFprobeService.getMetadata(fullPath);
2026
- if (metadata.width && metadata.height && data.meta?.width && data.meta?.height) {
2027
- const imageRatio = metadata.width / metadata.height;
2028
- const canvasRatio = data.meta.width / data.meta.height;
2029
- const fit = resolveRuntimeFit(entity.fit, imageRatio, canvasRatio);
2030
- if (fit === "cover") {
2031
- const visibleFraction = Math.min(canvasRatio / imageRatio, imageRatio / canvasRatio);
2032
- const cropPct = (1 - visibleFraction) * 100;
2033
- if (cropPct > 15) {
2034
- warnings.push(
2035
- `${prefix} may crop ~${cropPct.toFixed(1)}% of image area at render time (image ${metadata.width}x${metadata.height}, canvas ${data.meta.width}x${data.meta.height}). Consider "fit": "contain" or generate matching aspect ratio.`
2036
- );
3179
+ const resolved = registry.resolve(entity.src, project.rootPath || process.cwd());
3180
+ if (resolved.type === "unsupported") {
3181
+ errors.push(`${prefix} unsupported asset URI: ${entity.src} (${resolved.reason})`);
3182
+ } else if (resolved.type === "local") {
3183
+ if (!fs10.existsSync(resolved.localPath)) {
3184
+ errors.push(`${prefix} asset not found at ${resolved.localPath} (src: "${entity.src}")`);
3185
+ } else {
3186
+ try {
3187
+ const metadata = await FFprobeService.getMetadata(resolved.localPath);
3188
+ if (metadata.width && metadata.height && data.meta?.width && data.meta?.height) {
3189
+ const imageRatio = metadata.width / metadata.height;
3190
+ const canvasRatio = data.meta.width / data.meta.height;
3191
+ const fit = resolveRuntimeFit(entity.fit, imageRatio, canvasRatio);
3192
+ if (fit === "cover") {
3193
+ const visibleFraction = Math.min(canvasRatio / imageRatio, imageRatio / canvasRatio);
3194
+ const cropPct = (1 - visibleFraction) * 100;
3195
+ if (cropPct > 15) {
3196
+ warnings.push(
3197
+ `${prefix} may crop ~${cropPct.toFixed(1)}% of image area at render time (image ${metadata.width}x${metadata.height}, canvas ${data.meta.width}x${data.meta.height}). Consider "fit": "contain" or generate matching aspect ratio.`
3198
+ );
3199
+ }
2037
3200
  }
2038
3201
  }
3202
+ } catch (metaError) {
3203
+ warnings.push(`${prefix} could not inspect image dimensions: ${metaError.message}`);
2039
3204
  }
2040
- } catch (metaError) {
2041
- warnings.push(`${prefix} could not inspect image dimensions: ${metaError.message}`);
2042
3205
  }
2043
3206
  }
2044
3207
  }
@@ -2049,17 +3212,11 @@ validateCommand.description("Validate a scene file").argument("<file>", "Path to
2049
3212
  } else if (typeof entity.src !== "string") {
2050
3213
  errors.push(`${prefix} "src" must be a string`);
2051
3214
  } else {
2052
- let fullPath = "";
2053
- if (entity.src.startsWith("/api/fs/assets/")) {
2054
- const stripped = entity.src.replace("/api/fs/assets/", "");
2055
- fullPath = path8.resolve(process.cwd(), stripped);
2056
- } else if (entity.src.startsWith("/")) {
2057
- fullPath = entity.src;
2058
- } else {
2059
- fullPath = path8.resolve(process.cwd(), entity.src);
2060
- }
2061
- if (!fs7.existsSync(fullPath)) {
2062
- errors.push(`${prefix} asset not found at ${fullPath} (derived from src: "${entity.src}")`);
3215
+ const resolved = registry.resolve(entity.src, project.rootPath || process.cwd());
3216
+ if (resolved.type === "unsupported") {
3217
+ errors.push(`${prefix} unsupported asset URI: ${entity.src} (${resolved.reason})`);
3218
+ } else if (resolved.type === "local" && !fs10.existsSync(resolved.localPath)) {
3219
+ errors.push(`${prefix} asset not found at ${resolved.localPath} (src: "${entity.src}")`);
2063
3220
  }
2064
3221
  }
2065
3222
  if (entity.sourceStart !== void 0 && typeof entity.sourceStart !== "number") {
@@ -2079,9 +3236,9 @@ validateCommand.description("Validate a scene file").argument("<file>", "Path to
2079
3236
  if (errors.length > 0) {
2080
3237
  console.log(JSON.stringify({ valid: false, errors, warnings }, null, 2));
2081
3238
  process.exit(1);
2082
- } else {
2083
- console.log(JSON.stringify({ valid: true, warnings }, null, 2));
2084
3239
  }
3240
+ writeFlowState({ projectId, sceneId, apiUrl });
3241
+ console.log(JSON.stringify({ valid: true, warnings }, null, 2));
2085
3242
  } catch (e) {
2086
3243
  console.error(JSON.stringify({ valid: false, errors: ["Error validating scene: " + e.message] }, null, 2));
2087
3244
  process.exit(1);
@@ -2090,43 +3247,87 @@ validateCommand.description("Validate a scene file").argument("<file>", "Path to
2090
3247
 
2091
3248
  // src/cli/commands/set-scene.ts
2092
3249
  import { Command as Command4 } from "commander";
2093
- import fs8 from "fs";
2094
- import path9 from "path";
3250
+ import fs11 from "fs";
3251
+ import path10 from "path";
2095
3252
  var setSceneCommand = new Command4("set-scene");
2096
- setSceneCommand.description("Update or create a scene file with new content").argument("<file>", "Path to the scene file to update").option("-c, --content <json>", "JSON content string").option("-i, --input <file>", "Input JSON file to copy from").action(async (file, options) => {
2097
- const targetPath = path9.resolve(process.cwd(), file);
3253
+ setSceneCommand.description("Update an existing scene by project/scene ID").option("--project-id <id>", "Project ID").option("--scene-id <id>", "Scene ID").option("-c, --content <json>", "JSON content string").option("-i, --input <file>", "Input JSON file to copy from").option("--version <number>", "Expected scene version").option("--api-url <url>", "API base URL (e.g. http://localhost:3331)").action(async (options) => {
2098
3254
  let contentStr = "";
2099
3255
  if (options.content) {
2100
3256
  contentStr = options.content;
2101
3257
  } else if (options.input) {
2102
- const inputPath = path9.resolve(process.cwd(), options.input);
2103
- if (!fs8.existsSync(inputPath)) {
3258
+ const inputPath = path10.resolve(process.cwd(), options.input);
3259
+ if (!fs11.existsSync(inputPath)) {
2104
3260
  console.error(`Input file not found: ${inputPath}`);
2105
3261
  process.exit(1);
2106
3262
  }
2107
- contentStr = fs8.readFileSync(inputPath, "utf-8");
3263
+ contentStr = fs11.readFileSync(inputPath, "utf-8");
2108
3264
  } else {
2109
3265
  console.error("Error: Please provide --content <json> or --input <file>");
2110
3266
  process.exit(1);
2111
3267
  }
2112
3268
  try {
2113
3269
  const data = JSON.parse(contentStr);
2114
- if (!data.meta || !data.entities) {
3270
+ if (!data.meta || !Array.isArray(data.entities)) {
2115
3271
  console.error('Error: Invalid scene format. Missing "meta" or "entities".');
2116
3272
  process.exit(1);
2117
3273
  }
2118
- fs8.writeFileSync(targetPath, JSON.stringify(data, null, 2));
2119
- console.log(`Scene updated at ${targetPath}`);
3274
+ const projectId = resolveProjectId(options.projectId);
3275
+ const sceneId = resolveSceneId(options.sceneId);
3276
+ const apiUrl = resolveApiUrl(options.apiUrl);
3277
+ const scene = apiUrl ? await new SceneApiClient(apiUrl).updateScene(
3278
+ projectId,
3279
+ sceneId,
3280
+ {
3281
+ meta: data.meta,
3282
+ entities: data.entities,
3283
+ version: options.version !== void 0 ? Number(options.version) : void 0
3284
+ }
3285
+ ) : await createSceneStoreBundle("file").scenes.updateScene(
3286
+ projectId,
3287
+ sceneId,
3288
+ {
3289
+ meta: data.meta,
3290
+ entities: data.entities
3291
+ },
3292
+ options.version !== void 0 ? Number(options.version) : void 0
3293
+ );
3294
+ writeFlowState({ projectId, sceneId, apiUrl });
3295
+ console.log(`Scene updated: project=${scene.projectId} scene=${scene.id} version=${scene.version}`);
2120
3296
  } catch (e) {
2121
- console.error("Error: Content is not valid JSON.", e.message);
3297
+ console.error("Error: Content is not valid JSON or update failed.", e.message);
2122
3298
  process.exit(1);
2123
3299
  }
2124
3300
  });
2125
3301
 
2126
3302
  // src/cli/commands/imagine.ts
2127
3303
  import { Command as Command5 } from "commander";
2128
- import fs9 from "fs";
2129
- import path10 from "path";
3304
+ import fs13 from "fs";
3305
+ import path12 from "path";
3306
+
3307
+ // src/cli/utils/path.ts
3308
+ import path11 from "path";
3309
+ import fs12 from "fs";
3310
+ function resolveAssetOutputPath(outputOption, cwd = process.cwd()) {
3311
+ if (path11.isAbsolute(outputOption)) {
3312
+ return outputOption;
3313
+ }
3314
+ const assetsDir = path11.resolve(cwd, "assets");
3315
+ if (!fs12.existsSync(assetsDir)) {
3316
+ fs12.mkdirSync(assetsDir, { recursive: true });
3317
+ }
3318
+ const normalizedInput = path11.normalize(outputOption);
3319
+ let cleanRelativePath = normalizedInput;
3320
+ const segments = normalizedInput.split(path11.sep);
3321
+ if (segments[0] === "assets") {
3322
+ cleanRelativePath = segments.slice(1).join(path11.sep);
3323
+ }
3324
+ if (!cleanRelativePath) {
3325
+ cleanRelativePath = `unnamed-asset-${Date.now()}`;
3326
+ }
3327
+ return path11.join(assetsDir, cleanRelativePath);
3328
+ }
3329
+
3330
+ // src/cli/commands/imagine.ts
2130
3331
  var imagineCommand = new Command5("imagine");
2131
3332
  imagineCommand.description("Generate images using Google Gemini AI (Imagen)").argument("<prompt-or-file>", "Text prompt or path to a file containing the prompt").option("-o, --output <filename>", "Output filename (relative to assets/ or absolute path)", "generated.png").option("-k, --api-key <key>", "Gemini API key (or set GEMINI_API_KEY env var)").option("-n, --number <count>", "Number of images to generate", "1").option("--aspect-ratio <ratio>", "Aspect ratio (e.g., 9:16, 1:1, 16:9, or auto)", "auto").option("--image-size <size>", "Image size hint: 1K, 2K, or 4K", "2K").option("--project <path>", "Scene file path to infer aspect ratio from (reads meta.width/meta.height)", "scene_1.json").option("--json", "Emit machine-readable JSON output").addHelpText("after", `
2132
3333
 
@@ -2176,9 +3377,9 @@ Note:
2176
3377
  `).action(async (promptOrFile, options) => {
2177
3378
  try {
2178
3379
  let prompt;
2179
- if (fs9.existsSync(promptOrFile)) {
3380
+ if (fs13.existsSync(promptOrFile)) {
2180
3381
  console.log(`Reading prompt from file: ${promptOrFile}`);
2181
- prompt = fs9.readFileSync(promptOrFile, "utf-8").trim();
3382
+ prompt = fs13.readFileSync(promptOrFile, "utf-8").trim();
2182
3383
  } else {
2183
3384
  prompt = promptOrFile;
2184
3385
  }
@@ -2219,27 +3420,17 @@ Note:
2219
3420
  aspectRatio,
2220
3421
  imageSize
2221
3422
  });
2222
- let outputPath;
2223
- if (path10.isAbsolute(options.output)) {
2224
- outputPath = options.output;
2225
- } else {
2226
- const assetsDir = path10.resolve(process.cwd(), "assets");
2227
- if (!fs9.existsSync(assetsDir)) {
2228
- console.log(`\u{1F4C1} Creating assets directory: ${assetsDir}`);
2229
- fs9.mkdirSync(assetsDir, { recursive: true });
2230
- }
2231
- outputPath = path10.join(assetsDir, options.output);
2232
- }
2233
- const outputDir = path10.dirname(outputPath);
2234
- const outputExt = path10.extname(outputPath) || ".png";
2235
- const outputBase = path10.basename(outputPath, outputExt);
2236
- if (!fs9.existsSync(outputDir)) {
2237
- fs9.mkdirSync(outputDir, { recursive: true });
3423
+ const outputPath = resolveAssetOutputPath(options.output);
3424
+ const outputDir = path12.dirname(outputPath);
3425
+ const outputExt = path12.extname(outputPath) || ".png";
3426
+ const outputBase = path12.basename(outputPath, outputExt);
3427
+ if (!fs13.existsSync(outputDir)) {
3428
+ fs13.mkdirSync(outputDir, { recursive: true });
2238
3429
  }
2239
3430
  const savedFiles = [];
2240
3431
  for (let i = 0; i < images.length; i++) {
2241
- const filename = images.length > 1 ? path10.join(outputDir, `${outputBase}_${i + 1}${outputExt}`) : outputPath;
2242
- fs9.writeFileSync(filename, images[i]);
3432
+ const filename = images.length > 1 ? path12.join(outputDir, `${outputBase}_${i + 1}${outputExt}`) : outputPath;
3433
+ fs13.writeFileSync(filename, images[i]);
2243
3434
  if (!options.json) console.log(`\u2705 Image saved: ${filename}`);
2244
3435
  savedFiles.push(filename);
2245
3436
  }
@@ -2263,6 +3454,9 @@ Note:
2263
3454
  if (error.cause) {
2264
3455
  console.error("Details:", error.cause);
2265
3456
  }
3457
+ if (error.message.includes("Responsible AI practices")) {
3458
+ console.error("\n\u26A0\uFE0F Google GenAI Filter violation. Please try standardizing the prompt to be more descriptive and avoid vague words.");
3459
+ }
2266
3460
  process.exit(1);
2267
3461
  }
2268
3462
  });
@@ -2347,10 +3541,10 @@ function resolveAspectRatio(input, projectFile) {
2347
3541
  return normalizeAspectRatio(input);
2348
3542
  }
2349
3543
  function inferAspectRatioFromProject(projectFile) {
2350
- const filePath = path10.resolve(process.cwd(), projectFile);
2351
- if (!fs9.existsSync(filePath)) return null;
3544
+ const filePath = path12.resolve(process.cwd(), projectFile);
3545
+ if (!fs13.existsSync(filePath)) return null;
2352
3546
  try {
2353
- const scene = JSON.parse(fs9.readFileSync(filePath, "utf-8"));
3547
+ const scene = JSON.parse(fs13.readFileSync(filePath, "utf-8"));
2354
3548
  const width = scene?.meta?.width;
2355
3549
  const height = scene?.meta?.height;
2356
3550
  if (typeof width !== "number" || typeof height !== "number" || width <= 0 || height <= 0) {
@@ -2390,12 +3584,12 @@ function gcd(a, b) {
2390
3584
 
2391
3585
  // src/cli/commands/audio.ts
2392
3586
  import { Command as Command6 } from "commander";
2393
- import fs11 from "fs";
2394
- import path12 from "path";
3587
+ import fs16 from "fs";
3588
+ import path15 from "path";
2395
3589
 
2396
3590
  // src/cli/services/whisper.ts
2397
- import fs10 from "fs";
2398
- import path11 from "path";
3591
+ import fs14 from "fs";
3592
+ import path13 from "path";
2399
3593
  import os from "os";
2400
3594
  import { spawn as spawn5 } from "child_process";
2401
3595
  import https from "https";
@@ -2404,37 +3598,37 @@ var WhisperService = class {
2404
3598
  return os.homedir();
2405
3599
  }
2406
3600
  static getBaseDir() {
2407
- return path11.join(this.getHomeDir(), ".feedeas");
3601
+ return path13.join(this.getHomeDir(), ".feedeas");
2408
3602
  }
2409
3603
  static getBinDir() {
2410
- return path11.join(this.getBaseDir(), "bin");
3604
+ return path13.join(this.getBaseDir(), "bin");
2411
3605
  }
2412
3606
  static getModelsDir() {
2413
- return path11.join(this.getBaseDir(), "models");
3607
+ return path13.join(this.getBaseDir(), "models");
2414
3608
  }
2415
3609
  static getExecutablePath() {
2416
- const repoDir = path11.join(this.getBaseDir(), "whisper.cpp-repo");
3610
+ const repoDir = path13.join(this.getBaseDir(), "whisper.cpp-repo");
2417
3611
  const possiblePaths = [
2418
- path11.join(repoDir, "build", "bin", "whisper-cli"),
2419
- path11.join(repoDir, "build", "bin", "main"),
2420
- path11.join(repoDir, "main"),
2421
- path11.join(this.getBinDir(), "whisper-main")
3612
+ path13.join(repoDir, "build", "bin", "whisper-cli"),
3613
+ path13.join(repoDir, "build", "bin", "main"),
3614
+ path13.join(repoDir, "main"),
3615
+ path13.join(this.getBinDir(), "whisper-main")
2422
3616
  // fallback to copied/downloaded
2423
3617
  ];
2424
3618
  for (const p of possiblePaths) {
2425
- if (fs10.existsSync(p)) return p;
3619
+ if (fs14.existsSync(p)) return p;
2426
3620
  }
2427
- return path11.join(this.getBinDir(), "whisper-main");
3621
+ return path13.join(this.getBinDir(), "whisper-main");
2428
3622
  }
2429
3623
  static getModelPath(modelName = "base.en") {
2430
- return path11.join(this.getModelsDir(), `ggml-${modelName}.bin`);
3624
+ return path13.join(this.getModelsDir(), `ggml-${modelName}.bin`);
2431
3625
  }
2432
3626
  /**
2433
3627
  * Download a file from a URL to a destination path
2434
3628
  */
2435
3629
  static async downloadFile(url, destPath) {
2436
3630
  return new Promise((resolve, reject) => {
2437
- const file = fs10.createWriteStream(destPath);
3631
+ const file = fs14.createWriteStream(destPath);
2438
3632
  https.get(url, (response) => {
2439
3633
  if (response.statusCode === 302 || response.statusCode === 301) {
2440
3634
  this.downloadFile(response.headers.location, destPath).then(resolve).catch(reject);
@@ -2450,7 +3644,7 @@ var WhisperService = class {
2450
3644
  resolve();
2451
3645
  });
2452
3646
  }).on("error", (err) => {
2453
- fs10.unlink(destPath, () => {
3647
+ fs14.unlink(destPath, () => {
2454
3648
  });
2455
3649
  reject(err);
2456
3650
  });
@@ -2462,15 +3656,15 @@ var WhisperService = class {
2462
3656
  static async ensureReady() {
2463
3657
  const binDir = this.getBinDir();
2464
3658
  const modelsDir = this.getModelsDir();
2465
- if (!fs10.existsSync(binDir)) fs10.mkdirSync(binDir, { recursive: true });
2466
- if (!fs10.existsSync(modelsDir)) fs10.mkdirSync(modelsDir, { recursive: true });
3659
+ if (!fs14.existsSync(binDir)) fs14.mkdirSync(binDir, { recursive: true });
3660
+ if (!fs14.existsSync(modelsDir)) fs14.mkdirSync(modelsDir, { recursive: true });
2467
3661
  const execPath = this.getExecutablePath();
2468
3662
  const modelPath = this.getModelPath();
2469
- if (!fs10.existsSync(execPath)) {
3663
+ if (!fs14.existsSync(execPath)) {
2470
3664
  console.log("\u2B07\uFE0F Whisper binary not found. Attempting to build...");
2471
3665
  try {
2472
- const repoDir = path11.join(this.getBaseDir(), "whisper.cpp-repo");
2473
- if (!fs10.existsSync(repoDir)) {
3666
+ const repoDir = path13.join(this.getBaseDir(), "whisper.cpp-repo");
3667
+ if (!fs14.existsSync(repoDir)) {
2474
3668
  console.log("\u{1F4E6} Cloning whisper.cpp...");
2475
3669
  await runCommand("git", ["clone", "https://github.com/ggerganov/whisper.cpp.git", repoDir]);
2476
3670
  } else {
@@ -2478,7 +3672,7 @@ var WhisperService = class {
2478
3672
  console.log("\u{1F528} Building whisper.cpp (this may take a minute)...");
2479
3673
  await runCommand("make", [], repoDir);
2480
3674
  const newPath = this.getExecutablePath();
2481
- if (fs10.existsSync(newPath)) {
3675
+ if (fs14.existsSync(newPath)) {
2482
3676
  console.log(`\u2705 Whisper binary built at: ${newPath}`);
2483
3677
  } else {
2484
3678
  throw new Error("Build command finished but binary not found in expected paths.");
@@ -2489,7 +3683,7 @@ var WhisperService = class {
2489
3683
  Please install manually: 'brew install whisper-cpp'`);
2490
3684
  }
2491
3685
  }
2492
- if (!fs10.existsSync(modelPath)) {
3686
+ if (!fs14.existsSync(modelPath)) {
2493
3687
  console.log("\u2B07\uFE0F Downloading Whisper model (base.en)...");
2494
3688
  const modelUrl = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin";
2495
3689
  await this.downloadFile(modelUrl, modelPath);
@@ -2502,12 +3696,12 @@ Please install manually: 'brew install whisper-cpp'`);
2502
3696
  static async transcribe(audioPath) {
2503
3697
  const execPath = this.getExecutablePath();
2504
3698
  const modelPath = this.getModelPath();
2505
- const ext = path11.extname(audioPath).toLowerCase();
3699
+ const ext = path13.extname(audioPath).toLowerCase();
2506
3700
  let inputToWhisper = audioPath;
2507
3701
  let isTempFile = false;
2508
3702
  if (ext !== ".wav") {
2509
3703
  console.log("\u{1F504} Converting audio to 16kHz WAV for Whisper...");
2510
- const tempWav = path11.join(path11.dirname(audioPath), `temp_${Date.now()}.wav`);
3704
+ const tempWav = path13.join(path13.dirname(audioPath), `temp_${Date.now()}.wav`);
2511
3705
  await runCommand("ffmpeg", [
2512
3706
  "-i",
2513
3707
  audioPath,
@@ -2524,7 +3718,7 @@ Please install manually: 'brew install whisper-cpp'`);
2524
3718
  isTempFile = true;
2525
3719
  }
2526
3720
  try {
2527
- const outputBase = path11.join(path11.dirname(audioPath), path11.basename(audioPath, path11.extname(audioPath)));
3721
+ const outputBase = path13.join(path13.dirname(audioPath), path13.basename(audioPath, path13.extname(audioPath)));
2528
3722
  const baseArgs = [
2529
3723
  "-m",
2530
3724
  modelPath,
@@ -2561,8 +3755,8 @@ Please install manually: 'brew install whisper-cpp'`);
2561
3755
  words
2562
3756
  };
2563
3757
  } finally {
2564
- if (isTempFile && fs10.existsSync(inputToWhisper)) {
2565
- fs10.unlinkSync(inputToWhisper);
3758
+ if (isTempFile && fs14.existsSync(inputToWhisper)) {
3759
+ fs14.unlinkSync(inputToWhisper);
2566
3760
  }
2567
3761
  }
2568
3762
  }
@@ -2588,12 +3782,12 @@ function runWhisper(execPath, args) {
2588
3782
  const outputFileIndex = args.indexOf("-of");
2589
3783
  const outputBase = outputFileIndex >= 0 ? args[outputFileIndex + 1] : void 0;
2590
3784
  const jsonPath = outputBase ? `${outputBase}.json` : "";
2591
- if (!jsonPath || !fs10.existsSync(jsonPath)) {
3785
+ if (!jsonPath || !fs14.existsSync(jsonPath)) {
2592
3786
  reject(new Error("Whisper output JSON not found"));
2593
3787
  return;
2594
3788
  }
2595
3789
  try {
2596
- const data = JSON.parse(fs10.readFileSync(jsonPath, "utf-8"));
3790
+ const data = JSON.parse(fs14.readFileSync(jsonPath, "utf-8"));
2597
3791
  resolve(data);
2598
3792
  } catch (err) {
2599
3793
  reject(err);
@@ -2641,14 +3835,133 @@ function roundTo3(value) {
2641
3835
  return Math.round(value * 1e3) / 1e3;
2642
3836
  }
2643
3837
 
3838
+ // src/cli/services/audio-convert.ts
3839
+ import fs15 from "fs";
3840
+ import path14 from "path";
3841
+ import { spawn as spawn6 } from "node:child_process";
3842
+ import { pathToFileURL } from "node:url";
3843
+ import { FFmpeg } from "@ffmpeg/ffmpeg";
3844
+ function isNodeRuntime() {
3845
+ return typeof process !== "undefined" && !!process.versions?.node;
3846
+ }
3847
+ function buildCodecArgs(options) {
3848
+ if (options.format === "wav") {
3849
+ return ["-c:a", "pcm_s16le"];
3850
+ }
3851
+ if (typeof options.mp3Quality === "number") {
3852
+ return ["-c:a", "libmp3lame", "-q:a", String(options.mp3Quality)];
3853
+ }
3854
+ return ["-c:a", "libmp3lame", "-b:a", options.mp3Bitrate || "128k"];
3855
+ }
3856
+ async function convertWithNodeFfmpeg(options) {
3857
+ const { pcmPath, outputPath, sampleRate, channels, durationSec } = options;
3858
+ const codecArgs = buildCodecArgs(options);
3859
+ await new Promise((resolve, reject) => {
3860
+ const ffmpeg = spawn6("ffmpeg", [
3861
+ "-f",
3862
+ "s16le",
3863
+ "-ar",
3864
+ String(sampleRate),
3865
+ "-ac",
3866
+ String(channels),
3867
+ "-i",
3868
+ pcmPath,
3869
+ ...typeof durationSec === "number" ? ["-t", String(durationSec)] : [],
3870
+ ...codecArgs,
3871
+ "-y",
3872
+ outputPath
3873
+ ], { stdio: "inherit" });
3874
+ ffmpeg.on("close", (code) => {
3875
+ if (code === 0) resolve();
3876
+ else reject(new Error(`FFmpeg exited with code ${code}`));
3877
+ });
3878
+ ffmpeg.on("error", (err) => reject(err));
3879
+ });
3880
+ }
3881
+ async function convertWithWasm(options) {
3882
+ const { pcmPath, outputPath, sampleRate, channels, durationSec } = options;
3883
+ const ffmpeg = new FFmpeg();
3884
+ const baseDir = typeof process !== "undefined" && typeof process.cwd === "function" ? process.cwd() : ".";
3885
+ const coreDir = path14.resolve(baseDir, "node_modules/@ffmpeg/core/dist");
3886
+ const coreURL = pathToFileURL(path14.join(coreDir, "ffmpeg-core.js")).toString();
3887
+ const wasmURL = pathToFileURL(path14.join(coreDir, "ffmpeg-core.wasm")).toString();
3888
+ const workerURL = pathToFileURL(path14.join(coreDir, "ffmpeg-core.worker.js")).toString();
3889
+ await ffmpeg.load({ coreURL, wasmURL, workerURL });
3890
+ const pcmBytes = await fs15.promises.readFile(pcmPath);
3891
+ await ffmpeg.writeFile("input.pcm", pcmBytes);
3892
+ const codecArgs = buildCodecArgs(options);
3893
+ const outputName = options.format === "wav" ? "output.wav" : "output.mp3";
3894
+ await ffmpeg.exec([
3895
+ "-f",
3896
+ "s16le",
3897
+ "-ar",
3898
+ String(sampleRate),
3899
+ "-ac",
3900
+ String(channels),
3901
+ "-i",
3902
+ "input.pcm",
3903
+ ...typeof durationSec === "number" ? ["-t", String(durationSec)] : [],
3904
+ ...codecArgs,
3905
+ outputName
3906
+ ]);
3907
+ const output = await ffmpeg.readFile(outputName);
3908
+ await fs15.promises.writeFile(outputPath, output);
3909
+ }
3910
+ async function convertPcmToAudio(options) {
3911
+ const { pcmPath } = options;
3912
+ let useWasm = !isNodeRuntime();
3913
+ try {
3914
+ try {
3915
+ if (!useWasm) {
3916
+ await convertWithNodeFfmpeg(options);
3917
+ return;
3918
+ }
3919
+ } catch (err) {
3920
+ if (err?.code !== "ENOENT") {
3921
+ throw err;
3922
+ }
3923
+ useWasm = true;
3924
+ }
3925
+ if (useWasm) {
3926
+ try {
3927
+ await convertWithWasm(options);
3928
+ return;
3929
+ } catch (err) {
3930
+ throw new Error(
3931
+ `Audio conversion failed. Install ffmpeg or ensure @ffmpeg/core assets are present for wasm conversion. (${err?.message || err})`
3932
+ );
3933
+ }
3934
+ }
3935
+ throw new Error(
3936
+ "Audio conversion failed. Install ffmpeg or ensure @ffmpeg/core assets are present for wasm conversion."
3937
+ );
3938
+ } finally {
3939
+ if (fs15.existsSync(pcmPath)) {
3940
+ try {
3941
+ await fs15.promises.unlink(pcmPath);
3942
+ } catch {
3943
+ }
3944
+ }
3945
+ }
3946
+ }
3947
+ async function convertPcmToMp3(options) {
3948
+ await convertPcmToAudio({
3949
+ ...options,
3950
+ format: "mp3",
3951
+ sampleRate: 24e3,
3952
+ channels: 1,
3953
+ mp3Bitrate: "128k"
3954
+ });
3955
+ }
3956
+
2644
3957
  // src/cli/commands/audio.ts
2645
3958
  var audioCommand = new Command6("generate:audio");
2646
3959
  audioCommand.alias("audio").description("Generate audio from text using Gemini API and extract metadata").argument("<text-or-file>", "Input text or path to text file").option("-o, --output <filename>", "Output filename (relative to assets/ or absolute path)", "speech.mp3").option("-k, --api-key <key>", "Gemini API key (or set GEMINI_API_KEY env var)").option("--voice <name>", "Voice name (optional)").option("--no-transcribe", "Skip Whisper transcription/metadata generation").action(async (textOrFile, options) => {
2647
3960
  try {
2648
3961
  let text;
2649
- if (fs11.existsSync(textOrFile)) {
3962
+ if (fs16.existsSync(textOrFile)) {
2650
3963
  console.log(`\u{1F4D6} Reading text from file: ${textOrFile}`);
2651
- text = fs11.readFileSync(textOrFile, "utf-8").trim();
3964
+ text = fs16.readFileSync(textOrFile, "utf-8").trim();
2652
3965
  } else {
2653
3966
  text = textOrFile;
2654
3967
  }
@@ -2663,41 +3976,22 @@ audioCommand.alias("audio").description("Generate audio from text using Gemini A
2663
3976
  }
2664
3977
  console.log("\u{1F5E3}\uFE0F Generating speech with Gemini...");
2665
3978
  const audioBuffer = await generateGeminiAudio(text, apiKey, options.voice);
2666
- const outputPath = resolveOutputPath(options.output);
2667
- const tempPcmPath = path12.join(path12.dirname(outputPath), `temp_${Date.now()}.pcm`);
2668
- fs11.writeFileSync(tempPcmPath, audioBuffer);
2669
- try {
2670
- console.log(`\u{1F504} Converting raw PCM to ${path12.extname(outputPath)}...`);
2671
- await new Promise((resolve, reject) => {
2672
- const ffmpeg = __require("child_process").spawn("ffmpeg", [
2673
- "-f",
2674
- "s16le",
2675
- "-ar",
2676
- "24000",
2677
- "-ac",
2678
- "1",
2679
- "-i",
2680
- tempPcmPath,
2681
- "-y",
2682
- outputPath
2683
- ], { stdio: "inherit" });
2684
- ffmpeg.on("close", (code) => {
2685
- if (code === 0) resolve();
2686
- else reject(new Error(`FFmpeg exited with code ${code}`));
2687
- });
2688
- ffmpeg.on("error", (err) => reject(err));
2689
- });
2690
- console.log(`\u2705 Audio saved: ${outputPath}`);
2691
- } finally {
2692
- if (fs11.existsSync(tempPcmPath)) fs11.unlinkSync(tempPcmPath);
2693
- }
3979
+ const outputPath = resolveAssetOutputPath(options.output);
3980
+ const tempPcmPath = path15.join(path15.dirname(outputPath), `temp_${Date.now()}.pcm`);
3981
+ fs16.writeFileSync(tempPcmPath, audioBuffer);
3982
+ console.log(`\u{1F504} Converting raw PCM to ${path15.extname(outputPath)}...`);
3983
+ await convertPcmToMp3({
3984
+ pcmPath: tempPcmPath,
3985
+ outputPath
3986
+ });
3987
+ console.log(`\u2705 Audio saved: ${outputPath}`);
2694
3988
  if (options.transcribe) {
2695
3989
  try {
2696
3990
  console.log("\u{1F50D} Aligning audio with Whisper...");
2697
3991
  await WhisperService.ensureReady();
2698
3992
  const metadata = await WhisperService.transcribe(outputPath);
2699
- const metaPath = outputPath.replace(path12.extname(outputPath), ".json");
2700
- fs11.writeFileSync(metaPath, JSON.stringify(metadata, null, 2));
3993
+ const metaPath = outputPath.replace(path15.extname(outputPath), ".json");
3994
+ fs16.writeFileSync(metaPath, JSON.stringify(metadata, null, 2));
2701
3995
  console.log(`\u2705 Metadata saved: ${metaPath}`);
2702
3996
  } catch (wErr) {
2703
3997
  console.warn("\u26A0\uFE0F Whisper alignment failed:", wErr.message);
@@ -2708,12 +4002,6 @@ audioCommand.alias("audio").description("Generate audio from text using Gemini A
2708
4002
  process.exit(1);
2709
4003
  }
2710
4004
  });
2711
- function resolveOutputPath(outputOption) {
2712
- if (path12.isAbsolute(outputOption)) return outputOption;
2713
- const assetsDir = path12.resolve(process.cwd(), "assets");
2714
- if (!fs11.existsSync(assetsDir)) fs11.mkdirSync(assetsDir, { recursive: true });
2715
- return path12.join(assetsDir, outputOption);
2716
- }
2717
4005
  async function generateGeminiAudio(text, apiKey, voiceName) {
2718
4006
  const endpoint = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-tts:generateContent`;
2719
4007
  const requestBody = {
@@ -2753,9 +4041,8 @@ async function generateGeminiAudio(text, apiKey, voiceName) {
2753
4041
 
2754
4042
  // src/cli/commands/bgm.ts
2755
4043
  import { Command as Command7 } from "commander";
2756
- import fs12 from "fs";
2757
- import path13 from "path";
2758
- import { spawn as spawn6 } from "child_process";
4044
+ import fs17 from "fs";
4045
+ import path16 from "path";
2759
4046
  var bgmCommand = new Command7("generate:bgm");
2760
4047
  bgmCommand.alias("bgm").alias("music").description("Generate background music with Gemini Lyria (Live Music API)").argument("<prompt-or-file>", "Music prompt text or path to a text file").option("-o, --output <filename>", "Output filename (relative to assets/ or absolute path)", "bgm.mp3").option("-k, --api-key <key>", "Gemini API key (or set GEMINI_API_KEY env var)").option("-d, --duration <seconds>", "Target duration in seconds (5-300)", "30").option("--seed <number>", "Optional seed for reproducible output").option("--json", "Emit machine-readable JSON output").action(async (promptOrFile, options) => {
2761
4048
  try {
@@ -2766,7 +4053,7 @@ bgmCommand.alias("bgm").alias("music").description("Generate background music wi
2766
4053
  }
2767
4054
  const durationSec = parseDuration(options.duration);
2768
4055
  const seed = parseSeed(options.seed);
2769
- const outputPath = resolveOutputPath2(options.output);
4056
+ const outputPath = resolveAssetOutputPath(options.output);
2770
4057
  const format = inferOutputFormat(outputPath);
2771
4058
  const request = {
2772
4059
  provider: "gemini",
@@ -2806,7 +4093,7 @@ bgmCommand.alias("bgm").alias("music").description("Generate background music wi
2806
4093
  }
2807
4094
  });
2808
4095
  function resolvePrompt(promptOrFile) {
2809
- const prompt = fs12.existsSync(promptOrFile) ? fs12.readFileSync(promptOrFile, "utf-8").trim() : String(promptOrFile).trim();
4096
+ const prompt = fs17.existsSync(promptOrFile) ? fs17.readFileSync(promptOrFile, "utf-8").trim() : String(promptOrFile).trim();
2810
4097
  if (!prompt) {
2811
4098
  throw new Error("Prompt cannot be empty");
2812
4099
  }
@@ -2827,14 +4114,8 @@ function parseSeed(input) {
2827
4114
  }
2828
4115
  return value;
2829
4116
  }
2830
- function resolveOutputPath2(outputOption) {
2831
- if (path13.isAbsolute(outputOption)) return outputOption;
2832
- const assetsDir = path13.resolve(process.cwd(), "assets");
2833
- if (!fs12.existsSync(assetsDir)) fs12.mkdirSync(assetsDir, { recursive: true });
2834
- return path13.join(assetsDir, outputOption);
2835
- }
2836
4117
  function inferOutputFormat(outputPath) {
2837
- const ext = path13.extname(outputPath).toLowerCase();
4118
+ const ext = path16.extname(outputPath).toLowerCase();
2838
4119
  if (ext === ".wav") return "wav";
2839
4120
  if (ext === ".mp3" || !ext) return "mp3";
2840
4121
  throw new Error("Invalid output format. Use .mp3 or .wav.");
@@ -2922,54 +4203,36 @@ async function generateGeminiMusicPcm(request) {
2922
4203
  });
2923
4204
  }
2924
4205
  async function convertPcmToOutput(pcmBuffer, outputPath, format, durationSec) {
2925
- const outputDir = path13.dirname(outputPath);
2926
- if (!fs12.existsSync(outputDir)) fs12.mkdirSync(outputDir, { recursive: true });
2927
- const tempPcmPath = path13.join(outputDir, `temp_bgm_${Date.now()}.pcm`);
2928
- fs12.writeFileSync(tempPcmPath, pcmBuffer);
2929
- const codecArgs = format === "wav" ? ["-c:a", "pcm_s16le"] : ["-c:a", "libmp3lame", "-q:a", "2"];
2930
- try {
2931
- await new Promise((resolve, reject) => {
2932
- const ffmpeg = spawn6("ffmpeg", [
2933
- "-f",
2934
- "s16le",
2935
- "-ar",
2936
- "48000",
2937
- "-ac",
2938
- "2",
2939
- "-i",
2940
- tempPcmPath,
2941
- "-t",
2942
- String(durationSec),
2943
- ...codecArgs,
2944
- "-y",
2945
- outputPath
2946
- ], { stdio: "inherit" });
2947
- ffmpeg.on("close", (code) => {
2948
- if (code === 0) resolve();
2949
- else reject(new Error(`FFmpeg exited with code ${code}`));
2950
- });
2951
- ffmpeg.on("error", reject);
2952
- });
2953
- } finally {
2954
- if (fs12.existsSync(tempPcmPath)) fs12.unlinkSync(tempPcmPath);
2955
- }
4206
+ const outputDir = path16.dirname(outputPath);
4207
+ if (!fs17.existsSync(outputDir)) fs17.mkdirSync(outputDir, { recursive: true });
4208
+ const tempPcmPath = path16.join(outputDir, `temp_bgm_${Date.now()}.pcm`);
4209
+ fs17.writeFileSync(tempPcmPath, pcmBuffer);
4210
+ await convertPcmToAudio({
4211
+ pcmPath: tempPcmPath,
4212
+ outputPath,
4213
+ format,
4214
+ sampleRate: 48e3,
4215
+ channels: 2,
4216
+ durationSec,
4217
+ mp3Quality: format === "mp3" ? 2 : void 0
4218
+ });
2956
4219
  }
2957
4220
 
2958
4221
  // src/cli/commands/asset.ts
2959
4222
  import { Command as Command8 } from "commander";
2960
- import fs13 from "fs";
2961
- import path14 from "path";
4223
+ import fs18 from "fs";
4224
+ import path17 from "path";
2962
4225
  import { spawn as spawn7 } from "child_process";
2963
4226
  var assetCommand = new Command8("asset").description("Asset information and management");
2964
4227
  assetCommand.command("info <file>").description("Show detailed information about an asset").action(async (file) => {
2965
- const assetPath = path14.resolve(process.cwd(), "assets", file);
2966
- if (!fs13.existsSync(assetPath)) {
4228
+ const assetPath = path17.resolve(process.cwd(), "assets", file);
4229
+ if (!fs18.existsSync(assetPath)) {
2967
4230
  console.error(`Asset not found: ${file}`);
2968
4231
  console.error(`Looked in: ${assetPath}`);
2969
4232
  process.exit(1);
2970
4233
  }
2971
- const stats = fs13.statSync(assetPath);
2972
- const ext = path14.extname(file).toLowerCase();
4234
+ const stats = fs18.statSync(assetPath);
4235
+ const ext = path17.extname(file).toLowerCase();
2973
4236
  console.log(`
2974
4237
  Asset: ${file}`);
2975
4238
  console.log(`Size: ${formatBytes2(stats.size)}`);
@@ -2992,11 +4255,11 @@ Asset: ${file}`);
2992
4255
  console.log(`Channels: ${audioInfo.channels}`);
2993
4256
  console.log(`Bitrate: ${audioInfo.bitrate}`);
2994
4257
  const metadataFile = file.replace(ext, ".json");
2995
- const metadataPath = path14.resolve(process.cwd(), "assets", metadataFile);
2996
- if (fs13.existsSync(metadataPath)) {
4258
+ const metadataPath = path17.resolve(process.cwd(), "assets", metadataFile);
4259
+ if (fs18.existsSync(metadataPath)) {
2997
4260
  console.log(`
2998
4261
  Metadata: ${metadataFile}`);
2999
- const metadata = JSON.parse(fs13.readFileSync(metadataPath, "utf-8"));
4262
+ const metadata = JSON.parse(fs18.readFileSync(metadataPath, "utf-8"));
3000
4263
  if (Array.isArray(metadata.words) && metadata.words.length > 0) {
3001
4264
  console.log(`Word timings: ${metadata.words.length} words`);
3002
4265
  const firstWord = metadata.words[0];
@@ -3033,7 +4296,8 @@ function getAssetType(ext) {
3033
4296
  async function getImageDimensions(filePath) {
3034
4297
  try {
3035
4298
  const imageSize = await import("image-size");
3036
- const dimensions = imageSize.default(filePath);
4299
+ const buffer = fs18.readFileSync(filePath);
4300
+ const dimensions = imageSize.default(buffer);
3037
4301
  return { width: dimensions.width || 0, height: dimensions.height || 0 };
3038
4302
  } catch (err) {
3039
4303
  throw new Error("image-size package not available");
@@ -3080,9 +4344,9 @@ async function getAudioInfo(filePath) {
3080
4344
 
3081
4345
  // src/cli/index.ts
3082
4346
  import open2 from "open";
3083
- import path18 from "path";
4347
+ import path21 from "path";
3084
4348
  import { fileURLToPath as fileURLToPath5 } from "url";
3085
- import fs17 from "fs";
4349
+ import fs21 from "fs";
3086
4350
 
3087
4351
  // src/cli/commands/example.ts
3088
4352
  import { Command as Command9 } from "commander";
@@ -3375,72 +4639,42 @@ function printSchema(name, schema) {
3375
4639
 
3376
4640
  // src/cli/commands/create-scene.ts
3377
4641
  import { Command as Command11 } from "commander";
3378
- import path15 from "path";
3379
-
3380
- // src/cli/services/scene-builder.ts
3381
- import fs14 from "fs";
3382
- function loadScene(filePath) {
3383
- if (fs14.existsSync(filePath)) {
3384
- try {
3385
- return JSON.parse(fs14.readFileSync(filePath, "utf-8"));
3386
- } catch (e) {
3387
- throw new Error(`Failed to parse existing scene file: ${e}`);
3388
- }
3389
- } else {
3390
- return {
3391
- meta: {
3392
- width: 1080,
3393
- height: 1920,
3394
- duration: 10
3395
- },
3396
- entities: []
3397
- };
3398
- }
3399
- }
3400
- function saveScene(filePath, scene) {
3401
- fs14.writeFileSync(filePath, JSON.stringify(scene, null, 2));
3402
- }
3403
- function addEntityToScene(scene, entity) {
3404
- if (!entity.id) {
3405
- const typeCount = scene.entities.filter((e) => e.type === entity.type).length + 1;
3406
- entity.id = `${entity.type}-${typeCount}`;
3407
- }
3408
- if (scene.entities.some((e) => e.id === entity.id)) {
3409
- throw new Error(`Entity with ID "${entity.id}" already exists.`);
3410
- }
3411
- scene.entities.push(entity);
3412
- if (typeof entity.startTime === "number" && typeof entity.duration === "number") {
3413
- const entityEnd = entity.startTime + entity.duration;
3414
- if (entityEnd > scene.meta.duration) {
3415
- scene.meta.duration = Math.ceil(entityEnd);
3416
- }
3417
- }
3418
- }
3419
-
3420
- // src/cli/commands/create-scene.ts
3421
- import fs15 from "fs";
3422
4642
  var createSceneCommand = new Command11("create-scene");
3423
- createSceneCommand.description("Create and modify scene files").argument("<file>", "Path to the scene file").action(async (file) => {
3424
- const filePath = path15.resolve(process.cwd(), file);
4643
+ createSceneCommand.description("Create a new scene in a project").option("--project-id <id>", "Project ID").option("--name <name>", "Scene name").option("--api-url <url>", "API base URL (e.g. http://localhost:3331)").action(async (options) => {
3425
4644
  try {
3426
- const scene = loadScene(filePath);
3427
- saveScene(filePath, scene);
3428
- console.log(`\u2705 Scene file ready at: ${filePath}`);
4645
+ const projectId = resolveProjectId(options.projectId);
4646
+ const apiUrl = resolveApiUrl(options.apiUrl);
4647
+ const scene = apiUrl ? await new SceneApiClient(apiUrl).createScene(projectId, {
4648
+ name: options.name ? String(options.name) : void 0
4649
+ }) : await createSceneStoreBundle("file").scenes.createScene(projectId, {
4650
+ name: options.name ? String(options.name) : void 0
4651
+ });
4652
+ writeFlowState({ projectId, sceneId: scene.id, apiUrl });
4653
+ console.log(`\u2705 Scene created: ${scene.id} (project ${scene.projectId})`);
3429
4654
  } catch (error) {
3430
4655
  console.error(`\u274C Error creating scene: ${error.message}`);
3431
4656
  process.exit(1);
3432
4657
  }
3433
4658
  });
3434
4659
  var addEntityCommand = new Command11("add-entity");
3435
- addEntityCommand.description("Add an entity to an existing scene file").argument("<file>", "Path to the scene file").requiredOption("-t, --type <type>", "Entity type (image, audio, text)").option("--src <path>", "Asset path (relative to CWD or absolute)").option("--text <content>", "Text content (for text)").option("--fit <mode>", "Image fit mode: smart | contain | cover", "smart").option("--start <number>", "Start time in seconds").option("--duration <string>", 'Duration in seconds or "auto"', "5").option("--at <position>", 'Position ("end" to append after last entity)', "end").action(async (file, options) => {
3436
- const filePath = path15.resolve(process.cwd(), file);
3437
- if (!fs15.existsSync(filePath)) {
3438
- console.error(`\u274C Scene file not found: ${filePath}`);
3439
- process.exit(1);
3440
- }
4660
+ addEntityCommand.description("Add an entity to an existing scene").option("--project-id <id>", "Project ID").option("--scene-id <id>", "Scene ID").requiredOption("-t, --type <type>", "Entity type (image, audio, text)").option("--src <path>", "Asset URI/path").option("--text <content>", "Text content (for text)").option("--fit <mode>", "Image fit mode: smart | contain | cover", "smart").option("--start <number>", "Start time in seconds").option("--duration <string>", 'Duration in seconds or "auto"', "5").option("--at <position>", 'Position ("end" to append after last entity)', "end").option("--api-url <url>", "API base URL (e.g. http://localhost:3331)").action(async (options) => {
3441
4661
  try {
3442
- const scene = loadScene(filePath);
4662
+ const bundle = createSceneStoreBundle("file");
4663
+ const projectId = resolveProjectId(options.projectId);
4664
+ const sceneId = resolveSceneId(options.sceneId);
4665
+ const apiUrl = resolveApiUrl(options.apiUrl);
4666
+ const apiClient = apiUrl ? new SceneApiClient(apiUrl) : null;
4667
+ const project = apiClient ? await apiClient.getProject(projectId) : await bundle.projects.getProject(projectId);
4668
+ if (!project) {
4669
+ throw new Error(`Project not found: ${projectId}`);
4670
+ }
4671
+ const scene = apiClient ? await apiClient.getScene(projectId, sceneId) : await bundle.scenes.getScene(projectId, sceneId);
4672
+ if (!scene) {
4673
+ throw new Error(`Scene not found: ${sceneId}`);
4674
+ }
3443
4675
  const type = options.type;
4676
+ const entities = [...scene.entities];
4677
+ const registry = createDefaultAssetResolverRegistry();
3444
4678
  let entity = {
3445
4679
  type,
3446
4680
  visible: true
@@ -3448,30 +4682,27 @@ addEntityCommand.description("Add an entity to an existing scene file").argument
3448
4682
  let startTime = parseFloat(options.start);
3449
4683
  let duration = parseFloat(options.duration);
3450
4684
  if (isNaN(startTime) && options.at === "end") {
3451
- const entities = scene.entities;
3452
- if (entities.length > 0) {
3453
- let maxEnd = 0;
3454
- entities.forEach((e) => {
3455
- if (typeof e.startTime === "number" && typeof e.duration === "number") {
3456
- const end = e.startTime + e.duration;
3457
- if (end > maxEnd) maxEnd = end;
3458
- }
3459
- });
3460
- startTime = maxEnd;
3461
- } else {
3462
- startTime = 0;
3463
- }
4685
+ let maxEnd = 0;
4686
+ entities.forEach((e) => {
4687
+ if (typeof e.startTime === "number" && typeof e.duration === "number") {
4688
+ const end = e.startTime + e.duration;
4689
+ if (end > maxEnd) maxEnd = end;
4690
+ }
4691
+ });
4692
+ startTime = maxEnd;
3464
4693
  } else if (isNaN(startTime)) {
3465
4694
  startTime = 0;
3466
4695
  }
3467
4696
  if (options.duration === "auto" && options.src) {
3468
- const assetPath = path15.resolve(process.cwd(), options.src);
3469
- try {
3470
- const metadata = await FFprobeService.getMetadata(assetPath);
3471
- duration = metadata.duration;
3472
- console.log(`\u2139\uFE0F Auto-detected duration: ${duration.toFixed(2)}s`);
3473
- } catch (e) {
3474
- console.warn(`\u26A0\uFE0F Could not auto-detect duration for ${options.src}, using default 5s`);
4697
+ const resolved = registry.resolve(options.src, project.rootPath || process.cwd());
4698
+ if (resolved.type === "local") {
4699
+ try {
4700
+ const metadata = await FFprobeService.getMetadata(resolved.localPath);
4701
+ duration = metadata.duration;
4702
+ } catch {
4703
+ duration = 5;
4704
+ }
4705
+ } else {
3475
4706
  duration = 5;
3476
4707
  }
3477
4708
  } else if (options.duration === "auto") {
@@ -3498,12 +4729,32 @@ addEntityCommand.description("Add an entity to an existing scene file").argument
3498
4729
  entity.color = "#ffffff";
3499
4730
  entity.x = 540;
3500
4731
  entity.y = 960;
4732
+ entity.fontFamily = "Arial";
4733
+ entity.fontWeight = "700";
4734
+ entity.bgColor = "transparent";
4735
+ entity.maxWidth = 900;
4736
+ entity.lineHeight = 1.2;
4737
+ entity.padding = 8;
4738
+ entity.textAlign = "left";
3501
4739
  } else {
3502
4740
  throw new Error(`Unknown type: ${type}`);
3503
4741
  }
3504
- addEntityToScene(scene, entity);
3505
- saveScene(filePath, scene);
3506
- console.log(`\u2705 Added ${type} entity to ${file} at ${startTime}s`);
4742
+ if (!entity.id) {
4743
+ const typeCount = entities.filter((e) => e.type === entity.type).length + 1;
4744
+ entity.id = `${entity.type}-${typeCount}`;
4745
+ }
4746
+ entity.name = entity.name || entity.id;
4747
+ entities.push(entity);
4748
+ const updated = apiClient ? await apiClient.updateScene(projectId, sceneId, {
4749
+ entities,
4750
+ meta: scene.meta,
4751
+ version: scene.version
4752
+ }) : await bundle.scenes.updateScene(projectId, sceneId, {
4753
+ entities,
4754
+ meta: scene.meta
4755
+ }, scene.version);
4756
+ writeFlowState({ projectId, sceneId, apiUrl });
4757
+ console.log(`\u2705 Added ${type} entity to scene ${updated.id} at ${startTime}s`);
3507
4758
  } catch (error) {
3508
4759
  console.error(`\u274C Error adding entity: ${error.message}`);
3509
4760
  process.exit(1);
@@ -3512,7 +4763,7 @@ addEntityCommand.description("Add an entity to an existing scene file").argument
3512
4763
 
3513
4764
  // src/cli/services/telemetry.ts
3514
4765
  import os2 from "os";
3515
- import path16 from "path";
4766
+ import path18 from "path";
3516
4767
  import { createHash as createHash2 } from "crypto";
3517
4768
  var POSTHOG_CAPTURE_URL = "https://us.i.posthog.com/capture/";
3518
4769
  var TELEMETRY_DISABLED_VALUES = /* @__PURE__ */ new Set(["1", "true", "yes", "on"]);
@@ -3524,7 +4775,7 @@ var PostHogTelemetryService = class {
3524
4775
  if (!this.enabled) return;
3525
4776
  this.capture("cli_feedback", {
3526
4777
  details: String(details).slice(0, 2e3),
3527
- cwd_basename: path16.basename(process.cwd())
4778
+ cwd_basename: path18.basename(process.cwd())
3528
4779
  });
3529
4780
  }
3530
4781
  apiKey;
@@ -3547,7 +4798,7 @@ var PostHogTelemetryService = class {
3547
4798
  command_name: this.getCommandPath(actionCommand),
3548
4799
  command_aliases: actionCommand.aliases(),
3549
4800
  options_used: this.getUsedOptionNames(actionCommand),
3550
- cwd_basename: path16.basename(process.cwd()),
4801
+ cwd_basename: path18.basename(process.cwd()),
3551
4802
  node_version: process.version,
3552
4803
  platform: process.platform,
3553
4804
  arch: process.arch
@@ -3560,7 +4811,7 @@ var PostHogTelemetryService = class {
3560
4811
  status,
3561
4812
  duration_ms: Math.max(0, Math.round(durationMs)),
3562
4813
  error_message: errorMessage ? String(errorMessage).slice(0, 300) : void 0,
3563
- cwd_basename: path16.basename(process.cwd())
4814
+ cwd_basename: path18.basename(process.cwd())
3564
4815
  });
3565
4816
  }
3566
4817
  getCommandPath(command) {
@@ -3613,26 +4864,26 @@ var PostHogTelemetryService = class {
3613
4864
 
3614
4865
  // src/cli/commands/taste.ts
3615
4866
  import { Command as Command12 } from "commander";
3616
- import fs16 from "node:fs";
3617
- import path17 from "node:path";
4867
+ import fs19 from "node:fs";
4868
+ import path19 from "node:path";
3618
4869
  import { fileURLToPath as fileURLToPath4 } from "node:url";
3619
4870
  import open from "open";
3620
4871
  var __filename4 = fileURLToPath4(import.meta.url);
3621
- var __dirname4 = path17.dirname(__filename4);
4872
+ var __dirname4 = path19.dirname(__filename4);
3622
4873
  function resolveStaticRoot() {
3623
- let staticRoot = path17.resolve(__dirname4, "../../../dist/ui");
3624
- if (!fs16.existsSync(staticRoot)) {
3625
- staticRoot = path17.resolve(__dirname4, "../../ui");
4874
+ let staticRoot = path19.resolve(__dirname4, "../../../dist/ui");
4875
+ if (!fs19.existsSync(staticRoot)) {
4876
+ staticRoot = path19.resolve(__dirname4, "../../ui");
3626
4877
  }
3627
4878
  return staticRoot;
3628
4879
  }
3629
4880
  function prepareWorkingDirectory(pathArg) {
3630
4881
  if (!pathArg) return;
3631
- const targetPath = path17.resolve(process.cwd(), pathArg);
3632
- if (fs16.existsSync(targetPath)) {
3633
- const stats = fs16.statSync(targetPath);
4882
+ const targetPath = path19.resolve(process.cwd(), pathArg);
4883
+ if (fs19.existsSync(targetPath)) {
4884
+ const stats = fs19.statSync(targetPath);
3634
4885
  if (stats.isFile()) {
3635
- process.chdir(path17.dirname(targetPath));
4886
+ process.chdir(path19.dirname(targetPath));
3636
4887
  return;
3637
4888
  }
3638
4889
  if (stats.isDirectory()) {
@@ -3640,13 +4891,13 @@ function prepareWorkingDirectory(pathArg) {
3640
4891
  return;
3641
4892
  }
3642
4893
  }
3643
- if (path17.extname(pathArg)) {
3644
- const dir = path17.dirname(targetPath);
3645
- fs16.mkdirSync(dir, { recursive: true });
4894
+ if (path19.extname(pathArg)) {
4895
+ const dir = path19.dirname(targetPath);
4896
+ fs19.mkdirSync(dir, { recursive: true });
3646
4897
  process.chdir(dir);
3647
4898
  return;
3648
4899
  }
3649
- fs16.mkdirSync(targetPath, { recursive: true });
4900
+ fs19.mkdirSync(targetPath, { recursive: true });
3650
4901
  process.chdir(targetPath);
3651
4902
  }
3652
4903
  function createStoreFromOptions(options) {
@@ -3666,7 +4917,7 @@ var tasteCommand = new Command12("taste").description("Launch and automate the T
3666
4917
  const store = createStoreFromOptions(options);
3667
4918
  await store.ensureWorkspace();
3668
4919
  const staticRoot = resolveStaticRoot();
3669
- if (!fs16.existsSync(staticRoot)) {
4920
+ if (!fs19.existsSync(staticRoot)) {
3670
4921
  console.warn(`Warning: UI assets not found at ${staticRoot}. Did you run 'bun run build'?`);
3671
4922
  }
3672
4923
  const port = parseInt(options.port, 10);
@@ -3891,13 +5142,140 @@ function createFeedbackCommand(telemetry2) {
3891
5142
  });
3892
5143
  }
3893
5144
 
5145
+ // src/cli/commands/project.ts
5146
+ import { Command as Command14 } from "commander";
5147
+ var projectCommand = new Command14("project").description("Manage projects");
5148
+ projectCommand.command("create").description("Create a project with a default scene").requiredOption("--name <name>", "Project name").option("--root-path <path>", "Project root path", process.cwd()).option("--api-url <url>", "API base URL (e.g. http://localhost:3331)").action(async (options) => {
5149
+ try {
5150
+ const apiUrl = resolveApiUrl(options.apiUrl);
5151
+ if (apiUrl) {
5152
+ const client = new SceneApiClient(apiUrl);
5153
+ const created = await client.createProject({
5154
+ name: String(options.name),
5155
+ rootPath: String(options.rootPath || process.cwd())
5156
+ });
5157
+ writeFlowState({ projectId: created.project.id, sceneId: created.defaultScene.id, apiUrl });
5158
+ console.log(JSON.stringify(created, null, 2));
5159
+ } else {
5160
+ const bundle = createSceneStoreBundle("file");
5161
+ const project = await bundle.projects.createProject({
5162
+ name: String(options.name),
5163
+ rootPath: String(options.rootPath || process.cwd())
5164
+ });
5165
+ const defaultScene = await bundle.scenes.createScene(project.id, { name: "Scene 1" });
5166
+ const updated = await bundle.projects.updateProject(project.id, { defaultSceneId: defaultScene.id });
5167
+ writeFlowState({ projectId: updated.id, sceneId: defaultScene.id });
5168
+ console.log(JSON.stringify({ project: updated, defaultScene }, null, 2));
5169
+ }
5170
+ } catch (error) {
5171
+ console.error(`Error: ${error.message}`);
5172
+ process.exit(1);
5173
+ }
5174
+ });
5175
+ projectCommand.command("list").description("List projects").option("--api-url <url>", "API base URL (e.g. http://localhost:3331)").action(async (options) => {
5176
+ try {
5177
+ const apiUrl = resolveApiUrl(options.apiUrl);
5178
+ const projects = apiUrl ? await new SceneApiClient(apiUrl).listProjects() : await createSceneStoreBundle("file").projects.listProjects();
5179
+ console.log(JSON.stringify({ projects }, null, 2));
5180
+ } catch (error) {
5181
+ console.error(`Error: ${error.message}`);
5182
+ process.exit(1);
5183
+ }
5184
+ });
5185
+
5186
+ // src/cli/commands/scene.ts
5187
+ import fs20 from "node:fs";
5188
+ import path20 from "node:path";
5189
+ import { Command as Command15 } from "commander";
5190
+ var sceneCommand = new Command15("scene").description("Manage scenes");
5191
+ sceneCommand.command("list").option("--project-id <id>", "Project ID").option("--api-url <url>", "API base URL (e.g. http://localhost:3331)").action(async (options) => {
5192
+ try {
5193
+ const projectId = resolveProjectId(options.projectId);
5194
+ const apiUrl = resolveApiUrl(options.apiUrl);
5195
+ const scenes = apiUrl ? await new SceneApiClient(apiUrl).listScenes(projectId) : await createSceneStoreBundle("file").scenes.listScenes(projectId);
5196
+ console.log(JSON.stringify({ scenes }, null, 2));
5197
+ } catch (error) {
5198
+ console.error(`Error: ${error.message}`);
5199
+ process.exit(1);
5200
+ }
5201
+ });
5202
+ sceneCommand.command("create").option("--project-id <id>", "Project ID").option("--name <name>", "Scene name").option("--api-url <url>", "API base URL (e.g. http://localhost:3331)").action(async (options) => {
5203
+ try {
5204
+ const projectId = resolveProjectId(options.projectId);
5205
+ const apiUrl = resolveApiUrl(options.apiUrl);
5206
+ const scene = apiUrl ? await new SceneApiClient(apiUrl).createScene(projectId, {
5207
+ name: options.name ? String(options.name) : void 0
5208
+ }) : await createSceneStoreBundle("file").scenes.createScene(projectId, {
5209
+ name: options.name ? String(options.name) : void 0
5210
+ });
5211
+ writeFlowState({ projectId, sceneId: scene.id, apiUrl });
5212
+ console.log(JSON.stringify({ scene }, null, 2));
5213
+ } catch (error) {
5214
+ console.error(`Error: ${error.message}`);
5215
+ process.exit(1);
5216
+ }
5217
+ });
5218
+ sceneCommand.command("import").option("--project-id <id>", "Project ID").requiredOption("--file <path>", "Scene JSON file path").option("--name <name>", "Scene name").option("--api-url <url>", "API base URL (e.g. http://localhost:3331)").action(async (options) => {
5219
+ try {
5220
+ const filePath = path20.resolve(process.cwd(), String(options.file));
5221
+ if (!fs20.existsSync(filePath)) {
5222
+ throw new Error(`File not found: ${filePath}`);
5223
+ }
5224
+ const parsed = JSON.parse(fs20.readFileSync(filePath, "utf-8"));
5225
+ if (!parsed?.meta || !Array.isArray(parsed?.entities)) {
5226
+ throw new Error("Invalid scene file. Expected { meta, entities }.");
5227
+ }
5228
+ const projectId = resolveProjectId(options.projectId);
5229
+ const apiUrl = resolveApiUrl(options.apiUrl);
5230
+ const scene = apiUrl ? await new SceneApiClient(apiUrl).createScene(projectId, {
5231
+ name: options.name ? String(options.name) : path20.basename(filePath, path20.extname(filePath)),
5232
+ scene: { meta: parsed.meta, entities: parsed.entities }
5233
+ }) : await createSceneStoreBundle("file").scenes.createScene(projectId, {
5234
+ name: options.name ? String(options.name) : path20.basename(filePath, path20.extname(filePath)),
5235
+ scene: { meta: parsed.meta, entities: parsed.entities }
5236
+ });
5237
+ writeFlowState({ projectId, sceneId: scene.id, apiUrl });
5238
+ console.log(JSON.stringify({ scene }, null, 2));
5239
+ } catch (error) {
5240
+ console.error(`Error: ${error.message}`);
5241
+ process.exit(1);
5242
+ }
5243
+ });
5244
+ sceneCommand.command("export").option("--project-id <id>", "Project ID").option("--scene-id <id>", "Scene ID").requiredOption("--file <path>", "Output file path").option("--api-url <url>", "API base URL (e.g. http://localhost:3331)").action(async (options) => {
5245
+ try {
5246
+ const projectId = resolveProjectId(options.projectId);
5247
+ const sceneId = resolveSceneId(options.sceneId);
5248
+ const apiUrl = resolveApiUrl(options.apiUrl);
5249
+ const scene = apiUrl ? await new SceneApiClient(apiUrl).getScene(projectId, sceneId) : await createSceneStoreBundle("file").scenes.getScene(projectId, sceneId);
5250
+ if (!scene) {
5251
+ throw new Error(`Scene not found: ${sceneId}`);
5252
+ }
5253
+ const out = path20.resolve(process.cwd(), String(options.file));
5254
+ fs20.mkdirSync(path20.dirname(out), { recursive: true });
5255
+ fs20.writeFileSync(out, JSON.stringify({ meta: scene.meta, entities: scene.entities }, null, 2), "utf-8");
5256
+ writeFlowState({ projectId, sceneId, apiUrl });
5257
+ console.log(`Scene exported to ${out}`);
5258
+ } catch (error) {
5259
+ console.error(`Error: ${error.message}`);
5260
+ process.exit(1);
5261
+ }
5262
+ });
5263
+ sceneCommand.command("use").description("Set current flow state for project/scene").requiredOption("--project-id <id>", "Project ID").option("--scene-id <id>", "Scene ID").option("--api-url <url>", "API base URL (e.g. http://localhost:3331)").action((options) => {
5264
+ const next = writeFlowState({
5265
+ projectId: String(options.projectId),
5266
+ sceneId: options.sceneId ? String(options.sceneId) : void 0,
5267
+ apiUrl: options.apiUrl ? String(options.apiUrl) : void 0
5268
+ });
5269
+ console.log(JSON.stringify({ session: next }, null, 2));
5270
+ });
5271
+
3894
5272
  // src/cli/index.ts
3895
5273
  var __filename5 = fileURLToPath5(import.meta.url);
3896
- var __dirname5 = path18.dirname(__filename5);
3897
- var program = new Command14();
5274
+ var __dirname5 = path21.dirname(__filename5);
5275
+ var program = new Command16();
3898
5276
  var telemetry = new PostHogTelemetryService();
3899
5277
  var commandStartTimes = /* @__PURE__ */ new WeakMap();
3900
- program.name("feedeas").description("CLI for Feedeas - AI-native video creation tool").version("0.1.0-alpha.9");
5278
+ program.name("feedeas").description("CLI for Feedeas - AI-native video creation tool").version("0.1.0-alpha.18");
3901
5279
  program.hook("preAction", (_thisCommand, actionCommand) => {
3902
5280
  commandStartTimes.set(actionCommand, Date.now());
3903
5281
  telemetry.trackCommandStarted(actionCommand);
@@ -3906,57 +5284,49 @@ program.hook("postAction", (_thisCommand, actionCommand) => {
3906
5284
  const startedAt = commandStartTimes.get(actionCommand) || Date.now();
3907
5285
  telemetry.trackCommandFinished(actionCommand, "success", Date.now() - startedAt);
3908
5286
  });
3909
- program.command("edit [path]").alias("start").alias("init").description("Start the Feedeas editor server. Accepts a directory or scene file path.").option("-p, --port <number>", "Port to run on", "3331").option("--no-open", "Do not open browser").action(async (pathArg, options) => {
5287
+ program.command("edit").alias("start").alias("init").description("Start the Feedeas editor server for a project/scene.").option("-p, --port <number>", "Port to run on", "3331").option("--project-id <id>", "Project ID to open").option("--scene-id <id>", "Scene ID to open").option("--no-open", "Do not open browser").action(async (options) => {
3910
5288
  const port = parseInt(options.port);
3911
- let sceneFile;
3912
- if (pathArg) {
3913
- const targetPath = path18.resolve(process.cwd(), pathArg);
3914
- if (fs17.existsSync(targetPath)) {
3915
- const stats = fs17.statSync(targetPath);
3916
- if (stats.isFile()) {
3917
- sceneFile = path18.basename(targetPath);
3918
- const dir = path18.dirname(targetPath);
3919
- process.chdir(dir);
3920
- console.log(`Opening scene file: ${sceneFile}`);
3921
- } else if (stats.isDirectory()) {
3922
- process.chdir(targetPath);
3923
- }
3924
- } else {
3925
- if (pathArg.endsWith(".json")) {
3926
- sceneFile = path18.basename(pathArg);
3927
- const dir = path18.dirname(path18.resolve(process.cwd(), pathArg));
3928
- if (!fs17.existsSync(dir)) {
3929
- console.log(`Creating directory ${dir}...`);
3930
- fs17.mkdirSync(dir, { recursive: true });
3931
- }
3932
- process.chdir(dir);
3933
- console.log(`Will create new scene file: ${sceneFile}`);
3934
- } else {
3935
- console.log(`Creating directory ${targetPath}...`);
3936
- fs17.mkdirSync(targetPath, { recursive: true });
3937
- process.chdir(targetPath);
3938
- }
5289
+ const projectId = options.projectId ? resolveProjectId(options.projectId) : (() => {
5290
+ try {
5291
+ return resolveProjectId();
5292
+ } catch {
5293
+ return void 0;
3939
5294
  }
3940
- }
5295
+ })();
5296
+ const sceneId = options.sceneId ? resolveSceneId(options.sceneId) : (() => {
5297
+ try {
5298
+ return resolveSceneId();
5299
+ } catch {
5300
+ return void 0;
5301
+ }
5302
+ })();
3941
5303
  const cwd = process.cwd();
3942
5304
  console.log(`Starting Feedeas in ${cwd}...`);
3943
- let staticRoot = path18.resolve(__dirname5, "../../dist/ui");
3944
- if (!fs17.existsSync(staticRoot)) {
3945
- staticRoot = path18.resolve(__dirname5, "../ui");
5305
+ let staticRoot = path21.resolve(__dirname5, "../../dist/ui");
5306
+ if (!fs21.existsSync(staticRoot)) {
5307
+ staticRoot = path21.resolve(__dirname5, "../ui");
3946
5308
  }
3947
- if (!fs17.existsSync(staticRoot)) {
5309
+ if (!fs21.existsSync(staticRoot)) {
3948
5310
  console.warn(`Warning: UI assets not found at ${staticRoot}. Did you run 'bun run build'?`);
3949
5311
  }
3950
5312
  const app2 = createServer(staticRoot);
3951
5313
  startServer(app2, port);
3952
- const url = `http://localhost:${port}`;
5314
+ const params = new URLSearchParams();
5315
+ if (projectId) params.set("projectId", String(projectId));
5316
+ if (sceneId) params.set("sceneId", String(sceneId));
5317
+ const url = `http://localhost:${port}/editor${params.toString() ? `?${params.toString()}` : ""}`;
3953
5318
  console.log(`Server running at ${url}`);
3954
5319
  if (options.open) {
3955
5320
  await open2(url);
3956
5321
  }
5322
+ if (projectId || sceneId) {
5323
+ writeFlowState({ projectId, sceneId });
5324
+ }
3957
5325
  });
3958
- program.command("snap").alias("screenshot").description("Take a snapshot of the canvas at a specific time").argument("<time>", "Time in seconds").option("-o, --output <path>", "Output file path", "snapshot.png").option("-u, --url <url>", "URL of the running server", "http://localhost:3331").option("-W, --width <number>", "Viewport width", "1080").option("-H, --height <number>", "Viewport height", "1350").action(async (time, options) => {
5326
+ program.command("snap").alias("screenshot").description("Take a snapshot of the canvas at a specific time").argument("<time>", "Time in seconds").option("-o, --output <path>", "Output file path", "snapshot.png").option("-u, --url <url>", "URL of the running server", "http://localhost:3331").option("--project-id <id>", "Project ID").option("--scene-id <id>", "Scene ID").option("-W, --width <number>", "Viewport width", "1080").option("-H, --height <number>", "Viewport height", "1350").action(async (time, options) => {
3959
5327
  console.log(`Taking snapshot at ${time}s...`);
5328
+ const projectId = resolveProjectId(options.projectId);
5329
+ const sceneId = resolveSceneId(options.sceneId);
3960
5330
  try {
3961
5331
  const { chromium: chromium2 } = await import("playwright-core");
3962
5332
  let browser;
@@ -3985,7 +5355,7 @@ program.command("snap").alias("screenshot").description("Take a snapshot of the
3985
5355
  const width = parseInt(options.width);
3986
5356
  const height = parseInt(options.height);
3987
5357
  await page.setViewportSize({ width, height });
3988
- const targetUrl = `${options.url}?time=${time}&mode=render`;
5358
+ const targetUrl = `${options.url}?time=${time}&mode=render&projectId=${encodeURIComponent(projectId)}&sceneId=${encodeURIComponent(sceneId)}`;
3989
5359
  console.log(`Navigating to ${targetUrl}...`);
3990
5360
  await page.goto(targetUrl);
3991
5361
  try {
@@ -3999,6 +5369,7 @@ program.command("snap").alias("screenshot").description("Take a snapshot of the
3999
5369
  const file = options.output;
4000
5370
  await page.locator("canvas").first().screenshot({ path: file });
4001
5371
  console.log(`Snapshot saved to ${file}`);
5372
+ writeFlowState({ projectId, sceneId });
4002
5373
  await browser.close();
4003
5374
  } catch (e) {
4004
5375
  console.error("Failed to take snapshot:", e.message);
@@ -4019,16 +5390,18 @@ program.addCommand(createSceneCommand);
4019
5390
  program.addCommand(addEntityCommand);
4020
5391
  program.addCommand(tasteCommand);
4021
5392
  program.addCommand(createFeedbackCommand(telemetry));
5393
+ program.addCommand(projectCommand);
5394
+ program.addCommand(sceneCommand);
4022
5395
  program.addHelpText("after", `
4023
5396
 
4024
5397
  Agent tip:
4025
5398
  feedeas feedback "<what to improve>"
4026
5399
 
4027
5400
  Quick Start (30 seconds):
4028
- 1. feedeas example basic > scene.json # Create a scene template
4029
- 2. feedeas validate scene.json # Verify it's valid
4030
- 3. feedeas edit # Open interactive editor, or...
4031
- 4. feedeas record --project scene.json # Render video directly
5401
+ 1. feedeas project create --name "My Project" > project.json
5402
+ 2. feedeas scene list --project-id <projectId>
5403
+ 3. feedeas edit --project-id <projectId> --scene-id <sceneId>
5404
+ 4. feedeas record --project-id <projectId> --scene-id <sceneId> # Render video directly
4032
5405
 
4033
5406
  Guided Flow (Agent-Friendly, End-to-End):
4034
5407
  1. mkdir -p my-reel && cd my-reel
@@ -4053,8 +5426,8 @@ Agent Conversation Example:
4053
5426
  Agent (tool): feedeas audio "Life changes in small daily choices. A morning ritual of mindfulness can set the tone for your day..." -o narration.mp3 --no-transcribe
4054
5427
  Agent: Now I'll generate some visuals to match the narration.
4055
5428
  Agent (tool): feedeas example documentary > scene.json
4056
- Agent (tool): feedeas imagine "warm cinematic kitchen morning..." --aspect-ratio 9:16 -o scene1.png
4057
- Agent (tool): feedeas imagine "A person meditating in a park..." --aspect-ratio 9:16 -o scene2.png
5429
+ Agent (tool): feedeas imagine "A peaceful sunrise over a mountain lake, cinematic lighting" --aspect-ratio 9:16 -o scene1.png
5430
+ Agent (tool): feedeas imagine "A person meditating in a park, soft morning light" --aspect-ratio 9:16 -o scene2.png
4058
5431
  Agent (tool): feedeas audio "Life changes in small daily choices..." -o narration.mp3 --no-transcribe
4059
5432
  Agent (tool): feedeas bgm "soft reflective ambient, no vocals..." -d 25 -o bgm.mp3
4060
5433
  Agent: Now I\u2019ll wire assets into scene.json with src values under assets/.
@@ -4113,7 +5486,7 @@ Transition Types:
4113
5486
 
4114
5487
  Project Structure:
4115
5488
  your-project/
4116
- \u251C\u2500\u2500 scene_1.json # Scene definition (required)
5489
+ \u251C\u2500\u2500 .feedeas/ # Project + scene resource storage (default file backend)
4117
5490
  \u251C\u2500\u2500 assets/ # Asset folder (images, audio)
4118
5491
  \u2502 \u251C\u2500\u2500 image.jpg
4119
5492
  \u2502 \u251C\u2500\u2500 music.mp3
@@ -4126,14 +5499,14 @@ Programmatic Workflow (for Agents):
4126
5499
  $ feedeas audio "narration text" -o assets/voice.mp3 # Generate audio
4127
5500
  $ feedeas bgm "cinematic bgm, no vocals" -o assets/bgm.mp3
4128
5501
  $ feedeas example documentary # See a complete example
4129
- $ feedeas set-scene my.json --content '{...}' # Create scene
4130
- $ feedeas validate my.json # Validate before rendering
5502
+ $ feedeas set-scene --project-id <pid> --scene-id <sid> --content '{...}'
5503
+ $ feedeas validate --project-id <pid> --scene-id <sid> # Validate before rendering
4131
5504
  $ feedeas edit --no-open & # Start server in background
4132
- $ feedeas record --project my.json # Render to video
5505
+ $ feedeas record --project-id <pid> --scene-id <sid> # Render to video
4133
5506
 
4134
5507
  Interactive Workflow (for Humans):
4135
- $ feedeas edit . # Start editor in current dir
4136
- $ feedeas snap 2.5 -o frame.png # Take snapshot at 2.5s
5508
+ $ feedeas edit --project-id <pid> --scene-id <sid> # Start editor
5509
+ $ feedeas snap 2.5 --project-id <pid> --scene-id <sid> -o frame.png
4137
5510
 
4138
5511
  Taste Workflow:
4139
5512
  $ feedeas taste # Start UI Workspace