# Higgsfield Canvas 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:** Build a standalone node-canvas web app where the user creates image/video "boxes," wires them together, and runs one box or the whole pipeline — with each box's last frame fed into the next box as its first reference image — by calling the Higgsfield Cloud API.

**Architecture:** A Vite + React + TypeScript frontend (React Flow canvas, Zustand state, client-side pipeline runner) talks over HTTP to a small Node + Express backend. The backend holds the Higgsfield API key, calls the official `@higgsfield/client` SDK to generate and to upload media, and uses `ffmpeg-static` to extract a video's last frame. The backend uploads local images and extracted frames to Higgsfield storage so the returned URLs are reachable by Higgsfield's servers.

**Tech Stack:** React 18, TypeScript, Vite, `@xyflow/react` (React Flow), Zustand, Tailwind CSS, Node 24, Express, `@higgsfield/client`, `ffmpeg-static`, `multer`, `cors`, Vitest (+ `supertest` for routes).

**Repo layout:**
```
ShimritMovie/
  client/          # Vite React app
  server/          # Express backend
  docs/            # specs & plans (exists)
```

**Conventions:** TypeScript ESM throughout (`"type": "module"`). Vitest for tests. Commit after each task. Never commit `.env` or `node_modules`.

---

### Task 1: Repo scaffold + .gitignore

**Files:**
- Create: `.gitignore`
- Create: `README.md`

- [ ] **Step 1: Create `.gitignore`**

```gitignore
node_modules/
dist/
.env
.env.local
*.log
.DS_Store
/client/node_modules/
/server/node_modules/
/client/dist/
/server/dist/
.claude/settings.local.json
tmp/
```

- [ ] **Step 2: Create `README.md`**

```markdown
# Higgsfield Canvas

Node-based workflow builder for creating multi-shot movies with the Higgsfield Cloud API.

## Structure
- `client/` — Vite + React canvas UI
- `server/` — Express backend that proxies the Higgsfield SDK

## Setup
1. `cd server && npm install`
2. Copy `server/.env.example` to `server/.env` and set `HF_CREDENTIALS=KEY_ID:KEY_SECRET`
3. `cd client && npm install`

## Run (dev)
- Terminal 1: `cd server && npm run dev`
- Terminal 2: `cd client && npm run dev`
- Open the Vite URL (default http://localhost:5173)
```

- [ ] **Step 3: Commit**

```bash
git add .gitignore README.md
git commit -m "chore: add gitignore and project readme"
```

---

### Task 2: Backend scaffold

**Files:**
- Create: `server/package.json`
- Create: `server/tsconfig.json`
- Create: `server/vitest.config.ts`
- Create: `server/.env.example`
- Create: `server/src/index.ts` (placeholder, fleshed out in Task 8)

- [ ] **Step 1: Create `server/package.json`**

```json
{
  "name": "higgsfield-canvas-server",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "build": "tsc -p tsconfig.json",
    "start": "node dist/index.js",
    "test": "vitest run",
    "test:watch": "vitest"
  },
  "dependencies": {
    "@higgsfield/client": "latest",
    "cors": "^2.8.5",
    "dotenv": "^16.4.5",
    "express": "^4.19.2",
    "ffmpeg-static": "^5.2.0",
    "multer": "^1.4.5-lts.1"
  },
  "devDependencies": {
    "@types/cors": "^2.8.17",
    "@types/express": "^4.17.21",
    "@types/multer": "^1.4.11",
    "@types/node": "^22.0.0",
    "@types/supertest": "^6.0.2",
    "supertest": "^7.0.0",
    "tsx": "^4.16.0",
    "typescript": "^5.5.0",
    "vitest": "^2.0.0"
  }
}
```

- [ ] **Step 2: Create `server/tsconfig.json`**

```json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "resolveJsonModule": true
  },
  "include": ["src"]
}
```

- [ ] **Step 3: Create `server/vitest.config.ts`**

```ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: { environment: 'node', include: ['src/**/*.test.ts'] },
});
```

- [ ] **Step 4: Create `server/.env.example`**

```bash
# Higgsfield Cloud API credentials from https://cloud.higgsfield.ai (API section)
# Format is KEY_ID:KEY_SECRET
HF_CREDENTIALS=your_key_id:your_key_secret
PORT=8787
```

- [ ] **Step 5: Create placeholder `server/src/index.ts`**

```ts
// Fleshed out in Task 8.
export {};
```

- [ ] **Step 6: Install dependencies**

Run: `cd server && npm install`
Expected: installs without errors; `node_modules` created.

- [ ] **Step 7: Commit**

```bash
git add server/package.json server/tsconfig.json server/vitest.config.ts server/.env.example server/src/index.ts server/package-lock.json
git commit -m "chore(server): scaffold express + vitest backend"
```

---

### Task 3: Backend — job registry (TDD)

In-memory map of generation jobs. `/api/generate` creates a job and runs generation asynchronously; `/api/status/:id` reads it. Acceptable that jobs are lost on server restart (single-user local MVP).

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

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

```ts
import { describe, it, expect } from 'vitest';
import { createJob, getJob, updateJob } from './jobRegistry.js';

describe('jobRegistry', () => {
  it('creates a queued job with a unique id', () => {
    const a = createJob();
    const b = createJob();
    expect(a.status).toBe('queued');
    expect(a.id).not.toBe(b.id);
    expect(getJob(a.id)?.id).toBe(a.id);
  });

  it('patches an existing job', () => {
    const j = createJob();
    updateJob(j.id, { status: 'completed', outputUrl: 'https://x/v.mp4', outputType: 'video' });
    expect(getJob(j.id)).toMatchObject({ status: 'completed', outputUrl: 'https://x/v.mp4', outputType: 'video' });
  });

  it('returns undefined for unknown id', () => {
    expect(getJob('nope')).toBeUndefined();
  });
});
```

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

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

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

