diff --git a/.changelog/next/changed-issue-4165.md b/.changelog/next/changed-issue-4165.md
new file mode 100644
index 0000000000..dc3a9101ad
--- /dev/null
+++ b/.changelog/next/changed-issue-4165.md
@@ -0,0 +1 @@
+- Video history is now resolvable one entry at a time (`GET /api/video-gen/history/:id`) — Creative Director previews, the CD Overview, MusicVideo, and the pipeline's episode video stage no longer download the entire render history just to learn one clip's filename
diff --git a/client/src/components/creative-director/ProjectPreview.test.jsx b/client/src/components/creative-director/ProjectPreview.test.jsx
index b34686dfea..08cd18b912 100644
--- a/client/src/components/creative-director/ProjectPreview.test.jsx
+++ b/client/src/components/creative-director/ProjectPreview.test.jsx
@@ -11,7 +11,10 @@ import { MemoryRouter } from 'react-router';
// the test stays a unit of ProjectPreview's own branch selection.
vi.mock('../MediaImage.jsx', () => ({ default: () =>
}));
vi.mock('./ScenePreview.jsx', () => ({ default: () => }));
-vi.mock('../../services/apiImageVideo.js', () => ({ listVideoHistory: vi.fn(async () => []) }));
+vi.mock('../../services/apiImageVideo.js', () => ({
+ // useVideoFileSrc resolves one id at a time (#4165); nothing is in history here.
+ getVideoHistoryItem: vi.fn(async () => Promise.reject(Object.assign(new Error('Not found'), { status: 404 }))),
+}));
import ProjectPreview from './ProjectPreview.jsx';
diff --git a/client/src/hooks/README.md b/client/src/hooks/README.md
index fa4162b5fb..b5ee66495b 100644
--- a/client/src/hooks/README.md
+++ b/client/src/hooks/README.md
@@ -191,7 +191,7 @@ grep -i "what you want to do" client/src/hooks/README.md
| `useUniverseRender` | Universe Builder batch-render settings, scoped render payload compilation, canon-selection defaults, per-entry job queue, and run-list refresh. Returns the shared `runRender`/`handleRender` action used by the Render tab and inline bucket/canon controls. | Universe Builder composition shell and presentational panels; don't fork render payload semantics per tab. |
| `useUniverseTabs` | `useUniverseTabs(categories)` → `{ activeTab, activeBucket, bucketsByKind, hasOtherBuckets, setTab, setBucket }`. URL-backed (`?tab=&bucket=`) tab + sub-bucket state for the Universe Builder, including the self-healing effects that `replace`-strip a tab/bucket the current categories no longer support. | Universe Builder composition shell — don't re-roll the tab/bucket search-param plumbing or the stale-param cleanup. |
| `useUniverseNav` | `goToWorld(id)` → navigate to `/universes/:id`, preserve `location.search`. | Any Universe Builder caller that needs to switch worlds via URL. |
-| `useVideoFileSrc` | `useVideoFileSrc(jobId, { enabled })` → `{ src, resolving }`. Resolves a video-history **id** to the file it really points at (`/data/videos/`) via the history lookup. | Any surface rendering a video by history id where the id is NOT the filename stem — notably a CD `finalVideoId` (the timeline renderer mints `timeline-*.mp4` beside a `randomUUID()` id, so `/data/videos/.mp4` 404s while `.jpg` poster loads). Pass the result to ``. Gate autoplay on `resolving`. |
+| `useVideoFileSrc` | `useVideoFileSrc(jobId, { enabled })` → `{ src, resolving, retry }`. Resolves a video-history **id** to the file it really points at (`/data/videos/`) via the by-id lookup `GET /api/video-gen/history/:id`. | Any surface rendering a video by history id where the id is NOT the filename stem — notably a CD `finalVideoId` (the timeline renderer mints `timeline-*.mp4` beside a `randomUUID()` id, so `/data/videos/.mp4` 404s while `.jpg` poster loads). Pass the result to ``. Gate autoplay on `resolving`. |
| `useVoiceUiSync` | Keeps voice server's UI index in sync with current page. | Wire once at root for voice agent support. |
| `useMoltworldWs` | Moltworld WebSocket feed. | Moltworld surfaces only. |
| `useYoutubeIngest` | One YouTube brain-ingest job slot: start/cancel + SSE progress + terminal-frame handling via `POST /api/brain/youtube/ingest`. Returns `{ active, percent, stage, start(body), cancel }` — `start` takes the whole payload (`{ url, captureTranscript, downloadVideo, ingestAudio, agentPrompt, tags }`), not a bare URL. `onComplete(ingest)` fires with the stored ingest record; non-fatal `warnings[]` on the terminal frame are toasted automatically. | Quick Capture's YouTube path, and any other surface that ingests a video into the brain. |
diff --git a/client/src/hooks/useVideoFileSrc.js b/client/src/hooks/useVideoFileSrc.js
index b949651ce5..6e0f56273f 100644
--- a/client/src/hooks/useVideoFileSrc.js
+++ b/client/src/hooks/useVideoFileSrc.js
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react';
-import { listVideoHistory } from '../services/apiImageVideo.js';
+import { getVideoHistoryItem } from '../services/apiImageVideo.js';
/**
* Resolve a video-history id to the URL of the file it actually points at.
@@ -20,6 +20,12 @@ import { listVideoHistory } from '../services/apiImageVideo.js';
* (`stitchRunner.js` joins `PATHS.videos` with `finalEntry.filename`) and the
* media UI does (`components/media/normalize.js` → `/data/videos/${v.filename}`).
*
+ * The lookup is a single-entry read (`GET /api/video-gen/history/:id`, #4165).
+ * It used to pull the WHOLE history list and scan it client-side, because no
+ * by-id endpoint existed — so three surfaces (CD cards, CD Overview,
+ * EpisodeVideoStage) each downloaded every render the install has ever produced
+ * to learn one filename.
+ *
* Usage: pass the resolved `src` to ``, which falls back to
* its `.mp4` reconstruction when this returns null. Callers should gate
* on `resolving` before AUTOPLAYING, or the player would race the lookup and
@@ -46,15 +52,14 @@ export function useVideoFileSrc(jobId, { enabled = true } = {}) {
useEffect(() => {
if (!active || settled) return undefined;
let cancelled = false;
- // Silent: a failed lookup is not a user-facing error — ScenePreview's
- // reconstruction fallback (and its own missing-media UI) covers it, so a
- // toast here would be noise on a page that already degrades gracefully.
+ // Silent: neither a 404 (media deleted out from under the record) nor a
+ // transient failure is a user-facing error here — ScenePreview's
+ // reconstruction fallback (and its own missing-media UI) covers both, so a
+ // toast would be noise on a page that already degrades gracefully.
// Both paths settle on THIS jobId+attempt so `resolving` can never latch on.
- listVideoHistory({ silent: true })
- .then((entries) => {
+ getVideoHistoryItem(jobId, { silent: true })
+ .then((entry) => {
if (cancelled) return;
- const list = Array.isArray(entries) ? entries : [];
- const entry = list.find((e) => e?.id === jobId);
const filename = typeof entry?.filename === 'string' ? entry.filename.trim() : '';
setResolved({ jobId, attempt, src: filename ? `/data/videos/${filename}` : null });
})
diff --git a/client/src/hooks/useVideoFileSrc.test.js b/client/src/hooks/useVideoFileSrc.test.js
index ddf0bdc644..a895bd350e 100644
--- a/client/src/hooks/useVideoFileSrc.test.js
+++ b/client/src/hooks/useVideoFileSrc.test.js
@@ -1,9 +1,9 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor, act } from '@testing-library/react';
-const listVideoHistory = vi.fn();
+const getVideoHistoryItem = vi.fn();
vi.mock('../services/apiImageVideo.js', () => ({
- listVideoHistory: (...args) => listVideoHistory(...args),
+ getVideoHistoryItem: (...args) => getVideoHistoryItem(...args),
}));
const { useVideoFileSrc } = await import('./useVideoFileSrc.js');
@@ -11,14 +11,19 @@ const { useVideoFileSrc } = await import('./useVideoFileSrc.js');
// Obviously-fake entries. The two shapes that matter: a timeline render, whose
// filename is unrelated to its id, and a clip render, whose filename happens to
// be `.mp4`.
-const HISTORY = [
- { id: 'final-1', filename: 'timeline-abcd1234-1700000000000.mp4' },
- { id: 'scene-1', filename: 'scene-1.mp4' },
-];
+const HISTORY = {
+ 'final-1': { id: 'final-1', filename: 'timeline-abcd1234-1700000000000.mp4' },
+ 'scene-1': { id: 'scene-1', filename: 'scene-1.mp4' },
+};
+
+// Stand-in for the real by-id endpoint: `request()` throws an Error carrying
+// `.status` on a non-2xx, so an unknown id REJECTS with a 404 rather than
+// resolving to undefined. The hook must treat that as "no file", not as a bug.
+const notFound = () => Object.assign(new Error('Not found'), { status: 404, code: 'NOT_FOUND' });
beforeEach(() => {
- listVideoHistory.mockReset();
- listVideoHistory.mockResolvedValue(HISTORY);
+ getVideoHistoryItem.mockReset();
+ getVideoHistoryItem.mockImplementation(async (id) => HISTORY[id] || Promise.reject(notFound()));
});
describe('useVideoFileSrc', () => {
@@ -44,7 +49,7 @@ describe('useVideoFileSrc', () => {
{ initialProps: { enabled: false } },
);
expect(result.current.resolving).toBe(false);
- expect(listVideoHistory).not.toHaveBeenCalled();
+ expect(getVideoHistoryItem).not.toHaveBeenCalled();
rerender({ enabled: true });
expect(result.current.resolving).toBe(true); // synchronous, not after commit
@@ -54,33 +59,35 @@ describe('useVideoFileSrc', () => {
it('never fetches while disabled — the grid must stay light', () => {
renderHook(() => useVideoFileSrc('final-1', { enabled: false }));
- expect(listVideoHistory).not.toHaveBeenCalled();
+ expect(getVideoHistoryItem).not.toHaveBeenCalled();
});
it('does not fetch without a jobId', () => {
const { result } = renderHook(() => useVideoFileSrc(null));
- expect(listVideoHistory).not.toHaveBeenCalled();
+ expect(getVideoHistoryItem).not.toHaveBeenCalled();
expect(result.current.resolving).toBe(false);
expect(result.current.src).toBeNull();
});
- it('settles with a null src for an id missing from history (deleted media)', async () => {
+ it('settles with a null src when the endpoint 404s the id (deleted media)', async () => {
const { result } = renderHook(() => useVideoFileSrc('gone-1'));
await waitFor(() => expect(result.current.resolving).toBe(false));
// Null, not a guess — the caller falls back to ScenePreview's own
- // reconstruction + missing-media UI.
+ // reconstruction + missing-media UI. A 404 arrives as a REJECTION from
+ // request(), so this also pins that the hook doesn't leave `resolving` latched.
expect(result.current.src).toBeNull();
});
it('settles instead of latching when the lookup fails', async () => {
- listVideoHistory.mockRejectedValue(new Error('network down'));
+ getVideoHistoryItem.mockRejectedValue(new Error('network down'));
const { result } = renderHook(() => useVideoFileSrc('final-1'));
await waitFor(() => expect(result.current.resolving).toBe(false));
expect(result.current.src).toBeNull();
});
- it('tolerates a non-array history payload', async () => {
- listVideoHistory.mockResolvedValue({ oops: true });
+ it('tolerates an entry that carries no usable filename', async () => {
+ // A hand-edited/partially-written history row. Null, not `/data/videos/undefined`.
+ getVideoHistoryItem.mockResolvedValue({ id: 'final-1', filename: ' ' });
const { result } = renderHook(() => useVideoFileSrc('final-1'));
await waitFor(() => expect(result.current.resolving).toBe(false));
expect(result.current.src).toBeNull();
@@ -102,7 +109,7 @@ describe('useVideoFileSrc', () => {
// The regression: a settled failure used to be permanent, so a 5xx blip
// stranded a timeline final on the reconstructed URL that cannot exist for
// it — and ScenePreview's Retry only re-requested that same wrong URL.
- listVideoHistory.mockRejectedValueOnce(new Error('transient 503'));
+ getVideoHistoryItem.mockRejectedValueOnce(new Error('transient 503'));
const { result } = renderHook(() => useVideoFileSrc('final-1'));
await waitFor(() => expect(result.current.resolving).toBe(false));
expect(result.current.src).toBeNull();
@@ -111,7 +118,7 @@ describe('useVideoFileSrc', () => {
expect(result.current.resolving).toBe(true); // synchronously re-armed
await waitFor(() => expect(result.current.resolving).toBe(false));
expect(result.current.src).toBe('/data/videos/timeline-abcd1234-1700000000000.mp4');
- expect(listVideoHistory).toHaveBeenCalledTimes(2);
+ expect(getVideoHistoryItem).toHaveBeenCalledTimes(2);
});
it('keeps retry() stable across renders', async () => {
@@ -125,6 +132,15 @@ describe('useVideoFileSrc', () => {
it('requests silently — the caller owns the failure UI', async () => {
const { result } = renderHook(() => useVideoFileSrc('final-1'));
await waitFor(() => expect(result.current.resolving).toBe(false));
- expect(listVideoHistory).toHaveBeenCalledWith({ silent: true });
+ expect(getVideoHistoryItem).toHaveBeenCalledWith('final-1', { silent: true });
+ });
+
+ it('asks for exactly the one id — never the whole history list (#4165)', async () => {
+ // The regression this locks: three surfaces used to download every render
+ // the install has ever produced just to read one filename.
+ const { result } = renderHook(() => useVideoFileSrc('scene-1'));
+ await waitFor(() => expect(result.current.resolving).toBe(false));
+ expect(getVideoHistoryItem).toHaveBeenCalledTimes(1);
+ expect(getVideoHistoryItem.mock.calls[0][0]).toBe('scene-1');
});
});
diff --git a/client/src/pages/MusicVideo.test.jsx b/client/src/pages/MusicVideo.test.jsx
index cd012b0e5b..f652e51323 100644
--- a/client/src/pages/MusicVideo.test.jsx
+++ b/client/src/pages/MusicVideo.test.jsx
@@ -80,6 +80,11 @@ vi.mock('../services/apiImageVideo.js', () => ({
],
})),
listVideoHistory: vi.fn(async () => [{ id: 'rh-9', filename: 'final.mp4' }]),
+ // By-id resolver behind useVideoFileSrc (#4165) — 404s (rejects) for any
+ // other id, exactly as the real endpoint does.
+ getVideoHistoryItem: vi.fn(async (id) => (id === 'rh-9'
+ ? { id: 'rh-9', filename: 'final.mp4' }
+ : Promise.reject(Object.assign(new Error('Not found'), { status: 404 })))),
// Restricted-model license gate — none of the models above carry a
// `termsGate`, so the board renders no acceptance panel and nothing blocks.
getVideoModelTerms: vi.fn(async () => ({ accepted: [] })),
@@ -829,7 +834,7 @@ describe('MusicVideo media lightbox (#3718)', () => {
});
it('opens the final render from the resolved filename, not the history-id reconstruction', async () => {
- // listVideoHistory is mocked → { id: 'rh-9', filename: 'final.mp4' }; the
+ // getVideoHistoryItem is mocked → { id: 'rh-9', filename: 'final.mp4' }; the
// final-render id is NOT its filename stem, so /data/videos/rh-9.mp4 404s.
await openProject({ ...PROJECT_WITH_CLIP, renderHistoryId: 'rh-9' });
const expand = await screen.findByRole('button', { name: 'View final video full size' });
diff --git a/client/src/services/apiImageVideo.js b/client/src/services/apiImageVideo.js
index b2181c64f9..b0d5ef7a01 100644
--- a/client/src/services/apiImageVideo.js
+++ b/client/src/services/apiImageVideo.js
@@ -221,6 +221,13 @@ export const getVideoGenRuntimeStatus = (runtime, { signal } = {}) =>
// poll doesn't double-toast on every navigation.
export const getActiveVideoJob = () => request('/video-gen/active', { silent: true });
export const listVideoHistory = (options = {}) => request('/video-gen/history', options);
+// ONE history entry by id (#4165) — for a surface that holds only a video-history
+// id and needs the file it actually points at. A history id is not the filename
+// stem (a timeline render mints `timeline-*.mp4` beside a randomUUID() id), and
+// pulling the whole list to find one row is what this replaces. 404s (throwing an
+// Error with `.status === 404`) when the entry is gone; pass `{ silent: true }`
+// when the caller owns the not-found UI.
+export const getVideoHistoryItem = (id, options = {}) => request(`/video-gen/history/${encodeURIComponent(id)}`, options);
// Upload a video into the shared gallery (#4188) — lands under /data/videos/
// with a video-history entry (peer-syncable), unlike the /api/uploads scratch
// dir. Returns the history entry; feed it through normalizeVideo to display.
diff --git a/server/routes/videoGen.integrity.test.js b/server/routes/videoGen.integrity.test.js
index 00e66d346e..3a67859dd4 100644
--- a/server/routes/videoGen.integrity.test.js
+++ b/server/routes/videoGen.integrity.test.js
@@ -63,6 +63,7 @@ vi.mock('../services/videoGen/local.js', () => ({
]),
defaultVideoModelId: vi.fn(() => 'ltx2_unified'),
loadHistory: vi.fn(async () => []),
+ getHistoryItem: vi.fn(async () => null),
deleteHistoryItem: vi.fn(),
setHistoryItemHidden: vi.fn(),
extractLastFrame: vi.fn(),
diff --git a/server/routes/videoGen.js b/server/routes/videoGen.js
index 86beb477ef..9464d38f1f 100644
--- a/server/routes/videoGen.js
+++ b/server/routes/videoGen.js
@@ -37,6 +37,7 @@ import {
invalidateRuntimeFingerprintCache,
resolveRuntimeFingerprint,
loadHistory,
+ getHistoryItem,
deleteHistoryItem,
setHistoryItemHidden,
extractLastFrame,
@@ -1229,6 +1230,29 @@ router.get('/history', asyncHandler(async (_req, res) => {
res.json(await loadHistory());
}));
+// One history entry by id (#4165). A history id is NOT the filename stem — the
+// timeline renderer mints `timeline--.mp4` beside an independent
+// `randomUUID()` id — so a client holding only an id (a Creative Director
+// `finalVideoId`, an EpisodeVideoStage final) has to ask the server which file
+// it points at. Before this route existed, every such surface pulled the WHOLE
+// history list to find one row.
+//
+// The id is validated loosely on purpose: `historyIdSchema`'s UUID check below
+// suits ids this install MINTS, but entries also arrive from a caller-supplied
+// download id and from federated peers, so a `.guid()` gate here would 400 rows
+// that are legitimately in the list. Nothing is interpolated into a path — the
+// value is only compared against stored ids — so a length-capped string is the
+// right bound.
+const historyLookupIdSchema = z.string().min(1).max(200);
+
+router.get('/history/:id', asyncHandler(async (req, res) => {
+ const parsed = historyLookupIdSchema.safeParse(req.params.id);
+ if (!parsed.success) failValidation(parsed);
+ const entry = await getHistoryItem(parsed.data);
+ if (!entry) throw new ServerError('Not found', { status: 404, code: 'NOT_FOUND' });
+ res.json(entry);
+}));
+
// Upload a video into the shared gallery (#4188) — the video counterpart of
// POST /api/image-gen/upload. Lands the bytes under PATHS.videos with a
// `source: 'upload'` history entry so the file federates via the peer-sync
diff --git a/server/routes/videoGen.test.js b/server/routes/videoGen.test.js
index 059a32d465..6bbe5ad5aa 100644
--- a/server/routes/videoGen.test.js
+++ b/server/routes/videoGen.test.js
@@ -70,6 +70,7 @@ vi.mock('../services/videoGen/local.js', () => ({
listVideoModels: vi.fn(() => [{ id: 'ltx2_unified', name: 'LTX-2 Unified', runtime: 'ltx2' }]),
defaultVideoModelId: vi.fn(() => 'ltx2_unified'),
loadHistory: vi.fn(async () => []),
+ getHistoryItem: vi.fn(async () => null),
deleteHistoryItem: vi.fn(async (id) => ({ ok: true, id })),
// The route imports setHistoryItemHidden too — without this entry, ESM
// module linking fails when the route is loaded inside the test process.
@@ -2109,6 +2110,42 @@ describe('videoGen routes', () => {
});
});
+ describe('GET /history/:id', () => {
+ // The point of the route (#4165): a timeline render's history id is a
+ // randomUUID() that has nothing to do with its `timeline-*.mp4` filename,
+ // so a client holding only the id learns the real file from HERE instead of
+ // downloading the whole history list to find one row.
+ it('returns the one entry, resolving an id whose filename is unrelated to it', async () => {
+ const entry = { id: 'final-1', filename: 'timeline-abcd1234-1700000000000.mp4', thumbnail: 'final-1.jpg' };
+ videoGenService.getHistoryItem.mockResolvedValueOnce(entry);
+ const r = await request(app).get('/api/video-gen/history/final-1');
+ expect(r.status).toBe(200);
+ expect(r.body).toEqual(entry);
+ expect(videoGenService.getHistoryItem).toHaveBeenCalledWith('final-1');
+ // Never the full list — that fan-out is exactly what this replaced.
+ expect(videoGenService.loadHistory).not.toHaveBeenCalled();
+ });
+
+ it('404s for an id that is not in history', async () => {
+ videoGenService.getHistoryItem.mockResolvedValueOnce(null);
+ const r = await request(app).get('/api/video-gen/history/gone-1');
+ expect(r.status).toBe(404);
+ expect(videoGenService.getHistoryItem).toHaveBeenCalledWith('gone-1');
+ });
+
+ it('decodes a percent-encoded id before looking it up', async () => {
+ videoGenService.getHistoryItem.mockResolvedValueOnce(null);
+ await request(app).get(`/api/video-gen/history/${encodeURIComponent('a b/c')}`);
+ expect(videoGenService.getHistoryItem).toHaveBeenCalledWith('a b/c');
+ });
+
+ it('rejects an absurdly long id without touching the service', async () => {
+ const r = await request(app).get(`/api/video-gen/history/${'x'.repeat(201)}`);
+ expect(r.status).toBe(400);
+ expect(videoGenService.getHistoryItem).not.toHaveBeenCalled();
+ });
+ });
+
describe('DELETE /history/:id', () => {
it('proxies to deleteHistoryItem', async () => {
videoGenService.deleteHistoryItem.mockResolvedValue({ ok: true, id: 'abc' });
diff --git a/server/services/videoGen/history.js b/server/services/videoGen/history.js
index 3a24ee0254..47a915d071 100644
--- a/server/services/videoGen/history.js
+++ b/server/services/videoGen/history.js
@@ -19,6 +19,25 @@ const HISTORY_FILE = join(PATHS.data, 'video-history.json');
export const loadHistory = () => readJSONFile(HISTORY_FILE, [], { strict: true });
export const saveHistory = (h) => atomicWrite(HISTORY_FILE, h);
+// Resolve ONE entry by its history id, or `null` when no entry carries it (#4165).
+// A history id is NOT the filename stem — `videoGen/local.js` names a clip
+// `.mp4` (so reconstruction happens to work there), but the timeline
+// renderer mints `timeline--.mp4` beside an independent
+// `randomUUID()` id, so a Creative Director `finalVideoId` can only be resolved
+// through the stored `filename`. Callers that hold just an id used to pull the
+// WHOLE list to find one row; this is the single-entry read behind
+// `GET /api/video-gen/history/:id`.
+//
+// `null` (not a throw) is the not-found signal so the route owns the 404 and
+// this stays usable as a plain lookup; distinct from the strict `loadHistory`
+// read, which still throws on an unreadable history file rather than reporting
+// a bogus "no such entry".
+export async function getHistoryItem(id) {
+ const history = await loadHistory();
+ if (!Array.isArray(history)) return null;
+ return history.find((entry) => entry?.id === id) || null;
+}
+
// Serialized read-modify-write for the shared history file. `loadHistory` +
// mutate + `saveHistory` is not atomic on its own, so two write paths that
// finish near-simultaneously (e.g. two out-of-queue video downloads completing
diff --git a/server/services/videoGen/history.test.js b/server/services/videoGen/history.test.js
index 1ff876c878..0f199bc91c 100644
--- a/server/services/videoGen/history.test.js
+++ b/server/services/videoGen/history.test.js
@@ -14,7 +14,7 @@ vi.mock('../../lib/fileUtils.js', () => ({
atomicWrite: vi.fn(async (_file, value) => { store.data = value; }),
}));
-import { mutateVideoHistory } from './history.js';
+import { mutateVideoHistory, getHistoryItem } from './history.js';
describe('mutateVideoHistory serialization', () => {
beforeEach(() => { store.data = []; });
@@ -41,3 +41,40 @@ describe('mutateVideoHistory serialization', () => {
expect(store.data.map((x) => x.id)).toEqual(['after']);
});
});
+
+describe('getHistoryItem', () => {
+ beforeEach(() => { store.data = []; });
+
+ it('resolves an id whose filename is unrelated to it (the timeline case)', async () => {
+ store.data = [
+ { id: 'final-1', filename: 'timeline-abcd1234-1700000000000.mp4' },
+ { id: 'scene-1', filename: 'scene-1.mp4' },
+ ];
+ expect(await getHistoryItem('final-1')).toEqual({
+ id: 'final-1',
+ filename: 'timeline-abcd1234-1700000000000.mp4',
+ });
+ });
+
+ it('returns null — not a throw — for an id absent from history', async () => {
+ store.data = [{ id: 'scene-1', filename: 'scene-1.mp4' }];
+ expect(await getHistoryItem('gone-1')).toBeNull();
+ });
+
+ it('returns null on an empty history rather than undefined', async () => {
+ expect(await getHistoryItem('anything')).toBeNull();
+ });
+
+ it('matches ids exactly — a filename stem is not an id', async () => {
+ store.data = [{ id: 'final-1', filename: 'timeline-final-1.mp4' }];
+ expect(await getHistoryItem('timeline-final-1')).toBeNull();
+ });
+
+ it('survives a malformed entry sitting in the list', async () => {
+ // A hand-edited or half-written history file must not make the lookup
+ // throw on the null row before it reaches the entry the caller asked for.
+ store.data = [null, { filename: 'no-id.mp4' }, { id: 'scene-1', filename: 'scene-1.mp4' }];
+ const entry = await getHistoryItem('scene-1');
+ expect(entry?.filename).toBe('scene-1.mp4');
+ });
+});
diff --git a/server/services/videoGen/local.js b/server/services/videoGen/local.js
index 874a97bb25..94cf1aa99b 100644
--- a/server/services/videoGen/local.js
+++ b/server/services/videoGen/local.js
@@ -76,7 +76,7 @@ import {
invalidateByovReadyCache,
pickDeathFingerprint,
} from './runtimes.js';
-import { loadHistory, saveHistory, mutateVideoHistory } from './history.js';
+import { loadHistory, saveHistory, mutateVideoHistory, getHistoryItem } from './history.js';
import { videoModeContractError, videoChainUnsupportedError, VIDEO_MODE_GATED_RUNTIMES } from './modeContract.js';
import { minimaxH3ControlError } from './minimaxH3Controls.js';
import { estimateRenderMs } from './eta.js';
@@ -85,7 +85,7 @@ import { estimateRenderMs } from './eta.js';
export * from './runtimes.js';
export * from './modeContract.js';
export * from './eta.js';
-export { loadHistory, saveHistory, mutateVideoHistory };
+export { loadHistory, saveHistory, mutateVideoHistory, getHistoryItem };
// LoRA wrapper for the notapalindrome `mlx_video` runtime. The stock
// `mlx_video.generate_av` CLI has no --lora flag, but the package ships an