@telepat/rilo 0.1.0

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.
Files changed (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +209 -0
  3. package/index.js +1 -0
  4. package/models/black-forest-labs__flux-2-pro.json +78 -0
  5. package/models/black-forest-labs__flux-schnell.json +95 -0
  6. package/models/bytedance__seedream-4.json +71 -0
  7. package/models/deepseek-ai__deepseek-v3.json +61 -0
  8. package/models/google__nano-banana-pro.json +92 -0
  9. package/models/google__veo-3.1-fast.json +93 -0
  10. package/models/google__veo-3.1.json +93 -0
  11. package/models/jaaari__kokoro-82m.json +86 -0
  12. package/models/kwaivgi__kling-v3-video.json +101 -0
  13. package/models/minimax__speech-02-turbo.json +141 -0
  14. package/models/pixverse__pixverse-v5.6.json +113 -0
  15. package/models/prunaai__z-image-turbo.json +107 -0
  16. package/models/resemble-ai__chatterbox-turbo.json +102 -0
  17. package/models/wan-video__wan-2.2-i2v-fast.json +139 -0
  18. package/package.json +67 -0
  19. package/src/api/firebaseFunction.js +46 -0
  20. package/src/api/middleware/auth.js +70 -0
  21. package/src/api/openapi/generateOpenApi.js +21 -0
  22. package/src/api/openapi/spec.js +831 -0
  23. package/src/api/routes/jobs.js +45 -0
  24. package/src/api/routes/projectAssets.js +63 -0
  25. package/src/api/routes/projects.js +647 -0
  26. package/src/api/routes/webhooks.js +13 -0
  27. package/src/api/server.js +88 -0
  28. package/src/backends/firebaseClient.js +57 -0
  29. package/src/backends/outputBackend.js +186 -0
  30. package/src/backends/projectMetadataBackend.js +550 -0
  31. package/src/cli/commands/openHome.js +70 -0
  32. package/src/cli/commands/settingsFlow.js +196 -0
  33. package/src/cli/index.js +192 -0
  34. package/src/config/env.js +158 -0
  35. package/src/config/keystore.js +175 -0
  36. package/src/config/models.js +281 -0
  37. package/src/config/settingsSchema.js +214 -0
  38. package/src/media/ffmpeg.js +144 -0
  39. package/src/media/files.js +77 -0
  40. package/src/media/subtitles.js +444 -0
  41. package/src/observability/apiTrace.js +17 -0
  42. package/src/observability/logger.js +7 -0
  43. package/src/observability/metrics.js +10 -0
  44. package/src/pipeline/inputSanitizer.js +6 -0
  45. package/src/pipeline/orchestrator.js +1669 -0
  46. package/src/policy/contentGuardrails.js +30 -0
  47. package/src/providers/predictions.js +188 -0
  48. package/src/providers/replicateClient.js +12 -0
  49. package/src/steps/alignSubtitles.js +156 -0
  50. package/src/steps/burnInSubtitles.js +22 -0
  51. package/src/steps/composeFinalVideo.js +57 -0
  52. package/src/steps/generateKeyframes.js +70 -0
  53. package/src/steps/generateVideoSegments.js +95 -0
  54. package/src/steps/generateVoiceover.js +128 -0
  55. package/src/steps/imageToVideoAdapters.js +100 -0
  56. package/src/steps/script.js +177 -0
  57. package/src/steps/textToImageAdapters.js +87 -0
  58. package/src/store/assetStore.js +5 -0
  59. package/src/store/jobStore.js +102 -0
  60. package/src/store/projectAnalyticsStore.js +625 -0
  61. package/src/store/projectStore.js +684 -0
  62. package/src/store/settingsStore.js +155 -0
  63. package/src/store/staleAssetStore.js +63 -0
  64. package/src/types/job.js +28 -0
  65. package/src/types/media.js +28 -0
  66. package/src/worker/processor.js +24 -0
@@ -0,0 +1,684 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { env } from '../config/env.js';
4
+ import { ensureDir, writeJson } from '../media/files.js';
5
+ import {
6
+ DEFAULT_MODEL_SELECTIONS,
7
+ DEFAULT_VIDEO_CONFIG,
8
+ MODEL_OPTION_KEYS,
9
+ MODEL_SELECTION_KEYS,
10
+ SUPPORTED_MODEL_IDS,
11
+ resolveModelInputOptionsForCategory,
12
+ resolveProjectModelOptions
13
+ } from '../config/models.js';
14
+
15
+ export const SUPPORTED_ASPECT_RATIOS = ['1:1', '16:9', '9:16'];
16
+ export const SUPPORTED_FINAL_DURATION_MODES = ['match_audio', 'match_visual'];
17
+ export const SUPPORTED_SUBTITLE_POSITIONS = ['top', 'center', 'bottom'];
18
+ export const SUPPORTED_SUBTITLE_HIGHLIGHT_MODES = ['spoken_upcoming', 'current_only'];
19
+ export const SUPPORTED_SUBTITLE_TEMPLATE_IDS = [
20
+ 'custom',
21
+ 'social_center_punch',
22
+ 'social_center_clean',
23
+ 'social_center_story'
24
+ ];
25
+ const PROJECT_NAME_PATTERN = /^[a-z0-9](?:[a-z0-9_-]{0,62}[a-z0-9])?$/;
26
+ export const PROJECT_CONFIG_SCHEMA_VERSION = 1;
27
+
28
+ export const DEFAULT_SUBTITLE_OPTIONS = {
29
+ enabled: false,
30
+ templateId: 'custom',
31
+ position: 'center',
32
+ fontName: 'Poppins',
33
+ fontSize: 100,
34
+ bold: true,
35
+ italic: false,
36
+ makeUppercase: false,
37
+ primaryColor: '#ffffff',
38
+ activeColor: '#ffe066',
39
+ outlineColor: '#111111',
40
+ backgroundEnabled: false,
41
+ backgroundColor: '#000000',
42
+ backgroundOpacity: 0.45,
43
+ outline: 3,
44
+ shadow: 0,
45
+ marginV: 70,
46
+ maxWordsPerLine: 7,
47
+ maxLines: 2,
48
+ highlightMode: 'spoken_upcoming'
49
+ };
50
+
51
+ export const DEFAULT_PROJECT_CONFIG = {
52
+ schemaVersion: PROJECT_CONFIG_SCHEMA_VERSION,
53
+ aspectRatio: '9:16',
54
+ targetDurationSec: DEFAULT_VIDEO_CONFIG.durationSec,
55
+ finalDurationMode: 'match_audio',
56
+ subtitleOptions: {
57
+ ...DEFAULT_SUBTITLE_OPTIONS
58
+ },
59
+ models: {
60
+ ...DEFAULT_MODEL_SELECTIONS
61
+ },
62
+ modelOptions: resolveProjectModelOptions({}, DEFAULT_MODEL_SELECTIONS)
63
+ };
64
+
65
+ function isPlainObject(value) {
66
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
67
+ }
68
+
69
+ function normalizeOptionValue(value) {
70
+ if (typeof value === 'string') {
71
+ return value.trim();
72
+ }
73
+ return value;
74
+ }
75
+
76
+ function normalizeSubtitleOptions(candidate) {
77
+ if (!isPlainObject(candidate)) {
78
+ return {
79
+ ...DEFAULT_SUBTITLE_OPTIONS
80
+ };
81
+ }
82
+
83
+ const normalized = {
84
+ ...DEFAULT_SUBTITLE_OPTIONS,
85
+ ...candidate
86
+ };
87
+
88
+ // Preserve compatibility with older saved configs that used removed template ids.
89
+ if (normalized.templateId === 'social_top_minimal') {
90
+ normalized.templateId = 'social_center_clean';
91
+ } else if (normalized.templateId === 'social_bottom_classic') {
92
+ normalized.templateId = 'social_center_story';
93
+ }
94
+
95
+ for (const key of ['templateId', 'fontName', 'primaryColor', 'activeColor', 'outlineColor', 'backgroundColor']) {
96
+ if (typeof normalized[key] === 'string') {
97
+ normalized[key] = normalized[key].trim();
98
+ }
99
+ }
100
+
101
+ return normalized;
102
+ }
103
+
104
+ function validateHexColor(value, fieldPath) {
105
+ if (typeof value !== 'string' || !/^#[0-9a-f]{6}$/i.test(value.trim())) {
106
+ throw new Error(`Invalid project config: ${fieldPath} must be a hex color like #RRGGBB`);
107
+ }
108
+ }
109
+
110
+ function validateSubtitleOptions(subtitleOptions) {
111
+ if (!isPlainObject(subtitleOptions)) {
112
+ throw new Error('Invalid project config: subtitleOptions must be an object');
113
+ }
114
+
115
+ if (typeof subtitleOptions.enabled !== 'boolean') {
116
+ throw new Error('Invalid project config: subtitleOptions.enabled must be a boolean');
117
+ }
118
+
119
+ if (!SUPPORTED_SUBTITLE_TEMPLATE_IDS.includes(subtitleOptions.templateId)) {
120
+ throw new Error(
121
+ `Invalid project config: subtitleOptions.templateId must be one of ${SUPPORTED_SUBTITLE_TEMPLATE_IDS.join(', ')}`
122
+ );
123
+ }
124
+
125
+ if (!SUPPORTED_SUBTITLE_POSITIONS.includes(subtitleOptions.position)) {
126
+ throw new Error(
127
+ `Invalid project config: subtitleOptions.position must be one of ${SUPPORTED_SUBTITLE_POSITIONS.join(', ')}`
128
+ );
129
+ }
130
+
131
+ if (!SUPPORTED_SUBTITLE_HIGHLIGHT_MODES.includes(subtitleOptions.highlightMode)) {
132
+ throw new Error(
133
+ `Invalid project config: subtitleOptions.highlightMode must be one of ${SUPPORTED_SUBTITLE_HIGHLIGHT_MODES.join(', ')}`
134
+ );
135
+ }
136
+
137
+ if (typeof subtitleOptions.fontName !== 'string' || !subtitleOptions.fontName.trim()) {
138
+ throw new Error('Invalid project config: subtitleOptions.fontName must be a non-empty string');
139
+ }
140
+
141
+ if (typeof subtitleOptions.bold !== 'boolean') {
142
+ throw new Error('Invalid project config: subtitleOptions.bold must be a boolean');
143
+ }
144
+
145
+ if (typeof subtitleOptions.italic !== 'boolean') {
146
+ throw new Error('Invalid project config: subtitleOptions.italic must be a boolean');
147
+ }
148
+
149
+ if (typeof subtitleOptions.makeUppercase !== 'boolean') {
150
+ throw new Error('Invalid project config: subtitleOptions.makeUppercase must be a boolean');
151
+ }
152
+
153
+ if (!Number.isInteger(subtitleOptions.fontSize) || subtitleOptions.fontSize < 16 || subtitleOptions.fontSize > 120) {
154
+ throw new Error('Invalid project config: subtitleOptions.fontSize must be an integer between 16 and 120');
155
+ }
156
+
157
+ validateHexColor(subtitleOptions.primaryColor, 'subtitleOptions.primaryColor');
158
+ validateHexColor(subtitleOptions.activeColor, 'subtitleOptions.activeColor');
159
+ validateHexColor(subtitleOptions.outlineColor, 'subtitleOptions.outlineColor');
160
+
161
+ if (typeof subtitleOptions.backgroundEnabled !== 'boolean') {
162
+ throw new Error('Invalid project config: subtitleOptions.backgroundEnabled must be a boolean');
163
+ }
164
+
165
+ validateHexColor(subtitleOptions.backgroundColor, 'subtitleOptions.backgroundColor');
166
+
167
+ if (typeof subtitleOptions.backgroundOpacity !== 'number' || !Number.isFinite(subtitleOptions.backgroundOpacity)) {
168
+ throw new Error('Invalid project config: subtitleOptions.backgroundOpacity must be a number between 0 and 0.85');
169
+ }
170
+
171
+ if (subtitleOptions.backgroundOpacity < 0 || subtitleOptions.backgroundOpacity > 0.85) {
172
+ throw new Error('Invalid project config: subtitleOptions.backgroundOpacity must be between 0 and 0.85');
173
+ }
174
+
175
+ if (!Number.isInteger(subtitleOptions.outline) || subtitleOptions.outline < 0 || subtitleOptions.outline > 12) {
176
+ throw new Error('Invalid project config: subtitleOptions.outline must be an integer between 0 and 12');
177
+ }
178
+
179
+ if (!Number.isInteger(subtitleOptions.shadow) || subtitleOptions.shadow < 0 || subtitleOptions.shadow > 12) {
180
+ throw new Error('Invalid project config: subtitleOptions.shadow must be an integer between 0 and 12');
181
+ }
182
+
183
+ if (!Number.isInteger(subtitleOptions.marginV) || subtitleOptions.marginV < 0 || subtitleOptions.marginV > 400) {
184
+ throw new Error('Invalid project config: subtitleOptions.marginV must be an integer between 0 and 400');
185
+ }
186
+
187
+ if (
188
+ !Number.isInteger(subtitleOptions.maxWordsPerLine)
189
+ || subtitleOptions.maxWordsPerLine < 1
190
+ || subtitleOptions.maxWordsPerLine > 20
191
+ ) {
192
+ throw new Error('Invalid project config: subtitleOptions.maxWordsPerLine must be an integer between 1 and 20');
193
+ }
194
+
195
+ if (!Number.isInteger(subtitleOptions.maxLines) || subtitleOptions.maxLines < 1 || subtitleOptions.maxLines > 3) {
196
+ throw new Error('Invalid project config: subtitleOptions.maxLines must be an integer between 1 and 3');
197
+ }
198
+ }
199
+
200
+ function normalizeCategoryModelOptions(category, categoryOptions, modelSelections) {
201
+ if (!isPlainObject(categoryOptions)) {
202
+ return categoryOptions;
203
+ }
204
+
205
+ const normalized = {};
206
+ const inputOptions = resolveModelInputOptionsForCategory(category, modelSelections);
207
+ const allowedOptions = new Set(inputOptions.userConfigurable);
208
+
209
+ for (const [optionKey, optionValue] of Object.entries(categoryOptions)) {
210
+ if (!allowedOptions.has(optionKey)) {
211
+ normalized[optionKey] = normalizeOptionValue(optionValue);
212
+ continue;
213
+ }
214
+
215
+ normalized[optionKey] = normalizeOptionValue(optionValue);
216
+ }
217
+
218
+ return normalized;
219
+ }
220
+
221
+ function mergeProjectModelOptions(rawModelOptions, modelSelections) {
222
+ const defaults = resolveProjectModelOptions({}, modelSelections);
223
+
224
+ if (rawModelOptions === undefined) {
225
+ return defaults;
226
+ }
227
+
228
+ if (!isPlainObject(rawModelOptions)) {
229
+ return rawModelOptions;
230
+ }
231
+
232
+ const merged = {
233
+ ...defaults
234
+ };
235
+
236
+ for (const category of MODEL_OPTION_KEYS) {
237
+ if (!Object.prototype.hasOwnProperty.call(rawModelOptions, category)) {
238
+ continue;
239
+ }
240
+
241
+ const candidate = rawModelOptions[category];
242
+ if (!isPlainObject(candidate)) {
243
+ merged[category] = candidate;
244
+ continue;
245
+ }
246
+
247
+ merged[category] = normalizeCategoryModelOptions(
248
+ category,
249
+ {
250
+ ...defaults[category],
251
+ ...candidate
252
+ },
253
+ modelSelections
254
+ );
255
+ }
256
+
257
+ for (const key of Object.keys(rawModelOptions)) {
258
+ if (!MODEL_OPTION_KEYS.includes(key)) {
259
+ merged[key] = rawModelOptions[key];
260
+ }
261
+ }
262
+
263
+ return merged;
264
+ }
265
+
266
+ function validateFieldConstraints({ category, optionKey, optionValue, field }) {
267
+ if (optionValue === null) {
268
+ if (field.nullable) {
269
+ return;
270
+ }
271
+ throw new Error(`Invalid project config: modelOptions.${category}.${optionKey} cannot be null`);
272
+ }
273
+
274
+ if (field.type === 'boolean') {
275
+ if (typeof optionValue !== 'boolean') {
276
+ throw new Error(`Invalid project config: modelOptions.${category}.${optionKey} must be a boolean`);
277
+ }
278
+ return;
279
+ }
280
+
281
+ if (field.type === 'integer') {
282
+ if (!Number.isInteger(optionValue)) {
283
+ throw new Error(`Invalid project config: modelOptions.${category}.${optionKey} must be an integer`);
284
+ }
285
+ if (Number.isFinite(field.minimum) && optionValue < field.minimum) {
286
+ throw new Error(
287
+ `Invalid project config: modelOptions.${category}.${optionKey} must be >= ${field.minimum}`
288
+ );
289
+ }
290
+ if (Number.isFinite(field.maximum) && optionValue > field.maximum) {
291
+ throw new Error(
292
+ `Invalid project config: modelOptions.${category}.${optionKey} must be <= ${field.maximum}`
293
+ );
294
+ }
295
+ return;
296
+ }
297
+
298
+ if (field.type === 'number') {
299
+ if (typeof optionValue !== 'number' || !Number.isFinite(optionValue)) {
300
+ throw new Error(`Invalid project config: modelOptions.${category}.${optionKey} must be a number`);
301
+ }
302
+ if (Number.isFinite(field.minimum) && optionValue < field.minimum) {
303
+ throw new Error(
304
+ `Invalid project config: modelOptions.${category}.${optionKey} must be >= ${field.minimum}`
305
+ );
306
+ }
307
+ if (Number.isFinite(field.maximum) && optionValue > field.maximum) {
308
+ throw new Error(
309
+ `Invalid project config: modelOptions.${category}.${optionKey} must be <= ${field.maximum}`
310
+ );
311
+ }
312
+ return;
313
+ }
314
+
315
+ if (field.type === 'string') {
316
+ if (typeof optionValue !== 'string') {
317
+ throw new Error(`Invalid project config: modelOptions.${category}.${optionKey} must be a string`);
318
+ }
319
+
320
+ const trimmed = optionValue.trim();
321
+ if (!trimmed) {
322
+ throw new Error(`Invalid project config: modelOptions.${category}.${optionKey} must be a non-empty string`);
323
+ }
324
+
325
+ if (Number.isInteger(field.maxLength) && trimmed.length > field.maxLength) {
326
+ throw new Error(
327
+ `Invalid project config: modelOptions.${category}.${optionKey} length must be <= ${field.maxLength}`
328
+ );
329
+ }
330
+
331
+ if (Array.isArray(field.enum) && field.enum.length > 0 && !field.allowAnyString && !field.enum.includes(trimmed)) {
332
+ throw new Error(
333
+ `Invalid project config: modelOptions.${category}.${optionKey} must be one of ${field.enum.join(', ')}`
334
+ );
335
+ }
336
+
337
+ return;
338
+ }
339
+
340
+ throw new Error(`Invalid project config: modelOptions.${category}.${optionKey} has unsupported type`);
341
+ }
342
+
343
+ function validateProjectModelOptions(modelOptions, modelSelections) {
344
+ if (!isPlainObject(modelOptions)) {
345
+ throw new Error('Invalid project config: modelOptions must be an object');
346
+ }
347
+
348
+ for (const key of Object.keys(modelOptions)) {
349
+ if (!MODEL_OPTION_KEYS.includes(key)) {
350
+ throw new Error(`Invalid project config: modelOptions.${key} is not a supported model category`);
351
+ }
352
+ }
353
+
354
+ for (const category of MODEL_OPTION_KEYS) {
355
+ const categoryOptions = modelOptions[category];
356
+ if (!isPlainObject(categoryOptions)) {
357
+ throw new Error(`Invalid project config: modelOptions.${category} must be an object`);
358
+ }
359
+
360
+ const inputOptions = resolveModelInputOptionsForCategory(category, modelSelections);
361
+ const allowedOptions = new Set(inputOptions.userConfigurable);
362
+ const fields = inputOptions.fields || {};
363
+
364
+ for (const optionKey of Object.keys(categoryOptions)) {
365
+ if (!allowedOptions.has(optionKey)) {
366
+ throw new Error(
367
+ `Invalid project config: modelOptions.${category}.${optionKey} is not supported for selected model`
368
+ );
369
+ }
370
+
371
+ const field = fields[optionKey];
372
+ if (!field || typeof field !== 'object') {
373
+ throw new Error(
374
+ `Invalid project config: modelOptions.${category}.${optionKey} has no metadata definition`
375
+ );
376
+ }
377
+
378
+ validateFieldConstraints({
379
+ category,
380
+ optionKey,
381
+ optionValue: categoryOptions[optionKey],
382
+ field
383
+ });
384
+ }
385
+ }
386
+ };
387
+
388
+ export function normalizeProjectConfig(config) {
389
+ const nextConfig = config || {};
390
+
391
+ if (nextConfig.schemaVersion !== undefined) {
392
+ if (!Number.isInteger(nextConfig.schemaVersion) || nextConfig.schemaVersion < 1) {
393
+ throw new Error('Invalid project config: schemaVersion must be a positive integer');
394
+ }
395
+ if (nextConfig.schemaVersion > PROJECT_CONFIG_SCHEMA_VERSION) {
396
+ throw new Error(
397
+ `Invalid project config: schemaVersion ${nextConfig.schemaVersion} is newer than supported version ${PROJECT_CONFIG_SCHEMA_VERSION}`
398
+ );
399
+ }
400
+ }
401
+
402
+ const mergedModels = nextConfig.models === undefined
403
+ ? { ...DEFAULT_MODEL_SELECTIONS }
404
+ : {
405
+ ...DEFAULT_MODEL_SELECTIONS,
406
+ ...(nextConfig.models || {})
407
+ };
408
+ const mergedModelOptions = mergeProjectModelOptions(nextConfig.modelOptions, mergedModels);
409
+
410
+ return {
411
+ ...DEFAULT_PROJECT_CONFIG,
412
+ ...nextConfig,
413
+ schemaVersion: PROJECT_CONFIG_SCHEMA_VERSION,
414
+ subtitleOptions: normalizeSubtitleOptions(nextConfig.subtitleOptions),
415
+ models: mergedModels,
416
+ modelOptions: mergedModelOptions
417
+ };
418
+ }
419
+
420
+ function validateProjectModels(modelSelections) {
421
+ if (!modelSelections || typeof modelSelections !== 'object' || Array.isArray(modelSelections)) {
422
+ throw new Error('Invalid project config: models must be an object');
423
+ }
424
+
425
+ for (const key of Object.keys(modelSelections)) {
426
+ if (!MODEL_SELECTION_KEYS.includes(key)) {
427
+ throw new Error(`Invalid project config: models.${key} is not a supported model category`);
428
+ }
429
+ }
430
+
431
+ for (const key of MODEL_SELECTION_KEYS) {
432
+ const modelId = modelSelections[key];
433
+ if (typeof modelId !== 'string' || !modelId.trim()) {
434
+ throw new Error(`Invalid project config: models.${key} must be a non-empty model id string`);
435
+ }
436
+ if (!SUPPORTED_MODEL_IDS.includes(modelId.trim())) {
437
+ throw new Error(`Invalid project config: models.${key} must reference a supported model id`);
438
+ }
439
+ }
440
+ }
441
+
442
+ export function resolveProjectName(name) {
443
+ const normalized = String(name || '').trim().toLowerCase();
444
+ if (!normalized) {
445
+ throw new Error('Project name is required');
446
+ }
447
+ if (!PROJECT_NAME_PATTERN.test(normalized)) {
448
+ throw new Error(
449
+ 'Invalid project name: use 1-64 characters of lowercase letters, numbers, hyphen (-), or underscore (_), starting and ending with a letter or number'
450
+ );
451
+ }
452
+ return normalized;
453
+ }
454
+
455
+ export function getProjectDir(projectName) {
456
+ return path.join(env.projectsDir, resolveProjectName(projectName));
457
+ }
458
+
459
+ export function getProjectStoryPath(projectName) {
460
+ return path.join(getProjectDir(projectName), 'story.md');
461
+ }
462
+
463
+ export function getProjectStatePath(projectName) {
464
+ return path.join(getProjectDir(projectName), 'run-state.json');
465
+ }
466
+
467
+ export function getProjectArtifactsPath(projectName) {
468
+ return path.join(getProjectDir(projectName), 'artifacts.json');
469
+ }
470
+
471
+ export function getProjectConfigPath(projectName) {
472
+ return path.join(getProjectDir(projectName), 'config.json');
473
+ }
474
+
475
+ export function getProjectMetadataPath(projectName) {
476
+ return path.join(getProjectDir(projectName), 'metadata.json');
477
+ }
478
+
479
+ export function getProjectSyncPath(projectName) {
480
+ return path.join(getProjectDir(projectName), 'sync.json');
481
+ }
482
+
483
+ export function getProjectScriptAssetPath(projectName) {
484
+ return path.join(getProjectDir(projectName), 'assets', 'text', 'script.json');
485
+ }
486
+
487
+ export function validateProjectConfig(config) {
488
+ if (!config || typeof config !== 'object') {
489
+ throw new Error('Invalid project config: expected object');
490
+ }
491
+
492
+ if (!Number.isInteger(config.schemaVersion) || config.schemaVersion < 1) {
493
+ throw new Error('Invalid project config: schemaVersion must be a positive integer');
494
+ }
495
+
496
+ if (!SUPPORTED_ASPECT_RATIOS.includes(config.aspectRatio)) {
497
+ throw new Error(`Invalid project config: aspectRatio must be one of ${SUPPORTED_ASPECT_RATIOS.join(', ')}`);
498
+ }
499
+
500
+ if (!Number.isInteger(config.targetDurationSec)) {
501
+ throw new Error('Invalid project config: targetDurationSec must be an integer number of seconds');
502
+ }
503
+
504
+ if (config.targetDurationSec < 5 || config.targetDurationSec > 600) {
505
+ throw new Error('Invalid project config: targetDurationSec must be between 5 and 600');
506
+ }
507
+
508
+ if (!SUPPORTED_FINAL_DURATION_MODES.includes(config.finalDurationMode)) {
509
+ throw new Error(
510
+ `Invalid project config: finalDurationMode must be one of ${SUPPORTED_FINAL_DURATION_MODES.join(', ')}`
511
+ );
512
+ }
513
+
514
+ validateSubtitleOptions(config.subtitleOptions);
515
+
516
+ const hasWidth = config.keyframeWidth !== undefined;
517
+ const hasHeight = config.keyframeHeight !== undefined;
518
+ if (hasWidth !== hasHeight) {
519
+ throw new Error('Invalid project config: keyframeWidth and keyframeHeight must be set together');
520
+ }
521
+
522
+ if (hasWidth && hasHeight) {
523
+ if (!Number.isInteger(config.keyframeWidth) || !Number.isInteger(config.keyframeHeight)) {
524
+ throw new Error('Invalid project config: keyframeWidth/keyframeHeight must be integers');
525
+ }
526
+ if (config.keyframeWidth < 64 || config.keyframeWidth > 2048) {
527
+ throw new Error('Invalid project config: keyframeWidth must be between 64 and 2048');
528
+ }
529
+ if (config.keyframeHeight < 64 || config.keyframeHeight > 2048) {
530
+ throw new Error('Invalid project config: keyframeHeight must be between 64 and 2048');
531
+ }
532
+ }
533
+
534
+ validateProjectModels(config.models);
535
+ validateProjectModelOptions(config.modelOptions, config.models);
536
+ }
537
+
538
+ export function normalizeAndValidateProjectConfig(config) {
539
+ const normalized = normalizeProjectConfig(config);
540
+ validateProjectConfig(normalized);
541
+ return normalized;
542
+ }
543
+
544
+ export async function ensureProject(projectName) {
545
+ await ensureDir(getProjectDir(projectName));
546
+ }
547
+
548
+ export async function ensureProjectConfig(projectName) {
549
+ const configPath = getProjectConfigPath(projectName);
550
+ try {
551
+ const raw = await fs.readFile(configPath, 'utf8');
552
+ return normalizeAndValidateProjectConfig(JSON.parse(raw));
553
+ } catch (error) {
554
+ if (error.code === 'ENOENT') {
555
+ await writeJson(configPath, DEFAULT_PROJECT_CONFIG);
556
+ return DEFAULT_PROJECT_CONFIG;
557
+ }
558
+ throw error;
559
+ }
560
+ }
561
+
562
+ export async function readProjectConfig(projectName) {
563
+ return normalizeAndValidateProjectConfig(await ensureProjectConfig(projectName));
564
+ }
565
+
566
+ export async function writeProjectConfig(projectName, config) {
567
+ const normalized = normalizeAndValidateProjectConfig(config);
568
+ await writeJson(getProjectConfigPath(projectName), normalized);
569
+ return normalized;
570
+ }
571
+
572
+ export async function readProjectStory(projectName) {
573
+ const storyPath = getProjectStoryPath(projectName);
574
+ return fs.readFile(storyPath, 'utf8');
575
+ }
576
+
577
+ export async function writeProjectStory(projectName, story) {
578
+ const storyPath = getProjectStoryPath(projectName);
579
+ await ensureDir(path.dirname(storyPath));
580
+ await fs.writeFile(storyPath, story, 'utf8');
581
+ return storyPath;
582
+ }
583
+
584
+ export async function readProjectScriptAsset(projectName) {
585
+ try {
586
+ const raw = await fs.readFile(getProjectScriptAssetPath(projectName), 'utf8');
587
+ const parsed = JSON.parse(raw);
588
+ return parsed && typeof parsed === 'object' ? parsed : null;
589
+ } catch (error) {
590
+ if (error.code === 'ENOENT') {
591
+ return null;
592
+ }
593
+ throw error;
594
+ }
595
+ }
596
+
597
+ export async function writeProjectScriptAsset(projectName, scriptAsset) {
598
+ const scriptAssetPath = getProjectScriptAssetPath(projectName);
599
+ await ensureDir(path.dirname(scriptAssetPath));
600
+ await writeJson(scriptAssetPath, scriptAsset || {});
601
+ return scriptAssetPath;
602
+ }
603
+
604
+ export async function projectStoryExists(projectName) {
605
+ try {
606
+ await fs.access(getProjectStoryPath(projectName));
607
+ return true;
608
+ } catch {
609
+ return false;
610
+ }
611
+ }
612
+
613
+ export async function readProjectRunState(projectName) {
614
+ try {
615
+ const raw = await fs.readFile(getProjectStatePath(projectName), 'utf8');
616
+ return JSON.parse(raw);
617
+ } catch (error) {
618
+ if (error.code === 'ENOENT') {
619
+ return null;
620
+ }
621
+ throw error;
622
+ }
623
+ }
624
+
625
+ export async function writeProjectRunState(projectName, state) {
626
+ await writeJson(getProjectStatePath(projectName), state);
627
+ }
628
+
629
+ export async function writeProjectArtifacts(projectName, artifacts) {
630
+ await writeJson(getProjectArtifactsPath(projectName), artifacts);
631
+ }
632
+
633
+ export async function readProjectArtifacts(projectName) {
634
+ try {
635
+ const raw = await fs.readFile(getProjectArtifactsPath(projectName), 'utf8');
636
+ return JSON.parse(raw);
637
+ } catch (error) {
638
+ if (error.code === 'ENOENT') {
639
+ return null;
640
+ }
641
+ throw error;
642
+ }
643
+ }
644
+
645
+ export async function readProjectMetadata(projectName) {
646
+ try {
647
+ const raw = await fs.readFile(getProjectMetadataPath(projectName), 'utf8');
648
+ return JSON.parse(raw);
649
+ } catch (error) {
650
+ if (error.code === 'ENOENT') {
651
+ return {};
652
+ }
653
+ throw error;
654
+ }
655
+ }
656
+
657
+ export async function writeProjectMetadata(projectName, metadata) {
658
+ await writeJson(getProjectMetadataPath(projectName), metadata || {});
659
+ }
660
+
661
+ export async function readProjectSync(projectName) {
662
+ try {
663
+ const raw = await fs.readFile(getProjectSyncPath(projectName), 'utf8');
664
+ return JSON.parse(raw);
665
+ } catch (error) {
666
+ if (error.code === 'ENOENT') {
667
+ return null;
668
+ }
669
+ throw error;
670
+ }
671
+ }
672
+
673
+ export async function writeProjectSync(projectName, syncInfo) {
674
+ await writeJson(getProjectSyncPath(projectName), syncInfo || {});
675
+ }
676
+
677
+ export async function listLocalProjects() {
678
+ await ensureDir(env.projectsDir);
679
+ const entries = await fs.readdir(env.projectsDir, { withFileTypes: true });
680
+ return entries
681
+ .filter((entry) => entry.isDirectory())
682
+ .map((entry) => entry.name)
683
+ .sort((a, b) => a.localeCompare(b));
684
+ }