From 38f6721db09cbeae050b64db27da5c1c8c3ffecc Mon Sep 17 00:00:00 2001 From: Project7 Date: Wed, 27 May 2026 07:09:28 +0000 Subject: [PATCH 1/3] [#204] Build SVG/HTML lettering editor foundation Add overlay data model (Overlay type, coordinate normalization utils) and LetteringEditor component. Editor renders clean image as background, positions overlay elements using normalized coordinates that scale with container size, supports click-to-select with inspector panel showing type/position/text, and click-to-deselect. Extend Cut interface with overlays field (defaults to []). Integrate editor into CutListPanel via "Open editor" button for cuts with clean images. 17 new tests: coordinate normalization (10), editor rendering/selection (6), CutListPanel editor button (1). Co-Authored-By: Claude Opus 4.7 (1M context) --- app/lib/cuts.ts | 6 + app/lib/overlays.test.ts | 68 +++++++ app/lib/overlays.ts | 38 ++++ app/web/components/CutListPanel.tsx | 49 +++++ app/web/components/LetteringEditor.test.tsx | 162 +++++++++++++++++ app/web/components/LetteringEditor.tsx | 189 ++++++++++++++++++++ 6 files changed, 512 insertions(+) create mode 100644 app/lib/overlays.test.ts create mode 100644 app/lib/overlays.ts create mode 100644 app/web/components/LetteringEditor.test.tsx create mode 100644 app/web/components/LetteringEditor.tsx diff --git a/app/lib/cuts.ts b/app/lib/cuts.ts index 334db27..1e211c2 100644 --- a/app/lib/cuts.ts +++ b/app/lib/cuts.ts @@ -1,5 +1,6 @@ import fs from "fs"; import path from "path"; +import type { Overlay } from "./overlays"; export const SHOT_TYPES = ["wide", "medium", "close-up", "extreme-close-up"] as const; export type ShotType = (typeof SHOT_TYPES)[number]; @@ -22,6 +23,7 @@ export interface Cut { exportedAt: string | null; uploadedCid: string | null; uploadedUrl: string | null; + overlays: Overlay[]; } export interface CutsFile { @@ -44,6 +46,7 @@ export function createDefaultCut(id: number, _plotFile: string): Cut { exportedAt: null, uploadedCid: null, uploadedUrl: null, + overlays: [], }; } @@ -151,6 +154,9 @@ export function validateCutsFile(data: unknown): { valid: boolean; error?: strin return { valid: false, error: `Cut ${i} ${field} must be a string or null` }; } } + if (cut.overlays !== undefined && !Array.isArray(cut.overlays)) { + return { valid: false, error: `Cut ${i} overlays must be an array` }; + } } return { valid: true }; diff --git a/app/lib/overlays.test.ts b/app/lib/overlays.test.ts new file mode 100644 index 0000000..14c7a58 --- /dev/null +++ b/app/lib/overlays.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from "vitest"; +import { toPixel, toNorm, createOverlay } from "./overlays"; + +describe("toPixel", () => { + it("converts 0.5 to half of container", () => { + expect(toPixel(0.5, 800)).toBe(400); + }); + + it("converts 0 to 0", () => { + expect(toPixel(0, 600)).toBe(0); + }); + + it("converts 1 to full container", () => { + expect(toPixel(1, 600)).toBe(600); + }); + + it("handles fractional values", () => { + expect(toPixel(0.25, 1000)).toBe(250); + }); +}); + +describe("toNorm", () => { + it("converts half to 0.5", () => { + expect(toNorm(400, 800)).toBe(0.5); + }); + + it("converts 0 to 0", () => { + expect(toNorm(0, 800)).toBe(0); + }); + + it("returns 0 for zero container size", () => { + expect(toNorm(100, 0)).toBe(0); + }); +}); + +describe("createOverlay", () => { + it("creates speech overlay with defaults", () => { + const o = createOverlay("speech", 0.2, 0.3); + expect(o.type).toBe("speech"); + expect(o.x).toBe(0.2); + expect(o.y).toBe(0.3); + expect(o.width).toBe(0.25); + expect(o.height).toBe(0.12); + expect(o.text).toBe(""); + expect(o.speaker).toBe(""); + expect(o.id).toMatch(/^overlay-/); + }); + + it("creates sfx overlay with smaller dimensions", () => { + const o = createOverlay("sfx"); + expect(o.type).toBe("sfx"); + expect(o.width).toBe(0.15); + expect(o.height).toBe(0.08); + expect(o.speaker).toBeUndefined(); + }); + + it("creates narration overlay without speaker", () => { + const o = createOverlay("narration"); + expect(o.type).toBe("narration"); + expect(o.speaker).toBeUndefined(); + }); + + it("generates unique IDs", () => { + const a = createOverlay("speech"); + const b = createOverlay("speech"); + expect(a.id).not.toBe(b.id); + }); +}); diff --git a/app/lib/overlays.ts b/app/lib/overlays.ts new file mode 100644 index 0000000..f9123e7 --- /dev/null +++ b/app/lib/overlays.ts @@ -0,0 +1,38 @@ +export const OVERLAY_TYPES = ["speech", "narration", "sfx"] as const; +export type OverlayType = (typeof OVERLAY_TYPES)[number]; + +export interface Overlay { + id: string; + type: OverlayType; + x: number; + y: number; + width: number; + height: number; + text: string; + speaker?: string; +} + +export function toPixel(norm: number, containerSize: number): number { + return norm * containerSize; +} + +export function toNorm(pixel: number, containerSize: number): number { + if (containerSize === 0) return 0; + return pixel / containerSize; +} + +let counter = 0; + +export function createOverlay(type: OverlayType, x = 0.1, y = 0.1): Overlay { + counter++; + return { + id: `overlay-${Date.now()}-${counter}`, + type, + x, + y, + width: type === "sfx" ? 0.15 : 0.25, + height: type === "sfx" ? 0.08 : 0.12, + text: "", + ...(type === "speech" ? { speaker: "" } : {}), + }; +} diff --git a/app/web/components/CutListPanel.tsx b/app/web/components/CutListPanel.tsx index 7284e34..f85122a 100644 --- a/app/web/components/CutListPanel.tsx +++ b/app/web/components/CutListPanel.tsx @@ -1,4 +1,16 @@ import { useState, useEffect, useCallback, useRef } from "react"; +import { LetteringEditor } from "./LetteringEditor"; + +interface Overlay { + id: string; + type: "speech" | "narration" | "sfx"; + x: number; + y: number; + width: number; + height: number; + text: string; + speaker?: string; +} interface CutDialogue { speaker: string; @@ -18,6 +30,7 @@ interface Cut { exportedAt: string | null; uploadedCid: string | null; uploadedUrl: string | null; + overlays: Overlay[]; } interface CutsFile { @@ -75,6 +88,7 @@ function CutRow({ onToggle, authFetch, onUpdated, + onOpenEditor, }: { cut: Cut; storyName: string; @@ -83,6 +97,7 @@ function CutRow({ onToggle: () => void; authFetch: (url: string, opts?: RequestInit) => Promise; onUpdated: () => void; + onOpenEditor: () => void; }) { const fileInputRef = useRef(null); const [uploading, setUploading] = useState(false); @@ -177,6 +192,16 @@ function CutRow({ )} + {/* Open editor button */} + {cut.cleanImagePath && ( + + )} + {/* Cut metadata */} {cut.characters.length > 0 && (

Characters: {cut.characters.join(", ")}

@@ -202,6 +227,7 @@ export function CutListPanel({ storyName, fileName, authFetch }: CutListPanelPro const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [expandedCut, setExpandedCut] = useState(null); + const [editingCutId, setEditingCutId] = useState(null); const plotFile = fileName.replace(/\.md$/, ""); @@ -252,6 +278,28 @@ export function CutListPanel({ storyName, fileName, authFetch }: CutListPanelPro ); } + const editingCut = editingCutId !== null ? cutsFile.cuts.find((c) => c.id === editingCutId) : null; + + if (editingCut) { + return ( + { + const updated = { ...cutsFile, cuts: cutsFile.cuts.map((c) => c.id === editingCutId ? { ...c, overlays } : c) }; + await authFetch(`/api/stories/${storyName}/cuts/${plotFile}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(updated), + }); + setEditingCutId(null); + loadCuts(); + }} + onClose={() => setEditingCutId(null)} + /> + ); + } + const stats = cutsFile.cuts.reduce( (acc, cut) => { const s = getCutStatus(cut); @@ -284,6 +332,7 @@ export function CutListPanel({ storyName, fileName, authFetch }: CutListPanelPro onToggle={() => setExpandedCut(expandedCut === cut.id ? null : cut.id)} authFetch={authFetch} onUpdated={loadCuts} + onOpenEditor={() => setEditingCutId(cut.id)} /> ))} diff --git a/app/web/components/LetteringEditor.test.tsx b/app/web/components/LetteringEditor.test.tsx new file mode 100644 index 0000000..c54846d --- /dev/null +++ b/app/web/components/LetteringEditor.test.tsx @@ -0,0 +1,162 @@ +import { describe, it, expect, vi, afterEach, beforeAll } from "vitest"; +import { render, screen, cleanup, fireEvent } from "@testing-library/react"; +import { LetteringEditor } from "./LetteringEditor"; + +beforeAll(() => { + global.ResizeObserver = class { + callback: ResizeObserverCallback; + constructor(callback: ResizeObserverCallback) { this.callback = callback; } + observe(target: Element) { + this.callback([{ contentRect: { width: 400, height: 300 }, target } as unknown as ResizeObserverEntry], this); + } + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver; +}); + +interface Overlay { + id: string; + type: "speech" | "narration" | "sfx"; + x: number; + y: number; + width: number; + height: number; + text: string; + speaker?: string; +} + +afterEach(cleanup); + +function makeCut(overrides: Record = {}) { + return { + id: 1, + cleanImagePath: "assets/plot-01/cut-01-clean.webp", + overlays: [] as Overlay[], + ...overrides, + }; +} + +describe("LetteringEditor", () => { + it("renders clean image as background", () => { + render( + , + ); + const img = screen.getByAltText("Cut 1 clean"); + expect(img).toBeInTheDocument(); + expect(img).toHaveAttribute("src", "/api/stories/story/asset/plot-01/cut-01-clean.webp"); + }); + + it("shows message when no clean image", () => { + render( + , + ); + expect(screen.getByText("No clean image — upload one first.")).toBeInTheDocument(); + }); + + it("renders overlay elements", () => { + const overlay: Overlay = { + id: "test-overlay-1", + type: "speech", + x: 0.1, + y: 0.2, + width: 0.25, + height: 0.12, + text: "Hello!", + speaker: "Mira", + }; + + render( + , + ); + + const el = screen.getByTestId("overlay-test-overlay-1"); + expect(el).toBeInTheDocument(); + expect(screen.getByText("Hello!")).toBeInTheDocument(); + }); + + it("shows inspector when overlay is clicked", () => { + const overlay: Overlay = { + id: "test-overlay-2", + type: "narration", + x: 0.3, + y: 0.4, + width: 0.25, + height: 0.12, + text: "The sun set.", + }; + + render( + , + ); + + expect(screen.getByTestId("inspector-empty")).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId("overlay-test-overlay-2")); + + expect(screen.getByText("Narration")).toBeInTheDocument(); + expect(screen.queryByTestId("inspector-empty")).not.toBeInTheDocument(); + }); + + it("deselects overlay when clicking background", () => { + const overlay: Overlay = { + id: "test-overlay-3", + type: "speech", + x: 0.1, + y: 0.1, + width: 0.2, + height: 0.1, + text: "Test", + speaker: "Jin", + }; + + render( + , + ); + + fireEvent.click(screen.getByTestId("overlay-test-overlay-3")); + expect(screen.getByText("Speech")).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId("editor-surface")); + expect(screen.getByTestId("inspector-empty")).toBeInTheDocument(); + }); + + it("calls onClose when Close button is clicked", () => { + const onClose = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByText("Close")); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/app/web/components/LetteringEditor.tsx b/app/web/components/LetteringEditor.tsx new file mode 100644 index 0000000..be1d406 --- /dev/null +++ b/app/web/components/LetteringEditor.tsx @@ -0,0 +1,189 @@ +import { useState, useRef, useEffect, useCallback } from "react"; + +type OverlayType = "speech" | "narration" | "sfx"; + +interface Overlay { + id: string; + type: OverlayType; + x: number; + y: number; + width: number; + height: number; + text: string; + speaker?: string; +} + +function toPixel(norm: number, containerSize: number): number { + return norm * containerSize; +} + +interface Cut { + id: number; + cleanImagePath: string | null; + overlays: Overlay[]; +} + +interface LetteringEditorProps { + storyName: string; + cut: Cut; + onSave: (overlays: Overlay[]) => void; + onClose: () => void; +} + +function assetUrl(storyName: string, assetPath: string): string { + const relative = assetPath.startsWith("assets/") ? assetPath.slice(7) : assetPath; + return `/api/stories/${storyName}/asset/${relative}`; +} + +const TYPE_LABEL: Record = { + speech: "Speech", + narration: "Narration", + sfx: "SFX", +}; + +const TYPE_BORDER: Record = { + speech: "border-foreground/40", + narration: "border-muted/40", + sfx: "border-accent/40", +}; + +export function LetteringEditor({ storyName, cut, onSave, onClose }: LetteringEditorProps) { + const [overlays] = useState(cut.overlays || []); + const [selectedId, setSelectedId] = useState(null); + const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }); + const containerRef = useRef(null); + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (entry) { + setContainerSize({ + width: entry.contentRect.width, + height: entry.contentRect.height, + }); + } + }); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + const handleBackgroundClick = useCallback(() => { + setSelectedId(null); + }, []); + + const handleOverlayClick = useCallback((e: React.MouseEvent, id: string) => { + e.stopPropagation(); + setSelectedId(id); + }, []); + + const handleSave = useCallback(() => { + onSave(overlays); + }, [overlays, onSave]); + + const selectedOverlay = overlays.find((o) => o.id === selectedId); + + if (!cut.cleanImagePath) { + return ( +
+ No clean image — upload one first. +
+ ); + } + + return ( +
+ {/* Toolbar */} +
+
+ Cut #{cut.id} — Editor + {overlays.length} overlays +
+
+ + +
+
+ + {/* Editor surface */} +
+
+ {`Cut + + {/* Overlay elements */} + {containerSize.width > 0 && overlays.map((overlay) => { + const left = toPixel(overlay.x, containerSize.width); + const top = toPixel(overlay.y, containerSize.height); + const width = toPixel(overlay.width, containerSize.width); + const height = toPixel(overlay.height, containerSize.height); + const isSelected = overlay.id === selectedId; + + return ( +
handleOverlayClick(e, overlay.id)} + className={`absolute border-2 rounded cursor-pointer ${TYPE_BORDER[overlay.type]} ${ + isSelected ? "ring-2 ring-accent" : "" + }`} + style={{ left, top, width, height }} + > + + {overlay.text || TYPE_LABEL[overlay.type]} + +
+ ); + })} +
+ + {/* Inspector panel */} +
+ {selectedOverlay ? ( +
+

{TYPE_LABEL[selectedOverlay.type]}

+ {selectedOverlay.speaker !== undefined && ( +
+ Speaker: {selectedOverlay.speaker || "(none)"} +
+ )} +
+ Text: {selectedOverlay.text || "(empty)"} +
+
+

x: {selectedOverlay.x.toFixed(3)}

+

y: {selectedOverlay.y.toFixed(3)}

+

w: {selectedOverlay.width.toFixed(3)}

+

h: {selectedOverlay.height.toFixed(3)}

+
+
+ ) : ( +

+ Select an overlay to inspect. +

+ )} +
+
+
+ ); +} From 58e34717a2f798228c43e7fc7b83d11cad2c7612 Mon Sep 17 00:00:00 2001 From: Project7 Date: Wed, 27 May 2026 07:12:13 +0000 Subject: [PATCH 2/3] [#204] Fix overlay positioning to use rendered image bounds Position overlays relative to the actual rendered image area (after object-contain scaling) instead of the full container. Computes image bounds from naturalWidth/naturalHeight and container size, accounting for letterboxing. Tests updated to simulate image load. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/web/components/LetteringEditor.test.tsx | 23 ++++++++-- app/web/components/LetteringEditor.tsx | 51 ++++++++++++++------- 2 files changed, 54 insertions(+), 20 deletions(-) diff --git a/app/web/components/LetteringEditor.test.tsx b/app/web/components/LetteringEditor.test.tsx index c54846d..098fdb0 100644 --- a/app/web/components/LetteringEditor.test.tsx +++ b/app/web/components/LetteringEditor.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach, beforeAll } from "vitest"; -import { render, screen, cleanup, fireEvent } from "@testing-library/react"; +import { render, screen, cleanup, fireEvent, act } from "@testing-library/react"; import { LetteringEditor } from "./LetteringEditor"; beforeAll(() => { @@ -7,6 +7,8 @@ beforeAll(() => { callback: ResizeObserverCallback; constructor(callback: ResizeObserverCallback) { this.callback = callback; } observe(target: Element) { + Object.defineProperty(target, "clientWidth", { value: 400, configurable: true }); + Object.defineProperty(target, "clientHeight", { value: 300, configurable: true }); this.callback([{ contentRect: { width: 400, height: 300 }, target } as unknown as ResizeObserverEntry], this); } unobserve() {} @@ -27,6 +29,15 @@ interface Overlay { afterEach(cleanup); +function simulateImageLoad() { + const img = document.querySelector("img"); + if (img) { + Object.defineProperty(img, "naturalWidth", { value: 800, configurable: true }); + Object.defineProperty(img, "naturalHeight", { value: 600, configurable: true }); + act(() => { fireEvent.load(img); }); + } +} + function makeCut(overrides: Record = {}) { return { id: 1, @@ -63,7 +74,7 @@ describe("LetteringEditor", () => { expect(screen.getByText("No clean image — upload one first.")).toBeInTheDocument(); }); - it("renders overlay elements", () => { + it("renders overlay elements after image load", () => { const overlay: Overlay = { id: "test-overlay-1", type: "speech", @@ -84,6 +95,8 @@ describe("LetteringEditor", () => { />, ); + simulateImageLoad(); + const el = screen.getByTestId("overlay-test-overlay-1"); expect(el).toBeInTheDocument(); expect(screen.getByText("Hello!")).toBeInTheDocument(); @@ -109,7 +122,9 @@ describe("LetteringEditor", () => { />, ); - expect(screen.getByTestId("inspector-empty")).toBeInTheDocument(); + simulateImageLoad(); + + expect(screen.queryByTestId("inspector-empty")).toBeInTheDocument(); fireEvent.click(screen.getByTestId("overlay-test-overlay-2")); @@ -138,6 +153,8 @@ describe("LetteringEditor", () => { />, ); + simulateImageLoad(); + fireEvent.click(screen.getByTestId("overlay-test-overlay-3")); expect(screen.getByText("Speech")).toBeInTheDocument(); diff --git a/app/web/components/LetteringEditor.tsx b/app/web/components/LetteringEditor.tsx index be1d406..810e2c2 100644 --- a/app/web/components/LetteringEditor.tsx +++ b/app/web/components/LetteringEditor.tsx @@ -50,24 +50,39 @@ const TYPE_BORDER: Record = { export function LetteringEditor({ storyName, cut, onSave, onClose }: LetteringEditorProps) { const [overlays] = useState(cut.overlays || []); const [selectedId, setSelectedId] = useState(null); - const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }); + const [imageBounds, setImageBounds] = useState({ x: 0, y: 0, width: 0, height: 0 }); const containerRef = useRef(null); + const imgRef = useRef(null); + + const updateImageBounds = useCallback(() => { + const container = containerRef.current; + const img = imgRef.current; + if (!container || !img || !img.naturalWidth) return; + + const cw = container.clientWidth; + const ch = container.clientHeight; + const iw = img.naturalWidth; + const ih = img.naturalHeight; + + const scale = Math.min(cw / iw, ch / ih); + const rw = iw * scale; + const rh = ih * scale; + + setImageBounds({ + x: (cw - rw) / 2, + y: (ch - rh) / 2, + width: rw, + height: rh, + }); + }, []); useEffect(() => { const el = containerRef.current; if (!el) return; - const observer = new ResizeObserver((entries) => { - const entry = entries[0]; - if (entry) { - setContainerSize({ - width: entry.contentRect.width, - height: entry.contentRect.height, - }); - } - }); + const observer = new ResizeObserver(() => updateImageBounds()); observer.observe(el); return () => observer.disconnect(); - }, []); + }, [updateImageBounds]); const handleBackgroundClick = useCallback(() => { setSelectedId(null); @@ -125,18 +140,20 @@ export function LetteringEditor({ storyName, cut, onSave, onClose }: LetteringEd data-testid="editor-surface" > {`Cut - {/* Overlay elements */} - {containerSize.width > 0 && overlays.map((overlay) => { - const left = toPixel(overlay.x, containerSize.width); - const top = toPixel(overlay.y, containerSize.height); - const width = toPixel(overlay.width, containerSize.width); - const height = toPixel(overlay.height, containerSize.height); + {/* Overlay elements — positioned relative to rendered image bounds */} + {imageBounds.width > 0 && overlays.map((overlay) => { + const left = imageBounds.x + toPixel(overlay.x, imageBounds.width); + const top = imageBounds.y + toPixel(overlay.y, imageBounds.height); + const width = toPixel(overlay.width, imageBounds.width); + const height = toPixel(overlay.height, imageBounds.height); const isSelected = overlay.id === selectedId; return ( From dd0bc3cae1b3ae53b7f77c9aa8dcafb8cf00b05e Mon Sep 17 00:00:00 2001 From: Project7 Date: Wed, 27 May 2026 07:14:12 +0000 Subject: [PATCH 3/3] [#204] Add mismatched aspect ratio regression test Test overlay positioning with 800x200 image in 400x400 container, verifying nonzero y-offset (150px letterbox) and correct overlay dimensions. This would fail with the original container-based positioning. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/web/components/LetteringEditor.test.tsx | 42 +++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/app/web/components/LetteringEditor.test.tsx b/app/web/components/LetteringEditor.test.tsx index 098fdb0..01798a0 100644 --- a/app/web/components/LetteringEditor.test.tsx +++ b/app/web/components/LetteringEditor.test.tsx @@ -162,6 +162,48 @@ describe("LetteringEditor", () => { expect(screen.getByTestId("inspector-empty")).toBeInTheDocument(); }); + it("positions overlays correctly with mismatched aspect ratio (letterboxing)", () => { + const overlay: Overlay = { + id: "test-overlay-ar", + type: "speech", + x: 0, + y: 0, + width: 1, + height: 1, + text: "Full", + speaker: "A", + }; + + render( + , + ); + + // Simulate a wide image in a tall container (will letterbox top/bottom) + const img = document.querySelector("img")!; + Object.defineProperty(img, "naturalWidth", { value: 800, configurable: true }); + Object.defineProperty(img, "naturalHeight", { value: 200, configurable: true }); + + const container = screen.getByTestId("editor-surface"); + Object.defineProperty(container, "clientWidth", { value: 400, configurable: true }); + Object.defineProperty(container, "clientHeight", { value: 400, configurable: true }); + + act(() => { fireEvent.load(img); }); + + // With 800x200 image in 400x400 container: + // scale = min(400/800, 400/200) = min(0.5, 2) = 0.5 + // rendered: 400x100, offset y = (400-100)/2 = 150 + const el = screen.getByTestId("overlay-test-overlay-ar"); + expect(el.style.left).toBe("0px"); + expect(el.style.top).toBe("150px"); + expect(el.style.width).toBe("400px"); + expect(el.style.height).toBe("100px"); + }); + it("calls onClose when Close button is clicked", () => { const onClose = vi.fn(); render(