feat(media): add image / video / audio surfaces with unified od media generate dispatcher
Extends Open Design from web-only to a multi-modal creation tool. The unifying contract is one code-agent loop driven by skills + project metadata + prompt constraints; for non-web surfaces the agent shells out to a single dispatcher (`od media generate`) that the daemon routes per (surface, model). - Types: new Surface union, MediaAspect / AudioKind, image/video/audio ProjectKind + ProjectMetadata fields, video/audio ProjectFileKind. - NewProjectPanel: top-level surface picker + Image / Video / Audio forms with model, aspect, length, duration, voice, audio-kind pickers. - ExamplesTab + DesignSystemsTab: surface filter row that scopes before mode / scenario / category filters. - FileViewer / FileWorkspace: native <video> and <audio> previews and matching tab icons. - Daemon: parses `od.surface` and `> Surface:` blockquotes; recognises mp4 / webm / mov / mp3 / wav / ogg / m4a / flac extensions; spawns agents with OD_BIN / OD_DAEMON_URL / OD_PROJECT_ID / OD_PROJECT_DIR env so any code-agent CLI with shell access can call the dispatcher. - daemon/media.js + daemon/media-models.js: surface-agnostic dispatcher with stub providers that emit deterministic placeholder bytes (1x1 PNG, valid mp4 ftyp, mp3 frame / silent WAV) so the framework works without API keys; real provider integrations slot in later. - daemon/cli.js: `od media generate --surface ... --model ...` subcommand routes to POST /api/projects/:id/media/generate and prints one JSON line for the agent to parse. - prompts/media-contract.ts: hard contract pinned LAST in the system prompt for image/video/audio surfaces — env vars, exact invocation, registered model IDs per surface, six workflow rules. system.ts metadata block updated to point at the contract. - Seed skills: image-poster, video-shortform, audio-jingle each ship a SKILL.md with `mode/surface: image|video|audio` and a stylized example.html preview, and instruct the agent to dispatch via the contract. Made-with: Cursor
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useT } from '../i18n';
|
||||
import type { DesignSystemSummary } from '../types';
|
||||
import type { Dict } from '../i18n/types';
|
||||
import type { DesignSystemSummary, Surface } from '../types';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
interface Props {
|
||||
systems: DesignSystemSummary[];
|
||||
@@ -9,6 +11,20 @@ interface Props {
|
||||
onPreview: (id: string) => void;
|
||||
}
|
||||
|
||||
type SurfaceFilter = 'all' | Surface;
|
||||
|
||||
const SURFACE_PILLS: { value: SurfaceFilter; labelKey: keyof Dict; icon: 'grid' | 'image' | 'video' | 'music' | null }[] = [
|
||||
{ value: 'all', labelKey: 'common.all', icon: null },
|
||||
{ value: 'web', labelKey: 'ds.surfaceWeb', icon: 'grid' },
|
||||
{ value: 'image', labelKey: 'ds.surfaceImage', icon: 'image' },
|
||||
{ value: 'video', labelKey: 'ds.surfaceVideo', icon: 'video' },
|
||||
{ value: 'audio', labelKey: 'ds.surfaceAudio', icon: 'music' },
|
||||
];
|
||||
|
||||
function surfaceOf(system: DesignSystemSummary): Surface {
|
||||
return system.surface ?? 'web';
|
||||
}
|
||||
|
||||
const CATEGORY_ORDER = [
|
||||
'Starter',
|
||||
'AI & LLM',
|
||||
@@ -26,19 +42,43 @@ export function DesignSystemsTab({ systems, selectedId, onSelect, onPreview }: P
|
||||
const t = useT();
|
||||
const [filter, setFilter] = useState('');
|
||||
const [category, setCategory] = useState<string>('All');
|
||||
const [surfaceFilter, setSurfaceFilter] = useState<SurfaceFilter>('all');
|
||||
|
||||
// Pre-scope by surface so the category dropdown only lists categories
|
||||
// that exist within the active surface — avoids ghost options that
|
||||
// would yield zero rows.
|
||||
const surfaceScoped = useMemo(
|
||||
() =>
|
||||
surfaceFilter === 'all'
|
||||
? systems
|
||||
: systems.filter((s) => surfaceOf(s) === surfaceFilter),
|
||||
[systems, surfaceFilter],
|
||||
);
|
||||
|
||||
const surfaceCounts = useMemo(() => {
|
||||
const counts: Record<SurfaceFilter, number> = {
|
||||
all: systems.length,
|
||||
web: 0,
|
||||
image: 0,
|
||||
video: 0,
|
||||
audio: 0,
|
||||
};
|
||||
for (const s of systems) counts[surfaceOf(s)]++;
|
||||
return counts;
|
||||
}, [systems]);
|
||||
|
||||
const categories = useMemo(() => {
|
||||
const cats = new Set<string>();
|
||||
for (const s of systems) cats.add(s.category || 'Uncategorized');
|
||||
for (const s of surfaceScoped) cats.add(s.category || 'Uncategorized');
|
||||
const ordered: string[] = [];
|
||||
for (const c of CATEGORY_ORDER) if (cats.has(c)) ordered.push(c);
|
||||
for (const c of [...cats].sort()) if (!ordered.includes(c)) ordered.push(c);
|
||||
return ['All', ...ordered];
|
||||
}, [systems]);
|
||||
}, [surfaceScoped]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = filter.trim().toLowerCase();
|
||||
return systems.filter((s) => {
|
||||
return surfaceScoped.filter((s) => {
|
||||
if (category !== 'All' && (s.category || 'Uncategorized') !== category) return false;
|
||||
if (!q) return true;
|
||||
return (
|
||||
@@ -46,7 +86,7 @@ export function DesignSystemsTab({ systems, selectedId, onSelect, onPreview }: P
|
||||
s.summary.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}, [systems, filter, category]);
|
||||
}, [surfaceScoped, filter, category]);
|
||||
|
||||
// The category metadata coming from each design system is authored in
|
||||
// English. We translate the well-known buckets (All / Uncategorized) but
|
||||
@@ -60,6 +100,30 @@ export function DesignSystemsTab({ systems, selectedId, onSelect, onPreview }: P
|
||||
|
||||
return (
|
||||
<div className="tab-panel">
|
||||
<div
|
||||
className="examples-filter-row"
|
||||
role="tablist"
|
||||
aria-label={t('ds.surfaceLabel')}
|
||||
>
|
||||
<span className="examples-filter-label">{t('ds.surfaceLabel')}</span>
|
||||
{SURFACE_PILLS.map((p) => (
|
||||
<button
|
||||
key={p.value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={surfaceFilter === p.value}
|
||||
className={`filter-pill ${surfaceFilter === p.value ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setSurfaceFilter(p.value);
|
||||
setCategory('All');
|
||||
}}
|
||||
>
|
||||
{p.icon ? <Icon name={p.icon} size={12} /> : null}
|
||||
{t(p.labelKey)}
|
||||
<span className="filter-pill-count">{surfaceCounts[p.value]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="tab-panel-toolbar">
|
||||
<input
|
||||
placeholder={t('ds.searchPlaceholder')}
|
||||
|
||||
Reference in New Issue
Block a user