mixdog 0.9.87 → 0.9.88
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/package.json +13 -7
- package/scripts/tool-failures.mjs +60 -24
- package/src/agents/heavy-worker/AGENT.md +3 -4
- package/src/agents/worker/AGENT.md +1 -2
- package/src/cli.mjs +8 -0
- package/src/defaults/cycle3-review-prompt.md +4 -4
- package/src/rules/agent/00-core.md +3 -0
- package/src/rules/agent/42-cycle3-agent.md +5 -6
- package/src/rules/lead/01-general.md +2 -0
- package/src/rules/shared/01-tool.md +5 -1
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +3 -51
- package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +59 -0
- package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +2 -2
- package/src/runtime/agent/orchestrator/agent-runtime/title-completion.mjs +67 -0
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +57 -4
- package/src/runtime/agent/orchestrator/agent-trace-io.mjs +2 -3
- package/src/runtime/agent/orchestrator/config.mjs +29 -11
- package/src/runtime/agent/orchestrator/context/collect.mjs +1 -1
- package/src/runtime/agent/orchestrator/internal-agents.mjs +1 -1
- package/src/runtime/agent/orchestrator/mcp/client.mjs +0 -24
- package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +3 -3
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +25 -17
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +321 -22
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +35 -22
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +28 -23
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +10 -2
- package/src/runtime/agent/orchestrator/providers/grok-oauth-login.mjs +0 -1
- package/src/runtime/agent/orchestrator/providers/grok-oauth-tokens.mjs +0 -1
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +2 -5
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-native-blocks.mjs +27 -0
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +23 -0
- package/src/runtime/agent/orchestrator/providers/lib/stream-outcome.mjs +329 -0
- package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +23 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +107 -20
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +4 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +124 -12
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +19 -2
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +110 -75
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +96 -113
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +144 -10
- package/src/runtime/agent/orchestrator/session/lifecycle-scan.mjs +147 -117
- package/src/runtime/agent/orchestrator/session/loop/stop-hooks.mjs +88 -0
- package/src/runtime/agent/orchestrator/session/loop/termination.mjs +25 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +47 -6
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +485 -24
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +100 -2
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +28 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +80 -14
- package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +142 -2
- package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +128 -32
- package/src/runtime/agent/orchestrator/session/manager.mjs +5 -1
- package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +77 -4
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +70 -39
- package/src/runtime/agent/orchestrator/session/store/fs-probe.mjs +91 -0
- package/src/runtime/agent/orchestrator/session/store/listing.mjs +141 -64
- package/src/runtime/agent/orchestrator/session/store/live-state.mjs +253 -0
- package/src/runtime/agent/orchestrator/session/store/load-cache.mjs +211 -28
- package/src/runtime/agent/orchestrator/session/store/save-fault.mjs +313 -0
- package/src/runtime/agent/orchestrator/session/store/save-worker.mjs +622 -53
- package/src/runtime/agent/orchestrator/session/store/serialize.mjs +43 -10
- package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +53 -8
- package/src/runtime/agent/orchestrator/session/store/summary-rebuild-worker.mjs +20 -0
- package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +113 -9
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +476 -52
- package/src/runtime/agent/orchestrator/session/store.mjs +721 -196
- package/src/runtime/agent/orchestrator/session/token-native.mjs +2 -4
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +19 -0
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +4 -6
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +146 -90
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +6 -0
- package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +93 -17
- package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +5 -0
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +7 -7
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +2 -0
- package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +52 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +54 -51
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +14 -15
- package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +132 -8
- package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +67 -3
- package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +6 -1
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +203 -38
- package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +13 -1
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +73 -19
- package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
- package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +11 -8
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +12 -2
- package/src/runtime/media/lanes.mjs +2 -2
- package/src/runtime/media/renditions.mjs +101 -17
- package/src/runtime/media/renditions.test.mjs +54 -0
- package/src/runtime/media/store.mjs +111 -43
- package/src/runtime/media/store.test.mjs +53 -11
- package/src/runtime/memory/index.mjs +26 -50
- package/src/runtime/memory/lib/core-memory-candidates.mjs +2 -7
- package/src/runtime/memory/lib/core-memory-store.mjs +2 -9
- package/src/runtime/memory/lib/cycle-scheduler.mjs +10 -0
- package/src/runtime/memory/lib/embedding-provider.mjs +3 -3
- package/src/runtime/memory/lib/embedding-worker.mjs +60 -15
- package/src/runtime/memory/lib/http-router.mjs +4 -0
- package/src/runtime/memory/lib/ko-morph.mjs +49 -6
- package/src/runtime/memory/lib/memory-action-handlers.mjs +21 -18
- package/src/runtime/memory/lib/memory-cycle2.mjs +3 -3
- package/src/runtime/memory/lib/memory-cycle3.mjs +1 -3
- package/src/runtime/memory/lib/memory-recall-store.mjs +24 -0
- package/src/runtime/memory/lib/query-handlers.mjs +26 -28
- package/src/runtime/memory/tool-defs.mjs +3 -3
- package/src/runtime/shared/atomic-file.mjs +5 -1
- package/src/runtime/shared/child-guardian.mjs +52 -2
- package/src/runtime/shared/provider-api-key.mjs +22 -5
- package/src/runtime/shared/tool-execution-contract.mjs +53 -0
- package/src/session-runtime/config-helpers.mjs +6 -5
- package/src/session-runtime/lifecycle-api.mjs +16 -4
- package/src/session-runtime/media-api.mjs +31 -16
- package/src/session-runtime/prewarm.mjs +2 -24
- package/src/session-runtime/runtime-core.mjs +33 -15
- package/src/session-runtime/runtime-tunables.mjs +0 -3
- package/src/session-runtime/session-lifecycle.mjs +0 -6
- package/src/session-runtime/session-title.mjs +228 -0
- package/src/session-runtime/session-turn-api.mjs +11 -7
- package/src/session-runtime/workflow-agents-api.mjs +16 -0
- package/src/session-runtime/workflow.mjs +7 -5
- package/src/standalone/agent-tool/job-views.mjs +90 -36
- package/src/standalone/agent-tool/spawn-flow.mjs +25 -216
- package/src/standalone/agent-tool/worker-index.mjs +8 -0
- package/src/standalone/agent-tool/worker-rows.mjs +2 -1
- package/src/standalone/agent-tool.mjs +17 -17
- package/src/standalone/agent-watchdog-registry.mjs +19 -2
- package/src/standalone/channel-daemon.mjs +8 -0
- package/src/standalone/memory-runtime-proxy.mjs +4 -7
- package/src/standalone/projects.mjs +4 -1
- package/src/standalone/usage-dashboard.mjs +25 -3
- package/src/tui/App.jsx +5 -3
- package/src/tui/app/resume-picker.mjs +7 -2
- package/src/tui/app/slash-dispatch.mjs +13 -3
- package/src/tui/app/transcript-row-estimate.mjs +1 -1
- package/src/tui/app/transcript-window.mjs +17 -93
- package/src/tui/app/use-transcript-scroll.mjs +32 -2
- package/src/tui/app/use-transcript-window.mjs +64 -114
- package/src/tui/components/Markdown.jsx +19 -2
- package/src/tui/components/Message.jsx +8 -3
- package/src/tui/components/ToolExecution.jsx +7 -3
- package/src/tui/dist/index.mjs +233 -121
- package/src/tui/engine/live-share.mjs +15 -5
- package/src/tui/engine/render-timing.mjs +2 -2
- package/src/tui/engine/session-api-ext.mjs +74 -4
- package/src/tui/engine/session-flow.mjs +3 -1
- package/src/tui/engine.mjs +2 -2
- package/src/tui/hooks/useSharedTick.mjs +0 -2
- package/src/tui/index.jsx +5 -5
- package/src/ui/statusline-agents.mjs +44 -11
- package/src/vendor/statusline/bin/statusline-route.mjs +26 -3
- package/src/workflows/default/WORKFLOW.md +3 -3
- package/src/workflows/solo/WORKFLOW.md +1 -1
- package/src/runtime/media/index.mjs +0 -18
- package/src/runtime/memory/lib/embedding-warmup.mjs +0 -68
- package/src/standalone/agent-shard/shard-child.mjs +0 -300
- package/src/standalone/agent-shard/shard-pool.mjs +0 -443
|
@@ -214,6 +214,7 @@ export function execShellCommand({
|
|
|
214
214
|
let failureReason = null;
|
|
215
215
|
let spawnError = null;
|
|
216
216
|
let pendingChildError = null;
|
|
217
|
+
let childGuardian = null;
|
|
217
218
|
let settle = null;
|
|
218
219
|
let settled = false;
|
|
219
220
|
let timer = null;
|
|
@@ -398,10 +399,11 @@ export function execShellCommand({
|
|
|
398
399
|
child = spawned.child;
|
|
399
400
|
spawned.adoptErrorHandler(_onChildError);
|
|
400
401
|
}
|
|
401
|
-
startChildGuardian({
|
|
402
|
+
childGuardian = startChildGuardian({
|
|
402
403
|
childPid: child.pid,
|
|
403
404
|
childGroupPid: child.pid,
|
|
404
405
|
label: 'shell-command',
|
|
406
|
+
protectHostMemory: true,
|
|
405
407
|
});
|
|
406
408
|
// Windows has no process-group handle. Begin observing while the owned
|
|
407
409
|
// root still exists so descendants can be proven before root exit.
|
|
@@ -579,10 +581,18 @@ export function execShellCommand({
|
|
|
579
581
|
// pool when clean, tree-kills it otherwise) and the lease frees after.
|
|
580
582
|
const lease = resourceLease;
|
|
581
583
|
resourceLease = null;
|
|
584
|
+
// The standby root survives this command and may return to the pool.
|
|
585
|
+
// Stop this command-scoped guardian so every reuse does not leave
|
|
586
|
+
// another detached Node process watching the same pwsh forever.
|
|
587
|
+
const standbyGuardian = childGuardian;
|
|
588
|
+
childGuardian = null;
|
|
582
589
|
void _standby.recycle()
|
|
583
590
|
.catch(() => false)
|
|
584
|
-
.then(() => { try { return Promise.resolve(lease?.release()).catch(() => {}); } catch { return null; } })
|
|
591
|
+
.then(() => { try { return Promise.resolve(lease?.release()).catch(() => {}); } catch { return null; } })
|
|
592
|
+
.finally(() => standbyGuardian?.stop?.());
|
|
585
593
|
} else {
|
|
594
|
+
childGuardian?.stop?.();
|
|
595
|
+
childGuardian = null;
|
|
586
596
|
const leaseSettlement = resourceLeaseSettlement
|
|
587
597
|
|| releaseResourceLeaseWhenTreeQuiescent(child.pid);
|
|
588
598
|
leaseSettlement.tracker?.rootExited?.();
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* already signed in to. No lane owns a fallback to another lane's credential —
|
|
8
8
|
* an unauthenticated lane fails closed.
|
|
9
9
|
*/
|
|
10
|
-
import {
|
|
10
|
+
import { hasAgentApiKey } from '../shared/provider-api-key.mjs';
|
|
11
11
|
// Credential probes are file-level checks: the catalog must not drag the whole
|
|
12
12
|
// provider graph in just to render lane availability.
|
|
13
13
|
import {
|
|
@@ -155,7 +155,7 @@ const LANE_BY_ID = new Map(LANES.map((lane) => [lane.id, lane]));
|
|
|
155
155
|
|
|
156
156
|
function laneAuthenticated(lane) {
|
|
157
157
|
try {
|
|
158
|
-
if (lane.auth.type === 'api-key') return
|
|
158
|
+
if (lane.auth.type === 'api-key') return hasAgentApiKey(lane.auth.provider);
|
|
159
159
|
if (lane.auth.provider === 'grok-oauth') return hasGrokOAuthCredentials();
|
|
160
160
|
if (lane.auth.provider === 'openai-oauth') return hasOpenAIOAuthCredentials();
|
|
161
161
|
} catch {
|
|
@@ -26,9 +26,15 @@ const SPECS = {
|
|
|
26
26
|
};
|
|
27
27
|
// Probe order when reading a cached rendition: sharp writes webp, the
|
|
28
28
|
// ffmpeg-only poster path writes png.
|
|
29
|
-
const EXTENSION_MIME = {
|
|
29
|
+
const EXTENSION_MIME = {
|
|
30
|
+
'.webp': 'image/webp',
|
|
31
|
+
'.png': 'image/png',
|
|
32
|
+
'.jpg': 'image/jpeg',
|
|
33
|
+
};
|
|
30
34
|
const POSTER_TIMEOUT_MS = 20_000;
|
|
31
35
|
const MAX_POSTER_BYTES = 32 * 1024 * 1024;
|
|
36
|
+
const FAILED_RENDITION_COOLDOWN_MS = 30_000;
|
|
37
|
+
const MAX_CACHED_RENDITION_BYTES = 4 * 1024 * 1024;
|
|
32
38
|
|
|
33
39
|
const MEDIA_VARIANTS = Object.keys(SPECS);
|
|
34
40
|
|
|
@@ -93,6 +99,40 @@ function writeRendition(cacheDir, variant, id, extension, buffer) {
|
|
|
93
99
|
return { path, mime: EXTENSION_MIME[extension], bytes: buffer.length };
|
|
94
100
|
}
|
|
95
101
|
|
|
102
|
+
export function createPriorityScheduler(limit = 2) {
|
|
103
|
+
const foreground = [];
|
|
104
|
+
const background = [];
|
|
105
|
+
let active = 0;
|
|
106
|
+
let backgroundActive = 0;
|
|
107
|
+
const concurrency = Math.max(1, limit);
|
|
108
|
+
const backgroundConcurrency = Math.max(1, concurrency - 1);
|
|
109
|
+
const pump = () => {
|
|
110
|
+
while (active < concurrency) {
|
|
111
|
+
const foregroundEntry = foreground.shift();
|
|
112
|
+
const entry = foregroundEntry
|
|
113
|
+
?? (backgroundActive < backgroundConcurrency ? background.shift() : null);
|
|
114
|
+
if (!entry) return;
|
|
115
|
+
active += 1;
|
|
116
|
+
if (!foregroundEntry) backgroundActive += 1;
|
|
117
|
+
void Promise.resolve()
|
|
118
|
+
.then(entry.task)
|
|
119
|
+
.then(entry.resolve, entry.reject)
|
|
120
|
+
.finally(() => {
|
|
121
|
+
active -= 1;
|
|
122
|
+
if (!foregroundEntry) backgroundActive -= 1;
|
|
123
|
+
pump();
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
return (task, priority = 'foreground') => new Promise((resolve, reject) => {
|
|
128
|
+
const queue = priority === 'background' ? background : foreground;
|
|
129
|
+
queue.push({ task, resolve, reject });
|
|
130
|
+
pump();
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const scheduleRendition = createPriorityScheduler(2);
|
|
135
|
+
|
|
96
136
|
/** Downscale a still (path or buffer) into a webp; null without sharp. */
|
|
97
137
|
async function encodeStill(input, spec) {
|
|
98
138
|
const sharp = await loadSharp();
|
|
@@ -118,20 +158,33 @@ function parseDurationSeconds(text) {
|
|
|
118
158
|
return Number.isFinite(seconds) ? Math.round(seconds) : 0;
|
|
119
159
|
}
|
|
120
160
|
|
|
121
|
-
|
|
161
|
+
export function videoPosterArguments(sourcePath, spec) {
|
|
162
|
+
return [
|
|
163
|
+
'-hide_banner', '-nostdin',
|
|
164
|
+
'-i', sourcePath,
|
|
165
|
+
// The encoded first frame is frequently a black transition. An accurate
|
|
166
|
+
// post-input seek gives generated clips a representative early poster.
|
|
167
|
+
'-ss', '0.12',
|
|
168
|
+
'-frames:v', '1',
|
|
169
|
+
'-vf', `scale='min(${spec.maxEdge},iw)':-2`,
|
|
170
|
+
// Emit the tile JPEG directly. The old PNG -> sharp -> webp second encode
|
|
171
|
+
// doubled cold-video latency and failed when sharp was unavailable.
|
|
172
|
+
'-q:v', '4',
|
|
173
|
+
'-f', 'image2', '-vcodec', 'mjpeg', 'pipe:1',
|
|
174
|
+
];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Representative frame + duration of a clip, straight from ffmpeg; null when the
|
|
122
178
|
* managed runtime is not installed. */
|
|
123
179
|
async function grabVideoFrame(sourcePath, spec) {
|
|
124
180
|
const ffmpeg = await resolveFfmpeg();
|
|
125
181
|
if (!ffmpeg) return null;
|
|
126
182
|
return new Promise((resolve) => {
|
|
127
|
-
const child = spawn(
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
'-vf', `scale='min(${spec.maxEdge},iw)':-2`,
|
|
133
|
-
'-f', 'image2', '-vcodec', 'png', 'pipe:1',
|
|
134
|
-
], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
183
|
+
const child = spawn(
|
|
184
|
+
ffmpeg,
|
|
185
|
+
videoPosterArguments(sourcePath, spec),
|
|
186
|
+
{ stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true },
|
|
187
|
+
);
|
|
135
188
|
const chunks = [];
|
|
136
189
|
let total = 0;
|
|
137
190
|
let stderr = '';
|
|
@@ -165,6 +218,7 @@ async function grabVideoFrame(sourcePath, spec) {
|
|
|
165
218
|
|
|
166
219
|
// Two tiles asking for the same missing rendition must not both encode it.
|
|
167
220
|
const inflight = new Map();
|
|
221
|
+
const failedUntil = new Map();
|
|
168
222
|
|
|
169
223
|
async function generate({ id, kind, sourcePath, variant, spec, cacheDir }) {
|
|
170
224
|
if (kind === 'video') {
|
|
@@ -173,10 +227,7 @@ async function generate({ id, kind, sourcePath, variant, spec, cacheDir }) {
|
|
|
173
227
|
if (variant !== 'thumb') return null;
|
|
174
228
|
const frame = await grabVideoFrame(sourcePath, spec);
|
|
175
229
|
if (!frame) return null;
|
|
176
|
-
const
|
|
177
|
-
const written = encoded
|
|
178
|
-
? writeRendition(cacheDir, variant, id, encoded.extension, encoded.buffer)
|
|
179
|
-
: writeRendition(cacheDir, variant, id, '.png', frame.buffer);
|
|
230
|
+
const written = writeRendition(cacheDir, variant, id, '.jpg', frame.buffer);
|
|
180
231
|
return { ...written, durationSeconds: frame.durationSeconds };
|
|
181
232
|
}
|
|
182
233
|
const encoded = await encodeStill(sourcePath, spec);
|
|
@@ -188,16 +239,49 @@ async function generate({ id, kind, sourcePath, variant, spec, cacheDir }) {
|
|
|
188
239
|
* Returns `{ path, mime, bytes, durationSeconds? }`, or null when this host
|
|
189
240
|
* cannot produce it (no sharp / no ffmpeg / undecodable source).
|
|
190
241
|
*/
|
|
191
|
-
export async function ensureRendition({
|
|
242
|
+
export async function ensureRendition({
|
|
243
|
+
id,
|
|
244
|
+
kind,
|
|
245
|
+
sourcePath,
|
|
246
|
+
variant,
|
|
247
|
+
cacheDir,
|
|
248
|
+
priority = 'foreground',
|
|
249
|
+
generate = true,
|
|
250
|
+
}) {
|
|
192
251
|
const spec = renditionSpec(variant);
|
|
193
252
|
if (!spec) return null;
|
|
194
253
|
const cached = cachedRendition(cacheDir, variant, id);
|
|
195
254
|
if (cached) return cached;
|
|
255
|
+
if (!generate) return null;
|
|
196
256
|
const key = `${variant}:${id}`;
|
|
257
|
+
if ((failedUntil.get(key) || 0) > Date.now()) return null;
|
|
197
258
|
const pending = inflight.get(key)
|
|
198
|
-
??
|
|
199
|
-
|
|
259
|
+
?? scheduleRendition(
|
|
260
|
+
() => generate({ id, kind, sourcePath, variant, spec, cacheDir }),
|
|
261
|
+
priority,
|
|
262
|
+
)
|
|
263
|
+
.then((result) => {
|
|
264
|
+
if (result) failedUntil.delete(key);
|
|
265
|
+
else failedUntil.set(key, Date.now() + FAILED_RENDITION_COOLDOWN_MS);
|
|
266
|
+
return result;
|
|
267
|
+
})
|
|
268
|
+
.catch(() => {
|
|
269
|
+
failedUntil.set(key, Date.now() + FAILED_RENDITION_COOLDOWN_MS);
|
|
270
|
+
return null;
|
|
271
|
+
})
|
|
200
272
|
.finally(() => { inflight.delete(key); });
|
|
201
273
|
inflight.set(key, pending);
|
|
202
274
|
return pending;
|
|
203
275
|
}
|
|
276
|
+
|
|
277
|
+
/** Persist a small browser-generated fallback so a codec miss is paid once. */
|
|
278
|
+
export function cacheRendition({ id, variant = 'thumb', mime, buffer, cacheDir }) {
|
|
279
|
+
if (!renditionSpec(variant)) return null;
|
|
280
|
+
const extension = mime === 'image/webp'
|
|
281
|
+
? '.webp'
|
|
282
|
+
: mime === 'image/png' ? '.png'
|
|
283
|
+
: mime === 'image/jpeg' ? '.jpg' : '';
|
|
284
|
+
if (!extension || !buffer?.length || buffer.length > MAX_CACHED_RENDITION_BYTES) return null;
|
|
285
|
+
failedUntil.delete(`${variant}:${id}`);
|
|
286
|
+
return writeRendition(cacheDir, variant, id, extension, buffer);
|
|
287
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
createPriorityScheduler,
|
|
6
|
+
videoPosterArguments,
|
|
7
|
+
} from './renditions.mjs';
|
|
8
|
+
|
|
9
|
+
test('video posters seek off frame zero and encode one JPEG without a second still pass', () => {
|
|
10
|
+
const args = videoPosterArguments('clip.mp4', { maxEdge: 512 });
|
|
11
|
+
assert.deepEqual(args.slice(args.indexOf('-ss'), args.indexOf('-ss') + 2), ['-ss', '0.12']);
|
|
12
|
+
assert.equal(args[args.indexOf('-vcodec') + 1], 'mjpeg');
|
|
13
|
+
assert.equal(args.includes('png'), false);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('rendition scheduler bounds work and prioritizes visible requests over queued warmups', async () => {
|
|
17
|
+
const schedule = createPriorityScheduler(2);
|
|
18
|
+
const releases = [];
|
|
19
|
+
const started = [];
|
|
20
|
+
let active = 0;
|
|
21
|
+
let peak = 0;
|
|
22
|
+
const blocked = (name) => schedule(() => new Promise((resolve) => {
|
|
23
|
+
started.push(name);
|
|
24
|
+
active += 1;
|
|
25
|
+
peak = Math.max(peak, active);
|
|
26
|
+
releases.push(() => {
|
|
27
|
+
active -= 1;
|
|
28
|
+
resolve(name);
|
|
29
|
+
});
|
|
30
|
+
}), 'background');
|
|
31
|
+
const first = blocked('background-a');
|
|
32
|
+
const second = blocked('background-b');
|
|
33
|
+
const third = blocked('background-c');
|
|
34
|
+
const foreground = schedule(async () => {
|
|
35
|
+
started.push('foreground');
|
|
36
|
+
active += 1;
|
|
37
|
+
peak = Math.max(peak, active);
|
|
38
|
+
active -= 1;
|
|
39
|
+
return 'foreground';
|
|
40
|
+
});
|
|
41
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
42
|
+
assert.deepEqual(started, ['background-a', 'foreground'],
|
|
43
|
+
'one slot must stay available for a visible request');
|
|
44
|
+
assert.equal(await foreground, 'foreground');
|
|
45
|
+
releases.shift()();
|
|
46
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
47
|
+
assert.deepEqual(started, ['background-a', 'foreground', 'background-b']);
|
|
48
|
+
releases.shift()();
|
|
49
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
50
|
+
assert.deepEqual(started, ['background-a', 'foreground', 'background-b', 'background-c']);
|
|
51
|
+
releases.shift()();
|
|
52
|
+
await Promise.all([first, second, third]);
|
|
53
|
+
assert.equal(peak, 2);
|
|
54
|
+
});
|
|
@@ -12,11 +12,17 @@ import { randomUUID } from 'crypto';
|
|
|
12
12
|
import { spawn } from 'child_process';
|
|
13
13
|
import { resolvePluginData } from '../shared/plugin-paths.mjs';
|
|
14
14
|
import { writeJsonAtomicSync, withFileLockSync } from '../shared/atomic-file.mjs';
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
cacheRendition,
|
|
17
|
+
ensureRendition,
|
|
18
|
+
removeRenditions,
|
|
19
|
+
renditionSpec,
|
|
20
|
+
} from './renditions.mjs';
|
|
16
21
|
|
|
17
22
|
const MAX_INDEX_ENTRIES = 2_000;
|
|
18
23
|
// Renderer transport is base64 over IPC: refuse to inline anything larger.
|
|
19
24
|
const MAX_INLINE_BYTES = 48 * 1024 * 1024;
|
|
25
|
+
const MAX_CACHED_THUMBNAIL_BYTES = 4 * 1024 * 1024;
|
|
20
26
|
|
|
21
27
|
const EXTENSIONS = {
|
|
22
28
|
'image/png': 'png',
|
|
@@ -25,6 +31,12 @@ const EXTENSIONS = {
|
|
|
25
31
|
'video/mp4': 'mp4',
|
|
26
32
|
};
|
|
27
33
|
let layoutMigrationChecked = false;
|
|
34
|
+
let indexCachePath = '';
|
|
35
|
+
let indexCacheSignature = '';
|
|
36
|
+
let indexCacheAssets = [];
|
|
37
|
+
let indexCacheById = new Map();
|
|
38
|
+
let indexDiskReads = 0;
|
|
39
|
+
let indexCacheHits = 0;
|
|
28
40
|
|
|
29
41
|
function mediaDir() {
|
|
30
42
|
return join(resolvePluginData(), 'media');
|
|
@@ -48,26 +60,60 @@ function indexLockPath() {
|
|
|
48
60
|
return `${indexPath()}.lock`;
|
|
49
61
|
}
|
|
50
62
|
|
|
51
|
-
function ensureDirs() {
|
|
63
|
+
function ensureDirs({ migrate = false } = {}) {
|
|
52
64
|
mkdirSync(assetsDir(), { recursive: true });
|
|
53
|
-
if (!layoutMigrationChecked) {
|
|
65
|
+
if (migrate && !layoutMigrationChecked) {
|
|
54
66
|
migrateAssetLayout();
|
|
55
67
|
layoutMigrationChecked = true;
|
|
56
68
|
}
|
|
57
69
|
}
|
|
58
70
|
|
|
71
|
+
function indexSignature(path) {
|
|
72
|
+
try {
|
|
73
|
+
const info = statSync(path);
|
|
74
|
+
return `${info.mtimeMs}:${info.ctimeMs}:${info.size}`;
|
|
75
|
+
} catch {
|
|
76
|
+
return 'missing';
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function rememberIndex(path, signature, assets) {
|
|
81
|
+
indexCachePath = path;
|
|
82
|
+
indexCacheSignature = signature;
|
|
83
|
+
indexCacheAssets = assets;
|
|
84
|
+
indexCacheById = new Map(assets.map((entry) => [entry.id, entry]));
|
|
85
|
+
return assets;
|
|
86
|
+
}
|
|
87
|
+
|
|
59
88
|
function readIndex() {
|
|
89
|
+
const path = indexPath();
|
|
90
|
+
const signature = indexSignature(path);
|
|
91
|
+
if (path === indexCachePath && signature === indexCacheSignature) {
|
|
92
|
+
indexCacheHits += 1;
|
|
93
|
+
return indexCacheAssets;
|
|
94
|
+
}
|
|
60
95
|
try {
|
|
61
|
-
const raw = readFileSync(
|
|
96
|
+
const raw = readFileSync(path, 'utf8');
|
|
62
97
|
const parsed = JSON.parse(raw);
|
|
63
|
-
|
|
98
|
+
indexDiskReads += 1;
|
|
99
|
+
return rememberIndex(path, signature, Array.isArray(parsed?.assets) ? parsed.assets : []);
|
|
64
100
|
} catch {
|
|
65
|
-
return [];
|
|
101
|
+
return rememberIndex(path, signature, []);
|
|
66
102
|
}
|
|
67
103
|
}
|
|
68
104
|
|
|
69
105
|
function writeIndex(assets) {
|
|
70
|
-
|
|
106
|
+
const path = indexPath();
|
|
107
|
+
writeJsonAtomicSync(path, { version: 2, assets });
|
|
108
|
+
rememberIndex(path, indexSignature(path), assets);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function mediaStoreCacheStats() {
|
|
112
|
+
return {
|
|
113
|
+
indexDiskReads,
|
|
114
|
+
indexCacheHits,
|
|
115
|
+
indexEntries: indexCacheAssets.length,
|
|
116
|
+
};
|
|
71
117
|
}
|
|
72
118
|
|
|
73
119
|
function extensionFor(mime) {
|
|
@@ -142,7 +188,9 @@ function migrateAssetLayout() {
|
|
|
142
188
|
|
|
143
189
|
/** Persist one generated artifact and return its index entry. */
|
|
144
190
|
export function saveMediaAsset({ kind, lane, model, prompt, options = {}, mime, bytes, meta = {} }) {
|
|
145
|
-
|
|
191
|
+
// Moving legacy files is a write-side maintenance task. Running it from
|
|
192
|
+
// list/resolve made the first Studio read synchronously wait on index.lock.
|
|
193
|
+
ensureDirs({ migrate: true });
|
|
146
194
|
const id = randomUUID();
|
|
147
195
|
const createdAt = Date.now();
|
|
148
196
|
const entry = {
|
|
@@ -180,6 +228,7 @@ export function saveMediaAsset({ kind, lane, model, prompt, options = {}, mime,
|
|
|
180
228
|
sourcePath: path,
|
|
181
229
|
variant: 'thumb',
|
|
182
230
|
cacheDir: renditionsDir(),
|
|
231
|
+
priority: 'background',
|
|
183
232
|
});
|
|
184
233
|
return entry;
|
|
185
234
|
}
|
|
@@ -190,41 +239,13 @@ export function listMediaAssets({ limit = 60, offset = 0, kind = null } = {}) {
|
|
|
190
239
|
const start = Math.max(0, Math.trunc(Number(offset) || 0));
|
|
191
240
|
const count = Math.min(200, Math.max(1, Math.trunc(Number(limit) || 60)));
|
|
192
241
|
const assets = all.slice(start, start + count);
|
|
193
|
-
// Assets generated before this cache existed have no tile rendition, so the
|
|
194
|
-
// first gallery paint used to wait on an encode per visible tile. Listing is
|
|
195
|
-
// the earliest moment the host knows WHICH tiles are coming: warm them here,
|
|
196
|
-
// off the caller's path (ensureRendition dedupes the concurrent request).
|
|
197
|
-
void prewarmThumbs(assets);
|
|
198
242
|
return { total: all.length, assets };
|
|
199
243
|
}
|
|
200
244
|
|
|
201
|
-
// Bounded: the encoder is shared with generation, and a large page must not
|
|
202
|
-
// turn one listing into dozens of parallel sharp/ffmpeg jobs.
|
|
203
|
-
const PREWARM_LIMIT = 24;
|
|
204
|
-
const PREWARM_CONCURRENCY = 2;
|
|
205
|
-
|
|
206
|
-
async function prewarmThumbs(assets) {
|
|
207
|
-
const queue = assets.slice(0, PREWARM_LIMIT);
|
|
208
|
-
const workers = Array.from({ length: PREWARM_CONCURRENCY }, async () => {
|
|
209
|
-
for (let entry = queue.shift(); entry; entry = queue.shift()) {
|
|
210
|
-
const path = storedAssetPath(entry.file);
|
|
211
|
-
if (!path || !existsSync(path)) continue;
|
|
212
|
-
const rendition = await ensureRendition({
|
|
213
|
-
id: entry.id,
|
|
214
|
-
kind: entry.kind,
|
|
215
|
-
sourcePath: path,
|
|
216
|
-
variant: 'thumb',
|
|
217
|
-
cacheDir: renditionsDir(),
|
|
218
|
-
}).catch(() => null);
|
|
219
|
-
if (rendition?.durationSeconds) rememberDuration(entry, rendition.durationSeconds);
|
|
220
|
-
}
|
|
221
|
-
});
|
|
222
|
-
await Promise.all(workers).catch(() => {});
|
|
223
|
-
}
|
|
224
|
-
|
|
225
245
|
function getMediaAsset(id) {
|
|
226
246
|
ensureDirs();
|
|
227
|
-
|
|
247
|
+
readIndex();
|
|
248
|
+
return indexCacheById.get(id) || null;
|
|
228
249
|
}
|
|
229
250
|
|
|
230
251
|
/** Persist a duration probed while building a poster, so the gallery badge
|
|
@@ -262,7 +283,10 @@ function inlineBase64(path) {
|
|
|
262
283
|
* no ffmpeg); the caller reduces the feature instead of substituting the
|
|
263
284
|
* original behind the client's back.
|
|
264
285
|
*/
|
|
265
|
-
export async function resolveMediaFile(id, {
|
|
286
|
+
export async function resolveMediaFile(id, {
|
|
287
|
+
variant = 'original',
|
|
288
|
+
generate = true,
|
|
289
|
+
} = {}) {
|
|
266
290
|
const entry = getMediaAsset(id);
|
|
267
291
|
if (!entry) return null;
|
|
268
292
|
const path = storedAssetPath(entry.file);
|
|
@@ -275,6 +299,7 @@ export async function resolveMediaFile(id, { variant = 'original' } = {}) {
|
|
|
275
299
|
sourcePath: path,
|
|
276
300
|
variant,
|
|
277
301
|
cacheDir: renditionsDir(),
|
|
302
|
+
generate,
|
|
278
303
|
});
|
|
279
304
|
if (!rendition) return { ...entry, variant, path: '', available: false };
|
|
280
305
|
rememberDuration(entry, rendition.durationSeconds);
|
|
@@ -297,8 +322,12 @@ export async function resolveMediaFile(id, { variant = 'original' } = {}) {
|
|
|
297
322
|
* so (`available: false`) instead of quietly shipping full-size bytes — the
|
|
298
323
|
* caller that can afford them (local IPC) opts in with `allowOriginal`.
|
|
299
324
|
*/
|
|
300
|
-
export async function readMediaAsset(id, {
|
|
301
|
-
|
|
325
|
+
export async function readMediaAsset(id, {
|
|
326
|
+
variant = 'original',
|
|
327
|
+
allowOriginal = false,
|
|
328
|
+
generate = true,
|
|
329
|
+
} = {}) {
|
|
330
|
+
const file = await resolveMediaFile(id, { variant, generate });
|
|
302
331
|
if (!file) return null;
|
|
303
332
|
if (file.available) return { ...file, base64: inlineBase64(file.path) };
|
|
304
333
|
if (!allowOriginal) return { ...file, base64: '' };
|
|
@@ -307,6 +336,39 @@ export async function readMediaAsset(id, { variant = 'original', allowOriginal =
|
|
|
307
336
|
return { ...original, downgraded: true, base64: inlineBase64(original.path) };
|
|
308
337
|
}
|
|
309
338
|
|
|
339
|
+
/** Cache a bounded thumbnail produced by Chromium when native codecs miss. */
|
|
340
|
+
export function cacheMediaThumbnail(id, input = {}) {
|
|
341
|
+
const entry = getMediaAsset(id);
|
|
342
|
+
if (!entry) return null;
|
|
343
|
+
const base64 = typeof input.base64 === 'string' ? input.base64 : '';
|
|
344
|
+
if (!base64 || base64.length > Math.ceil(MAX_CACHED_THUMBNAIL_BYTES * 4 / 3) + 8) {
|
|
345
|
+
return { id, available: false };
|
|
346
|
+
}
|
|
347
|
+
const buffer = Buffer.from(base64, 'base64');
|
|
348
|
+
if (!buffer.length || buffer.length > MAX_CACHED_THUMBNAIL_BYTES) {
|
|
349
|
+
return { id, available: false };
|
|
350
|
+
}
|
|
351
|
+
const mime = String(input.mime || 'image/jpeg').toLowerCase();
|
|
352
|
+
const rendition = cacheRendition({
|
|
353
|
+
id: entry.id,
|
|
354
|
+
variant: 'thumb',
|
|
355
|
+
mime,
|
|
356
|
+
buffer,
|
|
357
|
+
cacheDir: renditionsDir(),
|
|
358
|
+
});
|
|
359
|
+
if (!rendition) return { id, available: false };
|
|
360
|
+
const durationSeconds = Math.max(0, Math.round(Number(input.durationSeconds) || 0));
|
|
361
|
+
if (durationSeconds) rememberDuration(entry, durationSeconds);
|
|
362
|
+
return {
|
|
363
|
+
id,
|
|
364
|
+
available: true,
|
|
365
|
+
variant: 'thumb',
|
|
366
|
+
mime: rendition.mime,
|
|
367
|
+
bytes: rendition.bytes,
|
|
368
|
+
...(durationSeconds ? { durationSeconds } : {}),
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
310
372
|
export function mediaAssetPath(id) {
|
|
311
373
|
const entry = getMediaAsset(id);
|
|
312
374
|
return entry ? storedAssetPath(entry.file) : null;
|
|
@@ -322,8 +384,14 @@ export function openMediaAsset(id) {
|
|
|
322
384
|
return { id, opened: openWithOs(path) };
|
|
323
385
|
}
|
|
324
386
|
|
|
325
|
-
/** Open
|
|
326
|
-
export function openMediaFolder() {
|
|
387
|
+
/** Open one asset's containing directory, or the Studio assets root. */
|
|
388
|
+
export function openMediaFolder(id = '') {
|
|
389
|
+
if (id) {
|
|
390
|
+
const path = mediaAssetPath(id);
|
|
391
|
+
if (!path || !existsSync(path)) return { id, path: '', opened: false };
|
|
392
|
+
const folder = dirname(path);
|
|
393
|
+
return { id, path: folder, opened: openWithOs(folder) };
|
|
394
|
+
}
|
|
327
395
|
ensureDirs();
|
|
328
396
|
return { path: assetsDir(), opened: openWithOs(assetsDir()) };
|
|
329
397
|
}
|
|
@@ -52,12 +52,42 @@ test('media assets are organized by kind, provider, model, and local date while
|
|
|
52
52
|
|
|
53
53
|
try {
|
|
54
54
|
const store = await import(`./store.mjs?test=${Date.now()}`);
|
|
55
|
-
const
|
|
55
|
+
const beforeMigration = store.listMediaAssets({ limit: 10 }).assets;
|
|
56
|
+
assert.equal(beforeMigration[0].file, 'legacy-image.jpg',
|
|
57
|
+
'read-only listing must not run the write-side layout migration');
|
|
58
|
+
const saved = store.saveMediaAsset({
|
|
59
|
+
kind: 'video',
|
|
60
|
+
lane: 'gemini',
|
|
61
|
+
model: 'veo:3/preview',
|
|
62
|
+
prompt: 'new clip',
|
|
63
|
+
options: { aspectRatio: '16:9' },
|
|
64
|
+
mime: 'video/mp4',
|
|
65
|
+
bytes: Buffer.from('new-video'),
|
|
66
|
+
});
|
|
67
|
+
const migrated = store.listMediaAssets({ limit: 10 }).assets;
|
|
68
|
+
const listed = ['legacy-image', 'legacy-video'].map((id) =>
|
|
69
|
+
migrated.find((entry) => entry.id === id));
|
|
56
70
|
assert.equal(listed[0].file, 'images/gemini/image-alpha/2026-07-05/legacy-image.jpg');
|
|
57
71
|
assert.equal(listed[1].file, 'videos/grok/video-beta/2026-07-05/legacy-video.mp4');
|
|
58
72
|
assert.equal(existsSync(join(assetsDir, ...listed[0].file.split('/'))), true);
|
|
59
73
|
assert.equal(existsSync(join(assetsDir, ...listed[1].file.split('/'))), true);
|
|
60
74
|
assert.equal(existsSync(join(assetsDir, 'legacy-image.jpg')), false);
|
|
75
|
+
const cacheBeforeReads = store.mediaStoreCacheStats();
|
|
76
|
+
await Promise.all([
|
|
77
|
+
store.resolveMediaFile('legacy-image'),
|
|
78
|
+
store.resolveMediaFile('legacy-video'),
|
|
79
|
+
store.resolveMediaFile('legacy-image'),
|
|
80
|
+
]);
|
|
81
|
+
const cacheAfterReads = store.mediaStoreCacheStats();
|
|
82
|
+
assert.equal(cacheAfterReads.indexDiskReads, cacheBeforeReads.indexDiskReads,
|
|
83
|
+
'a thumbnail-resolution burst must reuse the parsed media index');
|
|
84
|
+
assert.ok(cacheAfterReads.indexCacheHits >= cacheBeforeReads.indexCacheHits + 3);
|
|
85
|
+
const cachedOnlyMiss = await store.resolveMediaFile('legacy-image', {
|
|
86
|
+
variant: 'thumb',
|
|
87
|
+
generate: false,
|
|
88
|
+
});
|
|
89
|
+
assert.equal(cachedOnlyMiss.available, false,
|
|
90
|
+
'a local protocol probe must not start native rendition generation on a cache miss');
|
|
61
91
|
assert.equal(
|
|
62
92
|
(await store.readMediaAsset('legacy-video')).base64,
|
|
63
93
|
Buffer.from('video').toString('base64'),
|
|
@@ -66,7 +96,10 @@ test('media assets are organized by kind, provider, model, and local date while
|
|
|
66
96
|
// A tile-sized rendition is impossible for these stub bytes (and video
|
|
67
97
|
// needs ffmpeg): the read must REPORT the miss instead of quietly
|
|
68
98
|
// shipping the original, which is what made the remote gallery slow.
|
|
69
|
-
const missing = await store.readMediaAsset('legacy-image', {
|
|
99
|
+
const missing = await store.readMediaAsset('legacy-image', {
|
|
100
|
+
variant: 'thumb',
|
|
101
|
+
generate: false,
|
|
102
|
+
});
|
|
70
103
|
assert.equal(missing.available, false);
|
|
71
104
|
assert.equal(missing.base64, '');
|
|
72
105
|
// Callers that can afford full-size bytes (local IPC) opt in explicitly
|
|
@@ -74,20 +107,29 @@ test('media assets are organized by kind, provider, model, and local date while
|
|
|
74
107
|
const downgraded = await store.readMediaAsset('legacy-image', {
|
|
75
108
|
variant: 'thumb',
|
|
76
109
|
allowOriginal: true,
|
|
110
|
+
generate: false,
|
|
77
111
|
});
|
|
78
112
|
assert.equal(downgraded.variant, 'original');
|
|
79
113
|
assert.equal(downgraded.downgraded, true);
|
|
80
114
|
assert.equal(downgraded.base64, Buffer.from('image').toString('base64'));
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
prompt: 'new clip',
|
|
87
|
-
options: { aspectRatio: '16:9' },
|
|
88
|
-
mime: 'video/mp4',
|
|
89
|
-
bytes: Buffer.from('new-video'),
|
|
115
|
+
const browserThumb = Buffer.from('browser-generated-thumbnail');
|
|
116
|
+
const cached = store.cacheMediaThumbnail('legacy-video', {
|
|
117
|
+
mime: 'image/jpeg',
|
|
118
|
+
base64: browserThumb.toString('base64'),
|
|
119
|
+
durationSeconds: 7,
|
|
90
120
|
});
|
|
121
|
+
assert.equal(cached.available, true);
|
|
122
|
+
const cachedTarget = await store.resolveMediaFile('legacy-video', {
|
|
123
|
+
variant: 'thumb',
|
|
124
|
+
generate: false,
|
|
125
|
+
});
|
|
126
|
+
assert.equal(cachedTarget.available, true,
|
|
127
|
+
'a cache-only local protocol probe must serve a browser-generated rendition');
|
|
128
|
+
const cachedRead = await store.readMediaAsset('legacy-video', { variant: 'thumb' });
|
|
129
|
+
assert.equal(cachedRead.mime, 'image/jpeg');
|
|
130
|
+
assert.equal(cachedRead.base64, browserThumb.toString('base64'));
|
|
131
|
+
assert.equal(cachedRead.durationSeconds, 7);
|
|
132
|
+
|
|
91
133
|
assert.match(saved.file, /^videos\/gemini\/veo-3-preview\/\d{4}-\d{2}-\d{2}\/[0-9a-f-]+\.mp4$/);
|
|
92
134
|
assert.equal(existsSync(join(assetsDir, ...saved.file.split('/'))), true);
|
|
93
135
|
assert.equal(store.deleteMediaAsset(saved.id).removed, true);
|