@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.
- package/LICENSE +21 -0
- package/README.md +209 -0
- package/index.js +1 -0
- package/models/black-forest-labs__flux-2-pro.json +78 -0
- package/models/black-forest-labs__flux-schnell.json +95 -0
- package/models/bytedance__seedream-4.json +71 -0
- package/models/deepseek-ai__deepseek-v3.json +61 -0
- package/models/google__nano-banana-pro.json +92 -0
- package/models/google__veo-3.1-fast.json +93 -0
- package/models/google__veo-3.1.json +93 -0
- package/models/jaaari__kokoro-82m.json +86 -0
- package/models/kwaivgi__kling-v3-video.json +101 -0
- package/models/minimax__speech-02-turbo.json +141 -0
- package/models/pixverse__pixverse-v5.6.json +113 -0
- package/models/prunaai__z-image-turbo.json +107 -0
- package/models/resemble-ai__chatterbox-turbo.json +102 -0
- package/models/wan-video__wan-2.2-i2v-fast.json +139 -0
- package/package.json +67 -0
- package/src/api/firebaseFunction.js +46 -0
- package/src/api/middleware/auth.js +70 -0
- package/src/api/openapi/generateOpenApi.js +21 -0
- package/src/api/openapi/spec.js +831 -0
- package/src/api/routes/jobs.js +45 -0
- package/src/api/routes/projectAssets.js +63 -0
- package/src/api/routes/projects.js +647 -0
- package/src/api/routes/webhooks.js +13 -0
- package/src/api/server.js +88 -0
- package/src/backends/firebaseClient.js +57 -0
- package/src/backends/outputBackend.js +186 -0
- package/src/backends/projectMetadataBackend.js +550 -0
- package/src/cli/commands/openHome.js +70 -0
- package/src/cli/commands/settingsFlow.js +196 -0
- package/src/cli/index.js +192 -0
- package/src/config/env.js +158 -0
- package/src/config/keystore.js +175 -0
- package/src/config/models.js +281 -0
- package/src/config/settingsSchema.js +214 -0
- package/src/media/ffmpeg.js +144 -0
- package/src/media/files.js +77 -0
- package/src/media/subtitles.js +444 -0
- package/src/observability/apiTrace.js +17 -0
- package/src/observability/logger.js +7 -0
- package/src/observability/metrics.js +10 -0
- package/src/pipeline/inputSanitizer.js +6 -0
- package/src/pipeline/orchestrator.js +1669 -0
- package/src/policy/contentGuardrails.js +30 -0
- package/src/providers/predictions.js +188 -0
- package/src/providers/replicateClient.js +12 -0
- package/src/steps/alignSubtitles.js +156 -0
- package/src/steps/burnInSubtitles.js +22 -0
- package/src/steps/composeFinalVideo.js +57 -0
- package/src/steps/generateKeyframes.js +70 -0
- package/src/steps/generateVideoSegments.js +95 -0
- package/src/steps/generateVoiceover.js +128 -0
- package/src/steps/imageToVideoAdapters.js +100 -0
- package/src/steps/script.js +177 -0
- package/src/steps/textToImageAdapters.js +87 -0
- package/src/store/assetStore.js +5 -0
- package/src/store/jobStore.js +102 -0
- package/src/store/projectAnalyticsStore.js +625 -0
- package/src/store/projectStore.js +684 -0
- package/src/store/settingsStore.js +155 -0
- package/src/store/staleAssetStore.js +63 -0
- package/src/types/job.js +28 -0
- package/src/types/media.js +28 -0
- package/src/worker/processor.js +24 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { select, input, password, confirm } from '@inquirer/prompts';
|
|
2
|
+
import { SETTINGS, SECURE_SETTINGS, PUBLIC_SETTINGS } from '../../config/settingsSchema.js';
|
|
3
|
+
import {
|
|
4
|
+
resolvePublicValue,
|
|
5
|
+
resolveSecureStatus,
|
|
6
|
+
readPublicConfig,
|
|
7
|
+
savePublicSetting,
|
|
8
|
+
saveSecureToken
|
|
9
|
+
} from '../../store/settingsStore.js';
|
|
10
|
+
|
|
11
|
+
const DONE_CHOICE = '__done__';
|
|
12
|
+
const CANCEL_CHOICE = '__cancel__';
|
|
13
|
+
|
|
14
|
+
// ── Display helpers ────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Format a value for display next to a setting name.
|
|
18
|
+
* Secure values are always masked; env-sourced values show an indicator.
|
|
19
|
+
*/
|
|
20
|
+
export function formatCurrentValue(setting, value, source) {
|
|
21
|
+
if (setting.type === 'secure') {
|
|
22
|
+
if (source === 'env') return '(set via environment variable — not editable here)';
|
|
23
|
+
if (source === 'keystore') return '(stored securely)';
|
|
24
|
+
return '(not set)';
|
|
25
|
+
}
|
|
26
|
+
if (source === 'env') return `${value} ← from environment variable`;
|
|
27
|
+
const display = value !== undefined && value !== null ? String(value) : String(setting.default);
|
|
28
|
+
return source === 'default' ? `${display} ← default` : display;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ── Per-setting prompt ─────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
async function promptForSetting(setting, currentValue, source) {
|
|
34
|
+
const isEnvOverridden = source === 'env';
|
|
35
|
+
|
|
36
|
+
if (setting.type === 'secure') {
|
|
37
|
+
if (isEnvOverridden) {
|
|
38
|
+
console.log(
|
|
39
|
+
`\n ${setting.label}: This token is set via an environment variable and cannot be\n` +
|
|
40
|
+
' overridden here. Remove it from your environment / .env file first.\n'
|
|
41
|
+
);
|
|
42
|
+
return null; // nothing to do
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const hasExisting = source === 'keystore';
|
|
46
|
+
const action = await select({
|
|
47
|
+
message: `${setting.label}${hasExisting ? ' (currently stored securely)' : ' (not set)'}`,
|
|
48
|
+
choices: [
|
|
49
|
+
{ name: hasExisting ? 'Replace stored value' : 'Set value', value: 'set' },
|
|
50
|
+
...(hasExisting ? [{ name: 'Clear stored value', value: 'clear' }] : []),
|
|
51
|
+
{ name: 'Skip', value: 'skip' }
|
|
52
|
+
]
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
if (action === 'skip') return null;
|
|
56
|
+
if (action === 'clear') return { key: setting.keystoreKey, value: '', clear: true };
|
|
57
|
+
|
|
58
|
+
const value = await password({
|
|
59
|
+
message: `Enter ${setting.label}:`,
|
|
60
|
+
mask: '*',
|
|
61
|
+
validate(v) {
|
|
62
|
+
if (!v || !v.trim()) return 'Value must not be empty.';
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
return { key: setting.keystoreKey, value, secure: true };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Public setting
|
|
71
|
+
if (isEnvOverridden) {
|
|
72
|
+
console.log(
|
|
73
|
+
`\n ${setting.label}: Currently set to "${currentValue}" via environment variable.\n` +
|
|
74
|
+
' rilo will use the env var value; any saved setting here will be ignored while\n' +
|
|
75
|
+
' the env var is set.\n'
|
|
76
|
+
);
|
|
77
|
+
const proceed = await confirm({
|
|
78
|
+
message: 'Save a config.json value anyway (used when the env var is absent)?',
|
|
79
|
+
default: false
|
|
80
|
+
});
|
|
81
|
+
if (!proceed) return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const defaultDisplay = String(currentValue ?? setting.default);
|
|
85
|
+
const newValue = await input({
|
|
86
|
+
message: `${setting.label}:`,
|
|
87
|
+
default: defaultDisplay,
|
|
88
|
+
validate(v) {
|
|
89
|
+
if (setting.validate) return setting.validate(v);
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Coerce type
|
|
95
|
+
const coerced = setting.type === 'number' ? Number(newValue) : newValue;
|
|
96
|
+
return { key: setting.configKey, value: coerced, public: true };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Main flow ─────────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
export async function openSettings() {
|
|
102
|
+
console.log('\n rilo settings\n');
|
|
103
|
+
|
|
104
|
+
const storedConfig = await readPublicConfig();
|
|
105
|
+
|
|
106
|
+
// Resolve current state for all settings for menu display
|
|
107
|
+
const stateMap = {};
|
|
108
|
+
for (const s of SETTINGS) {
|
|
109
|
+
if (s.type === 'secure') {
|
|
110
|
+
const status = await resolveSecureStatus(s);
|
|
111
|
+
stateMap[s.id] = { value: null, source: status.source };
|
|
112
|
+
} else {
|
|
113
|
+
const resolved = resolvePublicValue(s, storedConfig);
|
|
114
|
+
stateMap[s.id] = resolved;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const pendingChanges = []; // { setting, change }
|
|
119
|
+
|
|
120
|
+
while (true) {
|
|
121
|
+
// Build menu choices
|
|
122
|
+
const choices = [
|
|
123
|
+
...SECURE_SETTINGS.map((s) => {
|
|
124
|
+
const { source } = stateMap[s.id];
|
|
125
|
+
return {
|
|
126
|
+
name: `Secure: ${s.label}`,
|
|
127
|
+
value: s.id,
|
|
128
|
+
description: formatCurrentValue(s, null, source)
|
|
129
|
+
};
|
|
130
|
+
}),
|
|
131
|
+
...PUBLIC_SETTINGS.map((s) => {
|
|
132
|
+
const { value, source } = stateMap[s.id];
|
|
133
|
+
return {
|
|
134
|
+
name: `General: ${s.label}`,
|
|
135
|
+
value: s.id,
|
|
136
|
+
description: formatCurrentValue(s, value, source)
|
|
137
|
+
};
|
|
138
|
+
}),
|
|
139
|
+
...(pendingChanges.length > 0
|
|
140
|
+
? [{ name: `✓ Save ${pendingChanges.length} change(s) and exit`, value: DONE_CHOICE }]
|
|
141
|
+
: [{ name: '✓ Done (no changes)', value: DONE_CHOICE }]),
|
|
142
|
+
{ name: '✗ Cancel / Discard (Ctrl+C to quit)', value: CANCEL_CHOICE }
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
const selected = await select({
|
|
146
|
+
message: 'Select a setting to edit (secure first, then general):',
|
|
147
|
+
choices,
|
|
148
|
+
pageSize: 20
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
if (selected === CANCEL_CHOICE) {
|
|
152
|
+
console.log('\n No changes saved.\n');
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (selected === DONE_CHOICE) break;
|
|
157
|
+
|
|
158
|
+
const setting = SETTINGS.find((s) => s.id === selected) ?? null;
|
|
159
|
+
if (!setting) continue;
|
|
160
|
+
|
|
161
|
+
const { value, source } = stateMap[setting.id];
|
|
162
|
+
const change = await promptForSetting(setting, value, source);
|
|
163
|
+
|
|
164
|
+
if (change) {
|
|
165
|
+
pendingChanges.push({ setting, change });
|
|
166
|
+
// Update display state optimistically
|
|
167
|
+
if (change.secure) {
|
|
168
|
+
stateMap[setting.id] = { value: null, source: 'keystore' };
|
|
169
|
+
} else if (change.clear) {
|
|
170
|
+
stateMap[setting.id] = { value: null, source: 'none' };
|
|
171
|
+
} else if (change.public) {
|
|
172
|
+
stateMap[setting.id] = { value: change.value, source: 'config' };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (pendingChanges.length === 0) {
|
|
178
|
+
console.log('\n No changes to save.\n');
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Persist all changes
|
|
183
|
+
for (const { change } of pendingChanges) {
|
|
184
|
+
if (change.secure) {
|
|
185
|
+
await saveSecureToken(change.key, change.value);
|
|
186
|
+
} else if (change.clear) {
|
|
187
|
+
// Import deleteSecret lazily to avoid circular deps in tests
|
|
188
|
+
const { deleteSecret } = await import('../../config/keystore.js');
|
|
189
|
+
await deleteSecret(change.key);
|
|
190
|
+
} else if (change.public) {
|
|
191
|
+
await savePublicSetting(change.key, change.value);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
console.log(`\n Saved ${pendingChanges.length} change(s) to ~/.rilo/config.json / keystore.\n`);
|
|
196
|
+
}
|
package/src/cli/index.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { createJob } from '../store/jobStore.js';
|
|
6
|
+
import { runPipeline } from '../pipeline/orchestrator.js';
|
|
7
|
+
import {
|
|
8
|
+
ensureProject,
|
|
9
|
+
ensureProjectConfig,
|
|
10
|
+
projectStoryExists,
|
|
11
|
+
readProjectStory,
|
|
12
|
+
resolveProjectName,
|
|
13
|
+
writeProjectStory
|
|
14
|
+
} from '../store/projectStore.js';
|
|
15
|
+
import { openSettings } from './commands/settingsFlow.js';
|
|
16
|
+
import { openHome } from './commands/openHome.js';
|
|
17
|
+
import { applyStoredSettings } from '../config/env.js';
|
|
18
|
+
|
|
19
|
+
function getArg(flag) {
|
|
20
|
+
const idx = process.argv.indexOf(flag);
|
|
21
|
+
if (idx === -1) return null;
|
|
22
|
+
return process.argv[idx + 1] || null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function hasFlag(flag) {
|
|
26
|
+
return process.argv.includes(flag);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function readCliVersion() {
|
|
30
|
+
try {
|
|
31
|
+
const cliDir = path.dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
const packagePath = path.resolve(cliDir, '../../package.json');
|
|
33
|
+
const raw = await fs.readFile(packagePath, 'utf8');
|
|
34
|
+
const parsed = JSON.parse(raw);
|
|
35
|
+
return typeof parsed.version === 'string' ? parsed.version : 'unknown';
|
|
36
|
+
} catch {
|
|
37
|
+
return 'unknown';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function main() {
|
|
42
|
+
if (process.argv.includes('--version')) {
|
|
43
|
+
console.log(await readCliVersion());
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (process.argv.includes('--help')) {
|
|
48
|
+
const helpText = `
|
|
49
|
+
Rilo — Story-first vertical video generation
|
|
50
|
+
|
|
51
|
+
USAGE
|
|
52
|
+
rilo --project <name> [--story-file <path>] [--force]
|
|
53
|
+
rilo settings
|
|
54
|
+
rilo home
|
|
55
|
+
rilo --help
|
|
56
|
+
rilo --version
|
|
57
|
+
|
|
58
|
+
COMMANDS
|
|
59
|
+
rilo --project <name> [--story-file <path>] [--force]
|
|
60
|
+
Generate a complete video from a story
|
|
61
|
+
|
|
62
|
+
--project <name> Project identifier (required); creates projects/<name>/
|
|
63
|
+
--story-file <path> Path to story text file (required on first run)
|
|
64
|
+
--force Force restart from earlier stages (use after config changes)
|
|
65
|
+
|
|
66
|
+
rilo settings
|
|
67
|
+
Configure API tokens, timeouts, and binary paths interactively
|
|
68
|
+
|
|
69
|
+
rilo home
|
|
70
|
+
Open ~/.rilo, the default home for projects and output files
|
|
71
|
+
|
|
72
|
+
FLAGS
|
|
73
|
+
--help Show this help message
|
|
74
|
+
--version Show version information
|
|
75
|
+
|
|
76
|
+
EXAMPLES
|
|
77
|
+
# First run: create project and generate
|
|
78
|
+
rilo --project wedding-case --story-file ./story.txt
|
|
79
|
+
|
|
80
|
+
# Subsequent runs: reuse saved story
|
|
81
|
+
rilo --project wedding-case
|
|
82
|
+
|
|
83
|
+
# Update config and regenerate
|
|
84
|
+
rilo --project wedding-case --force
|
|
85
|
+
|
|
86
|
+
# Configure settings
|
|
87
|
+
rilo settings
|
|
88
|
+
|
|
89
|
+
# Open the default Rilo home folder
|
|
90
|
+
rilo home
|
|
91
|
+
|
|
92
|
+
# Using npx (no installation needed)
|
|
93
|
+
npx @telepat/rilo --project wedding-case --story-file ./story.txt
|
|
94
|
+
npx @telepat/rilo home
|
|
95
|
+
|
|
96
|
+
DOCUMENTATION
|
|
97
|
+
Quick start: https://docs.telepat.io/rilo/getting-started/quickstart
|
|
98
|
+
CLI reference: https://docs.telepat.io/rilo/reference/cli-reference
|
|
99
|
+
Configuration: https://docs.telepat.io/rilo/guides/configuration
|
|
100
|
+
Troubleshooting: https://docs.telepat.io/rilo/guides/troubleshooting
|
|
101
|
+
All docs: https://docs.telepat.io/rilo/
|
|
102
|
+
|
|
103
|
+
PROJECT OUTPUT
|
|
104
|
+
Generated files are stored in:
|
|
105
|
+
projects/<name>/
|
|
106
|
+
├── config.json Project settings (models, aspect ratio, duration, etc.)
|
|
107
|
+
├── story.md Formatted story
|
|
108
|
+
├── final.mp4 Final video
|
|
109
|
+
├── artifacts.json Generation metadata (paths, durations, etc.)
|
|
110
|
+
├── run-state.json Checkpoint for resume/invalidation
|
|
111
|
+
├── assets/ Keyframes, audio, video segments
|
|
112
|
+
└── logs/ Detailed generation logs
|
|
113
|
+
|
|
114
|
+
SETTINGS
|
|
115
|
+
Configure via interactive menu:
|
|
116
|
+
rilo settings
|
|
117
|
+
|
|
118
|
+
Or with environment variables:
|
|
119
|
+
export RILO_REPLICATE_API_TOKEN=r8_xxxxx
|
|
120
|
+
export RILO_MAX_RETRIES=5
|
|
121
|
+
export PREDICTION_MAX_WAIT_MS=900000
|
|
122
|
+
rilo --project my-project --story-file ./story.txt
|
|
123
|
+
|
|
124
|
+
Settings precedence (highest to lowest):
|
|
125
|
+
1. Environment variable
|
|
126
|
+
2. ~/.rilo/config.json (saved via 'rilo settings')
|
|
127
|
+
3. Schema default
|
|
128
|
+
|
|
129
|
+
INVOCATION METHODS
|
|
130
|
+
Global install: rilo --project <name> --story-file <path>
|
|
131
|
+
No install (npx): npx @telepat/rilo --project <name> --story-file <path>
|
|
132
|
+
Local development: npm run dev -- --project <name> --story-file <path>
|
|
133
|
+
`;
|
|
134
|
+
console.log(helpText);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// `rilo settings` subcommand
|
|
139
|
+
if (process.argv[2] === 'settings') {
|
|
140
|
+
await openSettings();
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (process.argv[2] === 'home') {
|
|
145
|
+
await openHome();
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Merge stored settings (config.json + keystore) into env before running
|
|
150
|
+
await applyStoredSettings();
|
|
151
|
+
|
|
152
|
+
const projectArg = getArg('--project');
|
|
153
|
+
if (!projectArg) {
|
|
154
|
+
throw new Error('Missing --project argument');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const project = resolveProjectName(projectArg);
|
|
158
|
+
const forceRestart = hasFlag('--force');
|
|
159
|
+
const storyFile = getArg('--story-file');
|
|
160
|
+
|
|
161
|
+
await ensureProject(project);
|
|
162
|
+
await ensureProjectConfig(project);
|
|
163
|
+
|
|
164
|
+
let story;
|
|
165
|
+
const storyExists = await projectStoryExists(project);
|
|
166
|
+
if (storyExists) {
|
|
167
|
+
story = await readProjectStory(project);
|
|
168
|
+
} else if (storyFile) {
|
|
169
|
+
story = await fs.readFile(path.resolve(storyFile), 'utf8');
|
|
170
|
+
await writeProjectStory(project, story);
|
|
171
|
+
} else {
|
|
172
|
+
throw new Error('Project story.md not found. Provide --story-file once to initialize the project.');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const job = createJob({ story, project });
|
|
176
|
+
const result = await runPipeline(job.id, { forceRestart });
|
|
177
|
+
|
|
178
|
+
if (result.status !== 'completed') {
|
|
179
|
+
throw new Error(`Generation failed: ${result.error || 'unknown error'}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
console.log(JSON.stringify({
|
|
183
|
+
jobId: result.id,
|
|
184
|
+
project,
|
|
185
|
+
finalVideoPath: result.artifacts.finalVideoPath
|
|
186
|
+
}, null, 2));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
main().catch((error) => {
|
|
190
|
+
console.error(error.message);
|
|
191
|
+
process.exit(1);
|
|
192
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/* c8 ignore file */
|
|
2
|
+
import dotenv from 'dotenv';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
dotenv.config();
|
|
7
|
+
|
|
8
|
+
const DEFAULT_APP_DIR = path.join(os.homedir(), '.rilo');
|
|
9
|
+
|
|
10
|
+
export function parseEnvString(value, fallback) {
|
|
11
|
+
return typeof value === 'string' && value.length > 0 ? value : fallback;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function hasEnvValue(value) {
|
|
15
|
+
return value !== undefined && value !== null && value !== '';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/* c8 ignore start */
|
|
19
|
+
export function parseEnvNumber(value, fallback) {
|
|
20
|
+
if (!hasEnvValue(value)) {
|
|
21
|
+
return fallback;
|
|
22
|
+
}
|
|
23
|
+
const parsed = Number(value);
|
|
24
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function parseEnvBoolean(value, fallback = false) {
|
|
28
|
+
if (!hasEnvValue(value)) {
|
|
29
|
+
return fallback;
|
|
30
|
+
}
|
|
31
|
+
return String(value).toLowerCase() === 'true';
|
|
32
|
+
}
|
|
33
|
+
/* c8 ignore stop */
|
|
34
|
+
|
|
35
|
+
export function parseAllowedHosts(value, fallback = 'replicate.delivery,replicate.com') {
|
|
36
|
+
return parseEnvString(value, fallback)
|
|
37
|
+
.split(',')
|
|
38
|
+
.map((entry) => entry.trim().toLowerCase())
|
|
39
|
+
.filter(Boolean);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const env = {
|
|
43
|
+
replicateApiToken: parseEnvString(
|
|
44
|
+
process.env.SECRET_REPLICATE_API_TOKEN
|
|
45
|
+
|| process.env.RILO_REPLICATE_API_TOKEN
|
|
46
|
+
|| process.env.REPLICATE_API_TOKEN,
|
|
47
|
+
''
|
|
48
|
+
),
|
|
49
|
+
apiBearerToken: parseEnvString(
|
|
50
|
+
process.env.SECRET_API_BEARER_TOKEN
|
|
51
|
+
|| process.env.RILO_API_BEARER_TOKEN
|
|
52
|
+
|| process.env.API_BEARER_TOKEN,
|
|
53
|
+
''
|
|
54
|
+
),
|
|
55
|
+
port: parseEnvNumber(process.env.API_PORT || process.env.PORT, 3000),
|
|
56
|
+
webhookSecret: parseEnvString(process.env.WEBHOOK_SECRET, ''),
|
|
57
|
+
outputDir: parseEnvString(process.env.OUTPUT_DIR, path.join(DEFAULT_APP_DIR, 'output')),
|
|
58
|
+
projectsDir: parseEnvString(process.env.PROJECTS_DIR, path.join(DEFAULT_APP_DIR, 'projects')),
|
|
59
|
+
outputBackend: parseEnvString(
|
|
60
|
+
process.env.SECRET_OUTPUT_BACKEND
|
|
61
|
+
|| process.env.RILO_OUTPUT_BACKEND
|
|
62
|
+
|| process.env.OUTPUT_BACKEND,
|
|
63
|
+
'local'
|
|
64
|
+
),
|
|
65
|
+
firebaseProjectId: parseEnvString(
|
|
66
|
+
process.env.SECRET_FIREBASE_PROJECT_ID
|
|
67
|
+
|| process.env.RILO_FIREBASE_PROJECT_ID
|
|
68
|
+
|| process.env.FIREBASE_PROJECT_ID,
|
|
69
|
+
''
|
|
70
|
+
),
|
|
71
|
+
firebaseStorageBucket: parseEnvString(
|
|
72
|
+
process.env.SECRET_FIREBASE_STORAGE_BUCKET
|
|
73
|
+
|| process.env.RILO_FIREBASE_STORAGE_BUCKET
|
|
74
|
+
|| process.env.FIREBASE_STORAGE_BUCKET,
|
|
75
|
+
''
|
|
76
|
+
),
|
|
77
|
+
firebaseClientEmail: parseEnvString(
|
|
78
|
+
process.env.RILO_FIREBASE_CLIENT_EMAIL
|
|
79
|
+
|| process.env.FIREBASE_CLIENT_EMAIL,
|
|
80
|
+
''
|
|
81
|
+
),
|
|
82
|
+
firebasePrivateKey: parseEnvString(
|
|
83
|
+
process.env.RILO_FIREBASE_PRIVATE_KEY
|
|
84
|
+
|| process.env.FIREBASE_PRIVATE_KEY,
|
|
85
|
+
''
|
|
86
|
+
),
|
|
87
|
+
useWebhooks: parseEnvBoolean(process.env.USE_WEBHOOKS, false),
|
|
88
|
+
maxRetries: parseEnvNumber(process.env.MAX_RETRIES, 2),
|
|
89
|
+
retryDelayMs: parseEnvNumber(process.env.RETRY_DELAY_MS, 2500),
|
|
90
|
+
predictionPollIntervalMs: parseEnvNumber(process.env.PREDICTION_POLL_INTERVAL_MS, 1500),
|
|
91
|
+
predictionMaxWaitMs: parseEnvNumber(process.env.PREDICTION_MAX_WAIT_MS, 600000),
|
|
92
|
+
downloadTimeoutMs: parseEnvNumber(process.env.DOWNLOAD_TIMEOUT_MS, 20000),
|
|
93
|
+
downloadMaxBytes: parseEnvNumber(process.env.DOWNLOAD_MAX_BYTES, 104857600),
|
|
94
|
+
downloadAllowedHosts: parseAllowedHosts(process.env.DOWNLOAD_ALLOWED_HOSTS),
|
|
95
|
+
apiDefaultLogsLimit: parseEnvNumber(process.env.API_DEFAULT_LOGS_LIMIT, 100),
|
|
96
|
+
apiMaxLogsLimit: parseEnvNumber(process.env.API_MAX_LOGS_LIMIT, 1000),
|
|
97
|
+
ffmpegBin: parseEnvString(process.env.FFMPEG_BIN, 'ffmpeg'),
|
|
98
|
+
ffprobeBin: parseEnvString(process.env.FFPROBE_BIN, 'ffprobe'),
|
|
99
|
+
ffsubsyncBin: parseEnvString(process.env.FFSUBSYNC_BIN, 'ffsubsync')
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Merge stored settings (config.json + keystore) into the `env` object.
|
|
104
|
+
* Call this once at CLI/server startup, after env vars have been loaded.
|
|
105
|
+
*
|
|
106
|
+
* Precedence (highest → lowest):
|
|
107
|
+
* 1. Environment variables (already in `env` above — never overwritten)
|
|
108
|
+
* 2. ~/.rilo/config.json (public settings)
|
|
109
|
+
* 3. OS keystore / encrypted file (secure tokens)
|
|
110
|
+
* 4. Schema defaults (already reflected in `env` above)
|
|
111
|
+
*/
|
|
112
|
+
export async function applyStoredSettings() {
|
|
113
|
+
// Lazy imports to avoid circular deps and to keep env.js testable without FS
|
|
114
|
+
const { readPublicConfig } = await import('../store/settingsStore.js');
|
|
115
|
+
const { getSecret } = await import('./keystore.js');
|
|
116
|
+
const { PUBLIC_SETTINGS, SECURE_SETTINGS } = await import('./settingsSchema.js');
|
|
117
|
+
|
|
118
|
+
const storedConfig = await readPublicConfig();
|
|
119
|
+
|
|
120
|
+
// Apply public settings (only when no env var is set)
|
|
121
|
+
for (const setting of PUBLIC_SETTINGS) {
|
|
122
|
+
const hasEnvVar = setting.envNames.some((n) => process.env[n] !== undefined && process.env[n] !== '');
|
|
123
|
+
if (hasEnvVar) continue;
|
|
124
|
+
|
|
125
|
+
const stored = storedConfig[setting.configKey];
|
|
126
|
+
if (stored === undefined) continue;
|
|
127
|
+
|
|
128
|
+
// Map configKey → env property name (camelCase matches env object keys)
|
|
129
|
+
if (setting.id in env) {
|
|
130
|
+
env[setting.id] = setting.type === 'number' ? Number(stored) : stored;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Apply secure tokens (only when no env var is set)
|
|
135
|
+
for (const setting of SECURE_SETTINGS) {
|
|
136
|
+
const hasEnvVar = setting.envNames.some((n) => process.env[n] !== undefined && process.env[n] !== '');
|
|
137
|
+
if (hasEnvVar) continue;
|
|
138
|
+
|
|
139
|
+
if (env[setting.id]) continue; // already has a value
|
|
140
|
+
|
|
141
|
+
const stored = await getSecret(setting.keystoreKey);
|
|
142
|
+
if (stored) {
|
|
143
|
+
env[setting.id] = stored;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function assertRequiredEnv() {
|
|
149
|
+
if (!env.replicateApiToken) {
|
|
150
|
+
throw new Error('Missing REPLICATE_API_TOKEN in environment');
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function assertRequiredApiEnv() {
|
|
155
|
+
if (!env.apiBearerToken) {
|
|
156
|
+
throw new Error('Missing API_BEARER_TOKEN in environment');
|
|
157
|
+
}
|
|
158
|
+
}
|