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