@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,128 @@
1
+ import { DEFAULT_VIDEO_CONFIG, MODEL_CATEGORIES, getModelInputOptions, resolveModelForCategory } from '../config/models.js';
2
+ import { runModel, extractOutputUri } from '../providers/predictions.js';
3
+ import path from 'node:path';
4
+ import { downloadToFile, ensureDir } from '../media/files.js';
5
+
6
+ const TTS_WORDS_PER_SECOND = 2.6;
7
+ const MIN_TTS_SPEED = 0.75;
8
+ const MAX_TTS_SPEED = 1.25;
9
+
10
+ function countWords(text) {
11
+ return String(text || '')
12
+ .trim()
13
+ .split(/\s+/)
14
+ .filter(Boolean).length;
15
+ }
16
+
17
+ function clamp(value, min, max) {
18
+ return Math.min(max, Math.max(min, value));
19
+ }
20
+
21
+ function asModelOptions(candidate) {
22
+ if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) {
23
+ return {};
24
+ }
25
+ return candidate;
26
+ }
27
+
28
+ export function resolveTtsSpeed(script, targetDurationSec) {
29
+ const words = countWords(script);
30
+ const safeTarget = Number.isInteger(targetDurationSec) && targetDurationSec > 0
31
+ ? targetDurationSec
32
+ : DEFAULT_VIDEO_CONFIG.durationSec;
33
+
34
+ const estimatedAtSpeedOne = words / TTS_WORDS_PER_SECOND;
35
+ const rawSpeed = estimatedAtSpeedOne / safeTarget;
36
+ const speed = clamp(rawSpeed || 1, MIN_TTS_SPEED, MAX_TTS_SPEED);
37
+
38
+ return {
39
+ speed,
40
+ words,
41
+ estimatedAtSpeedOneSec: Math.round(estimatedAtSpeedOne * 1000) / 1000
42
+ };
43
+ }
44
+
45
+ export function buildFixedTimeline(shotsCount, segmentDurationSec = DEFAULT_VIDEO_CONFIG.segmentDurationSec) {
46
+ const normalizedShotCount = Number.isInteger(shotsCount) && shotsCount > 0 ? shotsCount : DEFAULT_VIDEO_CONFIG.shots;
47
+ const normalizedSegmentDuration = Number.isInteger(segmentDurationSec) && segmentDurationSec > 0
48
+ ? segmentDurationSec
49
+ : DEFAULT_VIDEO_CONFIG.segmentDurationSec;
50
+
51
+ return Array.from({ length: normalizedShotCount }, (_, idx) => ({
52
+ shot: idx + 1,
53
+ durationSec: normalizedSegmentDuration
54
+ }));
55
+ }
56
+
57
+ export function resolveSegmentCountFromAudioDuration(audioDurationSec, segmentDurationSec = DEFAULT_VIDEO_CONFIG.segmentDurationSec) {
58
+ const normalizedSegmentDuration = Number.isInteger(segmentDurationSec) && segmentDurationSec > 0
59
+ ? segmentDurationSec
60
+ : DEFAULT_VIDEO_CONFIG.segmentDurationSec;
61
+
62
+ if (!Number.isFinite(audioDurationSec) || audioDurationSec <= 0) {
63
+ return 1;
64
+ }
65
+
66
+ return Math.max(1, Math.ceil(audioDurationSec / normalizedSegmentDuration));
67
+ }
68
+
69
+ export async function generateVoiceover(script, options = {}, trace = null) {
70
+ const deps = options.deps || {};
71
+ const runModelFn = deps.runModel || runModel;
72
+ const extractOutputUriFn = deps.extractOutputUri || extractOutputUri;
73
+
74
+ const shotsCount = Number.isInteger(options.shotsCount) && options.shotsCount > 0
75
+ ? options.shotsCount
76
+ : DEFAULT_VIDEO_CONFIG.shots;
77
+ const segmentDurationSec = Number.isInteger(options.segmentDurationSec) && options.segmentDurationSec > 0
78
+ ? options.segmentDurationSec
79
+ : DEFAULT_VIDEO_CONFIG.segmentDurationSec;
80
+ const targetDurationSec = Number.isInteger(options.targetDurationSec) && options.targetDurationSec > 0
81
+ ? options.targetDurationSec
82
+ : shotsCount * segmentDurationSec;
83
+ const ttsPlan = resolveTtsSpeed(script, targetDurationSec);
84
+ const modelId = options.modelId || resolveModelForCategory(MODEL_CATEGORIES.textToSpeech);
85
+ const modelOptions = asModelOptions(options.modelOptions);
86
+ const inputOptions = getModelInputOptions(modelId);
87
+ const allowedFields = new Set(inputOptions.userConfigurable || []);
88
+
89
+ const input = {
90
+ ...modelOptions,
91
+ text: script
92
+ };
93
+
94
+ if (allowedFields.has('speed') && !Object.prototype.hasOwnProperty.call(modelOptions, 'speed')) {
95
+ input.speed = ttsPlan.speed;
96
+ }
97
+
98
+ if (allowedFields.has('subtitle_enable') && !Object.prototype.hasOwnProperty.call(modelOptions, 'subtitle_enable')) {
99
+ input.subtitle_enable = false;
100
+ }
101
+
102
+ const prediction = await runModelFn({
103
+ model: modelId,
104
+ input,
105
+ trace: trace ? { ...trace, step: 'voiceover' } : null
106
+ });
107
+
108
+ return {
109
+ voiceoverUrl: extractOutputUriFn(prediction.output),
110
+ timeline: buildFixedTimeline(shotsCount, segmentDurationSec),
111
+ ttsPlan: {
112
+ ...ttsPlan,
113
+ targetDurationSec
114
+ }
115
+ };
116
+ }
117
+
118
+ export async function persistVoiceover(projectDir, voiceoverUrl, options = {}) {
119
+ const deps = options.deps || {};
120
+ const ensureDirFn = deps.ensureDir || ensureDir;
121
+ const downloadToFileFn = deps.downloadToFile || downloadToFile;
122
+
123
+ const audioDir = path.join(projectDir, 'assets', 'audio');
124
+ await ensureDirFn(audioDir);
125
+ const voicePath = path.join(audioDir, 'voiceover.mp3');
126
+ await downloadToFileFn(voiceoverUrl, voicePath);
127
+ return voicePath;
128
+ }
@@ -0,0 +1,100 @@
1
+ import { DEFAULT_VIDEO_CONFIG, MODELS } from '../config/models.js';
2
+
3
+ function normalizeModelOptions(candidate) {
4
+ if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) {
5
+ return {};
6
+ }
7
+ return candidate;
8
+ }
9
+
10
+ function secondsToFrames(seconds, fps) {
11
+ return Math.max(81, Math.round(seconds * fps));
12
+ }
13
+
14
+ function buildWanInput({ prompt, startImage, endImage, durationSec, videoResolution, modelOptions }) {
15
+ return {
16
+ ...normalizeModelOptions(modelOptions),
17
+ prompt,
18
+ image: startImage,
19
+ last_image: endImage,
20
+ num_frames: secondsToFrames(durationSec, DEFAULT_VIDEO_CONFIG.fps),
21
+ frames_per_second: DEFAULT_VIDEO_CONFIG.fps,
22
+ resolution: videoResolution
23
+ };
24
+ }
25
+
26
+ function buildKlingInput({ prompt, startImage, endImage, aspectRatio, modelOptions }) {
27
+ const normalizedOptions = {
28
+ ...normalizeModelOptions(modelOptions),
29
+ generate_audio: false
30
+ };
31
+
32
+ return {
33
+ ...normalizedOptions,
34
+ prompt,
35
+ start_image: startImage,
36
+ end_image: endImage,
37
+ aspect_ratio: aspectRatio,
38
+ duration: 5
39
+ };
40
+ }
41
+
42
+ function buildPixverseInput({ prompt, startImage, endImage, aspectRatio, modelOptions }) {
43
+ const normalizedOptions = {
44
+ ...normalizeModelOptions(modelOptions),
45
+ generate_audio_switch: false
46
+ };
47
+
48
+ return {
49
+ ...normalizedOptions,
50
+ prompt,
51
+ image: startImage,
52
+ last_frame_image: endImage,
53
+ aspect_ratio: aspectRatio,
54
+ duration: 5
55
+ };
56
+ }
57
+
58
+ function buildVeoInput({ prompt, startImage, endImage, aspectRatio, modelOptions }) {
59
+ return {
60
+ ...normalizeModelOptions(modelOptions),
61
+ prompt,
62
+ image: startImage,
63
+ last_frame: endImage,
64
+ aspect_ratio: aspectRatio,
65
+ duration: 5,
66
+ generate_audio: false
67
+ };
68
+ }
69
+
70
+ const IMAGE_TO_VIDEO_ADAPTERS = {
71
+ [MODELS.video]: {
72
+ modelId: MODELS.video,
73
+ buildInput: buildWanInput
74
+ },
75
+ [MODELS.klingVideo3]: {
76
+ modelId: MODELS.klingVideo3,
77
+ buildInput: buildKlingInput
78
+ },
79
+ [MODELS.pixverseV56]: {
80
+ modelId: MODELS.pixverseV56,
81
+ buildInput: buildPixverseInput
82
+ },
83
+ [MODELS.veo31]: {
84
+ modelId: MODELS.veo31,
85
+ buildInput: buildVeoInput
86
+ },
87
+ [MODELS.veo31Fast]: {
88
+ modelId: MODELS.veo31Fast,
89
+ buildInput: buildVeoInput
90
+ }
91
+ };
92
+
93
+ const DEFAULT_IMAGE_TO_VIDEO_ADAPTER = {
94
+ modelId: null,
95
+ buildInput: buildWanInput
96
+ };
97
+
98
+ export function resolveImageToVideoAdapter(modelId) {
99
+ return IMAGE_TO_VIDEO_ADAPTERS[modelId] || DEFAULT_IMAGE_TO_VIDEO_ADAPTER;
100
+ }
@@ -0,0 +1,177 @@
1
+ import { DEFAULT_VIDEO_CONFIG, MODEL_CATEGORIES, resolveModelForCategory } from '../config/models.js';
2
+ import { runModel, extractOutputText } from '../providers/predictions.js';
3
+
4
+ const SCRIPT_WORDS_PER_SECOND = 2.6;
5
+ const SCRIPT_RETRY_LIMIT = 3;
6
+ const SHOTS_RETRY_LIMIT = 3;
7
+
8
+ function extractJsonBlock(text) {
9
+ const start = text.indexOf('{');
10
+ const end = text.lastIndexOf('}');
11
+ if (start === -1 || end === -1 || end <= start) {
12
+ throw new Error('Model response did not include JSON payload');
13
+ }
14
+ return text.slice(start, end + 1);
15
+ }
16
+
17
+ function countWords(text) {
18
+ return String(text || '')
19
+ .trim()
20
+ .split(/\s+/)
21
+ .filter(Boolean).length;
22
+ }
23
+
24
+ function buildWordBudget(targetDurationSec) {
25
+ const targetWords = Math.max(20, Math.round(targetDurationSec * SCRIPT_WORDS_PER_SECOND));
26
+ return {
27
+ targetWords,
28
+ minWords: Math.max(20, Math.floor(targetWords * 0.9)),
29
+ maxWords: Math.max(30, Math.ceil(targetWords * 1.1))
30
+ };
31
+ }
32
+
33
+ function buildScriptPrompt(story, targetDurationSec, wordBudget, attempt) {
34
+ const retryInstruction =
35
+ attempt > 1
36
+ ? `\nIMPORTANT: previous attempt missed the narration length target. Keep script length strictly between ${wordBudget.minWords} and ${wordBudget.maxWords} words.`
37
+ : '';
38
+
39
+ return `You are writing narration for a short video. Return ONLY JSON with keys: script, tone.\nRequirements:\n- script: narration text matching roughly ${targetDurationSec} seconds of voiceover\n- script length: strictly between ${wordBudget.minWords} and ${wordBudget.maxWords} words (target ${wordBudget.targetWords})\n- tone: concise tone label${retryInstruction}\nStory source:\n${story}`;
40
+ }
41
+
42
+ function buildShotsPrompt(script, shotCount, tone, attempt) {
43
+ const retryInstruction =
44
+ attempt > 1
45
+ ? `\nIMPORTANT: previous attempt failed shape checks. Return exactly ${shotCount} shots.`
46
+ : '';
47
+
48
+ const toneHint = typeof tone === 'string' && tone.trim() ? tone.trim() : 'neutral';
49
+ return `You are writing visual keyframe prompts for a short video narration. Return ONLY JSON with key: shots.\nRequirements:\n- shots: array of exactly ${shotCount} short visual descriptions\n- each shot: exactly one sentence, concrete and visually descriptive\n- each shot must be fully self-contained because prompts are generated independently (do not rely on context from other shots)\n- in every shot, restate essential visual context like characters, setting, era/time-of-day, and key scene details when relevant\n- keep continuity of characters/setting across shots while still repeating critical details per shot\n- align with narration pacing and tone: ${toneHint}${retryInstruction}\nNarration:\n${script}`;
50
+ }
51
+
52
+ function asModelOptions(candidate) {
53
+ if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) {
54
+ return {};
55
+ }
56
+ return candidate;
57
+ }
58
+
59
+ function normalizeShotDescription(shot) {
60
+ if (typeof shot === 'string') {
61
+ const value = shot.trim();
62
+ return value.length > 0 ? value : null;
63
+ }
64
+
65
+ if (shot && typeof shot === 'object' && !Array.isArray(shot) && typeof shot.description === 'string') {
66
+ const value = shot.description.trim();
67
+ return value.length > 0 ? value : null;
68
+ }
69
+
70
+ return null;
71
+ }
72
+
73
+ export async function generateScript(story, options = {}, trace = null) {
74
+ const deps = options.deps || {};
75
+ const runModelFn = deps.runModel || runModel;
76
+ const extractOutputTextFn = deps.extractOutputText || extractOutputText;
77
+
78
+ const targetDurationSec = Number.isInteger(options.targetDurationSec) && options.targetDurationSec > 0
79
+ ? options.targetDurationSec
80
+ : DEFAULT_VIDEO_CONFIG.durationSec;
81
+ const wordBudget = buildWordBudget(targetDurationSec);
82
+ const modelId = options.modelId || resolveModelForCategory(MODEL_CATEGORIES.textToText);
83
+ const modelOptions = asModelOptions(options.modelOptions);
84
+
85
+ let bestCandidate = null;
86
+ let bestDistance = Number.POSITIVE_INFINITY;
87
+
88
+ for (let attempt = 1; attempt <= SCRIPT_RETRY_LIMIT; attempt += 1) {
89
+ const prompt = buildScriptPrompt(story, targetDurationSec, wordBudget, attempt);
90
+ const prediction = await runModelFn({
91
+ model: modelId,
92
+ input: {
93
+ max_tokens: 1800,
94
+ temperature: 0.6,
95
+ ...modelOptions,
96
+ prompt
97
+ },
98
+ trace: trace ? { ...trace, step: 'script', attempt } : null
99
+ });
100
+
101
+ const text = extractOutputTextFn(prediction.output);
102
+ const parsed = JSON.parse(extractJsonBlock(text));
103
+
104
+ if (!parsed.script || typeof parsed.script !== 'string') {
105
+ continue;
106
+ }
107
+
108
+ const words = countWords(parsed.script);
109
+ const distance = Math.abs(words - wordBudget.targetWords);
110
+
111
+ if (distance < bestDistance) {
112
+ bestDistance = distance;
113
+ bestCandidate = {
114
+ script: parsed.script,
115
+ tone: parsed.tone || 'neutral',
116
+ scriptWordCount: words,
117
+ targetWordCount: wordBudget.targetWords
118
+ };
119
+ }
120
+
121
+ if (words >= wordBudget.minWords && words <= wordBudget.maxWords) {
122
+ return bestCandidate;
123
+ }
124
+ }
125
+
126
+ if (!bestCandidate) {
127
+ throw new Error('Invalid script output shape');
128
+ }
129
+
130
+ return bestCandidate;
131
+ }
132
+
133
+ export async function generateShots(script, options = {}, trace = null) {
134
+ const deps = options.deps || {};
135
+ const runModelFn = deps.runModel || runModel;
136
+ const extractOutputTextFn = deps.extractOutputText || extractOutputText;
137
+
138
+ const shotCount = Number.isInteger(options.shotCount) && options.shotCount > 0
139
+ ? options.shotCount
140
+ : DEFAULT_VIDEO_CONFIG.shots;
141
+ const tone = typeof options.tone === 'string' ? options.tone : 'neutral';
142
+ const modelId = options.modelId || resolveModelForCategory(MODEL_CATEGORIES.textToText);
143
+ const modelOptions = asModelOptions(options.modelOptions);
144
+
145
+ for (let attempt = 1; attempt <= SHOTS_RETRY_LIMIT; attempt += 1) {
146
+ const prompt = buildShotsPrompt(script, shotCount, tone, attempt);
147
+ const prediction = await runModelFn({
148
+ model: modelId,
149
+ input: {
150
+ max_tokens: 1800,
151
+ temperature: 0.5,
152
+ ...modelOptions,
153
+ prompt
154
+ },
155
+ trace: trace ? { ...trace, step: 'shots', attempt } : null
156
+ });
157
+
158
+ const text = extractOutputTextFn(prediction.output);
159
+ const parsed = JSON.parse(extractJsonBlock(text));
160
+ const candidateShots = parsed?.shots;
161
+ const normalizedShots = Array.isArray(candidateShots)
162
+ ? candidateShots.map((shot) => normalizeShotDescription(shot))
163
+ : null;
164
+ const hasValidShape =
165
+ Array.isArray(normalizedShots) &&
166
+ normalizedShots.length === shotCount &&
167
+ normalizedShots.every((shot) => typeof shot === 'string' && shot.length > 0);
168
+
169
+ if (hasValidShape) {
170
+ return {
171
+ shots: normalizedShots
172
+ };
173
+ }
174
+ }
175
+
176
+ throw new Error('Invalid shots output shape');
177
+ }
@@ -0,0 +1,87 @@
1
+ import { MODELS } from '../config/models.js';
2
+
3
+ function normalizeModelOptions(candidate) {
4
+ if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) {
5
+ return {};
6
+ }
7
+ return candidate;
8
+ }
9
+
10
+ function buildCommonPrompt(promptText, tone, index) {
11
+ return `Cinematic documentary style, coherent character continuity, shot ${index + 1}, tone ${tone}. ${promptText}`;
12
+ }
13
+
14
+ function buildPrunaInput({ promptText, tone, index, width, height, modelOptions }) {
15
+ return {
16
+ ...normalizeModelOptions(modelOptions),
17
+ prompt: buildCommonPrompt(promptText, tone, index),
18
+ width,
19
+ height
20
+ };
21
+ }
22
+
23
+ function buildFluxInput({ promptText, tone, index, width, height, modelOptions }) {
24
+ return {
25
+ ...normalizeModelOptions(modelOptions),
26
+ prompt: buildCommonPrompt(promptText, tone, index),
27
+ aspect_ratio: 'custom',
28
+ width,
29
+ height
30
+ };
31
+ }
32
+
33
+ function buildFluxSchnellInput({ promptText, tone, index, aspectRatio, modelOptions }) {
34
+ return {
35
+ ...normalizeModelOptions(modelOptions),
36
+ prompt: buildCommonPrompt(promptText, tone, index),
37
+ aspect_ratio: aspectRatio
38
+ };
39
+ }
40
+
41
+ function buildNanoBananaProInput({ promptText, tone, index, aspectRatio, modelOptions }) {
42
+ return {
43
+ ...normalizeModelOptions(modelOptions),
44
+ prompt: buildCommonPrompt(promptText, tone, index),
45
+ aspect_ratio: aspectRatio
46
+ };
47
+ }
48
+
49
+ function buildSeedream4Input({ promptText, tone, index, aspectRatio, modelOptions }) {
50
+ return {
51
+ ...normalizeModelOptions(modelOptions),
52
+ prompt: buildCommonPrompt(promptText, tone, index),
53
+ aspect_ratio: aspectRatio
54
+ };
55
+ }
56
+
57
+ const TEXT_TO_IMAGE_ADAPTERS = {
58
+ [MODELS.keyframe]: {
59
+ modelId: MODELS.keyframe,
60
+ buildInput: buildPrunaInput
61
+ },
62
+ [MODELS.flux]: {
63
+ modelId: MODELS.flux,
64
+ buildInput: buildFluxInput
65
+ },
66
+ [MODELS.fluxSchnell]: {
67
+ modelId: MODELS.fluxSchnell,
68
+ buildInput: buildFluxSchnellInput
69
+ },
70
+ [MODELS.nanoBananaPro]: {
71
+ modelId: MODELS.nanoBananaPro,
72
+ buildInput: buildNanoBananaProInput
73
+ },
74
+ [MODELS.seedream4]: {
75
+ modelId: MODELS.seedream4,
76
+ buildInput: buildSeedream4Input
77
+ }
78
+ };
79
+
80
+ const DEFAULT_TEXT_TO_IMAGE_ADAPTER = {
81
+ modelId: null,
82
+ buildInput: buildPrunaInput
83
+ };
84
+
85
+ export function resolveTextToImageAdapter(modelId) {
86
+ return TEXT_TO_IMAGE_ADAPTERS[modelId] || DEFAULT_TEXT_TO_IMAGE_ADAPTER;
87
+ }
@@ -0,0 +1,5 @@
1
+ import { writeProjectArtifacts } from './projectStore.js';
2
+
3
+ export async function persistArtifacts(projectName, artifacts) {
4
+ await writeProjectArtifacts(projectName, artifacts);
5
+ }
@@ -0,0 +1,102 @@
1
+ import { v4 as uuidv4 } from 'uuid';
2
+ import { JobStatus } from '../types/job.js';
3
+ import { emptyPipelineArtifacts } from '../types/media.js';
4
+ import { emptyStepState } from '../types/job.js';
5
+
6
+ const jobs = new Map();
7
+ const projectRunLocks = new Map();
8
+
9
+ function normalizeProjectName(project) {
10
+ return String(project || '').trim().toLowerCase();
11
+ }
12
+
13
+ export function createJob(payload) {
14
+ const id = uuidv4();
15
+ const job = {
16
+ id,
17
+ status: JobStatus.PENDING,
18
+ createdAt: new Date().toISOString(),
19
+ updatedAt: new Date().toISOString(),
20
+ payload,
21
+ error: null,
22
+ artifacts: emptyPipelineArtifacts(),
23
+ steps: emptyStepState()
24
+ };
25
+ jobs.set(id, job);
26
+ return job;
27
+ }
28
+
29
+ export function updateJob(id, updates) {
30
+ const current = jobs.get(id);
31
+ if (!current) return null;
32
+ const next = {
33
+ ...current,
34
+ ...updates,
35
+ updatedAt: new Date().toISOString()
36
+ };
37
+ jobs.set(id, next);
38
+ return next;
39
+ }
40
+
41
+ export function getJob(id) {
42
+ return jobs.get(id) || null;
43
+ }
44
+
45
+ export function findActiveJobByProject(project, options = {}) {
46
+ const targetProject = normalizeProjectName(project);
47
+ if (!targetProject) {
48
+ return null;
49
+ }
50
+
51
+ const excludeJobId = options.excludeJobId || null;
52
+ return [...jobs.values()].find((job) => {
53
+ if (!job || (excludeJobId && job.id === excludeJobId)) {
54
+ return false;
55
+ }
56
+
57
+ if (job.status !== JobStatus.PENDING && job.status !== JobStatus.RUNNING) {
58
+ return false;
59
+ }
60
+
61
+ return normalizeProjectName(job.payload?.project) === targetProject;
62
+ }) || null;
63
+ }
64
+
65
+ export function acquireProjectRunLock(project, jobId) {
66
+ const projectName = normalizeProjectName(project);
67
+ if (!projectName || !jobId) {
68
+ return false;
69
+ }
70
+
71
+ const currentOwner = projectRunLocks.get(projectName);
72
+ if (currentOwner && currentOwner !== jobId) {
73
+ return false;
74
+ }
75
+
76
+ projectRunLocks.set(projectName, jobId);
77
+ return true;
78
+ }
79
+
80
+ export function releaseProjectRunLock(project, jobId) {
81
+ const projectName = normalizeProjectName(project);
82
+ if (!projectName || !jobId) {
83
+ return;
84
+ }
85
+
86
+ const currentOwner = projectRunLocks.get(projectName);
87
+ if (currentOwner === jobId) {
88
+ projectRunLocks.delete(projectName);
89
+ }
90
+ }
91
+
92
+ export function getProjectRunLockOwner(project) {
93
+ const projectName = normalizeProjectName(project);
94
+ if (!projectName) {
95
+ return null;
96
+ }
97
+ return projectRunLocks.get(projectName) || null;
98
+ }
99
+
100
+ export function listPendingJobs() {
101
+ return [...jobs.values()].filter((job) => job.status === JobStatus.PENDING);
102
+ }