mixdog 0.9.69 → 0.9.70

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.
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Media generation jobs.
3
+ *
4
+ * Image lanes answer in one request, video lanes submit + poll for minutes, so
5
+ * both run as background jobs with a polled snapshot instead of a blocking
6
+ * call. The desktop reads snapshots over the normal capability bridge; nothing
7
+ * here streams, which keeps a long video job independent of window lifetime.
8
+ */
9
+ import { randomUUID } from 'crypto';
10
+ import { mediaError, resolveMediaRequest } from './lanes.mjs';
11
+ import { saveMediaAsset } from './store.mjs';
12
+
13
+ const JOBS = new Map();
14
+ // Finished jobs stay readable for a while so a slow poller still sees the
15
+ // terminal state, then drop out to bound memory.
16
+ const TERMINAL_TTL_MS = 10 * 60_000;
17
+ const MAX_PROMPT_CHARS = 8_000;
18
+
19
+ function snapshot(job) {
20
+ return {
21
+ id: job.id,
22
+ status: job.status,
23
+ kind: job.kind,
24
+ lane: job.lane,
25
+ model: job.model,
26
+ prompt: job.prompt,
27
+ options: job.options,
28
+ progress: job.progress,
29
+ assetId: job.assetId,
30
+ error: job.error,
31
+ errorCode: job.errorCode,
32
+ startedAt: job.startedAt,
33
+ endedAt: job.endedAt,
34
+ };
35
+ }
36
+
37
+ function sweep() {
38
+ const now = Date.now();
39
+ for (const [id, job] of JOBS) {
40
+ if (job.endedAt && now - job.endedAt > TERMINAL_TTL_MS) JOBS.delete(id);
41
+ }
42
+ }
43
+
44
+ async function runAdapter({ lane, kind, model, prompt, options, references, signal, onProgress }) {
45
+ if (lane.id === 'grok-oauth' || lane.id === 'xai') {
46
+ const adapter = await import('./adapters/xai-media.mjs');
47
+ return kind === 'video'
48
+ ? await adapter.generateVideo({ lane: lane.id, model, prompt, options, references, signal, onProgress })
49
+ : await adapter.generateImage({ lane: lane.id, model, prompt, options, references, signal });
50
+ }
51
+ if (lane.id === 'openai-oauth') {
52
+ const adapter = await import('./adapters/codex-image.mjs');
53
+ return await adapter.generateImage({ model, prompt, options, references, signal });
54
+ }
55
+ if (lane.id === 'gemini') {
56
+ if (kind === 'video') {
57
+ const adapter = await import('./adapters/gemini-video.mjs');
58
+ return await adapter.generateVideo({ model, prompt, options, references, signal, onProgress });
59
+ }
60
+ const adapter = await import('./adapters/gemini-image.mjs');
61
+ return await adapter.generateImage({ model, prompt, options, references, signal });
62
+ }
63
+ throw mediaError(`lane "${lane.id}" has no adapter`, 'MEDIA_LANE_UNKNOWN');
64
+ }
65
+
66
+ /**
67
+ * Validate + start one generation. Returns the initial snapshot immediately;
68
+ * the caller polls getMediaJob for progress and the finished asset id.
69
+ */
70
+ export function startMediaJob({ lane: laneId, kind, model, prompt, options = {}, references = [] } = {}) {
71
+ const text = String(prompt || '').trim();
72
+ if (!text) throw mediaError('prompt is required', 'MEDIA_PROMPT_REQUIRED');
73
+ if (text.length > MAX_PROMPT_CHARS) throw mediaError('prompt is too long', 'MEDIA_PROMPT_TOO_LONG');
74
+ const resolved = resolveMediaRequest({ lane: laneId, kind, model });
75
+ // Reference images: the MODEL publishes its own cap (Veo takes one start
76
+ // frame, Grok ref2v takes seven), so the bound follows the resolved model.
77
+ const modelEntry = resolved.spec.models.find((entry) => entry.id === resolved.model);
78
+ const maxRefs = Number(
79
+ modelEntry?.controls?.maxReferences ?? resolved.spec.controls?.maxReferences ?? 0,
80
+ ) || (resolved.kind === 'video' ? 7 : 5);
81
+ const refs = (Array.isArray(references) ? references : [])
82
+ .filter((ref) => typeof ref?.base64 === 'string' && ref.base64.length > 0)
83
+ .slice(0, maxRefs)
84
+ .map((ref) => ({ base64: ref.base64, mime: String(ref.mime || 'image/png') }));
85
+ sweep();
86
+
87
+ const controller = new AbortController();
88
+ const job = {
89
+ id: randomUUID(),
90
+ status: 'running',
91
+ kind: resolved.kind,
92
+ lane: resolved.lane.id,
93
+ model: resolved.model,
94
+ prompt: text,
95
+ options: { ...options },
96
+ referenceCount: refs.length,
97
+ progress: 0,
98
+ assetId: null,
99
+ error: null,
100
+ errorCode: null,
101
+ startedAt: Date.now(),
102
+ endedAt: null,
103
+ controller,
104
+ };
105
+ JOBS.set(job.id, job);
106
+
107
+ (async () => {
108
+ try {
109
+ const result = await runAdapter({
110
+ lane: resolved.lane,
111
+ kind: resolved.kind,
112
+ model: resolved.model,
113
+ prompt: text,
114
+ options,
115
+ references: refs,
116
+ signal: controller.signal,
117
+ onProgress: (value) => {
118
+ const raw = Number(value);
119
+ if (!Number.isFinite(raw)) return;
120
+ // Lanes report either a 0-1 fraction or a percentage, and a poll can
121
+ // come back stale — normalize and never let the rail walk backwards.
122
+ const next = Math.round(Math.max(0, Math.min(100, raw > 1 ? raw : raw * 100)));
123
+ if (next > job.progress) job.progress = next;
124
+ },
125
+ });
126
+ const asset = saveMediaAsset({
127
+ kind: resolved.kind,
128
+ lane: resolved.lane.id,
129
+ model: resolved.model,
130
+ prompt: text,
131
+ options: job.options,
132
+ mime: result.mime,
133
+ bytes: result.bytes,
134
+ meta: {
135
+ ...(result.revisedPrompt ? { revisedPrompt: result.revisedPrompt } : {}),
136
+ ...(result.durationSeconds ? { durationSeconds: result.durationSeconds } : {}),
137
+ },
138
+ });
139
+ job.assetId = asset.id;
140
+ job.progress = 100;
141
+ job.status = 'done';
142
+ } catch (err) {
143
+ const canceled = controller.signal.aborted || err?.code === 'MEDIA_CANCELED' || err?.name === 'AbortError';
144
+ job.status = canceled ? 'canceled' : 'failed';
145
+ job.error = canceled ? 'canceled' : String(err?.message || err).slice(0, 500);
146
+ job.errorCode = err?.code || null;
147
+ } finally {
148
+ job.endedAt = Date.now();
149
+ }
150
+ })();
151
+
152
+ return snapshot(job);
153
+ }
154
+
155
+ export function getMediaJob(id) {
156
+ const job = JOBS.get(String(id || ''));
157
+ return job ? snapshot(job) : null;
158
+ }
159
+
160
+ export function listMediaJobs() {
161
+ sweep();
162
+ return [...JOBS.values()]
163
+ .sort((a, b) => b.startedAt - a.startedAt)
164
+ .map(snapshot);
165
+ }
166
+
167
+ export function cancelMediaJob(id) {
168
+ const job = JOBS.get(String(id || ''));
169
+ if (!job) return { id, canceled: false };
170
+ if (job.status === 'running') {
171
+ try { job.controller.abort(); } catch {}
172
+ }
173
+ return { id: job.id, canceled: job.status === 'running' };
174
+ }
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Media generation lanes (image / video).
3
+ *
4
+ * A lane is one credential path we can actually generate with. Auth state is
5
+ * resolved from the SAME stores the chat providers use (OAuth token stores and
6
+ * the keychain-backed API keys), so the Studio only ever offers lanes the user
7
+ * already signed in to. No lane owns a fallback to another lane's credential —
8
+ * an unauthenticated lane fails closed.
9
+ */
10
+ import { getAgentApiKey } from '../shared/provider-api-key.mjs';
11
+ // Credential probes are file-level checks: the catalog must not drag the whole
12
+ // provider graph in just to render lane availability.
13
+ import {
14
+ hasGrokOAuthCredentials,
15
+ hasOpenAIOAuthCredentials,
16
+ } from '../agent/orchestrator/providers/oauth-credential-probes.mjs';
17
+
18
+ export const MEDIA_KINDS = Object.freeze(['image', 'video']);
19
+
20
+ const GROK_IMAGE_MODELS = Object.freeze([
21
+ Object.freeze({ id: 'grok-imagine-image', label: 'Imagine Image' }),
22
+ Object.freeze({ id: 'grok-imagine-image-quality', label: 'Imagine Image Q' }),
23
+ ]);
24
+ // Per-model control overrides: video contracts differ per model (fixed clip
25
+ // lengths on Veo, 1080p only on Grok 1.5, no length knob on Omni), so each
26
+ // entry carries what it actually accepts instead of one lane-wide guess.
27
+ const GROK_VIDEO_MODELS = Object.freeze([
28
+ Object.freeze({
29
+ id: 'grok-imagine-video',
30
+ label: 'Imagine Video',
31
+ controls: Object.freeze({ resolution: Object.freeze(['480p', '720p']) }),
32
+ }),
33
+ ]);
34
+ // grok-imagine-video-1.5 is intentionally absent: upstream rejects prompt-only
35
+ // text-to-video on it ("Text-to-video is not supported for this model"), and it
36
+ // only works through an image/reference input we do not accept yet.
37
+
38
+ // Aspect/resolution vocabularies are lane-native: xAI takes aspect_ratio +
39
+ // resolution, Gemini/Codex take pixel sizes. The UI renders whatever the lane
40
+ // declares instead of inventing a cross-provider size model.
41
+ const GROK_ASPECTS = Object.freeze(['auto', '1:1', '16:9', '9:16', '4:3', '3:4', '3:2', '2:3']);
42
+
43
+ const LANES = Object.freeze([
44
+ Object.freeze({
45
+ id: 'grok-oauth',
46
+ label: 'Grok Imagine (OAuth)',
47
+ auth: Object.freeze({ type: 'oauth', provider: 'grok-oauth' }),
48
+ image: Object.freeze({
49
+ models: GROK_IMAGE_MODELS,
50
+ defaultModel: 'grok-imagine-image',
51
+ controls: Object.freeze({ aspectRatio: GROK_ASPECTS, resolution: Object.freeze(['1k', '2k']), maxReferences: 5 }),
52
+ }),
53
+ video: Object.freeze({
54
+ models: GROK_VIDEO_MODELS,
55
+ defaultModel: 'grok-imagine-video',
56
+ controls: Object.freeze({
57
+ aspectRatio: GROK_ASPECTS,
58
+ resolution: Object.freeze(['480p', '720p', '1080p']),
59
+ durationRange: Object.freeze([1, 15]),
60
+ // 1 ref = image-to-video, 2-7 = reference-to-video.
61
+ maxReferences: 7,
62
+ }),
63
+ }),
64
+ }),
65
+ Object.freeze({
66
+ id: 'xai',
67
+ label: 'Grok Imagine (API key)',
68
+ auth: Object.freeze({ type: 'api-key', provider: 'xai' }),
69
+ image: Object.freeze({
70
+ models: GROK_IMAGE_MODELS,
71
+ defaultModel: 'grok-imagine-image',
72
+ controls: Object.freeze({ aspectRatio: GROK_ASPECTS, resolution: Object.freeze(['1k', '2k']), maxReferences: 5 }),
73
+ }),
74
+ video: Object.freeze({
75
+ models: GROK_VIDEO_MODELS,
76
+ defaultModel: 'grok-imagine-video',
77
+ controls: Object.freeze({
78
+ aspectRatio: GROK_ASPECTS,
79
+ resolution: Object.freeze(['480p', '720p', '1080p']),
80
+ durationRange: Object.freeze([1, 15]),
81
+ maxReferences: 7,
82
+ }),
83
+ }),
84
+ }),
85
+ Object.freeze({
86
+ id: 'openai-oauth',
87
+ label: 'ChatGPT Image (OAuth)',
88
+ auth: Object.freeze({ type: 'oauth', provider: 'openai-oauth' }),
89
+ image: Object.freeze({
90
+ // The hosted image_generation tool runs on the Codex responses backend,
91
+ // so the id is a chat model driving the tool. The catalog exposes it as
92
+ // an IMAGE choice (quality vs fast) — listing raw chat models here read
93
+ // as "why is a chat model in an image picker".
94
+ models: Object.freeze([
95
+ Object.freeze({ id: 'gpt-5.6-sol', label: 'GPT Image' }),
96
+ Object.freeze({ id: 'gpt-5.4-mini', label: 'GPT Image Fast' }),
97
+ ]),
98
+ defaultModel: 'gpt-5.6-sol',
99
+ controls: Object.freeze({
100
+ size: Object.freeze(['auto', '1024x1024', '1536x1024', '1024x1536']),
101
+ quality: Object.freeze(['auto', 'low', 'medium', 'high']),
102
+ maxReferences: 5,
103
+ }),
104
+ }),
105
+ }),
106
+ Object.freeze({
107
+ id: 'gemini',
108
+ label: 'Gemini Image (API key)',
109
+ auth: Object.freeze({ type: 'api-key', provider: 'gemini' }),
110
+ image: Object.freeze({
111
+ models: Object.freeze([
112
+ Object.freeze({ id: 'gemini-3-pro-image', label: 'Nano Banana Pro' }),
113
+ Object.freeze({ id: 'gemini-3.1-flash-image', label: 'Nano Banana 2' }),
114
+ Object.freeze({ id: 'gemini-2.5-flash-image', label: 'Nano Banana 2.5' }),
115
+ ]),
116
+ defaultModel: 'gemini-3.1-flash-image',
117
+ controls: Object.freeze({
118
+ aspectRatio: Object.freeze(['auto', '1:1', '16:9', '9:16', '4:3', '3:4']),
119
+ // Reference caps the image edit path at 3 inline references.
120
+ maxReferences: 3,
121
+ }),
122
+ }),
123
+ video: Object.freeze({
124
+ // Omni Flash answers inline on the Interactions API; the Veo trio runs as
125
+ // a long-running predict. Both are paid-tier only on this key.
126
+ models: Object.freeze([
127
+ Object.freeze({
128
+ id: 'gemini-omni-flash-preview',
129
+ label: 'Omni Flash',
130
+ // Omni picks its own clip length; only the frame shape is ours.
131
+ controls: Object.freeze({
132
+ resolution: Object.freeze([]),
133
+ durations: Object.freeze([]),
134
+ maxReferences: 3,
135
+ }),
136
+ }),
137
+ Object.freeze({ id: 'veo-3.1-fast-generate-preview', label: 'Veo 3.1 Fast' }),
138
+ Object.freeze({ id: 'veo-3.1-generate-preview', label: 'Veo 3.1' }),
139
+ Object.freeze({ id: 'veo-3.1-lite-generate-preview', label: 'Veo 3.1 Lite' }),
140
+ ]),
141
+ defaultModel: 'gemini-omni-flash-preview',
142
+ controls: Object.freeze({
143
+ aspectRatio: Object.freeze(['16:9', '9:16']),
144
+ resolution: Object.freeze(['720p', '1080p']),
145
+ // Veo 3.1 accepts discrete clip lengths, not a free range.
146
+ durations: Object.freeze([4, 6, 8]),
147
+ // Veo takes a single start frame; Omni overrides this below.
148
+ maxReferences: 1,
149
+ }),
150
+ }),
151
+ }),
152
+ ]);
153
+
154
+ const LANE_BY_ID = new Map(LANES.map((lane) => [lane.id, lane]));
155
+
156
+ function laneAuthenticated(lane) {
157
+ try {
158
+ if (lane.auth.type === 'api-key') return Boolean(getAgentApiKey(lane.auth.provider));
159
+ if (lane.auth.provider === 'grok-oauth') return hasGrokOAuthCredentials();
160
+ if (lane.auth.provider === 'openai-oauth') return hasOpenAIOAuthCredentials();
161
+ } catch {
162
+ return false;
163
+ }
164
+ return false;
165
+ }
166
+
167
+ function laneKindView(lane, kind) {
168
+ const spec = lane[kind];
169
+ if (!spec) return null;
170
+ const laneControls = spec.controls || {};
171
+ return {
172
+ // Each model publishes its EFFECTIVE controls (lane defaults + its own
173
+ // overrides) so the UI never offers a knob the model rejects.
174
+ models: spec.models.map((model) => ({
175
+ id: model.id,
176
+ label: model.label,
177
+ controls: JSON.parse(JSON.stringify({ ...laneControls, ...(model.controls || {}) })),
178
+ })),
179
+ defaultModel: spec.defaultModel,
180
+ controls: JSON.parse(JSON.stringify(laneControls)),
181
+ };
182
+ }
183
+
184
+ /** Lane catalog with live auth state; the caller filters on `authenticated`. */
185
+ export function listMediaLanes() {
186
+ return LANES.map((lane) => ({
187
+ id: lane.id,
188
+ label: lane.label,
189
+ authType: lane.auth.type,
190
+ authProvider: lane.auth.provider,
191
+ authenticated: laneAuthenticated(lane),
192
+ kinds: MEDIA_KINDS.filter((kind) => Boolean(lane[kind])),
193
+ image: laneKindView(lane, 'image'),
194
+ video: laneKindView(lane, 'video'),
195
+ }));
196
+ }
197
+
198
+ export function getMediaLane(laneId) {
199
+ return LANE_BY_ID.get(String(laneId || '').trim()) || null;
200
+ }
201
+
202
+ /**
203
+ * Resolve + validate a generation request against the lane contract. Throws a
204
+ * coded error rather than letting an unsupported model reach the network.
205
+ */
206
+ export function resolveMediaRequest({ lane: laneId, kind, model } = {}) {
207
+ const kindName = String(kind || '').trim();
208
+ if (!MEDIA_KINDS.includes(kindName)) {
209
+ throw mediaError(`unsupported media kind "${kindName}"`, 'MEDIA_KIND_UNSUPPORTED');
210
+ }
211
+ const lane = getMediaLane(laneId);
212
+ if (!lane) throw mediaError(`unknown media lane "${laneId}"`, 'MEDIA_LANE_UNKNOWN');
213
+ const spec = lane[kindName];
214
+ if (!spec) throw mediaError(`${lane.id} does not support ${kindName}`, 'MEDIA_KIND_UNSUPPORTED');
215
+ if (!laneAuthenticated(lane)) {
216
+ throw mediaError(`${lane.label} is not authenticated — sign in from Settings → Providers first`, 'MEDIA_LANE_UNAUTHENTICATED');
217
+ }
218
+ const requested = String(model || '').trim() || spec.defaultModel;
219
+ if (!spec.models.some((entry) => entry.id === requested)) {
220
+ throw mediaError(`model "${requested}" is not available on ${lane.id}/${kindName}`, 'MEDIA_MODEL_UNSUPPORTED');
221
+ }
222
+ return { lane, kind: kindName, model: requested, spec };
223
+ }
224
+
225
+ export function mediaError(message, code, status = 400) {
226
+ const err = new Error(message);
227
+ err.code = code;
228
+ err.status = status;
229
+ return err;
230
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * On-disk store for generated media.
3
+ *
4
+ * Assets live as real files under <data>/media/assets so nothing large is kept
5
+ * in memory or in a session transcript; index.json holds the metadata list
6
+ * (newest first) and is written atomically under a file lock because the
7
+ * desktop and CLI can generate concurrently.
8
+ */
9
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, statSync } from 'fs';
10
+ import { join } from 'path';
11
+ import { randomUUID } from 'crypto';
12
+ import { spawn } from 'child_process';
13
+ import { resolvePluginData } from '../shared/plugin-paths.mjs';
14
+ import { writeJsonAtomicSync, withFileLockSync } from '../shared/atomic-file.mjs';
15
+
16
+ const MAX_INDEX_ENTRIES = 2_000;
17
+ // Renderer transport is base64 over IPC: refuse to inline anything larger.
18
+ const MAX_INLINE_BYTES = 48 * 1024 * 1024;
19
+
20
+ const EXTENSIONS = {
21
+ 'image/png': 'png',
22
+ 'image/jpeg': 'jpg',
23
+ 'image/webp': 'webp',
24
+ 'video/mp4': 'mp4',
25
+ };
26
+
27
+ function mediaDir() {
28
+ return join(resolvePluginData(), 'media');
29
+ }
30
+
31
+ function assetsDir() {
32
+ return join(mediaDir(), 'assets');
33
+ }
34
+
35
+ function indexPath() {
36
+ return join(mediaDir(), 'index.json');
37
+ }
38
+
39
+ function indexLockPath() {
40
+ return `${indexPath()}.lock`;
41
+ }
42
+
43
+ function ensureDirs() {
44
+ mkdirSync(assetsDir(), { recursive: true });
45
+ }
46
+
47
+ function readIndex() {
48
+ try {
49
+ const raw = readFileSync(indexPath(), 'utf8');
50
+ const parsed = JSON.parse(raw);
51
+ return Array.isArray(parsed?.assets) ? parsed.assets : [];
52
+ } catch {
53
+ return [];
54
+ }
55
+ }
56
+
57
+ function writeIndex(assets) {
58
+ writeJsonAtomicSync(indexPath(), { version: 1, assets });
59
+ }
60
+
61
+ function extensionFor(mime) {
62
+ return EXTENSIONS[String(mime || '').toLowerCase()] || 'bin';
63
+ }
64
+
65
+ /** Persist one generated artifact and return its index entry. */
66
+ export function saveMediaAsset({ kind, lane, model, prompt, options = {}, mime, bytes, meta = {} }) {
67
+ ensureDirs();
68
+ const id = randomUUID();
69
+ const file = `${id}.${extensionFor(mime)}`;
70
+ writeFileSync(join(assetsDir(), file), bytes);
71
+ const entry = {
72
+ id,
73
+ file,
74
+ kind,
75
+ lane,
76
+ model,
77
+ prompt: String(prompt || '').slice(0, 4_000),
78
+ options,
79
+ mime,
80
+ bytes: bytes.length,
81
+ createdAt: Date.now(),
82
+ ...meta,
83
+ };
84
+ withFileLockSync(indexLockPath(), () => {
85
+ const assets = [entry, ...readIndex()];
86
+ const kept = assets.slice(0, MAX_INDEX_ENTRIES);
87
+ for (const dropped of assets.slice(MAX_INDEX_ENTRIES)) {
88
+ try { unlinkSync(join(assetsDir(), dropped.file)); } catch {}
89
+ }
90
+ writeIndex(kept);
91
+ });
92
+ return entry;
93
+ }
94
+
95
+ export function listMediaAssets({ limit = 60, offset = 0, kind = null } = {}) {
96
+ const all = readIndex().filter((entry) => (kind ? entry.kind === kind : true));
97
+ const start = Math.max(0, Math.trunc(Number(offset) || 0));
98
+ const count = Math.min(200, Math.max(1, Math.trunc(Number(limit) || 60)));
99
+ return { total: all.length, assets: all.slice(start, start + count) };
100
+ }
101
+
102
+ export function getMediaAsset(id) {
103
+ return readIndex().find((entry) => entry.id === id) || null;
104
+ }
105
+
106
+ /** Inline an asset for the renderer (base64 + mime). Large files are refused. */
107
+ export function readMediaAsset(id) {
108
+ const entry = getMediaAsset(id);
109
+ if (!entry) return null;
110
+ const path = join(assetsDir(), entry.file);
111
+ if (!existsSync(path)) return null;
112
+ const size = statSync(path).size;
113
+ if (size > MAX_INLINE_BYTES) {
114
+ const err = new Error('media asset is too large to preview');
115
+ err.code = 'MEDIA_ASSET_TOO_LARGE';
116
+ throw err;
117
+ }
118
+ return { ...entry, path, base64: readFileSync(path).toString('base64') };
119
+ }
120
+
121
+ export function mediaAssetPath(id) {
122
+ const entry = getMediaAsset(id);
123
+ return entry ? join(assetsDir(), entry.file) : null;
124
+ }
125
+
126
+ /**
127
+ * Hand an asset to the OS viewer. The path is resolved from our own index, so
128
+ * no caller-supplied path ever reaches the shell.
129
+ */
130
+ export function openMediaAsset(id) {
131
+ const path = mediaAssetPath(id);
132
+ if (!path || !existsSync(path)) return { id, opened: false };
133
+ return { id, opened: openWithOs(path) };
134
+ }
135
+
136
+ /** Open the assets directory itself (Studio → folder button). */
137
+ export function openMediaFolder() {
138
+ ensureDirs();
139
+ return { path: assetsDir(), opened: openWithOs(assetsDir()) };
140
+ }
141
+
142
+ function openWithOs(path) {
143
+ const [command, args] = process.platform === 'win32'
144
+ ? ['cmd', ['/c', 'start', '', path]]
145
+ : process.platform === 'darwin'
146
+ ? ['open', [path]]
147
+ : ['xdg-open', [path]];
148
+ const child = spawn(command, args, { detached: true, stdio: 'ignore', windowsHide: true });
149
+ child.unref();
150
+ return true;
151
+ }
152
+
153
+ export function deleteMediaAsset(id) {
154
+ let removed = false;
155
+ withFileLockSync(indexLockPath(), () => {
156
+ const assets = readIndex();
157
+ const entry = assets.find((row) => row.id === id);
158
+ if (!entry) return;
159
+ try { unlinkSync(join(assetsDir(), entry.file)); } catch {}
160
+ writeIndex(assets.filter((row) => row.id !== id));
161
+ removed = true;
162
+ });
163
+ return { id, removed };
164
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Upstream failure translation for media lanes.
3
+ *
4
+ * A credential can be present and still unusable (exhausted credits, billing
5
+ * caps, per-account gating). Those come back as raw provider JSON, so the lane
6
+ * maps them to one legible sentence instead of leaking a wall of upstream text
7
+ * into the Studio.
8
+ */
9
+ import { mediaError } from './lanes.mjs';
10
+
11
+ const CREDIT_HINTS = /credit|spending limit|billing|quota|insufficient|payment/i;
12
+
13
+ function reason(text) {
14
+ try {
15
+ const parsed = JSON.parse(text);
16
+ const message = parsed?.error?.message || parsed?.error || parsed?.message;
17
+ return typeof message === 'string' ? message : '';
18
+ } catch {
19
+ return '';
20
+ }
21
+ }
22
+
23
+ /** Build the error a lane throws for a non-OK upstream response. */
24
+ export function upstreamError(label, status, text) {
25
+ const detail = reason(text) || String(text || '').slice(0, 300);
26
+ if (status === 401 || status === 403) {
27
+ if (CREDIT_HINTS.test(detail)) {
28
+ return mediaError(`${label}: account has no usable balance — ${detail}`, 'MEDIA_BILLING_BLOCKED', status);
29
+ }
30
+ return mediaError(`${label}: authentication rejected — sign in again in Settings → Providers`, 'MEDIA_AUTH_REJECTED', status);
31
+ }
32
+ if (status === 402 || CREDIT_HINTS.test(detail)) {
33
+ return mediaError(`${label}: account has no usable balance — ${detail}`, 'MEDIA_BILLING_BLOCKED', status || 402);
34
+ }
35
+ if (status === 429) {
36
+ return mediaError(`${label}: rate limited — retry in a moment`, 'MEDIA_RATE_LIMITED', 429);
37
+ }
38
+ return mediaError(`${label} failed (${status})${detail ? `: ${detail}` : ''}`, 'MEDIA_UPSTREAM_FAILED', status);
39
+ }
@@ -17,13 +17,24 @@ import { runScheduleSession } from '../runtime/shared/schedule-session-run.mjs';
17
17
  // API object; the mutating admin helpers are imported directly here and the
18
18
  // runtime injects only the closure-owned callbacks (backend flush, channel
19
19
  // worker handle, soft reload).
20
- export function createChannelConfigApi({ flushBackendSave, channels, reloadChannelsSoon, ensureAutomationRuntime = () => {} }) {
20
+ export function createChannelConfigApi({
21
+ flushBackendSave,
22
+ channels,
23
+ reloadChannelsSoon,
24
+ ensureAutomationRuntime = () => {},
25
+ awaitKeychainPrewarm = async () => {},
26
+ }) {
21
27
  return {
22
28
  async getChannelSetup() {
23
29
  // Flush a pending debounced backend switch first so setup readers
24
30
  // (Settings → Channel Setting, remote toggles) never observe the
25
31
  // previous backend during the 150ms debounce window.
26
32
  try { await flushBackendSave(); } catch {}
33
+ // Bot tokens live in the OS keychain. Reading them before the batch
34
+ // prewarm lands costs one SYNCHRONOUS PowerShell DPAPI decrypt each
35
+ // (measured 971ms + 579ms on a cold desktop boot, blocking the whole
36
+ // event loop); waiting for the prewarm serves both from its cache.
37
+ try { await awaitKeychainPrewarm(); } catch {}
27
38
  return channelSetup();
28
39
  },
29
40
  getChannelWorkerStatus() {
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Media studio API (image / video generation) exposed on the session runtime.
3
+ *
4
+ * The media graph (lane catalog, adapters, asset store) is imported on first
5
+ * use: a session that never opens the Studio must not pay for it at boot.
6
+ */
7
+ let mediaModule = null;
8
+
9
+ async function media() {
10
+ mediaModule ??= await import('../runtime/media/index.mjs');
11
+ return mediaModule;
12
+ }
13
+
14
+ export function createMediaApi() {
15
+ return {
16
+ async listMediaLanes() {
17
+ return (await media()).listMediaLanes();
18
+ },
19
+ async startMediaJob(input) {
20
+ return (await media()).startMediaJob(input || {});
21
+ },
22
+ async getMediaJob(id) {
23
+ return (await media()).getMediaJob(id);
24
+ },
25
+ async listMediaJobs() {
26
+ return (await media()).listMediaJobs();
27
+ },
28
+ async cancelMediaJob(id) {
29
+ return (await media()).cancelMediaJob(id);
30
+ },
31
+ async listMediaAssets(options) {
32
+ return (await media()).listMediaAssets(options || {});
33
+ },
34
+ async readMediaAsset(id) {
35
+ return (await media()).readMediaAsset(id);
36
+ },
37
+ async deleteMediaAsset(id) {
38
+ return (await media()).deleteMediaAsset(id);
39
+ },
40
+ async openMediaAsset(id) {
41
+ return (await media()).openMediaAsset(id);
42
+ },
43
+ async openMediaFolder() {
44
+ return (await media()).openMediaFolder();
45
+ },
46
+ };
47
+ }