```ts
import { randomUUID } from 'node:crypto';

export type JobStatus = 'queued' | 'running' | 'completed' | 'failed';
export type OutputType = 'image' | 'video';

export interface JobRecord {
  id: string;
  status: JobStatus;
  outputUrl?: string;
  outputType?: OutputType;
  error?: string;
  createdAt: number;
}

const jobs = new Map<string, JobRecord>();

export function createJob(): JobRecord {
  const rec: JobRecord = { id: randomUUID(), status: 'queued', createdAt: Date.now() };
  jobs.set(rec.id, rec);
  return rec;
}

export function getJob(id: string): JobRecord | undefined {
  return jobs.get(id);
}

export function updateJob(id: string, patch: Partial<JobRecord>): void {
  const rec = jobs.get(id);
  if (rec) jobs.set(id, { ...rec, ...patch });
}
```

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

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

- [ ] **Step 5: Commit**

```bash
git add server/src/jobRegistry.ts server/src/jobRegistry.test.ts
git commit -m "feat(server): in-memory job registry"
```

---

### Task 4: Backend — model catalog (TDD)

Maps a UI model id + user settings to the exact SDK endpoint and `input` object. This is the single place that knows Higgsfield's per-model parameter shapes.

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

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

```ts
import { describe, it, expect } from 'vitest';
import { getModel, MODELS } from './models.js';

describe('models catalog', () => {
  it('exposes at least one image and one video model', () => {
    expect(MODELS.some((m) => m.kind === 'image')).toBe(true);
    expect(MODELS.some((m) => m.kind === 'video')).toBe(true);
  });

  it('throws on unknown model id', () => {
    expect(() => getModel('does-not-exist')).toThrow(/Unknown model/);
  });

  it('soul maps size/quality and includes image_reference when a ref is given', () => {
    const m = getModel('soul');
    const input = m.buildInput({ prompt: 'hi', aspectRatio: '2048x1152', resolution: '1080p', inputImageUrls: ['https://x/a.jpg'] });
    expect(input).toMatchObject({
      prompt: 'hi', width_and_height: '2048x1152', quality: '1080p', batch_size: 1,
      image_reference: { type: 'image_url', image_url: 'https://x/a.jpg' },
    });
  });

  it('soul omits image_reference when no ref, defaulting quality to 720p', () => {
    const input = getModel('soul').buildInput({ prompt: 'p' });
    expect('image_reference' in input).toBe(false);
    expect(input.quality).toBe('720p');
  });

  it('dop variants map model + input_images array', () => {
    const input = getModel('dop-standard').buildInput({ prompt: 'move', inputImageUrls: ['https://x/a.jpg', 'https://x/b.jpg'] });
    expect(input.model).toBe('dop-standard');
    expect(input.input_images).toEqual([
      { type: 'image_url', image_url: 'https://x/a.jpg' },
      { type: 'image_url', image_url: 'https://x/b.jpg' },
    ]);
  });
});
```

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

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

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

```ts
export type NodeKind = 'image' | 'video';

export interface BuildParams {
  prompt: string;
  aspectRatio?: string;   // image (Soul): a width_and_height value, e.g. '2048x1152'
  resolution?: string;    // image (Soul): '720p' | '1080p' (maps to Soul `quality`)
  inputImageUrls?: string[];
}

export interface ModelDef {
  id: string;
  label: string;
  kind: NodeKind;
  endpoint: string;        // exact @higgsfield/client subscribe path
  buildInput(p: BuildParams): Record<string, unknown>;
}

const imageUrlObj = (u: string) => ({ type: 'image_url', image_url: u });

// DoP image-to-video accepts ONLY: model, prompt, input_images (+ optional motions/seed).
// It has NO duration/aspect_ratio/resolution — those are inherited from the input image.
function dopInput(model: 'dop-turbo' | 'dop-standard' | 'dop-lite', p: BuildParams): Record<string, unknown> {
  return {
    model,
    prompt: p.prompt,
    input_images: (p.inputImageUrls ?? []).map(imageUrlObj),
  };
}

export const MODELS: ModelDef[] = [
  {
    id: 'soul',
    label: 'Soul (text → image)',
    kind: 'image',
    endpoint: '/v1/text2image/soul',
    buildInput(p) {
      const input: Record<string, unknown> = {
        prompt: p.prompt,
        width_and_height: p.aspectRatio ?? '1536x1536',
        quality: p.resolution === '1080p' ? '1080p' : '720p',
        batch_size: 1,
      };
      // Soul accepts a single optional reference image (used for chaining / uploads).
      const first = p.inputImageUrls?.[0];
      if (first) input.image_reference = imageUrlObj(first);
      return input;
    },
  },
  { id: 'dop-turbo', label: 'DoP Turbo (image → video)', kind: 'video', endpoint: '/v1/image2video/dop', buildInput: (p) => dopInput('dop-turbo', p) },
  { id: 'dop-standard', label: 'DoP Standard (image → video)', kind: 'video', endpoint: '/v1/image2video/dop', buildInput: (p) => dopInput('dop-standard', p) },
  { id: 'dop-lite', label: 'DoP Lite (image → video)', kind: 'video', endpoint: '/v1/image2video/dop', buildInput: (p) => dopInput('dop-lite', p) },
];

export function getModel(id: string): ModelDef {
  const m = MODELS.find((x) => x.id === id);
  if (!m) throw new Error(`Unknown model: ${id}`);
  return m;
}
```

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

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

- [ ] **Step 5: Commit**

```bash
git add server/src/models.ts server/src/models.test.ts
git commit -m "feat(server): higgsfield model catalog + input mapping"
```

---

### Task 5: Backend — Higgsfield service (TDD with mocked SDK)

Thin wrapper around `@higgsfield/client`. Two responsibilities: `generate(endpoint, input)` (blocking SDK call with internal polling) and `uploadImage(buffer, format)`.

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

- [ ] **Step 1: Write the failing test (mock the SDK modules)**

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

const subscribe = vi.fn();
const uploadImage = vi.fn();

vi.mock('@higgsfield/client/v2', () => ({
  createHiggsfieldClient: () => ({ subscribe }),
}));
vi.mock('@higgsfield/client', () => ({
  HiggsfieldClient: class { uploadImage = uploadImage; },
}));

beforeEach(() => {
  vi.resetModules();
  subscribe.mockReset();
  uploadImage.mockReset();
  process.env.HF_CREDENTIALS = 'kid:secret';
});

