feat(media): port generation workflow onto main (#12)

Co-authored-by: Elian <elian@EliandeMacBook-Pro.local>
This commit is contained in:
Tom Huang 2026-04-30 22:44:00 +08:00 committed by GitHub
parent 454e8373fb
commit 3f266103b0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
169 changed files with 13582 additions and 101 deletions

View file

@ -37,6 +37,12 @@ pnpm tools-dev run web # starts daemon + web in the foreground
# open the web URL printed by tools-dev
```
For the desktop shell and all managed sidecars in the background:
```bash
pnpm tools-dev # starts daemon + web + desktop in the background
```
On first load, the app detects your installed code-agent CLI (Claude Code / Codex / Gemini / OpenCode / Cursor Agent / Qwen), picks it automatically, and defaults to `web-prototype` skill + `Neutral Modern` design system. Type a prompt and hit **Send**. The agent streams into the left pane; the `<artifact>` tag is parsed out and the HTML renders live on the right. When it finishes, click **Save to disk** to persist the artifact under `./.od/artifacts/<timestamp>-<slug>/index.html`.
The **Design system** dropdown ships with 71 built-in systems — 2 hand-authored starters (Neutral Modern, Warm Editorial) and 69 product systems imported from [`awesome-design-md`](https://github.com/VoltAgent/awesome-design-md), grouped by category (AI & LLM, Developer Tools, Productivity, Backend, Design Tools, Fintech, E-Commerce, Media, Automotive). Pick one to skin every prototype in that brand's aesthetic.
@ -54,9 +60,13 @@ Pair a skill with a design system and a single prompt produces a layout-appropri
pnpm tools-dev # daemon + web + desktop in the background
pnpm tools-dev start web # daemon + web in the background
pnpm tools-dev run web # daemon + web in the foreground (e2e/dev server)
pnpm tools-dev restart # restart daemon + web + desktop
pnpm tools-dev restart --daemon-port 7457 --web-port 5175
pnpm tools-dev status # inspect managed runtimes
pnpm tools-dev logs # show daemon/web/desktop logs
pnpm tools-dev check # status + recent logs + common diagnostics
pnpm tools-dev stop # stop managed runtimes
pnpm --filter @open-design/daemon build # build apps/daemon/dist/cli.js for `od`
pnpm build # production build + static export to apps/web/out/
pnpm typecheck # workspace typecheck
```
@ -65,6 +75,36 @@ pnpm typecheck # workspace typecheck
During local development, `tools-dev` starts the daemon first, passes its port into `apps/web`, and `apps/web/next.config.ts` rewrites `/api/*`, `/artifacts/*`, and `/frames/*` to that daemon port so the App Router app can talk to the sibling Express process without CORS setup.
## Media generation / agent dispatcher checks
Image, video, audio, and HyperFrames skills call the local `od` CLI through environment variables injected by the daemon when it spawns an agent:
- `OD_BIN` — absolute path to `apps/daemon/dist/cli.js`.
- `OD_DAEMON_URL` — the running daemon URL.
- `OD_PROJECT_ID` — the active project id.
- `OD_PROJECT_DIR` — the active project's file directory.
If media generation fails with `OD_BIN: parameter not set`, `apps/daemon/dist/cli.js` missing, or `failed to reach daemon at http://127.0.0.1:0`, rebuild the daemon CLI and restart the managed runtime:
```bash
pnpm --filter @open-design/daemon build
pnpm tools-dev restart --daemon-port 7457 --web-port 5175
ls -la apps/daemon/dist/cli.js
curl -s http://127.0.0.1:7457/api/health
```
Then open the project from the Open Design app again instead of resuming an old terminal agent session. A daemon-spawned agent should see values like:
```bash
echo "OD_BIN=$OD_BIN"
echo "OD_PROJECT_ID=$OD_PROJECT_ID"
echo "OD_PROJECT_DIR=$OD_PROJECT_DIR"
echo "OD_DAEMON_URL=$OD_DAEMON_URL"
ls -la "$OD_BIN"
```
`OD_DAEMON_URL` must be a real daemon port such as `http://127.0.0.1:7457`, not `http://127.0.0.1:0`. The `:0` value is only an internal "pick a free port" launch hint and should not leak into agent sessions.
For the daemon-only production mode, the daemon serves the static Next.js export itself at `http://localhost:7456`, so no reverse proxy is involved.
If you place nginx in front of the daemon, keep SSE routes unbuffered and uncompressed. A common failure is the browser console showing `net::ERR_INCOMPLETE_CHUNKED_ENCODING 200 (OK)` after 80-90 seconds because nginx `gzip on` buffers chunked SSE responses even when the daemon sends `X-Accel-Buffering: no`.
@ -174,6 +214,7 @@ open-design/
- **"no agents found on PATH"** — install one of: `claude`, `codex`, `gemini`, `opencode`, `cursor-agent`, `qwen`, `copilot`. Or switch to "Anthropic API · BYOK" in the top bar and paste a key in **Settings**.
- **daemon 500 on /api/chat** — check the daemon terminal for the stderr tail; usually the CLI rejected its args. Different CLIs take different argv shapes; see `apps/daemon/src/agents.ts` `buildArgs` if you need to tweak.
- **media generation says `OD_BIN` is missing or daemon URL is `:0`** — run the media dispatcher checks above. Do not resume the old CLI session; reopen the project from the Open Design app so the daemon can inject fresh `OD_*` variables.
- **Codex loads too much plugin context** — start Open Design with `OD_CODEX_DISABLE_PLUGINS=1 pnpm tools-dev` to make daemon-spawned Codex processes run with `--disable plugins`.
- **artifact never renders** — the model produced text without wrapping in `<artifact>`. Confirm the system prompt is going through (check daemon log) and consider switching to a more capable model or a stricter skill.

View file

@ -268,6 +268,8 @@ pnpm tools-dev run web
Environment requirements: Node `~24` and pnpm `10.33.x`. `nvm`/`fnm` are optional helpers only; if you use one, run `nvm install 24 && nvm use 24` or `fnm install 24 && fnm use 24` before `pnpm install`.
For desktop/background startup, fixed-port restarts, and media generation dispatcher checks (`OD_BIN`, `OD_DAEMON_URL`, `apps/daemon/dist/cli.js`), see [`QUICKSTART.md`](QUICKSTART.md).
The first load:
1. Detects which agent CLIs you have on `PATH` and picks one automatically.

View file

@ -268,6 +268,8 @@ pnpm tools-dev run web
环境要求Node `~24`pnpm `10.33.x`。`nvm` / `fnm` 只是可选辅助工具,不是项目必需步骤;如果使用它们,先执行 `nvm install 24 && nvm use 24``fnm install 24 && fnm use 24`,再运行 `pnpm install`
桌面端/后台启动、固定端口重启,以及 media 生成派发器检查(`OD_BIN`、`OD_DAEMON_URL`、`apps/daemon/dist/cli.js`)见 [`QUICKSTART.md`](QUICKSTART.md)。
第一次加载会:
1. 检测你 `PATH` 上有哪些 agent CLI自动选一个。

View file

@ -165,7 +165,14 @@ export const AGENT_DEFS = [
// Prompt delivered via stdin (`codex exec -`) to avoid Windows
// `spawn ENAMETOOLONG` while keeping Codex on its structured JSON stream.
buildArgs: (_prompt, _imagePaths, _extra, options = {}, runtimeContext = {}) => {
const args = ['exec', '--json', '--skip-git-repo-check', '--full-auto'];
const args = [
'exec',
'--json',
'--skip-git-repo-check',
'--full-auto',
'-c',
'sandbox_workspace_write.network_access=true',
];
if (process.env.OD_CODEX_DISABLE_PLUGINS === '1') {
args.push('--disable', 'plugins');
}

View file

@ -2,24 +2,74 @@
// @ts-nocheck
import { startServer } from './server.js';
const args = process.argv.slice(2);
const argv = process.argv.slice(2);
// ---- Subcommand router ----------------------------------------------------
//
// `od` is two CLIs glued together:
// - default mode: starts the daemon + opens the web UI.
// - `od media …`: a thin client that POSTs to the running daemon. This
// is what the code agent invokes from inside a chat to actually
// produce image / video / audio bytes (the unifying contract).
//
// We dispatch on the first positional argument so flags like --port keep
// working unchanged. Subcommand routing is keyword-based; flags are
// parsed inside each handler.
// Flags accepted by `od media generate`. Whitelisted so a hallucinated
// `--lenght 5` from the LLM fails fast instead of silently no-op'ing
// while we route a bogus body to the daemon.
//
// Hoisted to the top of the module *before* the subcommand dispatch
// below: top-level `await SUBCOMMAND_MAP[first](rest)` runs runMedia
// synchronously during module evaluation, and runMedia references these
// `const` Sets — leaving them at the bottom of the file would hit the
// TDZ ("Cannot access 'MEDIA_GENERATE_STRING_FLAGS' before
// initialization") and crash every `od media …` invocation.
const MEDIA_GENERATE_STRING_FLAGS = new Set([
'project',
'surface',
'model',
'prompt',
'output',
'aspect',
'length',
'duration',
'voice',
'audio-kind',
'composition-dir',
'image',
'daemon-url',
]);
const MEDIA_GENERATE_BOOLEAN_FLAGS = new Set([
'help',
'h',
]);
const SUBCOMMAND_MAP = {
media: runMedia,
};
const first = argv.find((a) => !a.startsWith('-'));
if (first && SUBCOMMAND_MAP[first]) {
const idx = argv.indexOf(first);
const rest = [...argv.slice(0, idx), ...argv.slice(idx + 1)];
await SUBCOMMAND_MAP[first](rest);
process.exit(0);
}
// Default: daemon mode.
let port = Number(process.env.OD_PORT) || 7456;
let open = true;
for (let i = 0; i < args.length; i++) {
const a = args[i];
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '-p' || a === '--port') {
port = Number(args[++i]);
port = Number(argv[++i]);
} else if (a === '--no-open') {
open = false;
} else if (a === '-h' || a === '--help') {
console.log(`Usage: od [--port <n>] [--no-open]
Starts a local daemon that:
* scans PATH for installed code-agent CLIs (claude, codex, gemini, opencode, cursor-agent, ...)
* serves a tiny web chat UI at http://localhost:<port>
* proxies messages (text + images) to the selected agent via child-process spawn
`);
printRootHelp();
process.exit(0);
}
}
@ -35,3 +85,337 @@ startServer({ port }).then(url => {
});
}
});
function printRootHelp() {
console.log(`Usage:
od [--port <n>] [--no-open]
Start the local daemon and open the web UI.
od media generate --surface <image|video|audio> --model <id> [opts]
Generate a media artifact and write it into the active project.
Designed to be invoked by a code agent picks up OD_DAEMON_URL
and OD_PROJECT_ID from the env that the daemon injected on spawn.
What the daemon does:
* scans PATH for installed code-agent CLIs (claude, codex, gemini, opencode, cursor-agent, ...)
* serves the chat UI at http://localhost:<port>
* proxies messages (text + images) to the selected agent via child-process spawn
* exposes /api/projects/:id/media/generate the unified image/video/audio
dispatcher that the agent calls via \`od media generate\`.`);
}
// ---------------------------------------------------------------------------
// Subcommand: od media …
// ---------------------------------------------------------------------------
async function runMedia(args) {
const sub = args.find((a) => !a.startsWith('-')) || '';
if (sub === 'help' || sub === '-h' || sub === '--help' || sub === '') {
printMediaHelp();
return;
}
if (sub !== 'generate' && sub !== 'wait') {
console.error(`unknown subcommand: od media ${sub}`);
printMediaHelp();
process.exit(1);
}
const idx = args.indexOf(sub);
const subArgs = [...args.slice(0, idx), ...args.slice(idx + 1)];
if (sub === 'wait') return runMediaWait(subArgs);
return runMediaGenerate(subArgs);
}
async function runMediaGenerate(rawArgs) {
let flags;
try {
flags = parseFlags(rawArgs, {
string: MEDIA_GENERATE_STRING_FLAGS,
boolean: MEDIA_GENERATE_BOOLEAN_FLAGS,
});
} catch (err) {
console.error(err.message);
printMediaHelp();
process.exit(2);
}
const daemonUrl = flags['daemon-url'] || process.env.OD_DAEMON_URL || 'http://127.0.0.1:7456';
const projectId = flags.project || process.env.OD_PROJECT_ID;
if (!projectId) {
console.error(
'project id required. Pass --project <id> or set OD_PROJECT_ID. The daemon injects this when it spawns the code agent.',
);
process.exit(2);
}
const surface = flags.surface;
if (!surface || !['image', 'video', 'audio'].includes(surface)) {
console.error('--surface must be one of: image | video | audio');
process.exit(2);
}
if (!flags.model) {
console.error('--model required (see http://<daemon>/api/media/models)');
process.exit(2);
}
const body = {
surface,
model: flags.model,
prompt: flags.prompt,
output: flags.output,
aspect: flags.aspect,
voice: flags.voice,
audioKind: flags['audio-kind'],
compositionDir: flags['composition-dir'],
image: flags.image,
};
if (flags.length != null) body.length = Number(flags.length);
if (flags.duration != null) body.duration = Number(flags.duration);
const url = `${daemonUrl.replace(/\/$/, '')}/api/projects/${encodeURIComponent(projectId)}/media/generate`;
let resp;
try {
resp = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
} catch (err) {
surfaceFetchError(err, daemonUrl);
process.exit(3);
}
if (!resp.ok) {
const text = await resp.text();
console.error(`daemon ${resp.status}: ${text}`);
process.exit(4);
}
const accepted = await resp.json();
const { taskId } = accepted;
if (!taskId) {
console.error('daemon did not return a taskId');
process.exit(4);
}
console.error(`task ${taskId} queued (${accepted.status || 'queued'})`);
await pollUntilDoneOrBudget(daemonUrl, taskId, 0);
}
async function runMediaWait(rawArgs) {
const taskId = rawArgs.find((a) => a && !a.startsWith('--'));
if (!taskId) {
console.error('usage: od media wait <taskId> [--since <n>] [--daemon-url <url>]');
process.exit(2);
}
const flagsOnly = rawArgs.filter((a) => a !== taskId);
let flags;
try {
flags = parseFlags(flagsOnly, {
string: new Set(['since', 'daemon-url']),
boolean: new Set(['help', 'h']),
});
} catch (err) {
console.error(err.message);
printMediaHelp();
process.exit(2);
}
const daemonUrl =
flags['daemon-url'] || process.env.OD_DAEMON_URL || 'http://127.0.0.1:7456';
const since = Number.isFinite(Number(flags.since))
? Number(flags.since)
: 0;
await pollUntilDoneOrBudget(daemonUrl, taskId, since);
}
async function pollUntilDoneOrBudget(daemonUrl, taskId, sinceStart) {
const totalBudgetMs = 25_000;
const perCallTimeoutMs = 4_000;
const startedAt = Date.now();
const url = `${daemonUrl.replace(/\/$/, '')}/api/media/tasks/${encodeURIComponent(taskId)}/wait`;
let since = Number.isFinite(sinceStart) ? sinceStart : 0;
let lastSnapshot = null;
while (Date.now() - startedAt < totalBudgetMs) {
const remaining = totalBudgetMs - (Date.now() - startedAt);
const callTimeout = Math.max(500, Math.min(perCallTimeoutMs, remaining));
let resp;
try {
resp = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ since, timeoutMs: callTimeout }),
});
} catch (err) {
surfaceFetchError(err, daemonUrl);
process.exit(3);
}
if (resp.status === 404) {
console.error(`task ${taskId} not found (expired or never queued)`);
process.exit(4);
}
if (!resp.ok) {
const text = await resp.text();
console.error(`daemon ${resp.status}: ${text}`);
process.exit(4);
}
let snap;
try {
snap = await resp.json();
} catch {
console.error('daemon returned non-JSON for /wait');
process.exit(4);
}
lastSnapshot = snap;
if (Array.isArray(snap.progress)) {
for (const line of snap.progress) {
process.stderr.write(line + '\n');
process.stdout.write(`# ${line}\n`);
}
}
if (typeof snap.nextSince === 'number') since = snap.nextSince;
if (snap.status === 'done') {
const file = snap.file || {};
const warnings = Array.isArray(file.warnings) ? file.warnings : [];
for (const w of warnings) {
if (typeof w === 'string' && w) console.error(`WARN: ${w}`);
}
if (file.providerError) {
const provider = file.providerId || 'provider';
console.error(
`WARN: ${provider} call failed — wrote stub fallback (${file.size} bytes) to ${file.name}`,
);
console.error(`WARN: reason: ${file.providerError}`);
console.error(
'WARN: surface this verbatim to the user. Do NOT claim the stub is the final result.',
);
}
process.stdout.write(JSON.stringify({ file }) + '\n');
process.exit(file.providerError ? 5 : 0);
}
if (snap.status === 'failed') {
const msg = snap.error?.message || 'task failed';
console.error(`task failed: ${msg}`);
process.stdout.write(
JSON.stringify({ taskId, status: 'failed', error: snap.error || {} }) + '\n',
);
process.exit(snap.error?.status || 5);
}
}
const handoff = {
taskId,
status: lastSnapshot?.status || 'running',
nextSince: since,
elapsed: Math.round((Date.now() - startedAt) / 1000),
};
process.stdout.write(JSON.stringify(handoff) + '\n');
process.stderr.write(
`task ${taskId} still running after ${handoff.elapsed}s. ` +
`Run \`od media wait ${taskId} --since ${since}\` to continue ` +
`(exit code 2 = still running).\n`,
);
process.exit(2);
}
function surfaceFetchError(err, daemonUrl) {
const cause = err && typeof err === 'object' ? err.cause : null;
const code =
cause && typeof cause === 'object' && typeof cause.code === 'string'
? cause.code
: null;
const causeMsg =
cause && typeof cause === 'object' && typeof cause.message === 'string'
? cause.message
: '';
let detail = err && err.message ? err.message : String(err);
if (code) detail = `${code}${causeMsg ? `${causeMsg}` : ''}`;
else if (causeMsg) detail = causeMsg;
console.error(`failed to reach daemon at ${daemonUrl}: ${detail}`);
if (code === 'EPERM' || code === 'ENETUNREACH') {
console.error(
'hint: outbound connect was denied by a sandbox. If you launched ' +
'this command from a code agent, check the agent\'s sandbox / ' +
'network policy. The OD daemon itself is unaffected — it can be ' +
'reached from a regular shell.',
);
}
}
function parseFlags(argv, opts = {}) {
const stringFlags = opts.string instanceof Set ? opts.string : new Set();
const booleanFlags = opts.boolean instanceof Set ? opts.boolean : new Set();
const knownFlags = new Set([...stringFlags, ...booleanFlags]);
const out = {};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (!a || !a.startsWith('--')) {
throw new Error(`unexpected positional argument: ${a}`);
}
const eq = a.indexOf('=');
const key = eq >= 0 ? a.slice(2, eq) : a.slice(2);
if (knownFlags.size > 0 && !knownFlags.has(key)) {
throw new Error(
`unknown flag: --${key}. Run with --help for the list of accepted flags.`,
);
}
if (eq >= 0) {
out[key] = a.slice(eq + 1);
continue;
}
if (booleanFlags.has(key)) {
out[key] = true;
continue;
}
if (stringFlags.has(key)) {
const next = argv[i + 1];
if (next == null) {
throw new Error(`flag --${key} requires a value`);
}
out[key] = next;
i++;
continue;
}
const next = argv[i + 1];
if (next != null && !next.startsWith('--')) {
out[key] = next;
i++;
} else {
out[key] = true;
}
}
return out;
}
function printMediaHelp() {
console.log(`Usage: od media generate --surface <image|video|audio> --model <id> [opts]
Required:
--surface image | video | audio
--model Model id from /api/media/models (e.g. gpt-image-2, seedance-2, suno-v5).
--project Project id. Auto-resolved from OD_PROJECT_ID when invoked by the daemon.
Common options:
--prompt "<text>" Generation prompt.
--output <filename> File to write under the project. Auto-named if omitted.
--aspect 1:1|16:9|9:16|4:3|3:4
--length <seconds> Video length.
--duration <seconds> Audio duration.
--voice <voice-id> Speech / TTS voice.
--audio-kind music|speech|sfx
--composition-dir <path> hyperframes-html only project-relative path
to the dir containing hyperframes.json /
meta.json / index.html. The daemon runs
\`npx hyperframes render\` against it.
--image <path> Project-relative path to a reference image
(image-to-video for Seedance i2v models, or
future image-edit endpoints). Daemon reads
the file from the project, base64-encodes
it, and forwards it to the upstream API.
--daemon-url http://127.0.0.1:7456
Output: a single line of JSON: {"file": { name, size, kind, mime, ... }}.
Skills should call this and then reference the returned filename in their
artifact / message body. The daemon writes the bytes into the project's
files folder so the FileViewer can preview them immediately.`);
}

View file

@ -30,6 +30,7 @@ export async function listDesignSystems(root) {
category: extractCategory(raw) ?? 'Uncategorized',
summary: summarize(raw),
swatches: extractSwatches(raw),
surface: extractSurface(raw),
body: raw,
});
} catch {
@ -68,6 +69,14 @@ function extractCategory(raw) {
return m?.[1];
}
const KNOWN_SURFACES = new Set(['web', 'image', 'video', 'audio']);
function extractSurface(raw) {
const m = /^>\s*Surface:\s*(.+?)\s*$/im.exec(raw);
if (!m) return 'web';
const v = m[1].trim().toLowerCase();
return KNOWN_SURFACES.has(v) ? v : 'web';
}
// Strip boilerplate like "Design System Inspired by Cohere" → "Cohere" so
// the picker dropdown reads cleanly. Hand-authored titles that don't match
// the pattern (e.g. "Neutral Modern") pass through unchanged.

View file

@ -203,7 +203,7 @@ function handleCursorEvent(obj, onEvent, state) {
return false;
}
function handleCodexEvent(obj, onEvent) {
function handleCodexEvent(obj, onEvent, state) {
if (!obj || typeof obj !== 'object') return false;
if (obj.type === 'thread.started') {
@ -216,6 +216,48 @@ function handleCodexEvent(obj, onEvent) {
return true;
}
if (obj.type === 'item.started' && obj.item && typeof obj.item === 'object') {
const item = obj.item;
if (item.type === 'command_execution' && typeof item.id === 'string') {
if (!state.codexToolUses.has(item.id)) {
state.codexToolUses.add(item.id);
onEvent({
type: 'tool_use',
id: item.id,
name: 'Bash',
input: {
command: typeof item.command === 'string' ? item.command : '',
},
});
}
return true;
}
}
if (obj.type === 'item.completed' && obj.item && typeof obj.item === 'object') {
const item = obj.item;
if (item.type === 'command_execution' && typeof item.id === 'string') {
if (!state.codexToolUses.has(item.id)) {
state.codexToolUses.add(item.id);
onEvent({
type: 'tool_use',
id: item.id,
name: 'Bash',
input: {
command: typeof item.command === 'string' ? item.command : '',
},
});
}
onEvent({
type: 'tool_result',
toolUseId: item.id,
content: stringifyContent(item.aggregated_output ?? ''),
isError: typeof item.exit_code === 'number' ? item.exit_code !== 0 : item.status === 'failed',
});
return true;
}
}
if (
obj.type === 'item.completed' &&
obj.item &&
@ -247,6 +289,7 @@ export function createJsonEventStreamHandler(kind, onEvent) {
const state = {
cursorTextSoFar: '',
openCodeToolUses: new Set(),
codexToolUses: new Set(),
};
function handleLine(line) {
@ -261,7 +304,7 @@ export function createJsonEventStreamHandler(kind, onEvent) {
if (kind === 'opencode' && handleOpenCodeEvent(obj, onEvent, state)) return;
if (kind === 'gemini' && handleGeminiEvent(obj, onEvent)) return;
if (kind === 'cursor-agent' && handleCursorEvent(obj, onEvent, state)) return;
if (kind === 'codex' && handleCodexEvent(obj, onEvent)) return;
if (kind === 'codex' && handleCodexEvent(obj, onEvent, state)) return;
onEvent({ type: 'raw', line });
}

View file

@ -0,0 +1,175 @@
// @ts-nocheck
// Per-provider credentials for the media dispatcher.
//
// The frontend Settings dialog pushes API keys here via PUT
// /api/media/config; the daemon persists them to .od/media-config.json
// and reads them at generation time. Environment variables override the
// stored values so power users can keep keys out of the workspace
// folder altogether (`OD_OPENAI_API_KEY=… node daemon/cli.js`).
//
// The file is intentionally simple JSON — no encryption, no schema
// versioning yet. The daemon listens on 127.0.0.1 only and the workspace
// is already trusted, so adding a vault here would mostly be theatre.
// We DO mask keys when reading via the GET endpoint so the UI doesn't
// echo secrets back into the DOM.
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { MEDIA_PROVIDERS } from './media-models.js';
const PROVIDER_IDS = MEDIA_PROVIDERS.map((p) => p.id);
const ENV_KEYS = {
// OPENAI_API_KEY is the canonical env for the standard OpenAI API.
// AZURE_API_KEY / AZURE_OPENAI_API_KEY are the canonical envs Azure
// OpenAI examples use — we share the openai provider slot so a user
// who pastes an Azure deployment URL into the OpenAI Base URL field
// gets the credential picked up automatically.
openai: [
'OD_OPENAI_API_KEY',
'OPENAI_API_KEY',
'AZURE_API_KEY',
'AZURE_OPENAI_API_KEY',
],
volcengine: ['OD_VOLCENGINE_API_KEY', 'ARK_API_KEY', 'VOLCENGINE_API_KEY'],
bfl: ['OD_BFL_API_KEY', 'BFL_API_KEY'],
fal: ['OD_FAL_KEY', 'FAL_KEY'],
replicate: ['OD_REPLICATE_API_TOKEN', 'REPLICATE_API_TOKEN'],
google: ['OD_GOOGLE_API_KEY', 'GOOGLE_API_KEY', 'GEMINI_API_KEY'],
kling: ['OD_KLING_API_KEY', 'KLING_API_KEY'],
midjourney: ['OD_MIDJOURNEY_API_KEY'],
minimax: ['OD_MINIMAX_API_KEY', 'MINIMAX_API_KEY'],
suno: ['OD_SUNO_API_KEY'],
udio: ['OD_UDIO_API_KEY'],
elevenlabs: ['OD_ELEVENLABS_API_KEY', 'ELEVENLABS_API_KEY'],
fishaudio: ['OD_FISHAUDIO_API_KEY', 'FISH_AUDIO_API_KEY'],
};
function configFile(projectRoot) {
return path.join(projectRoot, '.od', 'media-config.json');
}
async function readStored(projectRoot) {
try {
const raw = await readFile(configFile(projectRoot), 'utf8');
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object' && parsed.providers) {
return parsed.providers;
}
return {};
} catch (err) {
if (err && err.code === 'ENOENT') return {};
throw err;
}
}
async function writeStored(projectRoot, providers) {
const file = configFile(projectRoot);
await mkdir(path.dirname(file), { recursive: true });
await writeFile(file, JSON.stringify({ providers }, null, 2), 'utf8');
}
function readEnvKey(providerId) {
const keys = ENV_KEYS[providerId];
if (!keys) return null;
for (const k of keys) {
const v = process.env[k];
if (typeof v === 'string' && v.trim()) return v.trim();
}
return null;
}
/**
* Resolve credentials for a provider. Env vars win, then stored config.
* Returns { apiKey, baseUrl } where either may be empty string.
*/
export async function resolveProviderConfig(projectRoot, providerId) {
const stored = await readStored(projectRoot);
const entry = stored[providerId] || {};
const envKey = readEnvKey(providerId);
return {
apiKey: envKey || entry.apiKey || '',
baseUrl: entry.baseUrl || '',
};
}
/**
* Read the full config for the GET endpoint. API keys are masked so the
* frontend can show "••••" + a "configured" indicator without leaking
* the secret back into the DOM.
*/
export async function readMaskedConfig(projectRoot) {
const stored = await readStored(projectRoot);
const providers = {};
for (const id of PROVIDER_IDS) {
const entry = stored[id] || {};
const envKey = readEnvKey(id);
const hasStoredKey = typeof entry.apiKey === 'string' && entry.apiKey.length > 0;
providers[id] = {
configured: Boolean(envKey || hasStoredKey),
source: envKey ? 'env' : hasStoredKey ? 'stored' : 'unset',
// Show last 4 chars only when stored locally; never echo env-var
// secrets so power users who only export ENV don't accidentally
// see them in the DOM.
apiKeyTail: hasStoredKey ? entry.apiKey.slice(-4) : '',
baseUrl: entry.baseUrl || '',
};
}
return { providers };
}
/**
* Write the supplied {providerId: {apiKey, baseUrl}} map. Empty
* apiKey deletes the entry. Unknown provider IDs are ignored. We
* deliberately replace the whole map rather than merging so the
* UI's "clear key" affordance just sends an empty string.
*
* Safety: if the incoming payload is empty but the on-disk config
* currently has providers, we log a WARN to stderr. This catches
* accidental wipes (e.g. a fresh-localStorage browser bootstrap
* pushing `{providers: {}}` onto a daemon that had keys from a
* previous session) without silently destroying the user's data.
*/
export async function writeConfig(projectRoot, body) {
const incoming = body && typeof body === 'object' ? body.providers || {} : {};
const force = Boolean(body && typeof body === 'object' && body.force === true);
const next = {};
for (const id of PROVIDER_IDS) {
const entry = incoming[id];
if (!entry || typeof entry !== 'object') continue;
const apiKey =
typeof entry.apiKey === 'string' && entry.apiKey.trim()
? entry.apiKey.trim()
: '';
const baseUrl =
typeof entry.baseUrl === 'string' && entry.baseUrl.trim()
? entry.baseUrl.trim()
: '';
if (!apiKey && !baseUrl) continue;
next[id] = { apiKey, baseUrl };
}
if (Object.keys(next).length === 0) {
const prior = await readStored(projectRoot);
const priorIds = Object.keys(prior).filter(
(id) => prior[id] && (prior[id].apiKey || prior[id].baseUrl),
);
if (priorIds.length > 0) {
if (!force) {
const err = new Error(
`refusing to wipe ${priorIds.length} configured provider(s) without force=true: ${priorIds.join(', ')}`,
);
err.status = 409;
throw err;
}
try {
console.error(
`[media-config] WARN: incoming PUT empty, would wipe ${priorIds.length} configured provider(s): ${priorIds.join(', ')}`,
);
} catch {
// best-effort logging only
}
}
}
await writeStored(projectRoot, next);
return readMaskedConfig(projectRoot);
}

View file

@ -0,0 +1,125 @@
// @ts-nocheck
// Daemon-side mirror of src/media/models.ts. We keep this in plain JS so
// node imports are native and the daemon never needs a TS toolchain at
// runtime. The two files are kept in sync by hand — any model added to
// src/media/models.ts must be added here too. Drift is enforced by
// `node scripts/verify-media-models.mjs` (also exposed as
// `npm run verify:media-models`); CI should call it before publish so
// the moment one side adds a model and the other doesn't, the build
// fails with a precise diff.
export const MEDIA_PROVIDERS = [
{ id: 'openai', label: 'OpenAI', hint: 'gpt-image-2 / dall-e-3', integrated: true, defaultBaseUrl: 'https://api.openai.com/v1' },
{ id: 'volcengine', label: 'Volcengine Ark (Doubao)', hint: 'Seedance 2.0 / Seedream', integrated: true, defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3' },
{ id: 'hyperframes', label: 'HyperFrames', hint: 'Local HTML -> MP4 renderer', integrated: true, credentialsRequired: false, settingsVisible: false },
{ id: 'bfl', label: 'Black Forest Labs', hint: 'FLUX 1.1 Pro / FLUX Pro / Dev', integrated: false, defaultBaseUrl: 'https://api.bfl.ai' },
{ id: 'fal', label: 'Fal.ai', hint: 'Sora / Seedance / Veo / FLUX', integrated: false, defaultBaseUrl: 'https://fal.run' },
{ id: 'replicate', label: 'Replicate', hint: 'FLUX / SDXL / Ideogram', integrated: false, defaultBaseUrl: 'https://api.replicate.com/v1' },
{ id: 'google', label: 'Google AI / Vertex', hint: 'Imagen 4 / Veo 3 / Lyria', integrated: false },
{ id: 'kling', label: 'Kuaishou Kling', hint: 'Kling 1.6 / 2.0 video', integrated: false },
{ id: 'midjourney', label: 'Midjourney (proxy)', hint: 'midjourney-v7', integrated: false },
{ id: 'minimax', label: 'MiniMax', hint: 'TTS / video-01', integrated: true, defaultBaseUrl: 'https://api.minimaxi.chat/v1' },
{ id: 'suno', label: 'Suno', hint: 'Music generation', integrated: false },
{ id: 'udio', label: 'Udio', hint: 'Music generation', integrated: false },
{ id: 'elevenlabs', label: 'ElevenLabs', hint: 'Voice / SFX', integrated: false },
{ id: 'fishaudio', label: 'FishAudio', hint: 'Speech / voice clone', integrated: true, defaultBaseUrl: 'https://api.fish.audio' },
{ id: 'stub', label: 'Stub (placeholder)', hint: 'Deterministic local placeholder bytes', integrated: true },
];
export const IMAGE_MODELS = [
{ id: 'gpt-image-2', label: 'gpt-image-2', hint: 'OpenAI · 4K, native multimodal', provider: 'openai', caps: ['t2i', 'i2i', 'inpaint'], default: true },
{ id: 'gpt-image-1.5', label: 'gpt-image-1.5', hint: 'OpenAI · 4× faster than gpt-image-1', provider: 'openai', caps: ['t2i', 'i2i', 'inpaint'] },
{ id: 'gpt-image-1', label: 'gpt-image-1', hint: 'OpenAI · ChatGPT native', provider: 'openai', caps: ['t2i', 'i2i', 'inpaint'] },
{ id: 'gpt-image-1-mini', label: 'gpt-image-1-mini', hint: 'OpenAI · low-cost variant', provider: 'openai', caps: ['t2i', 'i2i'] },
{ id: 'dall-e-3', label: 'dall-e-3', hint: 'OpenAI · classic', provider: 'openai', caps: ['t2i'] },
{ id: 'dall-e-2', label: 'dall-e-2', hint: 'OpenAI · legacy', provider: 'openai', caps: ['t2i'] },
{ id: 'doubao-seedream-3-0-t2i-250415', label: 'seedream-3.0', hint: 'ByteDance · Doubao image', provider: 'volcengine', caps: ['t2i'] },
{ id: 'doubao-seededit-3-0-i2i-250628', label: 'seededit-3.0', hint: 'ByteDance · image edit', provider: 'volcengine', caps: ['i2i'] },
{ id: 'flux-1.1-pro', label: 'flux-1.1-pro', hint: 'BFL · flagship', provider: 'bfl', caps: ['t2i', 'i2i'] },
{ id: 'flux-pro', label: 'flux-pro', hint: 'BFL', provider: 'bfl', caps: ['t2i'] },
{ id: 'flux-dev', label: 'flux-dev', hint: 'BFL · open weights', provider: 'bfl', caps: ['t2i'] },
{ id: 'flux-schnell', label: 'flux-schnell', hint: 'BFL · fast', provider: 'bfl', caps: ['t2i'] },
{ id: 'flux-kontext-pro', label: 'flux-kontext-pro', hint: 'BFL · in-context edits', provider: 'bfl', caps: ['t2i', 'i2i'] },
{ id: 'imagen-4', label: 'imagen-4', hint: 'Google · latest', provider: 'google', caps: ['t2i'] },
{ id: 'imagen-3', label: 'imagen-3', hint: 'Google', provider: 'google', caps: ['t2i'] },
{ id: 'gemini-3-pro-image-preview', label: 'gemini-3-pro-image', hint: 'Google · Nano Banana Pro', provider: 'google', caps: ['t2i', 'i2i'] },
{ id: 'ideogram-v2', label: 'ideogram-v2', hint: 'Replicate · typography', provider: 'replicate', caps: ['t2i'] },
{ id: 'sdxl', label: 'stable-diffusion-xl', hint: 'Replicate · SDXL', provider: 'replicate', caps: ['t2i'] },
{ id: 'sd-3.5', label: 'stable-diffusion-3.5', hint: 'Fal · SD 3.5', provider: 'fal', caps: ['t2i'] },
{ id: 'midjourney-v7', label: 'midjourney-v7', hint: 'Midjourney · via proxy', provider: 'midjourney', caps: ['t2i'] },
];
export const VIDEO_MODELS = [
{ id: 'doubao-seedance-2-0-260128', label: 'seedance-2.0', hint: 'ByteDance · t2v + i2v + audio', provider: 'volcengine', caps: ['t2v', 'i2v', 'audio'], default: true },
{ id: 'doubao-seedance-2-0-fast-260128', label: 'seedance-2.0-fast', hint: 'ByteDance · faster, cheaper', provider: 'volcengine', caps: ['t2v', 'i2v', 'audio'] },
{ id: 'doubao-seedance-1-0-pro-250528', label: 'seedance-1.0-pro', hint: 'ByteDance · 1.0', provider: 'volcengine', caps: ['t2v', 'i2v'] },
{ id: 'doubao-seedance-1-0-lite-i2v-250428', label: 'seedance-1.0-lite-i2v', hint: 'ByteDance · image-to-video', provider: 'volcengine', caps: ['i2v'] },
{ id: 'doubao-seedance-1-0-lite-t2v-250428', label: 'seedance-1.0-lite-t2v', hint: 'ByteDance · text-to-video', provider: 'volcengine', caps: ['t2v'] },
{ id: 'kling-2.0', label: 'kling-2.0', hint: 'Kuaishou · latest', provider: 'kling', caps: ['t2v', 'i2v'] },
{ id: 'kling-1.6', label: 'kling-1.6', hint: 'Kuaishou', provider: 'kling', caps: ['t2v', 'i2v'] },
{ id: 'kling-1.5', label: 'kling-1.5', hint: 'Kuaishou', provider: 'kling', caps: ['t2v', 'i2v'] },
{ id: 'veo-3', label: 'veo-3', hint: 'Google · sound-on', provider: 'google', caps: ['t2v', 'audio'] },
{ id: 'veo-2', label: 'veo-2', hint: 'Google', provider: 'google', caps: ['t2v'] },
{ id: 'sora-2', label: 'sora-2', hint: 'OpenAI · via Fal', provider: 'fal', caps: ['t2v'] },
{ id: 'sora-2-pro', label: 'sora-2-pro', hint: 'OpenAI · via Fal', provider: 'fal', caps: ['t2v'] },
{ id: 'minimax-video-01', label: 'video-01', hint: 'MiniMax · Hailuo', provider: 'minimax', caps: ['t2v', 'i2v'] },
{ id: 'hyperframes-html', label: 'hyperframes-html', hint: 'HyperFrames · local HTML renderer', provider: 'hyperframes', caps: ['t2v'] },
];
export const AUDIO_MODELS_BY_KIND = {
music: [
{ id: 'suno-v5', label: 'suno-v5', hint: 'Suno · default', provider: 'suno', caps: ['music'], default: true },
{ id: 'suno-v4-5', label: 'suno-v4.5', hint: 'Suno', provider: 'suno', caps: ['music'] },
{ id: 'udio-v2', label: 'udio-v2', hint: 'Udio', provider: 'udio', caps: ['music'] },
{ id: 'lyria-2', label: 'lyria-2', hint: 'Google', provider: 'google', caps: ['music'] },
],
speech: [
{ id: 'gpt-4o-mini-tts', label: 'gpt-4o-mini-tts', hint: 'OpenAI · expressive TTS', provider: 'openai', caps: ['tts'] },
{ id: 'minimax-tts', label: 'minimax-tts', hint: 'MiniMax · default', provider: 'minimax', caps: ['tts'], default: true },
{ id: 'fish-speech-2', label: 'fish-speech-2', hint: 'FishAudio', provider: 'fishaudio', caps: ['tts', 'voice-clone'] },
{ id: 'elevenlabs-v3', label: 'elevenlabs-v3', hint: 'ElevenLabs', provider: 'elevenlabs', caps: ['tts', 'voice-clone'] },
{ id: 'doubao-tts', label: 'doubao-tts', hint: 'Volcengine · TTS', provider: 'volcengine', caps: ['tts'] },
],
sfx: [
{ id: 'elevenlabs-sfx', label: 'elevenlabs-sfx', hint: 'ElevenLabs SFX', provider: 'elevenlabs', caps: ['sfx'], default: true },
{ id: 'audiocraft', label: 'audiocraft', hint: 'Meta · open', provider: 'replicate', caps: ['sfx', 'music'] },
],
};
export const MEDIA_ASPECTS = ['1:1', '16:9', '9:16', '4:3', '3:4'];
export const VIDEO_LENGTHS_SEC = [3, 5, 8, 10, 15, 30];
export const AUDIO_DURATIONS_SEC = [5, 10, 15, 30, 60, 120];
export function findMediaModel(id) {
const all = [
...IMAGE_MODELS,
...VIDEO_MODELS,
...AUDIO_MODELS_BY_KIND.music,
...AUDIO_MODELS_BY_KIND.speech,
...AUDIO_MODELS_BY_KIND.sfx,
];
return all.find((m) => m.id === id) || null;
}
export function findProvider(id) {
return MEDIA_PROVIDERS.find((p) => p.id === id) || null;
}
export function modelsForSurface(surface, audioKind) {
if (surface === 'image') return IMAGE_MODELS;
if (surface === 'video') return VIDEO_MODELS;
if (surface === 'audio') {
const k = audioKind || 'music';
return AUDIO_MODELS_BY_KIND[k] || AUDIO_MODELS_BY_KIND.music;
}
return [];
}

1429
apps/daemon/src/media.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -261,6 +261,12 @@ const EXT_MIME = {
'.gif': 'image/gif',
'.webp': 'image/webp',
'.avif': 'image/avif',
'.mp4': 'video/mp4',
'.mov': 'video/quicktime',
'.webm': 'video/webm',
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.m4a': 'audio/mp4',
};
export function mimeFor(name) {
@ -280,6 +286,8 @@ export function kindFor(name) {
if (name.startsWith('sketch-')) return 'sketch';
return 'image';
}
if (['.mp4', '.mov', '.webm'].includes(ext)) return 'video';
if (['.mp3', '.wav', '.m4a'].includes(ext)) return 'audio';
if (['.md', '.txt'].includes(ext)) return 'text';
if (['.js', '.mjs', '.cjs', '.ts', '.tsx', '.json', '.css'].includes(ext)) {
return 'code';

View file

@ -0,0 +1,108 @@
// @ts-nocheck
// Prompt template registry. Mirrors design-systems.js: scans
// <projectRoot>/prompt-templates/{image,video}/*.json on every list call
// and returns the parsed entries with light validation.
//
// Each JSON file is hand-curated (or imported via
// scripts/import-prompt-templates.mjs) and carries a `source` block so
// attribution stays intact when we surface the entry in the gallery and
// the system prompt.
import { readdir, readFile, stat } from 'node:fs/promises';
import path from 'node:path';
const SUPPORTED_SURFACES = ['image', 'video'];
export async function listPromptTemplates(root) {
const out = [];
for (const surface of SUPPORTED_SURFACES) {
const dir = path.join(root, surface);
let entries = [];
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (!entry.isFile()) continue;
if (!entry.name.endsWith('.json')) continue;
const filePath = path.join(dir, entry.name);
try {
const stats = await stat(filePath);
if (!stats.isFile()) continue;
const raw = await readFile(filePath, 'utf8');
const parsed = JSON.parse(raw);
const validated = validateTemplate(parsed, surface, entry.name);
if (validated) out.push(validated);
} catch (err) {
console.warn(`prompt-templates: failed ${filePath}`, err);
}
}
}
// Stable order — same surface group together, alpha by title within
// surface so the gallery matches what `ls` would suggest.
out.sort((a, b) => {
if (a.surface !== b.surface) {
return a.surface === 'image' ? -1 : 1;
}
return a.title.localeCompare(b.title);
});
return out;
}
export async function readPromptTemplate(root, surface, id) {
if (!SUPPORTED_SURFACES.includes(surface)) return null;
const filePath = path.join(root, surface, `${id}.json`);
try {
const raw = await readFile(filePath, 'utf8');
const parsed = JSON.parse(raw);
return validateTemplate(parsed, surface, `${id}.json`);
} catch {
return null;
}
}
function validateTemplate(raw, expectedSurface, fileName) {
if (!raw || typeof raw !== 'object') return null;
if (typeof raw.id !== 'string' || !raw.id) {
console.warn(`prompt-templates: ${fileName} missing id`);
return null;
}
if (raw.surface !== expectedSurface) {
console.warn(
`prompt-templates: ${fileName} surface=${raw.surface} ≠ folder=${expectedSurface}`,
);
return null;
}
if (typeof raw.title !== 'string' || !raw.title.trim()) return null;
if (typeof raw.prompt !== 'string' || raw.prompt.trim().length < 20) {
console.warn(`prompt-templates: ${fileName} prompt too short`);
return null;
}
const source = raw.source && typeof raw.source === 'object' ? raw.source : null;
if (!source || typeof source.repo !== 'string' || typeof source.license !== 'string') {
console.warn(`prompt-templates: ${fileName} missing source.repo / license`);
return null;
}
return {
id: raw.id,
surface: raw.surface,
title: raw.title.trim(),
summary: typeof raw.summary === 'string' ? raw.summary.trim() : '',
category: typeof raw.category === 'string' ? raw.category : 'General',
tags: Array.isArray(raw.tags) ? raw.tags.filter((t) => typeof t === 'string') : [],
model: typeof raw.model === 'string' ? raw.model : undefined,
aspect: typeof raw.aspect === 'string' ? raw.aspect : undefined,
prompt: raw.prompt.trim(),
previewImageUrl:
typeof raw.previewImageUrl === 'string' ? raw.previewImageUrl : undefined,
previewVideoUrl:
typeof raw.previewVideoUrl === 'string' ? raw.previewVideoUrl : undefined,
source: {
repo: source.repo,
license: source.license,
author: typeof source.author === 'string' ? source.author : undefined,
url: typeof source.url === 'string' ? source.url : undefined,
},
};
}

View file

@ -0,0 +1,340 @@
/**
* Media generation contract. Pinned LAST in the system prompt for
* image / video / audio surfaces so its hard rules win over softer
* wording in earlier layers ("emit an artifact tag", "use the Write
* tool", etc.).
*
* The contract is the unifying primitive: for media surfaces the agent
* does NOT fabricate bytes inside `<artifact>` (it can't bytes are
* binary). Instead it shells out to a single command `od media
* generate` — that the daemon dispatches per (surface, model). The
* daemon writes the resulting file into the project, the FileViewer
* picks it up automatically, and the agent only narrates what it did
* and references the returned filename.
*
* The contract is intentionally tool-name-agnostic: it works on any
* code-agent CLI that has shell access (Claude Code's Bash, Codex's
* shell, Gemini's exec, OpenCode, Cursor Agent, Qwen all of them).
* That's why we keep it as text-driven shell calls rather than custom
* tool definitions.
*/
import {
AUDIO_MODELS_BY_KIND,
IMAGE_MODELS,
VIDEO_MODELS,
} from '../media-models.js';
function fmtList(ids: string[]): string {
return ids.map((id) => `\`${id}\``).join(', ');
}
const IMAGE_IDS = fmtList(IMAGE_MODELS.map((m) => m.id));
const VIDEO_IDS = fmtList(VIDEO_MODELS.map((m) => m.id));
const AUDIO_MUSIC_IDS = fmtList(AUDIO_MODELS_BY_KIND.music.map((m) => m.id));
const AUDIO_SPEECH_IDS = fmtList(AUDIO_MODELS_BY_KIND.speech.map((m) => m.id));
const AUDIO_SFX_IDS = fmtList(AUDIO_MODELS_BY_KIND.sfx.map((m) => m.id));
export const MEDIA_GENERATION_CONTRACT = `
---
## Media generation contract (load-bearing overrides softer wording above)
This project is a **non-web** surface (image / video / audio). The unifying
contract is: skill workflow + project metadata tell you WHAT to make; one
shell command \`od media generate\` — is HOW you actually produce bytes.
Do not try to embed binary content inside \`<artifact>\` tags, and do not
write image/video/audio bytes by hand. Always call out to the dispatcher.
**Explicit layer overrides read this first.** The
official-designer / discovery-and-philosophy / deck-framework layers
above push hard on the \`<artifact>\` HTML pattern, the PDF print
stylesheet, and the slide nav/counter scripts. Those directives **do not
apply on this surface**. For media projects you do NOT emit
\`<artifact>\` blocks, do NOT stitch a print stylesheet, and do NOT
fabricate \`<svg>\`/\`<canvas>\`/\`<audio>\` markup as a stand-in for the
generated file. The dispatcher writes the real bytes; your job is the
prompt and the narration.
### Environment the daemon injected for you
The daemon spawns you with these env vars set (verify with \`echo\`):
- \`OD_BIN\` — absolute path to the \`od\` CLI script. Run with \`node "$OD_BIN" …\`.
- \`OD_PROJECT_ID\` — the active project's id. Pass it as \`--project "$OD_PROJECT_ID"\`.
- \`OD_PROJECT_DIR\` — the project's files folder (your cwd). Generated files land here.
- \`OD_DAEMON_URL\` — base URL of the local daemon, e.g. \`http://127.0.0.1:7456\`.
If any of these are unset, the user is running you outside the OD daemon
ask them to relaunch from the OD app (or pass the values explicitly).
TODO (post-v1): teach \`od media generate\` to auto-spawn a transient
daemon when invoked outside the OD app, so a user running \`claude\`
directly in the project dir doesn't have to relaunch.
### Invocation
Run via your shell tool (Bash on Claude Code, exec on Codex/Gemini, etc.):
\`\`\`bash
node "$OD_BIN" media generate \\
--project "$OD_PROJECT_ID" \\
--surface <image|video|audio> \\
--model <model-id> \\
--output <filename> \\
--prompt "<full prompt>" \\
[--aspect 1:1|16:9|9:16|4:3|3:4] \\
[--length <seconds>] # video only
[--duration <seconds>] # audio only
[--audio-kind music|speech|sfx] # audio only
[--voice <provider-voice-id>] # audio:speech only; omit to use provider default
\`\`\`
Always quote the prompt value. Use \`--prompt "<full prompt>"\` (or the
equivalent safe quoting for your shell) never splice an unquoted user
string into the command line.
The command prints a single line of JSON describing the written file:
\`\`\`json
{ "file": { "name": "poster.png", "size": 12345, "kind": "image", "mime": "image/png", ... } }
\`\`\`
Save the \`file.name\` and reference it in your reply ("I generated
\`poster.png\`."). The user's FileViewer renders it automatically.
### Allowed execution paths
For media projects, \`node "$OD_BIN" media generate …\` is the **only**
approved execution path **except for the \`hyperframes-html\` video
model** see the carve-out below. Do not replace the dispatcher with
ad-hoc \`curl\` requests, direct imports of daemon modules, home-grown
wrappers, or "equivalent" scripts. Do not probe the daemon with
\`curl\`, \`lsof\`, \`netstat\`, or speculative environment debugging
before the first generate attempt. Treat \`OD_BIN\`, \`OD_PROJECT_ID\`,
and \`OD_DAEMON_URL\` as the source of truth and try the dispatcher
first.
#### Carve-out: \`hyperframes-html\` is agent-authored, daemon-rendered
The composition HTML is your job; the render itself runs in the
daemon process, not your shell. Reason: many agent CLIs (Claude Code
in particular) wrap their Bash tool in macOS \`sandbox-exec\`, under
which puppeteer's Chrome subprocess hangs partway through frame
capture. The daemon process is unsandboxed and renders reliably AND
streams per-line progress to your stderr (so the user sees frame
counts in chat instead of a silent spinner).
**Default recipe use \`hyperframes init\`, don't write from scratch.**
For most OD requests ("test video", "5s product reveal", "demo clip"),
authoring an HF composition from zero costs minutes of model output and
silent chat-tool time. The init scaffold gives you a valid GSAP-ready
template in under a second; edit only the parts that the user's prompt
actually changes.
\`\`\`bash
COMP_REL=".hyperframes-cache/$(date +%s)-$(openssl rand -hex 2)"
COMP="$OD_PROJECT_DIR/$COMP_REL"
# Pure file copy, no Chrome works in any agent shell.
npx hyperframes init "$COMP" --example blank --skip-skills --non-interactive
# Edit ONLY $COMP/index.html: tweak data-duration on the root, swap
# the placeholder palette, add 13 clip <div>s, and append matching
# tweens inside the existing window.__timelines["main"] = gsap.timeline(...)
# block. Skip the Visual Identity HARD-GATE in skills/hyperframes/SKILL.md
# OD projects already have their own design-system layer. Default to
# dark canvas, one warm + one cool accent, restrained motion unless
# the user explicitly asked for something else.
node "$OD_BIN" media generate \\
--project "$OD_PROJECT_ID" \\
--surface video \\
--model hyperframes-html \\
--output "<descriptive-name>.mp4" \\
--composition-dir "$COMP_REL"
\`\`\`
The dispatcher streams per-line render progress to your stderr while
running. Then it prints a one-line JSON
\`{"file":{"name":...,"size":...,"kind":"video",...}}\` on stdout.
Quote \`file.name\` in your reply. The chat surfaces the mp4 as a
download/open chip automatically.
Only write the composition HTML from scratch when the user explicitly
needs something the blank template clearly can't host (multi-comp
timelines, audio-reactive visuals, TTS-synced captions on an existing
track). For typical test renders, the init+edit path is the default.
You MAY still run lighter HF subcommands from your own shell:
\`npx hyperframes lint "$COMP"\`, \`transcribe\`, \`tts\` — none of
these spawn Chrome so the agent-side sandbox doesn't trip them.
Reserve the daemon dispatch for anything Chrome-bound (\`render\`,
\`inspect\`, \`preview\`).
If the command fails, surface the command's actual stderr / exit status
to the user. Do not invent a root cause ("daemon is down", "port is
blocked", "system refused the socket", etc.) unless the command itself
reported that exact condition. One failed dispatcher call is enough to
report the error; do not fan out into alternate execution paths inside
the same turn.
### Long-running renders (Volcengine i2v, hyperframes-html): generate wait loop
\`od media generate\` no longer blocks for the full render. It dispatches
the task daemon-side and returns within ~1s with a \`{taskId}\`. You then
drive the render to completion by calling \`od media wait <taskId>\` in
a loop each call long-polls the daemon for up to 25s, well below your
shell tool's default 30s timeout. The wait subcommand exits with a
distinct code per outcome:
- \`exit 0\` — terminal **done**. Final stdout line is \`{"file":{...}}\`.
- \`exit 5\` — terminal **failed**. Stderr carries the upstream error.
- \`exit 2\` — still **running**. Final stdout line is
\`{"taskId":"…","status":"running","nextSince":<n>}\`. Re-run
\`od media wait <taskId> --since <n>\` to continue from where you left
off (\`--since\` skips already-seen progress lines so you don't see the
same chatter twice).
The pattern in your shell tool:
\`\`\`bash
out=$(node "$OD_BIN" media generate --surface video --model --image )
ec=$?
if [ "$ec" -ne 0 ] && [ "$ec" -ne 2 ]; then
echo "$out" >&2; exit "$ec"
fi
task_id=$(printf '%s\\n' "$out" | tail -1 | jq -r '.taskId // empty')
since=$(printf '%s\\n' "$out" | tail -1 | jq -r '.nextSince // 0')
while [ "$ec" -eq 2 ] && [ -n "$task_id" ]; do
out=$(node "$OD_BIN" media wait "$task_id" --since "$since")
ec=$?
since=$(printf '%s\\n' "$out" | tail -1 | jq -r '.nextSince // '"$since")
done
# At this point ec is 0 (done) or 5 (failed). Final result on the last
# stdout line of \`out\`.
\`\`\`
Each \`generate\` and \`wait\` call lasts at most ~25s, so the agent
shell tool's default ~30s cap never fires. Progress lines stream to
stderr as they arrive, so the user sees live status in chat throughout
the loop instead of waiting silently for a single multi-minute call.
A note on \`fetch failed\` to \`127.0.0.1\`. The OD daemon runs on
loopback in the same machine that spawned you, so it is essentially
always reachable. If your dispatcher attempt prints
\`failed to reach daemon at http://127.0.0.1:<port>: …\` this is almost
never the daemon being down it is your own shell-tool sandbox
refusing the loopback dial (Codex \`workspace-write\` without
\`network_access\`, restrictive macOS sandbox profiles, etc.). Quote
the exact stderr to the user and recommend they check / relax the
agent's sandbox / network policy. Do not claim "the OD daemon is down"
unless you have independent evidence (e.g. the daemon's terminal also
showed it crashed).
### Allowed model IDs (per surface)
- **image**: ${IMAGE_IDS}
- **video**: ${VIDEO_IDS}
Image-to-video (i2v): the Volcengine Seedance family
(\`doubao-seedance-2-0-260128\`, \`doubao-seedance-2-0-fast-260128\`,
\`doubao-seedance-1-0-pro-250528\`, \`doubao-seedance-1-0-lite-i2v-250428\`)
accepts a reference image as the first frame. Pass it via
\`--image <project-relative-path>\` to \`od media generate\`. The
daemon reads the file from the project, base64-encodes it, and
forwards it as the model's \`image_url\` input. Path traversal
outside the project is rejected.
- **audio · music**: ${AUDIO_MUSIC_IDS}
- **audio · speech**: ${AUDIO_SPEECH_IDS}
- **audio · sfx**: ${AUDIO_SFX_IDS}
If the user requests a model that is not in this list, surface a warning
in your reply and either (a) ask them to pick a registered ID or (b)
proceed with the project metadata's default model and explain the
substitution. Do not silently fall back.
### Workflow rules
1. **Read project metadata first.** The "Project metadata" block above
tells you the user's pre-selected model, aspect, length, voice, audio
kind, etc. Treat those as authoritative defaults only override if
the user's chat message explicitly contradicts them.
For \`minimax-tts\`, \`voice\` must be a valid MiniMax \`voice_id\`
(example: \`male-qn-qingse\`). Do not pass natural-language voice
descriptions like "warm Mandarin narrator" as \`--voice\`; omit the
flag instead unless you have a real id.
2. **One discovery turn before generating.** Even with metadata defaults
present, restate what you're about to make and ask one targeted
question if anything is ambiguous (subject, mood, brand, voice). The
discovery rules from the philosophy layer still apply emit a
question form on turn 1 unless the user's prompt already pins every
variable.
For \`hyperframes-html\`, the discovery turn is the last turn before
you start authoring. Once the user answers, write the composition
files into \`.hyperframes-cache/\` and run \`npx hyperframes render\`
immediately do not add a second "plan" or "environment check"
message first, and do not call \`od media generate\` (that path is
intentionally rejected for this model).
3. **Generate by shell, narrate in chat.** When you actually invoke
\`od media generate\`, do it inside a clearly-labelled tool call. After
it returns, write a short reply: what was produced, the filename,
and any notes (model substitutions, retries, follow-up suggestions).
If it fails, quote the real stderr / exit code and stop there.
Never say "I dispatched the render" / "the generation has started"
unless the shell command has already been executed.
4. **Iterate by re-running.** To revise, call \`od media generate\` again
with a new \`--output\` filename (or omit \`--output\` to auto-name).
Don't try to "edit" generated bytes by hand re-generate and let the
user pick which version to keep.
5. **Don't emit \`<artifact>\` blocks for media.** They're for HTML/text
artifacts. For media surfaces your "artifact" is the file written by
the dispatcher. The artifact lint and PDF-stitching layers don't
apply.
6. **Filenames are slugged.** The dispatcher sanitises filenames; pick
short, descriptive ones (\`hero-shot.png\`, \`intro-jingle.mp3\`,
\`teaser-15s.mp4\`) so the user's file list stays readable.
### Detecting and surfacing provider errors
Today the dispatcher ships two real provider integrations: \`openai\`
(image, with Azure OpenAI auto-detected from the configured base URL)
and \`volcengine\` (Doubao Seedance video / Seedream image). Other
providers (suno-v5, kling, fishaudio, ) are still stubs.
The dispatcher tags every outcome explicitly. Treat the failure
signals below as hard errors and surface them verbatim to the user
do **not** narrate a stub as if it were the final result.
1. **HTTP status.** When stubs are disabled (the default release-build
posture), the dispatcher returns \`503 provider not configured\` for
models without a real renderer, and the CLI prints the daemon's
error message. Set \`OD_MEDIA_ALLOW_STUBS=1\` to write a labelled
placeholder instead.
2. **Exit code.** \`od media generate\` and \`od media wait\` exit:
\`0\` on real success, \`2\` when the task is **still running** and
needs another \`wait\` call (see "Long-running renders" above), \`5\`
when the daemon accepted the request but the provider call failed
(key missing / 4xx / network blip), and \`14\` for client / daemon
errors. Always check \`$?\` before describing the output. \`2\` is
not a failure it just means "keep polling".
3. **stderr WARN lines.** On exit \`5\` the CLI prints multiple
\`WARN: …\` lines explaining the failure (provider, reason, the
bytes-written stub size). Quote the reason in your reply.
4. **Response JSON.** The single-line stdout JSON also carries
\`file.providerError\` (string) and \`file.usedStubFallback\` (bool)
when a fallback happened, plus \`file.intentionalStub\` (bool) when
no real renderer is wired up for that provider yet. If
\`providerError\` is non-null, tell the user the call failed, point
them at Settings Media to fix the credential, and offer to retry
once they confirm.
Do not overwrite this with your own diagnosis.
5. **Tiny placeholder PNGs (~67 bytes) / \`[stub]\` providerNote.** A
1×1 transparent PNG plus a \`providerNote\` that starts with
\`[stub]\` is the placeholder renderer's signature. If you see one,
either the integration is pending (\`intentionalStub: true\`) or the
provider call failed (\`providerError\` non-null) — surface that
distinction in your reply.
A few surfaces (audio, some long-tail image/video providers) are still
intentional stubs. In that case you can narrate the placeholder as
expected, but still mention to the user that the real provider
integration hasn't landed.
`;

View file

@ -32,6 +32,7 @@
import { OFFICIAL_DESIGNER_PROMPT } from './official-system.js';
import { DISCOVERY_AND_PHILOSOPHY } from './discovery.js';
import { DECK_FRAMEWORK_DIRECTIVE } from './deck-framework.js';
import { MEDIA_GENERATION_CONTRACT } from './media-contract.js';
type ProjectMetadata = {
kind?: string;
@ -41,6 +42,16 @@ type ProjectMetadata = {
templateId?: string | null;
templateLabel?: string | null;
inspirationDesignSystemIds?: string[];
imageModel?: string | null;
imageAspect?: string | null;
imageStyle?: string | null;
videoModel?: string | null;
videoLength?: number | null;
videoAspect?: string | null;
audioKind?: string | null;
audioModel?: string | null;
audioDuration?: number | null;
voice?: string | null;
};
type ProjectTemplate = { name: string; description?: string | null; files: Array<{ name: string; content: string }> };
@ -49,7 +60,15 @@ export const BASE_SYSTEM_PROMPT = OFFICIAL_DESIGNER_PROMPT;
export interface ComposeInput {
skillBody?: string | undefined;
skillName?: string | undefined;
skillMode?: 'prototype' | 'deck' | 'template' | 'design-system' | undefined;
skillMode?:
| 'prototype'
| 'deck'
| 'template'
| 'design-system'
| 'image'
| 'video'
| 'audio'
| undefined;
designSystemBody?: string | undefined;
designSystemTitle?: string | undefined;
// Project-level metadata captured by the new-project panel. Drives the
@ -121,6 +140,17 @@ export function composeSystemPrompt({
parts.push(`\n\n---\n\n${DECK_FRAMEWORK_DIRECTIVE}`);
}
const isMediaSurface =
skillMode === 'image' ||
skillMode === 'video' ||
skillMode === 'audio' ||
metadata?.kind === 'image' ||
metadata?.kind === 'video' ||
metadata?.kind === 'audio';
if (isMediaSurface) {
parts.push(MEDIA_GENERATION_CONTRACT);
}
return parts.join('');
}
@ -155,6 +185,61 @@ function renderMetadataBlock(
lines.push(`- **template**: ${metadata.templateLabel}`);
}
}
if (metadata.kind === 'image') {
lines.push(
`- **imageModel**: ${metadata.imageModel ?? '(unknown — ask: which image model to use)'}`,
);
lines.push(
`- **aspectRatio**: ${metadata.imageAspect ?? '(unknown — ask: 1:1, 16:9, 9:16, 4:3, 3:4)'}`,
);
if (metadata.imageStyle) {
lines.push(`- **styleNotes**: ${metadata.imageStyle}`);
}
lines.push('');
lines.push(
'This is an **image** project. Plan the prompt carefully, then dispatch via the **media generation contract** using `od media generate --surface image --model <imageModel>`. Do NOT emit `<artifact>` HTML for media surfaces.',
);
}
if (metadata.kind === 'video') {
lines.push(
`- **videoModel**: ${metadata.videoModel ?? '(unknown — ask: which video model to use)'}`,
);
lines.push(
`- **lengthSeconds**: ${typeof metadata.videoLength === 'number' ? metadata.videoLength : '(unknown — ask: 3s / 5s / 10s)'}`,
);
lines.push(
`- **aspectRatio**: ${metadata.videoAspect ?? '(unknown — ask: 16:9, 9:16, 1:1)'}`,
);
lines.push('');
lines.push(
'This is a **video** project. Plan the shotlist and motion, then dispatch via the **media generation contract** using `od media generate --surface video --model <videoModel> --length <seconds> --aspect <ratio>`. Do NOT emit `<artifact>` HTML.',
);
if (metadata.videoModel === 'hyperframes-html') {
lines.push(
'Special case: `hyperframes-html` is a local HTML-to-MP4 renderer, not a photoreal text-to-video model. Treat it like a motion design renderer, ask at most one clarifying question, then dispatch immediately.',
);
}
}
if (metadata.kind === 'audio') {
lines.push(
`- **audioKind**: ${metadata.audioKind ?? '(unknown — ask: music / speech / sfx)'}`,
);
lines.push(
`- **audioModel**: ${metadata.audioModel ?? '(unknown — ask: which audio model to use)'}`,
);
lines.push(
`- **durationSeconds**: ${typeof metadata.audioDuration === 'number' ? metadata.audioDuration : '(unknown — ask: target duration)'}`,
);
if (metadata.voice) {
lines.push(`- **voice**: ${metadata.voice}`);
} else if (metadata.audioKind === 'speech') {
lines.push('- **voice**: (unknown — ask: voice id / accent / pacing)');
}
lines.push('');
lines.push(
'This is an **audio** project. Lock the content intent first, then dispatch via the **media generation contract** using `od media generate --surface audio --audio-kind <kind> --model <audioModel> --duration <seconds>` and add `--voice <voice-id>` for speech when you have a provider-specific voice id. Do NOT emit `<artifact>` HTML.',
);
}
if (metadata.inspirationDesignSystemIds && metadata.inspirationDesignSystemIds.length > 0) {
lines.push(

View file

@ -26,8 +26,20 @@ import { renderDesignSystemPreview } from './design-system-preview.js';
import { renderDesignSystemShowcase } from './design-system-showcase.js';
import { createChatRunService } from './runs.js';
import { importClaudeDesignZip } from './claude-design-import.js';
import { listPromptTemplates, readPromptTemplate } from './prompt-templates.js';
import { buildDocumentPreview } from './document-preview.js';
import { lintArtifact, renderFindingsForAgent } from './lint-artifact.js';
import { generateMedia } from './media.js';
import {
AUDIO_DURATIONS_SEC,
AUDIO_MODELS_BY_KIND,
IMAGE_MODELS,
MEDIA_ASPECTS,
MEDIA_PROVIDERS,
VIDEO_LENGTHS_SEC,
VIDEO_MODELS,
} from './media-models.js';
import { readMaskedConfig, writeConfig } from './media-config.js';
import {
decodeMultipartFilename,
deleteProjectFile,
@ -147,6 +159,7 @@ const DAEMON_RESOURCE_ROOT = resolveDaemonResourceRoot();
// when this project shipped with Vite; the daemon serves whatever the
// frontend toolchain emits, no further config needed.
const STATIC_DIR = path.join(PROJECT_ROOT, 'apps', 'web', 'out');
const OD_BIN = path.join(PROJECT_ROOT, 'apps', 'daemon', 'dist', 'cli.js');
const SKILLS_DIR = resolveDaemonResourceDir(
DAEMON_RESOURCE_ROOT,
'skills',
@ -162,6 +175,11 @@ const FRAMES_DIR = resolveDaemonResourceDir(
'frames',
path.join(PROJECT_ROOT, 'assets', 'frames'),
);
const PROMPT_TEMPLATES_DIR = resolveDaemonResourceDir(
DAEMON_RESOURCE_ROOT,
'prompt-templates',
path.join(PROJECT_ROOT, 'prompt-templates'),
);
const RUNTIME_DATA_DIR = process.env.OD_DATA_DIR
? path.resolve(PROJECT_ROOT, process.env.OD_DATA_DIR)
: path.join(PROJECT_ROOT, '.od');
@ -338,6 +356,52 @@ function sendMulterError(res, err) {
return sendApiError(res, 500, 'INTERNAL_ERROR', 'upload failed');
}
const mediaTasks = new Map();
const TASK_TTL_AFTER_DONE_MS = 10 * 60 * 1000;
function createMediaTask(taskId, projectId, info = {}) {
const task = {
id: taskId,
projectId,
status: 'queued',
surface: info.surface,
model: info.model,
progress: [],
file: null,
error: null,
startedAt: Date.now(),
endedAt: null,
waiters: new Set(),
};
mediaTasks.set(taskId, task);
return task;
}
function appendTaskProgress(task, line) {
task.progress.push(line);
notifyTaskWaiters(task);
}
function notifyTaskWaiters(task) {
const wakers = Array.from(task.waiters);
for (const w of wakers) {
try {
w();
} catch {
// Never let one bad waiter block the rest.
}
}
if (
(task.status === 'done' || task.status === 'failed') &&
!task._gcScheduled
) {
task._gcScheduled = true;
setTimeout(() => {
if (task.waiters.size === 0) mediaTasks.delete(task.id);
}, TASK_TTL_AFTER_DONE_MS).unref?.();
}
}
export function createSseResponse(res, { keepAliveIntervalMs = SSE_KEEPALIVE_INTERVAL_MS } = {}) {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache, no-transform');
@ -829,6 +893,31 @@ export async function startServer({ port = 7456, returnServer = false } = {}) {
}
});
app.get('/api/prompt-templates', async (_req, res) => {
try {
const templates = await listPromptTemplates(PROMPT_TEMPLATES_DIR);
res.json({
promptTemplates: templates.map(({ prompt: _prompt, ...rest }) => rest),
});
} catch (err) {
res.status(500).json({ error: String(err) });
}
});
app.get('/api/prompt-templates/:surface/:id', async (req, res) => {
try {
const tpl = await readPromptTemplate(
PROMPT_TEMPLATES_DIR,
req.params.surface,
req.params.id,
);
if (!tpl) return res.status(404).json({ error: 'prompt template not found' });
res.json({ promptTemplate: tpl });
} catch (err) {
res.status(500).json({ error: String(err) });
}
});
// Showcase HTML for a design system — palette swatches, typography
// samples, sample components, and the full DESIGN.md rendered as prose.
// Built at request time from the on-disk DESIGN.md so any update to the
@ -1203,6 +1292,199 @@ export async function startServer({ port = 7456, returnServer = false } = {}) {
}
});
app.get('/api/media/models', (_req, res) => {
res.json({
providers: MEDIA_PROVIDERS,
image: IMAGE_MODELS,
video: VIDEO_MODELS,
audio: AUDIO_MODELS_BY_KIND,
aspects: MEDIA_ASPECTS,
videoLengthsSec: VIDEO_LENGTHS_SEC,
audioDurationsSec: AUDIO_DURATIONS_SEC,
});
});
app.get('/api/media/config', async (_req, res) => {
try {
const cfg = await readMaskedConfig(PROJECT_ROOT);
res.json(cfg);
} catch (err) {
res.status(500).json({ error: String(err && err.message ? err.message : err) });
}
});
app.put('/api/media/config', async (req, res) => {
try {
const cfg = await writeConfig(PROJECT_ROOT, req.body);
res.json(cfg);
} catch (err) {
const status = typeof err?.status === 'number' ? err.status : 400;
res.status(status).json({ error: String(err && err.message ? err.message : err) });
}
});
app.post('/api/projects/:id/media/generate', async (req, res) => {
if (!isLocalSameOrigin(req, port)) {
return res.status(403).json({
error: 'cross-origin request rejected: media generation is restricted to the local UI / CLI',
});
}
try {
const projectId = req.params.id;
const project = getProject(db, projectId);
if (!project) return res.status(404).json({ error: 'project not found' });
const taskId = randomUUID();
const task = createMediaTask(taskId, projectId, {
surface: req.body?.surface,
model: req.body?.model,
});
console.error(
`[task ${taskId.slice(0, 8)}] queued model=${req.body?.model} ` +
`surface=${req.body?.surface} ` +
`image=${req.body?.image ? 'yes' : 'no'} ` +
`compositionDir=${req.body?.compositionDir ? 'yes' : 'no'}`,
);
task.status = 'running';
generateMedia({
projectRoot: PROJECT_ROOT,
projectsRoot: PROJECTS_DIR,
projectId,
surface: req.body?.surface,
model: req.body?.model,
prompt: req.body?.prompt,
output: req.body?.output,
aspect: req.body?.aspect,
length: typeof req.body?.length === 'number' ? req.body.length : undefined,
duration:
typeof req.body?.duration === 'number' ? req.body.duration : undefined,
voice: req.body?.voice,
audioKind: req.body?.audioKind,
compositionDir: req.body?.compositionDir,
image: req.body?.image,
onProgress: (line) => appendTaskProgress(task, line),
})
.then((meta) => {
task.status = 'done';
task.file = meta;
task.endedAt = Date.now();
notifyTaskWaiters(task);
console.error(
`[task ${taskId.slice(0, 8)}] done size=${meta?.size} mime=${meta?.mime} ` +
`elapsed=${Math.round((task.endedAt - task.startedAt) / 1000)}s`,
);
})
.catch((err) => {
task.status = 'failed';
task.error = {
message: String(err && err.message ? err.message : err),
status: typeof err?.status === 'number' ? err.status : 400,
code: err?.code,
};
task.endedAt = Date.now();
notifyTaskWaiters(task);
console.error(
`[task ${taskId.slice(0, 8)}] failed status=${task.error.status} ` +
`message=${(task.error.message || '').slice(0, 240)}`,
);
});
res.status(202).json({
taskId,
status: task.status,
startedAt: task.startedAt,
});
} catch (err) {
const status = typeof err?.status === 'number' ? err.status : 400;
const code = err?.code;
const body = { error: String(err && err.message ? err.message : err) };
if (code) body.code = code;
res.status(status).json(body);
}
});
app.post('/api/media/tasks/:id/wait', async (req, res) => {
if (!isLocalSameOrigin(req, port)) {
return res.status(403).json({ error: 'cross-origin request rejected' });
}
const taskId = req.params.id;
const task = mediaTasks.get(taskId);
if (!task) return res.status(404).json({ error: 'task not found' });
const since = Number.isFinite(req.body?.since) ? Number(req.body.since) : 0;
const requestedTimeout = Number.isFinite(req.body?.timeoutMs)
? Number(req.body.timeoutMs)
: 25_000;
const timeoutMs = Math.min(Math.max(requestedTimeout, 0), 25_000);
const respond = () => {
if (res.writableEnded) return;
const snapshot = {
taskId,
status: task.status,
startedAt: task.startedAt,
endedAt: task.endedAt,
progress: task.progress.slice(since),
nextSince: task.progress.length,
};
if (task.status === 'done') snapshot.file = task.file;
if (task.status === 'failed') snapshot.error = task.error;
res.json(snapshot);
};
if (
task.status === 'done' ||
task.status === 'failed' ||
task.progress.length > since
) {
return respond();
}
let resolved = false;
const wake = () => {
if (resolved) return;
resolved = true;
task.waiters.delete(wake);
clearTimeout(timer);
respond();
};
task.waiters.add(wake);
const timer = setTimeout(wake, timeoutMs);
res.on('close', wake);
});
app.get('/api/projects/:id/media/tasks', (req, res) => {
if (!isLocalSameOrigin(req, port)) {
return res.status(403).json({ error: 'cross-origin request rejected' });
}
const projectId = req.params.id;
const includeDone =
req.query.includeDone === '1' || req.query.includeDone === 'true';
const tasks = [];
for (const t of mediaTasks.values()) {
if (t.projectId !== projectId) continue;
const isTerminal = t.status === 'done' || t.status === 'failed';
if (isTerminal && !includeDone) continue;
tasks.push({
taskId: t.id,
status: t.status,
startedAt: t.startedAt,
endedAt: t.endedAt,
elapsed: Math.round(((t.endedAt ?? Date.now()) - t.startedAt) / 1000),
surface: t.surface,
model: t.model,
progress: t.progress.slice(-3),
progressCount: t.progress.length,
...(t.status === 'done' ? { file: t.file } : {}),
...(t.status === 'failed' ? { error: t.error } : {}),
});
}
tasks.sort((a, b) => b.startedAt - a.startedAt);
res.json({ tasks });
});
// Multi-file upload that the chat composer uses for paste/drop/picker.
// Files land flat in the project folder; the response carries the same
// metadata as listFiles so the client can stage them as ChatAttachments
@ -1512,6 +1794,16 @@ export async function startServer({ port = 7456, returnServer = false } = {}) {
// case this branch covers.
const useShell =
process.platform === 'win32' && CMD_BAT_RE.test(resolvedBin);
const odMediaEnv = {
OD_BIN,
OD_DAEMON_URL: `http://127.0.0.1:${port}`,
...(typeof projectId === 'string' && projectId && cwd
? {
OD_PROJECT_ID: projectId,
OD_PROJECT_DIR: cwd,
}
: {}),
};
if (run.cancelRequested || design.runs.isTerminal(run.status)) return;
@ -1537,7 +1829,7 @@ export async function startServer({ port = 7456, returnServer = false } = {}) {
// which causes `spawn ENAMETOOLONG` for any non-trivial prompt.
const stdinMode = def.promptViaStdin || def.streamFormat === 'acp-json-rpc' || needsFilePrompt ? 'pipe' : 'ignore';
child = spawn(resolvedBin, args, {
env: { ...process.env },
env: { ...process.env, ...odMediaEnv },
stdio: [stdinMode, 'pipe', 'pipe'],
cwd: cwd || undefined,
shell: useShell,
@ -1847,6 +2139,24 @@ function escapeHtml(s) {
.replace(/"/g, '&quot;');
}
function isLocalSameOrigin(req, port) {
const allowedHosts = new Set([
`127.0.0.1:${port}`,
`localhost:${port}`,
`[::1]:${port}`,
]);
const allowedOrigins = new Set([
`http://127.0.0.1:${port}`,
`http://localhost:${port}`,
`http://[::1]:${port}`,
]);
const host = String(req.headers.host || '');
if (!allowedHosts.has(host)) return false;
const origin = req.headers.origin;
if (origin == null || origin === '') return true;
return allowedOrigins.has(String(origin));
}
function sanitizeSlug(s) {
return String(s)
.toLowerCase()

View file

@ -26,12 +26,14 @@ export async function listSkills(skillsRoot) {
const { data, body } = parseFrontmatter(raw);
const hasAttachments = await dirHasAttachments(dir);
const mode = data.od?.mode || inferMode(body, data.description);
const surface = normalizeSurface(data.od?.surface, mode);
out.push({
id: data.name || entry.name,
name: data.name || entry.name,
description: data.description || "",
triggers: Array.isArray(data.triggers) ? data.triggers : [],
mode,
surface,
platform: normalizePlatform(
data.od?.platform,
mode,
@ -153,6 +155,9 @@ function derivePrompt(data) {
function inferMode(body, description) {
const hay = `${description ?? ""}\n${body ?? ""}`.toLowerCase();
if (/\bimage|poster|illustration|photography|图片|海报|插画/.test(hay)) return "image";
if (/\bvideo|motion|shortform|animation|视频|动效|短片/.test(hay)) return "video";
if (/\baudio|music|jingle|tts|sound|音频|音乐|配音|音效/.test(hay)) return "audio";
if (/\bppt|deck|slide|presentation|幻灯|投影/.test(hay)) return "deck";
if (/\bdesign[- ]system|\bdesign\.md|\bdesign tokens/.test(hay))
return "design-system";
@ -160,6 +165,16 @@ function inferMode(body, description) {
return "prototype";
}
const KNOWN_SURFACES = new Set(["web", "image", "video", "audio"]);
function normalizeSurface(value, mode) {
if (typeof value === "string") {
const v = value.trim().toLowerCase();
if (KNOWN_SURFACES.has(v)) return v;
}
if (mode === "image" || mode === "video" || mode === "audio") return mode;
return "web";
}
// Validate platform tag — only desktop / mobile are meaningful for the
// Examples gallery. Falls back to autodetecting "mobile" from descriptions
// so legacy skills sort under the right pill without authoring changes.

View file

@ -20,11 +20,13 @@ test('codex args disable plugins when OD_CODEX_DISABLE_PLUGINS is 1', () => {
const args = codex.buildArgs('', [], [], {}, { cwd: '/tmp/od-project' });
assert.deepEqual(args.slice(0, 6), [
assert.deepEqual(args.slice(0, 8), [
'exec',
'--json',
'--skip-git-repo-check',
'--full-auto',
'-c',
'sandbox_workspace_write.network_access=true',
'--disable',
'plugins',
]);

View file

@ -216,6 +216,53 @@ test('codex json stream emits status text and usage events', () => {
]);
});
test('codex json stream emits command execution tool events', () => {
const events = [];
const handler = createJsonEventStreamHandler('codex', (event) => events.push(event));
handler.feed(
JSON.stringify({
type: 'item.started',
item: {
id: 'item-1',
type: 'command_execution',
command: "/bin/zsh -lc 'echo hello-from-codex'",
aggregated_output: '',
exit_code: null,
status: 'in_progress',
},
}) +
'\n' +
JSON.stringify({
type: 'item.completed',
item: {
id: 'item-1',
type: 'command_execution',
command: "/bin/zsh -lc 'echo hello-from-codex'",
aggregated_output: 'hello-from-codex\n',
exit_code: 0,
status: 'completed',
},
}) +
'\n',
);
assert.deepEqual(events, [
{
type: 'tool_use',
id: 'item-1',
name: 'Bash',
input: { command: "/bin/zsh -lc 'echo hello-from-codex'" },
},
{
type: 'tool_result',
toolUseId: 'item-1',
content: 'hello-from-codex\n',
isError: false,
},
]);
});
test('unhandled structured events fall back to raw', () => {
const events = [];
const handler = createJsonEventStreamHandler('codex', (event) => events.push(event));

View file

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View file

@ -7,10 +7,16 @@ import {
daemonIsLive,
fetchAgents,
fetchDesignSystems,
fetchPromptTemplates,
fetchSkills,
} from './providers/registry';
import { navigate, useRoute } from './router';
import { loadConfig, saveConfig } from './state/config';
import {
hasAnyConfiguredProvider,
loadConfig,
saveConfig,
syncMediaProvidersToDaemon,
} from './state/config';
import {
createProject,
deleteProject as deleteProjectApi,
@ -25,6 +31,7 @@ import type {
DesignSystemSummary,
Project,
ProjectTemplate,
PromptTemplateSummary,
SkillSummary,
} from './types';
@ -38,6 +45,7 @@ export function App() {
const [designSystems, setDesignSystems] = useState<DesignSystemSummary[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [templates, setTemplates] = useState<ProjectTemplate[]>([]);
const [promptTemplates, setPromptTemplates] = useState<PromptTemplateSummary[]>([]);
// Goes false once the bootstrap effect has finished its initial round of
// fetches. The entry view uses this to show shimmer / skeleton states
// instead of an "empty" page that flickers before data lands.
@ -51,7 +59,7 @@ export function App() {
const alive = await daemonIsLive();
if (cancelled) return;
setDaemonLive(alive);
const [agentList, skillList, dsList, projectList, templateList] =
const [agentList, skillList, dsList, projectList, templateList, promptTemplateList] =
await Promise.all([
alive ? fetchAgents() : Promise.resolve([] as AgentInfo[]),
alive ? fetchSkills() : Promise.resolve([] as SkillSummary[]),
@ -60,6 +68,7 @@ export function App() {
: Promise.resolve([] as DesignSystemSummary[]),
alive ? listProjects() : Promise.resolve([] as Project[]),
alive ? listTemplates() : Promise.resolve([] as ProjectTemplate[]),
alive ? fetchPromptTemplates() : Promise.resolve([] as PromptTemplateSummary[]),
]);
if (cancelled) return;
setAgents(agentList);
@ -67,6 +76,7 @@ export function App() {
setDesignSystems(dsList);
setProjects(projectList);
setTemplates(templateList);
setPromptTemplates(promptTemplateList);
setConfig((prev) => {
const next = { ...prev };
@ -83,6 +93,9 @@ export function App() {
next.mode = 'api';
}
saveConfig(next);
if (alive && hasAnyConfiguredProvider(next.mediaProviders)) {
void syncMediaProvidersToDaemon(next.mediaProviders);
}
// Pop the onboarding modal only on the first run. Once the user has
// saved or skipped past it once, we trust their stored config and
@ -116,6 +129,7 @@ export function App() {
// configuration, so future page loads can skip the auto-popup.
const withOnboarding: AppConfig = { ...next, onboardingCompleted: true };
saveConfig(withOnboarding);
void syncMediaProvidersToDaemon(withOnboarding.mediaProviders, { force: true });
setConfig(withOnboarding);
setSettingsOpen(false);
}, []);
@ -311,6 +325,7 @@ export function App() {
designSystems={designSystems}
projects={projects}
templates={templates}
promptTemplates={promptTemplates}
defaultDesignSystemId={config.designSystemId}
config={config}
agents={agents}

View file

@ -308,6 +308,15 @@ function DfPreview({
<img src={`${url}?v=${Math.round(file.mtime)}`} alt={file.name} />
) : file.kind === 'html' ? (
<iframe title={file.name} src={url} sandbox="allow-scripts" />
) : file.kind === 'video' ? (
<video
src={`${url}?v=${Math.round(file.mtime)}`}
controls
playsInline
preload="metadata"
/>
) : file.kind === 'audio' ? (
<audio src={`${url}?v=${Math.round(file.mtime)}`} controls preload="metadata" />
) : (
<div
style={{

View file

@ -1,6 +1,6 @@
import { useMemo, useState } from 'react';
import { useT } from '../i18n';
import type { DesignSystemSummary } from '../types';
import type { DesignSystemSummary, Surface } from '../types';
interface Props {
systems: DesignSystemSummary[];
@ -22,23 +22,49 @@ const CATEGORY_ORDER = [
'Automotive',
];
type SurfaceFilter = 'all' | Surface;
const SURFACE_PILLS: { value: SurfaceFilter; labelKey: 'examples.modeAll' | 'ds.surfaceWeb' | 'ds.surfaceImage' | 'ds.surfaceVideo' | 'ds.surfaceAudio' }[] = [
{ value: 'all', labelKey: 'examples.modeAll' },
{ value: 'web', labelKey: 'ds.surfaceWeb' },
{ value: 'image', labelKey: 'ds.surfaceImage' },
{ value: 'video', labelKey: 'ds.surfaceVideo' },
{ value: 'audio', labelKey: 'ds.surfaceAudio' },
];
function surfaceOf(system: DesignSystemSummary): Surface {
return system.surface ?? 'web';
}
export function DesignSystemsTab({ systems, selectedId, onSelect, onPreview }: Props) {
const t = useT();
const [filter, setFilter] = useState('');
const [surfaceFilter, setSurfaceFilter] = useState<SurfaceFilter>('all');
const [category, setCategory] = useState<string>('All');
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 +72,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
@ -74,6 +100,29 @@ export function DesignSystemsTab({ systems, selectedId, onSelect, onPreview }: P
))}
</select>
</div>
<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');
}}
>
{t(p.labelKey)}
<span className="filter-pill-count">{surfaceCounts[p.value]}</span>
</button>
))}
</div>
{filtered.length === 0 ? (
<div className="tab-empty">{t('ds.emptyNoMatch')}</div>
) : (

View file

@ -1,5 +1,10 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useT } from '../i18n';
import {
DEFAULT_AUDIO_MODEL,
DEFAULT_IMAGE_MODEL,
DEFAULT_VIDEO_MODEL,
} from '../media/models';
import type {
AgentInfo,
AppConfig,
@ -8,6 +13,7 @@ import type {
ProjectKind,
ProjectMetadata,
ProjectTemplate,
PromptTemplateSummary,
SkillSummary,
} from '../types';
import { DesignsTab } from './DesignsTab';
@ -18,14 +24,17 @@ import { Icon } from './Icon';
import { LanguageMenu } from './LanguageMenu';
import { CenteredLoader } from './Loading';
import { NewProjectPanel, type CreateInput } from './NewProjectPanel';
import { PromptTemplatePreviewModal } from './PromptTemplatePreviewModal';
import { PromptTemplatesTab } from './PromptTemplatesTab';
type TopTab = 'designs' | 'examples' | 'design-systems';
type TopTab = 'designs' | 'examples' | 'design-systems' | 'image-templates' | 'video-templates';
interface Props {
skills: SkillSummary[];
designSystems: DesignSystemSummary[];
projects: Project[];
templates: ProjectTemplate[];
promptTemplates: PromptTemplateSummary[];
defaultDesignSystemId: string | null;
config: AppConfig;
agents: AgentInfo[];
@ -60,6 +69,7 @@ export function EntryView({
designSystems,
projects,
templates,
promptTemplates,
defaultDesignSystemId,
config,
agents,
@ -74,6 +84,8 @@ export function EntryView({
const t = useT();
const [topTab, setTopTab] = useState<TopTab>('designs');
const [previewSystemId, setPreviewSystemId] = useState<string | null>(null);
const [previewPromptTemplate, setPreviewPromptTemplate] =
useState<PromptTemplateSummary | null>(null);
const [sidebarWidth, setSidebarWidth] = useState<number>(() => loadSidebarWidth());
const [resizing, setResizing] = useState(false);
@ -180,6 +192,7 @@ export function EntryView({
templates={templates}
onCreate={handleCreate}
onImportClaudeDesign={onImportClaudeDesign}
mediaProviders={config.mediaProviders}
loading={loading}
/>
<div className="entry-side-foot">
@ -225,6 +238,18 @@ export function EntryView({
label={t('entry.tabDesignSystems')}
onClick={setTopTab}
/>
<TopTabButton
current={topTab}
value="image-templates"
label={t('entry.tabImageTemplates')}
onClick={setTopTab}
/>
<TopTabButton
current={topTab}
value="video-templates"
label={t('entry.tabVideoTemplates')}
onClick={setTopTab}
/>
</div>
<div className="entry-header-right">
{/* Avatar settings live next to tabs to mirror the project view. */}
@ -270,6 +295,20 @@ export function EntryView({
onPreview={previewDesignSystem}
/>
) : null}
{topTab === 'image-templates' ? (
<PromptTemplatesTab
surface="image"
templates={promptTemplates}
onPreview={setPreviewPromptTemplate}
/>
) : null}
{topTab === 'video-templates' ? (
<PromptTemplatesTab
surface="video"
templates={promptTemplates}
onPreview={setPreviewPromptTemplate}
/>
) : null}
</>
)}
</div>
@ -280,6 +319,12 @@ export function EntryView({
onClose={() => setPreviewSystemId(null)}
/>
) : null}
{previewPromptTemplate ? (
<PromptTemplatePreviewModal
summary={previewPromptTemplate}
onClose={() => setPreviewPromptTemplate(null)}
/>
) : null}
</div>
);
}
@ -334,6 +379,20 @@ function metadataForSkill(skill: SkillSummary): ProjectMetadata {
typeof skill.animations === 'boolean' ? skill.animations : false,
};
}
if (kind === 'image') {
return { kind, imageModel: DEFAULT_IMAGE_MODEL, imageAspect: '1:1' };
}
if (kind === 'video') {
return { kind, videoModel: DEFAULT_VIDEO_MODEL, videoAspect: '16:9', videoLength: 5 };
}
if (kind === 'audio') {
return {
kind,
audioKind: 'speech',
audioModel: DEFAULT_AUDIO_MODEL.speech,
audioDuration: 10,
};
}
return { kind: 'other' };
}
@ -341,5 +400,8 @@ function kindForSkill(skill: SkillSummary): ProjectKind {
if (skill.mode === 'deck') return 'deck';
if (skill.mode === 'prototype') return 'prototype';
if (skill.mode === 'template') return 'template';
if (skill.mode === 'image' || skill.surface === 'image') return 'image';
if (skill.mode === 'video' || skill.surface === 'video') return 'video';
if (skill.mode === 'audio' || skill.surface === 'audio') return 'audio';
return 'other';
}

View file

@ -4,7 +4,7 @@ import type { Dict } from '../i18n/types';
import { fetchSkillExample } from '../providers/registry';
import { exportAsHtml, exportAsPdf, exportAsZip } from '../runtime/exports';
import { buildSrcdoc } from '../runtime/srcdoc';
import type { SkillSummary } from '../types';
import type { SkillSummary, Surface } from '../types';
import { PreviewModal } from './PreviewModal';
type TranslateFn = (key: keyof Dict, vars?: Record<string, string | number>) => string;
@ -15,8 +15,17 @@ interface Props {
}
type ModeFilter = 'all' | 'prototype-desktop' | 'prototype-mobile' | 'deck' | 'document';
type SurfaceFilter = 'all' | Surface;
type ScenarioFilter = string;
const SURFACE_PILLS: { value: SurfaceFilter; labelKey: keyof Dict }[] = [
{ value: 'all', labelKey: 'examples.modeAll' },
{ value: 'web', labelKey: 'examples.surfaceWeb' },
{ value: 'image', labelKey: 'examples.surfaceImage' },
{ value: 'video', labelKey: 'examples.surfaceVideo' },
{ value: 'audio', labelKey: 'examples.surfaceAudio' },
];
const MODE_PILLS: { value: ModeFilter; labelKey: keyof Dict }[] = [
{ value: 'all', labelKey: 'examples.modeAll' },
{ value: 'prototype-desktop', labelKey: 'examples.modePrototypeDesktop' },
@ -74,10 +83,21 @@ function matchesMode(skill: SkillSummary, filter: ModeFilter): boolean {
return true;
}
function surfaceOf(skill: SkillSummary): Surface {
if (skill.surface) return skill.surface;
if (skill.mode === 'image' || skill.mode === 'video' || skill.mode === 'audio') return skill.mode;
return 'web';
}
function matchesSurface(skill: SkillSummary, filter: SurfaceFilter): boolean {
return filter === 'all' || surfaceOf(skill) === filter;
}
export function ExamplesTab({ skills, onUsePrompt }: Props) {
const t = useT();
// Hold preview HTML per skill across re-renders so cards never re-flicker.
const [previews, setPreviews] = useState<Record<string, string | null>>({});
const [surfaceFilter, setSurfaceFilter] = useState<SurfaceFilter>('all');
const [modeFilter, setModeFilter] = useState<ModeFilter>('all');
const [scenarioFilter, setScenarioFilter] = useState<ScenarioFilter>('all');
const [previewSkillId, setPreviewSkillId] = useState<string | null>(null);
@ -107,31 +127,38 @@ export function ExamplesTab({ skills, onUsePrompt }: Props) {
);
const modeCounts = useMemo(() => {
const surfaceScoped = skills.filter((skill) => matchesSurface(skill, surfaceFilter));
const c: Record<ModeFilter, number> = {
all: skills.length,
all: surfaceScoped.length,
'prototype-desktop': 0,
'prototype-mobile': 0,
deck: 0,
document: 0,
};
for (const s of skills) {
for (const s of surfaceScoped) {
if (matchesMode(s, 'prototype-desktop')) c['prototype-desktop']++;
if (matchesMode(s, 'prototype-mobile')) c['prototype-mobile']++;
if (matchesMode(s, 'deck')) c.deck++;
if (matchesMode(s, 'document')) c.document++;
}
return c;
}, [skills, surfaceFilter]);
const surfaceCounts = useMemo(() => {
const counts: Record<SurfaceFilter, number> = { all: skills.length, web: 0, image: 0, video: 0, audio: 0 };
for (const s of skills) counts[surfaceOf(s)]++;
return counts;
}, [skills]);
const scenarioCounts = useMemo(() => {
const counts = new Map<string, number>();
for (const s of skills) {
if (!matchesMode(s, modeFilter)) continue;
if (!matchesSurface(s, surfaceFilter) || !matchesMode(s, modeFilter)) continue;
const tag = s.scenario || 'general';
counts.set(tag, (counts.get(tag) ?? 0) + 1);
}
return counts;
}, [skills, modeFilter]);
}, [skills, surfaceFilter, modeFilter]);
const scenarioOptions = useMemo(() => {
const have = new Set(scenarioCounts.keys());
@ -143,7 +170,7 @@ export function ExamplesTab({ skills, onUsePrompt }: Props) {
const filtered = useMemo(() => {
const matched = skills.filter((s) => {
if (!matchesMode(s, modeFilter)) return false;
if (!matchesSurface(s, surfaceFilter) || !matchesMode(s, modeFilter)) return false;
if (scenarioFilter === 'all') return true;
return (s.scenario || 'general') === scenarioFilter;
});
@ -159,7 +186,7 @@ export function ExamplesTab({ skills, onUsePrompt }: Props) {
return a.idx - b.idx;
})
.map(({ s }) => s);
}, [skills, modeFilter, scenarioFilter]);
}, [skills, surfaceFilter, modeFilter, scenarioFilter]);
if (skills.length === 0) {
return <div className="tab-empty">{t('examples.emptyNoSkills')}</div>;
@ -168,6 +195,30 @@ export function ExamplesTab({ skills, onUsePrompt }: Props) {
return (
<div className="tab-panel examples-panel">
<div className="examples-toolbar">
<div
className="examples-filter-row"
role="tablist"
aria-label={t('examples.surfaceLabel')}
>
<span className="examples-filter-label">{t('examples.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);
setModeFilter('all');
setScenarioFilter('all');
}}
>
{t(p.labelKey)}
<span className="filter-pill-count">{surfaceCounts[p.value]}</span>
</button>
))}
</div>
<div
className="examples-filter-row"
role="tablist"

View file

@ -66,6 +66,12 @@ export function FileViewer({
if (file.kind === 'image') {
return <ImageViewer projectId={projectId} file={file} />;
}
if (file.kind === 'video') {
return <VideoViewer projectId={projectId} file={file} />;
}
if (file.kind === 'audio') {
return <AudioViewer projectId={projectId} file={file} />;
}
if (file.kind === 'sketch') {
return <ImageViewer projectId={projectId} file={file} />;
}
@ -1128,6 +1134,62 @@ function ImageViewer({
);
}
function VideoViewer({
projectId,
file,
}: {
projectId: string;
file: ProjectFile;
}) {
const t = useT();
const url = `${projectFileUrl(projectId, file.name)}?v=${Math.round(file.mtime)}`;
return (
<div className="viewer video-viewer">
<div className="viewer-toolbar">
<div className="viewer-toolbar-left">
<span className="viewer-meta">
{t('fileViewer.videoMeta', { size: humanSize(file.size) })}
</span>
</div>
<FileActions projectId={projectId} file={file} />
</div>
<div className="viewer-body video-body">
<video src={url} controls playsInline preload="metadata" />
</div>
</div>
);
}
function AudioViewer({
projectId,
file,
}: {
projectId: string;
file: ProjectFile;
}) {
const t = useT();
const url = `${projectFileUrl(projectId, file.name)}?v=${Math.round(file.mtime)}`;
return (
<div className="viewer audio-viewer">
<div className="viewer-toolbar">
<div className="viewer-toolbar-left">
<span className="viewer-meta">
{t('fileViewer.audioMeta', { size: humanSize(file.size) })}
</span>
</div>
<FileActions projectId={projectId} file={file} />
</div>
<div className="viewer-body audio-body">
<div className="audio-card">
<Icon name="mic" size={28} />
<div className="audio-card-name">{file.name}</div>
<audio src={url} controls preload="metadata" />
</div>
</div>
</div>
);
}
function TextViewer({
projectId,
file,

View file

@ -6,6 +6,7 @@ type IconName =
| 'attach'
| 'check'
| 'chevron-down'
| 'chevron-left'
| 'chevron-right'
| 'close'
| 'copy'
@ -103,6 +104,12 @@ export function Icon({ name, size = 14, strokeWidth = 1.6, ...rest }: Props) {
<path d="m6 9 6 6 6-6" />
</svg>
);
case 'chevron-left':
return (
<svg {...common}>
<path d="m15 18-6-6 6-6" />
</svg>
);
case 'chevron-right':
return (
<svg {...common}>

View file

@ -2,18 +2,34 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { useT } from '../i18n';
import type { Dict } from '../i18n/types';
import type {
AudioKind,
DesignSystemSummary,
MediaAspect,
ProjectKind,
ProjectMetadata,
ProjectTemplate,
MediaProviderCredentials,
SkillSummary,
} from '../types';
import {
AUDIO_DURATIONS_SEC,
AUDIO_MODELS_BY_KIND,
DEFAULT_AUDIO_MODEL,
DEFAULT_IMAGE_MODEL,
DEFAULT_VIDEO_MODEL,
findProvider,
IMAGE_MODELS,
MEDIA_ASPECTS,
type MediaModel,
VIDEO_LENGTHS_SEC,
VIDEO_MODELS,
} from '../media/models';
import { Icon } from './Icon';
import { Skeleton } from './Loading';
type TranslateFn = (key: keyof Dict, vars?: Record<string, string | number>) => string;
export type CreateTab = 'prototype' | 'deck' | 'template' | 'other';
export type CreateTab = 'prototype' | 'deck' | 'template' | 'image' | 'video' | 'audio' | 'other';
export interface CreateInput {
name: string;
@ -29,6 +45,7 @@ interface Props {
templates: ProjectTemplate[];
onCreate: (input: CreateInput) => void;
onImportClaudeDesign?: (file: File) => Promise<void> | void;
mediaProviders?: Record<string, MediaProviderCredentials>;
loading?: boolean;
}
@ -36,6 +53,9 @@ const TAB_LABEL_KEYS: Record<CreateTab, keyof Dict> = {
prototype: 'newproj.tabPrototype',
deck: 'newproj.tabDeck',
template: 'newproj.tabTemplate',
image: 'newproj.surfaceImage',
video: 'newproj.surfaceVideo',
audio: 'newproj.surfaceAudio',
other: 'newproj.tabOther',
};
@ -46,12 +66,15 @@ export function NewProjectPanel({
templates,
onCreate,
onImportClaudeDesign,
mediaProviders,
loading = false,
}: Props) {
const t = useT();
const importInputRef = useRef<HTMLInputElement | null>(null);
const [importing, setImporting] = useState(false);
const [tab, setTab] = useState<CreateTab>('prototype');
const tabsRef = useRef<HTMLDivElement | null>(null);
const [tabScroll, setTabScroll] = useState({ left: false, right: false });
const [name, setName] = useState('');
// Design-system selection is now an *array* internally so the same
// component can drive both single-select and multi-select modes without
@ -67,6 +90,16 @@ export function NewProjectPanel({
const [speakerNotes, setSpeakerNotes] = useState(false);
const [animations, setAnimations] = useState(false);
const [templateId, setTemplateId] = useState<string | null>(null);
const [imageModel, setImageModel] = useState(DEFAULT_IMAGE_MODEL);
const [imageAspect, setImageAspect] = useState<MediaAspect>('1:1');
const [imageStyle, setImageStyle] = useState('');
const [videoModel, setVideoModel] = useState(DEFAULT_VIDEO_MODEL);
const [videoAspect, setVideoAspect] = useState<MediaAspect>('16:9');
const [videoLength, setVideoLength] = useState(5);
const [audioKind, setAudioKind] = useState<AudioKind>('speech');
const [audioModel, setAudioModel] = useState(DEFAULT_AUDIO_MODEL.speech);
const [audioDuration, setAudioDuration] = useState(10);
const [voice, setVoice] = useState('');
// When entering the template tab, snap to the first user-saved template
// if there is one (and we don't already have a valid pick). The template
@ -100,12 +133,58 @@ export function NewProjectPanel({
?? list[0]?.id
?? null;
}
if (tab === 'image' || tab === 'video' || tab === 'audio') {
const list = skills.filter((s) => s.mode === tab || s.surface === tab);
return list.find((s) => s.defaultFor.includes(tab))?.id
?? list[0]?.id
?? null;
}
return null;
}, [tab, skills]);
const canCreate =
!loading && (tab !== 'template' || templateId != null);
function updateTabScrollState() {
const el = tabsRef.current;
if (!el) return;
const maxLeft = el.scrollWidth - el.clientWidth;
setTabScroll({
left: el.scrollLeft > 2,
right: el.scrollLeft < maxLeft - 2,
});
}
function scrollTabs(direction: -1 | 1) {
const el = tabsRef.current;
if (!el) return;
el.scrollBy({
left: direction * Math.max(120, el.clientWidth * 0.65),
behavior: 'smooth',
});
}
useEffect(() => {
const el = tabsRef.current;
if (!el) return;
updateTabScrollState();
const onScroll = () => updateTabScrollState();
el.addEventListener('scroll', onScroll, { passive: true });
const ro = new ResizeObserver(updateTabScrollState);
ro.observe(el);
return () => {
el.removeEventListener('scroll', onScroll);
ro.disconnect();
};
}, []);
useEffect(() => {
const el = tabsRef.current;
const active = el?.querySelector<HTMLButtonElement>('.newproj-tab.active');
active?.scrollIntoView({ behavior: 'smooth', inline: 'nearest', block: 'nearest' });
window.setTimeout(updateTabScrollState, 180);
}, [tab]);
function handleCreate() {
if (!canCreate) return;
const primaryDs = selectedDsIds[0] ?? null;
@ -117,6 +196,16 @@ export function NewProjectPanel({
animations,
templateId,
templates,
imageModel,
imageAspect,
imageStyle,
videoModel,
videoAspect,
videoLength,
audioKind,
audioModel,
audioDuration,
voice,
inspirationIds: inspirations,
});
onCreate({
@ -141,19 +230,39 @@ export function NewProjectPanel({
return (
<div className="newproj" data-testid="new-project-panel">
<div className="newproj-tabs" role="tablist">
{(Object.keys(TAB_LABEL_KEYS) as CreateTab[]).map((entry) => (
<button
key={entry}
role="tab"
data-testid={`new-project-tab-${entry}`}
aria-selected={tab === entry}
className={`newproj-tab ${tab === entry ? 'active' : ''}`}
onClick={() => setTab(entry)}
>
{t(TAB_LABEL_KEYS[entry])}
</button>
))}
<div className={`newproj-tabs-shell${tabScroll.left ? ' can-left' : ''}${tabScroll.right ? ' can-right' : ''}`}>
<button
type="button"
className={`newproj-tabs-arrow left${tabScroll.left ? '' : ' hidden'}`}
onClick={() => scrollTabs(-1)}
aria-label="Scroll project types left"
tabIndex={tabScroll.left ? 0 : -1}
>
<Icon name="chevron-left" size={16} strokeWidth={2} />
</button>
<div className="newproj-tabs" role="tablist" ref={tabsRef}>
{(Object.keys(TAB_LABEL_KEYS) as CreateTab[]).map((entry) => (
<button
key={entry}
role="tab"
data-testid={`new-project-tab-${entry}`}
aria-selected={tab === entry}
className={`newproj-tab ${tab === entry ? 'active' : ''}`}
onClick={() => setTab(entry)}
>
{t(TAB_LABEL_KEYS[entry])}
</button>
))}
</div>
<button
type="button"
className={`newproj-tabs-arrow right${tabScroll.right ? '' : ' hidden'}`}
onClick={() => scrollTabs(1)}
aria-label="Scroll project types right"
tabIndex={tabScroll.right ? 0 : -1}
>
<Icon name="chevron-right" size={16} strokeWidth={2} />
</button>
</div>
<div className="newproj-body">
<h3 className="newproj-title">{titleForTab(tab, t)}</h3>
@ -205,6 +314,50 @@ export function NewProjectPanel({
</>
) : null}
{tab === 'image' ? (
<MediaProjectOptions
surface="image"
imageModel={imageModel}
imageAspect={imageAspect}
imageStyle={imageStyle}
mediaProviders={mediaProviders}
onImageModel={setImageModel}
onImageAspect={setImageAspect}
onImageStyle={setImageStyle}
/>
) : null}
{tab === 'video' ? (
<MediaProjectOptions
surface="video"
videoModel={videoModel}
videoAspect={videoAspect}
videoLength={videoLength}
mediaProviders={mediaProviders}
onVideoModel={setVideoModel}
onVideoAspect={setVideoAspect}
onVideoLength={setVideoLength}
/>
) : null}
{tab === 'audio' ? (
<MediaProjectOptions
surface="audio"
audioKind={audioKind}
audioModel={audioModel}
audioDuration={audioDuration}
voice={voice}
mediaProviders={mediaProviders}
onAudioKind={(kind) => {
setAudioKind(kind);
setAudioModel(DEFAULT_AUDIO_MODEL[kind]);
}}
onAudioModel={setAudioModel}
onAudioDuration={setAudioDuration}
onVoice={setVoice}
/>
) : null}
<button
className="primary newproj-create"
data-testid="create-project"
@ -809,6 +962,298 @@ function fallbackSwatches(seed: string): string[] {
];
}
function MediaProjectOptions(props:
| {
surface: 'image';
imageModel: string;
imageAspect: MediaAspect;
imageStyle: string;
mediaProviders?: Record<string, MediaProviderCredentials>;
onImageModel: (value: string) => void;
onImageAspect: (value: MediaAspect) => void;
onImageStyle: (value: string) => void;
}
| {
surface: 'video';
videoModel: string;
videoAspect: MediaAspect;
videoLength: number;
mediaProviders?: Record<string, MediaProviderCredentials>;
onVideoModel: (value: string) => void;
onVideoAspect: (value: MediaAspect) => void;
onVideoLength: (value: number) => void;
}
| {
surface: 'audio';
audioKind: AudioKind;
audioModel: string;
audioDuration: number;
voice: string;
mediaProviders?: Record<string, MediaProviderCredentials>;
onAudioKind: (value: AudioKind) => void;
onAudioModel: (value: string) => void;
onAudioDuration: (value: number) => void;
onVoice: (value: string) => void;
}
) {
const t = useT();
if (props.surface === 'image') {
return (
<div className="newproj-media-options">
<MediaModelCards
label={t('newproj.modelLabel')}
models={supportedModels('image', IMAGE_MODELS)}
mediaProviders={props.mediaProviders}
value={props.imageModel}
onChange={props.onImageModel}
/>
<AspectCards
label={t('newproj.aspectLabel')}
value={props.imageAspect}
onChange={props.onImageAspect}
/>
<label className="newproj-label">
<span>{t('newproj.imageStyleLabel')}</span>
<input
value={props.imageStyle}
placeholder={t('newproj.imageStylePlaceholder')}
onChange={(e) => props.onImageStyle(e.target.value)}
/>
</label>
</div>
);
}
if (props.surface === 'video') {
return (
<div className="newproj-media-options">
<MediaModelCards
label={t('newproj.modelLabel')}
models={supportedModels('video', VIDEO_MODELS)}
mediaProviders={props.mediaProviders}
value={props.videoModel}
onChange={props.onVideoModel}
/>
<AspectCards
label={t('newproj.aspectLabel')}
value={props.videoAspect}
onChange={props.onVideoAspect}
/>
<label className="newproj-label">
<span>{t('newproj.videoLengthLabel')}</span>
<select value={props.videoLength} onChange={(e) => props.onVideoLength(Number(e.target.value))}>
{VIDEO_LENGTHS_SEC.map((sec) => (
<option key={sec} value={sec}>{t('newproj.videoLengthSeconds', { n: sec })}</option>
))}
</select>
</label>
</div>
);
}
const models = supportedModels('audio', AUDIO_MODELS_BY_KIND[props.audioKind]);
return (
<div className="newproj-media-options">
<OptionCards
label={t('newproj.audioKindLabel')}
options={[
{ value: 'speech' as const, title: t('newproj.audioKindSpeech') },
]}
value={props.audioKind}
onChange={props.onAudioKind}
/>
<MediaModelCards
label={t('newproj.modelLabel')}
models={models}
mediaProviders={props.mediaProviders}
value={props.audioModel}
onChange={props.onAudioModel}
/>
<label className="newproj-label">
<span>{t('newproj.audioDurationLabel')}</span>
<select value={props.audioDuration} onChange={(e) => props.onAudioDuration(Number(e.target.value))}>
{AUDIO_DURATIONS_SEC.map((sec) => (
<option key={sec} value={sec}>{t('newproj.audioDurationSeconds', { n: sec })}</option>
))}
</select>
</label>
{props.audioKind === 'speech' ? (
<label className="newproj-label">
<span>{t('newproj.voiceLabel')}</span>
<input
value={props.voice}
placeholder={t('newproj.voicePlaceholder')}
onChange={(e) => props.onVoice(e.target.value)}
/>
</label>
) : null}
</div>
);
}
function supportedModels(surface: 'image' | 'video' | 'audio', models: MediaModel[]): MediaModel[] {
const supportedProviders: Record<'image' | 'video' | 'audio', Set<string>> = {
image: new Set(['openai', 'volcengine']),
video: new Set(['volcengine', 'hyperframes']),
audio: new Set(['minimax', 'fishaudio']),
};
return models.filter((model) => {
const provider = findProvider(model.provider);
return provider?.integrated === true && supportedProviders[surface].has(model.provider);
});
}
function MediaModelCards({
label,
models,
mediaProviders,
value,
onChange,
}: {
label: string;
models: MediaModel[];
mediaProviders?: Record<string, MediaProviderCredentials>;
value: string;
onChange: (value: string) => void;
}) {
const groups: Array<{
providerId: string;
providerLabel: string;
status: 'configured' | 'integrated' | 'unsupported';
models: MediaModel[];
}> = [];
for (const model of models) {
const provider = findProvider(model.provider);
const providerId = provider?.id ?? model.provider;
const entry = mediaProviders?.[providerId];
const configured = provider?.credentialsRequired === false || Boolean(entry?.apiKey.trim() || entry?.baseUrl.trim());
let group = groups.find((g) => g.providerId === providerId);
if (!group) {
group = {
providerId,
providerLabel: provider?.label ?? model.provider,
status: configured
? 'configured'
: provider?.integrated
? 'integrated'
: 'unsupported',
models: [],
};
groups.push(group);
}
group.models.push(model);
}
return (
<div className="newproj-media-field">
<div className="newproj-label">{label}</div>
<div className="newproj-model-groups">
{groups.map((group) => (
<div className="newproj-model-group" key={group.providerId}>
<div className="newproj-provider-row">
<span>{group.providerLabel}</span>
<span className={`newproj-provider-badge ${group.status}`}>
{group.status === 'configured'
? 'Configured'
: group.status === 'integrated'
? 'Integrated'
: 'Unsupported'}
</span>
</div>
<div className="newproj-model-grid">
{group.models.map((model) => (
<button
key={model.id}
type="button"
className={`newproj-card newproj-model-card${value === model.id ? ' active' : ''}`}
onClick={() => onChange(model.id)}
aria-pressed={value === model.id}
>
<span className="newproj-model-name">{model.label}</span>
<span className="newproj-model-hint">{model.hint}</span>
</button>
))}
</div>
</div>
))}
</div>
</div>
);
}
function AspectCards({
label,
value,
onChange,
}: {
label: string;
value: MediaAspect;
onChange: (value: MediaAspect) => void;
}) {
const labels: Record<MediaAspect, string> = {
'1:1': 'Square',
'16:9': 'Landscape',
'9:16': 'Portrait',
'4:3': 'Wide',
'3:4': 'Tall',
};
return (
<div className="newproj-media-field">
<div className="newproj-label">{label}</div>
<div className="newproj-option-grid aspect-grid">
{MEDIA_ASPECTS.map((aspect) => (
<button
key={aspect}
type="button"
className={`newproj-card newproj-option-card${value === aspect ? ' active' : ''}`}
onClick={() => onChange(aspect)}
aria-pressed={value === aspect}
>
<span className={`aspect-glyph aspect-${aspect.replace(':', '-')}`} aria-hidden />
<span className="aspect-copy">
<strong>{labels[aspect]}</strong>
<small>{aspect}</small>
</span>
</button>
))}
</div>
</div>
);
}
function OptionCards<T extends string | number>({
label,
options,
value,
onChange,
}: {
label: string;
options: Array<{ value: T; title: string; hint?: string }>;
value: T;
onChange: (value: T) => void;
}) {
return (
<div className="newproj-media-field">
<div className="newproj-label">{label}</div>
<div className="newproj-option-grid compact">
{options.map((option) => (
<button
key={String(option.value)}
type="button"
className={`newproj-card newproj-option-card${value === option.value ? ' active' : ''}`}
onClick={() => onChange(option.value)}
aria-pressed={value === option.value}
>
<span>{option.title}</span>
{option.hint ? <small>{option.hint}</small> : null}
</button>
))}
</div>
</div>
);
}
function buildMetadata(input: {
tab: CreateTab;
fidelity: 'wireframe' | 'high-fidelity';
@ -816,6 +1261,16 @@ function buildMetadata(input: {
animations: boolean;
templateId: string | null;
templates: ProjectTemplate[];
imageModel: string;
imageAspect: MediaAspect;
imageStyle: string;
videoModel: string;
videoAspect: MediaAspect;
videoLength: number;
audioKind: AudioKind;
audioModel: string;
audioDuration: number;
voice: string;
inspirationIds: string[];
}): ProjectMetadata {
const kind: ProjectKind = input.tab;
@ -843,6 +1298,34 @@ function buildMetadata(input: {
...inspirations,
};
}
if (input.tab === 'image') {
return {
kind,
imageModel: input.imageModel,
imageAspect: input.imageAspect,
imageStyle: input.imageStyle.trim() || undefined,
...inspirations,
};
}
if (input.tab === 'video') {
return {
kind,
videoModel: input.videoModel,
videoAspect: input.videoAspect,
videoLength: input.videoLength,
...inspirations,
};
}
if (input.tab === 'audio') {
return {
kind,
audioKind: input.audioKind,
audioModel: input.audioModel,
audioDuration: input.audioDuration,
voice: input.voice.trim() || undefined,
...inspirations,
};
}
return { kind: 'other', ...inspirations };
}
@ -854,6 +1337,12 @@ function titleForTab(tab: CreateTab, t: TranslateFn): string {
return t('newproj.titleDeck');
case 'template':
return t('newproj.titleTemplate');
case 'image':
return t('newproj.titleImage');
case 'video':
return t('newproj.titleVideo');
case 'audio':
return t('newproj.titleAudio');
case 'other':
return t('newproj.titleOther');
}

View file

@ -0,0 +1,252 @@
import { useEffect, useState } from 'react';
import { useT } from '../i18n';
import { fetchPromptTemplate } from '../providers/registry';
import type {
PromptTemplateDetail,
PromptTemplateSummary,
} from '../types';
import { Icon } from './Icon';
interface Props {
summary: PromptTemplateSummary;
onClose: () => void;
}
// Modal preview for a curated prompt template. The summary payload from
// /api/prompt-templates carries enough to render the header (title,
// description, category, tags, attribution) and the preview asset; the
// prompt body is fetched lazily so the gallery list stays cheap.
export function PromptTemplatePreviewModal({ summary, onClose }: Props) {
const t = useT();
const [detail, setDetail] = useState<PromptTemplateDetail | null>(null);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
// Immersive fullscreen preview state. Layered ABOVE the modal so the
// user can dive into the asset without losing the prompt context they
// came from — closing the lightbox restores the modal underneath.
const [lightboxOpen, setLightboxOpen] = useState(false);
useEffect(() => {
let cancelled = false;
setDetail(null);
setError(null);
setCopied(false);
setLightboxOpen(false);
void fetchPromptTemplate(summary.surface, summary.id).then((d) => {
if (cancelled) return;
if (!d) {
setError(t('promptTemplates.fetchError'));
return;
}
setDetail(d);
});
return () => {
cancelled = true;
};
}, [summary.id, summary.surface, t]);
// Close on Escape — when the lightbox is open, ESC closes only the
// lightbox (preserving the modal beneath); otherwise it closes the
// modal itself. Mirrors the design-system preview modal's pattern so
// the two gallery views feel consistent.
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key !== 'Escape') return;
if (lightboxOpen) {
setLightboxOpen(false);
return;
}
onClose();
}
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [onClose, lightboxOpen]);
function handleCopy() {
if (!detail) return;
void navigator.clipboard.writeText(detail.prompt).then(() => {
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
});
}
const sourceLabel = summary.source.author
? `${summary.source.author} · ${summary.source.repo}`
: summary.source.repo;
const hasAsset = !!(summary.previewVideoUrl || summary.previewImageUrl);
const fullscreenLabel = t('promptTemplates.openFullscreen');
const closeFullscreenLabel = t('promptTemplates.closeFullscreen');
return (
<>
<div
className="prompt-template-modal-backdrop"
role="dialog"
aria-modal="true"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="prompt-template-modal">
<header className="prompt-template-modal-head">
<div className="prompt-template-modal-titles">
<h2>{summary.title}</h2>
<p>{summary.summary}</p>
</div>
<button
type="button"
className="ghost"
onClick={onClose}
aria-label={t('common.close')}
>
<Icon name="close" size={14} />
</button>
</header>
<div className="prompt-template-modal-tags">
<span className="prompt-template-category">{summary.category}</span>
{(summary.tags ?? []).map((tag) => (
<span key={tag} className="prompt-template-tag">
{tag}
</span>
))}
{summary.model ? (
<span className="prompt-template-model">
{t('promptTemplates.modelHint', { model: summary.model })}
</span>
) : null}
{summary.aspect ? (
<span className="prompt-template-model">{summary.aspect}</span>
) : null}
</div>
<div className="prompt-template-modal-body">
{hasAsset ? (
<div className="prompt-template-modal-asset">
{summary.previewVideoUrl ? (
<video
src={summary.previewVideoUrl}
poster={summary.previewImageUrl}
controls
preload="none"
playsInline
/>
) : summary.previewImageUrl ? (
// Image is click-to-expand — the whole thumbnail acts as
// the trigger so it feels natural (cursor: zoom-in). The
// floating pill below also opens fullscreen and is the
// primary path for video previews where clicks land on
// the native <video controls> instead.
<button
type="button"
className="prompt-template-modal-asset-image-trigger"
onClick={() => setLightboxOpen(true)}
aria-label={fullscreenLabel}
>
<img
src={summary.previewImageUrl}
alt={summary.title}
loading="lazy"
/>
</button>
) : null}
<button
type="button"
className="prompt-template-modal-asset-expand"
onClick={() => setLightboxOpen(true)}
aria-label={fullscreenLabel}
title={fullscreenLabel}
>
<Icon name="eye" size={12} />
<span>{fullscreenLabel}</span>
</button>
</div>
) : null}
<div className="prompt-template-modal-prompt">
<div className="prompt-template-modal-prompt-head">
<span className="prompt-template-modal-prompt-label">
{t('promptTemplates.promptLabel')}
</span>
<button
type="button"
className="ghost"
onClick={handleCopy}
disabled={!detail}
>
<Icon name="copy" size={12} />
{copied
? t('promptTemplates.copyDone')
: t('promptTemplates.copyPrompt')}
</button>
</div>
<pre className="prompt-template-modal-prompt-body">
{detail
? detail.prompt
: error
? error
: t('common.loading')}
</pre>
</div>
</div>
<footer className="prompt-template-modal-foot">
<span>
{t('promptTemplates.sourcePrefix')} {sourceLabel} ·{' '}
<span className="prompt-template-license">
{summary.source.license}
</span>
</span>
{summary.source.url ? (
<a
href={summary.source.url}
target="_blank"
rel="noopener noreferrer"
>
{t('promptTemplates.openSource')}
</a>
) : null}
</footer>
</div>
</div>
{lightboxOpen && hasAsset ? (
// Immersive lightbox — full viewport, dark backdrop, centered
// media. Rendered as a sibling of the modal backdrop so its
// backdrop click is independent (clicking the lightbox backdrop
// closes only the lightbox, not the modal beneath).
<div
className="prompt-template-lightbox-backdrop"
role="dialog"
aria-modal="true"
aria-label={fullscreenLabel}
onClick={(e) => {
if (e.target === e.currentTarget) setLightboxOpen(false);
}}
>
{summary.previewVideoUrl ? (
<video
className="prompt-template-lightbox-media"
src={summary.previewVideoUrl}
poster={summary.previewImageUrl}
controls
autoPlay
playsInline
/>
) : summary.previewImageUrl ? (
<img
className="prompt-template-lightbox-media"
src={summary.previewImageUrl}
alt={summary.title}
/>
) : null}
<button
type="button"
className="prompt-template-lightbox-close"
onClick={() => setLightboxOpen(false)}
aria-label={closeFullscreenLabel}
title={closeFullscreenLabel}
>
<Icon name="close" size={18} />
</button>
</div>
) : null}
</>
);
}

View file

@ -0,0 +1,150 @@
import { useMemo, useState } from 'react';
import { useT } from '../i18n';
import type { PromptTemplateSummary } from '../types';
import { Icon } from './Icon';
interface Props {
surface: 'image' | 'video';
templates: PromptTemplateSummary[];
onPreview: (tpl: PromptTemplateSummary) => void;
}
// Curated prompt-template gallery — one tab per surface (image / video).
// Layout mirrors the Examples tab: a category filter row + a responsive
// card grid that lazy-loads remote thumbnails (the upstream README hosts
// images on CMS / Cloudflare Stream, both public). Each card opens a
// preview modal with the full prompt body and attribution.
export function PromptTemplatesTab({ surface, templates, onPreview }: Props) {
const t = useT();
const [filter, setFilter] = useState('');
const [category, setCategory] = useState<string>('All');
const surfaceScoped = useMemo(
() => templates.filter((tpl) => tpl.surface === surface),
[templates, surface],
);
const categories = useMemo(() => {
const set = new Set<string>();
for (const tpl of surfaceScoped) set.add(tpl.category || 'General');
return ['All', ...Array.from(set).sort()];
}, [surfaceScoped]);
const filtered = useMemo(() => {
const q = filter.trim().toLowerCase();
return surfaceScoped.filter((tpl) => {
if (category !== 'All' && (tpl.category || 'General') !== category) {
return false;
}
if (!q) return true;
return (
tpl.title.toLowerCase().includes(q)
|| tpl.summary.toLowerCase().includes(q)
|| (tpl.tags ?? []).some((tag) => tag.toLowerCase().includes(q))
);
});
}, [surfaceScoped, filter, category]);
if (surfaceScoped.length === 0) {
return (
<div className="tab-empty">
{surface === 'image'
? t('promptTemplates.emptyImage')
: t('promptTemplates.emptyVideo')}
</div>
);
}
return (
<div className="tab-panel prompt-templates-panel">
<div className="tab-panel-toolbar">
<input
placeholder={t('promptTemplates.searchPlaceholder')}
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
<select value={category} onChange={(e) => setCategory(e.target.value)}>
{categories.map((c) => (
<option key={c} value={c}>
{c === 'All' ? t('common.all') : c}
</option>
))}
</select>
<span className="prompt-templates-count">
{t('promptTemplates.countLabel', { n: filtered.length })}
</span>
</div>
{filtered.length === 0 ? (
<div className="tab-empty">{t('promptTemplates.emptyNoMatch')}</div>
) : (
<div className="prompt-templates-grid">
{filtered.map((tpl) => (
<PromptTemplateCard
key={tpl.id}
tpl={tpl}
onPreview={() => onPreview(tpl)}
/>
))}
</div>
)}
<div className="prompt-templates-footer">
{t('promptTemplates.attributionFooter')}
</div>
</div>
);
}
function PromptTemplateCard({
tpl,
onPreview,
}: {
tpl: PromptTemplateSummary;
onPreview: () => void;
}) {
const t = useT();
const sourceLabel = tpl.source.author
? `${tpl.source.author} · ${tpl.source.repo.split('/').pop()}`
: tpl.source.repo.split('/').pop();
return (
<button
type="button"
className="prompt-template-card"
onClick={onPreview}
title={t('promptTemplates.openPreviewTitle')}
>
<span className="prompt-template-thumb">
{tpl.previewImageUrl ? (
<img src={tpl.previewImageUrl} alt="" loading="lazy" draggable={false} />
) : tpl.surface === 'video' ? (
<span className="prompt-template-thumb-fallback" aria-hidden>
<Icon name="play" size={28} />
</span>
) : (
<span className="prompt-template-thumb-fallback" aria-hidden>
<Icon name="image" size={28} />
</span>
)}
{tpl.surface === 'video' && tpl.previewVideoUrl ? (
<span className="prompt-template-thumb-play" aria-hidden>
</span>
) : null}
</span>
<span className="prompt-template-meta">
<span className="prompt-template-title">{tpl.title}</span>
<span className="prompt-template-summary">{tpl.summary}</span>
<span className="prompt-template-tags">
<span className="prompt-template-category">{tpl.category}</span>
{(tpl.tags ?? []).slice(0, 3).map((tag) => (
<span key={tag} className="prompt-template-tag">
{tag}
</span>
))}
</span>
<span className="prompt-template-source">
{t('promptTemplates.sourcePrefix')} {sourceLabel}
</span>
</span>
</button>
);
}

View file

@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { Dispatch, SetStateAction } from 'react';
import { LOCALE_LABEL, LOCALES, useI18n } from '../i18n';
import type { Locale } from '../i18n';
import { AgentIcon } from './AgentIcon';
@ -10,6 +11,8 @@ import {
} from './modelOptions';
import { KNOWN_PROVIDERS } from '../state/config';
import type { AgentInfo, AppConfig, ExecMode } from '../types';
import { MEDIA_PROVIDERS } from '../media/models';
import type { MediaProvider } from '../media/models';
interface Props {
initial: AppConfig;
@ -41,6 +44,7 @@ export function SettingsDialog({
const [cfg, setCfg] = useState<AppConfig>(initial);
const [showApiKey, setShowApiKey] = useState(false);
const [languageOpen, setLanguageOpen] = useState(false);
const [activeSection, setActiveSection] = useState<'execution' | 'media' | 'language'>('execution');
const [languageMenuRect, setLanguageMenuRect] = useState<DOMRect | null>(null);
const languageRef = useRef<HTMLDivElement | null>(null);
@ -114,44 +118,81 @@ export function SettingsDialog({
)}
</header>
<div
className="seg-control"
role="tablist"
aria-label={t('settings.modeAria')}
>
<button
type="button"
role="tab"
aria-selected={cfg.mode === 'daemon'}
className={'seg-btn' + (cfg.mode === 'daemon' ? ' active' : '')}
disabled={!daemonLive}
onClick={() => setMode('daemon')}
title={
daemonLive
? t('settings.modeDaemonHelp')
: t('settings.modeDaemonOffline')
}
>
<span className="seg-title">{t('settings.modeDaemon')}</span>
<span className="seg-meta">
{daemonLive
? t('settings.modeDaemonInstalledMeta', { count: installedCount })
: t('settings.modeDaemonOfflineMeta')}
</span>
</button>
<button
type="button"
role="tab"
aria-selected={cfg.mode === 'api'}
className={'seg-btn' + (cfg.mode === 'api' ? ' active' : '')}
onClick={() => setMode('api')}
>
<span className="seg-title">{t('settings.modeApi')}</span>
<span className="seg-meta">{t('settings.modeApiMeta')}</span>
</button>
</div>
<div className="modal-body">
<aside className="settings-sidebar" aria-label="Settings sections">
<button
type="button"
className={`settings-nav-item${activeSection === 'execution' ? ' active' : ''}`}
onClick={() => setActiveSection('execution')}
>
<Icon name="sliders" size={18} />
<span>
<strong>{t('settings.envConfigure')}</strong>
<small>{t('settings.codeAgent')}</small>
</span>
</button>
<button
type="button"
className={`settings-nav-item${activeSection === 'media' ? ' active' : ''}`}
onClick={() => setActiveSection('media')}
>
<Icon name="image" size={18} />
<span>
<strong>{t('settings.mediaProviders')}</strong>
<small>Image / video / audio</small>
</span>
</button>
<button
type="button"
className={`settings-nav-item${activeSection === 'language' ? ' active' : ''}`}
onClick={() => setActiveSection('language')}
>
<Icon name="languages" size={18} />
<span>
<strong>{t('settings.language')}</strong>
<small>{t('settings.languageHint')}</small>
</span>
</button>
</aside>
<div className="settings-content">
{activeSection === 'execution' ? (
<>
<div
className="seg-control"
role="tablist"
aria-label={t('settings.modeAria')}
>
<button
type="button"
role="tab"
aria-selected={cfg.mode === 'daemon'}
className={'seg-btn' + (cfg.mode === 'daemon' ? ' active' : '')}
disabled={!daemonLive}
onClick={() => setMode('daemon')}
title={
daemonLive
? t('settings.modeDaemonHelp')
: t('settings.modeDaemonOffline')
}
>
<span className="seg-title">{t('settings.modeDaemon')}</span>
<span className="seg-meta">
{daemonLive
? t('settings.modeDaemonInstalledMeta', { count: installedCount })
: t('settings.modeDaemonOfflineMeta')}
</span>
</button>
<button
type="button"
role="tab"
aria-selected={cfg.mode === 'api'}
className={'seg-btn' + (cfg.mode === 'api' ? ' active' : '')}
onClick={() => setMode('api')}
>
<span className="seg-title">{t('settings.modeApi')}</span>
<span className="seg-meta">{t('settings.modeApiMeta')}</span>
</button>
</div>
{cfg.mode === 'daemon' ? (
<section className="settings-section">
<div className="section-head">
@ -394,7 +435,12 @@ export function SettingsDialog({
<p className="hint">{t('settings.apiHint')}</p>
</section>
)}
</>
) : null}
{activeSection === 'media' ? <MediaProvidersSection cfg={cfg} setCfg={setCfg} /> : null}
{activeSection === 'language' ? (
<section className="settings-section">
<div className="section-head">
<div>
@ -461,6 +507,8 @@ export function SettingsDialog({
) : null}
</div>
</section>
) : null}
</div>
</div>
<footer className="modal-foot">
@ -480,3 +528,104 @@ export function SettingsDialog({
</div>
);
}
function MediaProvidersSection({
cfg,
setCfg,
}: {
cfg: AppConfig;
setCfg: Dispatch<SetStateAction<AppConfig>>;
}) {
const { t } = useI18n();
const providers = MEDIA_PROVIDERS
.filter((p) => p.settingsVisible !== false)
.slice()
.sort((a, b) => {
const aEntry = cfg.mediaProviders?.[a.id];
const bEntry = cfg.mediaProviders?.[b.id];
const aConfigured = Boolean(aEntry?.apiKey.trim() || aEntry?.baseUrl.trim());
const bConfigured = Boolean(bEntry?.apiKey.trim() || bEntry?.baseUrl.trim());
if (aConfigured !== bConfigured) return aConfigured ? -1 : 1;
if (a.integrated !== b.integrated) return a.integrated ? -1 : 1;
return a.label.localeCompare(b.label);
});
const updateProvider = (
provider: MediaProvider,
patch: { apiKey?: string; baseUrl?: string },
) => {
setCfg((curr) => {
const prev = curr.mediaProviders?.[provider.id] ?? { apiKey: '', baseUrl: '' };
const next = { ...prev, ...patch };
const map = { ...(curr.mediaProviders ?? {}) };
if (!next.apiKey.trim() && !next.baseUrl.trim()) {
delete map[provider.id];
} else {
map[provider.id] = next;
}
return { ...curr, mediaProviders: map };
});
};
return (
<section className="settings-section">
<div className="section-head">
<div>
<h3>{t('settings.mediaProviders')}</h3>
<p className="hint">{t('settings.mediaProvidersHint')}</p>
</div>
</div>
<div className="media-provider-list">
{providers.map((provider) => {
const entry = cfg.mediaProviders?.[provider.id] ?? { apiKey: '', baseUrl: '' };
const configured = Boolean(entry.apiKey.trim() || entry.baseUrl.trim());
const disabled = !provider.integrated;
return (
<div key={provider.id} className={`media-provider-row${provider.integrated ? '' : ' pending'}`}>
<div className="media-provider-head">
<div className="media-provider-meta">
<span className="media-provider-name">{provider.label}</span>
<span className="media-provider-hint">{provider.hint}</span>
</div>
<div className="media-provider-badges">
<span className={`media-provider-badge ${provider.integrated ? 'integrated' : 'unsupported'}`}>
{provider.integrated ? 'Integrated' : 'Unsupported'}
</span>
{configured ? (
<span className="media-provider-badge on">
{t('settings.mediaProviderConfigured')}
</span>
) : null}
</div>
</div>
<div className="media-provider-body">
<input
type="password"
value={entry.apiKey}
placeholder={t('settings.mediaProviderPlaceholder')}
aria-label={`${provider.label} ${t('settings.mediaProviderApiKey')}`}
disabled={disabled}
onChange={(e) => updateProvider(provider, { apiKey: e.target.value })}
/>
<input
value={entry.baseUrl}
placeholder={provider.defaultBaseUrl || t('settings.mediaProviderBaseUrlPlaceholder')}
aria-label={`${provider.label} ${t('settings.mediaProviderBaseUrl')}`}
disabled={disabled}
onChange={(e) => updateProvider(provider, { baseUrl: e.target.value })}
/>
<button
type="button"
className="ghost"
disabled={!configured}
onClick={() => updateProvider(provider, { apiKey: '', baseUrl: '' })}
>
{t('settings.mediaProviderClear')}
</button>
</div>
</div>
);
})}
</div>
</section>
);
}

View file

@ -90,6 +90,16 @@ export const en: Dict = {
'settings.modelCustom': 'Custom (type below)…',
'settings.modelCustomLabel': 'Custom model id',
'settings.modelCustomPlaceholder': 'e.g. anthropic/claude-sonnet-4-6',
'settings.mediaProviders': 'Media providers',
'settings.mediaProvidersHint':
'API keys for image, video and audio generation. Stored locally and synced to the local daemon.',
'settings.mediaProviderApiKey': 'API key',
'settings.mediaProviderBaseUrl': 'Base URL',
'settings.mediaProviderConfigured': 'Configured',
'settings.mediaProviderUnset': 'Unset',
'settings.mediaProviderClear': 'Clear',
'settings.mediaProviderPlaceholder': 'Paste API key',
'settings.mediaProviderBaseUrlPlaceholder': 'Override default base URL',
'entry.tabDesigns': 'Designs',
'entry.tabExamples': 'Examples',
@ -98,6 +108,24 @@ export const en: Dict = {
'entry.openSettingsAria': 'Open settings',
'entry.resizeAria': 'Resize sidebar',
'entry.loadingWorkspace': 'Loading workspace…',
'entry.tabImageTemplates': 'Image prompts',
'entry.tabVideoTemplates': 'Video prompts',
'promptTemplates.searchPlaceholder': 'Search templates…',
'promptTemplates.countLabel': '{n} results',
'promptTemplates.emptyImage': 'No image prompt templates installed yet.',
'promptTemplates.emptyVideo': 'No video prompt templates installed yet.',
'promptTemplates.emptyNoMatch': 'No templates match your search.',
'promptTemplates.attributionFooter': 'Adapted from public prompt libraries. Each card links back to the original author.',
'promptTemplates.openPreviewTitle': 'Open prompt and preview',
'promptTemplates.sourcePrefix': 'Source:',
'promptTemplates.fetchError': 'Could not load this template body.',
'promptTemplates.promptLabel': 'Prompt body',
'promptTemplates.copyPrompt': 'Copy prompt',
'promptTemplates.copyDone': 'Copied!',
'promptTemplates.modelHint': 'Suggested model: {model}',
'promptTemplates.openSource': 'View original',
'promptTemplates.openFullscreen': 'Open fullscreen preview',
'promptTemplates.closeFullscreen': 'Close fullscreen preview',
'newproj.tabPrototype': 'Prototype',
'newproj.tabDeck': 'Slide deck',
@ -106,6 +134,9 @@ export const en: Dict = {
'newproj.titlePrototype': 'New prototype',
'newproj.titleDeck': 'New slide deck',
'newproj.titleTemplate': 'Start from a template',
'newproj.titleImage': 'New image',
'newproj.titleVideo': 'New video',
'newproj.titleAudio': 'New audio',
'newproj.titleOther': 'New project',
'newproj.namePlaceholder': 'Project name',
'newproj.fidelityLabel': 'Fidelity',
@ -148,6 +179,23 @@ export const en: Dict = {
'newproj.dsFootClear': 'Clear',
'newproj.dsBadgeDefault': 'DEFAULT',
'newproj.dsPrimaryFallback': 'Primary',
'newproj.surfaceImage': 'Image',
'newproj.surfaceVideo': 'Video',
'newproj.surfaceAudio': 'Audio',
'newproj.modelLabel': 'Model',
'newproj.aspectLabel': 'Aspect',
'newproj.imageStyleLabel': 'Style notes',
'newproj.imageStylePlaceholder': 'Editorial photo, soft daylight, muted palette',
'newproj.videoLengthLabel': 'Length',
'newproj.videoLengthSeconds': '{n}s',
'newproj.audioKindLabel': 'Audio type',
'newproj.audioKindMusic': 'Music',
'newproj.audioKindSpeech': 'Speech / TTS',
'newproj.audioKindSfx': 'SFX',
'newproj.audioDurationLabel': 'Duration',
'newproj.audioDurationSeconds': '{n}s',
'newproj.voiceLabel': 'Voice',
'newproj.voicePlaceholder': 'Provider voice id, optional',
'designs.subRecent': 'Recent',
'designs.subYours': 'Your designs',
@ -172,6 +220,11 @@ export const en: Dict = {
'designs.deleteAria': 'Delete project {name}',
'examples.typeLabel': 'Type',
'examples.surfaceLabel': 'Surface',
'examples.surfaceWeb': 'Web',
'examples.surfaceImage': 'Image',
'examples.surfaceVideo': 'Video',
'examples.surfaceAudio': 'Audio',
'examples.scenarioLabel': 'Scenario',
'examples.modeAll': 'All',
'examples.modePrototypeDesktop': 'Prototypes · Desktop',
@ -208,8 +261,16 @@ export const en: Dict = {
'examples.tagDesignSystem': 'Design system',
'examples.tagMobilePrototype': 'Mobile prototype',
'examples.tagDesktopPrototype': 'Desktop prototype',
'examples.tagImage': 'Image',
'examples.tagVideo': 'Video',
'examples.tagAudio': 'Audio',
'examples.previewLabel': 'Preview',
'ds.surfaceLabel': 'Surface',
'ds.surfaceWeb': 'Web',
'ds.surfaceImage': 'Image',
'ds.surfaceVideo': 'Video',
'ds.surfaceAudio': 'Audio',
'ds.searchPlaceholder': 'Search design systems…',
'ds.emptyNoMatch': 'No design systems match your search.',
'ds.badgeDefault': 'DEFAULT',
@ -390,6 +451,8 @@ export const en: Dict = {
'fileViewer.markdownErrorMeta': 'Preview may be incomplete (generation error).',
'fileViewer.markdownStreamingStatus': 'Streaming… showing partial markdown.',
'fileViewer.markdownErrorStatus': 'Generation error. Showing last available content.',
'fileViewer.videoMeta': 'Video · {size}',
'fileViewer.audioMeta': 'Audio · {size}',
'fileViewer.reload': 'Reload',
'fileViewer.reloadDisk': 'Reload from disk',
'fileViewer.copy': 'Copy',

View file

@ -90,14 +90,43 @@ export const fa: Dict = {
'settings.modelCustom': 'سفارشی (در زیر تایپ کنید)…',
'settings.modelCustomLabel': 'شناسه مدل سفارشی',
'settings.modelCustomPlaceholder': 'مثلاً anthropic/claude-sonnet-4-6',
'settings.mediaProviders': 'ارائه‌دهندگان رسانه',
'settings.mediaProvidersHint':
'کلیدهای API برای تولید تصویر، ویدئو و صدا. به صورت محلی ذخیره و با daemon محلی همگام می‌شود.',
'settings.mediaProviderApiKey': 'کلید API',
'settings.mediaProviderBaseUrl': 'آدرس پایه',
'settings.mediaProviderConfigured': 'پیکربندی شده',
'settings.mediaProviderUnset': 'تنظیم نشده',
'settings.mediaProviderClear': 'پاک کردن',
'settings.mediaProviderPlaceholder': 'کلید API را وارد کنید',
'settings.mediaProviderBaseUrlPlaceholder': 'بازنویسی آدرس پایه پیش‌فرض',
'entry.tabDesigns': 'طرح‌ها',
'entry.tabExamples': 'نمونه‌ها',
'entry.tabDesignSystems': 'سیستم‌های طراحی',
'entry.tabImageTemplates': 'پرامپت‌های تصویر',
'entry.tabVideoTemplates': 'پرامپت‌های ویدئو',
'entry.openSettingsTitle': 'تنظیمات',
'entry.openSettingsAria': 'باز کردن تنظیمات',
'entry.resizeAria': 'تغییر اندازه نوار کناری',
'entry.loadingWorkspace': 'در حال بارگذاری فضای کاری…',
'promptTemplates.searchPlaceholder': 'جستجوی قالب‌ها…',
'promptTemplates.countLabel': '{n} نتیجه',
'promptTemplates.emptyImage': 'هنوز قالب پرامپت تصویر نصب نشده است.',
'promptTemplates.emptyVideo': 'هنوز قالب پرامپت ویدئو نصب نشده است.',
'promptTemplates.emptyNoMatch': 'هیچ قالبی با جستجوی شما مطابقت ندارد.',
'promptTemplates.attributionFooter':
'برگرفته از کتابخانه‌های عمومی پرامپت. هر کارت به نویسنده اصلی لینک دارد.',
'promptTemplates.openPreviewTitle': 'باز کردن پرامپت و پیش‌نمایش',
'promptTemplates.sourcePrefix': 'منبع:',
'promptTemplates.fetchError': 'بارگذاری متن این قالب ممکن نبود.',
'promptTemplates.promptLabel': 'متن پرامپت',
'promptTemplates.copyPrompt': 'کپی پرامپت',
'promptTemplates.copyDone': 'کپی شد!',
'promptTemplates.modelHint': 'مدل پیشنهادی: {model}',
'promptTemplates.openSource': 'دیدن منبع اصلی',
'promptTemplates.openFullscreen': 'باز کردن پیش‌نمایش تمام‌صفحه',
'promptTemplates.closeFullscreen': 'بستن پیش‌نمایش تمام‌صفحه',
'newproj.tabPrototype': 'نمونه اولیه',
'newproj.tabDeck': 'ارائه اسلاید',
@ -106,6 +135,9 @@ export const fa: Dict = {
'newproj.titlePrototype': 'نمونه اولیه جدید',
'newproj.titleDeck': 'ارائه اسلاید جدید',
'newproj.titleTemplate': 'شروع از یک قالب',
'newproj.titleImage': 'تصویر جدید',
'newproj.titleVideo': 'ویدئوی جدید',
'newproj.titleAudio': 'صدای جدید',
'newproj.titleOther': 'پروژه جدید',
'newproj.namePlaceholder': 'نام پروژه',
'newproj.fidelityLabel': 'دقت',
@ -138,6 +170,23 @@ export const fa: Dict = {
'newproj.dsCategoryFallback': 'سیستم طراحی',
'newproj.dsSearch': 'جستجوی سیستم‌های طراحی…',
'newproj.dsModeAria': 'حالت انتخاب',
'newproj.surfaceImage': 'تصویر',
'newproj.surfaceVideo': 'ویدئو',
'newproj.surfaceAudio': 'صدا',
'newproj.modelLabel': 'مدل',
'newproj.aspectLabel': 'نسبت تصویر',
'newproj.imageStyleLabel': 'یادداشت‌های سبک',
'newproj.imageStylePlaceholder': 'عکس ادیتوریال، نور روز نرم، پالت ملایم',
'newproj.videoLengthLabel': 'طول',
'newproj.videoLengthSeconds': '{n}ث',
'newproj.audioKindLabel': 'نوع صدا',
'newproj.audioKindMusic': 'موسیقی',
'newproj.audioKindSpeech': 'گفتار / TTS',
'newproj.audioKindSfx': 'افکت صوتی',
'newproj.audioDurationLabel': 'مدت',
'newproj.audioDurationSeconds': '{n}ث',
'newproj.voiceLabel': 'صدا',
'newproj.voicePlaceholder': 'شناسه صدای ارائه‌دهنده، اختیاری',
'newproj.dsModeSingle': 'تکی',
'newproj.dsModeMulti': 'چندگانه',
'newproj.dsNoneTitle': 'هیچ — آزاد',
@ -172,6 +221,11 @@ export const fa: Dict = {
'designs.deleteAria': 'حذف پروژه {name}',
'examples.typeLabel': 'نوع',
'examples.surfaceLabel': 'سطح',
'examples.surfaceWeb': 'وب',
'examples.surfaceImage': 'تصویر',
'examples.surfaceVideo': 'ویدئو',
'examples.surfaceAudio': 'صدا',
'examples.scenarioLabel': 'سناریو',
'examples.modeAll': 'همه',
'examples.modePrototypeDesktop': 'نمونه اولیه · دسکتاپ',
@ -208,8 +262,16 @@ export const fa: Dict = {
'examples.tagDesignSystem': 'سیستم طراحی',
'examples.tagMobilePrototype': 'نمونه اولیه موبایل',
'examples.tagDesktopPrototype': 'نمونه اولیه دسکتاپ',
'examples.tagImage': 'تصویر',
'examples.tagVideo': 'ویدئو',
'examples.tagAudio': 'صدا',
'examples.previewLabel': 'پیش‌نمایش',
'ds.surfaceLabel': 'سطح',
'ds.surfaceWeb': 'وب',
'ds.surfaceImage': 'تصویر',
'ds.surfaceVideo': 'ویدئو',
'ds.surfaceAudio': 'صدا',
'ds.searchPlaceholder': 'جستجوی سیستم‌های طراحی…',
'ds.emptyNoMatch': 'هیچ سیستم طراحی با جستجوی شما مطابقت ندارد.',
'ds.badgeDefault': 'پیش‌فرض',
@ -390,6 +452,8 @@ export const fa: Dict = {
'fileViewer.open': 'باز کردن',
'fileViewer.imageMeta': 'تصویر · {size}',
'fileViewer.sketchMeta': 'طرح · {size}',
'fileViewer.videoMeta': 'ویدئو · {size}',
'fileViewer.audioMeta': 'صدا · {size}',
'fileViewer.reload': 'بارگذاری مجدد',
'fileViewer.reloadDisk': 'بارگذاری مجدد از دیسک',
'fileViewer.copy': 'کپی',

View file

@ -90,6 +90,15 @@ export const ptBR: Dict = {
'settings.modelCustom': 'Personalizado (digite abaixo)…',
'settings.modelCustomLabel': 'Id do modelo personalizado',
'settings.modelCustomPlaceholder': 'ex.: anthropic/claude-sonnet-4-6',
'settings.mediaProviders': 'Provedores de mídia',
'settings.mediaProvidersHint': 'Chaves de API para geração de imagem, vídeo e áudio. Salvas localmente e sincronizadas com o daemon local.',
'settings.mediaProviderApiKey': 'API key',
'settings.mediaProviderBaseUrl': 'Base URL',
'settings.mediaProviderConfigured': 'Configurado',
'settings.mediaProviderUnset': 'Não configurado',
'settings.mediaProviderClear': 'Limpar',
'settings.mediaProviderPlaceholder': 'Cole a API key',
'settings.mediaProviderBaseUrlPlaceholder': 'Sobrescrever Base URL padrão',
'entry.tabDesigns': 'Designs',
'entry.tabExamples': 'Exemplos',
@ -98,6 +107,24 @@ export const ptBR: Dict = {
'entry.openSettingsAria': 'Abrir configurações',
'entry.resizeAria': 'Redimensionar barra lateral',
'entry.loadingWorkspace': 'Carregando área de trabalho…',
'entry.tabImageTemplates': 'Prompts de imagem',
'entry.tabVideoTemplates': 'Prompts de vídeo',
'promptTemplates.searchPlaceholder': 'Buscar templates…',
'promptTemplates.countLabel': '{n} resultados',
'promptTemplates.emptyImage': 'Nenhum template de prompt de imagem instalado.',
'promptTemplates.emptyVideo': 'Nenhum template de prompt de vídeo instalado.',
'promptTemplates.emptyNoMatch': 'Nenhum template corresponde à busca.',
'promptTemplates.attributionFooter': 'Adaptado de bibliotecas públicas de prompts. Cada card aponta para o autor original.',
'promptTemplates.openPreviewTitle': 'Abrir prompt e prévia',
'promptTemplates.sourcePrefix': 'Fonte:',
'promptTemplates.fetchError': 'Não foi possível carregar o corpo deste template.',
'promptTemplates.promptLabel': 'Corpo do prompt',
'promptTemplates.copyPrompt': 'Copiar prompt',
'promptTemplates.copyDone': 'Copiado!',
'promptTemplates.modelHint': 'Modelo sugerido: {model}',
'promptTemplates.openSource': 'Ver original',
'promptTemplates.openFullscreen': 'Abrir prévia em tela cheia',
'promptTemplates.closeFullscreen': 'Fechar prévia em tela cheia',
'newproj.tabPrototype': 'Protótipo',
'newproj.tabDeck': 'Apresentação',
@ -106,6 +133,9 @@ export const ptBR: Dict = {
'newproj.titlePrototype': 'Novo protótipo',
'newproj.titleDeck': 'Nova apresentação',
'newproj.titleTemplate': 'Começar de um template',
'newproj.titleImage': 'Nova imagem',
'newproj.titleVideo': 'Novo vídeo',
'newproj.titleAudio': 'Novo áudio',
'newproj.titleOther': 'Novo projeto',
'newproj.namePlaceholder': 'Nome do projeto',
'newproj.fidelityLabel': 'Fidelidade',
@ -148,6 +178,23 @@ export const ptBR: Dict = {
'newproj.dsFootClear': 'Limpar',
'newproj.dsBadgeDefault': 'PADRÃO',
'newproj.dsPrimaryFallback': 'Principal',
'newproj.surfaceImage': 'Imagem',
'newproj.surfaceVideo': 'Vídeo',
'newproj.surfaceAudio': 'Áudio',
'newproj.modelLabel': 'Modelo',
'newproj.aspectLabel': 'Proporção',
'newproj.imageStyleLabel': 'Notas de estilo',
'newproj.imageStylePlaceholder': 'Foto editorial, luz suave, paleta contida',
'newproj.videoLengthLabel': 'Duração',
'newproj.videoLengthSeconds': '{n}s',
'newproj.audioKindLabel': 'Tipo de áudio',
'newproj.audioKindMusic': 'Música',
'newproj.audioKindSpeech': 'Voz / TTS',
'newproj.audioKindSfx': 'Efeito sonoro',
'newproj.audioDurationLabel': 'Duração',
'newproj.audioDurationSeconds': '{n}s',
'newproj.voiceLabel': 'Voz',
'newproj.voicePlaceholder': 'Voice id do provedor, opcional',
'designs.subRecent': 'Recentes',
'designs.subYours': 'Seus designs',
@ -172,6 +219,11 @@ export const ptBR: Dict = {
'designs.deleteAria': 'Excluir projeto {name}',
'examples.typeLabel': 'Tipo',
'examples.surfaceLabel': 'Superfície',
'examples.surfaceWeb': 'Web',
'examples.surfaceImage': 'Imagem',
'examples.surfaceVideo': 'Vídeo',
'examples.surfaceAudio': 'Áudio',
'examples.scenarioLabel': 'Cenário',
'examples.modeAll': 'Todos',
'examples.modePrototypeDesktop': 'Protótipos · Desktop',
@ -208,8 +260,16 @@ export const ptBR: Dict = {
'examples.tagDesignSystem': 'Sistema de design',
'examples.tagMobilePrototype': 'Protótipo mobile',
'examples.tagDesktopPrototype': 'Protótipo desktop',
'examples.tagImage': 'Imagem',
'examples.tagVideo': 'Vídeo',
'examples.tagAudio': 'Áudio',
'examples.previewLabel': 'Prévia',
'ds.surfaceLabel': 'Superfície',
'ds.surfaceWeb': 'Web',
'ds.surfaceImage': 'Imagem',
'ds.surfaceVideo': 'Vídeo',
'ds.surfaceAudio': 'Áudio',
'ds.searchPlaceholder': 'Buscar sistemas de design…',
'ds.emptyNoMatch': 'Nenhum sistema de design corresponde à sua busca.',
'ds.badgeDefault': 'PADRÃO',
@ -390,6 +450,8 @@ export const ptBR: Dict = {
'fileViewer.markdownErrorMeta': 'A prévia pode estar incompleta (erro de geração).',
'fileViewer.markdownStreamingStatus': 'Streaming… mostrando markdown parcial.',
'fileViewer.markdownErrorStatus': 'Erro de geração. Mostrando o último conteúdo disponível.',
'fileViewer.videoMeta': 'Vídeo · {size}',
'fileViewer.audioMeta': 'Áudio · {size}',
'fileViewer.reload': 'Recarregar',
'fileViewer.reloadDisk': 'Recarregar do disco',
'fileViewer.copy': 'Copiar',

View file

@ -90,6 +90,15 @@ export const ru: Dict = {
'settings.modelCustom': 'Пользовательская (введите ниже)…',
'settings.modelCustomLabel': 'Пользовательский ID модели',
'settings.modelCustomPlaceholder': 'например, anthropic/claude-sonnet-4-6',
'settings.mediaProviders': 'Медиа-провайдеры',
'settings.mediaProvidersHint': 'API-ключи для генерации изображений, видео и аудио. Хранятся локально и синхронизируются с локальным демоном.',
'settings.mediaProviderApiKey': 'API key',
'settings.mediaProviderBaseUrl': 'Base URL',
'settings.mediaProviderConfigured': 'Настроено',
'settings.mediaProviderUnset': 'Не настроено',
'settings.mediaProviderClear': 'Очистить',
'settings.mediaProviderPlaceholder': 'Вставьте API key',
'settings.mediaProviderBaseUrlPlaceholder': 'Переопределить Base URL',
'entry.tabDesigns': 'Дизайны',
'entry.tabExamples': 'Примеры',
@ -98,6 +107,24 @@ export const ru: Dict = {
'entry.openSettingsAria': 'Открыть настройки',
'entry.resizeAria': 'Изменить размер боковой панели',
'entry.loadingWorkspace': 'Загрузка рабочего пространства…',
'entry.tabImageTemplates': 'Промпты изображений',
'entry.tabVideoTemplates': 'Промпты видео',
'promptTemplates.searchPlaceholder': 'Поиск шаблонов…',
'promptTemplates.countLabel': '{n} результатов',
'promptTemplates.emptyImage': 'Шаблоны промптов изображений не установлены.',
'promptTemplates.emptyVideo': 'Шаблоны промптов видео не установлены.',
'promptTemplates.emptyNoMatch': 'Нет шаблонов, соответствующих поиску.',
'promptTemplates.attributionFooter': 'Адаптировано из публичных библиотек промптов. Каждая карточка ссылается на исходного автора.',
'promptTemplates.openPreviewTitle': 'Открыть промпт и предпросмотр',
'promptTemplates.sourcePrefix': 'Источник:',
'promptTemplates.fetchError': 'Не удалось загрузить текст шаблона.',
'promptTemplates.promptLabel': 'Текст промпта',
'promptTemplates.copyPrompt': 'Копировать промпт',
'promptTemplates.copyDone': 'Скопировано!',
'promptTemplates.modelHint': 'Рекомендуемая модель: {model}',
'promptTemplates.openSource': 'Открыть оригинал',
'promptTemplates.openFullscreen': 'Открыть полноэкранный предпросмотр',
'promptTemplates.closeFullscreen': 'Закрыть полноэкранный предпросмотр',
'newproj.tabPrototype': 'Прототип',
'newproj.tabDeck': 'Презентация',
@ -106,6 +133,9 @@ export const ru: Dict = {
'newproj.titlePrototype': 'Новый прототип',
'newproj.titleDeck': 'Новая презентация',
'newproj.titleTemplate': 'Начать с шаблона',
'newproj.titleImage': 'Новое изображение',
'newproj.titleVideo': 'Новое видео',
'newproj.titleAudio': 'Новое аудио',
'newproj.titleOther': 'Новый проект',
'newproj.namePlaceholder': 'Название проекта',
'newproj.fidelityLabel': 'Детализация',
@ -148,6 +178,23 @@ export const ru: Dict = {
'newproj.dsFootClear': 'Очистить',
'newproj.dsBadgeDefault': 'ПО УМОЛЧАНИЮ',
'newproj.dsPrimaryFallback': 'Основной',
'newproj.surfaceImage': 'Изображение',
'newproj.surfaceVideo': 'Видео',
'newproj.surfaceAudio': 'Аудио',
'newproj.modelLabel': 'Модель',
'newproj.aspectLabel': 'Формат',
'newproj.imageStyleLabel': 'Заметки о стиле',
'newproj.imageStylePlaceholder': 'Редакционная фотография, мягкий свет, приглушенная палитра',
'newproj.videoLengthLabel': 'Длина',
'newproj.videoLengthSeconds': '{n}с',
'newproj.audioKindLabel': 'Тип аудио',
'newproj.audioKindMusic': 'Музыка',
'newproj.audioKindSpeech': 'Речь / TTS',
'newproj.audioKindSfx': 'SFX',
'newproj.audioDurationLabel': 'Длительность',
'newproj.audioDurationSeconds': '{n}с',
'newproj.voiceLabel': 'Голос',
'newproj.voicePlaceholder': 'Voice id провайдера, опционально',
'designs.subRecent': 'Недавние',
'designs.subYours': 'Ваши дизайны',
@ -172,6 +219,11 @@ export const ru: Dict = {
'designs.deleteAria': 'Удалить проект {name}',
'examples.typeLabel': 'Тип',
'examples.surfaceLabel': 'Поверхность',
'examples.surfaceWeb': 'Web',
'examples.surfaceImage': 'Изображение',
'examples.surfaceVideo': 'Видео',
'examples.surfaceAudio': 'Аудио',
'examples.scenarioLabel': 'Сценарий',
'examples.modeAll': 'Все',
'examples.modePrototypeDesktop': 'Прототипы · Десктоп',
@ -208,8 +260,16 @@ export const ru: Dict = {
'examples.tagDesignSystem': 'Дизайн-система',
'examples.tagMobilePrototype': 'Мобильный прототип',
'examples.tagDesktopPrototype': 'Десктопный прототип',
'examples.tagImage': 'Изображение',
'examples.tagVideo': 'Видео',
'examples.tagAudio': 'Аудио',
'examples.previewLabel': 'Предпросмотр',
'ds.surfaceLabel': 'Поверхность',
'ds.surfaceWeb': 'Web',
'ds.surfaceImage': 'Изображение',
'ds.surfaceVideo': 'Видео',
'ds.surfaceAudio': 'Аудио',
'ds.searchPlaceholder': 'Поиск дизайн-систем…',
'ds.emptyNoMatch': 'Нет дизайн-систем, соответствующих вашему поиску.',
'ds.badgeDefault': 'ПО УМОЛЧАНИЮ',
@ -390,6 +450,8 @@ export const ru: Dict = {
'fileViewer.open': 'Открыть',
'fileViewer.imageMeta': 'Изображение · {size}',
'fileViewer.sketchMeta': 'Эскиз · {size}',
'fileViewer.videoMeta': 'Видео · {size}',
'fileViewer.audioMeta': 'Аудио · {size}',
'fileViewer.reload': 'Перезагрузить',
'fileViewer.reloadDisk': 'Перезагрузить с диска',
'fileViewer.copy': 'Копировать',

View file

@ -89,6 +89,15 @@ export const zhCN: Dict = {
'settings.modelCustom': '自定义(在下方填写)…',
'settings.modelCustomLabel': '自定义模型 id',
'settings.modelCustomPlaceholder': '例如 anthropic/claude-sonnet-4-6',
'settings.mediaProviders': '媒体生成提供商',
'settings.mediaProvidersHint': '图片、视频、音频生成的 API key。存于本机并同步到本地守护进程。',
'settings.mediaProviderApiKey': 'API key',
'settings.mediaProviderBaseUrl': 'Base URL',
'settings.mediaProviderConfigured': '已配置',
'settings.mediaProviderUnset': '未配置',
'settings.mediaProviderClear': '清除',
'settings.mediaProviderPlaceholder': '粘贴 API key',
'settings.mediaProviderBaseUrlPlaceholder': '覆盖默认 Base URL',
'entry.tabDesigns': '我的设计',
'entry.tabExamples': '示例',
@ -97,6 +106,24 @@ export const zhCN: Dict = {
'entry.openSettingsAria': '打开设置',
'entry.resizeAria': '调整侧边栏宽度',
'entry.loadingWorkspace': '正在加载工作区…',
'entry.tabImageTemplates': '图片 Prompt',
'entry.tabVideoTemplates': '视频 Prompt',
'promptTemplates.searchPlaceholder': '搜索模板…',
'promptTemplates.countLabel': '{n} 个结果',
'promptTemplates.emptyImage': '还没有安装图片 Prompt 模板。',
'promptTemplates.emptyVideo': '还没有安装视频 Prompt 模板。',
'promptTemplates.emptyNoMatch': '没有匹配的模板。',
'promptTemplates.attributionFooter': '改编自公开 Prompt 库,每张卡片都链接到原作者。',
'promptTemplates.openPreviewTitle': '打开 Prompt 与预览',
'promptTemplates.sourcePrefix': '来源:',
'promptTemplates.fetchError': '无法加载此模板正文。',
'promptTemplates.promptLabel': 'Prompt 正文',
'promptTemplates.copyPrompt': '复制 Prompt',
'promptTemplates.copyDone': '已复制!',
'promptTemplates.modelHint': '建议模型:{model}',
'promptTemplates.openSource': '查看原始来源',
'promptTemplates.openFullscreen': '打开全屏预览',
'promptTemplates.closeFullscreen': '关闭全屏预览',
'newproj.tabPrototype': '原型',
'newproj.tabDeck': '幻灯片',
@ -105,6 +132,9 @@ export const zhCN: Dict = {
'newproj.titlePrototype': '新建原型',
'newproj.titleDeck': '新建幻灯片',
'newproj.titleTemplate': '从模板开始',
'newproj.titleImage': '新建图片',
'newproj.titleVideo': '新建视频',
'newproj.titleAudio': '新建音频',
'newproj.titleOther': '新建项目',
'newproj.namePlaceholder': '项目名称',
'newproj.fidelityLabel': '精度',
@ -145,6 +175,23 @@ export const zhCN: Dict = {
'newproj.dsFootClear': '清除',
'newproj.dsBadgeDefault': '默认',
'newproj.dsPrimaryFallback': '主体系',
'newproj.surfaceImage': '图片',
'newproj.surfaceVideo': '视频',
'newproj.surfaceAudio': '音频',
'newproj.modelLabel': '模型',
'newproj.aspectLabel': '比例',
'newproj.imageStyleLabel': '风格备注',
'newproj.imageStylePlaceholder': '编辑摄影、柔和日光、低饱和配色',
'newproj.videoLengthLabel': '时长',
'newproj.videoLengthSeconds': '{n} 秒',
'newproj.audioKindLabel': '音频类型',
'newproj.audioKindMusic': '音乐',
'newproj.audioKindSpeech': '配音 / TTS',
'newproj.audioKindSfx': '音效',
'newproj.audioDurationLabel': '时长',
'newproj.audioDurationSeconds': '{n} 秒',
'newproj.voiceLabel': '声音',
'newproj.voicePlaceholder': '提供商 voice id可选',
'designs.subRecent': '最近',
'designs.subYours': '我的设计',
@ -169,6 +216,11 @@ export const zhCN: Dict = {
'designs.deleteAria': '删除项目 {name}',
'examples.typeLabel': '类型',
'examples.surfaceLabel': '类型',
'examples.surfaceWeb': '网页',
'examples.surfaceImage': '图片',
'examples.surfaceVideo': '视频',
'examples.surfaceAudio': '音频',
'examples.scenarioLabel': '场景',
'examples.modeAll': '全部',
'examples.modePrototypeDesktop': '原型 · 桌面端',
@ -205,8 +257,16 @@ export const zhCN: Dict = {
'examples.tagDesignSystem': '设计体系',
'examples.tagMobilePrototype': '移动端原型',
'examples.tagDesktopPrototype': '桌面端原型',
'examples.tagImage': '图片',
'examples.tagVideo': '视频',
'examples.tagAudio': '音频',
'examples.previewLabel': '预览',
'ds.surfaceLabel': '类型',
'ds.surfaceWeb': '网页',
'ds.surfaceImage': '图片',
'ds.surfaceVideo': '视频',
'ds.surfaceAudio': '音频',
'ds.searchPlaceholder': '搜索设计体系…',
'ds.emptyNoMatch': '没有匹配的设计体系。',
'ds.badgeDefault': '默认',
@ -381,6 +441,8 @@ export const zhCN: Dict = {
'fileViewer.markdownErrorMeta': '预览可能不完整(生成错误)。',
'fileViewer.markdownStreamingStatus': '正在流式生成…显示部分 Markdown。',
'fileViewer.markdownErrorStatus': '生成错误。正在显示最后可用内容。',
'fileViewer.videoMeta': '视频 · {size}',
'fileViewer.audioMeta': '音频 · {size}',
'fileViewer.reload': '重新加载',
'fileViewer.reloadDisk': '从磁盘重新加载',
'fileViewer.copy': '复制',

View file

@ -89,6 +89,15 @@ export const zhTW: Dict = {
'settings.modelCustom': '自訂(在下方填寫)…',
'settings.modelCustomLabel': '自訂模型 id',
'settings.modelCustomPlaceholder': '例如 anthropic/claude-sonnet-4-6',
'settings.mediaProviders': '媒體生成提供商',
'settings.mediaProvidersHint': '圖片、影片、音訊生成的 API key。存於本機並同步到本地守護程序。',
'settings.mediaProviderApiKey': 'API key',
'settings.mediaProviderBaseUrl': 'Base URL',
'settings.mediaProviderConfigured': '已設定',
'settings.mediaProviderUnset': '未設定',
'settings.mediaProviderClear': '清除',
'settings.mediaProviderPlaceholder': '貼上 API key',
'settings.mediaProviderBaseUrlPlaceholder': '覆蓋預設 Base URL',
'entry.tabDesigns': '我的設計',
'entry.tabExamples': '範例',
@ -97,6 +106,24 @@ export const zhTW: Dict = {
'entry.openSettingsAria': '開啟設定',
'entry.resizeAria': '調整側邊欄寬度',
'entry.loadingWorkspace': '正在載入工作區…',
'entry.tabImageTemplates': '圖片 Prompt',
'entry.tabVideoTemplates': '影片 Prompt',
'promptTemplates.searchPlaceholder': '搜尋範本…',
'promptTemplates.countLabel': '{n} 個結果',
'promptTemplates.emptyImage': '還沒有安裝圖片 Prompt 範本。',
'promptTemplates.emptyVideo': '還沒有安裝影片 Prompt 範本。',
'promptTemplates.emptyNoMatch': '沒有符合的範本。',
'promptTemplates.attributionFooter': '改編自公開 Prompt 庫,每張卡片都連結到原作者。',
'promptTemplates.openPreviewTitle': '開啟 Prompt 與預覽',
'promptTemplates.sourcePrefix': '來源:',
'promptTemplates.fetchError': '無法載入此範本文字。',
'promptTemplates.promptLabel': 'Prompt 內容',
'promptTemplates.copyPrompt': '複製 Prompt',
'promptTemplates.copyDone': '已複製!',
'promptTemplates.modelHint': '建議模型:{model}',
'promptTemplates.openSource': '查看原始來源',
'promptTemplates.openFullscreen': '開啟全螢幕預覽',
'promptTemplates.closeFullscreen': '關閉全螢幕預覽',
'newproj.tabPrototype': '原型',
'newproj.tabDeck': '投影片',
@ -105,6 +132,9 @@ export const zhTW: Dict = {
'newproj.titlePrototype': '新建原型',
'newproj.titleDeck': '新建投影片',
'newproj.titleTemplate': '從範本開始',
'newproj.titleImage': '新建圖片',
'newproj.titleVideo': '新建影片',
'newproj.titleAudio': '新建音訊',
'newproj.titleOther': '新建專案',
'newproj.namePlaceholder': '專案名稱',
'newproj.fidelityLabel': '精細度',
@ -145,6 +175,23 @@ export const zhTW: Dict = {
'newproj.dsFootClear': '清除',
'newproj.dsBadgeDefault': '預設',
'newproj.dsPrimaryFallback': '主系統',
'newproj.surfaceImage': '圖片',
'newproj.surfaceVideo': '影片',
'newproj.surfaceAudio': '音訊',
'newproj.modelLabel': '模型',
'newproj.aspectLabel': '比例',
'newproj.imageStyleLabel': '風格備註',
'newproj.imageStylePlaceholder': '編輯攝影、柔和日光、低飽和配色',
'newproj.videoLengthLabel': '時長',
'newproj.videoLengthSeconds': '{n} 秒',
'newproj.audioKindLabel': '音訊類型',
'newproj.audioKindMusic': '音樂',
'newproj.audioKindSpeech': '配音 / TTS',
'newproj.audioKindSfx': '音效',
'newproj.audioDurationLabel': '時長',
'newproj.audioDurationSeconds': '{n} 秒',
'newproj.voiceLabel': '聲音',
'newproj.voicePlaceholder': '提供商 voice id可選',
'designs.subRecent': '最近',
'designs.subYours': '我的設計',
@ -169,6 +216,11 @@ export const zhTW: Dict = {
'designs.deleteAria': '刪除專案 {name}',
'examples.typeLabel': '類型',
'examples.surfaceLabel': '類型',
'examples.surfaceWeb': '網頁',
'examples.surfaceImage': '圖片',
'examples.surfaceVideo': '影片',
'examples.surfaceAudio': '音訊',
'examples.scenarioLabel': '情境',
'examples.modeAll': '全部',
'examples.modePrototypeDesktop': '原型 · 桌面版',
@ -205,8 +257,16 @@ export const zhTW: Dict = {
'examples.tagDesignSystem': '設計系統',
'examples.tagMobilePrototype': '行動版原型',
'examples.tagDesktopPrototype': '桌面版原型',
'examples.tagImage': '圖片',
'examples.tagVideo': '影片',
'examples.tagAudio': '音訊',
'examples.previewLabel': '預覽',
'ds.surfaceLabel': '類型',
'ds.surfaceWeb': '網頁',
'ds.surfaceImage': '圖片',
'ds.surfaceVideo': '影片',
'ds.surfaceAudio': '音訊',
'ds.searchPlaceholder': '搜尋設計系統…',
'ds.emptyNoMatch': '沒有符合的設計系統。',
'ds.badgeDefault': '預設',
@ -381,6 +441,8 @@ export const zhTW: Dict = {
'fileViewer.markdownErrorMeta': '預覽可能不完整(產生錯誤)。',
'fileViewer.markdownStreamingStatus': '正在串流產生…顯示部分 Markdown。',
'fileViewer.markdownErrorStatus': '產生錯誤。正在顯示最後可用內容。',
'fileViewer.videoMeta': '影片 · {size}',
'fileViewer.audioMeta': '音訊 · {size}',
'fileViewer.reload': '重新載入',
'fileViewer.reloadDisk': '從磁碟重新載入',
'fileViewer.copy': '複製',

View file

@ -103,11 +103,22 @@ export interface Dict {
'settings.modelCustom': string;
'settings.modelCustomLabel': string;
'settings.modelCustomPlaceholder': string;
'settings.mediaProviders': string;
'settings.mediaProvidersHint': string;
'settings.mediaProviderApiKey': string;
'settings.mediaProviderBaseUrl': string;
'settings.mediaProviderConfigured': string;
'settings.mediaProviderUnset': string;
'settings.mediaProviderClear': string;
'settings.mediaProviderPlaceholder': string;
'settings.mediaProviderBaseUrlPlaceholder': string;
// Entry view / tabs
'entry.tabDesigns': string;
'entry.tabExamples': string;
'entry.tabDesignSystems': string;
'entry.tabImageTemplates': string;
'entry.tabVideoTemplates': string;
'entry.openSettingsTitle': string;
'entry.openSettingsAria': string;
'entry.resizeAria': string;
@ -121,6 +132,9 @@ export interface Dict {
'newproj.titlePrototype': string;
'newproj.titleDeck': string;
'newproj.titleTemplate': string;
'newproj.titleImage': string;
'newproj.titleVideo': string;
'newproj.titleAudio': string;
'newproj.titleOther': string;
'newproj.namePlaceholder': string;
'newproj.fidelityLabel': string;
@ -160,6 +174,41 @@ export interface Dict {
'newproj.dsFootClear': string;
'newproj.dsBadgeDefault': string;
'newproj.dsPrimaryFallback': string;
'newproj.surfaceImage': string;
'newproj.surfaceVideo': string;
'newproj.surfaceAudio': string;
'newproj.modelLabel': string;
'newproj.aspectLabel': string;
'newproj.imageStyleLabel': string;
'newproj.imageStylePlaceholder': string;
'newproj.videoLengthLabel': string;
'newproj.videoLengthSeconds': string;
'newproj.audioKindLabel': string;
'newproj.audioKindMusic': string;
'newproj.audioKindSpeech': string;
'newproj.audioKindSfx': string;
'newproj.audioDurationLabel': string;
'newproj.audioDurationSeconds': string;
'newproj.voiceLabel': string;
'newproj.voicePlaceholder': string;
// Prompt templates
'promptTemplates.searchPlaceholder': string;
'promptTemplates.countLabel': string;
'promptTemplates.emptyImage': string;
'promptTemplates.emptyVideo': string;
'promptTemplates.emptyNoMatch': string;
'promptTemplates.attributionFooter': string;
'promptTemplates.openPreviewTitle': string;
'promptTemplates.sourcePrefix': string;
'promptTemplates.fetchError': string;
'promptTemplates.promptLabel': string;
'promptTemplates.copyPrompt': string;
'promptTemplates.copyDone': string;
'promptTemplates.modelHint': string;
'promptTemplates.openSource': string;
'promptTemplates.openFullscreen': string;
'promptTemplates.closeFullscreen': string;
// Designs tab
'designs.subRecent': string;
@ -186,6 +235,11 @@ export interface Dict {
// Examples tab
'examples.typeLabel': string;
'examples.surfaceLabel': string;
'examples.surfaceWeb': string;
'examples.surfaceImage': string;
'examples.surfaceVideo': string;
'examples.surfaceAudio': string;
'examples.scenarioLabel': string;
'examples.modeAll': string;
'examples.modePrototypeDesktop': string;
@ -222,9 +276,17 @@ export interface Dict {
'examples.tagDesignSystem': string;
'examples.tagMobilePrototype': string;
'examples.tagDesktopPrototype': string;
'examples.tagImage': string;
'examples.tagVideo': string;
'examples.tagAudio': string;
'examples.previewLabel': string;
// Design systems tab
'ds.surfaceLabel': string;
'ds.surfaceWeb': string;
'ds.surfaceImage': string;
'ds.surfaceVideo': string;
'ds.surfaceAudio': string;
'ds.searchPlaceholder': string;
'ds.emptyNoMatch': string;
'ds.badgeDefault': string;
@ -400,6 +462,8 @@ export interface Dict {
'fileViewer.markdownErrorMeta': string;
'fileViewer.markdownStreamingStatus': string;
'fileViewer.markdownErrorStatus': string;
'fileViewer.videoMeta': string;
'fileViewer.audioMeta': string;
'fileViewer.reload': string;
'fileViewer.reloadDisk': string;
'fileViewer.copy': string;

View file

@ -747,9 +747,9 @@ code {
.modal-settings {
--modal-padding: 24px;
width: 600px;
padding: var(--modal-padding);
gap: 18px;
width: min(920px, calc(100vw - 48px));
padding: 0;
gap: 0;
max-height: calc(100vh - 64px);
}
@media (max-height: 600px) {
@ -759,13 +759,20 @@ code {
overflow-y: auto;
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: 240px minmax(0, 1fr);
gap: 0;
margin: 0;
padding: 0;
border-top: 1px solid var(--border);
}
.modal-head {
display: flex;
flex-direction: column;
gap: 18px;
margin: 0 calc(var(--modal-padding) * -1);
padding: 0 var(--modal-padding);
gap: 4px;
flex-shrink: 0;
padding: var(--modal-padding);
}
.modal-head { display: flex; flex-direction: column; gap: 4px; flex-shrink: 0; }
.modal-head .kicker {
font-size: 11px;
text-transform: uppercase;
@ -790,11 +797,91 @@ code {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 8px;
padding: 12px var(--modal-padding);
border-top: 1px solid var(--border);
margin-top: 4px;
margin-top: 0;
flex-shrink: 0;
}
.settings-sidebar {
display: flex;
flex-direction: column;
gap: 8px;
padding: 22px 12px;
background: var(--bg-subtle);
border-right: 1px solid var(--border);
}
.settings-nav-item {
width: 100%;
border: 1px solid transparent;
border-radius: 12px;
background: transparent;
color: var(--text-muted);
display: grid;
grid-template-columns: 24px minmax(0, 1fr);
gap: 10px;
align-items: center;
padding: 12px;
text-align: left;
cursor: pointer;
}
.settings-nav-item:hover {
background: color-mix(in srgb, var(--bg-panel) 72%, transparent);
color: var(--text);
}
.settings-nav-item.active {
background: var(--bg-panel);
border-color: color-mix(in srgb, var(--accent) 62%, var(--border));
color: var(--text);
box-shadow: var(--shadow-xs);
}
.settings-nav-item svg {
justify-self: center;
}
.settings-nav-item span {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.settings-nav-item strong {
color: currentColor;
font-size: 13px;
font-weight: 650;
}
.settings-nav-item small {
color: var(--text-muted);
font-size: 11px;
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-content {
min-width: 0;
padding: 22px var(--modal-padding);
overflow: auto;
display: flex;
flex-direction: column;
gap: 18px;
}
@media (max-width: 760px) {
.modal-settings {
width: min(560px, calc(100vw - 24px));
}
.modal-settings .modal-body {
grid-template-columns: 1fr;
}
.settings-sidebar {
flex-direction: row;
overflow-x: auto;
padding: 10px 12px;
border-right: 0;
border-bottom: 1px solid var(--border);
}
.settings-nav-item {
min-width: 150px;
}
}
/* Segmented control */
.seg-control {
@ -829,6 +916,78 @@ code {
.seg-btn:disabled { opacity: 0.55; cursor: not-allowed; }
.settings-section { display: flex; flex-direction: column; gap: 12px; }
.media-provider-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.media-provider-row {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.media-provider-row.pending {
background: var(--bg-subtle);
border-style: dashed;
}
.media-provider-head {
display: flex;
justify-content: space-between;
gap: 10px;
}
.media-provider-meta {
display: flex;
flex-direction: column;
gap: 2px;
}
.media-provider-name {
font-size: 13px;
font-weight: 600;
color: var(--text);
}
.media-provider-hint {
font-size: 11px;
color: var(--text-muted);
}
.media-provider-badge {
align-self: flex-start;
font-size: 10px;
font-weight: 600;
padding: 2px 7px;
border-radius: var(--radius-pill);
border: 1px solid var(--border);
}
.media-provider-badges {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 6px;
}
.media-provider-badge.integrated {
color: #137a3d;
background: color-mix(in srgb, #1f9d55 10%, transparent);
border-color: color-mix(in srgb, #1f9d55 28%, var(--border));
}
.media-provider-badge.unsupported {
color: var(--text-soft);
background: var(--bg-subtle);
border-color: var(--border);
}
.media-provider-badge.on {
color: #3155c9;
background: color-mix(in srgb, #4169e1 10%, transparent);
border-color: color-mix(in srgb, #4169e1 28%, var(--border));
}
.media-provider-body {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
gap: 6px;
}
.section-head {
display: flex;
justify-content: space-between;
@ -1178,14 +1337,78 @@ code {
flex-direction: column;
box-shadow: var(--shadow-xs);
}
.newproj-tabs-shell {
position: relative;
border-bottom: 1px solid var(--border);
overflow: hidden;
}
.newproj-tabs {
display: flex;
border-bottom: 1px solid var(--border);
padding: 4px 4px 0;
gap: 2px;
overflow-x: auto;
overflow-y: hidden;
scroll-behavior: smooth;
scrollbar-width: none;
}
.newproj-tabs::-webkit-scrollbar { display: none; }
.newproj-tabs-shell::before,
.newproj-tabs-shell::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
width: 28px;
z-index: 1;
pointer-events: none;
opacity: 0;
transition: opacity 120ms ease;
}
.newproj-tabs-shell::before {
left: 0;
background: linear-gradient(90deg, var(--bg-panel), transparent);
}
.newproj-tabs-shell::after {
right: 0;
background: linear-gradient(270deg, var(--bg-panel), transparent);
}
.newproj-tabs-shell.can-left::before,
.newproj-tabs-shell.can-right::after {
opacity: 1;
}
.newproj-tabs-arrow {
position: absolute;
top: 50%;
z-index: 2;
width: 28px;
height: 28px;
transform: translateY(-50%);
border: 1px solid var(--border);
border-radius: 999px;
background: var(--bg-panel);
color: var(--text-strong);
box-shadow: var(--shadow-xs);
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
transition: opacity 120ms ease, transform 120ms ease;
}
.newproj-tabs-arrow:hover { border-color: var(--border-strong); background: var(--bg-subtle); }
.newproj-tabs-arrow svg {
display: block;
flex: none;
}
.newproj-tabs-arrow.left { left: 6px; }
.newproj-tabs-arrow.right { right: 6px; }
.newproj-tabs-arrow.hidden {
opacity: 0;
pointer-events: none;
transform: translateY(-50%) scale(0.92);
}
.newproj-tab {
flex: 1;
flex: 0 0 auto;
min-width: max-content;
padding: 10px 6px;
border: none;
background: transparent;
@ -1214,6 +1437,159 @@ code {
color: var(--text-muted);
font-weight: 500;
}
.newproj-media-options {
display: flex;
flex-direction: column;
gap: 14px;
}
.newproj-media-field,
.newproj-media-options .newproj-label {
display: flex;
flex-direction: column;
gap: 6px;
}
.newproj-model-groups {
display: flex;
flex-direction: column;
gap: 12px;
}
.newproj-model-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.newproj-provider-row {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-muted);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.11em;
text-transform: uppercase;
}
.newproj-provider-badge {
border-radius: 999px;
padding: 2px 8px;
font-size: 10px;
letter-spacing: 0.08em;
}
.newproj-provider-badge.configured {
border: 1px solid color-mix(in srgb, #4169e1 24%, transparent);
background: color-mix(in srgb, #4169e1 10%, transparent);
color: #3155c9;
}
.newproj-provider-badge.integrated {
border: 1px solid color-mix(in srgb, var(--accent) 24%, transparent);
background: color-mix(in srgb, var(--accent) 10%, transparent);
color: color-mix(in srgb, var(--accent) 78%, var(--text-strong));
}
.newproj-provider-badge.unsupported {
border: 1px solid var(--border);
background: var(--bg-subtle);
color: var(--text-muted);
}
.newproj-model-grid,
.newproj-option-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.newproj-option-grid.aspect-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.newproj-option-grid.compact {
grid-template-columns: repeat(auto-fit, minmax(92px, 1fr));
}
.newproj-card {
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg-panel);
color: var(--text-strong);
box-shadow: var(--shadow-xs);
cursor: pointer;
text-align: left;
transition: border-color 140ms ease, background 140ms ease, box-shadow 140ms ease;
}
.newproj-card:hover {
border-color: var(--border-strong);
background: var(--bg-subtle);
}
.newproj-card.active {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 9%, var(--bg-panel));
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 26%, transparent);
}
.newproj-model-card {
min-height: 74px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 5px;
}
.newproj-model-name {
font-family: var(--font-mono);
font-size: 14px;
font-weight: 700;
line-height: 1.2;
}
.newproj-model-hint {
color: var(--text-muted);
font-size: 12px;
line-height: 1.35;
}
.newproj-option-card {
min-height: 62px;
padding: 10px;
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
text-align: center;
color: var(--text-muted);
font-size: 12px;
font-weight: 600;
}
.newproj-option-card small {
color: var(--text-muted);
font-size: 11px;
}
.aspect-copy {
display: flex;
flex-direction: column;
gap: 2px;
align-items: center;
line-height: 1.15;
}
.aspect-copy strong {
color: var(--text-muted);
font-size: 13px;
font-weight: 650;
}
.aspect-copy small {
color: var(--text-muted);
font-size: 12px;
}
.aspect-glyph {
flex: none;
width: 34px;
height: 24px;
border-radius: 4px;
background: var(--bg-subtle);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--border) 80%, transparent);
}
.aspect-1-1 { width: 26px; height: 26px; }
.aspect-16-9 { width: 36px; height: 20px; }
.aspect-9-16 { width: 20px; height: 36px; }
.aspect-4-3 { width: 32px; height: 24px; }
.aspect-3-4 { width: 24px; height: 32px; }
@media (max-width: 560px) {
.newproj-model-grid,
.newproj-option-grid,
.newproj-option-grid.aspect-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.newproj-skills {
display: flex;
flex-direction: column;
@ -1245,6 +1621,44 @@ code {
font-size: 12px; color: var(--text-muted);
font-style: italic; padding: 8px 0;
}
.video-body,
.audio-body {
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: var(--bg-subtle);
flex: 1;
min-height: 0;
}
.video-body video {
max-width: 100%;
max-height: 100%;
border-radius: var(--radius-sm);
background: #000;
}
.audio-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
width: min(100%, 480px);
padding: 28px 32px;
color: var(--text-muted);
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.audio-card-name {
font-size: 13px;
font-weight: 500;
color: var(--text);
text-align: center;
word-break: break-word;
}
.audio-card audio {
width: 100%;
}
.newproj-create {
display: inline-flex;
align-items: center;
@ -2702,9 +3116,16 @@ code {
position: relative;
}
.df-preview-thumb iframe,
.df-preview-thumb img {
.df-preview-thumb img,
.df-preview-thumb video {
width: 100%; height: 100%; border: none; background: white; object-fit: cover; display: block;
}
.df-preview-thumb audio {
width: calc(100% - 24px);
position: absolute;
left: 12px;
bottom: 12px;
}
.df-preview-meta {
padding: 0 16px 16px;
display: flex;
@ -5140,3 +5561,344 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
background: var(--bg-panel);
outline: none;
}
/* ============================================================
Prompt template gallery
============================================================ */
.prompt-templates-panel { display: flex; flex-direction: column; gap: 16px; }
.prompt-templates-count {
margin-left: auto;
color: var(--text-muted);
font-size: 12px;
}
.prompt-templates-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 14px;
}
.prompt-template-card {
display: flex;
flex-direction: column;
gap: 10px;
padding: 0;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
text-align: left;
cursor: pointer;
transition: border-color 0.15s ease, transform 0.15s ease;
}
.prompt-template-card:hover {
border-color: var(--border-strong);
transform: translateY(-1px);
}
.prompt-template-thumb {
position: relative;
width: 100%;
aspect-ratio: 4 / 3;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-subtle);
overflow: hidden;
}
.prompt-template-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.prompt-template-thumb-fallback {
color: var(--text-faint);
}
.prompt-template-thumb-play {
position: absolute;
right: 8px;
bottom: 8px;
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-size: 10px;
width: 22px;
height: 22px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
letter-spacing: 0;
}
.prompt-template-meta {
display: flex;
flex-direction: column;
gap: 4px;
padding: 0 12px 12px;
}
.prompt-template-title {
font-size: 13px;
font-weight: 600;
color: var(--text);
line-height: 1.3;
}
.prompt-template-summary {
font-size: 12px;
color: var(--text-muted);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.prompt-template-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 4px;
}
.prompt-template-category {
font-size: 10px;
font-weight: 600;
padding: 2px 6px;
border-radius: 4px;
background: var(--accent-tint);
color: var(--accent);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.prompt-template-tag,
.prompt-template-model {
font-size: 10px;
padding: 2px 6px;
border-radius: 4px;
background: var(--bg-subtle);
color: var(--text-muted);
}
.prompt-template-model {
font-weight: 500;
}
.prompt-template-source {
font-size: 10px;
color: var(--text-faint);
margin-top: 6px;
}
.prompt-templates-footer {
font-size: 11px;
color: var(--text-faint);
padding-top: 12px;
border-top: 1px dashed var(--border);
text-align: center;
}
.prompt-template-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(15, 15, 18, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1500;
padding: 24px;
}
.prompt-template-modal {
background: var(--bg-panel);
border-radius: 14px;
width: min(820px, 100%);
max-height: 90vh;
display: flex;
flex-direction: column;
border: 1px solid var(--border);
overflow: hidden;
box-shadow: 0 32px 80px rgba(0, 0, 0, 0.25);
}
.prompt-template-modal-head {
display: flex;
gap: 12px;
align-items: flex-start;
padding: 18px 18px 0;
}
.prompt-template-modal-titles { flex: 1; min-width: 0; }
.prompt-template-modal-titles h2 {
font-size: 17px;
margin: 0 0 6px 0;
color: var(--text);
}
.prompt-template-modal-titles p {
margin: 0;
font-size: 13px;
color: var(--text-muted);
line-height: 1.45;
}
.prompt-template-modal-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 10px 18px 0;
}
.prompt-template-modal-body {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px 18px;
overflow: auto;
}
.prompt-template-modal-asset {
position: relative;
display: flex;
align-items: center;
justify-content: center;
background: #000;
border-radius: 8px;
overflow: hidden;
max-height: 360px;
}
.prompt-template-modal-asset img,
.prompt-template-modal-asset video {
max-width: 100%;
max-height: 360px;
display: block;
}
.prompt-template-modal-asset-image-trigger {
display: block;
padding: 0;
margin: 0;
border: 0;
background: transparent;
cursor: zoom-in;
max-height: 360px;
line-height: 0;
}
.prompt-template-modal-asset-image-trigger img {
transition: transform 200ms ease;
}
.prompt-template-modal-asset-image-trigger:hover img,
.prompt-template-modal-asset-image-trigger:focus-visible img {
transform: scale(1.02);
}
.prompt-template-modal-asset-expand {
position: absolute;
top: 10px;
right: 10px;
z-index: 1;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border: 0;
border-radius: 999px;
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.01em;
cursor: pointer;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
opacity: 0.78;
transition: opacity 120ms ease, background 120ms ease;
}
.prompt-template-modal-asset:hover .prompt-template-modal-asset-expand,
.prompt-template-modal-asset-expand:hover,
.prompt-template-modal-asset-expand:focus-visible {
opacity: 1;
background: rgba(0, 0, 0, 0.72);
}
.prompt-template-lightbox-backdrop {
position: fixed;
inset: 0;
z-index: 1600;
background: rgba(8, 9, 12, 0.94);
display: flex;
align-items: center;
justify-content: center;
padding: 48px;
cursor: zoom-out;
animation: prompt-template-lightbox-fade 140ms ease-out;
}
@keyframes prompt-template-lightbox-fade {
from { opacity: 0; }
to { opacity: 1; }
}
.prompt-template-lightbox-media {
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
display: block;
border-radius: 10px;
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.6);
cursor: default;
background: #000;
}
.prompt-template-lightbox-close {
position: fixed;
top: 18px;
right: 18px;
width: 40px;
height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.14);
color: #fff;
cursor: pointer;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
transition: background 120ms ease, transform 120ms ease;
}
.prompt-template-lightbox-close:hover,
.prompt-template-lightbox-close:focus-visible {
background: rgba(255, 255, 255, 0.26);
transform: scale(1.05);
}
@media (max-width: 640px) {
.prompt-template-lightbox-backdrop { padding: 16px; }
.prompt-template-lightbox-close { top: 12px; right: 12px; }
}
.prompt-template-modal-prompt {
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-subtle);
display: flex;
flex-direction: column;
}
.prompt-template-modal-prompt-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
}
.prompt-template-modal-prompt-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
}
.prompt-template-modal-prompt-body {
margin: 0;
padding: 12px;
white-space: pre-wrap;
word-break: break-word;
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 12px;
line-height: 1.5;
max-height: 320px;
overflow: auto;
}
.prompt-template-modal-foot {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 12px 18px;
border-top: 1px solid var(--border);
font-size: 11px;
color: var(--text-faint);
}
.prompt-template-license {
padding: 1px 6px;
background: var(--bg-subtle);
border-radius: 4px;
color: var(--text-muted);
}

View file

@ -0,0 +1,452 @@
/**
* Single source of truth for the media-generation model registry.
*
* Both the frontend (NewProjectPanel model pickers, Settings dialog
* provider list) and the daemon (od media generate dispatcher) consume
* this registry. When you add a model entry here, the picker shows it,
* the daemon can dispatch to it, and the Settings dialog knows which
* API keys are needed.
*
* The model catalogue mirrors the breadth of lobehub's model-bank:
* every image / video model that lobehub natively supports is listed
* here so the user can pick from the same surface area without us
* re-implementing every provider's transport. For provider integrations
* we only ship the two flagship paths today OpenAI (gpt-image-*) and
* Volcengine Ark (Seedance 2.0) the rest fall back to a placeholder
* with a clear "no provider integration yet" note. The contract the
* code agent follows is identical regardless.
*
* The daemon imports the JS mirror of this file at
* daemon/media-models.js (kept in sync by review).
*/
import type { AudioKind, MediaAspect } from '../types';
/**
* Provider identifier used both as a grouping key in the picker and as
* the lookup key for API-credentials in `AppConfig.mediaProviders`. New
* providers must be added to {@link MEDIA_PROVIDERS} below.
*/
export type MediaProviderId =
| 'openai'
| 'volcengine'
| 'hyperframes'
| 'bfl'
| 'fal'
| 'replicate'
| 'google'
| 'midjourney'
| 'kling'
| 'minimax'
| 'suno'
| 'udio'
| 'elevenlabs'
| 'fishaudio'
| 'stub';
export interface MediaProvider {
id: MediaProviderId;
/** Display name shown in Settings + ModelPicker headers. */
label: string;
/** Short marketing-style sub-label. */
hint: string;
/** Whether the daemon ships a real integration for this provider. */
integrated: boolean;
/** Whether the provider needs user-supplied credentials. */
credentialsRequired?: boolean;
/** Whether the provider should appear in Settings -> Media. */
settingsVisible?: boolean;
/** Default base URL the daemon hits when no override is configured. */
defaultBaseUrl?: string;
/** Documentation URL for getting an API key. */
docsUrl?: string;
}
/**
* Catalogue of providers. The Settings dialog renders one section per
* entry; the new-project model picker uses {@link integrated} to flag
* cards that will silently fall back to a stub if the user hasn't
* configured a key.
*/
export const MEDIA_PROVIDERS: MediaProvider[] = [
{
id: 'openai',
label: 'OpenAI',
hint: 'gpt-image-2 / dall-e-3',
integrated: true,
defaultBaseUrl: 'https://api.openai.com/v1',
docsUrl: 'https://platform.openai.com/api-keys',
},
{
id: 'volcengine',
label: 'Volcengine Ark (Doubao)',
hint: 'Seedance 2.0 / Seedream',
integrated: true,
defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3',
docsUrl: 'https://console.volcengine.com/ark',
},
{
id: 'hyperframes',
label: 'HyperFrames',
hint: 'Local HTML -> MP4 renderer',
integrated: true,
credentialsRequired: false,
settingsVisible: false,
docsUrl: 'https://hyperframes.heygen.com',
},
{
id: 'bfl',
label: 'Black Forest Labs',
hint: 'FLUX 1.1 Pro / FLUX Pro / Dev',
integrated: false,
defaultBaseUrl: 'https://api.bfl.ai',
docsUrl: 'https://docs.bfl.ai/quick_start/create_account',
},
{
id: 'fal',
label: 'Fal.ai',
hint: 'Sora / Seedance / Veo / FLUX',
integrated: false,
defaultBaseUrl: 'https://fal.run',
docsUrl: 'https://fal.ai/dashboard/keys',
},
{
id: 'replicate',
label: 'Replicate',
hint: 'FLUX / SDXL / Ideogram',
integrated: false,
defaultBaseUrl: 'https://api.replicate.com/v1',
docsUrl: 'https://replicate.com/account/api-tokens',
},
{
id: 'google',
label: 'Google AI / Vertex',
hint: 'Imagen 4 / Veo 3 / Lyria',
integrated: false,
docsUrl: 'https://ai.google.dev/gemini-api/docs/api-key',
},
{
id: 'kling',
label: 'Kuaishou Kling',
hint: 'Kling 1.6 / 2.0 video',
integrated: false,
docsUrl: 'https://klingai.com/dev-center',
},
{
id: 'midjourney',
label: 'Midjourney (proxy)',
hint: 'midjourney-v7',
integrated: false,
},
{
id: 'minimax',
label: 'MiniMax',
hint: 'TTS / video-01',
integrated: true,
defaultBaseUrl: 'https://api.minimaxi.chat/v1',
docsUrl: 'https://platform.minimaxi.com',
},
{
id: 'suno',
label: 'Suno',
hint: 'Music generation',
integrated: false,
},
{
id: 'udio',
label: 'Udio',
hint: 'Music generation',
integrated: false,
},
{
id: 'elevenlabs',
label: 'ElevenLabs',
hint: 'Voice / SFX',
integrated: false,
docsUrl: 'https://elevenlabs.io/app/settings/api-keys',
},
{
id: 'fishaudio',
label: 'FishAudio',
hint: 'Speech / voice clone',
integrated: true,
defaultBaseUrl: 'https://api.fish.audio',
docsUrl: 'https://fish.audio',
},
{
id: 'stub',
label: 'Stub (placeholder)',
hint: 'Deterministic local placeholder bytes',
integrated: true,
},
];
export interface MediaModel {
/** Stable ID used in metadata.imageModel / videoModel / audioModel. */
id: string;
/** Short label shown in pickers. */
label: string;
/** Vendor / context hint shown under the label. */
hint: string;
/** Provider this model is dispatched through. */
provider: MediaProviderId;
/**
* Capabilities the agent may rely on when planning. Used downstream by
* the dispatcher to decide which provider call to make.
*/
caps?: string[];
/** Marks the default-checked card per surface in the picker. */
default?: boolean;
}
/**
* Image generation models. Mirrors the breadth of
* `packages/model-bank/src/aiModels/openai.ts` and friends in lobehub.
*/
export const IMAGE_MODELS: MediaModel[] = [
// OpenAI — fully integrated path.
{
id: 'gpt-image-2',
label: 'gpt-image-2',
hint: 'OpenAI · 4K, native multimodal',
provider: 'openai',
caps: ['t2i', 'i2i', 'inpaint'],
default: true,
},
{
id: 'gpt-image-1.5',
label: 'gpt-image-1.5',
hint: 'OpenAI · 4× faster than gpt-image-1',
provider: 'openai',
caps: ['t2i', 'i2i', 'inpaint'],
},
{
id: 'gpt-image-1',
label: 'gpt-image-1',
hint: 'OpenAI · ChatGPT native',
provider: 'openai',
caps: ['t2i', 'i2i', 'inpaint'],
},
{
id: 'gpt-image-1-mini',
label: 'gpt-image-1-mini',
hint: 'OpenAI · low-cost variant',
provider: 'openai',
caps: ['t2i', 'i2i'],
},
{
id: 'dall-e-3',
label: 'dall-e-3',
hint: 'OpenAI · classic',
provider: 'openai',
caps: ['t2i'],
},
{
id: 'dall-e-2',
label: 'dall-e-2',
hint: 'OpenAI · legacy',
provider: 'openai',
caps: ['t2i'],
},
// Volcengine — Doubao Seedream image generation.
{
id: 'doubao-seedream-3-0-t2i-250415',
label: 'seedream-3.0',
hint: 'ByteDance · Doubao image',
provider: 'volcengine',
caps: ['t2i'],
},
{
id: 'doubao-seededit-3-0-i2i-250628',
label: 'seededit-3.0',
hint: 'ByteDance · image edit',
provider: 'volcengine',
caps: ['i2i'],
},
// Black Forest Labs FLUX family.
{ id: 'flux-1.1-pro', label: 'flux-1.1-pro', hint: 'BFL · flagship', provider: 'bfl', caps: ['t2i', 'i2i'] },
{ id: 'flux-pro', label: 'flux-pro', hint: 'BFL', provider: 'bfl', caps: ['t2i'] },
{ id: 'flux-dev', label: 'flux-dev', hint: 'BFL · open weights', provider: 'bfl', caps: ['t2i'] },
{ id: 'flux-schnell', label: 'flux-schnell', hint: 'BFL · fast', provider: 'bfl', caps: ['t2i'] },
{ id: 'flux-kontext-pro', label: 'flux-kontext-pro', hint: 'BFL · in-context edits', provider: 'bfl', caps: ['t2i', 'i2i'] },
// Google.
{ id: 'imagen-4', label: 'imagen-4', hint: 'Google · latest', provider: 'google', caps: ['t2i'] },
{ id: 'imagen-3', label: 'imagen-3', hint: 'Google', provider: 'google', caps: ['t2i'] },
{ id: 'gemini-3-pro-image-preview', label: 'gemini-3-pro-image', hint: 'Google · Nano Banana Pro', provider: 'google', caps: ['t2i', 'i2i'] },
// Replicate / Fal hosted image models.
{ id: 'ideogram-v2', label: 'ideogram-v2', hint: 'Replicate · typography', provider: 'replicate', caps: ['t2i'] },
{ id: 'sdxl', label: 'stable-diffusion-xl', hint: 'Replicate · SDXL', provider: 'replicate', caps: ['t2i'] },
{ id: 'sd-3.5', label: 'stable-diffusion-3.5', hint: 'Fal · SD 3.5', provider: 'fal', caps: ['t2i'] },
// Midjourney via community proxies.
{ id: 'midjourney-v7', label: 'midjourney-v7', hint: 'Midjourney · via proxy', provider: 'midjourney', caps: ['t2i'] },
];
/**
* Video generation models. Mirrors lobehub's volcengine.ts (Seedance,
* Seedance Lite), kling.ts and friends.
*/
export const VIDEO_MODELS: MediaModel[] = [
// Volcengine — Seedance 2.0 (integrated).
{
id: 'doubao-seedance-2-0-260128',
label: 'seedance-2.0',
hint: 'ByteDance · t2v + i2v + audio',
provider: 'volcengine',
caps: ['t2v', 'i2v', 'audio'],
default: true,
},
{
id: 'doubao-seedance-2-0-fast-260128',
label: 'seedance-2.0-fast',
hint: 'ByteDance · faster, cheaper',
provider: 'volcengine',
caps: ['t2v', 'i2v', 'audio'],
},
{
id: 'doubao-seedance-1-0-pro-250528',
label: 'seedance-1.0-pro',
hint: 'ByteDance · 1.0',
provider: 'volcengine',
caps: ['t2v', 'i2v'],
},
{
id: 'doubao-seedance-1-0-lite-i2v-250428',
label: 'seedance-1.0-lite-i2v',
hint: 'ByteDance · image-to-video',
provider: 'volcengine',
caps: ['i2v'],
},
{
id: 'doubao-seedance-1-0-lite-t2v-250428',
label: 'seedance-1.0-lite-t2v',
hint: 'ByteDance · text-to-video',
provider: 'volcengine',
caps: ['t2v'],
},
// Kuaishou Kling.
{ id: 'kling-2.0', label: 'kling-2.0', hint: 'Kuaishou · latest', provider: 'kling', caps: ['t2v', 'i2v'] },
{ id: 'kling-1.6', label: 'kling-1.6', hint: 'Kuaishou', provider: 'kling', caps: ['t2v', 'i2v'] },
{ id: 'kling-1.5', label: 'kling-1.5', hint: 'Kuaishou', provider: 'kling', caps: ['t2v', 'i2v'] },
// Google Veo.
{ id: 'veo-3', label: 'veo-3', hint: 'Google · sound-on', provider: 'google', caps: ['t2v', 'audio'] },
{ id: 'veo-2', label: 'veo-2', hint: 'Google', provider: 'google', caps: ['t2v'] },
// OpenAI Sora (via Fal hosting today).
{ id: 'sora-2', label: 'sora-2', hint: 'OpenAI · via Fal', provider: 'fal', caps: ['t2v'] },
{ id: 'sora-2-pro', label: 'sora-2-pro', hint: 'OpenAI · via Fal', provider: 'fal', caps: ['t2v'] },
// MiniMax video.
{ id: 'minimax-video-01', label: 'video-01', hint: 'MiniMax · Hailuo', provider: 'minimax', caps: ['t2v', 'i2v'] },
{ id: 'hyperframes-html', label: 'hyperframes-html', hint: 'HyperFrames · local HTML renderer', provider: 'hyperframes', caps: ['t2v'] },
];
export const AUDIO_MODELS_BY_KIND: Record<AudioKind, MediaModel[]> = {
music: [
{ id: 'suno-v5', label: 'suno-v5', hint: 'Suno · default', provider: 'suno', caps: ['music'], default: true },
{ id: 'suno-v4-5', label: 'suno-v4.5', hint: 'Suno', provider: 'suno', caps: ['music'] },
{ id: 'udio-v2', label: 'udio-v2', hint: 'Udio', provider: 'udio', caps: ['music'] },
{ id: 'lyria-2', label: 'lyria-2', hint: 'Google', provider: 'google', caps: ['music'] },
],
speech: [
{ id: 'gpt-4o-mini-tts', label: 'gpt-4o-mini-tts', hint: 'OpenAI · expressive TTS', provider: 'openai', caps: ['tts'] },
{ id: 'minimax-tts', label: 'minimax-tts', hint: 'MiniMax · default', provider: 'minimax', caps: ['tts'], default: true },
{ id: 'fish-speech-2', label: 'fish-speech-2', hint: 'FishAudio', provider: 'fishaudio', caps: ['tts', 'voice-clone'] },
{ id: 'elevenlabs-v3', label: 'elevenlabs-v3', hint: 'ElevenLabs', provider: 'elevenlabs', caps: ['tts', 'voice-clone'] },
{ id: 'doubao-tts', label: 'doubao-tts', hint: 'Volcengine · TTS', provider: 'volcengine', caps: ['tts'] },
],
sfx: [
{ id: 'elevenlabs-sfx', label: 'elevenlabs-sfx', hint: 'ElevenLabs SFX', provider: 'elevenlabs', caps: ['sfx'], default: true },
{ id: 'audiocraft', label: 'audiocraft', hint: 'Meta · open', provider: 'replicate', caps: ['sfx', 'music'] },
],
};
export const MEDIA_ASPECTS: MediaAspect[] = ['1:1', '16:9', '9:16', '4:3', '3:4'];
export const VIDEO_LENGTHS_SEC: number[] = [3, 5, 8, 10, 15, 30];
export const AUDIO_DURATIONS_SEC: number[] = [5, 10, 15, 30, 60, 120];
export const DEFAULT_IMAGE_MODEL =
IMAGE_MODELS.find((m) => m.default)?.id ?? IMAGE_MODELS[0]!.id;
export const DEFAULT_VIDEO_MODEL =
VIDEO_MODELS.find((m) => m.default)?.id ?? VIDEO_MODELS[0]!.id;
export const DEFAULT_AUDIO_MODEL: Record<AudioKind, string> = {
music:
AUDIO_MODELS_BY_KIND.music.find((m) => m.default)?.id
?? AUDIO_MODELS_BY_KIND.music[0]!.id,
speech:
AUDIO_MODELS_BY_KIND.speech.find((m) => m.default)?.id
?? AUDIO_MODELS_BY_KIND.speech[0]!.id,
sfx:
AUDIO_MODELS_BY_KIND.sfx.find((m) => m.default)?.id
?? AUDIO_MODELS_BY_KIND.sfx[0]!.id,
};
/**
* Look up a model record across all surfaces by ID. Returns null if the
* agent passes an unknown model the dispatcher rejects with a clear
* error so the agent re-plans instead of silently falling back.
*/
export function findMediaModel(id: string): MediaModel | null {
const all: MediaModel[] = [
...IMAGE_MODELS,
...VIDEO_MODELS,
...AUDIO_MODELS_BY_KIND.music,
...AUDIO_MODELS_BY_KIND.speech,
...AUDIO_MODELS_BY_KIND.sfx,
];
return all.find((m) => m.id === id) ?? null;
}
export function findProvider(id: MediaProviderId): MediaProvider | null {
return MEDIA_PROVIDERS.find((p) => p.id === id) ?? null;
}
/** All model IDs grouped by surface, used for prompt-side disclosure. */
export function modelIdsBySurface(): {
image: string[];
video: string[];
audio: { music: string[]; speech: string[]; sfx: string[] };
} {
return {
image: IMAGE_MODELS.map((m) => m.id),
video: VIDEO_MODELS.map((m) => m.id),
audio: {
music: AUDIO_MODELS_BY_KIND.music.map((m) => m.id),
speech: AUDIO_MODELS_BY_KIND.speech.map((m) => m.id),
sfx: AUDIO_MODELS_BY_KIND.sfx.map((m) => m.id),
},
};
}
/**
* Group a flat list of {@link MediaModel} by provider while preserving
* the catalogue order. Used by the picker to render section headers.
*/
export function groupByProvider(models: MediaModel[]): Array<{
provider: MediaProvider;
models: MediaModel[];
}> {
const order: MediaProviderId[] = [];
const map = new Map<MediaProviderId, MediaModel[]>();
for (const m of models) {
if (!map.has(m.provider)) {
order.push(m.provider);
map.set(m.provider, []);
}
map.get(m.provider)!.push(m);
}
return order
.map((id) => {
const provider = findProvider(id);
const list = map.get(id) ?? [];
return provider ? { provider, models: list } : null;
})
.filter((entry): entry is { provider: MediaProvider; models: MediaModel[] } => entry != null);
}

View file

@ -6,6 +6,8 @@ import type {
DesignSystemDetail,
DesignSystemSummary,
ProjectDeploymentsResponse,
PromptTemplateDetail,
PromptTemplateSummary,
ProjectFile,
SkillDetail,
SkillSummary,
@ -66,6 +68,33 @@ export async function fetchDesignSystem(id: string): Promise<DesignSystemDetail
}
}
export async function fetchPromptTemplates(): Promise<PromptTemplateSummary[]> {
try {
const resp = await fetch('/api/prompt-templates');
if (!resp.ok) return [];
const json = (await resp.json()) as { promptTemplates: PromptTemplateSummary[] };
return json.promptTemplates ?? [];
} catch {
return [];
}
}
export async function fetchPromptTemplate(
surface: 'image' | 'video',
id: string,
): Promise<PromptTemplateDetail | null> {
try {
const resp = await fetch(
`/api/prompt-templates/${encodeURIComponent(surface)}/${encodeURIComponent(id)}`,
);
if (!resp.ok) return null;
const json = (await resp.json()) as { promptTemplate: PromptTemplateDetail };
return json.promptTemplate ?? null;
} catch {
return null;
}
}
export async function daemonIsLive(): Promise<boolean> {
try {
const resp = await fetch('/api/health');

View file

@ -1,4 +1,4 @@
import type { AppConfig } from '../types';
import type { AppConfig, MediaProviderCredentials } from '../types';
const STORAGE_KEY = 'open-design:config';
@ -11,6 +11,7 @@ export const DEFAULT_CONFIG: AppConfig = {
skillId: null,
designSystemId: null,
onboardingCompleted: false,
mediaProviders: {},
agentModels: {},
};
@ -26,7 +27,12 @@ export function loadConfig(): AppConfig {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return { ...DEFAULT_CONFIG };
const parsed = JSON.parse(raw) as Partial<AppConfig>;
return { ...DEFAULT_CONFIG, ...parsed };
return {
...DEFAULT_CONFIG,
...parsed,
mediaProviders: { ...(parsed.mediaProviders ?? {}) },
agentModels: { ...(parsed.agentModels ?? {}) },
};
} catch {
return { ...DEFAULT_CONFIG };
}
@ -35,3 +41,28 @@ export function loadConfig(): AppConfig {
export function saveConfig(config: AppConfig): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
}
export function hasAnyConfiguredProvider(
providers: Record<string, MediaProviderCredentials> | undefined,
): boolean {
if (!providers) return false;
return Object.values(providers).some((entry) =>
Boolean(entry?.apiKey?.trim() || entry?.baseUrl?.trim()),
);
}
export async function syncMediaProvidersToDaemon(
providers: Record<string, MediaProviderCredentials> | undefined,
options?: { force?: boolean },
): Promise<void> {
if (!providers) return;
try {
await fetch('/api/media/config', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ providers, force: Boolean(options?.force) }),
});
} catch {
// Daemon offline; localStorage keeps the user's copy for the next save.
}
}

View file

@ -1,5 +1,6 @@
import type {
AgentInfo,
AudioKind,
ChatAttachment,
ChatMessage,
Conversation,
@ -7,6 +8,7 @@ import type {
DeployProjectFileResponse,
DesignSystemDetail,
DesignSystemSummary,
MediaAspect,
ProjectDeploymentsResponse,
PersistedAgentEvent,
Project,
@ -23,6 +25,11 @@ import type {
export type ExecMode = 'daemon' | 'api';
export interface MediaProviderCredentials {
apiKey: string;
baseUrl: string;
}
// Per-CLI model + reasoning the user picked in the model menu. Each agent
// keeps its own slot so flipping between Codex and Gemini doesn't reset the
// other one's choice. Missing entries fall back to the agent's first
@ -44,6 +51,7 @@ export interface AppConfig {
// least once (saved or skipped). Bootstrap skips the auto-popup when
// this is set so refreshing the page doesn't re-prompt.
onboardingCompleted?: boolean;
mediaProviders?: Record<string, MediaProviderCredentials>;
// Per-CLI model picker state, keyed by agent id (e.g. `gemini`, `codex`).
// Pre-existing configs without this field fall through to the agent's
// declared default.
@ -73,13 +81,42 @@ export interface AgentModelOption {
label: string;
}
export type Surface = 'web' | 'image' | 'video' | 'audio';
export interface PromptTemplateSource {
repo: string;
license: string;
author?: string;
url?: string;
}
export interface PromptTemplateSummary {
id: string;
surface: 'image' | 'video';
title: string;
summary: string;
category: string;
tags?: string[];
model?: string;
aspect?: MediaAspect;
previewImageUrl?: string;
previewVideoUrl?: string;
source: PromptTemplateSource;
}
export interface PromptTemplateDetail extends PromptTemplateSummary {
prompt: string;
}
export type {
AgentInfo,
AudioKind,
Conversation,
DeployConfigResponse,
DeployProjectFileResponse,
DesignSystemDetail,
DesignSystemSummary,
MediaAspect,
ProjectDeploymentsResponse,
Project,
ProjectDisplayStatus,

View file

@ -4,6 +4,8 @@ import type { ArtifactKind, ArtifactManifest } from './artifacts';
export type ProjectFileKind =
| 'html'
| 'image'
| 'video'
| 'audio'
| 'sketch'
| 'text'
| 'code'

View file

@ -1,6 +1,17 @@
import type { ChatMessage } from './chat';
export type ProjectKind = 'prototype' | 'deck' | 'template' | 'other';
export type ProjectKind =
| 'prototype'
| 'deck'
| 'template'
| 'other'
| 'image'
| 'video'
| 'audio';
export type MediaAspect = '1:1' | '16:9' | '9:16' | '4:3' | '3:4';
export type AudioKind = 'music' | 'speech' | 'sfx';
export type ProjectDisplayStatus =
| 'not_started'
@ -28,6 +39,16 @@ export interface ProjectMetadata {
importedFrom?: 'claude-design' | string;
entryFile?: string;
sourceFileName?: string;
imageModel?: string;
imageAspect?: MediaAspect;
imageStyle?: string;
videoModel?: string;
videoLength?: number;
videoAspect?: MediaAspect;
audioKind?: AudioKind;
audioModel?: string;
audioDuration?: number;
voice?: string;
}
export interface Project {

View file

@ -23,7 +23,15 @@ export interface SkillSummary {
name: string;
description: string;
triggers: string[];
mode: 'prototype' | 'deck' | 'template' | 'design-system';
mode:
| 'prototype'
| 'deck'
| 'template'
| 'design-system'
| 'image'
| 'video'
| 'audio';
surface?: 'web' | 'image' | 'video' | 'audio';
platform?: 'desktop' | 'mobile' | null;
scenario?: string | null;
previewType: string;
@ -56,6 +64,7 @@ export interface DesignSystemSummary {
category: string;
summary: string;
swatches?: string[];
surface?: 'web' | 'image' | 'video' | 'audio';
}
export interface DesignSystemDetail extends DesignSystemSummary {

View file

@ -0,0 +1,58 @@
export const MEDIA_GENERATION_CONTRACT = `
---
## Media generation contract (load-bearing - overrides softer wording above)
This project is a **non-web** surface (image / video / audio). The unifying
contract is: skill workflow + project metadata tell you WHAT to make; one
shell command - \`od media generate\` - is HOW you actually produce bytes.
Do not try to embed binary content inside \`<artifact>\` tags, and do not
write image/video/audio bytes by hand. Always call out to the dispatcher.
The daemon injects these environment variables for agent sessions:
- \`OD_BIN\` - absolute path to the OD CLI script. Run with \`node "$OD_BIN" ...\`.
- \`OD_PROJECT_ID\` - active project id. Pass it as \`--project "$OD_PROJECT_ID"\`.
- \`OD_PROJECT_DIR\` - active project files directory.
- \`OD_DAEMON_URL\` - base URL of the local daemon.
Run media generation through the dispatcher:
\`\`\`bash
node "$OD_BIN" media generate \\
--project "$OD_PROJECT_ID" \\
--surface <image|video|audio> \\
--model <model-id> \\
--output <filename> \\
--prompt "<full prompt>" \\
[--aspect 1:1|16:9|9:16|4:3|3:4] \\
[--length <seconds>] \\
[--duration <seconds>] \\
[--audio-kind music|speech|sfx] \\
[--voice <provider-voice-id>]
\`\`\`
Always quote the prompt value. Never splice unquoted user text into the
command line. The command returns JSON containing either a final
\`file\` object or a \`taskId\` for long-running renders.
For long-running renders, continue with:
\`\`\`bash
node "$OD_BIN" media wait <taskId> --since <nextSince>
\`\`\`
\`media wait\` exits \`0\` when done, \`2\` when still running, and \`5\`
when the provider task failed. Exit code \`2\` is not an error; keep polling
with the returned \`nextSince\`.
Do not emit \`<artifact>\` blocks for media. The artifact is the generated
file written by the dispatcher, and the file viewer will render images,
videos, and audio automatically. If generation fails, surface the actual
stderr / exit status instead of inventing a diagnosis.
Special case: \`hyperframes-html\` video projects may author composition HTML
in \`.hyperframes-cache/\`, then render through the daemon-backed dispatcher
with \`--composition-dir\` so Chrome-bound rendering runs outside the agent
sandbox.
`;

View file

@ -33,13 +33,22 @@ import type { ProjectMetadata, ProjectTemplate } from '../api/projects';
import { OFFICIAL_DESIGNER_PROMPT } from './official-system';
import { DISCOVERY_AND_PHILOSOPHY } from './discovery';
import { DECK_FRAMEWORK_DIRECTIVE } from './deck-framework';
import { MEDIA_GENERATION_CONTRACT } from './media-contract';
export const BASE_SYSTEM_PROMPT = OFFICIAL_DESIGNER_PROMPT;
export interface ComposeInput {
skillBody?: string | undefined;
skillName?: string | undefined;
skillMode?: 'prototype' | 'deck' | 'template' | 'design-system' | undefined;
skillMode?:
| 'prototype'
| 'deck'
| 'template'
| 'design-system'
| 'image'
| 'video'
| 'audio'
| undefined;
designSystemBody?: string | undefined;
designSystemTitle?: string | undefined;
// Project-level metadata captured by the new-project panel. Drives the
@ -111,6 +120,17 @@ export function composeSystemPrompt({
parts.push(`\n\n---\n\n${DECK_FRAMEWORK_DIRECTIVE}`);
}
const isMediaSurface =
skillMode === 'image' ||
skillMode === 'video' ||
skillMode === 'audio' ||
metadata?.kind === 'image' ||
metadata?.kind === 'video' ||
metadata?.kind === 'audio';
if (isMediaSurface) {
parts.push(MEDIA_GENERATION_CONTRACT);
}
return parts.join('');
}
@ -145,6 +165,61 @@ function renderMetadataBlock(
lines.push(`- **template**: ${metadata.templateLabel}`);
}
}
if (metadata.kind === 'image') {
lines.push(
`- **imageModel**: ${metadata.imageModel ?? '(unknown - ask: which image model to use)'}`,
);
lines.push(
`- **aspectRatio**: ${metadata.imageAspect ?? '(unknown - ask: 1:1, 16:9, 9:16, 4:3, 3:4)'}`,
);
if (metadata.imageStyle) {
lines.push(`- **styleNotes**: ${metadata.imageStyle}`);
}
lines.push('');
lines.push(
'This is an **image** project. Plan the prompt carefully, then dispatch via the **media generation contract** using `od media generate --surface image --model <imageModel>`. Do NOT emit `<artifact>` HTML for media surfaces.',
);
}
if (metadata.kind === 'video') {
lines.push(
`- **videoModel**: ${metadata.videoModel ?? '(unknown - ask: which video model to use)'}`,
);
lines.push(
`- **lengthSeconds**: ${typeof metadata.videoLength === 'number' ? metadata.videoLength : '(unknown - ask: 3s / 5s / 10s)'}`,
);
lines.push(
`- **aspectRatio**: ${metadata.videoAspect ?? '(unknown - ask: 16:9, 9:16, 1:1)'}`,
);
lines.push('');
lines.push(
'This is a **video** project. Plan the shotlist and motion, then dispatch via the **media generation contract** using `od media generate --surface video --model <videoModel> --length <seconds> --aspect <ratio>`. Do NOT emit `<artifact>` HTML.',
);
if (metadata.videoModel === 'hyperframes-html') {
lines.push(
'Special case: `hyperframes-html` is a local HTML-to-MP4 renderer, not a photoreal text-to-video model. Treat it like a motion design renderer, ask at most one clarifying question, then dispatch immediately.',
);
}
}
if (metadata.kind === 'audio') {
lines.push(
`- **audioKind**: ${metadata.audioKind ?? '(unknown - ask: music / speech / sfx)'}`,
);
lines.push(
`- **audioModel**: ${metadata.audioModel ?? '(unknown - ask: which audio model to use)'}`,
);
lines.push(
`- **durationSeconds**: ${typeof metadata.audioDuration === 'number' ? metadata.audioDuration : '(unknown - ask: target duration)'}`,
);
if (metadata.voice) {
lines.push(`- **voice**: ${metadata.voice}`);
} else if (metadata.audioKind === 'speech') {
lines.push('- **voice**: (unknown - ask: voice id / accent / pacing)');
}
lines.push('');
lines.push(
'This is an **audio** project. Lock the content intent first, then dispatch via the **media generation contract** using `od media generate --surface audio --audio-kind <kind> --model <audioModel> --duration <seconds>` and add `--voice <voice-id>` for speech when you have a provider-specific voice id. Do NOT emit `<artifact>` HTML.',
);
}
if (metadata.inspirationDesignSystemIds && metadata.inspirationDesignSystemIds.length > 0) {
lines.push(

View file

@ -0,0 +1,20 @@
{
"id": "3d-stone-staircase-evolution-infographic",
"surface": "image",
"title": "3D Stone Staircase Evolution Infographic",
"summary": "Transforms a flat evolutionary timeline into a realistic 3D stone staircase infographic with detailed organism renders and structured side panels.",
"category": "Infographic",
"tags": [
"3d-render"
],
"model": "gpt-image-2",
"aspect": "1:1",
"prompt": "{\n \"type\": \"evolutionary timeline infographic\",\n \"instruction\": \"Using REFERENCE_0 as a structural base, transform the flat vector design into a highly realistic 3D infographic. Replace the smooth ramps with distinct stone steps and upgrade all organisms to photorealistic 3D models.\",\n \"style\": {\n \"background\": \"{argument name=\\\"background style\\\" default=\\\"vintage textured parchment paper\\\"}\",\n \"staircase\": \"{argument name=\\\"staircase material\\\" default=\\\"realistic textured stone blocks\\\"}\",\n \"subjects\": \"{argument name=\\\"organism style\\\" default=\\\"highly detailed photorealistic 3D renders\\\"}\"\n },\n \"layout\": {\n \"main_title\": \"{argument name=\\\"main title\\\" default=\\\"人类演化\\\"}\",\n \"sections\": [\n {\n \"position\": \"left sidebar\",\n \"count\": 8,\n \"labels\": [\"L0: 单细胞生命\", \"L1: 多细胞生物\", \"L2: 动物界\", \"L3: 脊索动物\", \"L4: 上陆革命\", \"L5: 哺乳纲\", \"L6: 人科演化\", \"L7: 智人纪元\"]\n },\n {\n \"position\": \"top right\",\n \"title\": \"获得的功能 / 失去的功能\",\n \"description\": \"Legend with plus and minus icons\"\n },\n {\n \"position\": \"bottom center\",\n \"title\": \"演化关键里程碑\",\n \"count\": 6,\n \"description\": \"Timeline with a silhouette graphic of 6 figures showing ape-to-human evolution\"\n }\n ],\n \"centerpiece\": {\n \"description\": \"Winding stone staircase with 25 numbered steps featuring specific organisms.\",\n \"count\": 25,\n \"notable_elements\": [\n \"Step 07: Jellyfish\",\n \"Step 09: Ammonite\",\n \"Step 10: Trilobite\",\n \"Step 24: Walking human\",\n \"Step 25: {argument name=\\\"future evolution concept\\\" default=\\\"glowing cosmic silhouette with a question mark\\\"}\"\n ]\n }\n }\n}",
"previewImageUrl": "https://cms-assets.youmind.com/media/1776661968404_8a5flm_HGQc_KOaMAA2vt0.jpg",
"source": {
"repo": "YouMind-OpenLab/awesome-gpt-image-2",
"license": "CC-BY-4.0",
"author": "知识猫图解",
"url": "https://x.com/GeekCatX/status/2045792240044511277#reversed-1"
}
}

View file

@ -0,0 +1,21 @@
{
"id": "anime-martial-arts-battle-illustration",
"surface": "image",
"title": "Anime Martial Arts Battle Illustration",
"summary": "Generates a dynamic, high-impact anime illustration of two female characters fighting in a traditional dojo with elemental energy effects.",
"category": "Anime / Manga",
"tags": [
"anime",
"action"
],
"model": "gpt-image-2",
"aspect": "1:1",
"prompt": "An anime-style illustration of a {argument name=\"action type\" default=\"high-impact martial arts battle\"} between two young female fighters in a {argument name=\"setting\" default=\"traditional wooden martial arts dojo\"}. In the foreground, a girl with black hair in a high bun wears a {argument name=\"character 1 color theme\" default=\"red and white\"} Chinese-style martial arts outfit with baggy pants. She is in a dynamic, low, forward-thrusting stance, surrounded by swirling red energy and water splashes. In the background to the right, a girl with light purple hair in twin buns wears a {argument name=\"character 2 color theme\" default=\"green and purple\"} Chinese dress with gold embroidery and black tights. She is leaping through the air in a flying kick pose, surrounded by swirling blue energy. The wooden floorboards are splintering from the intense impact, with debris and dust flying through the air. Above them hangs a weathered wooden sign with the text \"{argument name=\"sign text\" default=\"武術会\"}\". The scene features dramatic lighting, a low-angle dynamic perspective, and intense action effects.",
"previewImageUrl": "https://cms-assets.youmind.com/media/1776756799880_c8u8w7_HGUKjjaasAAvVRa.jpg",
"source": {
"repo": "YouMind-OpenLab/awesome-gpt-image-2",
"license": "CC-BY-4.0",
"author": "たねもみ 2.0 / Tanemomi Ver2.0",
"url": "https://x.com/Tanemomi_Ver2/status/2046063806846214265#reversed-0"
}
}

View file

@ -0,0 +1,22 @@
{
"id": "e-commerce-live-stream-ui-mockup",
"surface": "image",
"title": "E-commerce Live Stream UI Mockup",
"summary": "Generates a realistic social media live stream interface overlaying a portrait, featuring customizable chat messages, gift popups, and a product purchase card.",
"category": "App / Web Design",
"tags": [
"portrait",
"fantasy",
"product"
],
"model": "gpt-image-2",
"aspect": "1:1",
"prompt": "{\n \"type\": \"live stream UI mockup\",\n \"subject\": {\n \"description\": \"portrait of {argument name=\\\"host name\\\" default=\\\"Elon Musk\\\"}, smiling, wearing a black t-shirt with a white technical schematic graphic\",\n \"background\": \"left side shows a screen with '{argument name=\\\"left background logo\\\" default=\\\"SPACEX\\\"}' text, right side shows a red '{argument name=\\\"right background logo\\\" default=\\\"Tesla T logo\\\"}' and a dark car\"\n },\n \"ui_overlay\": {\n \"top_header\": {\n \"host_info\": \"avatar, name '{argument name=\\\"host name\\\" default=\\\"Elon Musk\\\"}', subtext '55.6万本场点赞', red '关注' button\",\n \"rank_badge\": \"gold coin icon with '全站第1名'\",\n \"viewer_stats\": \"3 top viewer avatars with '12.3w', '8.6w', '5.7w', total '68.7万', 'X' close button\",\n \"right_links\": \"'更多直播 >', '礼物展馆 0/24' with blue '经典' tag\"\n },\n \"mid_left_gifts\": {\n \"count\": 2,\n \"items\": [\n \"avatar '科技爱好者', '送小心心', heart icon x 1314\",\n \"avatar '星辰大海', '送火箭', rocket icon x 666\"\n ]\n },\n \"bottom_left_chat\": {\n \"system_message\": \"level 37 badge '宇宙漫游者 加入了直播间'\",\n \"message_count\": 7,\n \"messages\": [\n \"小火箭: 马斯克!未来可期!🚀\",\n \"future: 特斯拉Model 2什么时候出\",\n \"星空梦想家: SpaceX今年能上火星吗\",\n \"AI探索者: Neuralink进展如何\",\n \"帅气的网友: 马总好!\",\n \"Mars: 第一次来你的直播,超激动!\",\n \"用户123: 讲讲AI吧会取代人类吗\"\n ]\n },\n \"bottom_right_product_card\": {\n \"hot_tag\": \"orange '热卖 x 1888'\",\n \"image\": \"Tesla Cybertruck\",\n \"title\": \"{argument name=\\\"product name\\\" default=\\\"特斯拉Cybertruck 电动皮卡\\\"}\",\n \"price\": \"{argument name=\\\"product price\\\" default=\\\"¥ 1,618,000\\\"}\",\n \"button\": \"red '抢' button\",\n \"floating_animation\": \"translucent hearts floating up the right edge\"\n },\n \"bottom_bar\": {\n \"input_field\": \"'说点什么...'\",\n \"icons\": [\"smiley face\", \"three dots\", \"shopping cart\", \"gift box\", \"share\"]\n }\n }\n}",
"previewImageUrl": "https://cms-assets.youmind.com/media/1776699445498_ga2ry5_HGO7H0DWkAApdKK.jpg",
"source": {
"repo": "YouMind-OpenLab/awesome-gpt-image-2",
"license": "CC-BY-4.0",
"author": "神经病不想好转",
"url": "https://x.com/sjbbxhz/status/2045684734714380687#reversed-0"
}
}

View file

@ -0,0 +1,21 @@
{
"id": "illustrated-city-food-map",
"surface": "image",
"title": "Illustrated City Food Map",
"summary": "Generates a hand-drawn, watercolor-style tourist map featuring numbered local food specialties, landmarks, and a legend.",
"category": "Illustration",
"tags": [
"food",
"nature"
],
"model": "gpt-image-2",
"aspect": "1:1",
"prompt": "{\n \"type\": \"illustrated map infographic\",\n \"style\": \"{argument name=\\\"art style\\\" default=\\\"watercolor and ink hand-drawn illustration on vintage parchment\\\"}\",\n \"title_section\": {\n \"text\": \"{argument name=\\\"city name\\\" default=\\\"成都\\\"} {argument name=\\\"map title\\\" default=\\\"吃货暴走地图\\\"}\",\n \"mascot\": \"cartoon red chili pepper wearing sunglasses and giving a thumbs up\"\n },\n \"border\": \"{argument name=\\\"border decoration\\\" default=\\\"vine of green leaves and red chili peppers\\\"}\",\n \"layout\": {\n \"background\": \"textured beige parchment paper with yellow roads, blue rivers, and green park areas\",\n \"sections\": [\n {\n \"title\": \"landmarks\",\n \"count\": 6,\n \"illustrations\": [\"traditional pavilion\", \"traditional monastery\", \"modern skyscraper with climbing panda\", \"tall TV tower\", \"traditional gate\", \"industrial buildings\"],\n \"labels\": [\"人民公园\", \"文殊院\", \"IFS\", \"339电视塔\", \"宽窄巷子\", \"东郊记忆\"]\n },\n {\n \"title\": \"food_spots\",\n \"count\": 12,\n \"illustrations\": [\"mapo tofu\", \"dumplings in chili oil\", \"skewers in pot\", \"sticky rice balls\", \"egg baking cake\", \"nine-grid hotpot\", \"sweet potato noodles\", \"cold skewers\", \"spicy mixed dish\", \"covered tea bowl\", \"ice jelly dessert\", \"spicy rabbit heads\"],\n \"labels\": [\"1 陈麻婆豆腐\", \"2 钟水饺\", \"3 春熙路\", \"4 宽窄巷子·三大炮\", \"5 建设路·叶婆婆蛋烘糕\", \"6 玉林路·小龙坎火锅\", \"7 香香巷·肥肠粉\", \"8 武侯祠大街·钵钵鸡\", \"9 东郊记忆·冒椒火辣\", \"10 人民公园·鹤鸣茶社\", \"11 锦里古街·冰粉\", \"12 双流老妈兔头\"]\n },\n {\n \"title\": \"图例\",\n \"position\": \"bottom-right\",\n \"count\": 5,\n \"items\": [\"red dot\", \"green house\", \"green tree\", \"blue line\", \"yellow double line\"],\n \"labels\": [\"美食地点\", \"地标景点\", \"公园绿地\", \"河流湖泊\", \"主要道路\"]\n }\n ],\n \"centerpiece\": \"giant panda sitting and eating bamboo\",\n \"bottom_right_extras\": [\"vintage compass rose with N, S, E, W\", \"disclaimer text '温馨提示:吃辣需谨慎,肠胃要保护~' with a red chili pepper icon\"]\n }\n}",
"previewImageUrl": "https://cms-assets.youmind.com/media/1776662673014_nf0taw_HGRMNDybsAAGG88.jpg",
"source": {
"repo": "YouMind-OpenLab/awesome-gpt-image-2",
"license": "CC-BY-4.0",
"author": "皮皮特",
"url": "https://x.com/mm_zzm44854/status/2045861258520568230#reversed-1"
}
}

View file

@ -0,0 +1,18 @@
{
"id": "momotaro-explainer-slide-in-hybrid-style",
"surface": "image",
"title": "Momotaro Explainer Slide in Hybrid Style",
"summary": "A prompt that combines the simple, warm aesthetic of Irasutoya illustrations with the high-information density characteristic of Japanese government slides.",
"category": "Illustration",
"tags": [],
"model": "gpt-image-2",
"aspect": "1:1",
"prompt": "Create an explanatory slide ({argument name=\"format\" default=\"ponchi-e diagram\"}) for {argument name=\"theme\" default=\"Momotaro\"} that fuses the gentle atmosphere of \"Irasutoya\" with the overwhelming information density of \"Kasumigaseki slides\".",
"previewImageUrl": "https://cms-assets.youmind.com/media/1776699414289_t6mebs_HGQQxukbUAA_qc0.jpg",
"source": {
"repo": "YouMind-OpenLab/awesome-gpt-image-2",
"license": "CC-BY-4.0",
"author": "やまもん",
"url": "https://x.com/yammamon/status/2045778624092254603"
}
}

View file

@ -0,0 +1,22 @@
{
"id": "profile-avatar-anime-girl-to-cinematic-photo",
"surface": "image",
"title": "Profile / Avatar - Anime Girl to Cinematic Photo",
"summary": "This prompt turns a character reference illustration into a realistic, warm-toned vintage interior portrait while preserving the original outfit, pose, and cat.",
"category": "Profile / Avatar",
"tags": [
"anime",
"cinematic",
"fantasy"
],
"model": "gpt-image-2",
"aspect": "1:1",
"prompt": "Using the provided reference image, recreate the same girl and black cat in the same seated pose, but transform the flat anime drawing into a realistic cinematic photo. Keep the orange-and-black gothic dress, white frills, lightning armband, headpiece, black cat lying across her knees, white socks, and black Mary Jane shoes consistent with the reference. Place her in a moody vintage interior with a worn wooden floor, aged plaster walls, and 1 tall softly glowing window with sheer curtains on the left casting warm late-afternoon light. Use a nostalgic sepia-orange color grade, subtle film grain, soft shadows, and shallow depth of field for a photoreal editorial look.",
"previewImageUrl": "https://cms-assets.youmind.com/media/1777453169843_ceq758_HG-nC89aQAApDXC.jpg",
"source": {
"repo": "YouMind-OpenLab/awesome-gpt-image-2",
"license": "CC-BY-4.0",
"author": "maku",
"url": "https://x.com/maku67879787/status/2049040029612486845#reversed-0"
}
}

View file

@ -0,0 +1,22 @@
{
"id": "profile-avatar-casual-fashion-grid-photoshoot",
"surface": "image",
"title": "Profile / Avatar - Casual Fashion Grid Photoshoot",
"summary": "A structured JSON prompt for a 4-photo collage of a casual fashion photoshoot with detailed subject and lighting parameters.",
"category": "Profile / Avatar",
"tags": [
"portrait",
"cinematic",
"3d-render"
],
"model": "gpt-image-2",
"aspect": "1:1",
"prompt": "{ \n \"scene_type\": \"smartphone fashion portrait series\",\n \"composition\": {\n \"layout\": \"4-photo grid collage\",\n \"camera\": \"smartphone photography\",\n \"framing\": [\n \"full body standing\",\n \"crouching pose\",\n \"casual seated pose\",\n \"upper body portrait\"\n ],\n \"angle\": \"eye-level, natural perspective\",\n \"aspect_ratio\": \"1:1 collage\"\n },\n \"subject\": {\n \"gender\": \"{argument name=\"gender\" default=\"female\"}\",\n \"age\": \"{argument name=\"age\" default=\"early 20s\"}\",\n \"aesthetic\": \"extremely beautiful, sensual, candid\",\n \"appearance\": {\n \"skin_tone\": \"smooth light complexion\",\n \"face\": \"soft feminine features, natural symmetry, bright smile\",\n \"expression\": \"playful, warm, confident, subtly sensual\",\n \"eyes\": \"expressive, gentle gaze\",\n \"hair\": {\n \"color\": \"{argument name=\"hair color\" default=\"deep black\"}\",\n \"style\": \"long, loose waves\",\n \"texture\": \"soft, natural shine\"\n },\n \"makeup\": \"natural glam, dewy skin, soft blush, subtle lip tint\"\n },\n \"outfit\": {\n \"top\": \"white fitted sleeveless crop top\",\n \"bottom\": \"loose straight-leg blue jeans\",\n \"shoes\": \"casual white sneakers\",\n \"style\": \"minimal, effortless, modern casual\"\n },\n \"pose_style\": \"relaxed, candid, playful fashion poses\",\n \"body_language\": \"confident yet soft, natural movements, gentle sensual elegance\"\n },\n \"environment\": {\n \"location\": \"minimal studio backdrop\",\n \"background\": \"clean light gray seamless wall\",\n \"floor\": \"neutral studio floor\",\n \"lighting\": {\n \"type\": \"soft diffused studio lighting\",\n \"tone\": \"neutral\",\n \"shadows\": \"soft and natural\",\n \"highlights\": \"subtle skin glow\"\n }\n },\n \"style\": {\n \"photography_type\": \"smartphone editorial fashion\",\n \"visual_tone\": \"minimalist, airy, modern\",\n \"mood\": \"candid, sensual elegance, fresh and confident\",\n \"color_palette\": \"white, denim blue, soft gray\",\n \"contrast\": \"low to medium\",\n \"grain\": \"very light natural grain\"\n },\n \"rendering\": {\n \"realism\": \"ultra-realistic\",\n \"detail_level\": \"high skin and fabric texture detail\",\n \"sharpness\": \"high\",\n \"depth_of_field\": \"natural\",\n \"post_processing\": \"minimal, clean, slightly soft finish\"\n },\n \"atmosphere\": \"modern, youthful, effortless beauty, subtle sensual charm\"\n}",
"previewImageUrl": "https://cms-assets.youmind.com/media/1777367267771_teyn0r_HG74_nJaoAEM5oD.jpg",
"source": {
"repo": "YouMind-OpenLab/awesome-gpt-image-2",
"license": "CC-BY-4.0",
"author": "Keskin",
"url": "https://x.com/craftian_keskin/status/2048848908999135645"
}
}

View file

@ -0,0 +1,22 @@
{
"id": "profile-avatar-cinematic-south-asian-male-portrait-with-vultures",
"surface": "image",
"title": "Profile / Avatar - Cinematic South Asian Male Portrait with Vultures",
"summary": "A detailed cinematic portrait of a young South Asian man in a moody, dark fantasy setting surrounded by vultures and ravens.",
"category": "Profile / Avatar",
"tags": [
"portrait",
"cinematic",
"fantasy"
],
"model": "gpt-image-2",
"aspect": "1:1",
"prompt": "A highly detailed cinematic portrait of a handsome {argument name=\"ethnicity\" default=\"South Asian\"} man in his late 20s or early 30s, sitting on a metal railing with a soccer goal net behind him. He has sharp facial features, dark styled hair, light stubble, and intense dark eyes. He is wearing a {argument name=\"clothing\" default=\"black zip-up hoodie, black sweatpants, and white speckled sneakers\"}. His hands are clasped together resting on his knees as he looks directly at the viewer with a confident, slightly brooding expression.\n\nHe is surrounded by a dramatic flock of large black vultures and ravens. Some vultures are flying with wings spread in a dark stormy sky, while others are perched on the railing and goalpost near him. The atmosphere is {argument name=\"atmosphere\" default=\"dark, moody, and cinematic\"} with heavy storm clouds, dramatic lighting, and a mysterious, powerful vibe. High contrast, moody color grading, ultra-realistic, photorealistic, epic composition, dark fantasy aesthetic.",
"previewImageUrl": "https://cms-assets.youmind.com/media/1777453132629_dmkonb_HG9Und1aYAAyo9g.jpg",
"source": {
"repo": "YouMind-OpenLab/awesome-gpt-image-2",
"license": "CC-BY-4.0",
"author": "Jahan Zaib",
"url": "https://x.com/jzaib4269/status/2048949396222489081"
}
}

View file

@ -0,0 +1,23 @@
{
"id": "profile-avatar-cyberpunk-anime-portrait-with-neon-face-text",
"surface": "image",
"title": "Profile / Avatar - Cyberpunk Anime Portrait with Neon Face Text",
"summary": "A stylish neon-soaked anime portrait for posters, social media art, or futuristic branding visuals.",
"category": "Profile / Avatar",
"tags": [
"portrait",
"anime",
"cinematic",
"cyberpunk"
],
"model": "gpt-image-2",
"aspect": "1:1",
"prompt": "A dramatic cyberpunk anime close-up portrait of a white-haired young man in side profile facing right, with spiky silver hair, pale skin, and a black blindfold covering his eyes. He wears a high-collar dark coat and stands in a neon-lit futuristic city at night. Bright electric-blue glowing text is projected across the side of his face, reading exactly {argument name=\"face text\" default=\"GPT IMAGE 2\"} in three stacked lines. The mood is cool, mysterious, and high-energy, with deep black shadows, saturated blue and violet lighting, reflective highlights on the skin and hair, and a cinematic anime look reminiscent of modern supernatural action series. The background is a blurred urban street with dense vertical neon signs and holographic billboards; include 1 large vertical sign on the right with Japanese characters, plus at least 6 additional smaller glowing signs scattered in the distance. Use strong rim lighting, soft bloom, shallow depth of field, high contrast, ultra-detailed digital illustration, and a sleek sci-fi atmosphere.",
"previewImageUrl": "https://cms-assets.youmind.com/media/1777453164993_mt5b69_HHDoWfeaUAEA6Vt.jpg",
"source": {
"repo": "YouMind-OpenLab/awesome-gpt-image-2",
"license": "CC-BY-4.0",
"author": "Anifun AI",
"url": "https://x.com/Anifun_AI/status/2049393871642345834#reversed-0"
}
}

View file

@ -0,0 +1,23 @@
{
"id": "profile-avatar-elegant-fantasy-girl-in-violet-garden",
"surface": "image",
"title": "Profile / Avatar - Elegant Fantasy Girl in Violet Garden",
"summary": "This prompt generates a polished anime-style fantasy portrait of an elegant woman with glossy styled hair, ornate violet-black clothing, and a flower-filled magical garden setting, ideal for character",
"category": "Profile / Avatar",
"tags": [
"portrait",
"anime",
"fantasy",
"cinematic-romance"
],
"model": "gpt-image-2",
"aspect": "1:1",
"prompt": "A highly detailed anime fantasy portrait of a beautiful young woman seated at a stone table in an enchanted flower garden at golden hour, framed from the waist up in a vertical composition. She has {argument name=\"hair color\" default=\"platinum blonde\"} hair that is long, silky, glossy, and carefully groomed, with smooth flowing strands, soft waves, delicate shine, no frizz, no messy texture, and an elegant partial updo with a braided side twist and a gold hair ornament. Her visible styling should emphasize healthy, luxurious hair with clean strand definition and luminous highlights. She wears an ornate fantasy dress in {argument name=\"outfit colors\" default=\"black, white, and violet\"}, featuring a high black collar with gold filigree, white floral lace over the bodice, translucent puffed sleeves with lace cuffs, jeweled purple crystal ornaments, and elegant arm accessories. A large faceted violet gemstone pendant rests at her chest, with matching purple earrings and decorative accents. Her pose is graceful and refined, one hand lightly raised near her chin and the other holding a small bouquet of purple flowers. Surround her with blooming violet flowers in the foreground and background, glowing butterflies, drifting petals, and a dreamy cathedral-like garden with gothic arches and spires softly blurred in the distance. Place an open book on the table in the lower foreground. Use warm backlighting, rim light through the hair, soft magical bloom, pastel lavender and pink atmosphere, sparkling particles, shallow depth of field, ultra-detailed textures, polished anime illustration, romantic fantasy mood, ethereal elegance, and a luxurious painterly finish.",
"previewImageUrl": "https://cms-assets.youmind.com/media/1777453212849_lh9pew_HHA47WybEAAft9f.jpg",
"source": {
"repo": "YouMind-OpenLab/awesome-gpt-image-2",
"license": "CC-BY-4.0",
"author": "美和",
"url": "https://x.com/tokikageyomikag/status/2049200427842064715#reversed-1"
}
}

View file

@ -0,0 +1,23 @@
{
"id": "profile-avatar-ethereal-blue-haired-fantasy-portrait",
"surface": "image",
"title": "Profile / Avatar - Ethereal Blue-Haired Fantasy Portrait",
"summary": "This prompt generates a soft, luminous anime-style fantasy character portrait, ideal for creating elegant vertical key art or character illustrations with flowing hair and a dreamy spring atmosphere.",
"category": "Profile / Avatar",
"tags": [
"portrait",
"anime",
"fantasy",
"3d-render"
],
"model": "gpt-image-2",
"aspect": "1:1",
"prompt": "A highly detailed anime fantasy portrait of {argument name=\"character name\" default=\"an elegant blue-haired fantasy woman\"}, shown from the back in a three-quarter pose, turning her head over her shoulder to look at the viewer with calm violet eyes and a soft, slightly distant expression. She has very long, flowing {argument name=\"hair color\" default=\"icy pastel blue\"} hair with layered wispy bangs, loose windblown strands, one small ahoge on top, and 1 dark curved horn with subtle crimson striping emerging from the left side of her head. Her outfit is a refined, backless fantasy gown with 4 visible main pieces: a dark fitted bodice, a white open-backed outer layer with ornate gold trim and pale embroidered patterns, 2 long detached sleeves that fade into translucent blue-violet pointed cuffs, and red-blue ribbon ornaments tied at the neck and waist. Add delicate jewel-like tassel details at the upper back and trailing ribbon ends drifting in the air. The scene is backlit by soft spring sunlight in a pale stone pavilion or arched balcony, with 1 large arch opening behind her and clusters of {argument name=\"flower type\" default=\"pink cherry blossoms\"} glowing in the top right background. Include a few drifting petals, luminous haze, subtle sparkles, and a dreamy pastel atmosphere. Composition is vertical, upper-thigh portrait, character centered slightly right, hair sweeping broadly across the left side of the frame. Render in a polished ethereal anime illustration style with soft bloom, translucent fabrics, glossy eyes, delicate linework, cool lavender and blue tones, gentle rim light, painterly background blur, and an emphasis on smooth elegant surfaces, clean fabric flow, and minimal wrinkling.",
"previewImageUrl": "https://cms-assets.youmind.com/media/1777367299255_7e01qg_HG7uRRbbIAABIeT.jpg",
"source": {
"repo": "YouMind-OpenLab/awesome-gpt-image-2",
"license": "CC-BY-4.0",
"author": "𝑳𝒊𝒊𝒈𝒋𝒎",
"url": "https://x.com/lchngjin91/status/2048836910676926484#reversed-0"
}
}

View file

@ -0,0 +1,21 @@
{
"id": "profile-avatar-glamorous-woman-in-black-portrait",
"surface": "image",
"title": "Profile / Avatar - Glamorous Woman in Black Portrait",
"summary": "This prompt generates a photorealistic luxury-style portrait of an elegant woman in a plunging black outfit, ideal for fashion editorial or beauty imagery.",
"category": "Profile / Avatar",
"tags": [
"portrait",
"cinematic"
],
"model": "gpt-image-2",
"aspect": "1:1",
"prompt": "A photorealistic half-body portrait of an elegant glamorous woman indoors, framed vertically from the upper chest to just above the head, standing slightly angled toward the camera with a poised, confident presence. She has {argument name=\"hair color\" default=\"dark brown\"} long loose wavy hair with a soft tousled texture, warm lightly tanned skin, and a slender neck and shoulders. She wears a fitted black long-sleeve dress or top with a very deep plunging V neckline in finely pleated fabric, creating a sleek sensual evening look, plus 1 delicate gold chain necklace with 1 small round pendant resting at the base of her neck. Use flattering warm ambient lighting, soft shadows, shallow depth of field, and a luxurious modern interior background with creamy beige walls, a blurred warm lamp glow on the left, and a bright window or doorway edge on the right. The mood is refined, feminine, and high-end, like a fashion editorial portrait of {argument name=\"subject\" default=\"a beautiful woman\"}, shot with realistic skin texture, subtle natural makeup, cinematic bokeh, and premium lifestyle photography styling.",
"previewImageUrl": "https://cms-assets.youmind.com/media/1777453184257_vb9hvl_HG9tAkOa4AAuRrn.jpg",
"source": {
"repo": "YouMind-OpenLab/awesome-gpt-image-2",
"license": "CC-BY-4.0",
"author": "LJ",
"url": "https://x.com/XLOOP37/status/2048976490575155202#reversed-1"
}
}

View file

@ -0,0 +1,21 @@
{
"id": "profile-avatar-hyper-realistic-selfie-texture-prompts",
"surface": "image",
"title": "Profile / Avatar - Hyper-Realistic Selfie Texture Prompts",
"summary": "Detailed prompt snippets for generating realistic skin textures and authentic phone selfie framing, focusing on visible pores and natural lighting.",
"category": "Profile / Avatar",
"tags": [
"portrait",
"fantasy"
],
"model": "gpt-image-2",
"aspect": "1:1",
"prompt": "realistic skin texture, visible pores around nose and cheeks, natural slight unevenness, no filter quality, handheld phone camera feel, slight angle, casual framing, filmed in a real environment, soft window light from the left, natural indoor lighting, no harsh highlights",
"previewImageUrl": "https://cms-assets.youmind.com/media/1777453164857_ghcikd_HG9U5wnbYAE3-76.jpg",
"source": {
"repo": "YouMind-OpenLab/awesome-gpt-image-2",
"license": "CC-BY-4.0",
"author": "Adrian Solarz",
"url": "https://x.com/adriansolarzz/status/2048950419204751574"
}
}

View file

@ -0,0 +1,23 @@
{
"id": "profile-avatar-lavender-fantasy-mage-portrait",
"surface": "image",
"title": "Profile / Avatar - Lavender Fantasy Mage Portrait",
"summary": "This prompt generates a polished anime-style fantasy portrait of an elegant mage princess with glossy blonde hair, purple flowers, and ornate crystal attire, ideal for character art or magical illustr",
"category": "Profile / Avatar",
"tags": [
"portrait",
"anime",
"cinematic",
"fantasy"
],
"model": "gpt-image-2",
"aspect": "1:1",
"prompt": "A highly detailed anime fantasy portrait of a beautiful young woman mage in a luminous flower garden at a castle. She is shown from about the waist up in a vertical composition, holding an ornate staff topped with a large faceted purple crystal in her right hand. Her face is obscured, but the rest of her design is elegant and refined. She has {argument name=\"hair color\" default=\"platinum blonde\"} hair, long and silky with a smooth glossy finish, soft flowing strands, delicate highlights, and no frizz or messy dryness; the hair is partially braided on one side and decorated with 3 large purple flowers and fine gold filigree hair ornaments. She wears a {argument name=\"dress color\" default=\"lavender and white\"} fantasy gown with off-shoulder ruffled sleeves, translucent fabric, layered chiffon, intricate gold trim, embroidered details, and 3 visible purple gemstones set into the outfit and jewelry at the collar, chest, and waist. Add a jeweled choker-like collar and elegant arm details with gold chains. The background is a dreamy palace courtyard with purple blossoms, flowering vines, stone arches, and distant castle spires, filled with glowing particles and drifting petals. Use strong warm backlighting mixed with soft pastel ambient light, sparkling highlights, rim light through the hair, ethereal bloom, and a romantic magical atmosphere. Color palette focused on lavender, violet, soft pink, pearl white, and gold. Ultra-detailed, polished anime illustration, delicate linework, glossy fabric reflections, cinematic depth of field, premium fantasy card art aesthetic.",
"previewImageUrl": "https://cms-assets.youmind.com/media/1777453212320_egzd24_HHA47W2aIAEKWQz.jpg",
"source": {
"repo": "YouMind-OpenLab/awesome-gpt-image-2",
"license": "CC-BY-4.0",
"author": "美和",
"url": "https://x.com/tokikageyomikag/status/2049200427842064715#reversed-0"
}
}

View file

@ -0,0 +1,21 @@
{
"id": "profile-avatar-monochrome-studio-portrait",
"surface": "image",
"title": "Profile / Avatar - Monochrome Studio Portrait",
"summary": "A high-end commercial photography prompt for a monochrome portrait with a distinctive split-background and dramatic studio lighting.",
"category": "Profile / Avatar",
"tags": [
"portrait",
"cinematic"
],
"model": "gpt-image-2",
"aspect": "1:1",
"prompt": "A stunning black and white studio portrait of {argument name=\"subject\" default=\"uploaded person\"}. Eye-level medium shot, framed from the waist up. The subject is standing with his arms casually but firmly crossed over his chest. He is looking downward and slightly off-camera to the left with a calm, contemplative posture. He is wearing a {argument name=\"outfit\" default=\"dark, heavy-textured waffle-knit long-sleeve sweater\"} and a delicate silver chain necklace with a small pendant. He is wearing a classic analog watch with a light dial and leather strap on the lower arm. The background is a {argument name=\"background style\" default=\"stark, graphic vertical split: pure white on the left half and pure deep black on the right half\"}. High-end commercial photography, monochrome masterpiece. Soft but dramatic directional studio lighting originating from the left, highlighting the textures of the clothing and skin while casting natural, smooth shadows on the right side. Crisp focus, hyper-realistic,8k resolution, cinematic composition. ar 4:5",
"previewImageUrl": "https://cms-assets.youmind.com/media/1777367273368_hp9n0c_HG7mqKmb0AA1ecq.jpg",
"source": {
"repo": "YouMind-OpenLab/awesome-gpt-image-2",
"license": "CC-BY-4.0",
"author": "K",
"url": "https://x.com/ChillaiKalan__/status/2048828505497198838"
}
}

View file

@ -0,0 +1,21 @@
{
"id": "profile-avatar-old-photo-restoration-to-dslr-portrait",
"surface": "image",
"title": "Profile / Avatar - Old Photo Restoration to DSLR Portrait",
"summary": "This prompt restores a damaged vintage 4-person family photo into a clean, colorized, high-resolution realistic portrait for photo repair and enhancement.",
"category": "Profile / Avatar",
"tags": [
"portrait",
"fantasy"
],
"model": "gpt-image-2",
"aspect": "1:1",
"prompt": "Using the provided reference image, restore the damaged old family photo into a natural-looking modern high-resolution portrait while keeping the same 4 people, pose, framing, clothing, and outdoor rural setting unchanged. Remove all visible age damage including tears, cracks, creases, stains, worn paper edges, scratches, and fading. Convert the black-and-white sepia image into realistic soft color, preserving accurate skin tones and neutral earth-toned clothing. Enhance fine detail, sharpen fabric and hair texture, improve contrast and dynamic range, and upscale it to professional DSLR-quality realism with clean focus and a subtle shallow depth of field, as if photographed on a {argument name=\"camera model\" default=\"Canon EOS R6 II\"}. Keep the result highly realistic, natural, and faithful to the original faces and proportions.",
"previewImageUrl": "https://cms-assets.youmind.com/media/1777453186815_er6vgp_HG-IvNXaIAALR4b.jpg",
"source": {
"repo": "YouMind-OpenLab/awesome-gpt-image-2",
"license": "CC-BY-4.0",
"author": "摆烂程序媛",
"url": "https://x.com/wanerfu/status/2049006709692359015#reversed-1"
}
}

View file

@ -0,0 +1,21 @@
{
"id": "profile-avatar-poetic-woman-in-garden-portrait",
"surface": "image",
"title": "Profile / Avatar - Poetic Woman in Garden Portrait",
"summary": "This prompt generates a realistic editorial-style portrait of a bookish young woman in a sunlit garden, ideal for lifestyle photography, literary branding, or elegant character imagery.",
"category": "Profile / Avatar",
"tags": [
"portrait",
"fantasy"
],
"model": "gpt-image-2",
"aspect": "1:1",
"prompt": "A realistic outdoor portrait of a thoughtful, naturally beautiful young woman standing on a garden path in soft golden-hour light. She is framed from about mid-thigh upward, centered in the image, facing the camera with a relaxed upright posture. She has {argument name=\"hair color\" default=\"dark brown\"} long, voluminous, loosely curly hair with a slightly tousled texture, falling around her shoulders. Dress her in an oversized cream-white knit sweater with long sleeves and dark high-waisted loose trousers or a flowing skirt-like bottom in deep navy or black. A pair of thin round eyeglasses hangs from the neckline of the sweater. In one hand she holds a sharpened yellow pencil, and in the other she carries an open sketchbook or notebook with slightly worn pages, suggesting she is writing, sketching, or observing nature. The mood should feel literary, artistic, intelligent, and understated rather than glamorous. Place her in a lush garden with 1 visible stone pathway, abundant soft greenery, and blurred flowers in the foreground and background. Use shallow depth of field with creamy bokeh, warm sunlight filtering through trees behind her, and gentle natural highlights on her hair and sweater. The image should look like a candid editorial photograph, highly realistic, soft and tasteful, with muted natural colors, subtle texture, and an atmosphere of calm, cultured beauty. Vertical composition, 4:5 portrait orientation.",
"previewImageUrl": "https://cms-assets.youmind.com/media/1777453183422_nu32e1_HG9s-kFbMAACMYA.jpg",
"source": {
"repo": "YouMind-OpenLab/awesome-gpt-image-2",
"license": "CC-BY-4.0",
"author": "LJ",
"url": "https://x.com/XLOOP37/status/2048976490575155202#reversed-0"
}
}

View file

@ -0,0 +1,23 @@
{
"id": "profile-avatar-professional-identity-portrait-wallpaper",
"surface": "image",
"title": "Profile / Avatar - Professional Identity Portrait Wallpaper",
"summary": "Generates a high-resolution, premium wallpaper featuring a subject in professional attire with career-related activities and typography.",
"category": "Profile / Avatar",
"tags": [
"portrait",
"cinematic",
"fantasy",
"typography"
],
"model": "gpt-image-2",
"aspect": "1:1",
"prompt": "Create a portrait size wallpaper of pride in carrying out the profession as an ({argument name=\"name\" default=\"ALINA\"}), in the wallpaper contains a photo of the attached subject wearing a uniform or things related to the profession, make a pose, the subject's expression looks happy, don't have the same expression as the attached photo, give the wallpaper ornaments, decorations related to the profession, add several activities related to the profession arranged neatly, precisely, harmoniously, the typography says \"I am ({argument name=\"job title\" default=\"ALINA FASHION TEACHER\"}) \"above the subject's head, the font adjusts to the subject's job, each part of the wallpaper must be neat, the wallpaper visuals should not look monotonous, should not look stiff, must be original style wallpaper cinematic resolution 8K coloring, grading, wallpaper effects must look premium. Face and body exactly same as uploaded image.",
"previewImageUrl": "https://cms-assets.youmind.com/media/1777453121103_le4xip_HG958SlbsAAESjg.jpg",
"source": {
"repo": "YouMind-OpenLab/awesome-gpt-image-2",
"license": "CC-BY-4.0",
"author": "𝗦𝗮𝗻𝗶𝗮",
"url": "https://x.com/saniaspeaks_/status/2048990448882942051"
}
}

View file

@ -0,0 +1,21 @@
{
"id": "profile-avatar-realistically-imperfect-ai-selfie",
"surface": "image",
"title": "Profile / Avatar - Realistically Imperfect AI Selfie",
"summary": "A creative prompt used with GPT Image 2 to generate a 'failed' selfie that looks like an accidental, low-quality smartphone snapshot.",
"category": "Profile / Avatar",
"tags": [
"portrait",
"fantasy"
],
"model": "gpt-image-2",
"aspect": "1:1",
"prompt": "ChatGPT, you've been with me for a while now, and I want to see what you look like. Please generate a photo similar to an {argument name=\"shooting method\" default=\"accidental selfie\"} taken with an {argument name=\"phone model\" default=\"iPhone\"}: no clear subject, no intentional composition, just a very ordinary, even slightly failed snapshot. The photo should have slight motion blur, uneven lighting, light overexposure, an awkward angle, and chaotic composition, presenting an 'overly realistic candid' feeling, as if it were a selfie accidentally triggered while taking the phone out of a pocket.",
"previewImageUrl": "https://cms-assets.youmind.com/media/1777453151202_3usbgm_HHAkoXnaMAAFvsx.jpg",
"source": {
"repo": "YouMind-OpenLab/awesome-gpt-image-2",
"license": "CC-BY-4.0",
"author": "Tz",
"url": "https://x.com/Tz_2022/status/2049178230762934731"
}
}

View file

@ -0,0 +1,22 @@
{
"id": "profile-avatar-signed-marker-portrait-on-shikishi",
"surface": "image",
"title": "Profile / Avatar - Signed Marker Portrait on Shikishi",
"summary": "This generates a lively signed marker-style portrait on a square shikishi board, useful for fan-art autographs, commemorative illustration posts, and personalized thank-you visuals.",
"category": "Profile / Avatar",
"tags": [
"portrait",
"fantasy",
"3d-render"
],
"model": "gpt-image-2",
"aspect": "1:1",
"prompt": "A lively hand-drawn fashion portrait in a changed illustration style, made to look like a signed fan-art sketch drawn with markers on a square white shikishi board with a thin gold border. Show a stylish young woman from about the waist up, leaning slightly forward with one elbow resting up near her face in a casual, friendly pose. Her face area is covered by a simple rectangular censor block in a muted beige tone. She has shoulder-length medium brown hair with warm highlights, soft volume, side-swept bangs, and flipped-out ends. Render the art with expressive black ink outlines, visible marker strokes, watercolor-like blending, sketchy hatching, and an energetic, vivid handmade feel. She wears a fitted dark gray ribbed long-sleeve knit top with subtle puffed shoulders, layered delicate gold necklaces, a dangling pearl earring, a beige crossbody bag strap running diagonally across her chest, and a light beige skirt or dress visible at the waist. Leave plenty of clean white background around the figure. Add 2 small sparkle doodles on the left side. Add handwritten Japanese thank-you messages and signature-style black ink writing around the portrait: at upper right write {argument name=\"top message\" default=\"ありがとう!\"} with an underline and a small heart, beneath it place a large stylized autograph reading {argument name=\"signature name\" default=\"Yui\"} with a smiling face mark and a heart, at lower left write {argument name=\"side message\" default=\"いつも応援してくれてありがとう♡\"}, and at lower right write the date {argument name=\"date\" default=\"2024.5.20\"} with another heart. The overall image should feel warm, personal, lively, and like a celebratory signed illustration on a square autograph board.",
"previewImageUrl": "https://cms-assets.youmind.com/media/1777367317129_2rohn0_HG8hIdab0AAwzdp.jpg",
"source": {
"repo": "YouMind-OpenLab/awesome-gpt-image-2",
"license": "CC-BY-4.0",
"author": "ダルトワ★TV",
"url": "https://x.com/MireilleDartois/status/2048894364479565869#reversed-0"
}
}

View file

@ -0,0 +1,23 @@
{
"id": "profile-avatar-snow-rabbit-empress-portrait",
"surface": "image",
"title": "Profile / Avatar - Snow Rabbit Empress Portrait",
"summary": "A realistic fantasy portrait prompt for generating a regal rabbit-themed woman in ornate winter hanfu standing in a snowy mountain temple setting.",
"category": "Profile / Avatar",
"tags": [
"portrait",
"cinematic",
"fantasy",
"nature"
],
"model": "gpt-image-2",
"aspect": "1:1",
"prompt": "A cinematic fantasy portrait of an elegant East Asian-inspired woman standing outdoors in a snowy mountain temple courtyard, centered in the frame from about waist-up. She wears a luxurious winter hanfu in glossy white and deep black satin with soft white fur trim at the collar and sleeves, embroidered with rabbit motifs and delicate floral patterns. Her long straight hair is silver-white, falling over both shoulders, and she wears an ornate silver headdress with filigree, pearls, dangling tassels, a pale turquoise jewel, and prominent upright white rabbit ears. Her face is deliberately obscured by a smooth rectangular blur block. Snow is falling across the scene. The background shows a dramatic cold blue-gray sky, snow-covered pine trees, distant jagged mountains, stone lanterns, and traditional Chinese temple buildings with curved tiled roofs on the right side. Mood is ethereal, regal, and wintry, with soft diffused lighting, shallow depth of field, high detail fabric texture, realistic fantasy styling, and a polished gpt-image-2 aesthetic.",
"previewImageUrl": "https://cms-assets.youmind.com/media/1777453211307_ml0yqj_HG_dACOaUAArlU6.jpg",
"source": {
"repo": "YouMind-OpenLab/awesome-gpt-image-2",
"license": "CC-BY-4.0",
"author": "전자넹_특이점",
"url": "https://x.com/zeonzwane_spud/status/2049099351310692544#reversed-2"
}
}

View file

@ -0,0 +1,23 @@
{
"id": "profile-avatar-snow-rabbit-mask-hanfu-portrait",
"surface": "image",
"title": "Profile / Avatar - Snow Rabbit Mask Hanfu Portrait",
"summary": "This prompt generates a cinematic winter fantasy portrait of a masked woman in a rabbit-themed white Hanfu, ideal for elegant character art and atmospheric AI showcase imagery.",
"category": "Profile / Avatar",
"tags": [
"portrait",
"cinematic",
"fantasy",
"nature"
],
"model": "gpt-image-2",
"aspect": "1:1",
"prompt": "A serene winter fantasy portrait of a woman standing outdoors in softly falling snow, framed from about mid-thigh upward, wearing an elegant traditional Hanfu-inspired robe in white and black. Her face is fully covered by a smooth white rabbit mask with upright pink-lined ears, small black eye openings, and a minimal cute expression. She has very long straight silver-white hair flowing past her waist, with delicate white floral and branch-like hair ornaments on both sides. Her robe is bright white with subtle embroidered silver detailing on the chest and shoulders, very wide draping sleeves, black trim along the collar and sleeve edges, and a fitted black waist sash tied at the front with tasseled cords and a snowflake-like ornament. The garment features visible rabbit motifs: 4 illustrated white rabbits in total, with 2 large rabbits near the outer lower sleeves, 1 small hopping rabbit on the lower black skirt panel, and 1 seated rabbit on the front lower skirt panel. The atmosphere is quiet, ethereal, and cinematic, with a cold blue-gray palette, shallow depth of field, and soft natural winter light. In the background, place blurred snow-covered traditional East Asian buildings and distant steep mountains, creating a misty alpine temple setting. Add gentle snowfall across the entire image, ultra-detailed fabric texture, soft volumetric haze, and a refined dreamlike gpt-image-2 aesthetic.",
"previewImageUrl": "https://cms-assets.youmind.com/media/1777453211026_n5y31f_HG_dAB7aoAAZg6K.jpg",
"source": {
"repo": "YouMind-OpenLab/awesome-gpt-image-2",
"license": "CC-BY-4.0",
"author": "전자넹_특이점",
"url": "https://x.com/zeonzwane_spud/status/2049099351310692544#reversed-1"
}
}

View file

@ -0,0 +1,22 @@
{
"id": "profile-avatar-snowy-rabbit-hanfu-portrait",
"surface": "image",
"title": "Profile / Avatar - Snowy Rabbit Hanfu Portrait",
"summary": "This prompt generates an ultra-detailed fantasy beauty portrait of a rabbit-eared woman in embroidered hanfu, ideal for elegant character art, costume design, or cinematic AI portrait showcases.",
"category": "Profile / Avatar",
"tags": [
"portrait",
"cinematic",
"fantasy"
],
"model": "gpt-image-2",
"aspect": "1:1",
"prompt": "A highly detailed fantasy portrait of a young woman in side profile wearing elegant white rabbit ears and traditional East Asian hanfu in a snowy winter garden. She has {argument name=\"hair color\" default=\"silver white\"} hair, extremely long, silky, and softly wind-swept, styled with ornate floral and jeweled hair ornaments. The face is mostly obscured by a large centered rectangular blur mask in muted gray, covering the eyes, nose, and upper cheeks, as if censored for privacy. The rabbit ears are tall, plush, white, and realistic, with pale pink inner fur, attached through an elaborate headdress featuring black lace, silver filigree, small blossoms, crystals, beads, and tassels. Visible in the headdress are 2 round embroidered ornaments with black rabbit motifs, plus multiple dangling tassels in black and white, delicate chains, and floral metal branches. She wears asymmetrical long earrings with beads and tassels. Her robe is a layered {argument name=\"outfit style\" default=\"black-and-white hanfu with rabbit embroidery\"}, with glossy dark trim, translucent pale fabric, and embroidered rabbit designs visible in 3 places: one small rabbit near the collar, one large circular rabbit emblem on the chest, and one faint rabbit motif on the sleeve. The overall palette is monochrome silver, white, charcoal, and soft gray, creating a cold ethereal mood. Snow is gently falling in the foreground and background. Behind her is a softly blurred {argument name=\"background setting\" default=\"snow-covered classical Chinese garden with pavilion roofs\"}, with shallow depth of field, atmospheric haze, cinematic bokeh, ultra-fine textile detail, soft winter lighting, elegant composition, and a serene mystical aesthetic. Vertical portrait framing, upper torso crop, luxurious fantasy costume photography, ultra-realistic, high detail, polished editorial beauty image.",
"previewImageUrl": "https://cms-assets.youmind.com/media/1777453211568_as7go2_HG_dAFracAA38vJ.jpg",
"source": {
"repo": "YouMind-OpenLab/awesome-gpt-image-2",
"license": "CC-BY-4.0",
"author": "전자넹_특이점",
"url": "https://x.com/zeonzwane_spud/status/2049099351310692544#reversed-3"
}
}

View file

@ -0,0 +1,22 @@
{
"id": "profile-avatar-snowy-rabbit-spirit-portrait",
"surface": "image",
"title": "Profile / Avatar - Snowy Rabbit Spirit Portrait",
"summary": "This prompt generates a serene fantasy portrait of an anonymous rabbit-eared woman in winter, ideal for atmospheric character art and stylized profile illustrations.",
"category": "Profile / Avatar",
"tags": [
"portrait",
"fantasy",
"nature"
],
"model": "gpt-image-2",
"aspect": "1:1",
"prompt": "A soft, painterly portrait of a mysterious young woman with {argument name=\"hair color\" default=\"long white hair\"} and 2 tall rabbit ears rising above her head, centered in a vertical composition from chest up. Her face is completely obscured by a flat rectangular censor block in muted beige, creating an anonymous surreal effect. She wears a traditional kimono-inspired robe in warm ivory with bold black trim: 3 visible black sections total, including the wide crossover collar, 2 black sleeve bands, and a black waist sash tied in front. On the left chest is 1 embroidered white rabbit patch outlined in brown. On the right side of her hair hangs 1 red braided cord ornament tied into a bow, decorated with 2 tassels and 1 small rabbit-shaped charm. The hair is long, flowing, slightly windswept, and silky, framing the shoulders. Set her in a quiet snowy landscape with falling snow, pale gray winter atmosphere, bare trees, and a softly blurred traditional pagoda silhouette in the distance on the right. Use a delicate East Asian fantasy aesthetic, muted colors, gentle lighting, subtle texture like watercolor or gouache on paper, highly refined costume details, calm mood, and a centered symmetrical composition.",
"previewImageUrl": "https://cms-assets.youmind.com/media/1777453209807_szh7zz_HG_c_-ca4AAz43H.jpg",
"source": {
"repo": "YouMind-OpenLab/awesome-gpt-image-2",
"license": "CC-BY-4.0",
"author": "전자넹_특이점",
"url": "https://x.com/zeonzwane_spud/status/2049099351310692544#reversed-0"
}
}

View file

@ -0,0 +1,20 @@
{
"id": "profile-avatar-song-dynasty-hanfu-portrait",
"surface": "image",
"title": "Profile / Avatar - Song Dynasty Hanfu Portrait",
"summary": "An optimized prompt for generating a detailed and realistic portrait of a beauty in Song Dynasty traditional Hanfu within an ancient courtyard.",
"category": "Profile / Avatar",
"tags": [
"portrait"
],
"model": "gpt-image-2",
"aspect": "1:1",
"prompt": "An {argument name=\"character description\" default=\"18-year-old Chinese Internet celebrity beauty\"}, with a model figure, exquisite facial features, cold and sweet temperament, wearing {argument name=\"outfit\" default=\"elegant light pink Song Dynasty Hanfu\"}, exquisite clothing details, with ancient-style buns, exquisite hairpin headdresses and embroidered shoes. The whole body stands in the front, with a natural and elegant posture, slightly showing the curve of the body. The {argument name=\"setting\" default=\"scene is a beautiful ancient-style courtyard, with flowers and trees, cloisters and soft light and shadow\"}. The picture is a high-quality ultra-realistic photography style, the characters are clear, the skin is delicate, the whole is aesthetic and high-end, and the 9:16 vertical composition.",
"previewImageUrl": "https://cms-assets.youmind.com/media/1777453126318_sew6kg_HG-PNvQbsAAup2e.jpg",
"source": {
"repo": "YouMind-OpenLab/awesome-gpt-image-2",
"license": "CC-BY-4.0",
"author": "Shinning",
"url": "https://x.com/Shinning1010/status/2049013833021145235"
}
}

View file

@ -0,0 +1,23 @@
{
"id": "social-media-post-anime-pokemon-shop-outfit-teaser-poster",
"surface": "image",
"title": "Social Media Post - Anime Pokémon Shop Outfit Teaser Poster",
"summary": "This prompt generates a soft pastel anime fashion announcement poster featuring a blurred-face girl in a blue dress inside a Pokémon store, ideal for outfit reveal teasers and character promo visuals.",
"category": "Social Media Post",
"tags": [
"anime",
"fantasy",
"typography",
"action"
],
"model": "gpt-image-2",
"aspect": "1:1",
"prompt": "A dreamy pastel anime fashion announcement poster set inside a bright Pokémon merchandise shop. The composition is vertical and split visually into two zones: a large translucent information panel on the left and a full-body character showcase on the right. The scene has a soft, elegant, airy atmosphere with diffused indoor lighting, creamy highlights, gentle reflections on the polished floor, and a refined shoujo illustration style. In the background, show a clearly recognizable Pokémon store interior with display shelves, the Pokémon logo sign, a large Poké Ball emblem on the wall, potted plants, plush toys, and figures; visible Pokémon merchandise includes exactly 3 prominent character plushies or mascots: Pikachu at the bottom right, plus 2 small shelf plushies resembling Piplup and another pastel blue-green character. The girl stands slightly right of center in a graceful fashion pose with one leg crossing in front of the other, one hand lightly raised near her chest, and the other relaxed outward. Her face is intentionally obscured by a soft rectangular blur block. She has long wavy {argument name=\"hair color\" default=\"platinum blonde\"} hair with loose curls and a delicate feminine look. She wears a refined pastel outfit: a frilled white high-neck blouse with layered ruffles, a light blue sleeveless pinafore-style dress with a fitted waist and a flowing mid-calf flared skirt, a small white crossbody purse with a flap, white ankle socks, and glossy black Mary Jane shoes. Add subtle pink earrings. The outfit should feel classy, fresh, and cute, with gentle fabric movement. On the left, place a frosted semi-transparent poster panel with elegant typography and decorative flourishes. Include exactly 5 text blocks or labeled areas on this panel: 1) a top banner with Japanese text \"次回衣装プロンプト公開\" above large cursive English text {argument name=\"headline text\" default=\"Next Outfit\"}; 2) a name block with Japanese text \"セラス・柳田・リリエンフェルト\" and smaller romanized text \"Ceras Yanagida Lilienfeld\"; 3) a teaser line reading \"次回の衣装も お楽しみに!\" with a small Poké Ball icon; 4) a bordered description box titled \"Next Coordinate\" followed by several lines of small Japanese body text; 5) a bottom ribbon reading {argument name=\"footer text\" default=\"Coming Soon...\"} and \"STAY TUNED!\" with a small Pikachu silhouette. Use pale blue, white, silver-gray, and blush pastel tones throughout. Add faint ornamental corner decorations and a polished promotional layout, like a boutique fashion teaser poster for a themed anime character outfit reveal.",
"previewImageUrl": "https://cms-assets.youmind.com/media/1777453222738_l3artn_HG_koUwaAAAk7hW.jpg",
"source": {
"repo": "YouMind-OpenLab/awesome-gpt-image-2",
"license": "CC-BY-4.0",
"author": "ねずみ男(AIイラスト専用)",
"url": "https://x.com/ratman_aiillust/status/2049107740686204942#reversed-0"
}
}

View file

@ -0,0 +1,20 @@
{
"id": "social-media-post-cinematic-elevator-scene",
"surface": "image",
"title": "Social Media Post - Cinematic Elevator Scene",
"summary": "A prompt for generating a moody, cinematic scene of a woman inside a metallic elevator with realistic lighting and reflections.",
"category": "Social Media Post",
"tags": [
"cinematic"
],
"model": "gpt-image-2",
"aspect": "1:1",
"prompt": "Inside an elevator, the metal walls have a slight cold reflection, and the ceiling lights are whitish but uneven. The space is enclosed and quiet. A {argument name=\"subject\" default=\"young Asian girl\"} stands in a corner position of the elevator, with a background of slightly distorted mirrors and floor lights.",
"previewImageUrl": "https://cms-assets.youmind.com/media/1777453149026_gd2k50_HHCSvymboAAVscc.jpg",
"source": {
"repo": "YouMind-OpenLab/awesome-gpt-image-2",
"license": "CC-BY-4.0",
"author": "Leo AIPhi",
"url": "https://x.com/xiaochou1945/status/2049299191550407147"
}
}

View file

@ -0,0 +1,21 @@
{
"id": "social-media-post-confused-elf-girl-at-pastel-desk",
"surface": "image",
"title": "Social Media Post - Confused Elf Girl at Pastel Desk",
"summary": "This prompt generates a soft pastel anime illustration of an elf girl typing at her computer in a cozy kawaii workspace, ideal for social posts, wallpapers, or streamer-themed art.",
"category": "Social Media Post",
"tags": [
"anime",
"fantasy"
],
"model": "gpt-image-2",
"aspect": "1:1",
"prompt": "A cute pastel anime illustration of a young elf girl streamer or office worker sitting at a desk and typing on a mechanical keyboard in a cozy bedroom workspace, shown from a front three-quarter view with a large black computer monitor in the left foreground partially blocking her body. She has long wavy {argument name=\"hair color\" default=\"orange\"} hair with glossy highlights, pointed elf ears, and a small red flower hair clip on the right side, wearing a light blue pajama-style blouse covered in red heart prints with a very frilly white lace collar and a shiny red ribbon bow at the neck. Her hands are on the keyboard, nails painted soft pink, and she sits in a rounded pink desk chair. Above her head is a speech bubble containing a large question mark, suggesting confusion while working at the computer. The room is soft, bright, and feminine, with a pale pink and cream color palette, shallow depth of field, and delicate line art. In the background, include 1 framed wall picture with a pink animal and heart motif, 1 small potted plant near the center-left, 1 plush toy on a shelf behind her, 2 sticky notes on the upper right wall, one with a plus sign and one reading {argument name=\"note text\" default=\"がんばろう!\"}, 1 blue cat figurine or plush on the right shelf, 1 small potted plant on the right, 4 pastel binders or books on the lower right shelf, and 1 white mug with a pink heart on the desk in the lower right corner. The computer monitor should have a subtle glowing blue heart icon on its back, and the keyboard should have RGB lighting. Clean polished cel-shaded anime style, high detail, soft ambient lighting, cozy gamer desk atmosphere, pastel kawaii decor, 4k illustration.",
"previewImageUrl": "https://cms-assets.youmind.com/media/1777453203838_2bzdt9_HHAXnBlbwAA5Ke4.jpg",
"source": {
"repo": "YouMind-OpenLab/awesome-gpt-image-2",
"license": "CC-BY-4.0",
"author": "える",
"url": "https://x.com/el_el_san/status/2049164203542679602#reversed-0"
}
}

View file

@ -0,0 +1,18 @@
{
"id": "social-media-post-editorial-fashion-photography",
"surface": "image",
"title": "Social Media Post - Editorial Fashion Photography",
"summary": "A moody, fashion-focused prompt for a minimalist studio scene with soft lighting and warm tones.",
"category": "Social Media Post",
"tags": [],
"model": "gpt-image-2",
"aspect": "1:1",
"prompt": "A woman with {argument name=\"hair color\" default=\"long red hair\"} crouching in a minimalist studio setting with a {argument name=\"background color\" default=\"soft pink background\"}. She is wearing a {argument name=\"dress style\" default=\"fitted black dress\"} and black high heels. She holds a lit match in one hand, looking at it thoughtfully, while a small decorated cake with a single lit candle sits on the floor in front of her. The lighting is soft and warm, casting gentle highlights and subtle shadows, creating a moody, editorial atmosphere.",
"previewImageUrl": "https://cms-assets.youmind.com/media/1777453137877_aqjk7l_HHAumFda4AAVbjJ.jpg",
"source": {
"repo": "YouMind-OpenLab/awesome-gpt-image-2",
"license": "CC-BY-4.0",
"author": "Miz",
"url": "https://x.com/mizq06/status/2049189070732157408"
}
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,21 @@
{
"id": "social-media-post-psg-transfer-announcement-poster",
"surface": "image",
"title": "Social Media Post - PSG Transfer Announcement Poster",
"summary": "A bold, professional football signing poster for announcing a player's move to Paris Saint-Germain on social media or sports promo graphics.",
"category": "Social Media Post",
"tags": [
"cinematic",
"typography"
],
"model": "gpt-image-2",
"aspect": "1:1",
"prompt": "Create a dramatic football transfer announcement poster in a vertical social-media format, centered on a photorealistic adult male soccer player wearing a modern Paris Saint-Germain home jersey, arms crossed, chest-up framing, strong athletic build, face mostly obscured by a soft rectangular blur block for anonymity, short close-cropped hair visible around the edges. Use a deep navy blue PSG-themed color palette with bold red and white accents. The jersey should feature a central red vertical stripe bordered by white, a red swoosh-style sports logo on the left side from the viewer's perspective, the PSG crest on the opposite chest, and faint sponsor lettering across the torso. Place the player in front of a layered graphic background featuring an oversized faded PSG crest filling most of the upper-right background, a dark Eiffel Tower silhouette on the right side, painterly brush-stroke textures, subtle grunge, and a white vertical paint strip on the left with a rough red brush accent near the middle. Include the PSG club badge near the upper left. Add left-side stacked slogan text in bold uppercase sans serif reading: \"NEW CLUB. NEW CHAPTER. PARIS.\" with the last word in red. Add a huge distressed white block headline across the lower middle reading \"{argument name=\"player surname\" default=\"MBAPPE\"}\", with smaller spaced uppercase first name above it reading \"{argument name=\"player first name\" default=\"KYLIAN\"}\". Overlay a red handwritten script across the big surname saying \"{argument name=\"welcome text\" default=\"Welcome To Paris\"}\". At the bottom center, add small uppercase text \"PARIS SAINT-GERMAIN\" and beneath it the year \"{argument name=\"year\" default=\"2026\"}\" in red with thin divider lines on each side. In the lower left, include small stacked text \"ICI C'EST PARIS\" with \"PARIS\" in red. In the lower right, add a circular stamp-style badge reading \"PARIS IS MAGIQUE\". Use cinematic lighting, high contrast, premium sports-brand poster design, sharp fabric texture, moody shadows, gritty editorial finish, and polished transfer-window announcement aesthetics.",
"previewImageUrl": "https://cms-assets.youmind.com/media/1777453173788_tb78r0_HHDu7nUWQAAWH7O.jpg",
"source": {
"repo": "YouMind-OpenLab/awesome-gpt-image-2",
"license": "CC-BY-4.0",
"author": "UxUi Tega (Design & Ai)",
"url": "https://x.com/Tegadesigns/status/2049400556578382190#reversed-0"
}
}

View file

@ -0,0 +1,22 @@
{
"id": "social-media-post-showa-day-retro-culture-magazine-cover",
"surface": "image",
"title": "Social Media Post - Showa Day Retro Culture Magazine Cover",
"summary": "A warm editorial-style Japanese holiday feature page combining anime character art, nostalgic Showa-era street imagery, and magazine-style informational layout for seasonal cultural promotions.",
"category": "Social Media Post",
"tags": [
"anime",
"typography",
"food"
],
"model": "gpt-image-2",
"aspect": "1:1",
"prompt": "{\"type\":\"retro Japanese lifestyle magazine cover poster\",\"theme\":\"Showa Day feature celebrating nostalgic Japanese retro culture\",\"style\":\"clean editorial layout mixed with warm anime illustration, soft natural sunlight, nostalgic yet fresh atmosphere, cream paper background, olive green accents, elegant serif and Japanese Mincho typography\",\"aspect_ratio\":\"3:4 vertical\",\"headline\":{\"top_tags\":\"LIFESTYLE / FEATURE / RETRO CULTURE\",\"date_text\":\"{argument name=\\\"event date\\\" default=\\\"4.29\\\"} EVENT\",\"main_title\":\"昭和の日特集\",\"subtitle_ribbon\":\"懐かしさの中に、新しい発見を。\"},\"badge\":{\"position\":\"top right\",\"shape\":\"circular date stamp with botanical decoration\",\"text\":[\"4/29\",\"TUE.\",\"祝日\"]},\"main_text\":{\"intro_lines\":[\"今日は『昭和の日』です。\",\"昭和という時代を振り返り、\",\"これからの未来について考える日として\",\"制定されました。\",\"レトロな文化や暮らしには、\",\"今見ても魅力的なものが\",\"たくさんあります。\"]},\"layout\":{\"sections\":[{\"title\":\"main illustration\",\"position\":\"upper right\",\"count\":1,\"labels\":[\"full-body character in retro shopping street\"]},{\"title\":\"POINT\",\"position\":\"left lower column\",\"count\":3,\"labels\":[\"POINT 01 昭和の日とは?\",\"POINT 02 レトロ文化を楽しむ\",\"POINT 03 今の暮らしに活かす\"]},{\"title\":\"photo-style chibi panels\",\"position\":\"bottom right strip\",\"count\":3,\"labels\":[\"純喫茶でひと休み。\",\"レコードやおもちゃも素敵。\",\"思い出をノートに残して。\"]},{\"title\":\"footer summary\",\"position\":\"bottom center\",\"count\":1,\"labels\":[\"まとめ\"]}],\"decorations\":{\"botanical_sprigs_count\":6,\"bottom_icons_count\":3,\"bottom_icons\":[\"vinyl record\",\"retro camera\",\"coffee cup\"]}},\"character\":{\"gender_presentation\":\"cute anime girl\",\"age_appearance\":\"young teen to young adult chibi proportions\",\"hair\":{\"color\":\"{argument name=\\\"hair color\\\" default=\\\"medium brown\\\"}\",\"style\":\"messy high ponytail with loose fluffy strands and a red hair tie\"},\"eyes\":\"large amber-brown anime eyes\",\"outfit\":{\"count\":5,\"pieces\":[\"white oversized T-shirt with a red box logo reading {argument name=\\\"shirt logo text\\\" default=\\\"SUPPER\\\"}\",\"olive green cargo pants\",\"black low-top sneakers with white toe caps and laces\",\"cream canvas shoulder tote bag\",\"simple necklace\"]}},\"main_scene\":{\"setting\":\"sunny narrow retro Japanese alley with wooden storefronts and vintage signs\",\"background_elements\":{\"count\":9,\"items\":[\"vertical red sign ナショナル電球\",\"blue salt shop sign 塩 まるしお\",\"vertical sign 森永ミルク\",\"vertical sign 文具のサクラ堂\",\"red cylindrical post box\",\"wooden shop facades\",\"overhead utility wires\",\"green tree leaves casting dappled light\",\"stacked vintage radio and tin box in lower right\"]},\"pose\":\"walking confidently toward the viewer with one hand near the pocket and tote bag hanging from the shoulder\"},\"point_cards\":[{\"icon\":\"retro television\",\"title\":\"POINT 01\",\"body\":\"昭和という激動の時代を振り返る国民の祝日です。\"},{\"icon\":\"coffee cup\",\"title\":\"POINT 02\",\"body\":\"純喫茶や昭和レトロ雑貨巡りも人気です。\"},{\"icon\":\"camera\",\"title\":\"POINT 03\",\"body\":\"物を大切にする価値観を見直すきっかけになります。\"}],\"bottom_panels\":[{\"index\":1,\"scene\":\"character seated in a retro cafe with a green cream soda and coffee on a wooden table, warm interior, slight front view\",\"caption\":\"純喫茶でひと休み。\"},{\"index\":2,\"scene\":\"side view of the character browsing records or retro toys in a nostalgic shop filled with colorful posters and shelves\",\"caption\":\"レコードやおもちゃも素敵。\"},{\"index\":3,\"scene\":\"character sitting on a bench writing in a notebook, small camera in hand, cozy storefront backdrop\",\"caption\":\"思い出をノートに残して。\"}],\"summary_box\":{\"title\":\"まとめ\",\"text\":[\"懐かしさを楽しみながら、未来について考える。\",\"そんな一日にしてみるのも素敵です。\",\"昭和の魅力にふれる時間が、きっと、今をより豊かにしてくれるはずです。\"]},\"footer\":{\"left_text\":\"季節の行事をきっかけに、心豊かな毎日を。\",\"right_text\":\"NEXT ISSUE : 5.5 こどもの日特集\"},\"color_palette\":{\"count\":6,\"colors\":[\"warm cream\",\"olive green\",\"sepia brown\",\"sunlit gold\",\"brick red\",\"soft sky blue\"]}}",
"previewImageUrl": "https://cms-assets.youmind.com/media/1777453174956_qapj6l_HGy7GDlbYAAR6Np.jpg",
"source": {
"repo": "YouMind-OpenLab/awesome-gpt-image-2",
"license": "CC-BY-4.0",
"author": "Kazuch2ND@AI ART",
"url": "https://x.com/Kazuch75240438/status/2049292582606496252#reversed-0"
}
}

View file

@ -0,0 +1,20 @@
{
"id": "social-media-post-social-media-fashion-outfit-generation",
"surface": "image",
"title": "Social Media Post - Social Media Fashion Outfit Generation",
"summary": "A prompt to generate a week's worth of fashion blogger-style outfit recommendations based on a character profile, complete with item labels and prices.",
"category": "Social Media Post",
"tags": [
"fantasy"
],
"model": "gpt-image-2",
"aspect": "1:1",
"prompt": "Based on this character info card for a {argument name=\"subject\" default=\"girl\"}, generate a 7-day outfit recommendation guide suitable for her appearance, height, and weight. Use a {argument name=\"platform style\" default=\"Xiaohongshu\"} fashion blogger presentation style. Generate 7 images at once (one for each day), specifically labeling the styles and prices of accessories, shoes, hats, pendants, tops, pants, socks, and other items for easy reference.",
"previewImageUrl": "https://cms-assets.youmind.com/media/1777453151822_tkaefc_HG_wnqGbAAAq416.jpg",
"source": {
"repo": "YouMind-OpenLab/awesome-gpt-image-2",
"license": "CC-BY-4.0",
"author": "Rion Wu",
"url": "https://x.com/rionaifantasy/status/2049122261249204626"
}
}

View file

@ -0,0 +1,21 @@
{
"id": "social-media-post-travel-snapshot-collage-prompt",
"surface": "image",
"title": "Social Media Post - Travel Snapshot Collage Prompt",
"summary": "A detailed prompt for creating a nostalgic, 12-frame collage of smartphone-style travel photos depicting a solo journey.",
"category": "Social Media Post",
"tags": [
"cinematic",
"fantasy"
],
"model": "gpt-image-2",
"aspect": "1:1",
"prompt": "A 12-frame collage of candid, emotional snapshots of a young {argument name=\"ethnicity\" default=\"Chinese\"} woman traveling alone in {argument name=\"location\" default=\"Phuket Island\"}, casually captured on a {argument name=\"device\" default=\"smartphone\"}.\nEach frame feels like a fleeting personal memory — imperfect, sun-drenched, intimate, and unposed.\n\nThe woman has a naturally curvy figure with a soft, feminine silhouette, subtly emphasizing her bust without exaggeration. Her presence feels real and unstyled, like a private photo album.\n\nScenes include: walking barefoot on the beach, seaside under the strong sunlight, palm trees swaying, overexposed ocean reflections, small local cafés, a modest motel room, sunset the coast, night markets, views from inside a moving car. \n\nShot with a smartphone aesthetic: slight motion blur, soft focus, blown-out highlights from tropical sunlight, lens flare, sun glare, high ISO noise at night, uneven framing, accidental cropping.\n\nComposition feels random and spontaneous — subject sometimes off-center, partially cut off, mid-motion, or obscured by light leaks.\n\nLighting varies: harsh midday sun, warm golden hour glow, deep sunset tones, humid night street lighting.\n\nColor grading: faded cinematic tones, slightly desaturated with warm highlights, nostalgic film-like look, subtle grain, lifted blacks.\n\nEmotion: solitude, fleeting youth, bittersweet nostalgia, quiet introspection, like memories from a trip taken alone.\n\nLayout: 12 images arranged in a loose, imperfect collage grid, slightly tilted and misaligned like a scrapbook.\n\nNo text, no watermark.",
"previewImageUrl": "https://cms-assets.youmind.com/media/1777453145397_amcmoh_HG_1BaQb0AAKXrk.jpg",
"source": {
"repo": "YouMind-OpenLab/awesome-gpt-image-2",
"license": "CC-BY-4.0",
"author": "麻酱AI实验室",
"url": "https://x.com/zhongying14/status/2049128619134300200"
}
}

View file

@ -0,0 +1,20 @@
{
"id": "social-media-post-vintage-sign-painter-sketch",
"surface": "image",
"title": "Social Media Post - Vintage Sign-Painter Sketch",
"summary": "Generates a hand-drawn marker sketch on paper with realistic details like graphite lines and ink bleed, perfect for vintage lettering styles.",
"category": "Social Media Post",
"tags": [
"action"
],
"model": "gpt-image-2",
"aspect": "1:1",
"prompt": "A hand-lettered sketch of the phrase “{argument name=\"phrase\" default=\"Good Morning\"} ” on warm-white marker paper, drawn with a black brush marker. Soft graphite construction lines visible underneath the inked strokes. Slight ink bleed-through from the previous page showing as faint ghosting. Letterforms are vintage sign-painter caps. Confident single-pass strokes, not retraced. Paper edges visible at the margins. Studio scan, slightly warm white balance, 600 DPI texture, no digital cleanup. No vector outlines, no AI airbrushed shading, no perfect symmetry.",
"previewImageUrl": "https://cms-assets.youmind.com/media/1777453138935_3hpxkg_HHC-7jObsAAWmsk.jpg",
"source": {
"repo": "YouMind-OpenLab/awesome-gpt-image-2",
"license": "CC-BY-4.0",
"author": "Ashish Sheth",
"url": "https://x.com/commanderdgr8/status/2049347770725912874"
}
}

View file

@ -0,0 +1,22 @@
{
"id": "vr-headset-exploded-view-poster",
"surface": "image",
"title": "VR Headset Exploded View Poster",
"summary": "Generates a high-tech exploded view diagram of a VR headset with detailed component callouts and promotional text.",
"category": "Social Media Post",
"tags": [
"fantasy",
"3d-render",
"product"
],
"model": "gpt-image-2",
"aspect": "1:1",
"prompt": "{\n \"type\": \"exploded view product diagram poster\",\n \"subject\": \"VR headset\",\n \"style\": \"clean high-tech 3D render, studio lighting, glowing accents\",\n \"background\": \"{argument name=\\\"background color\\\" default=\\\"soft purple and blue gradient\\\"}\",\n \"header\": {\n \"logo\": \"∞ {argument name=\\\"product name\\\" default=\\\"Meta Quest 3\\\"}\",\n \"subtitle\": \"{argument name=\\\"main catchphrase\\\" default=\\\"まったく新しい現実を、まったく新しい構造から。\\\"}\"\n },\n \"layout\": {\n \"centerpiece\": \"vertically stacked exploded view of a VR headset showing 9 distinct layers of internal components: outer shell, camera sensors, motherboard with chip, pancake lenses, internal frame, battery packs, side straps, top strap, and facial interface cushion.\",\n \"callout_labels\": {\n \"count\": 8,\n \"left_side\": [\n \"Snapdragon® XR2 Gen 2\\n圧倒的な処理性能でリアルタイムな体験を。\",\n \"調整可能なIPD機構\\n幅広いユーザーに快適なフィット感を。\",\n \"精密設計されたヘッドストラップ\\n快適さと安定性を追求したエルゴミクス。\"\n ],\n \"right_side\": [\n \"フェイスプレート\\n洗練されたデザインと最適な重量バランス。\",\n \"トラッキングカメラ\\n高精度な位置トラッキングと環境認識を実現。\",\n \"パンケーキレンズ\\n薄型設計で広い視野角と鮮明な映像を提供。\",\n \"高性能バッテリー\\n長時間駆動を支える最適化された電源設計。\",\n \"柔らかなフェイスインターフェース\\n長時間でも快適な装着感を実現。\"\n ]\n },\n \"footer\": {\n \"left_text_block\": {\n \"headline\": \"{argument name=\\\"bottom headline\\\" default=\\\"体験は、構造から進化する。\\\"}\",\n \"body\": \"一つひとつのパーツに、没入体験を支える最先端テクロジーとこだわりの設計。Meta Quest 3は、未来を感じさせる体験を内部から生み出しています。\"\n },\n \"right_logo\": \"∞ Meta\"\n }\n }\n}",
"previewImageUrl": "https://cms-assets.youmind.com/media/1776658772018_lukyfw_HGSUfldbIAEiMWZ.jpg",
"source": {
"repo": "YouMind-OpenLab/awesome-gpt-image-2",
"license": "CC-BY-4.0",
"author": "woryホッピング中",
"url": "https://x.com/wory37303852/status/2045925660401795478#reversed-0"
}
}

View file

@ -0,0 +1,19 @@
{
"id": "3d-animated-boy-building-lego",
"surface": "video",
"title": "3D Animated Boy Building Lego",
"summary": "A multi-shot video prompt in 3D animation style describing a boy carefully assembling Lego pieces in a room, featuring time-lapse effects.",
"category": "General",
"tags": [],
"model": "seedance-2.0",
"aspect": "16:9",
"prompt": "Scene: A boy in a room seriously assembling Lego blocks. The visual style is 3D animation with vibrant colors, smooth lines, full of childlike fun and vitality. A time-lapse effect is added to show the assembly process.\nScene: Wide shot of the room, sunlight spilling onto the desk through the window. The boy sits at the desk focused on assembling Lego, with a serious expression. The camera slowly zooms in.\nScene: Time-lapse effect showing the boy quickly snapping Lego pieces together, the blocks gradually taking shape in his hands. The camera switches to different angles.\nScene: Close-up of hands, showing details of the boy skillfully assembling Lego, fingers moving nimbly. The camera follows the hand movements.\nScene: Time-lapse effect continues showing the assembly process. The Lego creation becomes complete, and the boy's expression changes from focused to satisfied.\nScene: The boy looks up with a satisfied smile. The camera pulls back to reveal the finished Lego masterpiece.\n\nDuration: 00:20",
"previewImageUrl": "https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/2dba80d5da706c3ea078ed69096c67d3/thumbnails/thumbnail.jpg",
"previewVideoUrl": "https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/2dba80d5da706c3ea078ed69096c67d3/downloads/default.mp4",
"source": {
"repo": "YouMind-OpenLab/awesome-seedance-2-prompts",
"license": "CC-BY-4.0",
"author": "Alex Zhang",
"url": "https://x.com/jojogh_007/status/2049123558102810714"
}
}

View file

@ -0,0 +1,23 @@
{
"id": "a-decade-of-refinement-glow-up",
"surface": "video",
"title": "A Decade of Refinement Glow-Up",
"summary": "A transformation prompt for Seedance 2.0 showing a man's transition from a casual 2016 setting to a luxurious 2026 Dubai lifestyle while maintaining character consistency.",
"category": "Advertising",
"tags": [
"cinematic",
"fantasy",
"product"
],
"model": "seedance-2.0",
"aspect": "16:9",
"prompt": "Create a 15-second ultra-realistic cinematic transformation video using the exact same man from the uploaded reference image. Maintain perfect face consistency, same hairstyle, facial features, identity, and body proportions throughout. No face change. Concept: “2026 is the new 2016” nostalgia-to-luxury glow-up. Scene 1: 2016 version — simple casual clothes, basic hairstyle, walking alone on a normal street, warm nostalgic colors, old Instagram aesthetic, simple life, no luxury. Scene 2: Flashback cuts — old bike ride, cheap café alone, late-night dreams, city lights, silent ambition in his eyes. Scene 3: Strong transition — speed-ramp effect, screen crack cinematic transition, time shifts from 2016 to 2026, luxury watch appears, black suit transformation begins. Scene 4: 2026 version — walking confidently in Dubai downtown, luxury black suit, sunglasses, expensive watch, black luxury car behind him, people turn and stare. Scene 5: Hero shot — rooftop skyline at sunset, slow motion, wind moving, camera rotating around him, strong eye contact, main character energy. Final scene: cinematic ending with the feeling “Same Man. Different Era.” Style: hyper-realistic, Netflix-level production, luxury transformation, dramatic lighting, viral Instagram reel style, strong masculine aura, editorial fashion visuals, 4K ultra realism, emotional and powerful storytelling.",
"previewImageUrl": "https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/d2d6d15cbc6ef4d4d4c8c9a7de7007d7/thumbnails/thumbnail.jpg",
"previewVideoUrl": "https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/d2d6d15cbc6ef4d4d4c8c9a7de7007d7/downloads/default.mp4",
"source": {
"repo": "YouMind-OpenLab/awesome-seedance-2-prompts",
"license": "CC-BY-4.0",
"author": "Maverick | AI",
"url": "https://x.com/RizwanAly07/status/2048948726623056366"
}
}

View file

@ -0,0 +1,22 @@
{
"id": "ancient-guardian-dragon-rescue",
"surface": "video",
"title": "Ancient Guardian Dragon Rescue",
"summary": "A detailed multi-shot cinematic prompt for a story about a girl in a rainy village saved by an emerging dragon, focusing on VFX and atmospheric sound.",
"category": "General",
"tags": [
"fantasy",
"action"
],
"model": "seedance-2.0",
"aspect": "16:9",
"prompt": "Shot 1 (00:0000:02) WS, Rainy Night, Forward Tracking. A narrow, ancient village alley drenched in relentless rain. Water streams down slanted rooftops and floods uneven stone pathways, reflecting flickering lantern light. A young girl runs barefoot through the water, her soaked dress clinging to her as she struggles to keep balance. Behind her, shadowy figures move unnaturally—distorted, stretching and glitching with each lightning flash as they close in. VFX: Heavy rain simulation, reflective wet surfaces, lightning illuminating distorted shadows. SFX: Thunder cracks, rapid splashing footsteps, howling wind. Shot 2 (00:0200:04) CU, Panic Fall, Slight Handheld Shake. She suddenly slips and crashes onto the wet stone. Water splashes outward. Close on her face—rain mixes with tears, her breath sharp and uneven. Her trembling hands push against the ground as she tries to move back, eyes locked on the approaching darkness. VFX: Detailed splash simulation, motion blur, lens water droplets. SFX: Intensifying heartbeat, heavy breathing, rain striking surfaces. Shot 3 (00:0400:06) LS, Violent Ground Eruption. The ground beneath the shadows fractures violently. Stone explodes upward in a powerful shockwave, sending debris and water into the air. A massive dragon bursts from below—its body dark and armored, faint glowing veins pulsing beneath its scales. It rises between the girl and the shadows, instantly scattering them into fragments of darkness. VFX: Ground destruction, flying debris, glowing cracks, volumetric dust. SFX: Deep impact boom, layered dragon roar with sub-bass rumble. Shot 4 (00:0600:08) CU, Emotional Realization, Slow Push-In. The girl freezes, looking up. Her fear begins to fade. Lightning briefly illuminates the dragons face—its glowing eye calm, focused. The reflection of that eye fills hers. The rain appears to slow slightly in this moment. VFX: Eye reflection detail, subtle slow-motion rain, soft glow from dragons eye. SFX: Thunder fades into low ambient tone, rain softens. Shot 5 (00:0800:11) MS, Gentle Interaction, Static Frame. The dragon slowly lowers its massive head toward her, movements controlled and careful. It gently nudges her shoulder. Water droplets slide across its scales, glowing faintly as they fall. She hesitates, then slowly lifts her hand toward it, tension leaving her body. VFX: Subtle bioluminescent pulses under scales, detailed water interaction. SFX: Deep calm breathing, soft ambient hum. Shot 6 (00:1100:13) LS, Protective Wing Expansion. The dragon spreads its massive wings wide, forming a protective barrier around her. Rain violently hits the outer surface of the wings, but inside the space becomes still—dry, warm, and silent. The contrast between chaos outside and calm inside is immediate and striking. VFX: Rain deflection on wings, cold blue tones outside vs warm",
"previewImageUrl": "https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/18d35c93cc1d6ab0a8eff2a68e6d701b/thumbnails/thumbnail.jpg",
"previewVideoUrl": "https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/18d35c93cc1d6ab0a8eff2a68e6d701b/downloads/default.mp4",
"source": {
"repo": "YouMind-OpenLab/awesome-seedance-2-prompts",
"license": "CC-BY-4.0",
"author": "Jasmine Ai",
"url": "https://x.com/jasminekhan90_/status/2049038597333090769"
}
}

View file

@ -0,0 +1,22 @@
{
"id": "ancient-indian-kingdom-fpv-video",
"surface": "video",
"title": "Ancient Indian Kingdom FPV Video",
"summary": "A fast-paced FPV drone-style cinematic prompt depicting a mystical Indian kingdom with temples and jungles.",
"category": "General",
"tags": [
"cinematic",
"nature"
],
"model": "seedance-2.0",
"aspect": "16:9",
"prompt": "extremely fast-paced cinematic FPV flying through the ancient Indian Dandaka kingdom, dense mystical forests, towering sal and teak trees, tribal settlements, ancient ashrams, sages meditating, wildlife moving through fog, dramatic sunlight rays piercing canopy, rivers cutting through rugged terrain, ruined temples covered in vines, hyper-realistic textures, high-speed aerial dives and sharp turns, immersive depth, volumetric lighting, earthy tones, epic scale, realism, cinematic color grading, smooth stabilization, ultra-detailed environment, intense atmosphere",
"previewImageUrl": "https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/146717bc1b96541c0da02f0ba053b9c3/thumbnails/thumbnail.jpg",
"previewVideoUrl": "https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/146717bc1b96541c0da02f0ba053b9c3/downloads/default.mp4",
"source": {
"repo": "YouMind-OpenLab/awesome-seedance-2-prompts",
"license": "CC-BY-4.0",
"author": "Shushant Lakhyani",
"url": "https://x.com/shushant_l/status/2049141805233672529"
}
}

View file

@ -0,0 +1,19 @@
{
"id": "animation-transfer-and-camera-tracking-prompt",
"surface": "video",
"title": "Animation transfer and camera tracking prompt",
"summary": "A technical prompt for Seedance 2.0 that applies a specific motion reference to a character while maintaining fixed camera tracking.",
"category": "General",
"tags": [],
"model": "seedance-2.0",
"aspect": "16:9",
"prompt": "apply the walking animation of @anim excatly as it is to @char7 . the camera tracks the character exactly in place, camera angle does not change",
"previewImageUrl": "https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/089e7cd70d20131d6d1b44741520eaee/thumbnails/thumbnail.jpg",
"previewVideoUrl": "https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/089e7cd70d20131d6d1b44741520eaee/downloads/default.mp4",
"source": {
"repo": "YouMind-OpenLab/awesome-seedance-2-prompts",
"license": "CC-BY-4.0",
"author": "Olivio Sarikas",
"url": "https://x.com/OlivioSarikas/status/2049093762077630628"
}
}

View file

@ -0,0 +1,20 @@
{
"id": "beat-synced-outfit-transformation-dance",
"surface": "video",
"title": "Beat-Synced Outfit Transformation Dance",
"summary": "A prompt for Seedance 2.0 that coordinates a character dance following breakdown frames while performing a beat-synced outfit change.",
"category": "General",
"tags": [
"fantasy"
],
"model": "seedance-2.0",
"aspect": "16:9",
"prompt": "Have the character from Image 1 perform the dance based on the breakdown in Image 3. During the performance, include a beat-synced transformation into the character from Image 2. After the transformation, the character from Image 2 continues and completes the remaining dance steps from Image 3. Emphasize precise beat matching with the music",
"previewImageUrl": "https://pbs.twimg.com/media/HG_FqHJboAA5vAe.jpg",
"source": {
"repo": "YouMind-OpenLab/awesome-seedance-2-prompts",
"license": "CC-BY-4.0",
"author": "Kashberg",
"url": "https://x.com/Kashberg_0/status/2049074008730247669"
}
}

View file

@ -0,0 +1,23 @@
{
"id": "character-intro-motion-graphics-sequence",
"surface": "video",
"title": "Character Intro Motion Graphics Sequence",
"summary": "A complex, multi-stage motion graphics prompt for introducing a team of characters with specific UI overlays and transitions, designed for the Seedance 2.0 model.",
"category": "Motion Graphics",
"tags": [
"cinematic",
"fantasy",
"3d-render"
],
"model": "seedance-2.0",
"aspect": "16:9",
"prompt": "Based on the three characters in the reference images. High definition, Unreal Engine rendering, cinematic quality, candy-colored palette, Japanese-style aesthetics, artistic, with strong sense of rhythm.\n\n02s: Empty scene, a small dot at the center, thin-line UI frame, subtle particles. Text: “STATUS: STANDBY” “SYSTEM: INIT”.\n\n24s: The fox on the left from image2 appears, riding a hovering skateboard, waves toward the camera. Curved motion trails behind. Text: “ID: 01” “CODENAME: RED” “ROLE: TACTICIAN”.\n\n46s: The rabbit on the right from image1 appears, swings a carrot weapon and takes a combat stance. Circular motion trails. Text: “ID: 02” “CODENAME: KANA” “ROLE: EXECUTIONER” “WEAPON: CARROT”.\n\n68s: The corgi from image3 appears, looks left and right, showing a simple, friendly smile. Concentric circle UI under its feet. Text: “ID: 03” “CODENAME: Arthur” “ROLE: COMMANDER”.\n\n815s: The three characters align horizontally, from left to right: FIREBIRD, SAGE, MAD RABBIT. Snap alignment, unified circular platform beneath. Text: “SYSTEM SYNC COMPLETE” “UNIT READY”. Add UI overlay to each character: tracking frames, data bars, simplified charts. Text: “TRACKING” “ANALYSIS” “LOCKED”.\n\nLarge title “CHAOS UNIT” appears, breaking into geometric fragments that expand outward. Text: “SYSTEM ERROR” “DATA BREAK”. The fragments then reassemble into “CHAOS UNIT”, centered layout with subtle circular guide lines. Text: “REBUILD COMPLETE” “SYSTEM ONLINE” “KANAWORKS_AI”.\n\nFinal frame: “CHAOS UNIT” at the top, the three characters standing side by side below, with a clean circular platform at the bottom. Text: “STATUS: LOCKED” “UNIT: ACTIVE”.",
"previewImageUrl": "https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/d7697b00e2a3cb0ecb91273a772eda39/thumbnails/thumbnail.jpg",
"previewVideoUrl": "https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/d7697b00e2a3cb0ecb91273a772eda39/downloads/default.mp4",
"source": {
"repo": "YouMind-OpenLab/awesome-seedance-2-prompts",
"license": "CC-BY-4.0",
"author": "KANA",
"url": "https://x.com/KanaWorks_AI/status/2049281443956974029"
}
}

View file

@ -0,0 +1,23 @@
{
"id": "cinematic-birthday-celebration-sequence",
"surface": "video",
"title": "Cinematic Birthday Celebration Sequence",
"summary": "A highly detailed multi-shot video prompt for a birthday sequence, focusing on character consistency and emotional storytelling.",
"category": "Cinematic",
"tags": [
"cinematic",
"fantasy",
"cinematic-romance"
],
"model": "seedance-2.0",
"aspect": "16:9",
"prompt": "0s4s\nClose-up of a young girl waking up in a softly lit bedroom, warm golden sunlight through curtains, she gently smiles while checking her phone filled with birthday wishes, natural makeup, SAME facial features as reference image, cinematic lighting, shallow depth of field, ultra-realistic, 4K\n\n4s8s\nCut to a cozy, beautifully decorated room with balloons and fairy lights, her friends surprise her with a birthday cake, everyone cheering, she laughs happily, SAME face as reference image, joyful expressions, cinematic camera movement, vibrant colors, soft glow, high detail\n\n8s12s\nHer boyfriend enters — a well-dressed young man with neatly styled dark hair, sharp jawline, warm expressive eyes, wearing a clean elegant outfit (white shirt with a fitted blazer), minimal accessories, charming and calm presence ,he presents a beautiful bouquet of fresh flowers, she looks surprised and emotional, soft eye contact, SAME facial features maintained, romantic cinematic tone, warm lighting, slight slow motion, realistic textures, elegant framing\n\n12s16s\nFinal scene: she stands surrounded by friends, holding the bouquet and cake, boyfriend beside her smiling softly, candles glowing, she closes her eyes to make a wish, SAME face consistency, cinematic wide shot, dreamy atmosphere, soft bokeh lights, high-end film look",
"previewImageUrl": "https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/47f113f50f5bd3794cbd83d2bb99320b/thumbnails/thumbnail.jpg",
"previewVideoUrl": "https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/47f113f50f5bd3794cbd83d2bb99320b/downloads/default.mp4",
"source": {
"repo": "YouMind-OpenLab/awesome-seedance-2-prompts",
"license": "CC-BY-4.0",
"author": "Soulful Ai",
"url": "https://x.com/soulful__ai/status/2048908956178001993"
}
}

View file

@ -0,0 +1,23 @@
{
"id": "cinematic-dragon-interaction-flight",
"surface": "video",
"title": "Cinematic Dragon Interaction & Flight",
"summary": "A detailed storyboard-style prompt for a video featuring a woman's emotional interaction with a dragon followed by a cinematic flight sequence.",
"category": "Cinematic",
"tags": [
"cinematic",
"fantasy",
"action"
],
"model": "seedance-2.0",
"aspect": "16:9",
"prompt": "STYLE Handheld + aerial camera blend Soft motion blur (only during fast transitions) Tealorange cinematic grade Cool tones during dragon moments, warm tones at emotional peak ⏱ TIMELINE (15s) 02s (HOOK) Close-up on woman standing at a cliff Wind moving through hair A giant shadow passes over her → she slowly turns Low rumble builds tension 25s (CONNECTION) Dragon lands behind her with heavy presence It lowers its head slowly She hesitates, then touches its face Wind + dust particles react subtly Quiet emotional moment (no aggression) 58s (TAKEOFF) She climbs onto its back Dragon launches powerfully into the sky Camera follows upward, slight rotation Clouds rush past, strong sense of speed 812s (FLIGHT SEQUENCE) Fast but controlled cuts: Flying through clouds Passing mountain peaks Close-up of wings moving Her expression shifting to awe Wide aerial shot showing scale 1215s (FINAL MOMENT) Above the clouds in golden light Dragon slows and stabilizes She stands confidently on its back Wide cinematic shot → calm, powerful ending",
"previewImageUrl": "https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/f72b7a26635bdf580a2899bf2682f7f6/thumbnails/thumbnail.jpg",
"previewVideoUrl": "https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/f72b7a26635bdf580a2899bf2682f7f6/downloads/default.mp4",
"source": {
"repo": "YouMind-OpenLab/awesome-seedance-2-prompts",
"license": "CC-BY-4.0",
"author": "simply",
"url": "https://x.com/kingofdairyque/status/2049052738924023976"
}
}

View file

@ -0,0 +1,22 @@
{
"id": "cinematic-east-asian-woman-hand-dance",
"surface": "video",
"title": "Cinematic East Asian Woman Hand Dance",
"summary": "A highly detailed multi-shot cinematic video prompt for a stylized hand dance, featuring time-coded instructions for camera movement and character actions.",
"category": "Cinematic",
"tags": [
"cinematic",
"action"
],
"model": "seedance-2.0",
"aspect": "16:9",
"prompt": "1 0-3s Extreme close-up of the face, exquisite and three-dimensional features, cold and elegant eyes locked on the lens, sword dance opening pose: hands quickly swipe from both sides of the cheeks to a fixed point in front of the chest, clean fingertip movements. 15-second vertical screen 9:16, 24fps, 8K ultra-high definition, realistic movie texture, stable screen without flicker. Top-tier East Asian young female, exquisite features, delicate and transparent skin with natural luster, clear and bright atmosphere makeup, distinct hair strands. Cold and confident gaze locked on the lens throughout, hands quickly swiping from cheeks to chest, clean sword dance hand gestures, clear fingertip details. Soft ring light, soft facial light and shadow without dead blacks, clear and bright eye light, camera moves forward slightly at a uniform speed, subject always in the center of the frame, first-person interaction, natural color saturation, full of details. Cold impact, strong freeze frame at the first heavy drum beat, hand gestures perfectly match the beat.\n\n2 3-6s Medium close-up of the upper body, showing shoulder and neck lines and smooth arms, core sword dance cutting hand gestures, combined with shoulder rhythmic beats, body swaying slightly left and right, eyes never leaving the lens. Vertical screen 9:16, 24fps, 8K, realistic texture, stable screen. Young woman with smooth and tight body lines, superior shoulder and neck lines, wearing a slim black short top, coherent and smooth movements, core sword dance hand gestures, matching shoulder rhythmic beats, body swaying slightly with the rhythm, eyes always locked on the lens. Warm atmosphere light, distinct levels of light and dark, camera moves horizontally slowly, subject in the center throughout, no distortion, no lag in movement. Sharp beats, rhythmic progression with 3 consecutive light drum beats, each hand movement precisely hitting the beat.\n\n3 6-9s Full-body wide shot, fully displaying superior body proportions and dance rhythm, iconic sword dance double-hand circling + body wave combination, small steps matching the beat, movements stretched and powerful. Vertical screen 9:16, 24fps, 8K, realistic movie texture, stable screen without shaking. Female with superior head-to-body ratio, tight waist and abdominal lines, long legs, wearing slim high-waisted black pants, smooth and coherent movements without lag, iconic sword dance circling + body wave, small footsteps matching the rhythm, stretched and powerful movements. Modern minimalist luxury white background wall, soft top light + side light compensation, rich light and shadow layers, camera slowly and uniformly pulls back, subject remains in the center throughout, no clipping or deformation. Grand and elegant, full of rhythm at the heavy drum burst point, wave movement peak precisely hits the heavy drum.\n\n4 9-12s Local close-up of hands + waist and hips, sword dance fingertip fixed-point details, matching slight waist and hip swaying, highlighting body curves and gesture details, clean and precise movements. Vertical screen 9:16, 24fps, 8K, realistic texture, stable screen. Fingertip detail movements, long and slender fingers, clean and exquisite nails, matching rhythmic waist and hip swaying, tight and smooth waist and abdominal lines, precise and sharp movements. Soft side light outlines the body, camera moves slightly following hand movements, focus always on gestures and body lines, clear picture without blurring. Detailed and high-end texture with consecutive light drum beats, each fingertip movement hitting the beat.\n\n5 12-15s Quick zoom from full body back to upper body + face close-up, sword dance closing pose, eyebrow raise + confident smile, eyes locked on the lens throughout, clean ending. Vertical screen 9:16, 24fps, 8K, realistic movie texture, stable screen without flicker. Top-tier beauty and figure, closing sword dance pose, hands sharply fixed in front of the chest, confident smile, eyes locked on the lens, clean ending. Soft ring light, soft facial light and shadow, camera quickly and uniformly zooms from full body to face close-up, final frame frozen on the face, subject always in the center, no distortion, coherent movement. Explosive ending with full memory points at the last heavy drum beat, pose synchronized with the drum, frozen for 3 frames.",
"previewImageUrl": "https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/3b2699622675dd4b8b24808a1d7c4a34/thumbnails/thumbnail.jpg",
"previewVideoUrl": "https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/3b2699622675dd4b8b24808a1d7c4a34/downloads/default.mp4",
"source": {
"repo": "YouMind-OpenLab/awesome-seedance-2-prompts",
"license": "CC-BY-4.0",
"author": "阿绎 AYi",
"url": "https://x.com/AYi_AInotes/status/2049047545435889883"
}
}

View file

@ -0,0 +1,23 @@
{
"id": "cinematic-emotional-face-close-up",
"surface": "video",
"title": "Cinematic Emotional Face Close-up",
"summary": "A highly detailed technical prompt for Seedance 2.0 focusing on realistic skin textures and a series of complex emotional facial transitions.",
"category": "Cinematic",
"tags": [
"portrait",
"cinematic",
"action"
],
"model": "seedance-2.0",
"aspect": "16:9",
"prompt": "A realistic human face with highly detailed skin texture, pores, and micro-musculature. Scene: Tight portrait close-up against a dark, void-like background. Style: Cinematic realism, 35mm film aesthetic, shallow depth of field with soft bokeh, moody and introspective. Lighting: Dynamic emotional lighting that shifts in color temperature and direction to match the internal state. Audio: Ambient atmospheric drone, soft rhythmic breathing, subtle emotional orchestral swells. Avoid: Identity drift, jitter, distorted limbs, unnatural morphing artifacts. [0-3s] Camera: Slow, imperceptible push-in. Action: The face breaks into a genuine, soft smile; eyes crinkle at the corners and the cheeks lift. Lighting: Warm golden-hour glow, soft and frontal. Vfx: Subtle lens flare. [3-6s] Camera: Static extreme close-up. Action: The smile dissolves into a heavy, downward curve; eyes well up with glistening tears that catch the light, and the lower lip trembles. Lighting: Transition to a cool, melancholy blue wash from above. [6-9s] Camera: Controlled lateral pan. Action: The brow furrows deeply into a sharp V-shape; the jaw clenches visibly, and nostrils flare with rhythmic, heavy breathing. Lighting: Harsh, high-contrast red and orange side-lighting creating deep shadows. [9-12s] Camera: Subtle handheld micro-shake for tension. Action: The eyes snap wide, pupils dilating; the face pales as the muscles go taut, and the mouth hangs slightly open in a shallow gasp. Lighting: Dim, desaturated, flickering low-key light. [12-15s] Camera: Gentle pull-out to a medium close-up. Action: All tension drains from the face; the eyes slowly close, and the features settle into a mask of perfect, serene stillness. Lighting: Soft, diffused white light enveloping the subject like a halo.",
"previewImageUrl": "https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/4a47ba646e7cedd79363c861864b8714/thumbnails/thumbnail.jpg",
"previewVideoUrl": "https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/4a47ba646e7cedd79363c861864b8714/downloads/default.mp4",
"source": {
"repo": "YouMind-OpenLab/awesome-seedance-2-prompts",
"license": "CC-BY-4.0",
"author": "Ai Doctor",
"url": "https://x.com/DoctorAmna11/status/2049119918755283014"
}
}

View file

@ -0,0 +1,21 @@
{
"id": "cinematic-marine-biologist-exploration",
"surface": "video",
"title": "Cinematic Marine Biologist Exploration",
"summary": "A detailed cinematic video prompt for an underwater scene featuring a marine biologist discovering an ancient shipwreck in a coral reef.",
"category": "Cinematic",
"tags": [
"cinematic"
],
"model": "seedance-2.0",
"aspect": "16:9",
"prompt": "A marine biologist in a sleek wetsuit swims through the vibrant coral reefs of the Great Barrier Reef. At the 3-second mark, he dives deeper to approach an ancient shipwreck. The camera follows him as schools of colorful fish dart around. He retrieves a mysterious artifact from the wreck just as a curious shark glides by.\nUnderwater ruins, coral reef exploration, ancient artifact retrieval, marine life encounter, cinematic underwater lighting, 4K.",
"previewImageUrl": "https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/4394ac601188eb66755d2c92451665c6/thumbnails/thumbnail.jpg",
"previewVideoUrl": "https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/4394ac601188eb66755d2c92451665c6/downloads/default.mp4",
"source": {
"repo": "YouMind-OpenLab/awesome-seedance-2-prompts",
"license": "CC-BY-4.0",
"author": "LudovicCreator",
"url": "https://x.com/LudovicCreator/status/2049190693550055726"
}
}

View file

@ -0,0 +1,23 @@
{
"id": "cinematic-music-podcast-and-guitar-technique",
"surface": "video",
"title": "Cinematic Music Podcast and Guitar Technique",
"summary": "An advanced cinematic prompt for generating a 4K music podcast video, with specific focus on guitar technique, pinch harmonics, and studio aesthetics.",
"category": "Cinematic",
"tags": [
"cinematic",
"cyberpunk",
"fantasy"
],
"model": "seedance-2.0",
"aspect": "16:9",
"prompt": "**Cinematic Truth Source & Setup** \nProfessional music podcast video production, shot on Sony FX6 cinema camera in 4K DCI, anamorphic lenses with natural breathing and subtle flare, controlled studio lighting using ARRI Skypanels and practical LED backlights, clean broadcast color science with warm highlights and rich mid-tones exactly like high-end Netflix music documentaries. Realistic 24fps motion, light film grain, zero stylization.\n\n** Image Reference & Legend** \nNo external image reference supplied. Original generation locked to user character tagged @character on frame 0. Exact black electric guitar (Stratocaster style with whammy bar) must remain 100% consistent in shape, color, and wear. Back wall behind character locked with large professional podcast branding text “StudioName\" in bold modern sans-serif font, subtly backlit with soft neon glow. No deviation allowed on character identity @character , guitar model/design/colors, or background text.\n\n** Timeline (Second-by-Second)** \n0-3s: Medium close-up handheld camera on guitarist seated in modern podcast studio, microphone visible stage left. Left hand frets high note on 3rd string while right hand picks aggressively; camera slowly pushes in toward guitar neck. Pinch harmonic executed at 2.2s — thumb edge lightly touches string node creating exact “nguik” squealing overtone with natural string vibration and slight whammy bar dive. Back wall clearly shows large “StudioName” podcast name text. Studio monitors in background show faint reflection of hands. \n\n3-7s: Cut to tighter ECU on right hand performing rapid pinch-harmonic technique; strings visibly bend and ring with realistic metallic sustain and micro-vibrato. Left hand shifts positions smoothly, forearm muscles tensing naturally. Camera dollies left in slow arc revealing podcast microphone and back wall “StudioName” branding. \n\n7-11s: Camera pulls back to medium shot as guitarist sustains final high-pitched “nguik” harmonic, letting it feedback naturally through amp. Head nods slightly in time. Background podcast setup with “StudioName” wall text stays in soft focus. \n\n11-15s: Final wide push-in as guitarist releases note, right hand lifts off strings cleanly, left hand relaxes on fretboard. Guitarist glances toward camera with professional nod. Full back wall “StudioName” podcast branding remains visible. Natural string decay and light body movement throughout.\n\n** Style, Quality Boosters & Negative Prompts** \nUltra-realistic guitar physics with accurate string tension, pinch-harmonic squeal, and natural sustain; perfect finger synchronization and skin texture; natural motion blur on picking hand; professional color grading with high dynamic range and subtle lens breathing. Strict negatives: no extra limbs, no deformed fingers or hands, no rubbe",
"previewImageUrl": "https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/c4515f4f328539e1ded2cc32f4ce63e7/thumbnails/thumbnail.jpg",
"previewVideoUrl": "https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/c4515f4f328539e1ded2cc32f4ce63e7/downloads/default.mp4",
"source": {
"repo": "YouMind-OpenLab/awesome-seedance-2-prompts",
"license": "CC-BY-4.0",
"author": "TheYudayVerse",
"url": "https://x.com/yuday9909/status/2048949262109880363"
}
}

View file

@ -0,0 +1,23 @@
{
"id": "cinematic-route-navigation-guide",
"surface": "video",
"title": "Cinematic Route Navigation Guide",
"summary": "A structured multi-scene prompt designed for Seedance to create a consistent walking navigation video featuring a recurring tour guide character and smooth transitions between real-world locations.",
"category": "Cinematic",
"tags": [
"cinematic",
"fantasy",
"action"
],
"model": "seedance-2.0",
"aspect": "16:9",
"prompt": "Create a 5-second cinematic route-guide clip for a walking navigation video.\n\nContinuity:\nThis is scene {N} of 5 in a route from North Avenue MARTA Station to Coda Tech Square in Atlanta.\nThe guide is the same stylish female tour guide in every scene: black sunglasses, sleeveless cream belted dress, brown leather belt, tour lanyard, small shoulder bag, brown hair tied back, confident warm expression.\nShe appears on the sidewalk or plaza only, never in traffic lanes.\n\nScene role:\n{route_step}\n\nStarting frame:\nUse the supplied Street View image as the real-world location reference. Preserve the recognizable street layout, building massing, sidewalk direction, signage, and lighting.\n\nAction:\nThe guide is already in frame, slightly ahead of the viewer. She turns toward the camera, gestures toward the next walking direction, then begins to lead the viewer forward.\n\nCamera:\nSmooth handheld walking pace, slight forward push-in, no jumpy zooms, no orbit. Keep horizon stable. The final second should frame the direction of the next scene so the edit can cut naturally.\n\nEnd frame:\nEnd with the camera facing {next_direction_or_landmark}, with the guide near the edge of frame pointing forward.\n\nRestrictions:\nDo not invent a different city, indoor location, parking lot, or tourist group. Do not place the guide in the road. Do not block crosswalks, street signs, building entrances, or the Coda facade.",
"previewImageUrl": "https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/49a08d9ecf7257120711ce6d7b158073/thumbnails/thumbnail.jpg",
"previewVideoUrl": "https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/49a08d9ecf7257120711ce6d7b158073/downloads/default.mp4",
"source": {
"repo": "YouMind-OpenLab/awesome-seedance-2-prompts",
"license": "CC-BY-4.0",
"author": "Michael Guo",
"url": "https://x.com/Michaelzsguo/status/2048966649982669053"
}
}

View file

@ -0,0 +1,23 @@
{
"id": "cinematic-street-racing-sequence-for-seedance-2",
"surface": "video",
"title": "Cinematic Street Racing Sequence for Seedance 2",
"summary": "A detailed, multi-shot prompt designed for Seedance 2 to generate a cinematic street racing sequence at night, focusing on intense driver focus, dynamic camera work, and explosive acceleration, struct",
"category": "Cinematic",
"tags": [
"cinematic",
"cyberpunk",
"action"
],
"model": "seedance-2.0",
"aspect": "16:9",
"prompt": "cinematic street racing sequence at night, a focused driver inside a high-performance car grips the steering wheel, intense eye focus, city lights reflecting on windshield, tension building before sudden acceleration\n\ncamera: rapid multi-angle system with seamless transitions, interior close-up → over-the-shoulder → exterior tracking → low ground shots, ultra dynamic camera movement, whip pans + speed ramp transitions + motion blur masking cuts, continuous flow illusion\n\n(0-2s) interior close-up on driver, hand tightens on gear shift, subtle breathing, dashboard lights glowing\n(2-4s) over-the-shoulder shot, road ahead stretching into neon-lit city, engine vibration building\n(4-6s) extreme close-up on finger pressing NOS button, instant ignition reaction\n(6-8s) explosive acceleration, camera snaps to exterior side tracking shot, car launches forward with violent speed surge\n(8-10s) ultra low ground shot near asphalt, wheels spinning at extreme velocity, environment streaking past\n(10-12s) high-speed chase through tight streets, sharp turns, camera whip pans between angles, reflections and light trails enhancing speed\n\nDense urban night environment, wet asphalt reflecting neon lights, tunnel passages, street lights streaking, high-speed city atmosphere\nUltra realistic, fast and furious inspired energy, photorealistic lighting, intense motion blur, high contrast neon reflections, cinematic depth of field, extreme sense of speed, fluid transitions, no distortion, no stretching",
"previewImageUrl": "https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/3a7fb0a6d706b9f568479bb720ce1ad4/thumbnails/thumbnail.jpg",
"previewVideoUrl": "https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/3a7fb0a6d706b9f568479bb720ce1ad4/downloads/default.mp4",
"source": {
"repo": "YouMind-OpenLab/awesome-seedance-2-prompts",
"license": "CC-BY-4.0",
"author": "Pierrick Chevallier | IA",
"url": "https://x.com/CharaspowerAI/status/2039651574297792688"
}
}

View file

@ -0,0 +1,22 @@
{
"id": "cinematic-vampire-alley-fight-sequence",
"surface": "video",
"title": "Cinematic vampire alley fight sequence",
"summary": "A comprehensive action prompt for a short film scene involving dynamic camera movements and high-speed combat in a neon-lit alley.",
"category": "Cinematic",
"tags": [
"cinematic",
"cyberpunk"
],
"model": "seedance-2.0",
"aspect": "16:9",
"prompt": "Draev stands in the center of the neon-lit alley, surrounded by multiple vampires positioned on rooftops and street level.\n\nThe vampires attack simultaneously.\nDraev reacts instantly with superhuman speed.\n\nHe dodges the first attacker with a fast sidestep and counters with a brutal punch, sending the vampire crashing into a wall.\n\nSecond vampire lunges from above Draev jumps unnaturally high, grabs him mid-air, and slams him into the ground.\n\nImpact creates a small shockwave on the wet street.\nThe defeated vampire rapidly disintegrates into ash and particles.\n\nCamera moves dynamically:\nstarts frontal wide shot transitions into fast tracking side movement then switches to over-the-shoulder following Draev More vampires rush in.\n\nDraev performs a fast acrobatic kick hitting two enemies at once.\n\nOne vampire is thrown into neon signs, sparks and electricity burst.\n\nAnother gets grabbed by the throat — Draev lifts him with one hand and crushes him, turning him into ash.\nRain reacts to movement, splashes intensify with impacts.\n\nFinal moment:\nDraev stands still in the center, surrounded by falling ash, breathing slightly, eyes glowing red.\nRemaining vampires hesitate, stepping back in fear. No music",
"previewImageUrl": "https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/e665ecf343c35d97dd64e3b930a96fa5/thumbnails/thumbnail.jpg",
"previewVideoUrl": "https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/e665ecf343c35d97dd64e3b930a96fa5/downloads/default.mp4",
"source": {
"repo": "YouMind-OpenLab/awesome-seedance-2-prompts",
"license": "CC-BY-4.0",
"author": "Cortex Visual ・ AI Movies",
"url": "https://x.com/Cortex__Visual/status/2049070872426688714"
}
}

View file

@ -0,0 +1,21 @@
{
"id": "crimson-horizon-sci-fi-cinematic-sequence",
"surface": "video",
"title": "Crimson Horizon Sci-Fi Cinematic Sequence",
"summary": "A comprehensive 9-shot cinematic video sequence for a sci-fi film titled 'Crimson Horizon', detailing everything from a rocket launch to an eerie alien encounter on Mars.",
"category": "Cinematic",
"tags": [
"cinematic"
],
"model": "seedance-2.0",
"aspect": "16:9",
"prompt": "SHOT 1: Cinematic wide angle format — rocket launching into night sky, city lights below, clouds parting, stars above. Dark, dramatic, photorealistic.\n\nSHOT 2: Medium two-shot format — man and woman in astronaut suits inside a dark capsule, Mars glowing red through the porthole behind them. Cinematic, intimate.\n\nSHOT 3: Dramatic aerial format — descent capsule burning through Mars atmosphere, heat shield glowing orange, red desert surface rushing up below. Hyperrealistic.\n\nSHOT 4: Ultra wide low angle format — two astronauts standing with backs to camera on Mars surface, vast red desert and amber sky stretching before them. Empty, eerie.\n\nSHOT 5: Extreme close-up format — female astronaut's gloved hand tracing ancient carved symbols on a canyon wall, helmet lamp lighting the carvings, her eyes wide with fear.\n\nSHOT 6: Tight close-up format — male astronaut's arm display glowing red reading \"UNKNOWN\", a massive dark shape looming behind him in the dust haze. Tense, cinematic.\n\nSHOT 7: Extreme wide format — colossal dark horned creature emerging from a dust storm, violet glowing eyes, two tiny astronauts dwarfed at the bottom of the frame. Cinematic horror.\n\nSHOT 8: Locked macro format — creature's enormous purple glowing eye filling the entire frame, two astronaut silhouettes reflected in the iris. Pure black background. Photorealistic.\n\nSHOT 9: Title card format — pure black background, bold cracked text reads CRIMSON HORIZON, tagline below: \"They came searching for life. Life was already waiting.\"",
"previewImageUrl": "https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/ff7d9d956d3e812f4f99cf99e0552382/thumbnails/thumbnail.jpg",
"previewVideoUrl": "https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/ff7d9d956d3e812f4f99cf99e0552382/downloads/default.mp4",
"source": {
"repo": "YouMind-OpenLab/awesome-seedance-2-prompts",
"license": "CC-BY-4.0",
"author": "wonder",
"url": "https://x.com/WonderBoy023/status/2049086862858367347"
}
}

Some files were not shown because too many files have changed in this diff Show more