feat(media): add image/video/audio project kinds via od media generate

Introduce non-web media surfaces (image, video, audio) as first-class
project kinds. The unifying contract is "skill workflow + project
metadata tell the agent WHAT to make; one shell command — od media
generate — is HOW bytes are produced", so any code-agent CLI with
shell access can drive it without bespoke tools.

- Frontend: New Project panel gains Image/Video/Audio tabs with model
  picker, aspect/length/duration controls, and audio kind/voice
  selection. Examples and Design Systems tabs gain layered sections.
  FileViewer renders the generated image/video/audio files.
- Shared registry: src/media/models.ts is the single source of truth
  for image/video/audio model IDs, aspects, and defaults — consumed
  by the picker AND the daemon dispatcher.
- Prompts: media-contract.ts is pinned LAST in the system prompt for
  media surfaces so its hard rules (call od media generate, don't
  emit binary in <artifact>, allowed model IDs) win over softer
  earlier wording.
- Daemon: new media.js dispatcher + media-models.js JSON view of the
  registry; cli.js gets the `od media generate` subcommand wired up
  via server.js / projects.js so the daemon writes files back into
  the project dir.
- Skills: audio-jingle, image-poster, video-shortform seed examples
  for the three surfaces.

Made-with: Cursor
This commit is contained in:
pftom
2026-04-28 22:41:14 +08:00
parent 0b61be5d96
commit 976a6eadf2
28 changed files with 2902 additions and 78 deletions
+121
View File
@@ -0,0 +1,121 @@
---
name: audio-jingle
description: |
Audio generation skill — jingles, beds, voiceover, and sound effects.
Routes music requests to Suno V5 / Udio / Lyria, speech to MiniMax
TTS / FishAudio / ElevenLabs V3, and SFX to ElevenLabs SFX or
AudioCraft. Output is one MP3/WAV file saved to the project folder.
triggers:
- "music"
- "jingle"
- "bed"
- "voiceover"
- "tts"
- "sound effect"
- "音乐"
- "配音"
- "音效"
od:
mode: audio
surface: audio
scenario: marketing
preview:
type: html
entry: example.html
design_system:
requires: false
example_prompt: |
A 30-second upbeat indie-pop jingle for a coffee shop launch — warm
electric piano lead, brushed drums, gentle bass, a single sun-soaked
"ahhh" choir on the chorus. No vocals. Loop-friendly tail.
---
# Audio Jingle Skill
Three sub-modes. The active project's `audioKind` decides which one
runs:
| `audioKind` | Models we route to | Plan focus |
|---|---|---|
| `music` | Suno V5 (default), Udio, Lyria 2 | genre + tempo + instrumentation |
| `speech` | MiniMax TTS (default), Fish, ElevenLabs V3 | script + voice + pacing |
| `sfx` | ElevenLabs SFX (default), AudioCraft | texture + impact + duration |
## Resource map
```
audio-jingle/
├── SKILL.md
└── example.html
```
## Workflow
### Step 0 — Read the project metadata
`audioKind`, `audioModel`, `audioDuration` (seconds), and (for speech)
`voice`. Branch by `audioKind` and use the values verbatim — no
clarifying form unless something is marked `(unknown — ask)`.
### Step 1 — Plan
**Music**
- Genre + reference artists (1-2)
- Tempo (BPM) + key
- Instrumentation (3-5 instruments max)
- Vocals: yes / no / hummed / choir
- Mood arc (intro → chorus → outro)
**Speech**
- Script (final, not draft — TTS runs verbatim)
- Voice description (warmth, age, accent, pacing)
- Pronunciation hints for proper nouns / acronyms
**SFX**
- Texture (impact / whoosh / ambience / foley)
- Duration + envelope (sharp attack vs. gentle swell)
- Layering note (single hit vs. stacked)
State the plan in 2-3 sentences before dispatching.
### Step 2 — Compose the prompt
Use the format the upstream model prefers. Bind `audioDuration` to the
API parameter directly; never put "make it 30 seconds" in prose.
### Step 3 — Dispatch via the media contract
Use the unified dispatcher — do **not** call provider APIs by hand:
```bash
node "$OD_BIN" media generate \
--project "$OD_PROJECT_ID" \
--surface audio \
--audio-kind "<music|speech|sfx>" \
--model "<audioModel from metadata>" \
--duration <audioDuration seconds> \
--voice "<voice (speech only)>" \
--output "<short-slug>-<duration>s.mp3" \
--prompt "<assembled prompt from Step 2 — for speech, the literal script>"
```
The command prints one line of JSON: `{"file": {"name": "...", ...}}`.
The bytes land in the project; the FileViewer renders the audio
transport controls automatically.
### Step 4 — Hand off
Reply with: plan summary, the filename returned by the dispatcher, and
one sentence on what to try if the user wants a variation (e.g. "swap
tempo from 92 to 108 BPM" rather than "make it different").
## Hard rules
- TTS runs your script **literally**. Proof it before dispatching —
even one stray comma changes the cadence.
- Music: under 30s = single section; 3090s = intro + body; 90s+ =
full arc. Don't try to fit a 3-act song into 15 seconds.
- SFX: prefer one well-described layer over a paragraph of "make it
cool" — generators reward specific texture words.
- Save the file every turn. The audio viewer shows transport controls
the moment the file lands.
+128
View File
@@ -0,0 +1,128 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Audio jingle — example</title>
<style>
:root {
--bg: #f5efe5;
--panel: #ffffff;
--ink: #1c1b1a;
--muted: #8b8579;
--accent: #c96442;
--grid: #e6dfd1;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--ink);
font-family: 'Iowan Old Style', 'Charter', Georgia, serif; }
body { min-height: 100dvh; display: grid; place-items: center; padding: 32px; }
.card {
width: min(640px, 92vw);
background: var(--panel);
border-radius: 8px;
padding: 26px 28px 22px;
box-shadow: 0 16px 40px rgba(28,27,26,0.10), 0 1px 2px rgba(28,27,26,0.05);
border: 1px solid rgba(28,27,26,0.06);
}
.row1 { display: flex; align-items: center; gap: 14px; margin-bottom: 18px; }
.icon {
width: 44px; height: 44px; border-radius: 50%;
background: var(--accent); color: #fff;
display: grid; place-items: center;
box-shadow: 0 6px 18px rgba(201, 100, 66, 0.35);
}
.icon svg { width: 22px; height: 22px; }
.title { margin: 0; font-size: 20px; line-height: 1.2; }
.sub { font-family: ui-monospace, 'SF Mono', Menlo, monospace;
font-size: 11px; color: var(--muted); letter-spacing: 0.14em; text-transform: uppercase; margin-top: 2px; }
.wave {
display: flex; align-items: end; gap: 3px;
height: 96px; padding: 0 4px;
border-top: 1px dashed var(--grid);
border-bottom: 1px dashed var(--grid);
}
.wave span {
flex: 1; background: linear-gradient(180deg, var(--accent), #a4502f);
border-radius: 2px;
animation: bob 2s ease-in-out infinite;
animation-delay: var(--d, 0s);
}
@keyframes bob {
0%, 100% { height: var(--h, 30%); }
50% { height: calc(var(--h, 30%) * 1.6); }
}
.transport {
margin-top: 14px;
display: grid; grid-template-columns: auto 1fr auto auto; gap: 12px;
align-items: center;
}
.play {
width: 36px; height: 36px; border-radius: 50%;
background: var(--ink); color: #fff;
display: grid; place-items: center;
}
.timeline {
height: 4px; border-radius: 2px;
background: linear-gradient(90deg, var(--accent) 0 32%, var(--grid) 32% 100%);
}
.time {
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
font-size: 11px; color: var(--muted);
letter-spacing: 0.08em;
}
.badge {
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
font-size: 10px; color: var(--accent);
letter-spacing: 0.18em; text-transform: uppercase;
padding: 4px 8px; border-radius: 999px;
background: rgba(201, 100, 66, 0.1);
}
</style>
</head>
<body>
<div class="card">
<div class="row1">
<div class="icon" aria-hidden>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
</div>
<div>
<h1 class="title">A 30s coffee-shop launch jingle.</h1>
<div class="sub">suno-v5 · 92 BPM · loop-friendly tail</div>
</div>
</div>
<div class="wave" aria-hidden>
<span style="--h:24%;--d:0s"></span>
<span style="--h:38%;--d:.05s"></span>
<span style="--h:52%;--d:.1s"></span>
<span style="--h:64%;--d:.15s"></span>
<span style="--h:48%;--d:.2s"></span>
<span style="--h:70%;--d:.25s"></span>
<span style="--h:42%;--d:.3s"></span>
<span style="--h:58%;--d:.35s"></span>
<span style="--h:36%;--d:.4s"></span>
<span style="--h:62%;--d:.45s"></span>
<span style="--h:26%;--d:.5s"></span>
<span style="--h:50%;--d:.55s"></span>
<span style="--h:34%;--d:.6s"></span>
<span style="--h:46%;--d:.65s"></span>
<span style="--h:58%;--d:.7s"></span>
<span style="--h:30%;--d:.75s"></span>
<span style="--h:44%;--d:.8s"></span>
<span style="--h:54%;--d:.85s"></span>
<span style="--h:28%;--d:.9s"></span>
<span style="--h:48%;--d:.95s"></span>
</div>
<div class="transport">
<div class="play" aria-hidden>
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M6 4v16l14-8z"/></svg>
</div>
<div class="timeline" aria-hidden></div>
<span class="time">00:09 / 00:30</span>
<span class="badge">MP3</span>
</div>
</div>
</body>
</html>
+104
View File
@@ -0,0 +1,104 @@
---
name: image-poster
description: |
Single-image generation skill for posters, key art, and editorial
illustrations. Defaults to gpt-image-2 but is provider-agnostic — the
same workflow drives Flux, Imagen, or Midjourney via the active
upstream tooling. Output is one or more PNG/JPEG files saved to the
project folder.
triggers:
- "poster"
- "key art"
- "illustration"
- "image"
- "cover art"
- "海报"
- "插画"
od:
mode: image
surface: image
scenario: design
preview:
type: html
entry: example.html
design_system:
requires: false
example_prompt: |
Editorial poster for an indie film festival — one bold abstract
silhouette over a warm, slightly grainy paper background; hand-set
sans serif title at the top, festival dates and venue at the bottom
in monospace. Muted ochre + ink palette.
---
# Image Poster Skill
Produce **one** finished image asset per turn unless the user asks for
variations. Image generation rewards a tight, structured prompt — your
job is to assemble that prompt from the user's brief, then dispatch.
## Resource map
```
image-poster/
├── SKILL.md ← you're reading this
└── example.html ← what the resulting card looks like in Examples
```
## Workflow
### Step 0 — Read the project metadata
The active project carries `imageModel`, `imageAspect`, and (optional)
`imageStyle` notes. Use them as the upstream model + canvas + style
anchor; only ask the user to fill them in if they're marked `(unknown
— ask)`.
### Step 1 — Compose the prompt
Plan in this exact order before calling any tool:
1. **Subject + composition** — what is in the frame, where, at what
scale; eye-line and crop.
2. **Lighting + mood** — natural / studio / moody; warm / cool; key
plus rim plus fill; time of day if outdoor.
3. **Palette + textures** — hex anchors when the user gave a brand
palette; otherwise a 3-word mood tag (e.g. "muted ochre + ink").
4. **Camera / lens** — only if the user wants photographic realism
("85mm portrait, shallow DOF") or a specific film stock.
5. **What to avoid** — common AI-slop patterns ("no extra fingers, no
warped text, no logo placeholders").
### Step 2 — Dispatch via the media contract
Use the unified dispatcher — do **not** call upstream provider APIs by
hand. Run from your shell tool:
```bash
node "$OD_BIN" media generate \
--project "$OD_PROJECT_ID" \
--surface image \
--model "<imageModel from metadata>" \
--aspect "<imageAspect from metadata>" \
--output "<short-descriptive-name>.png" \
--prompt "<the full assembled prompt from Step 1>"
```
The command prints one line of JSON: `{"file": {"name": "...", ...}}`.
The daemon writes the bytes into the project folder; the FileViewer
picks it up automatically.
### Step 3 — Hand off
Reply with a one-paragraph summary of the prompt you used and the
filename returned by the dispatcher (e.g. *I generated `hero-poster.png`
with `gpt-image-2` at 1:1.*). Do **not** emit an `<artifact>` tag.
## Hard rules
- One image per turn unless asked for variations.
- Honor `imageAspect` exactly — the upstream cost is the same; matching
the aspect avoids a re-render.
- No filler typography in the image itself unless the user asked for
in-frame text. Real copy beats lorem.
- Save every render — never describe an image without producing the
file. The user expects something to open in the file viewer.
+113
View File
@@ -0,0 +1,113 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Image poster — example</title>
<style>
:root {
--bg: #f5efe5;
--ink: #1c1b1a;
--accent: #c96442;
--muted: #8b8579;
--paper: #efe7d7;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--ink);
font-family: 'Iowan Old Style', 'Charter', Georgia, serif; }
body { min-height: 100dvh; display: grid; place-items: center; padding: 32px; }
.poster {
width: min(640px, 92vw);
aspect-ratio: 3 / 4;
background: var(--paper);
border: 1px solid rgba(28, 27, 26, 0.08);
border-radius: 6px;
box-shadow: 0 16px 48px rgba(28, 27, 26, 0.12), 0 1px 2px rgba(28, 27, 26, 0.06);
display: grid;
grid-template-rows: auto 1fr auto;
padding: 38px 32px;
position: relative;
overflow: hidden;
}
.poster::after {
content: '';
position: absolute; inset: 0;
pointer-events: none;
background:
radial-gradient(circle at 30% 18%, rgba(255,255,255,0.7), transparent 60%),
repeating-linear-gradient(0deg, rgba(28,27,26,0.025) 0 1px, transparent 1px 2px);
}
.eyebrow {
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
font-size: 11px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--muted);
display: flex;
justify-content: space-between;
align-items: center;
}
.accent-dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--accent);
}
.silhouette {
align-self: center;
justify-self: center;
width: 70%;
aspect-ratio: 1 / 1;
position: relative;
}
.silhouette svg { width: 100%; height: 100%; display: block; }
.meta {
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
font-size: 10.5px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--muted);
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 12px;
align-items: end;
}
.meta strong { color: var(--ink); font-weight: 600; }
.title {
font-size: 44px;
line-height: 0.95;
margin: 18px 0 0;
letter-spacing: -0.01em;
}
.title em { font-style: italic; color: var(--accent); }
.footer {
margin-top: 12px;
font-size: 13px;
color: var(--muted);
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
}
</style>
</head>
<body>
<div class="poster">
<div class="eyebrow">
<span>Open Design · Image</span>
<span class="accent-dot" aria-hidden></span>
</div>
<div class="silhouette" aria-hidden>
<svg viewBox="0 0 100 100">
<circle cx="50" cy="38" r="18" fill="#1c1b1a" />
<path d="M22 100 C 22 70, 78 70, 78 100 Z" fill="#1c1b1a" />
<circle cx="68" cy="22" r="6" fill="#c96442" />
</svg>
</div>
<div>
<h1 class="title">An <em>image</em> project<br />produced by the agent.</h1>
<div class="meta">
<span><strong>gpt-image-2</strong></span>
<span>·</span>
<span style="text-align:right">3:4 · poster</span>
</div>
<p class="footer">Saved as PNG into the project folder.</p>
</div>
</div>
</body>
</html>
+108
View File
@@ -0,0 +1,108 @@
---
name: video-shortform
description: |
Short-form video generation skill — 3-10 second clips for product
reveals, motion teasers, ambient loops. Defaults to Seedance 2 but
works the same with Kling 3 / 4, Veo 3 or Sora 2. Output is one MP4
saved to the project folder. When the workspace also ships an
interactive-video / hyperframes skill, prefer composing several short
shots into a single timeline rather than one long monolithic clip.
triggers:
- "video"
- "clip"
- "shortform"
- "reel"
- "短视频"
- "动效"
od:
mode: video
surface: video
scenario: marketing
preview:
type: html
entry: example.html
design_system:
requires: false
example_prompt: |
5-second product reveal — ceramic coffee mug rotating on a soft
paper backdrop, warm side-light from camera-left, micro dust motes
drifting through the beam. Cinematic, 16:9, slow drift on the camera.
---
# Video Shortform Skill
Short-form (≤ 10s) is the sweet spot for current text-to-video models —
they're great at one **shot** with one **idea**, weaker at multi-cut
narratives. Plan one shot per call.
## Resource map
```
video-shortform/
├── SKILL.md
└── example.html
```
## Workflow
### Step 0 — Read the project metadata
`videoModel`, `videoLength` (seconds), `videoAspect`. These are
hard-locks — clamp the prompt to whatever the chosen model supports
(Seedance 2 caps at 10s; Kling 4 supports up to 10s + image-to-video;
Veo 3 supports 8s with audio).
### Step 1 — Plan the shot
Write the shotlist BEFORE calling the model:
| Slot | Content |
|---|---|
| Subject | What's in frame? |
| Camera | Static / pan / push-in / orbit? |
| Lighting | Key direction + temperature |
| Motion | What moves, at what pace? Subject motion vs camera motion. |
| Sound | Ambient bed? (only if the model supports audio) |
Show this to the user as a one-sentence plan before dispatching — they
can redirect cheaply.
### Step 2 — Compose the prompt
Use the format the upstream model prefers (Seedance: motion + camera +
mood; Kling: subject + camera + style; Veo: subject + cinematography +
sound). Bind the project's `videoAspect` and `videoLength` directly to
the API parameters; never put them in prose.
### Step 3 — Dispatch via the media contract
Use the unified dispatcher — do **not** call provider APIs by hand:
```bash
node "$OD_BIN" media generate \
--project "$OD_PROJECT_ID" \
--surface video \
--model "<videoModel from metadata>" \
--aspect "<videoAspect from metadata>" \
--length <videoLength seconds> \
--output "<short-slug>-<seconds>s.mp4" \
--prompt "<assembled shot prompt from Step 2>"
```
The command prints one line of JSON: `{"file": {"name": "...", ...}}`.
The bytes land in the project; the FileViewer plays it automatically.
### Step 4 — Hand off
Reply with: shot summary, the filename returned by the dispatcher, and
one sentence on what to try if the user wants a variation.
## Hard rules
- One shot per turn. Multi-shot timelines belong in a hyperframes /
interactive-video skill, not here.
- Match `videoAspect` exactly — re-renders are slow.
- Never ship a video without saving the file — the user expects
something to play in the file viewer.
- When the underlying model fails (NSFW filter, content policy,
timeout), report the error verbatim. Don't silently retry.
+90
View File
@@ -0,0 +1,90 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Short-form video — example</title>
<style>
:root {
--bg: #0e0d0c;
--panel: #1a1816;
--ink: #f5efe5;
--muted: #8b8579;
--accent: #c96442;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--ink);
font-family: 'Iowan Old Style', 'Charter', Georgia, serif; }
body { min-height: 100dvh; display: grid; place-items: center; padding: 32px; }
.stage {
width: min(720px, 92vw);
background: var(--panel);
border-radius: 8px;
padding: 22px;
box-shadow: 0 24px 60px rgba(0,0,0,0.45);
}
.frame {
position: relative;
aspect-ratio: 16 / 9;
border-radius: 6px;
overflow: hidden;
background:
radial-gradient(circle at 30% 35%, #d8b08b 0%, #6f4a35 40%, #1a120c 80%);
}
.frame::after {
content: ''; position: absolute; inset: 0;
background: repeating-linear-gradient(0deg, rgba(0,0,0,0.18) 0 1px, transparent 1px 4px);
pointer-events: none;
animation: scan 12s linear infinite;
}
@keyframes scan { from { background-position-y: 0; } to { background-position-y: 200px; } }
.frame .mug {
position: absolute; left: 50%; top: 56%; transform: translate(-50%, -50%);
width: 28%; aspect-ratio: 1 / 1;
background: radial-gradient(ellipse at 35% 35%, #f5efe5 0%, #c2b8a7 50%, #6f6757 100%);
border-radius: 18% 18% 22% 22% / 28% 28% 18% 18%;
box-shadow: 18px 6px 30px rgba(0,0,0,0.45);
animation: turn 6s ease-in-out infinite alternate;
}
.frame .mug::after {
content: ''; position: absolute; right: -14%; top: 28%;
width: 18%; height: 44%;
border: 6px solid #c2b8a7; border-left: none; border-radius: 0 100% 100% 0 / 0 50% 50% 0;
}
@keyframes turn { from { transform: translate(-50%, -50%) rotate(-6deg); } to { transform: translate(-50%, -50%) rotate(6deg); } }
.frame .timecode {
position: absolute; left: 14px; bottom: 12px;
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
font-size: 11px; letter-spacing: 0.16em;
color: var(--muted);
background: rgba(0,0,0,0.4);
padding: 4px 8px; border-radius: 999px;
}
.frame .badge {
position: absolute; left: 14px; top: 12px;
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
font-size: 10.5px; letter-spacing: 0.2em; text-transform: uppercase;
color: var(--accent);
}
.meta {
display: grid; grid-template-columns: 1fr auto; gap: 10px;
align-items: end; margin-top: 18px;
}
.title { font-size: 22px; line-height: 1.1; margin: 0; }
.sub { font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 11px; color: var(--muted); letter-spacing: 0.14em; text-transform: uppercase; }
</style>
</head>
<body>
<div class="stage">
<div class="frame">
<span class="badge">● REC</span>
<div class="mug" aria-hidden></div>
<span class="timecode">00:05 · 16:9 · seedance-2</span>
</div>
<div class="meta">
<h1 class="title">A 5-second product reveal — saved as MP4.</h1>
<span class="sub">Open Design · Video</span>
</div>
</div>
</body>
</html>