describe('higgsfield service', () => {
  it('returns the raw output url on a completed jobSet', async () => {
    subscribe.mockResolvedValue({
      isCompleted: true,
      jobs: [{ status: 'completed', results: { raw: { url: 'https://x/out.mp4' } } }],
    });
    const { generate } = await import('./higgsfield.js');
    const res = await generate('/v1/image2video/dop', { prompt: 'p' });
    expect(res.url).toBe('https://x/out.mp4');
    expect(subscribe).toHaveBeenCalledWith('/v1/image2video/dop', { input: { prompt: 'p' }, withPolling: true });
  });

  it('throws when the jobSet is not completed', async () => {
    subscribe.mockResolvedValue({ isCompleted: false, jobs: [{ status: 'failed' }] });
    const { generate } = await import('./higgsfield.js');
    await expect(generate('e', {})).rejects.toThrow(/not completed/);
  });

  it('delegates uploads to the client and returns the url', async () => {
    uploadImage.mockResolvedValue('https://x/uploaded.jpg');
    const { uploadImage: up } = await import('./higgsfield.js');
    const url = await up(Buffer.from('abc'), 'jpeg');
    expect(url).toBe('https://x/uploaded.jpg');
  });
});
```

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

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

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

```ts
import { createHiggsfieldClient } from '@higgsfield/client/v2';
import { HiggsfieldClient } from '@higgsfield/client';

function credentials(): string {
  const creds = process.env.HF_CREDENTIALS;
  if (!creds) throw new Error('HF_CREDENTIALS missing in environment (KEY_ID:KEY_SECRET)');
  return creds;
}

// V2 client used for generation (internal polling via withPolling).
const v2 = createHiggsfieldClient({ credentials: credentials() });

// V1 client used for media upload. Credentials are KEY_ID:KEY_SECRET.
const [apiKey, apiSecret] = credentials().split(':');
const v1 = new HiggsfieldClient({ apiKey, apiSecret });

export interface GenResult {
  url: string;
}

export async function generate(endpoint: string, input: Record<string, unknown>): Promise<GenResult> {
  const jobSet: any = await v2.subscribe(endpoint, { input, withPolling: true });
  if (!jobSet?.isCompleted) {
    const status = jobSet?.jobs?.[0]?.status ?? 'unknown';
    throw new Error(`Higgsfield job not completed (status: ${status})`);
  }
  const url: string | undefined = jobSet?.jobs?.[0]?.results?.raw?.url;
  if (!url) throw new Error('Higgsfield job completed but returned no output URL');
  return { url };
}

export async function uploadImage(buffer: Buffer, format: 'jpeg' | 'png' | 'webp' = 'jpeg'): Promise<string> {
  // NOTE: if the installed SDK exposes upload on the V2 client instead, adjust here only.
  return await (v1 as any).uploadImage(buffer, format);
}
```

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

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

- [ ] **Step 5: Commit**

```bash
git add server/src/higgsfield.ts server/src/higgsfield.test.ts
git commit -m "feat(server): higgsfield SDK service (generate + upload)"
```

---

### Task 6: Backend — last-frame extractor (TDD with mocks)

Downloads a video URL, runs ffmpeg to write the last frame to a JPEG, returns the bytes.

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

- [ ] **Step 1: Write the failing test (mock fetch, fs, child_process)**

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

const spawn = vi.fn();
vi.mock('node:child_process', () => ({ spawn }));
vi.mock('ffmpeg-static', () => ({ default: '/fake/ffmpeg' }));
vi.mock('node:fs/promises', () => ({
  mkdtemp: vi.fn(async () => '/tmp/hf-frame-x'),
  writeFile: vi.fn(async () => undefined),
  readFile: vi.fn(async () => Buffer.from('JPEGDATA')),
  rm: vi.fn(async () => undefined),
}));

function fakeProc() {
  const handlers: Record<string, (a?: unknown) => void> = {};
  return {
    stderr: { on: () => undefined },
    on: (ev: string, cb: (a?: unknown) => void) => { handlers[ev] = cb; },
    _fire: (ev: string, a?: unknown) => handlers[ev]?.(a),
  };
}

beforeEach(() => {
  spawn.mockReset();
  // @ts-expect-error inject a fetch stub
  global.fetch = vi.fn(async () => ({ ok: true, arrayBuffer: async () => new ArrayBuffer(4) }));
});

describe('extractLastFrame', () => {
  it('downloads, runs ffmpeg with -sseof -1, and returns the frame bytes', async () => {
    const proc = fakeProc();
    spawn.mockReturnValue(proc);
    const { extractLastFrame } = await import('./frameExtractor.js');
    const promise = extractLastFrame('https://x/v.mp4');
    proc._fire('close', 0); // simulate ffmpeg success
    const buf = await promise;
    expect(buf.toString()).toBe('JPEGDATA');
    const args = spawn.mock.calls[0][1] as string[];
    expect(args).toContain('-sseof');
    expect(args).toContain('-1');
  });
});
```

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

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

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

```ts
import { spawn } from 'node:child_process';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import ffmpegPath from 'ffmpeg-static';

function runFfmpeg(args: string[]): Promise<void> {
  return new Promise((resolve, reject) => {
    const proc = spawn(ffmpegPath as unknown as string, args);
    let stderr = '';
    proc.stderr.on('data', (d: Buffer) => (stderr += d.toString()));
    proc.on('error', reject);
    proc.on('close', (code: number) =>
      code === 0 ? resolve() : reject(new Error(`ffmpeg exited ${code}: ${stderr}`)),
    );
  });
}

export async function extractLastFrame(videoUrl: string): Promise<Buffer> {
  const dir = await mkdtemp(join(tmpdir(), 'hf-frame-'));
  const inPath = join(dir, 'in.mp4');
  const outPath = join(dir, 'out.jpg');
  try {
    const res = await fetch(videoUrl);
    if (!res.ok) throw new Error(`Failed to download video: ${res.status}`);
    await writeFile(inPath, Buffer.from(await res.arrayBuffer()));
    // -sseof -1 seeks to the last second; -update 1 overwrites out.jpg each frame,
    // so the final write is the last frame of the video.
    await runFfmpeg(['-y', '-sseof', '-1', '-i', inPath, '-update', '1', '-q:v', '2', outPath]);
    return await readFile(outPath);
  } finally {
    await rm(dir, { recursive: true, force: true });
  }
}
```

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

