@telepat/rilo 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,17 +1,17 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
+ import {
5
+ getLimnGenerationModels,
6
+ isKnownLimnFamily,
7
+ resolveFamilyFromReplicateModelId
8
+ } from '../images/limnModelCatalog.js';
4
9
 
5
10
  const CONFIG_DIR = path.dirname(fileURLToPath(import.meta.url));
6
11
  const MODELS_DIR = path.resolve(CONFIG_DIR, '../../models');
7
12
 
8
13
  export const MODELS = {
9
14
  deepseek: 'deepseek-ai/deepseek-v3',
10
- keyframe: 'prunaai/z-image-turbo',
11
- flux: 'black-forest-labs/flux-2-pro',
12
- fluxSchnell: 'black-forest-labs/flux-schnell',
13
- nanoBananaPro: 'google/nano-banana-pro',
14
- seedream4: 'bytedance/seedream-4',
15
15
  video: 'wan-video/wan-2.2-i2v-fast',
16
16
  klingVideo3: 'kwaivgi/kling-v3-video',
17
17
  pixverseV56: 'pixverse/pixverse-v5.6',
@@ -32,7 +32,7 @@ export const MODEL_CATEGORIES = {
32
32
  export const DEFAULT_MODEL_SELECTIONS = {
33
33
  [MODEL_CATEGORIES.textToText]: MODELS.deepseek,
34
34
  [MODEL_CATEGORIES.textToSpeech]: MODELS.tts,
35
- [MODEL_CATEGORIES.textToImage]: MODELS.keyframe,
35
+ [MODEL_CATEGORIES.textToImage]: 'z-image',
36
36
  [MODEL_CATEGORIES.imageTextToVideo]: MODELS.video
37
37
  };
38
38
 
@@ -41,11 +41,6 @@ export const MODEL_OPTION_KEYS = [...MODEL_SELECTION_KEYS];
41
41
 
42
42
  const MODEL_METADATA_FILES = {
43
43
  [MODELS.deepseek]: 'deepseek-ai__deepseek-v3.json',
44
- [MODELS.keyframe]: 'prunaai__z-image-turbo.json',
45
- [MODELS.flux]: 'black-forest-labs__flux-2-pro.json',
46
- [MODELS.fluxSchnell]: 'black-forest-labs__flux-schnell.json',
47
- [MODELS.nanoBananaPro]: 'google__nano-banana-pro.json',
48
- [MODELS.seedream4]: 'bytedance__seedream-4.json',
49
44
  [MODELS.video]: 'wan-video__wan-2.2-i2v-fast.json',
50
45
  [MODELS.klingVideo3]: 'kwaivgi__kling-v3-video.json',
51
46
  [MODELS.pixverseV56]: 'pixverse__pixverse-v5.6.json',
@@ -59,10 +54,13 @@ const MODEL_METADATA_FILES = {
59
54
  export const MODEL_IDS_BY_CATEGORY = {
60
55
  [MODEL_CATEGORIES.textToText]: [MODELS.deepseek],
61
56
  [MODEL_CATEGORIES.textToSpeech]: [MODELS.tts, MODELS.chatterboxTurbo, MODELS.kokoro82m],
62
- [MODEL_CATEGORIES.textToImage]: [MODELS.keyframe, MODELS.flux, MODELS.fluxSchnell, MODELS.nanoBananaPro, MODELS.seedream4],
63
57
  [MODEL_CATEGORIES.imageTextToVideo]: [MODELS.video, MODELS.klingVideo3, MODELS.pixverseV56, MODELS.veo31, MODELS.veo31Fast]
64
58
  };
65
59
 
60
+ export function getTextToImageModelIds() {
61
+ return getLimnGenerationModels().map((model) => model.family);
62
+ }
63
+
66
64
  export function toNullableNumber(value) {
67
65
  if (value === null || value === undefined || value === '') {
68
66
  return null;
@@ -109,7 +107,14 @@ export const MODEL_METADATA = Object.fromEntries(
109
107
  Object.values(MODELS).map((modelId) => [modelId, readModelMetadata(modelId)])
110
108
  );
111
109
 
112
- export const SUPPORTED_MODEL_IDS = Object.keys(MODEL_METADATA);
110
+ export const SUPPORTED_MODEL_IDS = [
111
+ ...Object.keys(MODEL_METADATA),
112
+ ...getTextToImageModelIds(),
113
+ ...getTextToImageModelIds().flatMap((family) => {
114
+ const match = getLimnGenerationModels().find((m) => m.family === family);
115
+ return match ? match.replicateModelIds : [];
116
+ })
117
+ ];
113
118
 
114
119
  export const MODEL_PRICING = Object.fromEntries(
115
120
  Object.entries(MODEL_METADATA).map(([modelId, metadata]) => [modelId, normalizePricing(metadata.pricing || {})])
@@ -181,6 +186,9 @@ export function resolveProjectModelOptions(modelOptions = {}, modelSelections =
181
186
  }
182
187
 
183
188
  export function isKnownModelId(modelId) {
189
+ if (typeof modelId === 'string' && isKnownLimnFamily(modelId)) {
190
+ return true;
191
+ }
184
192
  return typeof modelId === 'string' && SUPPORTED_MODEL_IDS.includes(modelId);
185
193
  }
186
194
 
@@ -198,10 +206,22 @@ export function resolveProjectModelSelections(modelSelections = {}) {
198
206
  for (const key of MODEL_SELECTION_KEYS) {
199
207
  const candidate = modelSelections[key];
200
208
  if (typeof candidate === 'string' && candidate.trim()) {
201
- resolved[key] = candidate.trim();
209
+ const trimmed = candidate.trim();
210
+ // Normalize old Replicate T2I IDs to Limn family names
211
+ if (key === MODEL_CATEGORIES.textToImage) {
212
+ const family = resolveFamilyFromReplicateModelId(trimmed);
213
+ resolved[key] = family ?? trimmed;
214
+ } else {
215
+ resolved[key] = trimmed;
216
+ }
202
217
  }
203
218
  }
204
219
 
220
+ // Preserve textToImageReplicateModel if present
221
+ if (typeof modelSelections.textToImageReplicateModel === 'string' && modelSelections.textToImageReplicateModel.trim()) {
222
+ resolved.textToImageReplicateModel = modelSelections.textToImageReplicateModel.trim();
223
+ }
224
+
205
225
  return resolved;
206
226
  }
207
227
 
@@ -217,6 +237,10 @@ export function getSupportedModelIdsForCategory(category) {
217
237
  throw new Error(`Unknown model category: ${category}`);
218
238
  }
219
239
 
240
+ if (category === MODEL_CATEGORIES.textToImage) {
241
+ return getTextToImageModelIds();
242
+ }
243
+
220
244
  return [...(MODEL_IDS_BY_CATEGORY[category] || [])];
221
245
  }
222
246
 
@@ -22,7 +22,16 @@ export const SETTINGS = [
22
22
  description: 'Your Replicate API key (replicate.com/account/api-tokens).',
23
23
  type: 'secure',
24
24
  keystoreKey: 'replicateApiToken',
25
- envNames: ['RILO_REPLICATE_API_TOKEN', 'REPLICATE_API_TOKEN'],
25
+ envNames: ['TELEPAT_REPLICATE_TOKEN'],
26
+ default: ''
27
+ },
28
+ {
29
+ id: 'openRouterApiKey',
30
+ label: 'OpenRouter API Key',
31
+ description: 'Your OpenRouter API key for LLM-driven prompt transformation (openrouter.ai/keys).',
32
+ type: 'secure',
33
+ keystoreKey: 'openRouterApiKey',
34
+ envNames: ['TELEPAT_OPENROUTER_KEY'],
26
35
  default: ''
27
36
  },
28
37
  {
@@ -0,0 +1,39 @@
1
+ import { getSupportedModelCatalog } from '@telepat/limn';
2
+
3
+ export function getLimnGenerationModels() {
4
+ return getSupportedModelCatalog().filter((entry) => entry.generationEnabled);
5
+ }
6
+
7
+ export const DEFAULT_LIMN_MODEL_ID = 'z-image';
8
+
9
+ export function resolveFamilyFromReplicateModelId(replicateModelId) {
10
+ const match = getLimnGenerationModels().find((model) =>
11
+ model.replicateModelIds.includes(replicateModelId)
12
+ );
13
+ return match?.family ?? null;
14
+ }
15
+
16
+ export function isKnownLimnFamily(family) {
17
+ return getLimnGenerationModels().some((model) => model.family === family);
18
+ }
19
+
20
+ export function isReplicateModelIdForFamily(family, replicateModelId) {
21
+ const match = getLimnGenerationModels().find((model) => model.family === family);
22
+ if (!match) {
23
+ return false;
24
+ }
25
+ return match.replicateModelIds.includes(replicateModelId);
26
+ }
27
+
28
+ export function getLimnReplicateModelsForFamily(family) {
29
+ const match = getLimnGenerationModels().find((model) => model.family === family);
30
+ if (!match) {
31
+ return [];
32
+ }
33
+ return [...match.replicateModelIds];
34
+ }
35
+
36
+ export function getLimnDefaultReplicateModel(family) {
37
+ const match = getLimnGenerationModels().find((model) => model.family === family);
38
+ return match?.defaultReplicateModelId ?? null;
39
+ }
@@ -28,17 +28,21 @@ import {
28
28
  import {
29
29
  DEFAULT_VIDEO_CONFIG,
30
30
  MODEL_CATEGORIES,
31
+ MODEL_SELECTION_KEYS,
31
32
  resolveKeyframeSize,
32
33
  resolveProjectModelOptions,
33
34
  resolveProjectModelSelections,
34
35
  resolveShotCount,
35
36
  resolveTargetDurationSec
36
37
  } from '../config/models.js';
38
+ import { Limn } from '@telepat/limn';
39
+ import { resolveFamilyFromReplicateModelId } from '../images/limnModelCatalog.js';
37
40
  import fs from 'node:fs/promises';
38
41
  import crypto from 'node:crypto';
39
42
  import path from 'node:path';
40
43
  import { ensureDir, writeJson } from '../media/files.js';
41
44
  import { probeMediaDurationSeconds } from '../media/ffmpeg.js';
45
+ import { env } from '../config/env.js';
42
46
  import {
43
47
  ensureProject,
44
48
  readProjectConfig,
@@ -229,6 +233,15 @@ export async function regenerateProjectAsset(projectName, target, options = {})
229
233
  const projectConfig = await deps.readProjectConfig(project);
230
234
  const modelSelections = resolveProjectModelSelections(projectConfig.models);
231
235
  const modelOptions = resolveProjectModelOptions(projectConfig.modelOptions, modelSelections);
236
+ const rawT2IModel = modelSelections[MODEL_CATEGORIES.textToImage];
237
+ const limnFamily = resolveFamilyFromReplicateModelId(rawT2IModel) ?? rawT2IModel;
238
+ const limnImageDryRun = !env.replicateApiToken;
239
+ const limn = limnImageDryRun ? null : new Limn({
240
+ openrouterApiKey: env.openRouterApiKey || undefined,
241
+ replicateApiKey: env.replicateApiToken,
242
+ openrouterModel: modelSelections[MODEL_CATEGORIES.textToText]
243
+ });
244
+ const t2iReplicateModel = modelSelections.textToImageReplicateModel || undefined;
232
245
  const projectDir = deps.getProjectDir(project);
233
246
  const runState = await deps.readProjectRunState(project);
234
247
 
@@ -452,21 +465,21 @@ export async function regenerateProjectAsset(projectName, target, options = {})
452
465
  }
453
466
 
454
467
  if (targetType === 'keyframe') {
455
- const keyframeSize = resolveKeyframeSize(projectConfig);
456
- const keyframeUrl = await deps.generateKeyframe(
468
+ const keyframeResult = await deps.generateKeyframe(
457
469
  artifacts.shots[index],
458
470
  artifacts.tone || 'neutral',
459
471
  projectConfig.aspectRatio,
460
472
  index,
461
473
  traceBase,
462
- keyframeSize,
463
474
  {
464
- modelId: modelSelections[MODEL_CATEGORIES.textToImage],
475
+ limn,
476
+ family: limnFamily,
477
+ replicateModel: t2iReplicateModel,
465
478
  modelOptions: modelOptions[MODEL_CATEGORIES.textToImage]
466
479
  }
467
480
  );
468
- artifacts.keyframeUrls[index] = keyframeUrl;
469
- artifacts.keyframePaths[index] = await deps.persistKeyframe(projectDir, keyframeUrl, index);
481
+ artifacts.keyframeUrls[index] = keyframeResult.outputUrl || keyframeResult.modelSlug;
482
+ artifacts.keyframePaths[index] = await deps.persistKeyframe(projectDir, keyframeResult, index);
470
483
 
471
484
  const affectedSegmentIndexes = collectAffectedSegmentIndexes([index], artifacts.shots.length);
472
485
  const maxSegmentIndex = Math.max(0, artifacts.shots.length - 1);
@@ -627,6 +640,15 @@ export async function runPipeline(jobId, options = {}) {
627
640
  const projectConfig = await deps.readProjectConfig(project);
628
641
  const modelSelections = resolveProjectModelSelections(projectConfig.models);
629
642
  const modelOptions = resolveProjectModelOptions(projectConfig.modelOptions, modelSelections);
643
+ const rawT2IModel = modelSelections[MODEL_CATEGORIES.textToImage];
644
+ const limnFamily = resolveFamilyFromReplicateModelId(rawT2IModel) ?? rawT2IModel;
645
+ const limnImageDryRun = !env.replicateApiToken;
646
+ const limn = limnImageDryRun ? null : new Limn({
647
+ openrouterApiKey: env.openRouterApiKey || undefined,
648
+ replicateApiKey: env.replicateApiToken,
649
+ openrouterModel: modelSelections[MODEL_CATEGORIES.textToText]
650
+ });
651
+ const t2iReplicateModel = modelSelections.textToImageReplicateModel || undefined;
630
652
  const projectDir = deps.getProjectDir(project);
631
653
  const targetDurationSec = resolveTargetDurationSec(projectConfig);
632
654
  const plannedShots = resolveShotCount(projectConfig);
@@ -899,6 +921,10 @@ export async function runPipeline(jobId, options = {}) {
899
921
  (category) => JSON.stringify(previousModelOptions[category]) !== JSON.stringify(modelOptions[category])
900
922
  );
901
923
 
924
+ const modelSelectionsChanged = MODEL_SELECTION_KEYS.some(
925
+ (key) => modelSelections[key] !== previousModelSelections[key]
926
+ );
927
+
902
928
  const resetFromScript = () => {
903
929
  seedSteps[JobStep.SCRIPT] = false;
904
930
  seedSteps[JobStep.VOICE] = false;
@@ -975,7 +1001,7 @@ export async function runPipeline(jobId, options = {}) {
975
1001
  resetFromScript();
976
1002
  }
977
1003
 
978
- if (JSON.stringify(previousModelSelections) !== JSON.stringify(modelSelections)) {
1004
+ if (modelSelectionsChanged) {
979
1005
  resetFromScript();
980
1006
  }
981
1007
 
@@ -1214,20 +1240,21 @@ export async function runPipeline(jobId, options = {}) {
1214
1240
  if (shotIndex < 0 || shotIndex >= currentJob.artifacts.shots.length) {
1215
1241
  continue;
1216
1242
  }
1217
- const keyframeUrl = await deps.generateKeyframe(
1243
+ const keyframeResult = await deps.generateKeyframe(
1218
1244
  currentJob.artifacts.shots[shotIndex],
1219
1245
  currentJob.artifacts.tone || 'neutral',
1220
1246
  projectConfig.aspectRatio,
1221
1247
  shotIndex,
1222
1248
  traceBase,
1223
- keyframeSize,
1224
1249
  {
1225
- modelId: modelSelections[MODEL_CATEGORIES.textToImage],
1250
+ limn,
1251
+ family: limnFamily,
1252
+ replicateModel: t2iReplicateModel,
1226
1253
  modelOptions: modelOptions[MODEL_CATEGORIES.textToImage]
1227
1254
  }
1228
1255
  );
1229
- keyframeUrls[shotIndex] = keyframeUrl;
1230
- keyframePaths[shotIndex] = await deps.persistKeyframe(projectDir, keyframeUrl, shotIndex);
1256
+ keyframeUrls[shotIndex] = keyframeResult.outputUrl || keyframeResult.modelSlug;
1257
+ keyframePaths[shotIndex] = await deps.persistKeyframe(projectDir, keyframeResult, shotIndex);
1231
1258
  }
1232
1259
 
1233
1260
  const totalShots = currentJob.artifacts.shots.length;
@@ -1356,18 +1383,20 @@ export async function runPipeline(jobId, options = {}) {
1356
1383
  }
1357
1384
 
1358
1385
  if (!hasUrl) {
1359
- keyframeUrls[shotIndex] = await deps.generateKeyframe(
1386
+ const keyframeResult = await deps.generateKeyframe(
1360
1387
  currentJob.artifacts.shots[shotIndex],
1361
1388
  currentJob.artifacts.tone || 'neutral',
1362
1389
  projectConfig.aspectRatio,
1363
1390
  shotIndex,
1364
1391
  traceBase,
1365
- keyframeSize,
1366
1392
  {
1367
- modelId: modelSelections[MODEL_CATEGORIES.textToImage],
1393
+ limn,
1394
+ family: limnFamily,
1395
+ replicateModel: t2iReplicateModel,
1368
1396
  modelOptions: modelOptions[MODEL_CATEGORIES.textToImage]
1369
1397
  }
1370
1398
  );
1399
+ keyframeUrls[shotIndex] = keyframeResult.outputUrl || keyframeResult.modelSlug;
1371
1400
  }
1372
1401
 
1373
1402
  keyframePaths[shotIndex] = await deps.persistKeyframe(projectDir, keyframeUrls[shotIndex], shotIndex);
@@ -1,68 +1,94 @@
1
- import { ASPECT_RATIO_PRESETS, MODEL_CATEGORIES, resolveModelForCategory } from '../config/models.js';
2
- import { runModel, extractOutputUri } from '../providers/predictions.js';
3
1
  import path from 'node:path';
4
- import { downloadToFile, ensureDir } from '../media/files.js';
5
- import { resolveTextToImageAdapter } from './textToImageAdapters.js';
2
+ import { writeFile } from 'node:fs/promises';
3
+ import { ensureDir } from '../media/files.js';
6
4
 
7
5
  export async function generateKeyframe(
8
6
  promptText,
9
7
  tone,
10
8
  aspectRatio = '9:16',
11
9
  index = 0,
12
- trace = null,
13
- sizeOverride = null,
10
+ _trace,
14
11
  options = {}
15
12
  ) {
16
13
  const deps = options.deps || {};
17
- const runModelFn = deps.runModel || runModel;
18
- const extractOutputUriFn = deps.extractOutputUri || extractOutputUri;
14
+ const limn = options.limn || deps.limn || null;
15
+ const family = options.family;
16
+ const replicateModel = options.replicateModel;
17
+ const modelOptions = options.modelOptions ?? {};
19
18
 
20
- const preset = ASPECT_RATIO_PRESETS[aspectRatio] || ASPECT_RATIO_PRESETS['9:16'];
21
- const width = sizeOverride?.width || preset.keyframeWidth || ASPECT_RATIO_PRESETS['9:16'].keyframeWidth;
22
- const height = sizeOverride?.height || preset.keyframeHeight || ASPECT_RATIO_PRESETS['9:16'].keyframeHeight;
23
- const modelId = options.modelId || resolveModelForCategory(MODEL_CATEGORIES.textToImage);
24
- const modelOptions = options.modelOptions;
25
- const adapter = resolveTextToImageAdapter(modelId);
19
+ if (!family) {
20
+ throw new Error('Limn model family is required for keyframe generation');
21
+ }
22
+
23
+ if (!limn) {
24
+ throw new Error('Limn instance is required for keyframe generation');
25
+ }
26
26
 
27
- const prediction = await runModelFn({
28
- model: modelId,
29
- input: adapter.buildInput({ promptText, tone, index, aspectRatio, width, height, modelOptions }),
30
- trace: trace ? { ...trace, step: 'keyframe', index } : null
27
+ const result = await limn.generate(promptText, family, {
28
+ aspectRatio,
29
+ ...(replicateModel ? { replicateModel } : {}),
30
+ options: modelOptions
31
31
  });
32
32
 
33
- const imageUrl = extractOutputUriFn(prediction.output);
34
- if (!imageUrl) {
33
+ if (!result || !result.image) {
35
34
  throw new Error(`Missing keyframe output for shot ${index + 1}`);
36
35
  }
37
36
 
38
- return imageUrl;
37
+ return {
38
+ buffer: result.image,
39
+ outputUrl: result.outputUrl || '',
40
+ mimeType: result.mimeType || 'image/png',
41
+ modelSlug: result.modelSlug || family
42
+ };
39
43
  }
40
44
 
41
- export async function generateKeyframes(shots, tone, aspectRatio = '9:16', trace = null, options = {}) {
42
- const urls = [];
45
+ export async function generateKeyframes(shots, tone, aspectRatio = '9:16', _trace, options = {}) {
46
+ const results = [];
43
47
  for (let i = 0; i < shots.length; i += 1) {
44
- const imageUrl = await generateKeyframe(shots[i], tone, aspectRatio, i, trace, null, options);
45
- urls.push(imageUrl);
48
+ const result = await generateKeyframe(shots[i], tone, aspectRatio, i, _trace, options);
49
+ results.push(result);
46
50
  }
47
- return urls;
51
+ return results;
52
+ }
53
+
54
+ function mimeTypeToExtension(mimeType) {
55
+ if (mimeType === 'image/jpeg') return 'jpg';
56
+ if (mimeType === 'image/webp') return 'webp';
57
+ return 'png';
48
58
  }
49
59
 
50
- export async function persistKeyframe(projectDir, keyframeUrl, index, options = {}) {
60
+ export async function persistKeyframe(projectDir, keyframeResult, index, options = {}) {
51
61
  const deps = options.deps || {};
52
62
  const ensureDirFn = deps.ensureDir || ensureDir;
53
- const downloadToFileFn = deps.downloadToFile || downloadToFile;
63
+ const writeFileFn = deps.writeFile || writeFile;
54
64
 
55
65
  const keyframesDir = path.join(projectDir, 'assets', 'keyframes');
56
66
  await ensureDirFn(keyframesDir);
57
- const keyframePath = path.join(keyframesDir, `keyframe_${String(index + 1).padStart(2, '0')}.png`);
58
- await downloadToFileFn(keyframeUrl, keyframePath);
59
- return keyframePath;
67
+
68
+ // Handle Limn buffer result
69
+ if (keyframeResult && typeof keyframeResult === 'object' && keyframeResult.buffer) {
70
+ const ext = mimeTypeToExtension(keyframeResult.mimeType || 'image/png');
71
+ const keyframePath = path.join(keyframesDir, `keyframe_${String(index + 1).padStart(2, '0')}.${ext}`);
72
+ await writeFileFn(keyframePath, keyframeResult.buffer);
73
+ return keyframePath;
74
+ }
75
+
76
+ // Handle legacy URL string
77
+ if (typeof keyframeResult === 'string') {
78
+ const depsDownload = deps.downloadToFile;
79
+ const downloadToFileFn = depsDownload || (await import('../media/files.js')).downloadToFile;
80
+ const keyframePath = path.join(keyframesDir, `keyframe_${String(index + 1).padStart(2, '0')}.png`);
81
+ await downloadToFileFn(keyframeResult, keyframePath);
82
+ return keyframePath;
83
+ }
84
+
85
+ throw new Error('Invalid keyframe result: expected buffer object or URL string');
60
86
  }
61
87
 
62
- export async function persistKeyframes(projectDir, keyframeUrls, options = {}) {
88
+ export async function persistKeyframes(projectDir, keyframeResults, options = {}) {
63
89
  const keyframePaths = [];
64
- for (let i = 0; i < keyframeUrls.length; i += 1) {
65
- const keyframePath = await persistKeyframe(projectDir, keyframeUrls[i], i, options);
90
+ for (let i = 0; i < keyframeResults.length; i += 1) {
91
+ const keyframePath = await persistKeyframe(projectDir, keyframeResults[i], i, options);
66
92
  keyframePaths.push(keyframePath);
67
93
  }
68
94
 
@@ -5,11 +5,13 @@ import { ensureDir, writeJson } from '../media/files.js';
5
5
  import {
6
6
  DEFAULT_MODEL_SELECTIONS,
7
7
  DEFAULT_VIDEO_CONFIG,
8
+ MODEL_CATEGORIES,
8
9
  MODEL_OPTION_KEYS,
9
10
  MODEL_SELECTION_KEYS,
10
11
  SUPPORTED_MODEL_IDS,
11
12
  resolveModelInputOptionsForCategory,
12
- resolveProjectModelOptions
13
+ resolveProjectModelOptions,
14
+ resolveProjectModelSelections
13
15
  } from '../config/models.js';
14
16
 
15
17
  export const SUPPORTED_ASPECT_RATIOS = ['1:1', '16:9', '9:16'];
@@ -358,6 +360,11 @@ function validateProjectModelOptions(modelOptions, modelSelections) {
358
360
  throw new Error(`Invalid project config: modelOptions.${category} must be an object`);
359
361
  }
360
362
 
363
+ // T2I model options are validated by Limn at generation time
364
+ if (category === MODEL_CATEGORIES.textToImage) {
365
+ continue;
366
+ }
367
+
361
368
  const inputOptions = resolveModelInputOptionsForCategory(category, modelSelections);
362
369
  const allowedOptions = new Set(inputOptions.userConfigurable);
363
370
  const fields = inputOptions.fields || {};
@@ -400,12 +407,18 @@ export function normalizeProjectConfig(config) {
400
407
  }
401
408
  }
402
409
 
410
+ // Reject unknown model selection keys before normalization
411
+ if (nextConfig.models && typeof nextConfig.models === 'object' && !Array.isArray(nextConfig.models)) {
412
+ for (const key of Object.keys(nextConfig.models)) {
413
+ if (!MODEL_SELECTION_KEYS.includes(key) && key !== 'textToImageReplicateModel') {
414
+ throw new Error(`Invalid project config: models.${key} is not a supported model category`);
415
+ }
416
+ }
417
+ }
418
+
403
419
  const mergedModels = nextConfig.models === undefined
404
420
  ? { ...DEFAULT_MODEL_SELECTIONS }
405
- : {
406
- ...DEFAULT_MODEL_SELECTIONS,
407
- ...(nextConfig.models || {})
408
- };
421
+ : resolveProjectModelSelections(nextConfig.models);
409
422
  const mergedModelOptions = mergeProjectModelOptions(nextConfig.modelOptions, mergedModels);
410
423
 
411
424
  return {
@@ -1,34 +0,0 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="18 26 197 48" width="394" height="96">
2
- <defs>
3
- <linearGradient id="flameGrad" x1="0%" y1="100%" x2="0%" y2="0%">
4
- <stop offset="0%" stop-color="#ff3d00"/>
5
- <stop offset="60%" stop-color="#ff7a00"/>
6
- <stop offset="100%" stop-color="#ffcc00"/>
7
- </linearGradient>
8
-
9
- <filter id="flameGlow" x="-40%" y="-40%" width="180%" height="180%">
10
- <feGaussianBlur stdDeviation="1.5" result="blur"/>
11
- <feMerge>
12
- <feMergeNode in="blur"/>
13
- <feMergeNode in="SourceGraphic"/>
14
- </feMerge>
15
- </filter>
16
- </defs>
17
-
18
- <!-- Transparent background -->
19
-
20
- <!-- Minimal flame — two triangles -->
21
- <polygon points="44,31 58,68 30,68" fill="#ff4e00"/>
22
- <polygon points="44,44 53,68 35,68" fill="#ff7a00"/>
23
-
24
- <!-- Wordmark — white text, orange "fire" -->
25
- <text
26
- x="66" y="68"
27
- font-family="'Helvetica Neue', Helvetica, Arial, sans-serif"
28
- font-size="52"
29
- font-weight="200"
30
- letter-spacing="-1"
31
- fill="#ffffff"
32
- >tale<tspan font-weight="600" fill="#ff4e00">fire</tspan></text>
33
-
34
- </svg>
@@ -1,36 +0,0 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="18 26 197 48" width="394" height="96">
2
- <defs>
3
- <linearGradient id="flameGrad" x1="0%" y1="100%" x2="0%" y2="0%">
4
- <stop offset="0%" stop-color="#ff3d00"/>
5
- <stop offset="60%" stop-color="#ff7a00"/>
6
- <stop offset="100%" stop-color="#ffcc00"/>
7
- </linearGradient>
8
-
9
- <filter id="flameGlow" x="-40%" y="-40%" width="180%" height="180%">
10
- <feGaussianBlur stdDeviation="1.5" result="blur"/>
11
- <feMerge>
12
- <feMergeNode in="blur"/>
13
- <feMergeNode in="SourceGraphic"/>
14
- </feMerge>
15
- </filter>
16
- </defs>
17
-
18
- <!-- Transparent background -->
19
-
20
- <!-- Minimal flame — two triangles, clean and simple -->
21
- <!-- Main triangle -->
22
- <polygon points="44,31 58,68 30,68" fill="#ff4e00"/>
23
- <!-- Small inner triangle cutout to suggest flame shape -->
24
- <polygon points="44,44 53,68 35,68" fill="#ff7a00"/>
25
-
26
- <!-- Wordmark — original letter spacing -->
27
- <text
28
- x="66" y="68"
29
- font-family="'Helvetica Neue', Helvetica, Arial, sans-serif"
30
- font-size="52"
31
- font-weight="200"
32
- letter-spacing="-1"
33
- fill="#1a1a1a"
34
- >tale<tspan font-weight="600" fill="#ff4e00">fire</tspan></text>
35
-
36
- </svg>
@@ -1,78 +0,0 @@
1
- {
2
- "modelId": "black-forest-labs/flux-2-pro",
3
- "provider": "replicate",
4
- "displayName": "FLUX 2 Pro",
5
- "category": "image-generation",
6
- "pricingSourceUrl": "https://replicate.com/black-forest-labs/flux-2-pro",
7
- "pricingNotes": "Replicate lists multi-property pricing for run and input/output megapixels; this model currently uses best-effort cost fallback when exact rule mapping is unavailable.",
8
- "pricing": {
9
- "usdPerSecond": null,
10
- "usdPer1kInputTokens": null,
11
- "usdPer1kOutputTokens": null
12
- },
13
- "inputOptions": {
14
- "userConfigurable": [
15
- "safety_tolerance",
16
- "seed",
17
- "output_format",
18
- "output_quality"
19
- ],
20
- "pipelineManaged": [
21
- "prompt",
22
- "aspect_ratio",
23
- "width",
24
- "height"
25
- ],
26
- "fields": {
27
- "safety_tolerance": {
28
- "type": "integer",
29
- "default": 2,
30
- "minimum": 1,
31
- "maximum": 5
32
- },
33
- "seed": {
34
- "type": "integer",
35
- "nullable": true
36
- },
37
- "output_format": {
38
- "type": "string",
39
- "default": "webp",
40
- "enum": [
41
- "webp",
42
- "png",
43
- "jpg",
44
- "jpeg"
45
- ]
46
- },
47
- "output_quality": {
48
- "type": "integer",
49
- "default": 80,
50
- "minimum": 0,
51
- "maximum": 100
52
- },
53
- "prompt": {
54
- "type": "string",
55
- "required": true
56
- },
57
- "aspect_ratio": {
58
- "type": "string",
59
- "required": true,
60
- "enum": [
61
- "custom"
62
- ]
63
- },
64
- "width": {
65
- "type": "integer",
66
- "required": true,
67
- "minimum": 256,
68
- "maximum": 2048
69
- },
70
- "height": {
71
- "type": "integer",
72
- "required": true,
73
- "minimum": 256,
74
- "maximum": 2048
75
- }
76
- }
77
- }
78
- }