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/added-issue-4188.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
- Universes can now link a mood board directly on the record: pick or create a board from the Universe Bible tab and the link survives reload, stays per-universe, and syncs to your other machines (previously the reference strip only remembered one board per browser).
- Mood boards can now pull items straight from your galleries: pick images from the image gallery, pick or upload videos, and video items play right on the board with a poster thumbnail. Uploaded videos land in the shared video gallery, so board items sync to your other machines like any other media.
- Mood board items can now be analyzed with AI: an "Analyze with AI" action on gallery-backed items runs the same Prompt-from-media flow as the video page (your choice of vision provider) and saves the resulting prompt, negative prompt, and rationale onto the item — shown with a highlighted badge, viewable/copyable/removable from the item, and synced to your other machines with the board.
13 changes: 11 additions & 2 deletions client/src/components/media/PromptFromMedia.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ function SourceThumb({ source }) {
* `kindDefault` seeds the target checkboxes (`image` / `video` / `both`).
* When `setPrompt` is passed (Image Gen / Video Gen), Apply fills the host
* form. Otherwise the result offers "Open in Image Gen / Video Gen".
* `onResult` (optional) fires with the full analysis payload after each
* successful run, so a host (e.g. a mood-board item — #4188) can persist it.
*/
export default function PromptFromMedia({
kindDefault = 'both',
Expand All @@ -60,6 +62,7 @@ export default function PromptFromMedia({
initialSource = null,
disabled = false,
alwaysOpen = false,
onResult,
}) {
const navigate = useNavigate();
const idPrefix = useId();
Expand Down Expand Up @@ -174,6 +177,7 @@ export default function PromptFromMedia({
if (!data) return;
setResult(data);
toast.success('Prompts ready');
if (onResult) onResult(data);
};

const apply = (kind) => {
Expand Down Expand Up @@ -389,7 +393,10 @@ function PromptResultField({ label, value, negative, onCopy, onApply, applyLabel
);
}

export function PromptFromMediaModal({ item, open, onClose }) {
// `kindDefault` / `onResult` pass through to PromptFromMedia; `children`
// render above the analyzer in the scroll area — a host can slot in the
// item's stored analysis (mood boards — #4188).
export function PromptFromMediaModal({ item, open, onClose, kindDefault = 'both', onResult, children }) {
if (!open || !item) return null;
return (
<Modal
Expand All @@ -416,10 +423,12 @@ export function PromptFromMediaModal({ item, open, onClose }) {
</button>
</header>
<div className="flex-1 overflow-y-auto p-4">
{children}
<PromptFromMedia
kindDefault="both"
kindDefault={kindDefault}
initialSource={item}
alwaysOpen
onResult={onResult}
/>
</div>
</Modal>
Expand Down
32 changes: 32 additions & 0 deletions client/src/components/media/PromptFromMedia.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,38 @@ describe('PromptFromMedia', () => {
expect(screen.getByText('the camera dollies past the subject')).toBeInTheDocument();
});

it('notifies the host via onResult and sends a filename-only clip without a videoId (#4188)', async () => {
const onResult = vi.fn();
const payload = {
videoPrompt: 'a slow dolly through fog',
videoNegativePrompt: 'jitter',
rationale: 'Foggy push-in.',
providerId: 'openai',
model: 'gpt-4o',
};
vi.mocked(api.promptFromMedia).mockResolvedValue(payload);

renderPanel({
kindDefault: 'video',
applyKind: undefined,
onResult,
// A mood-board video item resolves by on-disk filename — no history id.
initialSource: { kind: 'video', filename: 'clip.mp4', previewUrl: '/data/video-thumbnails/clip.jpg' },
});

fireEvent.click(screen.getByRole('button', { name: /create prompt/i }));

await waitFor(() => {
expect(api.promptFromMedia).toHaveBeenCalledWith(expect.objectContaining({
sourceKind: 'video',
videoId: undefined,
filename: 'clip.mp4',
targets: ['video'],
}));
});
expect(onResult).toHaveBeenCalledWith(payload);
});

it('skips the disclosure toggle when hosted as an always-open card', () => {
renderPanel({ alwaysOpen: true, initialSource: null });
expect(screen.queryByRole('button', { name: /toggle prompt from media/i })).toBeNull();
Expand Down
2 changes: 1 addition & 1 deletion client/src/lib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ grep -i "what you want to do" client/src/lib/README.md
| `mediaNavigation.js` | `getAdjacentMedia(items, item)` — prev/next computation for lightboxes. |
| `mediaCollectionList.js` | Search / sort ordering for the Media Collections grid (#3283) and the `CollectionPickerShell` dropdown (#3312): `applyCollectionView(collections, { query, sort })` filters via `mediaSearch.js`'s AND-token matcher over name+description and orders synthetic "Unsorted" → non-empty-or-user-created → auto-generated empties, then by `COLLECTION_SORTS` (`updated` / `name` / `count`, `normalizeCollectionSort` coerces an unknown/URL value); keys are derived once per record, not per comparison. "Hide empty" is deliberately NOT a parameter — the page owns that one predicate (`collectionItemCount(c) > 0`) because it also needs the pre-filter count. `isAutoCollection(c)` recognizes a machine-created collection from ANY of four independent markers (`AUTO_NAME_PREFIXES`, `Auto-created…`/`Auto-generated…` description, `uc-`/`sc-` id, universe/series link) so a record predating a given marker still classifies; `splitCollectionName(name)` lifts the shared `Creative Director: ` / `Writers Room: ` / `Universe: ` / `Series: ` prefix into a badge label so the distinguishing tail survives truncation, and name sorting uses that stripped title. **Adding a server-side auto-creator means adding its prefix here** or its collections silently rank as user-created. Pure — no React. |
| `mediaSearch.js` | `buildMediaHaystack`, `tokenizeQuery`, `matchHaystack`, `filterByQuery` — client-side AND-token search over normalized media items (prompt/model/seed/LoRA/universe tags). Shared by MediaHistory + the Image Gen gallery picker. |
| `moodBoardItemSrc.js` | `moodBoardItemSrc(item)` resolves a mood-board item to a display image/poster src (`imageUrl` → served `image:<file>` bytes → derived video thumbnail → null); `moodBoardItemVideoSrc(item)` resolves a `type:'video'` item's playback URL. Shared by MoodBoardDetail + MoodBoardReferenceStrip. |
| `moodBoardItemSrc.js` | `moodBoardItemSrc(item)` resolves a mood-board item to a display image/poster src (`imageUrl` → served `image:<file>` bytes → derived video thumbnail → null); `moodBoardItemVideoSrc(item)` resolves a `type:'video'` item's playback URL; `moodBoardItemAnalysisSource(item)` resolves an item to a prompt-from-media source (null when not a local gallery asset). Shared by MoodBoardDetail + MoodBoardReferenceStrip. |
| `registerServiceWorker.js` | `registerServiceWorker()` / `unregisterServiceWorkers()` — wires up the offline app-shell + low-bandwidth asset-caching service worker (`public/sw.js`). Registers only in a production secure context (HTTPS or localhost); no-ops over plain-HTTP Tailnet and tears down any stale SW in dev. Called once from `main.jsx`. |
| `safeStorage.js` | `safeReadStorage` / `safeReadJsonStorage` / `safeWriteStorage` / `safeWriteJsonStorage` / `safeRemoveStorage` — guarded `localStorage` access that swallows throws (Safari private mode, blocked storage), with fallback-safe JSON parsing for structured entries. Use instead of touching `localStorage` inline so a storage failure never crashes init or a write path (#2387). Consumed by `useTheme`, `useCitySettings`, `useNavWorkingSet`, and the command palette. Also `safeReadJsonSession` / `safeWriteJsonSession` / `safeRemoveSession` — the same guarantees over `sessionStorage`, for tab-scoped crash-recovery buffers of edits the server has not accepted yet (QuotaBurn's unsaved-patch stash). |
| `sameJsonShape.js` | `sameJsonShape(prev, next)` — JSON.stringify-based equality for `useAutoRefetch`'s `compare` option on small, deterministically-shaped poll payloads. |
Expand Down
50 changes: 50 additions & 0 deletions client/src/lib/moodBoardItemSrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,56 @@ export function moodBoardItemSrc(item) {
return null;
}

// Reduce an item's `imageUrl` to its on-disk gallery filename, mirroring the
// server's `imageUrlToAppAsset`/`localImageFilename` contract (and the
// client's `startingImageSrc`): remote/inline schemes and non-gallery
// absolute paths are rejected; a `/data/images/...` path OR a legacy
// bare/relative ref reduces to its basename, stripping any query/hash
// BEFORE the basename (a suffix can itself contain a slash). The URL path
// segment is percent-encoded, so decode it back to the on-disk name.
function galleryFilenameFromImageUrl(imageUrl) {
if (typeof imageUrl !== 'string' || !imageUrl.trim()) return null;
const raw = imageUrl.trim();
if (/^(https?:|data:|blob:)/i.test(raw)) return null;
const GALLERY_PREFIX = '/data/images/';
let name;
if (raw.startsWith(GALLERY_PREFIX)) name = raw.slice(GALLERY_PREFIX.length);
else if (raw.startsWith('/')) return null; // some other absolute path → not a gallery image
else name = raw; // legacy bare gallery ref
let filename = name.split(/[?#]/)[0].split('/').pop() || '';
try { filename = decodeURIComponent(filename); } catch { /* keep raw */ }
if (!filename || filename === '.' || filename === '..' || filename.includes('/')) return null;
return filename;
}

// Resolve a board item to a prompt-from-media source (#4188 Phase 3) — the
// gallery-item shape PromptFromMedia's `initialSource` expects. Returns null
// when the item's media isn't a local gallery asset the analyzer can read:
// text items, external-URL pins, and legacy `video:<id>` pins on image items.
// A video item resolves by FILENAME (`kind:'video'` with no id — the server
// accepts filename in place of the history id); an image item resolves by its
// `image:<file>` media-key or a gallery-shaped imageUrl (app path or legacy
// bare ref).
export function moodBoardItemAnalysisSource(item) {
if (item?.type === 'video') {
if (typeof item?.mediaKey === 'string' && item.mediaKey.startsWith(VIDEO_PREFIX)) {
const filename = item.mediaKey.slice(VIDEO_PREFIX.length);
if (filename) return { kind: 'video', filename, previewUrl: moodBoardItemSrc(item) };
}
return null;
}
if (item?.type !== 'image') return null;
if (typeof item?.mediaKey === 'string' && item.mediaKey.startsWith(IMAGE_PREFIX)) {
const filename = item.mediaKey.slice(IMAGE_PREFIX.length);
if (filename) return { filename, previewUrl: moodBoardItemSrc(item) };
}
const filename = galleryFilenameFromImageUrl(item?.imageUrl);
if (filename) {
return { filename, previewUrl: `/data/images/${encodeURIComponent(filename)}` };
}
return null;
}

// Playback URL for a `type: 'video'` item; null for anything else (including
// legacy `video:<id>` pins on image items, whose ref is not a filename).
export function moodBoardItemVideoSrc(item) {
Expand Down
34 changes: 33 additions & 1 deletion client/src/lib/moodBoardItemSrc.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { moodBoardItemSrc, moodBoardItemVideoSrc } from './moodBoardItemSrc';
import { moodBoardItemSrc, moodBoardItemVideoSrc, moodBoardItemAnalysisSource } from './moodBoardItemSrc';

describe('moodBoardItemSrc', () => {
it('prefers an explicit imageUrl', () => {
Expand Down Expand Up @@ -51,3 +51,35 @@ describe('moodBoardItemVideoSrc', () => {
expect(moodBoardItemVideoSrc(null)).toBeNull();
});
});

describe('moodBoardItemAnalysisSource (#4188 Phase 3)', () => {
it('resolves a video item to a filename video source with its poster', () => {
expect(moodBoardItemAnalysisSource({
type: 'video', mediaKey: 'video:clip.mp4', imageUrl: '/data/video-thumbnails/clip.jpg',
})).toEqual({ kind: 'video', filename: 'clip.mp4', previewUrl: '/data/video-thumbnails/clip.jpg' });
});
it('resolves an image item by media-key or a /data/images app path (decoded)', () => {
expect(moodBoardItemAnalysisSource({ type: 'image', mediaKey: 'image:ref.png' }))
.toEqual({ filename: 'ref.png', previewUrl: '/data/images/ref.png' });
expect(moodBoardItemAnalysisSource({ type: 'image', imageUrl: '/data/images/my%20render.png' }))
.toEqual({ filename: 'my render.png', previewUrl: '/data/images/my%20render.png' });
});
it('normalizes legacy bare refs and query/hash-suffixed gallery URLs (server imageUrlToAppAsset parity)', () => {
expect(moodBoardItemAnalysisSource({ type: 'image', imageUrl: 'render.png' }))
.toEqual({ filename: 'render.png', previewUrl: '/data/images/render.png' });
expect(moodBoardItemAnalysisSource({ type: 'image', imageUrl: '/data/images/ref.png?v=2#top' }))
.toEqual({ filename: 'ref.png', previewUrl: '/data/images/ref.png' });
// Query/hash strips BEFORE the basename — a suffix containing a slash
// must not swap in a different asset name.
expect(moodBoardItemAnalysisSource({ type: 'image', imageUrl: '/data/images/photo.png?source=/other.png' }))
.toEqual({ filename: 'photo.png', previewUrl: '/data/images/photo.png' });
expect(moodBoardItemAnalysisSource({ type: 'image', imageUrl: '/data/other/ref.png' })).toBeNull();
});
it('returns null for text items, external pins, and legacy video: pins on image items', () => {
expect(moodBoardItemAnalysisSource({ type: 'text', text: 'n' })).toBeNull();
expect(moodBoardItemAnalysisSource({ type: 'image', imageUrl: 'https://x/y.png' })).toBeNull();
expect(moodBoardItemAnalysisSource({ type: 'image', mediaKey: 'video:abc', imageUrl: 'https://x/t.jpg' })).toBeNull();
expect(moodBoardItemAnalysisSource({ type: 'video', mediaKey: null })).toBeNull();
expect(moodBoardItemAnalysisSource(null)).toBeNull();
});
});
Loading