Run: `cd server && npx vitest run src/frameExtractor.test.ts`
Expected: PASS (1 test).

- [ ] **Step 5: Commit**

```bash
git add server/src/frameExtractor.ts server/src/frameExtractor.test.ts
git commit -m "feat(server): ffmpeg last-frame extractor"
```

---

### Task 7: Backend — Express routes (TDD with supertest + mocked deps)

Routes: `POST /api/generate`, `GET /api/status/:id`, `POST /api/upload`, `POST /api/extract-frame`.

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

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

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

const generate = vi.fn();
const uploadImage = vi.fn();
const extractLastFrame = vi.fn();

vi.mock('./higgsfield.js', () => ({ generate, uploadImage }));
vi.mock('./frameExtractor.js', () => ({ extractLastFrame }));

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();
  uploadImage.mockReset();
  extractLastFrame.mockReset();
});

describe('routes', () => {
  it('POST /api/generate returns a jobId immediately', async () => {
    generate.mockResolvedValue({ url: 'https://x/out.jpg' });
    const app = await makeApp();
    const res = await request(app)
      .post('/api/generate')
      .send({ modelId: 'soul', prompt: 'hello', aspectRatio: '1536x1536' });
    expect(res.status).toBe(200);
    expect(typeof res.body.jobId).toBe('string');
  });

  it('POST /api/generate 400s on unknown model', async () => {
    const app = await makeApp();
    const res = await request(app).post('/api/generate').send({ modelId: 'nope', prompt: 'x' });
    expect(res.status).toBe(400);
  });

  it('GET /api/status/:id 404s for unknown job', async () => {
    const app = await makeApp();
    const res = await request(app).get('/api/status/unknown-id');
    expect(res.status).toBe(404);
  });

  it('POST /api/extract-frame extracts then uploads', async () => {
    extractLastFrame.mockResolvedValue(Buffer.from('frame'));
    uploadImage.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 — cannot find module `./routes.js`.

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

```ts
import { Router } from 'express';
import multer from 'multer';
import { createJob, getJob, updateJob } from './jobRegistry.js';
import { generate, uploadImage } from './higgsfield.js';
import { getModel } from './models.js';
import { extractLastFrame } from './frameExtractor.js';

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

export const router = Router();

router.post('/generate', (req, res) => {
  try {
    const { modelId, prompt, aspectRatio, resolution, inputImageUrls } = req.body ?? {};
    const model = getModel(modelId);
    const input = model.buildInput({ prompt, aspectRatio, resolution, inputImageUrls });
    const job = createJob();
    res.json({ jobId: job.id });

    updateJob(job.id, { status: 'running' });
    generate(model.endpoint, input)
      .then((r) => updateJob(job.id, { status: 'completed', outputUrl: r.url, outputType: model.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 mime = req.file.mimetype;
    const fmt = mime.includes('png') ? 'png' : mime.includes('webp') ? 'webp' : 'jpeg';
    const url = await uploadImage(req.file.buffer, fmt as 'jpeg' | 'png' | 'webp');
    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 uploadImage(buf, 'jpeg');
    res.json({ url });
  } catch (e: any) {
    res.status(500).json({ error: String(e?.message ?? e) });
  }
});
```

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

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

- [ ] **Step 5: Commit**

```bash
git add server/src/routes.ts server/src/routes.test.ts
git commit -m "feat(server): generate/status/upload/extract-frame routes"
```

---

### Task 8: Backend — server entry + manual run check

**Files:**
- Modify: `server/src/index.ts`

- [ ] **Step 1: Replace `server/src/index.ts`**

```ts
import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import { router } from './routes.js';

const app = express();
app.use(cors());
app.use(express.json({ limit: '5mb' }));
app.use('/api', router);
app.get('/health', (_req, res) => res.json({ ok: true }));

const port = Number(process.env.PORT ?? 8787);
app.listen(port, () => console.log(`Higgsfield Canvas backend listening on :${port}`));
```

- [ ] **Step 2: Verify the full test suite passes**

Run: `cd server && npm test`
Expected: all suites PASS.

- [ ] **Step 3: Manual smoke test (requires real `.env`)**

Create `server/.env` from `.env.example` with real `HF_CREDENTIALS`. Then:
Run: `cd server && npm run dev`
In another terminal: `curl http://localhost:8787/health`
Expected: `{"ok":true}`. Stop the server.

- [ ] **Step 4: Commit**

```bash
git add server/src/index.ts
git commit -m "feat(server): express entrypoint with health check"
```

---

### Task 9: Frontend scaffold (Vite + React + TS + Tailwind + React Flow + Zustand)

**Files:**
- Create: `client/` via Vite, then add deps and config.

- [ ] **Step 1: Scaffold Vite app**

Run: `npm create vite@latest client -- --template react-ts`
Then: `cd client && npm install`

- [ ] **Step 2: Add dependencies**

Run (from `client/`):
```bash
npm install @xyflow/react zustand
npm install -D tailwindcss postcss autoprefixer vitest jsdom @testing-library/react @testing-library/jest-dom
npx tailwindcss init -p
```

- [ ] **Step 3: Configure Tailwind — replace `client/tailwind.config.js`**

```js
/** @type {import('tailwindcss').Config} */
export default {
  content: ['./index.html', './src/**/*.{ts,tsx}'],
  theme: { extend: {} },
  plugins: [],
};
```

- [ ] **Step 4: Replace `client/src/index.css`**

```css
@tailwind base;
@tailwind components;
@tailwind utilities;

html, body, #root { height: 100%; margin: 0; background: #0b0b0f; color: #e7e7ea; }
```

- [ ] **Step 5: Configure Vitest + dev proxy — replace `client/vite.config.ts`**

```ts
/// <reference types="vitest" />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  server: {
    proxy: { '/api': 'http://localhost:8787' },
  },
  test: { environment: 'jsdom', globals: true, setupFiles: ['./src/test-setup.ts'] },
});
```

- [ ] **Step 6: Create `client/src/test-setup.ts`**

```ts
import '@testing-library/jest-dom';
```

- [ ] **Step 7: Add test script — edit `client/package.json` scripts**

Add to `"scripts"`: `"test": "vitest run"`.

- [ ] **Step 8: Verify scaffold builds**

Run: `cd client && npm run build`
Expected: build succeeds.

- [ ] **Step 9: Commit**

```bash
git add client
git commit -m "chore(client): scaffold vite react app with tailwind, react flow, zustand, vitest"
```

---

### Task 10: Frontend — shared types + UI model catalog

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

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

```ts
export type NodeKind = 'image' | 'video';
export type RunStatus = 'idle' | 'queued' | 'running' | 'done' | 'failed' | 'skipped';

export interface MediaRef {
  source: 'upload' | 'piped' | 'url';
  url: string;
}

export interface NodeOutput {
  type: NodeKind;
  url: string;
  lastFrameUrl?: string;
}

export interface BoxNodeData {
  kind: NodeKind;
  prompt: string;
  referenceImages: MediaRef[];
  model: string;        // model id from the catalog
  aspectRatio: string;
  resolution: string;
  duration?: number;    // seconds, video only
  status: RunStatus;
  jobId?: string;
  output?: NodeOutput;
  error?: string;
}

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

- [ ] **Step 2: Create `client/src/models.ts`** (display metadata; mirrors server catalog ids)

```ts
import type { NodeKind } from './types';

export interface SizeOption { value: string; label: string; }

export interface UiModel {
  id: string;
  label: string;
  kind: NodeKind;
  sizes: SizeOption[];     // image only (Soul width_and_height); empty for video
  resolutions: string[];   // image only (Soul quality); empty for video
}

// Soul width_and_height options, labelled by aspect ratio.
const SOUL_SIZES: SizeOption[] = [
  { value: '1536x1536', label: 'Square 1:1 (1536×1536)' },
  { value: '2048x1152', label: 'Landscape 16:9 (2048×1152)' },
  { value: '1152x2048', label: 'Portrait 9:16 (1152×2048)' },
  { value: '2048x1536', label: 'Landscape 4:3 (2048×1536)' },
  { value: '1536x2048', label: 'Portrait 3:4 (1536×2048)' },
];

export const UI_MODELS: UiModel[] = [
  { id: 'soul', label: 'Soul', kind: 'image', sizes: SOUL_SIZES, resolutions: ['720p', '1080p'] },
  { id: 'dop-turbo', label: 'DoP Turbo', kind: 'video', sizes: [], resolutions: [] },
  { id: 'dop-standard', label: 'DoP Standard', kind: 'video', sizes: [], resolutions: [] },
  { id: 'dop-lite', label: 'DoP Lite', kind: 'video', sizes: [], resolutions: [] },
];

export const modelsForKind = (kind: NodeKind) => UI_MODELS.filter((m) => m.kind === kind);
export const defaultModelFor = (kind: NodeKind) => modelsForKind(kind)[0];
```

- [ ] **Step 3: Commit**

```bash
git add client/src/types.ts client/src/models.ts
git commit -m "feat(client): shared types and UI model catalog"
```

---

### Task 11: Frontend — topological sort (TDD)

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

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

```ts
import { describe, it, expect } from 'vitest';
import { topoSort, incomingEdges } from './topo';

const e = (source: string, target: string) => ({ id: `${source}-${target}`, source, target });

describe('topoSort', () => {
  it('orders a linear chain', () => {
    expect(topoSort(['a', 'b', 'c'], [e('a', 'b'), e('b', 'c')])).toEqual(['a', 'b', 'c']);
  });

  it('places a dependency before its dependents in a diamond', () => {
    const order = topoSort(['a', 'b', 'c', 'd'], [e('a', 'b'), e('a', 'c'), e('b', 'd'), e('c', 'd')]);
    expect(order.indexOf('a')).toBeLessThan(order.indexOf('b'));
    expect(order.indexOf('b')).toBeLessThan(order.indexOf('d'));
    expect(order.indexOf('c')).toBeLessThan(order.indexOf('d'));
  });

  it('throws on a cycle', () => {
    expect(() => topoSort(['a', 'b'], [e('a', 'b'), e('b', 'a')])).toThrow(/Cycle/);
  });

  it('finds incoming edges for a node', () => {
    expect(incomingEdges('d', [e('b', 'd'), e('c', 'd'), e('a', 'b')]).map((x) => x.source)).toEqual(['b', 'c']);
  });
});
```

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

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

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

```ts
import type { GraphEdge } from '../types';

export function topoSort(nodeIds: string[], edges: GraphEdge[]): string[] {
  const indeg = new Map<string, number>(nodeIds.map((id) => [id, 0]));
  const adj = new Map<string, string[]>(nodeIds.map((id) => [id, []]));
  for (const edge of edges) {
    if (!adj.has(edge.source) || !indeg.has(edge.target)) continue;
    adj.get(edge.source)!.push(edge.target);
    indeg.set(edge.target, (indeg.get(edge.target) ?? 0) + 1);
  }
  const queue = nodeIds.filter((id) => (indeg.get(id) ?? 0) === 0);
  const order: string[] = [];
  while (queue.length) {
    const n = queue.shift()!;
    order.push(n);
    for (const m of adj.get(n) ?? []) {
      indeg.set(m, (indeg.get(m) ?? 1) - 1);
      if ((indeg.get(m) ?? 0) === 0) queue.push(m);
    }
  }
  if (order.length !== nodeIds.length) throw new Error('Cycle detected in workflow graph');
  return order;
}

export function incomingEdges(nodeId: string, edges: GraphEdge[]): GraphEdge[] {
  return edges.filter((e) => e.target === nodeId);
}
```

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

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

- [ ] **Step 5: Commit**

```bash
git add client/src/graph/topo.ts client/src/graph/topo.test.ts
git commit -m "feat(client): topological sort + cycle detection"
```

---

### Task 12: Frontend — upstream image resolution (TDD)

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

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

```ts
import { describe, it, expect } from 'vitest';
import { resolveUpstreamImageUrl } from './resolveInputs';
import type { BoxNodeData } from '../types';

const base: BoxNodeData = {
  kind: 'image', prompt: '', referenceImages: [], model: 'm', aspectRatio: '1:1', resolution: 'default', status: 'done',
};

describe('resolveUpstreamImageUrl', () => {
  it('returns the image url for an image node', () => {
    expect(resolveUpstreamImageUrl({ ...base, output: { type: 'image', url: 'https://x/i.jpg' } })).toBe('https://x/i.jpg');
  });

  it('returns the extracted last frame for a video node', () => {
    expect(resolveUpstreamImageUrl({ ...base, kind: 'video', output: { type: 'video', url: 'https://x/v.mp4', lastFrameUrl: 'https://x/f.jpg' } })).toBe('https://x/f.jpg');
  });

  it('returns undefined for a video node with no extracted frame yet', () => {
    expect(resolveUpstreamImageUrl({ ...base, kind: 'video', output: { type: 'video', url: 'https://x/v.mp4' } })).toBeUndefined();
  });

  it('returns undefined when there is no output', () => {
    expect(resolveUpstreamImageUrl(base)).toBeUndefined();
  });
});
```

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

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

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

```ts
import type { BoxNodeData } from '../types';

/** Resolve an upstream node's output to a single still image URL usable as a reference. */
export function resolveUpstreamImageUrl(upstream: BoxNodeData): string | undefined {
  if (!upstream.output) return undefined;
  return upstream.output.type === 'video' ? upstream.output.lastFrameUrl : upstream.output.url;
}
```

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

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

- [ ] **Step 5: Commit**

```bash
git add client/src/graph/resolveInputs.ts client/src/graph/resolveInputs.test.ts
git commit -m "feat(client): resolve upstream output to reference image url"
```

---

### Task 13: Frontend — Zustand store (TDD)

Holds nodes/edges and actions. Persists to localStorage (excluding transient run state is acceptable to keep — we persist everything; outputs are URLs).

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

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

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

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

describe('graph store', () => {
  it('adds an image node with defaults', () => {
    const id = useGraph.getState().addNode('image', { x: 0, y: 0 });
    const node = useGraph.getState().nodes.find((n) => n.id === id)!;
    expect(node.data.kind).toBe('image');
    expect(node.data.status).toBe('idle');
    expect(node.data.model).toBeTruthy();
  });

  it('connects two nodes into an edge', () => {
    const a = useGraph.getState().addNode('image', { x: 0, y: 0 });
    const b = useGraph.getState().addNode('video', { x: 200, y: 0 });
    useGraph.getState().connect(a, b);
    expect(useGraph.getState().edges).toHaveLength(1);
    expect(useGraph.getState().edges[0]).toMatchObject({ source: a, target: b });
  });

  it('updates node data and runtime', () => {
    const a = useGraph.getState().addNode('image', { x: 0, y: 0 });
    useGraph.getState().updateNodeData(a, { prompt: 'hello' });
    useGraph.getState().setRuntime(a, { status: 'done', output: { type: 'image', url: 'https://x/i.jpg' } });
    const node = useGraph.getState().nodes.find((n) => n.id === a)!;
    expect(node.data.prompt).toBe('hello');
    expect(node.data.status).toBe('done');
    expect(node.data.output?.url).toBe('https://x/i.jpg');
  });
});
```

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

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

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

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

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

interface GraphState {
  nodes: FlowNode[];
  edges: GraphEdge[];
  addNode: (kind: NodeKind, position: { x: number; y: number }) => string;
  connect: (source: string, target: string) => void;
  removeNode: (id: string) => void;
  updateNodeData: (id: string, patch: Partial<BoxNodeData>) => void;
  setRuntime: (id: string, patch: { status?: RunStatus; jobId?: string; output?: NodeOutput; error?: 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 defaultData(kind: NodeKind): BoxNodeData {
  const model = defaultModelFor(kind);
  return {
    kind,
    prompt: '',
    referenceImages: [],
    model: model.id,
    aspectRatio: model.sizes[0]?.value ?? '',
    resolution: model.resolutions[0] ?? '',
    status: 'idle',
  };
}

export const useGraph = create<GraphState>()(
  persist(
    (set) => ({
      nodes: [],
      edges: [],
      addNode: (kind, position) => {
        const id = newId();
        set((s) => ({ nodes: [...s.nodes, { id, type: 'box', position, data: defaultData(kind) }] }));
        return id;
      },
      connect: (source, target) =>
        set((s) => {
          if (source === target) return s;
          if (s.edges.some((e) => e.source === source && e.target === target)) return s;
          return { edges: [...s.edges, { id: `${source}->${target}`, source, target }] };
        }),
      removeNode: (id) =>
        set((s) => ({
          nodes: s.nodes.filter((n) => n.id !== id),
          edges: s.edges.filter((e) => e.source !== id && e.target !== id),
        })),
      updateNodeData: (id, patch) =>
        set((s) => ({ nodes: s.nodes.map((n) => (n.id === id ? { ...n, data: { ...n.data, ...patch } } : n)) })),
      setRuntime: (id, patch) =>
        set((s) => ({ nodes: s.nodes.map((n) => (n.id === id ? { ...n, data: { ...n.data, ...patch } } : n)) })),
      setNodes: (nodes) => set({ nodes }),
      setEdges: (edges) => set({ edges }),
      importGraph: (g) => set({ nodes: g.nodes, edges: g.edges }),
      reset: () => set({ nodes: [], edges: [] }),
    }),
    { name: 'higgsfield-canvas' },
  ),
);
```

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

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

- [ ] **Step 5: Commit**

```bash
git add client/src/store.ts client/src/store.test.ts
git commit -m "feat(client): zustand graph store with persistence"
```

---

### Task 14: Frontend — backend API client

**Files:**
- Create: `client/src/api.ts`

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

```ts
export interface GenerateArgs {
  modelId: string;
  prompt: string;
  aspectRatio?: string;
  resolution?: string;
  duration?: number;
  inputImageUrls?: 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**

Run: `cd client && npx tsc --noEmit`
Expected: no errors.

- [ ] **Step 3: Commit**

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

---

### Task 15: Frontend — pipeline runner (TDD)

Orchestrates a single node and the full pipeline. Pure functions take an injected `api` and state accessors so they are testable without React.

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

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

```ts
import { describe, 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: { kind: 'video', prompt: 'p', referenceImages: [], model: 'dop-turbo', aspectRatio: '16:9', resolution: '720p', status: 'idle', ...over },
  };
}

it('runNode passes the upstream video last-frame as the first input image', async () => {
  const upstream = node('a', { kind: 'video', status: 'done', output: { type: 'video', url: 'https://x/v.mp4' } });
  const target = node('b');
  const edges: GraphEdge[] = [{ id: 'a->b', source: 'a', target: 'b' }];
  const nodes = [upstream, target];

  const api = {
    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(),
  };
  const setRuntime = vi.fn();

  await runNode('b', () => ({ nodes, edges }), setRuntime, api as any, { pollMs: 0 });

  expect(api.extractFrame).toHaveBeenCalledWith('https://x/v.mp4');
  const genArgs = api.generate.mock.calls[0][0];
  expect(genArgs.inputImageUrls[0]).toBe('https://x/frame.jpg');
  expect(setRuntime).toHaveBeenCalledWith('b', expect.objectContaining({ status: 'done' }));
});
```

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

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

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

```ts
import type { FlowNode } from './store';
import type { GraphEdge, RunStatus, NodeOutput } 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`);

  // Resolve piped reference images from upstream outputs (extract video frames as needed).
  const pipedUrls: string[] = [];
  for (const edge of incomingEdges(nodeId, edges)) {
    const up = nodes.find((n) => n.id === edge.source);
    if (!up?.data.output) throw new Error(`upstream "${edge.source}" has no output yet`);
    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) pipedUrls.push(url);
  }

  const uploaded = node.data.referenceImages.filter((r) => r.source !== 'piped').map((r) => r.url);
  const inputImageUrls = [...pipedUrls, ...uploaded];

  setRuntime(nodeId, { status: 'running', error: undefined });
  const { jobId } = await api.generate({
    modelId: node.data.model,
    prompt: node.data.prompt,
    aspectRatio: node.data.aspectRatio,
    resolution: node.data.resolution,
    duration: node.data.duration,
    inputImageUrls,
  });
  setRuntime(nodeId, { jobId });

  const result = await pollUntilDone(jobId, api, pollMs);
  if (result.status === 'completed' && result.outputUrl) {
    setRuntime(nodeId, { status: 'done', output: { type: node.data.kind, 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) {
    const upstreamFailed = incomingEdges(id, edges).some((e) => failed.has(e.source));
    if (upstreamFailed) {
      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.

- [ ] **Step 5: Commit**

```bash
git add client/src/runner.ts client/src/runner.test.ts
git commit -m "feat(client): single-node and pipeline runner"
```

---

### Task 16: Frontend — BoxNode component

Custom React Flow node: prompt, reference-image strip with upload, settings, status badge, output preview, per-node Run/delete.

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

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

```tsx
import { Handle, Position, type NodeProps } from '@xyflow/react';
import type { BoxNodeData } from '../types';
import { useGraph } from '../store';
import { modelsForKind } from '../models';
import { api } from '../api';
import { runNode } from '../runner';

const STATUS_COLOR: Record<string, string> = {
  idle: '#6b7280', queued: '#a78bfa', running: '#f59e0b', done: '#10b981', failed: '#ef4444', skipped: '#6b7280',
};

export function BoxNode({ id, data }: NodeProps & { data: BoxNodeData }) {
  const updateNodeData = useGraph((s) => s.updateNodeData);
  const setRuntime = useGraph((s) => s.setRuntime);
  const removeNode = useGraph((s) => s.removeNode);
  const models = modelsForKind(data.kind);
  const model = models.find((m) => m.id === data.model) ?? models[0];

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

  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: 300 }} 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">
        <span className="font-semibold uppercase tracking-wide">{data.kind} box</span>
        <span className="flex items-center gap-2">
          <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">
        <textarea
          value={data.prompt}
          onChange={(e) => updateNodeData(id, { prompt: e.target.value })}
          placeholder="Prompt…"
          className="h-16 w-full resize-none rounded bg-zinc-800 p-2 outline-none"
        />

        <div className="flex flex-wrap gap-1">
          {data.referenceImages.map((r, i) => (
            <img key={i} src={r.url} alt="" className="h-10 w-10 rounded object-cover ring-1 ring-zinc-700" title={r.source} />
          ))}
          <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>

        <div className="grid grid-cols-2 gap-2">
          <select value={data.model} onChange={(e) => updateNodeData(id, { model: e.target.value })} className="col-span-2 rounded bg-zinc-800 p-1">
            {models.map((m) => <option key={m.id} value={m.id}>{m.label}</option>)}
          </select>
          {model.sizes.length > 0 && (
            <select value={data.aspectRatio} onChange={(e) => updateNodeData(id, { aspectRatio: e.target.value })} className="col-span-2 rounded bg-zinc-800 p-1">
              {model.sizes.map((s) => <option key={s.value} value={s.value}>{s.label}</option>)}
            </select>
          )}
          {model.resolutions.length > 0 && (
            <select value={data.resolution} onChange={(e) => updateNodeData(id, { resolution: e.target.value })} className="rounded bg-zinc-800 p-1">
              {model.resolutions.map((r) => <option key={r} value={r}>{r}</option>)}
            </select>
          )}
        </div>

        {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} className="flex-1 rounded bg-indigo-600 py-1 font-semibold hover:bg-indigo-500">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**

Run: `cd client && npx tsc --noEmit`
Expected: no errors.

- [ ] **Step 3: Commit**

```bash
git add client/src/components/BoxNode.tsx
git commit -m "feat(client): BoxNode component"
```

---

### Task 17: Frontend — Canvas (React Flow wiring)

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

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

```tsx
import { useCallback, useMemo } from 'react';
import {
  ReactFlow, Background, Controls, MiniMap,
  applyNodeChanges, applyEdgeChanges, type Connection, type NodeChange, type EdgeChange,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { useGraph, type FlowNode } from '../store';
import { BoxNode } from './BoxNode';

export function Canvas() {
  const nodes = useGraph((s) => s.nodes);
  const edges = useGraph((s) => s.edges);
  const setNodes = useGraph((s) => s.setNodes);
  const setEdges = useGraph((s) => s.setEdges);
  const connect = useGraph((s) => s.connect);

  const nodeTypes = useMemo(() => ({ box: BoxNode }), []);

  const onNodesChange = useCallback(
    (changes: NodeChange[]) => setNodes(applyNodeChanges(changes, nodes as any) as FlowNode[]),
    [nodes, setNodes],
  );
  const onEdgesChange = useCallback(
    (changes: EdgeChange[]) => setEdges(applyEdgeChanges(changes, edges as any) as any),
    [edges, setEdges],
  );
  const onConnect = useCallback(
    (c: Connection) => { if (c.source && c.target) connect(c.source, c.target); },
    [connect],
  );

  return (
    <div className="h-full w-full">
      <ReactFlow
        nodes={nodes as any}
        edges={edges as any}
        nodeTypes={nodeTypes}
        onNodesChange={onNodesChange}
        onEdgesChange={onEdgesChange}
        onConnect={onConnect}
        fitView
        proOptions={{ hideAttribution: true }}
      >
        <Background color="#27272a" gap={20} />
        <MiniMap pannable zoomable />
        <Controls />
      </ReactFlow>
    </div>
  );
}
```

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

Run: `cd client && npx tsc --noEmit`
Expected: no errors.

- [ ] **Step 3: Commit**

```bash
git add client/src/components/Canvas.tsx
git commit -m "feat(client): react flow canvas"
```

---

### Task 18: Frontend — Toolbar (add boxes, run pipeline, save/export/import)

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

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

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

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 [running, setRunning] = useState(false);

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

  const onRunAll = async () => {
    setRunning(true);
    try {
      await runPipeline(() => useGraph.getState(), setRuntime, api);
    } finally {
      setRunning(false);
    }
  };

  const onExport = () => {
    const { nodes, edges } = useGraph.getState();
    const blob = new Blob([JSON.stringify({ nodes, 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) return;
    const g = JSON.parse(await file.text());
    importGraph(g);
  };

  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('image', place())} className="rounded bg-zinc-700 px-3 py-1 hover:bg-zinc-600">+ Image box</button>
      <button onClick={() => addNode('video', place())} className="rounded bg-zinc-700 px-3 py-1 hover:bg-zinc-600">+ Video 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'}
      </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: Type-check**

Run: `cd client && npx tsc --noEmit`
Expected: no errors.

- [ ] **Step 3: Commit**

```bash
git add client/src/components/Toolbar.tsx
git commit -m "feat(client): toolbar with add/run/export/import"
```

---

### Task 19: Frontend — App assembly

**Files:**
- Modify: `client/src/App.tsx`
- Modify: `client/src/main.tsx` (ensure `index.css` imported)

- [ ] **Step 1: Replace `client/src/App.tsx`**

```tsx
import { Canvas } from './components/Canvas';
import { Toolbar } from './components/Toolbar';

export default function App() {
  return (
    <div className="relative h-screen w-screen">
      <Toolbar />
      <Canvas />
    </div>
  );
}
```

- [ ] **Step 2: Ensure `client/src/main.tsx` imports `./index.css`**

```tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.tsx';
import './index.css';

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
);
```

- [ ] **Step 3: Build + full client test run**

Run: `cd client && npm run build && npm test`
Expected: build succeeds; all unit tests PASS.

- [ ] **Step 4: Commit**

```bash
git add client/src/App.tsx client/src/main.tsx
git commit -m "feat(client): assemble canvas app"
```

---

### Task 20: End-to-end manual verification

This task uses real Higgsfield credits — keep prompts short and durations minimal.

- [ ] **Step 1: Configure credentials**

Ensure `server/.env` has a valid `HF_CREDENTIALS=KEY_ID:KEY_SECRET` from cloud.higgsfield.ai.

- [ ] **Step 2: Start both processes**

Terminal 1: `cd server && npm run dev`
Terminal 2: `cd client && npm run dev`
Open the Vite URL.

- [ ] **Step 3: Single image box**

Add an Image box, type a prompt (e.g., "a quiet airport gate at golden hour"), click **Run this box**.
Expected: status goes running → done; the generated image renders in the box.

> **If generation fails:** check the server terminal. The most likely fix is in `server/src/models.ts` (`buildInput`) or `server/src/higgsfield.ts` — adjust the `input` field names to match what the live endpoint expects, then re-run. These are the two isolated places that touch the Higgsfield request shape.

- [ ] **Step 4: Chain image → video with last-frame passing**

Add a Video box. Drag from the image box's right handle to the video box's left handle. Put a motion prompt in the video box. Click **Run pipeline**.
Expected: image box runs first; its image becomes the video box's first reference; video box runs and produces a clip continuous with the image.

- [ ] **Step 5: Chain video → video (frame extraction)**

Add a second Video box connected after the first. Run pipeline.
Expected: `/api/extract-frame` is hit (server log), the first video's last frame is uploaded and used as the second video box's reference.

- [ ] **Step 6: Persistence + export/import**

Reload the page → the graph is restored from localStorage. Click **Export** → a `workflow.json` downloads. Clear, then Import that file → the graph returns.

- [ ] **Step 7: Final commit**

```bash
git add -A
git commit -m "docs: mark e2e verification complete"
```

---

## Self-Review (completed during planning)

- **Spec coverage:** node model (T10/T16), reference upload (T16 + `/api/upload` T7), settings model/ratio/resolution/duration (T10/T16/T4), connect boxes (T13/T17), last-frame → first reference (T6/T12/T15), run single (T15/T16) + run pipeline (T15/T18), previews (T16), save/load + export/import (T13/T18), API key never in browser (T5/T8 backend-only). All MVP requirements mapped.
- **Type consistency:** `BoxNodeData`, `GraphEdge`, `FlowNode`, `MediaRef`, `NodeOutput` are defined once (T10/T13) and reused. Server `JobRecord.status` ('completed') is mapped to client `RunStatus` ('done') inside `runNode` (T15). Model ids (`soul`, `dop-turbo`/`dop-standard`/`dop-lite`) match between server (T4) and client (T10).
- **Placeholders:** none. SDK API verified against the installed `@higgsfield/client@0.2.1`: generation via V2 `subscribe()` (returns a JobSet-shaped object with `isCompleted` + `jobs[0].results.raw.url`), upload via V1 `uploadImage(buffer, format)`. DoP image→video takes no duration/aspect/resolution (so those are image-only controls); reflected in Tasks 4/10/13/16.
- **Known risk to watch:** exact Higgsfield request/response field names. Contained to `server/src/models.ts` and `server/src/higgsfield.ts`; T20 explains how to adjust.
