# Atlas Modes, Dynamic Models & Pricing — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Turn each canvas box into a mode → model → schema-driven-settings picker covering all console-visible Atlas models, with live per-box and pipeline pricing.

**Architecture:** The Express backend gains a cached **catalog** (classifies Atlas models into 5 modes + parses prices), a cached **schema** service (per-model input params), and a **media-input** resolver (finds a model's primary image/video param). `/api/generate` becomes generic: it injects chained/uploaded media into the right schema param and submits to Atlas. The React frontend renders a dynamic settings form from each model's schema, shows a price chip per box, and a summed total on the Run button.

**Tech Stack:** Node/Express + Vitest (backend); React + TypeScript + Vite + Zustand + React Flow + Vitest (frontend). Atlas Cloud REST.

**Verified Atlas facts (do not re-guess):**
- Price: `model.price.actual.base_price` (string $, discount applied); fallback `origin.base_price`.
- Media params by model: image edit → `images` (array); image-to-video → `image` (string, sometimes `end_image`); video-to-video → `videos` (array); start-end → `image`+`end_image`.
- Schema doc: OpenAPI JSON; params at `components.schemas.Input.properties`, required at `.required`.
- Submit: `POST /api/v1/model/generateImage|generateVideo` with `{ model, ...params }`; poll `/api/v1/model/prediction/{id}`.

**Conventions:** ESM TS. Backend intra-imports use `.js`; client imports have no extension. Commit per task.

---

### Task 1: Backend — media-input param resolver (TDD)

**Files:**
- Create: `server/src/mediaInput.ts`
- Test: `server/src/mediaInput.test.ts`

- [ ] **Step 1: Write the failing test**

```ts
import { describe, it, expect } from 'vitest';
import { mediaKind, isMediaParam, primaryImageParam, primaryVideoParam } from './mediaInput.js';

describe('mediaInput', () => {
  it('classifies media param names', () => {
    expect(mediaKind('images')).toBe('image');
    expect(mediaKind('image')).toBe('image');
    expect(mediaKind('videos')).toBe('video');
    expect(mediaKind('prompt')).toBeUndefined();
    expect(isMediaParam('image_url')).toBe(true);
    expect(isMediaParam('duration')).toBe(false);
  });

  it('picks the primary image param by priority (array images > image)', () => {
    expect(primaryImageParam(['prompt', 'image', 'images'])).toBe('images');
    expect(primaryImageParam(['prompt', 'image', 'end_image'])).toBe('image');
    expect(primaryImageParam(['prompt'])).toBeUndefined();
  });

  it('picks the primary video param', () => {
    expect(primaryVideoParam(['prompt', 'videos'])).toBe('videos');
    expect(primaryVideoParam(['prompt', 'video_url'])).toBe('video_url');
    expect(primaryVideoParam(['prompt'])).toBeUndefined();
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `cd server && npx vitest run src/mediaInput.test.ts`
Expected: FAIL — cannot find module `./mediaInput.js`.

- [ ] **Step 3: Write minimal implementation**

```ts
// Candidate param names in priority order (array forms first where applicable).
const IMAGE_PARAMS = ['images', 'image', 'image_url', 'start_image', 'first_frame', 'input_image', 'subject_image'];
const VIDEO_PARAMS = ['videos', 'video', 'video_url', 'input_video'];

export type MediaKind = 'image' | 'video';

export function mediaKind(name: string): MediaKind | undefined {
  if (IMAGE_PARAMS.includes(name)) return 'image';
  if (VIDEO_PARAMS.includes(name)) return 'video';
  return undefined;
}

export function isMediaParam(name: string): boolean {
  return mediaKind(name) !== undefined;
}

export function primaryImageParam(names: string[]): string | undefined {
  return IMAGE_PARAMS.find((c) => names.includes(c));
}

export function primaryVideoParam(names: string[]): string | undefined {
  return VIDEO_PARAMS.find((c) => names.includes(c));
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `cd server && npx vitest run src/mediaInput.test.ts`
Expected: PASS (3 tests).

- [ ] **Step 5: Commit**

```bash
git add server/src/mediaInput.ts server/src/mediaInput.test.ts
git commit -m "feat(server): media-input param resolver"
```

---

### Task 2: Backend — schema service (TDD)

**Files:**
- Create: `server/src/schema.ts`
- Test: `server/src/schema.test.ts`

- [ ] **Step 1: Write the failing test**

```ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { extractParams } from './schema.js';

const DOC = {
  components: { schemas: { Input: {
    required: ['model', 'prompt', 'image'],
    properties: {
      model: { type: 'string', default: 'x/y' },
      enable_base64_output: { type: 'boolean', default: false },
      prompt: { type: 'string' },
      image: { type: 'string' },
      duration: { type: 'integer', default: 5, enum: [4, 5, 6] },
      ratio: { type: 'string', default: 'adaptive', enum: ['16:9', '9:16'] },
      generate_audio: { type: 'boolean', default: true },
    },
  } } },
};

describe('extractParams', () => {
  it('drops model + internal params and flags media params', () => {
    const params = extractParams(DOC);
    const names = params.map((p) => p.name);
    expect(names).not.toContain('model');
    expect(names).not.toContain('enable_base64_output');
    expect(names).toEqual(expect.arrayContaining(['prompt', 'image', 'duration', 'ratio', 'generate_audio']));
    const image = params.find((p) => p.name === 'image')!;
    expect(image.isMedia).toBe(true);
    expect(image.mediaKind).toBe('image');
    expect(image.required).toBe(true);
    const duration = params.find((p) => p.name === 'duration')!;
    expect(duration.type).toBe('integer');
    expect(duration.enum).toEqual([4, 5, 6]);
    expect(duration.default).toBe(5);
  });

  it('returns [] for a malformed doc', () => {
    expect(extractParams({})).toEqual([]);
  });
});

describe('getParams', () => {
  beforeEach(() => { vi.resetModules(); });
  it('fetches + caches the schema doc by url', async () => {
    const fetchMock = vi.fn(async () => ({ ok: true, json: async () => DOC } as any));
    (globalThis as any).fetch = fetchMock;
    const { getParams } = await import('./schema.js');
    const a = await getParams('https://x/schema.json');
    const b = await getParams('https://x/schema.json');
    expect(a.length).toBeGreaterThan(0);
    expect(b).toEqual(a);
    expect(fetchMock).toHaveBeenCalledTimes(1); // cached
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `cd server && npx vitest run src/schema.test.ts`
Expected: FAIL — cannot find module `./schema.js`.

- [ ] **Step 3: Write minimal implementation**

```ts
import { mediaKind, type MediaKind } from './mediaInput.js';

export interface SchemaParam {
  name: string;
  type: 'string' | 'integer' | 'number' | 'boolean' | 'array';
  enum?: (string | number)[];
  default?: unknown;
  required: boolean;
  title?: string;
  description?: string;
  isMedia: boolean;
  mediaKind?: MediaKind;
}

const INTERNAL = new Set(['model', 'enable_base64_output']);

export function extractParams(doc: any): SchemaParam[] {
  const input = doc?.components?.schemas?.Input;
  const props = input?.properties ?? {};
  const required: string[] = input?.required ?? [];
  const out: SchemaParam[] = [];
  for (const name of Object.keys(props)) {
    if (INTERNAL.has(name)) continue;
    const d = props[name] ?? {};
    const mk = mediaKind(name);
    out.push({
      name,
      type: (d.type as SchemaParam['type']) ?? 'string',
      enum: d.enum,
      default: d.default,
      required: required.includes(name),
      title: d.title,
      description: d.description,
      isMedia: mk !== undefined,
      mediaKind: mk,
    });
  }
  return out;
}

const cache = new Map<string, SchemaParam[]>();

export async function getParams(schemaUrl: string): Promise<SchemaParam[]> {
  const hit = cache.get(schemaUrl);
  if (hit) return hit;
  const res = await fetch(schemaUrl);
  if (!res.ok) throw new Error(`Failed to fetch schema: HTTP ${res.status}`);
  const doc = await res.json();
  const params = extractParams(doc);
  cache.set(schemaUrl, params);
  return params;
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `cd server && npx vitest run src/schema.test.ts`
Expected: PASS (3 tests).

- [ ] **Step 5: Commit**

```bash
git add server/src/schema.ts server/src/schema.test.ts
git commit -m "feat(server): per-model schema param extraction + cache"
```

---

### Task 3: Backend — catalog (classify + price + build) (TDD)

**Files:**
- Create: `server/src/catalog.ts`
- Test: `server/src/catalog.test.ts`

- [ ] **Step 1: Write the failing test**

```ts
import { describe, it, expect } from 'vitest';
import { classify, parsePrice, buildCatalog } from './catalog.js';

const m = (model: string, type: string, base = '0.05') => ({
  model, type, displayName: model, display_console: true,
  price: { actual: { base_price: base } }, schema: `https://x/${model}.json`,
});

describe('classify', () => {
  it('maps image models', () => {
    expect(classify(m('bytedance/seedream-v5.0-lite', 'Image'))).toBe('t2i');
    expect(classify(m('google/nano-banana-2/edit', 'Image'))).toBe('i2i');
  });
  it('maps video models by id suffix', () => {
    expect(classify(m('bytedance/seedance-2.0/text-to-video', 'Video'))).toBe('t2v');
    expect(classify(m('kwaivgi/kling-v3.0-std/image-to-video', 'Video'))).toBe('i2v');
    expect(classify(m('alibaba/wan-2.6/video-to-video', 'Video'))).toBe('v2v');
  });
  it('returns null for ambiguous video models', () => {
    expect(classify(m('vendor/weird-model', 'Video'))).toBeNull();
  });
});

describe('parsePrice', () => {
  it('reads actual.base_price as a number', () => {
    expect(parsePrice(m('a', 'Image', '0.096'))).toBe(0.096);
    expect(parsePrice({ price: {} })).toBe(0);
  });
});

describe('buildCatalog', () => {
  it('classifies, prices, sorts, and resolves ambiguous video via required fields', async () => {
    const raw = [
      m('bytedance/seedream-v5.0-lite', 'Image', '0.03'),
      m('google/nano-banana-2/edit', 'Image', '0.05'),
      m('bytedance/seedance-2.0/text-to-video', 'Video', '0.09'),
      m('vendor/weird-vid', 'Video', '0.04'),     // ambiguous → fallback uses required
      m('internal/hidden', 'Image', '0.01'),
    ];
    raw[4].display_console = false; // excluded
    const getRequired = async (id: string) => (id === 'vendor/weird-vid' ? ['model', 'videos'] : []);
    const cat = await buildCatalog(raw, getRequired);
    expect(cat.t2i.map((x) => x.id)).toEqual(['bytedance/seedream-v5.0-lite']);
    expect(cat.i2i.map((x) => x.id)).toEqual(['google/nano-banana-2/edit']);
    expect(cat.t2v.map((x) => x.id)).toEqual(['bytedance/seedance-2.0/text-to-video']);
    expect(cat.v2v.map((x) => x.id)).toEqual(['vendor/weird-vid']); // resolved via required 'videos'
    expect(cat.t2i[0].price).toBe(0.03);
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `cd server && npx vitest run src/catalog.test.ts`
Expected: FAIL — cannot find module `./catalog.js`.

- [ ] **Step 3: Write minimal implementation**

```ts
import { getParams } from './schema.js';

export type Mode = 't2i' | 'i2i' | 't2v' | 'i2v' | 'v2v';
export interface CatalogModel { id: string; label: string; price: number; outputType: 'image' | 'video'; }
export type Catalog = Record<Mode, CatalogModel[]>;

export function parsePrice(model: any): number {
  const p = model?.price?.actual?.base_price ?? model?.price?.origin?.base_price;
  const n = Number(p);
  return Number.isFinite(n) ? n : 0;
}

export function classify(model: any): Mode | null {
  const id: string = model?.model ?? '';
  if (model?.type === 'Image') {
    return /(edit|reference-to-image)/.test(id) ? 'i2i' : 't2i';
  }
  if (model?.type === 'Video') {
    if (/(video-to-video|video-edit|video-extend)/.test(id)) return 'v2v';
    if (/(image-to-video|start-end-to-video|reference-to-video|infinite-image-to-video|animate)/.test(id)) return 'i2v';
    if (/text-to-video/.test(id)) return 't2v';
    return null;
  }
  return null;
}

export async function buildCatalog(
  rawModels: any[],
  getRequired: (modelId: string) => Promise<string[]>,
): Promise<Catalog> {
  const cat: Catalog = { t2i: [], i2i: [], t2v: [], i2v: [], v2v: [] };
  for (const m of rawModels) {
    if (!m?.display_console || (m.type !== 'Image' && m.type !== 'Video')) continue;
    let mode = classify(m);
    if (!mode && m.type === 'Video') {
      try {
        const req = await getRequired(m.model);
        if (req.some((r) => /video/.test(r))) mode = 'v2v';
        else if (req.some((r) => /image/.test(r))) mode = 'i2v';
        else mode = 't2v';
      } catch { mode = 't2v'; }
    }
    if (!mode) continue;
    cat[mode].push({
      id: m.model,
      label: m.displayName ?? m.model,
      price: parsePrice(m),
      outputType: m.type === 'Image' ? 'image' : 'video',
    });
  }
  (Object.keys(cat) as Mode[]).forEach((k) => cat[k].sort((a, b) => a.price - b.price));
  return cat;
}

// ---- cached live fetch ----
const MODELS_URL = 'https://api.atlascloud.ai/api/v1/models';
let rawCache: { at: number; models: any[] } | null = null;
const TTL = 10 * 60 * 1000;

async function rawModels(): Promise<any[]> {
  if (rawCache && Date.now() - rawCache.at < TTL) return rawCache.models;
  const res = await fetch(MODELS_URL);
  if (!res.ok) throw new Error(`Failed to fetch Atlas models: HTTP ${res.status}`);
  const json = await res.json();
  const models = (json.data ?? json) as any[];
  rawCache = { at: Date.now(), models };
  return models;
}

export function getSchemaUrl(models: any[], id: string): string | undefined {
  return models.find((m) => m.model === id)?.schema;
}

let catalogCache: { at: number; catalog: Catalog } | null = null;

export async function getCatalog(): Promise<Catalog> {
  if (catalogCache && Date.now() - catalogCache.at < TTL) return catalogCache.catalog;
  const models = await rawModels();
  const getRequired = async (id: string) => {
    const url = getSchemaUrl(models, id);
    if (!url) return [];
    return (await getParams(url)).filter((p) => p.required).map((p) => p.name);
  };
  const catalog = await buildCatalog(models, getRequired);
  catalogCache = { at: Date.now(), catalog };
  return catalog;
}

export async function schemaUrlFor(id: string): Promise<string | undefined> {
  return getSchemaUrl(await rawModels(), id);
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `cd server && npx vitest run src/catalog.test.ts`
Expected: PASS (5 tests).

- [ ] **Step 5: Commit**

```bash
git add server/src/catalog.ts server/src/catalog.test.ts
git commit -m "feat(server): model catalog classify/price/build + cache"
```

---

### Task 4: Backend — routes: /models, /schema, generic /generate (TDD)

**Files:**
- Modify: `server/src/routes.ts`
- Test: `server/src/routes.test.ts` (overwrite)
- Delete: `server/src/models.ts`, `server/src/models.test.ts`

- [ ] **Step 1: Overwrite the test `server/src/routes.test.ts`**

```ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import express from 'express';
import request from 'supertest';

const generate = vi.fn();
const uploadMedia = vi.fn();
const extractLastFrame = vi.fn();
const getCatalog = vi.fn();
const getParams = vi.fn();
const schemaUrlFor = vi.fn();

vi.mock('./atlas.js', () => ({ generate, uploadMedia }));
vi.mock('./frameExtractor.js', () => ({ extractLastFrame }));
vi.mock('./catalog.js', () => ({ getCatalog, schemaUrlFor }));
vi.mock('./schema.js', () => ({ getParams }));

async function makeApp() {
  const { router } = await import('./routes.js');
  const app = express();
  app.use(express.json());
  app.use('/api', router);
  return app;
}

beforeEach(() => {
  generate.mockReset(); uploadMedia.mockReset(); extractLastFrame.mockReset();
  getCatalog.mockReset(); getParams.mockReset(); schemaUrlFor.mockReset();
});

describe('routes', () => {
  it('GET /api/models returns the catalog', async () => {
    getCatalog.mockResolvedValue({ t2i: [{ id: 'a', label: 'A', price: 0.03, outputType: 'image' }], i2i: [], t2v: [], i2v: [], v2v: [] });
    const app = await makeApp();
    const res = await request(app).get('/api/models');
    expect(res.status).toBe(200);
    expect(res.body.t2i[0].id).toBe('a');
  });

  it('GET /api/schema returns params for a model', async () => {
    schemaUrlFor.mockResolvedValue('https://x/s.json');
    getParams.mockResolvedValue([{ name: 'prompt', type: 'string', required: true, isMedia: false }]);
    const app = await makeApp();
    const res = await request(app).get('/api/schema?model=a/b');
    expect(res.status).toBe(200);
    expect(res.body[0].name).toBe('prompt');
  });

  it('POST /api/generate injects the chained image into the primary image param', async () => {
    schemaUrlFor.mockResolvedValue('https://x/s.json');
    getParams.mockResolvedValue([
      { name: 'prompt', type: 'string', required: true, isMedia: false },
      { name: 'image', type: 'string', required: true, isMedia: true, mediaKind: 'image' },
    ]);
    generate.mockResolvedValue({ url: 'https://x/out.mp4' });
    const app = await makeApp();
    const res = await request(app).post('/api/generate').send({
      model: 'vendor/i2v', outputType: 'video', params: { prompt: 'go' }, imageInputs: ['https://x/f.jpg'],
    });
    expect(res.status).toBe(200);
    expect(typeof res.body.jobId).toBe('string');
    // generate called with body containing model + prompt + injected image
    await new Promise((r) => setTimeout(r, 0));
    const [kind, body] = generate.mock.calls[0];
    expect(kind).toBe('video');
    expect(body).toMatchObject({ model: 'vendor/i2v', prompt: 'go', image: 'https://x/f.jpg' });
  });

  it('POST /api/generate 400s with no model', async () => {
    const app = await makeApp();
    const res = await request(app).post('/api/generate').send({ outputType: 'image', params: {} });
    expect(res.status).toBe(400);
  });

  it('POST /api/extract-frame extracts then uploads', async () => {
    extractLastFrame.mockResolvedValue(Buffer.from('frame'));
    uploadMedia.mockResolvedValue('https://x/frame.jpg');
    const app = await makeApp();
    const res = await request(app).post('/api/extract-frame').send({ videoUrl: 'https://x/v.mp4' });
    expect(res.status).toBe(200);
    expect(res.body.url).toBe('https://x/frame.jpg');
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `cd server && npx vitest run src/routes.test.ts`
Expected: FAIL — routes still import `./models.js` / shapes mismatch.

- [ ] **Step 3: Overwrite `server/src/routes.ts`**

```ts
import { Router } from 'express';
import multer from 'multer';
import { createJob, getJob, updateJob } from './jobRegistry.js';
import { generate, uploadMedia } from './atlas.js';
import { extractLastFrame } from './frameExtractor.js';
import { getCatalog, schemaUrlFor } from './catalog.js';
import { getParams } from './schema.js';
import { primaryImageParam, primaryVideoParam } from './mediaInput.js';

const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 25 * 1024 * 1024 } });

export const router = Router();

router.get('/models', async (_req, res) => {
  try {
    res.json(await getCatalog());
  } catch (e: any) {
    res.status(502).json({ error: String(e?.message ?? e) });
  }
});

router.get('/schema', async (req, res) => {
  try {
    const model = String(req.query.model ?? '');
    if (!model) return res.status(400).json({ error: 'model query param required' });
    const url = await schemaUrlFor(model);
    if (!url) return res.status(404).json({ error: `unknown model: ${model}` });
    res.json(await getParams(url));
  } catch (e: any) {
    res.status(502).json({ error: String(e?.message ?? e) });
  }
});

router.post('/generate', async (req, res) => {
  try {
    const { model, outputType, params, imageInputs, videoInputs } = req.body ?? {};
    if (!model) return res.status(400).json({ error: 'model required' });
    const kind = outputType === 'video' ? 'video' : 'image';

    // Resolve the model's media params from its schema, then inject chained/uploaded media.
    const url = await schemaUrlFor(model);
    const names = url ? (await getParams(url)).map((p) => p.name) : [];
    const body: Record<string, unknown> = { model, ...(params ?? {}) };

    if (Array.isArray(imageInputs) && imageInputs.length) {
      const p = primaryImageParam(names);
      if (p) body[p] = p === 'images' ? imageInputs : imageInputs[0];
    }
    if (Array.isArray(videoInputs) && videoInputs.length) {
      const p = primaryVideoParam(names);
      if (p) body[p] = p === 'videos' ? videoInputs : videoInputs[0];
    }

    const job = createJob();
    res.json({ jobId: job.id });

    updateJob(job.id, { status: 'running' });
    generate(kind, body)
      .then((r) => updateJob(job.id, { status: 'completed', outputUrl: r.url, outputType: kind }))
      .catch((e) => updateJob(job.id, { status: 'failed', error: String(e?.message ?? e) }));
  } catch (e: any) {
    res.status(400).json({ error: String(e?.message ?? e) });
  }
});

router.get('/status/:id', (req, res) => {
  const job = getJob(req.params.id);
  if (!job) return res.status(404).json({ error: 'job not found' });
  res.json(job);
});

router.post('/upload', upload.single('file'), async (req, res) => {
  try {
    if (!req.file) return res.status(400).json({ error: 'no file uploaded' });
    const url = await uploadMedia(req.file.buffer, req.file.originalname || 'upload', req.file.mimetype || 'application/octet-stream');
    res.json({ url });
  } catch (e: any) {
    res.status(500).json({ error: String(e?.message ?? e) });
  }
});

router.post('/extract-frame', async (req, res) => {
  try {
    const { videoUrl } = req.body ?? {};
    if (!videoUrl) return res.status(400).json({ error: 'videoUrl required' });
    const buf = await extractLastFrame(videoUrl);
    const url = await uploadMedia(buf, 'frame.jpg', 'image/jpeg');
    res.json({ url });
  } catch (e: any) {
    res.status(500).json({ error: String(e?.message ?? e) });
  }
});
```

- [ ] **Step 4: Delete the obsolete static catalog**

Run: `cd server && Remove-Item src/models.ts, src/models.test.ts -Force`

- [ ] **Step 5: Run the full server suite**

Run: `cd server && npm test`
Expected: PASS — `mediaInput`, `schema`, `catalog`, `routes`, `atlas`, `jobRegistry`, `frameExtractor`.

- [ ] **Step 6: Build + commit**

```bash
cd server && npm run build
git add server/src/routes.ts server/src/routes.test.ts
git rm server/src/models.ts server/src/models.test.ts
git commit -m "feat(server): /models + /schema endpoints, generic media-injecting /generate"
```

---

### Task 5: Frontend — types + catalog client

**Files:**
- Modify: `client/src/types.ts`
- Create: `client/src/catalog.ts`

- [ ] **Step 1: Overwrite `client/src/types.ts`**

```ts
export type Mode = 't2i' | 'i2i' | 't2v' | 'i2v' | 'v2v';
export type RunStatus = 'idle' | 'queued' | 'running' | 'done' | 'failed' | 'skipped';

export interface MediaRef { source: 'upload' | 'piped' | 'url'; url: string; }
export interface NodeOutput { type: 'image' | 'video'; url: string; lastFrameUrl?: string; }

export interface CatalogModel { id: string; label: string; price: number; outputType: 'image' | 'video'; }
export type Catalog = Record<Mode, CatalogModel[]>;

export interface SchemaParam {
  name: string;
  type: 'string' | 'integer' | 'number' | 'boolean' | 'array';
  enum?: (string | number)[];
  default?: unknown;
  required: boolean;
  title?: string;
  description?: string;
  isMedia: boolean;
  mediaKind?: 'image' | 'video';
}

export interface BoxNodeData {
  mode: Mode;
  model: string;                     // atlas model id, or '' until chosen
  params: Record<string, unknown>;   // schema-driven settings (non-media)
  mediaInputs: MediaRef[];           // uploaded refs; chaining fills the primary slot at run time
  status: RunStatus;
  jobId?: string;
  output?: NodeOutput;
  error?: string;
  price?: number;
}

export interface GraphEdge { id: string; source: string; target: string; }

export const MODE_LABEL: Record<Mode, string> = {
  t2i: 'Text → Image', i2i: 'Image → Image', t2v: 'Text → Video', i2v: 'Image → Video', v2v: 'Video → Video',
};
export const outputTypeForMode = (m: Mode): 'image' | 'video' => (m === 't2i' || m === 'i2i' ? 'image' : 'video');
export const inputKindForMode = (m: Mode): 'image' | 'video' | 'none' =>
  m === 'v2v' ? 'video' : (m === 'i2i' || m === 'i2v') ? 'image' : 'none';
```

- [ ] **Step 2: Create `client/src/catalog.ts`**

```ts
import type { Catalog, SchemaParam } from './types';

let catalogPromise: Promise<Catalog> | null = null;
export function getCatalog(): Promise<Catalog> {
  if (!catalogPromise) {
    catalogPromise = fetch('/api/models').then((r) => {
      if (!r.ok) throw new Error(`models HTTP ${r.status}`);
      return r.json();
    });
  }
  return catalogPromise;
}

const schemaCache = new Map<string, Promise<SchemaParam[]>>();
export function getSchema(modelId: string): Promise<SchemaParam[]> {
  let p = schemaCache.get(modelId);
  if (!p) {
    p = fetch(`/api/schema?model=${encodeURIComponent(modelId)}`).then((r) => {
      if (!r.ok) throw new Error(`schema HTTP ${r.status}`);
      return r.json();
    });
    schemaCache.set(modelId, p);
  }
  return p;
}
```

- [ ] **Step 3: Type-check + commit**

Run: `cd client && npx tsc --noEmit`
Expected: errors only in files updated in later tasks (store/runner/BoxNode still reference old shapes). That's expected; commit now.

```bash
git add client/src/types.ts client/src/catalog.ts
git commit -m "feat(client): mode/catalog/schema types + catalog fetch client"
```

---

### Task 6: Frontend — pricing helpers (TDD)

**Files:**
- Create: `client/src/pricing.ts`
- Test: `client/src/pricing.test.ts`

- [ ] **Step 1: Write the failing test**

```ts
import { describe, it, expect } from 'vitest';
import { formatPrice, pipelineTotal } from './pricing';

describe('pricing', () => {
  it('formats dollars with up to 3 decimals', () => {
    expect(formatPrice(0)).toBe('$0');
    expect(formatPrice(0.096)).toBe('$0.096');
    expect(formatPrice(0.1)).toBe('$0.1');
    expect(formatPrice(1.5)).toBe('$1.5');
  });

  it('sums prices across nodes (ignoring undefined)', () => {
    const nodes = [{ data: { price: 0.03 } }, { data: { price: 0.096 } }, { data: {} }] as any;
    expect(pipelineTotal(nodes)).toBeCloseTo(0.126, 6);
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `cd client && npx vitest run src/pricing.test.ts`
Expected: FAIL — cannot find module `./pricing`.

- [ ] **Step 3: Write minimal implementation**

```ts
import type { FlowNode } from './store';

export function formatPrice(n: number): string {
  if (!n) return '$0';
  return '$' + Number(n.toFixed(3)).toString();
}

export function pipelineTotal(nodes: FlowNode[]): number {
  return nodes.reduce((sum, n) => sum + (n.data.price ?? 0), 0);
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `cd client && npx vitest run src/pricing.test.ts`
Expected: PASS (2 tests).

- [ ] **Step 5: Commit**

```bash
git add client/src/pricing.ts client/src/pricing.test.ts
git commit -m "feat(client): price formatting + pipeline total"
```

---

### Task 7: Frontend — store rework (TDD)

**Files:**
- Modify: `client/src/store.ts` (overwrite)
- Test: `client/src/store.test.ts` (overwrite)

- [ ] **Step 1: Overwrite `client/src/store.test.ts`**

```ts
import { describe, it, expect, beforeEach } from 'vitest';
import { useGraph } from './store';

beforeEach(() => { useGraph.getState().reset(); localStorage.clear(); });

describe('graph store', () => {
  it('adds a box with a default mode and empty model/params', () => {
    const id = useGraph.getState().addNode('t2i', { x: 0, y: 0 });
    const n = useGraph.getState().nodes.find((x) => x.id === id)!;
    expect(n.data.mode).toBe('t2i');
    expect(n.data.model).toBe('');
    expect(n.data.params).toEqual({});
    expect(n.data.status).toBe('idle');
  });

  it('setMode clears model + params; setModel sets price + resets params to defaults', () => {
    const id = useGraph.getState().addNode('t2i', { x: 0, y: 0 });
    useGraph.getState().setModel(id, 'a/b', 0.05, [{ name: 'prompt', type: 'string', required: true, isMedia: false }, { name: 'steps', type: 'integer', required: false, isMedia: false, default: 30 }] as any);
    let n = useGraph.getState().nodes.find((x) => x.id === id)!;
    expect(n.data.model).toBe('a/b');
    expect(n.data.price).toBe(0.05);
    expect(n.data.params).toEqual({ steps: 30 }); // prompt has no default → omitted
    useGraph.getState().setMode(id, 'i2v');
    n = useGraph.getState().nodes.find((x) => x.id === id)!;
    expect(n.data.mode).toBe('i2v');
    expect(n.data.model).toBe('');
    expect(n.data.params).toEqual({});
  });

  it('setParam updates a single param', () => {
    const id = useGraph.getState().addNode('t2i', { x: 0, y: 0 });
    useGraph.getState().setParam(id, 'prompt', 'hello');
    expect(useGraph.getState().nodes.find((x) => x.id === id)!.data.params.prompt).toBe('hello');
  });

  it('connects two nodes', () => {
    const a = useGraph.getState().addNode('t2i', { x: 0, y: 0 });
    const b = useGraph.getState().addNode('i2v', { x: 200, y: 0 });
    useGraph.getState().connect(a, b);
    expect(useGraph.getState().edges).toHaveLength(1);
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `cd client && npx vitest run src/store.test.ts`
Expected: FAIL — `addNode('t2i')`/`setModel`/`setMode`/`setParam` not defined as specified.

- [ ] **Step 3: Overwrite `client/src/store.ts`**

```ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { BoxNodeData, GraphEdge, Mode, NodeOutput, RunStatus, SchemaParam } from './types';

export interface FlowNode {
  id: string;
  type: 'box';
  position: { x: number; y: number };
  data: BoxNodeData;
}

interface GraphState {
  nodes: FlowNode[];
  edges: GraphEdge[];
  addNode: (mode: Mode, position: { x: number; y: number }) => string;
  setMode: (id: string, mode: Mode) => void;
  setModel: (id: string, model: string, price: number, params: SchemaParam[]) => void;
  setParam: (id: string, name: string, value: unknown) => void;
  setMediaInputs: (id: string, media: BoxNodeData['mediaInputs']) => void;
  removeNode: (id: string) => void;
  setRuntime: (id: string, patch: { status?: RunStatus; jobId?: string; output?: NodeOutput; error?: string }) => void;
  connect: (source: string, target: string) => void;
  setNodes: (nodes: FlowNode[]) => void;
  setEdges: (edges: GraphEdge[]) => void;
  importGraph: (g: { nodes: FlowNode[]; edges: GraphEdge[] }) => void;
  reset: () => void;
}

let counter = 0;
const newId = () => `n_${Date.now().toString(36)}_${counter++}`;

function defaultsFor(params: SchemaParam[]): Record<string, unknown> {
  const out: Record<string, unknown> = {};
  for (const p of params) if (!p.isMedia && p.default !== undefined) out[p.name] = p.default;
  return out;
}

const patchData = (s: GraphState, id: string, patch: Partial<BoxNodeData>) => ({
  nodes: s.nodes.map((n) => (n.id === id ? { ...n, data: { ...n.data, ...patch } } : n)),
});

export const useGraph = create<GraphState>()(
  persist(
    (set) => ({
      nodes: [],
      edges: [],
      addNode: (mode, position) => {
        const id = newId();
        const data: BoxNodeData = { mode, model: '', params: {}, mediaInputs: [], status: 'idle' };
        set((s) => ({ nodes: [...s.nodes, { id, type: 'box', position, data }] }));
        return id;
      },
      setMode: (id, mode) => set((s) => patchData(s, id, { mode, model: '', params: {}, price: undefined, output: undefined, status: 'idle', error: undefined })),
      setModel: (id, model, price, params) => set((s) => patchData(s, id, { model, price, params: defaultsFor(params) })),
      setParam: (id, name, value) =>
        set((s) => ({ nodes: s.nodes.map((n) => (n.id === id ? { ...n, data: { ...n.data, params: { ...n.data.params, [name]: value } } } : n)) })),
      setMediaInputs: (id, media) => set((s) => patchData(s, id, { mediaInputs: media })),
      removeNode: (id) => set((s) => ({ nodes: s.nodes.filter((n) => n.id !== id), edges: s.edges.filter((e) => e.source !== id && e.target !== id) })),
      setRuntime: (id, patch) => set((s) => patchData(s, id, patch)),
      connect: (source, target) =>
        set((s) => {
          if (source === target || s.edges.some((e) => e.source === source && e.target === target)) return s;
          return { edges: [...s.edges, { id: `${source}->${target}`, source, target }] };
        }),
      setNodes: (nodes) => set({ nodes }),
      setEdges: (edges) => set({ edges }),
      importGraph: (g) => set({ nodes: g.nodes, edges: g.edges }),
      reset: () => set({ nodes: [], edges: [] }),
    }),
    { name: 'atlas-canvas-v2' },
  ),
);
```

- [ ] **Step 4: Run test to verify it passes**

Run: `cd client && npx vitest run src/store.test.ts`
Expected: PASS (4 tests).

- [ ] **Step 5: Commit**

```bash
git add client/src/store.ts client/src/store.test.ts
git commit -m "feat(client): mode/model/params store"
```

---

### Task 8: Frontend — runner input resolution by mode (TDD)

**Files:**
- Modify: `client/src/runner.ts` (overwrite)
- Test: `client/src/runner.test.ts` (overwrite)
- Note: `client/src/graph/topo.ts` and `client/src/graph/resolveInputs.ts` are unchanged and reused.

- [ ] **Step 1: Overwrite `client/src/runner.test.ts`**

```ts
import { it, expect, vi } from 'vitest';
import { runNode } from './runner';
import type { FlowNode } from './store';
import type { GraphEdge } from './types';

function node(id: string, over: Partial<FlowNode['data']> = {}): FlowNode {
  return { id, type: 'box', position: { x: 0, y: 0 }, data: { mode: 'i2v', model: 'vendor/i2v', params: { prompt: 'go' }, mediaInputs: [], status: 'idle', ...over } };
}

const apiOk = () => ({
  extractFrame: vi.fn(async () => ({ url: 'https://x/frame.jpg' })),
  generate: vi.fn(async () => ({ jobId: 'job1' })),
  status: vi.fn(async () => ({ status: 'completed' as const, outputUrl: 'https://x/out.mp4', outputType: 'video' as const })),
  upload: vi.fn(),
});

it('i2v consumes an upstream video last-frame as imageInputs', async () => {
  const upstream = node('a', { mode: 't2v', model: 'vendor/t2v', output: { type: 'video', url: 'https://x/v.mp4' }, status: 'done' });
  const target = node('b', { mode: 'i2v' });
  const nodes = [upstream, target];
  const edges: GraphEdge[] = [{ id: 'a->b', source: 'a', target: 'b' }];
  const api = apiOk();
  const setRuntime = vi.fn();
  await runNode('b', () => ({ nodes, edges }), setRuntime, api as any, { pollMs: 0 });
  expect(api.extractFrame).toHaveBeenCalledWith('https://x/v.mp4');
  const body = api.generate.mock.calls[0][0] as any;
  expect(body.imageInputs).toEqual(['https://x/frame.jpg']);
  expect(body.outputType).toBe('video');
  expect(setRuntime).toHaveBeenCalledWith('b', expect.objectContaining({ status: 'done' }));
});

it('v2v consumes an upstream video as videoInputs', async () => {
  const upstream = node('a', { mode: 't2v', model: 'vendor/t2v', output: { type: 'video', url: 'https://x/v.mp4' }, status: 'done' });
  const target = node('b', { mode: 'v2v', model: 'vendor/v2v' });
  const nodes = [upstream, target];
  const edges: GraphEdge[] = [{ id: 'a->b', source: 'a', target: 'b' }];
  const api = apiOk();
  const setRuntime = vi.fn();
  await runNode('b', () => ({ nodes, edges }), setRuntime, api as any, { pollMs: 0 });
  const body = api.generate.mock.calls[0][0] as any;
  expect(body.videoInputs).toEqual(['https://x/v.mp4']);
  expect(api.extractFrame).not.toHaveBeenCalled();
});

it('v2v fed an image upstream fails clearly', async () => {
  const upstream = node('a', { mode: 't2i', model: 'vendor/t2i', output: { type: 'image', url: 'https://x/i.jpg' }, status: 'done' });
  const target = node('b', { mode: 'v2v', model: 'vendor/v2v' });
  const nodes = [upstream, target];
  const edges: GraphEdge[] = [{ id: 'a->b', source: 'a', target: 'b' }];
  const api = apiOk();
  const setRuntime = vi.fn();
  await expect(runNode('b', () => ({ nodes, edges }), setRuntime, api as any, { pollMs: 0 })).rejects.toThrow(/video input/i);
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `cd client && npx vitest run src/runner.test.ts`
Expected: FAIL — runner still uses old single-image logic / no `imageInputs`/`videoInputs`/`outputType`.

- [ ] **Step 3: Overwrite `client/src/runner.ts`**

```ts
import type { FlowNode } from './store';
import type { GraphEdge, RunStatus, NodeOutput } from './types';
import { outputTypeForMode, inputKindForMode } from './types';
import { topoSort, incomingEdges } from './graph/topo';
import { resolveUpstreamImageUrl } from './graph/resolveInputs';
import type { api as realApi } from './api';

type Api = typeof realApi;
type GetState = () => { nodes: FlowNode[]; edges: GraphEdge[] };
type SetRuntime = (id: string, patch: { status?: RunStatus; jobId?: string; output?: NodeOutput; error?: string }) => void;

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

async function pollUntilDone(jobId: string, api: Api, pollMs: number) {
  for (;;) {
    const s = await api.status(jobId);
    if (s.status === 'completed' || s.status === 'failed') return s;
    await sleep(pollMs);
  }
}

export async function runNode(nodeId: string, getState: GetState, setRuntime: SetRuntime, api: Api, opts: { pollMs?: number } = {}): Promise<void> {
  const pollMs = opts.pollMs ?? 2000;
  const { nodes, edges } = getState();
  const node = nodes.find((n) => n.id === nodeId);
  if (!node) throw new Error(`node ${nodeId} not found`);
  if (!node.data.model) throw new Error('Pick a model for this box first.');

  const need = inputKindForMode(node.data.mode);
  const imageInputs: string[] = [];
  const videoInputs: string[] = [];

  if (need !== 'none') {
    for (const edge of incomingEdges(nodeId, edges)) {
      const up = nodes.find((n) => n.id === edge.source);
      if (!up?.data.output) throw new Error('Upstream box has no output yet — run it first, or use Run pipeline.');
      if (need === 'image') {
        let url = resolveUpstreamImageUrl(up.data);
        if (up.data.output.type === 'video' && !up.data.output.lastFrameUrl) {
          const { url: frameUrl } = await api.extractFrame(up.data.output.url);
          setRuntime(up.id, { output: { ...up.data.output, lastFrameUrl: frameUrl } });
          url = frameUrl;
        }
        if (url) imageInputs.push(url);
      } else if (need === 'video') {
        if (up.data.output.type !== 'video') throw new Error('Video → Video needs a video input, but the upstream box outputs an image.');
        videoInputs.push(up.data.output.url);
      }
    }
    // user-uploaded refs (images only)
    if (need === 'image') imageInputs.push(...node.data.mediaInputs.filter((m) => m.source !== 'piped').map((m) => m.url));
  }

  setRuntime(nodeId, { status: 'running', error: undefined });
  const { jobId } = await api.generate({
    model: node.data.model,
    outputType: outputTypeForMode(node.data.mode),
    params: node.data.params,
    imageInputs: imageInputs.length ? imageInputs : undefined,
    videoInputs: videoInputs.length ? videoInputs : undefined,
  });
  setRuntime(nodeId, { jobId });

  const result = await pollUntilDone(jobId, api, pollMs);
  if (result.status === 'completed' && result.outputUrl) {
    setRuntime(nodeId, { status: 'done', output: { type: outputTypeForMode(node.data.mode), url: result.outputUrl } });
  } else {
    setRuntime(nodeId, { status: 'failed', error: result.error ?? 'generation failed' });
    throw new Error(result.error ?? 'generation failed');
  }
}

export async function runPipeline(getState: GetState, setRuntime: SetRuntime, api: Api, opts: { pollMs?: number } = {}): Promise<void> {
  const { nodes, edges } = getState();
  const order = topoSort(nodes.map((n) => n.id), edges);
  const failed = new Set<string>();
  for (const id of order) {
    if (incomingEdges(id, edges).some((e) => failed.has(e.source))) {
      failed.add(id);
      setRuntime(id, { status: 'skipped', error: 'upstream failed' });
      continue;
    }
    try {
      await runNode(id, getState, setRuntime, api, opts);
    } catch {
      failed.add(id);
    }
  }
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `cd client && npx vitest run src/runner.test.ts`
Expected: PASS (3 tests).

- [ ] **Step 5: Commit**

```bash
git add client/src/runner.ts client/src/runner.test.ts
git commit -m "feat(client): mode-aware input resolution in the runner"
```

---

### Task 9: Frontend — API client update

**Files:**
- Modify: `client/src/api.ts` (overwrite)

- [ ] **Step 1: Overwrite `client/src/api.ts`**

```ts
export interface GenerateArgs {
  model: string;
  outputType: 'image' | 'video';
  params: Record<string, unknown>;
  imageInputs?: string[];
  videoInputs?: string[];
}

export interface StatusResponse {
  status: 'queued' | 'running' | 'completed' | 'failed';
  outputUrl?: string;
  outputType?: 'image' | 'video';
  error?: string;
}

async function jsonPost<T>(url: string, body: unknown): Promise<T> {
  const res = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
  if (!res.ok) throw new Error((await res.json().catch(() => ({})))?.error ?? `HTTP ${res.status}`);
  return res.json() as Promise<T>;
}

export const api = {
  generate: (args: GenerateArgs) => jsonPost<{ jobId: string }>('/api/generate', args),
  status: async (jobId: string): Promise<StatusResponse> => {
    const res = await fetch(`/api/status/${jobId}`);
    if (!res.ok) throw new Error(`status HTTP ${res.status}`);
    return res.json();
  },
  extractFrame: (videoUrl: string) => jsonPost<{ url: string }>('/api/extract-frame', { videoUrl }),
  upload: async (file: File): Promise<{ url: string }> => {
    const form = new FormData();
    form.append('file', file);
    const res = await fetch('/api/upload', { method: 'POST', body: form });
    if (!res.ok) throw new Error(`upload HTTP ${res.status}`);
    return res.json();
  },
};
```

- [ ] **Step 2: Type-check + commit**

Run: `cd client && npx tsc --noEmit` (errors only in BoxNode/Toolbar until Tasks 10–12 land — OK to proceed)

```bash
git add client/src/api.ts
git commit -m "feat(client): generic generate api args"
```

---

### Task 10: Frontend — SchemaForm component

**Files:**
- Create: `client/src/components/SchemaForm.tsx`

- [ ] **Step 1: Create `client/src/components/SchemaForm.tsx`**

```tsx
import type { SchemaParam } from '../types';

interface Props {
  params: SchemaParam[];
  values: Record<string, unknown>;
  onChange: (name: string, value: unknown) => void;
}

const field = 'rounded bg-zinc-800 p-1 w-full';

export function SchemaForm({ params, values, onChange }: Props) {
  const visible = params.filter((p) => !p.isMedia);
  return (
    <div className="space-y-2">
      {visible.map((p) => {
        const val = values[p.name] ?? p.default ?? '';
        const label = (
          <span className="text-[11px] text-zinc-400">
            {p.title ?? p.name}{p.required ? ' *' : ''}
          </span>
        );
        if (p.enum) {
          return (
            <label key={p.name} className="block">{label}
              <select className={field} value={String(val)} onChange={(e) => onChange(p.name, coerce(p, e.target.value))}>
                {p.enum.map((o) => <option key={String(o)} value={String(o)}>{String(o)}</option>)}
              </select>
            </label>
          );
        }
        if (p.type === 'boolean') {
          return (
            <label key={p.name} className="flex items-center gap-2 text-[11px] text-zinc-400">
              <input type="checkbox" checked={Boolean(val)} onChange={(e) => onChange(p.name, e.target.checked)} />
              {p.title ?? p.name}
            </label>
          );
        }
        if (p.type === 'integer' || p.type === 'number') {
          return (
            <label key={p.name} className="block">{label}
              <input type="number" className={field} value={val === '' ? '' : Number(val)} onChange={(e) => onChange(p.name, e.target.value === '' ? undefined : Number(e.target.value))} />
            </label>
          );
        }
        if (p.name === 'prompt') {
          return (
            <label key={p.name} className="block">{label}
              <textarea className={`${field} h-16 resize-none`} value={String(val)} onChange={(e) => onChange(p.name, e.target.value)} />
            </label>
          );
        }
        return (
          <label key={p.name} className="block">{label}
            <input type="text" className={field} value={String(val)} onChange={(e) => onChange(p.name, e.target.value)} />
          </label>
        );
      })}
    </div>
  );
}

function coerce(p: SchemaParam, v: string): unknown {
  if (p.type === 'integer' || p.type === 'number') return Number(v);
  return v;
}
```

- [ ] **Step 2: Type-check + commit**

Run: `cd client && npx tsc --noEmit` (BoxNode/Toolbar still pending)

```bash
git add client/src/components/SchemaForm.tsx
git commit -m "feat(client): schema-driven settings form"
```

---

### Task 11: Frontend — BoxNode rework

**Files:**
- Modify: `client/src/components/BoxNode.tsx` (overwrite)

- [ ] **Step 1: Overwrite `client/src/components/BoxNode.tsx`**

```tsx
import { useEffect, useState } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
import type { BoxNodeData, Catalog, Mode, SchemaParam } from '../types';
import { MODE_LABEL, inputKindForMode } from '../types';
import { useGraph } from '../store';
import { getCatalog, getSchema } from '../catalog';
import { formatPrice } from '../pricing';
import { api } from '../api';
import { runNode } from '../runner';
import { SchemaForm } from './SchemaForm';

const STATUS_COLOR: Record<string, string> = { idle: '#6b7280', queued: '#a78bfa', running: '#f59e0b', done: '#10b981', failed: '#ef4444', skipped: '#6b7280' };
const MODES: Mode[] = ['t2i', 'i2i', 't2v', 'i2v', 'v2v'];

export function BoxNode({ id, data: rawData }: NodeProps) {
  const data = rawData as unknown as BoxNodeData;
  const setMode = useGraph((s) => s.setMode);
  const setModel = useGraph((s) => s.setModel);
  const setParam = useGraph((s) => s.setParam);
  const setMediaInputs = useGraph((s) => s.setMediaInputs);
  const setRuntime = useGraph((s) => s.setRuntime);
  const removeNode = useGraph((s) => s.removeNode);

  const [catalog, setCatalog] = useState<Catalog | null>(null);
  const [params, setParams] = useState<SchemaParam[]>([]);

  useEffect(() => { getCatalog().then(setCatalog).catch(() => setCatalog(null)); }, []);
  useEffect(() => {
    if (!data.model) { setParams([]); return; }
    let live = true;
    getSchema(data.model).then((p) => { if (live) setParams(p); }).catch(() => { if (live) setParams([]); });
    return () => { live = false; };
  }, [data.model]);

  const models = catalog?.[data.mode] ?? [];
  const inputKind = inputKindForMode(data.mode);

  const onSelectModel = async (modelId: string) => {
    if (!modelId) return;
    const price = models.find((m) => m.id === modelId)?.price ?? 0;
    const p = await getSchema(modelId).catch(() => [] as SchemaParam[]);
    setModel(id, modelId, price, p);
    setParams(p);
  };

  const onUpload = async (files: FileList | null) => {
    if (!files) return;
    const added = [...data.mediaInputs];
    for (const file of Array.from(files)) {
      const { url } = await api.upload(file);
      added.push({ source: 'upload', url });
    }
    setMediaInputs(id, added);
  };

  const onRun = async () => {
    try { await runNode(id, () => useGraph.getState(), setRuntime, api); }
    catch (e) { setRuntime(id, { status: 'failed', error: String((e as Error).message) }); }
  };

  return (
    <div style={{ width: 320 }} className="rounded-xl border border-zinc-700 bg-zinc-900 text-xs text-zinc-200 shadow-lg">
      <Handle type="target" position={Position.Left} />
      <div className="flex items-center justify-between border-b border-zinc-800 px-3 py-2">
        <select value={data.mode} onChange={(e) => setMode(id, e.target.value as Mode)} className="rounded bg-zinc-800 p-1 font-semibold">
          {MODES.map((m) => <option key={m} value={m}>{MODE_LABEL[m]}</option>)}
        </select>
        <span className="flex items-center gap-2">
          {data.price !== undefined && <span className="rounded bg-zinc-800 px-1.5 py-0.5 text-emerald-400">{formatPrice(data.price)}</span>}
          <span style={{ background: STATUS_COLOR[data.status] }} className="rounded px-1.5 py-0.5 text-[10px] text-black">{data.status}</span>
          <button onClick={() => removeNode(id)} className="text-zinc-500 hover:text-red-400">✕</button>
        </span>
      </div>

      <div className="space-y-2 p-3">
        <select value={data.model} onChange={(e) => onSelectModel(e.target.value)} className="w-full rounded bg-zinc-800 p-1">
          <option value="">{models.length ? `Select a model (${models.length})…` : 'Loading models…'}</option>
          {models.map((m) => <option key={m.id} value={m.id}>{m.label} — {formatPrice(m.price)}</option>)}
        </select>

        {inputKind === 'image' && (
          <div className="flex flex-wrap gap-1">
            {data.mediaInputs.map((r, i) => <img key={i} src={r.url} alt="" className="h-10 w-10 rounded object-cover ring-1 ring-zinc-700" />)}
            <label className="flex h-10 w-10 cursor-pointer items-center justify-center rounded border border-dashed border-zinc-600 text-zinc-400 hover:border-zinc-400">
              +<input type="file" accept="image/*" multiple hidden onChange={(e) => onUpload(e.target.files)} />
            </label>
          </div>
        )}
        {inputKind === 'video' && <div className="rounded bg-zinc-800/60 p-2 text-[11px] text-zinc-400">Connect a video box upstream to feed this.</div>}

        {data.model && <SchemaForm params={params} values={data.params} onChange={(n, v) => setParam(id, n, v)} />}

        {data.output && (data.output.type === 'video'
          ? <video src={data.output.url} controls className="w-full rounded" />
          : <img src={data.output.url} alt="" className="w-full rounded" />)}
        {data.error && <div className="text-red-400">{data.error}</div>}

        <div className="flex gap-2">
          <button onClick={onRun} disabled={!data.model} className="flex-1 rounded bg-indigo-600 py-1 font-semibold hover:bg-indigo-500 disabled:opacity-50">Run this box</button>
          {data.output && <a href={data.output.url} download className="rounded bg-zinc-700 px-2 py-1 hover:bg-zinc-600">↓</a>}
        </div>
      </div>
      <Handle type="source" position={Position.Right} />
    </div>
  );
}
```

- [ ] **Step 2: Type-check + commit**

Run: `cd client && npx tsc --noEmit` (Toolbar still pending)

```bash
git add client/src/components/BoxNode.tsx
git commit -m "feat(client): mode/model/schema-form/price box"
```

---

### Task 12: Frontend — Toolbar with mode-add + pipeline total

**Files:**
- Modify: `client/src/components/Toolbar.tsx` (overwrite)

- [ ] **Step 1: Overwrite `client/src/components/Toolbar.tsx`**

```tsx
import { useState } from 'react';
import { useGraph } from '../store';
import { api } from '../api';
import { runPipeline } from '../runner';
import { pipelineTotal, formatPrice } from '../pricing';

export function Toolbar() {
  const addNode = useGraph((s) => s.addNode);
  const setRuntime = useGraph((s) => s.setRuntime);
  const importGraph = useGraph((s) => s.importGraph);
  const reset = useGraph((s) => s.reset);
  const nodes = useGraph((s) => s.nodes);
  const [running, setRunning] = useState(false);

  const place = () => ({ x: 80 + Math.round((useGraph.getState().nodes.length % 5) * 60), y: 90 });
  const total = pipelineTotal(nodes);

  const onRunAll = async () => {
    setRunning(true);
    try { await runPipeline(() => useGraph.getState(), setRuntime, api); }
    catch (e) { alert(`Cannot run pipeline: ${(e as Error).message}`); }
    finally { setRunning(false); }
  };

  const onExport = () => {
    const { nodes: n, edges } = useGraph.getState();
    const blob = new Blob([JSON.stringify({ nodes: n, edges }, null, 2)], { type: 'application/json' });
    const a = document.createElement('a');
    a.href = URL.createObjectURL(blob); a.download = 'workflow.json'; a.click();
  };
  const onImport = async (file?: File) => { if (file) importGraph(JSON.parse(await file.text())); };

  return (
    <div className="absolute left-1/2 top-3 z-10 flex -translate-x-1/2 items-center gap-2 rounded-xl border border-zinc-700 bg-zinc-900/90 px-3 py-2 text-sm backdrop-blur">
      <button onClick={() => addNode('t2i', place())} className="rounded bg-zinc-700 px-3 py-1 hover:bg-zinc-600">+ Box</button>
      <div className="mx-1 h-5 w-px bg-zinc-700" />
      <button onClick={onRunAll} disabled={running} className="rounded bg-indigo-600 px-3 py-1 font-semibold hover:bg-indigo-500 disabled:opacity-50">
        {running ? 'Running…' : `Run pipeline · ${formatPrice(total)}`}
      </button>
      <div className="mx-1 h-5 w-px bg-zinc-700" />
      <button onClick={onExport} className="rounded bg-zinc-700 px-3 py-1 hover:bg-zinc-600">Export</button>
      <label className="cursor-pointer rounded bg-zinc-700 px-3 py-1 hover:bg-zinc-600">Import<input type="file" accept="application/json" hidden onChange={(e) => onImport(e.target.files?.[0])} /></label>
      <button onClick={() => { if (confirm('Clear the canvas?')) reset(); }} className="rounded bg-zinc-800 px-3 py-1 hover:bg-red-700">Clear</button>
    </div>
  );
}
```

- [ ] **Step 2: Full client build + test**

Run: `cd client && npm run build && npm test`
Expected: build succeeds; all unit tests pass (pricing, store, runner, topo, resolveInputs).

- [ ] **Step 3: Commit**

```bash
git add client/src/components/Toolbar.tsx
git commit -m "feat(client): mode-add toolbar with pipeline total price"
```

---

### Task 13: End-to-end manual verification (live)

Uses real Atlas credits — keep generations minimal.

- [ ] **Step 1:** Ensure `server/.env` has a funded `ATLASCLOUD_API_KEY`. Start backend (`cd server && npm run dev`) and client (`cd client && npm run dev`).
- [ ] **Step 2 — catalog:** Open the app, add a box. Confirm the Mode dropdown has all 5 modes and that switching modes repopulates the Model dropdown with priced models. Check the server logs show one `/api/v1/models` fetch (cached after).
- [ ] **Step 3 — t2i:** Mode = Text→Image, pick a cheap model (e.g. Seedream/Z-Image), confirm its settings render, set a prompt, Run this box → image appears; price chip matches the dropdown.
- [ ] **Step 4 — i2v chain:** Add an Image→Video box (Seedance 2.0), connect the t2i box into it, Run pipeline. Confirm the image is fed as the i2v `image` and a video is produced. Confirm the Run button shows the summed total.
- [ ] **Step 5 — v2v chain:** Add a Video→Video box (e.g. Wan 2.6 v2v) after the video, Run pipeline → confirm `/api/extract-frame` is NOT used and the upstream video URL is passed as `videos`.
- [ ] **Step 6:** Reload (persists), Export/Import round-trips.
- [ ] **Step 7:** `git commit -am "docs: e2e verification for modes/pricing"` (if any notes/fixes).

---

## Self-Review (completed during planning)

- **Spec coverage:** modes + classification (T3), all-models catalog + price (T3, /models T4), schema-driven settings (T2 schema, T10 SchemaForm, T11 wiring), media injection (T1 mediaInput, T4 generate), chaining by mode (T8 runner), per-box price (T11), pipeline total (T6 pricing, T12 toolbar), mode/model reset semantics (T7 store). All mapped.
- **Type consistency:** `Mode`, `SchemaParam`, `CatalogModel`/`Catalog` defined once per side (server T1–T3, client T5) and reused. `GenerateArgs` (T9) ↔ runner call (T8) ↔ route body (T4: `{ model, outputType, params, imageInputs, videoInputs }`) all agree. Store actions `addNode(mode)`, `setMode`, `setModel(id,price,params)`, `setParam`, `setMediaInputs`, `setRuntime` used consistently in T11/T12.
- **Placeholders:** none. The only runtime variability (ambiguous-model classification) is handled by the schema-required fallback in T3 with a test.
- **Persistence note:** store key bumped to `atlas-canvas-v2` (T7) so old `kind`-based saved graphs don't load with missing fields.
