Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changelog/next/changed-issue-4165.md
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => <div data-testid="media-image" /> }));
vi.mock('./ScenePreview.jsx', () => ({ default: () => <div data-testid="scene-preview" /> }));
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';

Expand Down
2 changes: 1 addition & 1 deletion client/src/hooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<filename>`) 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/<id>.mp4` 404s while `<id>.jpg` poster loads). Pass the result to `<ScenePreview src=…>`. 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/<filename>`) 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/<id>.mp4` 404s while `<id>.jpg` poster loads). Pass the result to `<ScenePreview src=…>`. 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. |
Expand Down
21 changes: 13 additions & 8 deletions client/src/hooks/useVideoFileSrc.js
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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 `<ScenePreview src=…>`, which falls back to
* its `<jobId>.mp4` reconstruction when this returns null. Callers should gate
* on `resolving` before AUTOPLAYING, or the player would race the lookup and
Expand All @@ -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 });
})
Expand Down
54 changes: 35 additions & 19 deletions client/src/hooks/useVideoFileSrc.test.js
Original file line number Diff line number Diff line change
@@ -1,24 +1,29 @@
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');

// 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 `<id>.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', () => {
Expand All @@ -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
Expand All @@ -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();
Expand All @@ -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();
Expand All @@ -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 () => {
Expand All @@ -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');
});
});
7 changes: 6 additions & 1 deletion client/src/pages/MusicVideo.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] })),
Expand Down Expand Up @@ -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' });
Expand Down
7 changes: 7 additions & 0 deletions client/src/services/apiImageVideo.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions server/routes/videoGen.integrity.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
24 changes: 24 additions & 0 deletions server/routes/videoGen.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
invalidateRuntimeFingerprintCache,
resolveRuntimeFingerprint,
loadHistory,
getHistoryItem,
deleteHistoryItem,
setHistoryItemHidden,
extractLastFrame,
Expand Down Expand Up @@ -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-<project>-<ts>.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
Expand Down
37 changes: 37 additions & 0 deletions server/routes/videoGen.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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' });
Expand Down
Loading