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..01798a0 --- /dev/null +++ b/app/web/components/LetteringEditor.test.tsx @@ -0,0 +1,221 @@ +import { describe, it, expect, vi, afterEach, beforeAll } from "vitest"; +import { render, screen, cleanup, fireEvent, act } from "@testing-library/react"; +import { LetteringEditor } from "./LetteringEditor"; + +beforeAll(() => { + global.ResizeObserver = class { + 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() {} + 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 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, + 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 after image load", () => { + 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( + , + ); + + simulateImageLoad(); + + 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( + , + ); + + simulateImageLoad(); + + expect(screen.queryByTestId("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( + , + ); + + simulateImageLoad(); + + 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("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( + , + ); + + 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..810e2c2 --- /dev/null +++ b/app/web/components/LetteringEditor.tsx @@ -0,0 +1,206 @@ +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 [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(() => updateImageBounds()); + observer.observe(el); + return () => observer.disconnect(); + }, [updateImageBounds]); + + 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 — 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 ( +
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. +

+ )} +
+
+
+ ); +}