Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changelog/NEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Unreleased Changes

## Media

- **[issue-2036] Sketch & Annotation Canvas (phase 2): re-render an image guided by your annotations.** The annotate page (`/media/annotate/:mediaKey`) gained a "Re-render" action: draw over a generated image, then feed your markup back through local img2img so the marks reshape the render. A confirmation dialog names the exact local model it will run (no surprise AI calls) and lets you add an optional prompt and tune how much to change before it starts; the new render is queued and appears in Media History. Requires a local FLUX img2img runner — the action explains when one isn't available. Phase 3 (blank-canvas storyboard) remains open on #2036.
165 changes: 161 additions & 4 deletions client/src/pages/MediaAnnotate.jsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { Pencil, Eraser, Undo2, Trash2, Save, Download, ArrowLeft, ImageOff } from 'lucide-react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import { Pencil, Eraser, Undo2, Trash2, Save, Download, ArrowLeft, ImageOff, Wand2, Loader2 } from 'lucide-react';
import toast from '../components/ui/Toast';
import Modal from '../components/ui/Modal';
import AnnotationCanvas from '../components/media/AnnotationCanvas';
import { useAsyncAction } from '../hooks/useAsyncAction';
import { undoStrokes, clampSize, DEFAULT_COLOR, DEFAULT_SIZE, MIN_SIZE, MAX_SIZE } from '../lib/sketchCanvas';
import { getMediaSketch, saveMediaSketch } from '../services/api';
import { getMediaSketch, saveMediaSketch, getRegenAvailability, rerenderWithAnnotations } from '../services/api';

// Default denoise for an annotation re-render — high enough that the drawn marks
// actually reshape the image (mirrors the server's REGEN_ANNOTATED_STRENGTH_DEFAULT).
// Clamped into the backend's advertised [min, max] bounds at render time.
const DEFAULT_ANNOTATED_STRENGTH = 0.5;
const clamp = (n, lo, hi) => Math.min(hi, Math.max(lo, n));

// Parse a media key `<kind>:<ref>` on the client (mirrors server/lib/mediaItemKey.js
// rules loosely — the server re-validates authoritatively). Phase 1 only supports
Expand All @@ -24,6 +31,7 @@ const COLOR_SWATCHES = ['#ef4444', '#f59e0b', '#22c55e', '#3b82f6', '#a855f7', '

export default function MediaAnnotate() {
const { mediaKey } = useParams();
const navigate = useNavigate();
const parsed = useMemo(() => parseMediaKey(mediaKey), [mediaKey]);
const isImage = parsed?.kind === 'image';
// Images are served from /data/images/<filename>; the ref IS the filename.
Expand All @@ -38,6 +46,31 @@ export default function MediaAnnotate() {
const [loading, setLoading] = useState(true);
const [imageError, setImageError] = useState(false);

// Re-render (issue #2036 phase 2). `regenInfo` is the same local-FLUX
// img2img availability the lightbox uses — it names the exact model a
// re-render would run, so the user sees the provider/model before any AI
// call (no cold-bootstrap). null until fetched; `available:false` gates it.
const [regenInfo, setRegenInfo] = useState(null);
const [rerenderOpen, setRerenderOpen] = useState(false);
const [rerenderPrompt, setRerenderPrompt] = useState('');
const [rerenderStrength, setRerenderStrength] = useState(DEFAULT_ANNOTATED_STRENGTH);

useEffect(() => {
if (!isImage) return;
let active = true;
// Clear synchronously on every image change: React Router reuses this page
// across :mediaKey changes, so a leftover regenInfo would otherwise disclose
// the PREVIOUS image's model/bounds until the new (possibly slow) probe
// resolves — letting the dialog open on stale availability.
setRegenInfo(null);
// Pass the source filename so the reported model is the exact one a regen of
// THIS image would run (multi-model installs), keeping the dialog honest.
getRegenAvailability(parsed?.ref)
.then((r) => { if (active) setRegenInfo(r || null); })
.catch(() => { if (active) setRegenInfo(null); });
return () => { active = false; };
}, [isImage, parsed?.ref]);

// Load any previously-saved strokes for this media key. React Router reuses
// this component instance across :mediaKey changes (no remount), so reset all
// transient view state synchronously before fetching — otherwise the previous
Expand Down Expand Up @@ -94,6 +127,39 @@ export default function MediaAnnotate() {
document.body.removeChild(a);
}, [parsed]);

const strengthMin = regenInfo?.strengthMin ?? 0.02;
const strengthMax = regenInfo?.strengthMax ?? 0.6;
const regenAvailable = !!regenInfo?.available;
const regenModelLabel = regenInfo?.modelId || 'local FLUX img2img';

const openRerender = useCallback(() => {
setRerenderStrength(clamp(DEFAULT_ANNOTATED_STRENGTH, strengthMin, strengthMax));
setRerenderPrompt('');
setRerenderOpen(true);
}, [strengthMin, strengthMax]);

// Persist the annotation (writes the flattened PNG sidecar the server uses as
// the img2img init image), then enqueue the re-render. Both API calls are
// silent so useAsyncAction owns the single error toast.
const [rerender, rerendering] = useAsyncAction(async () => {
if (!dims) throw new Error('Canvas not ready');
if (strokes.length === 0) throw new Error('Draw over the image before re-rendering');
const png = canvasApiRef.current?.exportPng?.() || undefined;
await saveMediaSketch(mediaKey, { width: dims.w, height: dims.h, strokes, png }, { silent: true });
return rerenderWithAnnotations(parsed.ref, {
strength: rerenderStrength,
prompt: rerenderPrompt.trim() || undefined,
});
}, { errorMessage: 'Failed to start re-render' });

const handleRerender = useCallback(async () => {
const res = await rerender();
if (!res) return;
setRerenderOpen(false);
toast.success('Re-rendering with your annotations — it’ll appear in Media History');
navigate('/media/history');
}, [rerender, navigate]);

if (!isImage) {
return (
<div className="p-4 md:p-6 max-w-3xl mx-auto text-sm text-gray-400">
Expand Down Expand Up @@ -208,11 +274,20 @@ export default function MediaAnnotate() {
type="button"
onClick={handleSave}
disabled={saving || !dims}
className="px-3 py-1.5 text-xs bg-port-accent text-white rounded hover:bg-port-accent/80 disabled:opacity-40 inline-flex items-center gap-1"
className="px-3 py-1.5 text-xs bg-port-card border border-port-border text-gray-200 rounded hover:bg-port-border disabled:opacity-40 inline-flex items-center gap-1"
title="Save annotation"
>
<Save className="w-3.5 h-3.5" /> {saving ? 'Saving…' : 'Save'}
</button>
<button
type="button"
onClick={openRerender}
disabled={!dims || strokes.length === 0}
className="px-3 py-1.5 text-xs bg-port-accent text-white rounded hover:bg-port-accent/80 disabled:opacity-40 inline-flex items-center gap-1"
title={strokes.length === 0 ? 'Draw over the image first' : 'Re-render this image guided by your annotations'}
>
<Wand2 className="w-3.5 h-3.5" /> <span className="hidden sm:inline">Re-render</span>
</button>
</div>
</div>

Expand Down Expand Up @@ -240,6 +315,88 @@ export default function MediaAnnotate() {
</div>
)}
</div>

<Modal
open={rerenderOpen}
onClose={() => !rerendering && setRerenderOpen(false)}
size="sm"
closeOnBackdrop={!rerendering}
closeOnEsc={!rerendering}
ariaLabel="Re-render with annotations"
panelClassName="bg-port-card border border-port-border rounded-xl p-5 text-sm"
>
<h2 className="text-base font-semibold text-white flex items-center gap-2">
<Wand2 className="w-4 h-4 text-port-accent" /> Re-render with annotations
</h2>
<p className="text-gray-400 mt-1 text-xs">
Your drawing is flattened onto the image and fed back through img2img so the marks reshape the render.
</p>

{/* Provider/model visible before any AI call runs (no cold-bootstrap). */}
<div className="mt-3 rounded-lg bg-port-bg border border-port-border px-3 py-2 text-xs">
<span className="text-gray-500">Renders locally via </span>
<span className="text-gray-200 font-mono">{regenModelLabel}</span>
</div>

{!regenAvailable ? (
<div className="mt-3 text-xs text-port-warning">
{regenInfo?.reason || 'Local img2img isn’t available on this install. A local FLUX runner is required to re-render.'}
</div>
) : (
<>
<div className="mt-4">
<label htmlFor="rerender-prompt" className="block text-xs text-gray-400 mb-1">
Prompt <span className="text-gray-600">(optional — steer the redraw)</span>
</label>
<textarea
id="rerender-prompt"
value={rerenderPrompt}
onChange={(e) => setRerenderPrompt(e.target.value)}
rows={2}
placeholder="Leave blank to let the annotations alone guide the redraw"
className="w-full rounded-lg bg-port-bg border border-port-border px-3 py-2 text-sm text-gray-200 resize-none focus:outline-none focus:border-port-accent"
/>
</div>

<div className="mt-3">
<label htmlFor="rerender-strength" className="flex items-center justify-between text-xs text-gray-400 mb-1">
<span>Strength <span className="text-gray-600">(how much to change)</span></span>
<span className="text-gray-300 tabular-nums">{rerenderStrength.toFixed(2)}</span>
</label>
<input
id="rerender-strength"
type="range"
min={strengthMin}
max={strengthMax}
step={0.01}
value={rerenderStrength}
onChange={(e) => setRerenderStrength(clamp(parseFloat(e.target.value), strengthMin, strengthMax))}
className="w-full accent-port-accent"
/>
</div>
</>
)}

<div className="mt-5 flex items-center justify-end gap-2">
<button
type="button"
onClick={() => setRerenderOpen(false)}
disabled={rerendering}
className="px-3 py-1.5 text-xs bg-port-card border border-port-border rounded hover:bg-port-border disabled:opacity-40"
>
Cancel
</button>
<button
type="button"
onClick={handleRerender}
disabled={rerendering || !regenAvailable}
className="px-3 py-1.5 text-xs bg-port-accent text-white rounded hover:bg-port-accent/80 disabled:opacity-40 inline-flex items-center gap-1"
>
{rerendering ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Wand2 className="w-3.5 h-3.5" />}
{rerendering ? 'Starting…' : 'Re-render'}
</button>
</div>
</Modal>
</div>
);
}
108 changes: 108 additions & 0 deletions client/src/pages/MediaAnnotate.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor, within } from '@testing-library/react';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
import MediaAnnotate from './MediaAnnotate';

// Re-render (issue #2036 phase 2) is the focus: annotate an image, then feed the
// flattened markup back through img2img. The canvas itself is exercised by
// sketchCanvas.test.js — here it's stubbed to synchronously report dimensions
// and expose a flattened-PNG export so the page's re-render wiring can run.
vi.mock('../components/media/AnnotationCanvas', async () => {
const React = await import('react');
return {
default: React.forwardRef(function StubCanvas({ onImageLoad }, ref) {
React.useImperativeHandle(ref, () => ({ exportPng: () => 'data:image/png;base64,QQ==' }), []);
// Defer to a macrotask: the real <img> onLoad fires asynchronously, AFTER
// the page's own mount effect resets dims to null. Reporting synchronously
// here would be clobbered by that reset (child effects run before parent).
React.useEffect(() => {
const t = setTimeout(() => onImageLoad?.({ w: 100, h: 80 }), 0);
return () => clearTimeout(t);
}, [onImageLoad]);
return <div data-testid="stub-canvas" />;
}),
};
});

const getMediaSketch = vi.fn();
const saveMediaSketch = vi.fn();
const getRegenAvailability = vi.fn();
const rerenderWithAnnotations = vi.fn();

vi.mock('../services/api', () => ({
getMediaSketch: (...a) => getMediaSketch(...a),
saveMediaSketch: (...a) => saveMediaSketch(...a),
getRegenAvailability: (...a) => getRegenAvailability(...a),
rerenderWithAnnotations: (...a) => rerenderWithAnnotations(...a),
}));

const toastSuccess = vi.fn();
const toastError = vi.fn();
vi.mock('../components/ui/Toast', () => ({
default: { success: (...a) => toastSuccess(...a), error: (...a) => toastError(...a) },
}));

const navigate = vi.fn();
vi.mock('react-router-dom', async (importOriginal) => {
const actual = await importOriginal();
return { ...actual, useNavigate: () => navigate };
});

const renderPage = () => render(
<MemoryRouter initialEntries={['/media/annotate/image:foo.png']}>
<Routes>
<Route path="/media/annotate/:mediaKey" element={<MediaAnnotate />} />
</Routes>
</MemoryRouter>,
);

describe('MediaAnnotate re-render with annotations', () => {
beforeEach(() => {
vi.clearAllMocks();
getMediaSketch.mockResolvedValue({ sketch: { strokes: [{ mode: 'draw', color: '#ef4444', size: 4, points: [{ x: 1, y: 2 }] }] } });
getRegenAvailability.mockResolvedValue({ available: true, modelId: 'flux-dev', strengthMin: 0.02, strengthMax: 0.6, strengthDefault: 0.25 });
saveMediaSketch.mockResolvedValue({ sketch: {} });
rerenderWithAnnotations.mockResolvedValue({ jobId: 'job-1', position: 1, status: 'queued' });
});

it('saves the annotation and enqueues an img2img re-render, naming the local model', async () => {
renderPage();

// The button enables once the canvas reports dims AND saved strokes load.
const btn = await screen.findByTitle('Re-render this image guided by your annotations');
await waitFor(() => expect(btn).not.toBeDisabled());
fireEvent.click(btn);

// Provider/model is visible before any AI call (no cold-bootstrap).
expect(await screen.findByText('flux-dev')).toBeInTheDocument();

// Confirm → persist annotation (flattened PNG) then enqueue the re-render.
const dialog = screen.getByRole('dialog');
fireEvent.click(within(dialog).getByRole('button', { name: 'Re-render' }));

await waitFor(() => expect(rerenderWithAnnotations).toHaveBeenCalledTimes(1));
expect(saveMediaSketch).toHaveBeenCalledWith(
'image:foo.png',
expect.objectContaining({ width: 100, height: 80, png: 'data:image/png;base64,QQ==' }),
{ silent: true },
);
expect(rerenderWithAnnotations).toHaveBeenCalledWith('foo.png', { strength: 0.5, prompt: undefined });
expect(toastSuccess).toHaveBeenCalled();
expect(navigate).toHaveBeenCalledWith('/media/history');
});

it('disables re-render and surfaces the reason when local img2img is unavailable', async () => {
getRegenAvailability.mockResolvedValue({ available: false, reason: 'No local FLUX runner installed.' });
renderPage();

const btn = await screen.findByTitle('Re-render this image guided by your annotations');
await waitFor(() => expect(btn).not.toBeDisabled());
fireEvent.click(btn);

expect(await screen.findByText('No local FLUX runner installed.')).toBeInTheDocument();
// The confirm button in the modal is disabled when unavailable.
const confirm = within(screen.getByRole('dialog')).getByRole('button', { name: 'Re-render' });
expect(confirm).toBeDisabled();
expect(rerenderWithAnnotations).not.toHaveBeenCalled();
});
});
22 changes: 21 additions & 1 deletion client/src/services/apiImageVideo.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,27 @@ export const regenerateGalleryImage = (filename, { strength, steps, prompt, meth
});
// Whether the local FLUX regen backend is installed (hardware gate). Also carries
// the strength slider bounds: `{ available, modelId, reason, strengthMin, strengthMax, strengthDefault }`.
export const getRegenAvailability = () => request('/image-gen/regen/availability', { silent: true });
// Pass a source `filename` (issue #2036) to get the EXACT model a regen of that
// image would run — the backend picks by the source's own model on multi-model
// installs, so the annotate dialog can disclose the real model before rendering.
export const getRegenAvailability = (filename) =>
request(`/image-gen/regen/availability${filename ? `?filename=${encodeURIComponent(filename)}` : ''}`, { silent: true });
// Annotation re-render (issue #2036 phase 2). Feeds the saved flattened sketch
// (source image + drawn strokes) back through the local-FLUX img2img regen as the
// init image; returns the queue ack ({ jobId, position, ... }). The annotation
// must already be saved (the flattened PNG sidecar is the init image). `silent`
// so the annotate page owns its own error toast (single-layer rule).
export const rerenderWithAnnotations = (filename, { strength, steps, prompt } = {}) =>
request(`/image-gen/${encodeURIComponent(filename)}/regenerate`, {
method: 'POST',
body: JSON.stringify({
annotated: true,
...(strength != null ? { strength } : {}),
...(steps != null ? { steps } : {}),
...(prompt != null ? { prompt } : {}),
}),
silent: true,
});

// HuggingFace token (gated local Flux models). Stored in settings.imageGen.hfToken;
// reads fall back to HF_TOKEN env var and then ~/.cache/huggingface/token.
Expand Down
Loading