From b12fb86349d4bcaf82bd7c2c9fe6d49f38b06ba4 Mon Sep 17 00:00:00 2001 From: bubulux Date: Tue, 27 Oct 2026 12:56:36 +0100 Subject: [PATCH 1/4] Composition model: layers, bakes, migration 0030 (#275) A shot is a composition: drawing and adjustment layers with an order (below or above the footage), a range of steps and a space (frame or photo). The Studio bakes one image per step; the phone shows bakes. - vocab layers.ts: types, strict validation, layersAt, bakeHash; steps no longer reference overlays - migration 0030: layers, step_bakes, timeline_clips.step_id; copies overlays into layers, gives overlay-only shots stored steps, points clips at steps (tested on a real SQLite) - Worker: layer routes, bakes, shots embed layers and bakes, clips show a step, promotion of clip layers, extract and merge carry layers; overlay routes removed - route tests, tenancy map, API and data-model docs Co-Authored-By: Claude Opus 5.5 --- apps/worker/migrations/0030_composition.sql | 91 ++++++++ apps/worker/src/compose.ts | 221 +++--------------- apps/worker/src/layers.ts | 234 +++++++++++++++++++ apps/worker/src/promote.ts | 26 ++- apps/worker/src/routes.ts | 2 + apps/worker/src/shots.ts | 23 +- apps/worker/src/steps.ts | 82 +++---- apps/worker/src/tenancy.ts | 3 +- apps/worker/src/timelines.ts | 52 ++--- apps/worker/src/trash.ts | 11 +- apps/worker/test/routes/helpers.ts | 20 +- apps/worker/test/routes/images.test.ts | 4 +- apps/worker/test/routes/layers.test.ts | 133 +++++++++++ apps/worker/test/routes/limits.test.ts | 22 +- apps/worker/test/routes/steps.test.ts | 43 ++-- apps/worker/test/routes/tenancy.test.ts | 24 +- apps/worker/test/routes/trash.test.ts | 6 +- apps/worker/test/unit/migration-0030.test.ts | 100 ++++++++ docs/api.md | 49 ++-- docs/data-model.md | 13 +- packages/vocab/src/index.ts | 1 + packages/vocab/src/layers.test.ts | 52 +++++ packages/vocab/src/layers.ts | 153 ++++++++++++ packages/vocab/src/steps.test.ts | 42 ++-- packages/vocab/src/steps.ts | 70 ++---- 25 files changed, 1045 insertions(+), 432 deletions(-) create mode 100644 apps/worker/migrations/0030_composition.sql create mode 100644 apps/worker/src/layers.ts create mode 100644 apps/worker/test/routes/layers.test.ts create mode 100644 apps/worker/test/unit/migration-0030.test.ts create mode 100644 packages/vocab/src/layers.test.ts create mode 100644 packages/vocab/src/layers.ts diff --git a/apps/worker/migrations/0030_composition.sql b/apps/worker/migrations/0030_composition.sql new file mode 100644 index 0000000..9f6785f --- /dev/null +++ b/apps/worker/migrations/0030_composition.sql @@ -0,0 +1,91 @@ +-- Epic #275: a shot is a composition. Layers (drawing or adjustment) stack over the footage with an +-- order, a range of steps and a space (frame or photo); the Studio bakes one image per step for the +-- phone. This migration adds the tables and copies every overlay into layers. Nothing is deleted: +-- the overlays table and its renders stay until a later release drops them. + +CREATE TABLE layers ( + id TEXT PRIMARY KEY, + shot_id TEXT NOT NULL REFERENCES shots(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('drawing', 'adjustment')), + name TEXT NOT NULL, + description TEXT, + position INTEGER NOT NULL DEFAULT 0, -- < 0 below the footage, >= 0 above + visible INTEGER NOT NULL DEFAULT 1, + locked INTEGER NOT NULL DEFAULT 0, + opacity REAL NOT NULL DEFAULT 1, + space TEXT NOT NULL CHECK (space IN ('frame', 'photo')), + photo_id TEXT REFERENCES photos(id) ON DELETE CASCADE, -- photo space only + range TEXT CHECK (range IS NULL OR json_valid(range)), -- null = every step, else [step ids] + shapes TEXT CHECK (shapes IS NULL OR json_valid(shapes)), + look TEXT CHECK (look IS NULL OR json_valid(look)), + timeline_id TEXT REFERENCES timelines(id) ON DELETE CASCADE, -- with clip_id: a timeline clip's own + clip_id TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT +); +CREATE INDEX layers_shot_idx ON layers (shot_id, position); +CREATE INDEX layers_clip_idx ON layers (timeline_id, clip_id); + +-- One flattened image per step, made by the Studio; hash = what it was made from (vocab bakeHash). +CREATE TABLE step_bakes ( + shot_id TEXT NOT NULL REFERENCES shots(id) ON DELETE CASCADE, + step_key TEXT NOT NULL, -- the stored step's id, or the photo's id for a shot without stored steps + hash TEXT NOT NULL, + render_key TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (shot_id, step_key) +); + +-- A clip shows a step's composition (its layers) instead of one overlay. +ALTER TABLE timeline_clips ADD COLUMN step_id TEXT REFERENCES shot_steps(id) ON DELETE SET NULL; + +-- 1. Shots without stored steps that have overlays get them: each photo in order, followed by one +-- step per overlay of that photo, so every overlay that could be shown stays a step. +CREATE TABLE _m30_steps (id TEXT PRIMARY KEY, shot_id TEXT, ordinal INTEGER, photo_id TEXT, framing_id TEXT, overlay_id TEXT); +INSERT INTO _m30_steps (id, shot_id, ordinal, photo_id, framing_id, overlay_id) + SELECT lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' || substr(lower(hex(randomblob(2))), 2) || '-' || substr('89ab', 1 + abs(random()) % 4, 1) || substr(lower(hex(randomblob(2))), 2) || '-' || lower(hex(randomblob(6))), ph.shot_id, ph.ordinal * 1000, ph.id, NULL, NULL + FROM photos ph + WHERE ph.shot_id IN (SELECT p2.shot_id FROM overlays o JOIN photos p2 ON p2.id = o.photo_id WHERE o.timeline_id IS NULL) + AND ph.shot_id NOT IN (SELECT shot_id FROM shot_steps); +INSERT INTO _m30_steps (id, shot_id, ordinal, photo_id, framing_id, overlay_id) + SELECT lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' || substr(lower(hex(randomblob(2))), 2) || '-' || substr('89ab', 1 + abs(random()) % 4, 1) || substr(lower(hex(randomblob(2))), 2) || '-' || lower(hex(randomblob(6))), ph.shot_id, + ph.ordinal * 1000 + 1 + (SELECT count(*) FROM overlays o2 WHERE o2.photo_id = o.photo_id AND o2.timeline_id IS NULL AND (o2.position < o.position OR (o2.position = o.position AND o2.id < o.id))), + ph.id, + (SELECT f.id FROM framings f WHERE f.id = json_extract(o.presentation, '$.framing_id') AND f.photo_id = ph.id), + o.id + FROM overlays o JOIN photos ph ON ph.id = o.photo_id + WHERE o.timeline_id IS NULL AND ph.shot_id NOT IN (SELECT shot_id FROM shot_steps); +INSERT INTO shot_steps (id, shot_id, ordinal, photo_id, framing_id, overlay_id) SELECT id, shot_id, ordinal, photo_id, framing_id, overlay_id FROM _m30_steps; +DROP TABLE _m30_steps; + +-- 2. Every overlay becomes a drawing layer (same id, photo space) and, when its look changes the +-- photo, an adjustment layer just below it. A shot overlay shows on the steps that showed it +-- (none: an empty range, it waits in the layer list); a clip's own overlay stays the clip's. +INSERT INTO layers (id, shot_id, kind, name, description, position, visible, locked, opacity, space, photo_id, range, shapes, look, timeline_id, clip_id, created_at, updated_at) + SELECT o.id, ph.shot_id, 'drawing', o.name, o.description, o.position * 2 + 1, 1, 0, 1, 'photo', o.photo_id, + CASE WHEN o.timeline_id IS NULL THEN (SELECT json_group_array(st.id) FROM shot_steps st WHERE st.overlay_id = o.id) END, + coalesce(json_extract(o.drawing, '$.shapes'), '[]'), NULL, o.timeline_id, o.clip_id, o.created_at, coalesce(o.updated_at, o.created_at) + FROM overlays o JOIN photos ph ON ph.id = o.photo_id; +CREATE TABLE _m30_adj (overlay_id TEXT PRIMARY KEY, id TEXT); +INSERT INTO _m30_adj (overlay_id, id) + SELECT o.id, lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' || substr(lower(hex(randomblob(2))), 2) || '-' || substr('89ab', 1 + abs(random()) % 4, 1) || substr(lower(hex(randomblob(2))), 2) || '-' || lower(hex(randomblob(6))) FROM overlays o + WHERE json_extract(o.drawing, '$.look') IS NOT NULL AND NOT (coalesce(json_extract(o.drawing, '$.look.exposure'), 0) = 0 AND coalesce(json_extract(o.drawing, '$.look.contrast'), 0) = 0 AND coalesce(json_extract(o.drawing, '$.look.saturation'), 0) = 0 AND coalesce(json_extract(o.drawing, '$.look.bw'), 0) = 0 AND json_extract(o.drawing, '$.look.tint') IS NULL AND coalesce(json_extract(o.drawing, '$.look.vignette'), 0) = 0 AND coalesce(json_extract(o.drawing, '$.look.blur'), 0) = 0); +INSERT INTO layers (id, shot_id, kind, name, description, position, visible, locked, opacity, space, photo_id, range, shapes, look, timeline_id, clip_id, created_at, updated_at) + SELECT a.id, ph.shot_id, 'adjustment', substr(o.name || ' · look', 1, 80), NULL, o.position * 2, 1, 0, 1, 'photo', o.photo_id, + CASE WHEN o.timeline_id IS NULL THEN (SELECT json_group_array(st.id) FROM shot_steps st WHERE st.overlay_id = o.id) END, + NULL, json_extract(o.drawing, '$.look'), o.timeline_id, o.clip_id, o.created_at, coalesce(o.updated_at, o.created_at) + FROM _m30_adj a JOIN overlays o ON o.id = a.overlay_id JOIN photos ph ON ph.id = o.photo_id; + +-- 3. A clip that showed a shot overlay points at the step that shows it. Where no step does (a shot +-- whose curated steps left that overlay out), the overlay's layers become the clip's own copies, +-- so the clip still looks the same. +UPDATE timeline_clips SET step_id = ( + SELECT st.id FROM shot_steps st WHERE st.overlay_id = timeline_clips.overlay_id AND st.photo_id = timeline_clips.photo_id ORDER BY st.ordinal LIMIT 1) +WHERE overlay_id IN (SELECT id FROM overlays WHERE timeline_id IS NULL); +INSERT INTO layers (id, shot_id, kind, name, description, position, visible, locked, opacity, space, photo_id, range, shapes, look, timeline_id, clip_id, created_at, updated_at) + SELECT lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' || substr(lower(hex(randomblob(2))), 2) || '-' || substr('89ab', 1 + abs(random()) % 4, 1) || substr(lower(hex(randomblob(2))), 2) || '-' || lower(hex(randomblob(6))), l.shot_id, l.kind, l.name, l.description, l.position, 1, 0, 1, 'photo', l.photo_id, NULL, l.shapes, l.look, c.timeline_id, c.id, l.created_at, l.updated_at + FROM timeline_clips c + JOIN overlays o ON o.id = c.overlay_id AND o.timeline_id IS NULL + JOIN layers l ON l.timeline_id IS NULL AND (l.id = o.id OR l.id = (SELECT a.id FROM _m30_adj a WHERE a.overlay_id = o.id)) + WHERE c.step_id IS NULL; +DROP TABLE _m30_adj; diff --git a/apps/worker/src/compose.ts b/apps/worker/src/compose.ts index 4aab13c..022db5c 100644 --- a/apps/worker/src/compose.ts +++ b/apps/worker/src/compose.ts @@ -1,4 +1,4 @@ -import { MAX_COMPOSE_NAME, MAX_DESCRIPTION, MAX_SKETCH_KIND, validateDrawing, validatePresentation } from "@openclapper/vocab"; +import { MAX_COMPOSE_NAME, MAX_DESCRIPTION, MAX_SKETCH_KIND, validateDrawing } from "@openclapper/vocab"; import { assertNumber, assertString, assertUuid, HttpError, isJsonType, json, readJson, type Router } from "@openclapper/service-kit"; import { assertBodySize, imageExt, imageTypeOf, preconditionFailed, type ImageType } from "./images.ts"; import type { Ctx } from "./index.ts"; @@ -6,17 +6,14 @@ import { loadShot } from "./shots.ts"; import { accessIfExists, assertAccess, type Access } from "./tenancy.ts"; /** - * Overlays (a drawing + look over one photo) and sketches (a free canvas on a shot), issue #12. - * The Studio owns them: it edits the vector JSON and uploads a flattened render with every - * save, which is what the phone shows. Shots embed both lists without the drawing - * (`composeFor`); the drawing comes with GET /api/overlays/:id and /api/sketches/:id. - * - * Issue #31: an overlay or sketch drawn inline in a timeline belongs to that timeline clip - * (timeline_id + clip_id) until it is promoted to the shot. Shots never embed clip-owned rows, - * so the phone does not see them; timelines embed their own (`clipComposeFor`). + * Sketches (a free canvas on a shot, issue #12): the Studio edits the vector JSON and uploads a + * flattened render with every save, which is what the phone shows. Shots embed them without the + * drawing (`composeFor`); the drawing comes with GET /api/sketches/:id. A sketch drawn inline in a + * timeline belongs to that clip (timeline_id + clip_id, issue #31) until it is attached to a shot. + * What was an overlay is now a layer of the shot (layers.ts, epic #275); the old overlays table is + * only read to clean up its renders. */ -interface OverlayRow { id: string; photo_id: string; shot_id: string; timeline_id: string | null; clip_id: string | null; name: string; description: string | null; drawing?: string; presentation: string; render_key: string | null; position: number; created_at: string; updated_at: string | null } interface SketchRow { id: string; shot_id: string | null; timeline_id: string | null; clip_id: string | null; name: string; kind: string | null; description: string | null; drawing?: string; aspect: number; render_key: string | null; position: number; created_at: string; updated_at: string | null } const MAX_RENDER_BYTES = 6 * 1024 * 1024; @@ -25,17 +22,9 @@ const MAX_DRAWING_BYTES = 1024 * 1024; const MAX_SAVE_BYTES = MAX_RENDER_BYTES + MAX_DRAWING_BYTES + 64 * 1024; /** The render URL changes with every save, so the image route can cache for a year. */ -const renderUrl = (kind: "overlays" | "sketches", r: { id: string; render_key: string | null; updated_at: string | null; created_at: string }) => +const renderUrl = (kind: "sketches", r: { id: string; render_key: string | null; updated_at: string | null; created_at: string }) => r.render_key ? `/api/${kind}/${r.id}/render?v=${encodeURIComponent(r.updated_at ?? r.created_at)}` : null; -export function overlayToApi(r: OverlayRow) { - return { - id: r.id, photo_id: r.photo_id, shot_id: r.shot_id, timeline_id: r.timeline_id, clip_id: r.clip_id, name: r.name, description: r.description, - ...(r.drawing !== undefined ? { drawing: JSON.parse(r.drawing) as unknown } : {}), - presentation: JSON.parse(r.presentation) as unknown, position: r.position, - render_url: renderUrl("overlays", r), created_at: r.created_at, updated_at: r.updated_at, - }; -} export function sketchToApi(r: SketchRow) { return { id: r.id, shot_id: r.shot_id, timeline_id: r.timeline_id, clip_id: r.clip_id, name: r.name, kind: r.kind, description: r.description, @@ -44,78 +33,54 @@ export function sketchToApi(r: SketchRow) { render_url: renderUrl("sketches", r), created_at: r.created_at, updated_at: r.updated_at, }; } -export type OverlayApi = ReturnType; export type SketchApi = ReturnType; -const OVERLAY_COLS = "o.id, o.photo_id, ph.shot_id, o.timeline_id, o.clip_id, o.name, o.description, o.presentation, o.render_key, o.position, o.created_at, o.updated_at"; const SKETCH_COLS = "id, shot_id, timeline_id, clip_id, name, kind, description, aspect, render_key, position, created_at, updated_at"; -/** Overlays and sketches of many shots, without drawings, in display order. */ -export async function composeFor(env: Ctx["env"], shotIds: string[]): Promise<{ overlays: Map; sketches: Map }> { - const overlays = new Map(), sketches = new Map(); - if (shotIds.length === 0) return { overlays, sketches }; - const ids = JSON.stringify(shotIds); - const [o, s] = await env.DB.batch([ - env.DB.prepare(`SELECT ${OVERLAY_COLS} FROM overlays o JOIN photos ph ON ph.id = o.photo_id WHERE ph.shot_id IN (SELECT value FROM json_each(?1)) AND o.timeline_id IS NULL ORDER BY ph.shot_id, o.position, o.created_at`).bind(ids), - env.DB.prepare(`SELECT ${SKETCH_COLS} FROM sketches WHERE shot_id IN (SELECT value FROM json_each(?1)) ORDER BY shot_id, position, created_at`).bind(ids), - ]); - for (const r of (o.results ?? []) as OverlayRow[]) overlays.set(r.shot_id, [...(overlays.get(r.shot_id) ?? []), overlayToApi(r)]); - for (const r of (s.results ?? []) as SketchRow[]) sketches.set(r.shot_id!, [...(sketches.get(r.shot_id!) ?? []), sketchToApi(r)]); - return { overlays, sketches }; +/** Sketches of many shots, without drawings, in display order. */ +export async function composeFor(env: Ctx["env"], shotIds: string[]): Promise<{ sketches: Map }> { + const sketches = new Map(); + if (shotIds.length === 0) return { sketches }; + const { results } = await env.DB.prepare(`SELECT ${SKETCH_COLS} FROM sketches WHERE shot_id IN (SELECT value FROM json_each(?1)) ORDER BY shot_id, position, created_at`).bind(JSON.stringify(shotIds)).all(); + for (const r of results) sketches.set(r.shot_id!, [...(sketches.get(r.shot_id!) ?? []), sketchToApi(r)]); + return { sketches }; } -/** The clip-owned overlays and sketches of these timelines, without drawings, keyed by timeline id. */ -export async function clipComposeFor(env: Ctx["env"], timelineIds: string[]): Promise<{ overlays: Map; sketches: Map }> { - const overlays = new Map(), sketches = new Map(); - if (timelineIds.length === 0) return { overlays, sketches }; - const ids = JSON.stringify(timelineIds); - const [o, s] = await env.DB.batch([ - env.DB.prepare(`SELECT ${OVERLAY_COLS} FROM overlays o JOIN photos ph ON ph.id = o.photo_id WHERE o.timeline_id IN (SELECT value FROM json_each(?1)) ORDER BY o.created_at`).bind(ids), - env.DB.prepare(`SELECT ${SKETCH_COLS} FROM sketches WHERE timeline_id IN (SELECT value FROM json_each(?1)) ORDER BY created_at`).bind(ids), - ]); - for (const r of (o.results ?? []) as OverlayRow[]) overlays.set(r.timeline_id!, [...(overlays.get(r.timeline_id!) ?? []), overlayToApi(r)]); - for (const r of (s.results ?? []) as SketchRow[]) sketches.set(r.timeline_id!, [...(sketches.get(r.timeline_id!) ?? []), sketchToApi(r)]); - return { overlays, sketches }; +/** The clip-owned sketches of these timelines, without drawings, keyed by timeline id. */ +export async function clipComposeFor(env: Ctx["env"], timelineIds: string[]): Promise<{ sketches: Map }> { + const sketches = new Map(); + if (timelineIds.length === 0) return { sketches }; + const { results } = await env.DB.prepare(`SELECT ${SKETCH_COLS} FROM sketches WHERE timeline_id IN (SELECT value FROM json_each(?1)) ORDER BY created_at`).bind(JSON.stringify(timelineIds)).all(); + for (const r of results) sketches.set(r.timeline_id!, [...(sketches.get(r.timeline_id!) ?? []), sketchToApi(r)]); + return { sketches }; } /** - * Delete the clip-owned overlays and sketches matching a WHERE clause over `o` (overlays) or `s` - * (sketches), and their renders. Used when clips leave a timeline (PUT), a timeline is deleted, or + * Delete what clips own (layers, sketches with their renders) by the statements given: the reads + * return the R2 keys to remove. Used when clips leave a timeline (PUT), a timeline is deleted, or * a shot moves out of the timeline's project. */ -export async function deleteClipCompose(env: Ctx["env"], where: { overlays: D1PreparedStatement | null; sketches: D1PreparedStatement | null; deletes: D1PreparedStatement[] }): Promise { - const reads = [where.overlays, where.sketches].filter((x): x is D1PreparedStatement => !!x); +export async function deleteClipCompose(env: Ctx["env"], where: { reads: D1PreparedStatement[]; deletes: D1PreparedStatement[] }): Promise { + const reads = where.reads; const keys = reads.length ? (await env.DB.batch(reads)).flatMap((r) => ((r.results ?? []) as { k: string | null }[]).map((x) => x.k).filter((k): k is string => !!k)) : []; if (where.deletes.length) await env.DB.batch(where.deletes); if (keys.length) await env.SHOTS_BUCKET.delete(keys).catch(() => {}); } -/** Clip-owned overlays on these shots' photos in timelines of other projects (the shots moved to `projectId`). */ -export function clipOverlaysLeaving(env: Ctx["env"], shotIdsJson: string, projectId: string) { - const where = "photo_id IN (SELECT id FROM photos WHERE shot_id IN (SELECT value FROM json_each(?1))) AND timeline_id IN (SELECT id FROM timelines WHERE project_id != ?2)"; - return { - overlays: env.DB.prepare(`SELECT render_key AS k FROM overlays WHERE ${where}`).bind(shotIdsJson, projectId), - sketches: null, - deletes: [env.DB.prepare(`DELETE FROM overlays WHERE ${where}`).bind(shotIdsJson, projectId)], - }; +/** Clip-owned layers on these shots in timelines of other projects (the shots moved to `projectId`). */ +export function clipLayersLeaving(env: Ctx["env"], shotIdsJson: string, projectId: string) { + const where = "shot_id IN (SELECT value FROM json_each(?1)) AND timeline_id IN (SELECT id FROM timelines WHERE project_id != ?2)"; + return { reads: [], deletes: [env.DB.prepare(`DELETE FROM layers WHERE ${where}`).bind(shotIdsJson, projectId)] }; } -/** R2 keys of every render under a shot (to delete with the shot). */ +/** R2 keys of every render under a shot (to delete with the shot): sketches, bakes, and renders of old overlays. */ export async function renderKeysOf(env: Ctx["env"], shotId: string): Promise { - const [o, s] = await env.DB.batch([ + const [o, s, b] = await env.DB.batch([ env.DB.prepare("SELECT o.render_key AS k FROM overlays o JOIN photos ph ON ph.id = o.photo_id WHERE ph.shot_id = ?1 AND o.render_key IS NOT NULL").bind(shotId), env.DB.prepare("SELECT render_key AS k FROM sketches WHERE shot_id = ?1 AND render_key IS NOT NULL").bind(shotId), + env.DB.prepare("SELECT render_key AS k FROM step_bakes WHERE shot_id = ?1").bind(shotId), ]); - return [...((o.results ?? []) as { k: string }[]), ...((s.results ?? []) as { k: string }[])].map((r) => r.k); -} - -/** Counts for the delete confirmation. */ -export async function composeCounts(env: Ctx["env"], shotId: string): Promise<{ overlays: number; sketches: number }> { - const [o, s] = await env.DB.batch([ - env.DB.prepare("SELECT count(*) AS n FROM overlays o JOIN photos ph ON ph.id = o.photo_id WHERE ph.shot_id = ?1").bind(shotId), - env.DB.prepare("SELECT count(*) AS n FROM sketches WHERE shot_id = ?1").bind(shotId), - ]); - return { overlays: ((o.results?.[0] as { n: number } | undefined)?.n) ?? 0, sketches: ((s.results?.[0] as { n: number } | undefined)?.n) ?? 0 }; + return [o, s, b].flatMap((r) => (r.results ?? []) as { k: string }[]).map((r) => r.k); } /** A PUT body: JSON alone, or multipart with `metadata` (JSON) and an optional `render` image. */ @@ -151,7 +116,7 @@ const drawingJson = (v: unknown, withLook: boolean): string => { }; /** * The clip owner in a PUT body (both `timeline_id` and `clip_id`, or neither). The timeline must - * exist, be editable by the caller and, for an overlay, be in the project of the photo's shot. + * exist and be editable by the caller. */ async function clipOwner(a: Access, m: Record, projectOfPhoto: string | null): Promise<{ timeline_id: string; clip_id: string; workspace_id: string } | null> { if ((m.timeline_id === undefined || m.timeline_id === null) && (m.clip_id === undefined || m.clip_id === null)) return null; @@ -181,7 +146,7 @@ async function readBody(request: Request): Promise<{ m: Record; } /** Write the render to R2 (keyed by id and type), then run the row statements; the object is removed again if the rows fail. */ -async function saveWithRender(env: Ctx["env"], kind: "overlays" | "sketches", id: string, render: Render | null, oldKey: string | null, rows: (key: string | null) => D1PreparedStatement[]) { +async function saveWithRender(env: Ctx["env"], kind: "sketches", id: string, render: Render | null, oldKey: string | null, rows: (key: string | null) => D1PreparedStatement[]) { const key = render ? `renders/${kind}/${id}.${imageExt(render.type)}` : oldKey; if (render) await env.SHOTS_BUCKET.put(key!, render.file.stream(), { httpMetadata: { contentType: render.type }, customMetadata: { kind, id } }); try { await env.DB.batch(rows(key)); } catch (err) { @@ -192,7 +157,7 @@ async function saveWithRender(env: Ctx["env"], kind: "overlays" | "sketches", id } /** Copy a render object to the key of another row (a duplicated clip gets its own render). */ -export async function copyRender(env: Ctx["env"], kind: "overlays" | "sketches", id: string, key: string | null): Promise { +export async function copyRender(env: Ctx["env"], kind: "sketches", id: string, key: string | null): Promise { if (!key) return null; const obj = await env.SHOTS_BUCKET.get(key); if (!obj) return null; @@ -216,118 +181,6 @@ async function serveRender(env: Ctx["env"], request: Request, key: string | null } export function registerComposeRoutes(r: Router) { - // ---------- Overlays ---------- - const overlay = (env: Ctx["env"], id: string, withDrawing: boolean) => env.DB.prepare( - `SELECT ${OVERLAY_COLS}${withDrawing ? ", o.drawing" : ""} FROM overlays o JOIN photos ph ON ph.id = o.photo_id WHERE o.id = ?1`, - ).bind(id).first(); - - r.on("GET", "/api/overlays/:id", async (ctx, { id }) => { - const oid = assertUuid(id, "id"); - await assertAccess(ctx, "overlay", oid, "viewer"); - const row = await overlay(ctx.env, oid, true); - if (!row) throw new HttpError(404, "overlay not found"); - return json({ overlay: overlayToApi(row) }); - }); - - /** - * Upsert. JSON, or multipart with `metadata` + `render`: - * { photo_id, name, description?, drawing, presentation, position?, timeline_id?, clip_id? } - * The photo fixes the shot; moving an overlay to another photo is not supported. With - * timeline_id + clip_id the overlay belongs to that timeline clip (issue #31); the owner is - * fixed at creation (promotion goes through the timeline route). - */ - r.on("PUT", "/api/overlays/:id", async (ctx, { id }) => { - const { env, request } = ctx; - const oid = assertUuid(id, "id"); - const { m, render } = await readBody(request); - const photoId = assertUuid(m.photo_id, "photo_id"); - const name = assertString(m.name, "name", MAX_COMPOSE_NAME); - const description = optText(m.description, "description", MAX_DESCRIPTION); - const drawing = drawingJson(m.drawing, true); - const perr = validatePresentation(m.presentation); - if (perr) throw new HttpError(400, `presentation: ${perr}`); - const presentation = JSON.stringify(m.presentation); - await assertAccess(ctx, "photo", photoId, "editor"); - await accessIfExists(ctx, "overlay", oid, "editor"); - const photo = await env.DB.prepare("SELECT ph.shot_id, s.project_id FROM photos ph JOIN shots s ON s.id = ph.shot_id WHERE ph.id = ?1").bind(photoId).first<{ shot_id: string; project_id: string }>(); - if (!photo) throw new HttpError(404, "photo not found"); - const owner = await clipOwner(ctx, m, photo.project_id); - const cur = await env.DB.prepare("SELECT photo_id, render_key, position, timeline_id, clip_id FROM overlays WHERE id = ?1").bind(oid).first<{ photo_id: string; render_key: string | null; position: number; timeline_id: string | null; clip_id: string | null }>(); - if (cur && cur.photo_id !== photoId) throw new HttpError(409, "an overlay cannot move to another photo"); - if (cur && !sameOwner(cur, owner)) throw new HttpError(409, "an overlay cannot change its owner here"); - const position = optPosition(m.position) ?? cur?.position ?? ((await env.DB.prepare("SELECT coalesce(max(position), -1) + 1 AS p FROM overlays WHERE photo_id = ?1").bind(photoId).first<{ p: number }>())?.p ?? 0); - const now = new Date().toISOString(); - await saveWithRender(env, "overlays", oid, render, cur?.render_key ?? null, (key) => [ - env.DB.prepare( - `INSERT INTO overlays (id, photo_id, name, description, drawing, presentation, render_key, position, updated_at, timeline_id, clip_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) - ON CONFLICT(id) DO UPDATE SET name = ?3, description = ?4, drawing = ?5, presentation = ?6, render_key = ?7, position = ?8, updated_at = ?9`, - ).bind(oid, photoId, name, description, drawing, presentation, key, position, now, owner?.timeline_id ?? null, owner?.clip_id ?? null), - ]); - return json({ overlay: overlayToApi((await overlay(env, oid, true))!), shot: await loadShot(env, photo.shot_id) }, cur ? 200 : 201); - }); - - /** Rename, describe or reorder without touching the drawing: JSON { name?, description?, position? }. */ - r.on("PATCH", "/api/overlays/:id", async (ctx, { id }) => { - const { env, request } = ctx; - const oid = assertUuid(id, "id"); - await assertAccess(ctx, "overlay", oid, "editor"); - const { m } = await readBody(request); - const sets: string[] = []; - const args: unknown[] = []; - if (m.name !== undefined) { args.push(assertString(m.name, "name", MAX_COMPOSE_NAME)); sets.push(`name = ?${args.length}`); } - if (m.description !== undefined) { args.push(optText(m.description, "description", MAX_DESCRIPTION)); sets.push(`description = ?${args.length}`); } - if (m.position !== undefined) { args.push(optPosition(m.position) ?? 0); sets.push(`position = ?${args.length}`); } - if (!sets.length) throw new HttpError(400, "nothing to update"); - args.push(new Date().toISOString()); sets.push(`updated_at = ?${args.length}`); - args.push(oid); - const res = await env.DB.prepare(`UPDATE overlays SET ${sets.join(", ")} WHERE id = ?${args.length}`).bind(...args).run(); - if (!res.meta.changes) throw new HttpError(404, "overlay not found"); - const row = (await overlay(env, oid, true))!; - return json({ overlay: overlayToApi(row), shot: await loadShot(env, row.shot_id) }); - }); - - r.on("DELETE", "/api/overlays/:id", async (ctx, { id }) => { - const { env } = ctx; - const oid = assertUuid(id, "id"); - await assertAccess(ctx, "overlay", oid, "editor"); - const row = await overlay(env, oid, false); - if (!row) throw new HttpError(404, "overlay not found"); - await env.DB.prepare("DELETE FROM overlays WHERE id = ?1").bind(oid).run(); - if (row.render_key) await env.SHOTS_BUCKET.delete(row.render_key).catch(() => {}); - return json({ deleted: oid, shot: await loadShot(env, row.shot_id) }); - }); - - /** - * Copy an overlay (shot- or clip-owned) as the own overlay of a timeline clip: { id, timeline_id, clip_id }. - * A duplicated clip must not share its drawing with the original (issue #31). - */ - r.on("POST", "/api/overlays/:id/copy", async (ctx, { id }) => { - const { env, request } = ctx; - const from = assertUuid(id, "id"); - await assertAccess(ctx, "overlay", from, "viewer"); - const { m } = await readBody(request); - const to = assertUuid(m.id, "id"); - const src = await env.DB.prepare("SELECT o.*, s.project_id FROM overlays o JOIN photos ph ON ph.id = o.photo_id JOIN shots s ON s.id = ph.shot_id WHERE o.id = ?1").bind(from).first & { photo_id: string; render_key: string | null; project_id: string }>(); - if (!src) throw new HttpError(404, "overlay not found"); - const owner = await clipOwner(ctx, m, src.project_id); - if (!owner) throw new HttpError(400, "timeline_id and clip_id are required"); - if (await env.DB.prepare("SELECT 1 FROM overlays WHERE id = ?1").bind(to).first()) throw new HttpError(409, "an overlay with that id exists"); - const key = await copyRender(env, "overlays", to, src.render_key); - await env.DB.prepare( - "INSERT INTO overlays (id, photo_id, name, description, drawing, presentation, render_key, position, updated_at, timeline_id, clip_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 0, ?8, ?9, ?10)", - ).bind(to, src.photo_id, src.name, src.description, src.drawing, src.presentation, key, new Date().toISOString(), owner.timeline_id, owner.clip_id).run(); - return json({ overlay: overlayToApi((await overlay(env, to, false))!) }, 201); - }); - - r.on("GET", "/api/overlays/:id/render", async (ctx, { id }) => { - const { env, request } = ctx; - const oid = assertUuid(id, "id"); - await assertAccess(ctx, "overlay", oid, "viewer"); - const row = await env.DB.prepare("SELECT render_key FROM overlays WHERE id = ?1").bind(oid).first<{ render_key: string | null }>(); - if (!row) throw new HttpError(404, "overlay not found"); - return serveRender(env, request, row.render_key); - }); - // ---------- Sketches ---------- const sketch = (env: Ctx["env"], id: string, withDrawing: boolean) => env.DB.prepare( `SELECT ${SKETCH_COLS}${withDrawing ? ", drawing" : ""} FROM sketches WHERE id = ?1`, diff --git a/apps/worker/src/layers.ts b/apps/worker/src/layers.ts new file mode 100644 index 0000000..0981daa --- /dev/null +++ b/apps/worker/src/layers.ts @@ -0,0 +1,234 @@ +import { MAX_LAYERS, validateLayerInput, type Layer, type LayerInput, type StepBake } from "@openclapper/vocab"; +import { assertUuid, HttpError, json, readJson, type Router } from "@openclapper/service-kit"; +import { assertBodySize, imageExt, imageTypeOf, preconditionFailed } from "./images.ts"; +import type { Ctx } from "./index.ts"; +import { loadShot } from "./shots.ts"; +import { accessIfExists, assertAccess } from "./tenancy.ts"; + +/** + * Layers and bakes (epic #275, vocab layers.ts). A shot's layers stack over its footage; a timeline + * clip's own layers (timeline_id + clip_id) stack over the clip. The Studio edits them and uploads + * one flattened image per step (a bake) with the hash of what it was made from; the phone shows a + * bake when its hash matches. Interactive edits, so validation is strict. + */ + +interface LayerRow { + id: string; shot_id: string; kind: string; name: string; description: string | null; position: number; visible: number; locked: number; opacity: number; + space: string; photo_id: string | null; range: string | null; shapes?: string | null; look: string | null; timeline_id: string | null; clip_id: string | null; + created_at: string; updated_at: string | null; +} +const COLS = "id, shot_id, kind, name, description, position, visible, locked, opacity, space, photo_id, range, look, timeline_id, clip_id, created_at, updated_at"; + +export function layerToApi(r: LayerRow): Layer { + return { + id: r.id, shot_id: r.shot_id, kind: r.kind as Layer["kind"], name: r.name, description: r.description, position: r.position, + visible: r.visible === 1, locked: r.locked === 1, opacity: r.opacity, space: r.space as Layer["space"], photo_id: r.photo_id, + range: r.range === null ? null : (JSON.parse(r.range) as string[]), look: r.look === null ? null : (JSON.parse(r.look) as Layer["look"]), + ...(r.shapes !== undefined ? { shapes: r.shapes === null ? [] : (JSON.parse(r.shapes) as Layer["shapes"]) } : {}), + timeline_id: r.timeline_id, clip_id: r.clip_id, created_at: r.created_at, updated_at: r.updated_at, + }; +} + +/** The shots' own layers (not clips'), without shapes, keyed by shot id, in stack order. */ +export async function layersFor(env: Ctx["env"], shotIds: string[]): Promise> { + const out = new Map(); + if (!shotIds.length) return out; + const { results } = await env.DB.prepare(`SELECT ${COLS} FROM layers WHERE shot_id IN (SELECT value FROM json_each(?1)) AND timeline_id IS NULL ORDER BY shot_id, position, created_at, id`) + .bind(JSON.stringify(shotIds)).all(); + for (const r of results) out.set(r.shot_id, [...(out.get(r.shot_id) ?? []), layerToApi(r)]); + return out; +} + +/** Timeline clips' own layers, without shapes, keyed by timeline id. */ +export async function clipLayersFor(env: Ctx["env"], timelineIds: string[]): Promise> { + const out = new Map(); + if (!timelineIds.length) return out; + const { results } = await env.DB.prepare(`SELECT ${COLS} FROM layers WHERE timeline_id IN (SELECT value FROM json_each(?1)) ORDER BY timeline_id, position, created_at, id`) + .bind(JSON.stringify(timelineIds)).all(); + for (const r of results) out.set(r.timeline_id!, [...(out.get(r.timeline_id!) ?? []), layerToApi(r)]); + return out; +} + +/** Stored bakes of these shots, keyed by shot id. The URL carries the hash, so the image route can cache for a year. */ +export async function bakesFor(env: Ctx["env"], shotIds: string[]): Promise> { + const out = new Map(); + if (!shotIds.length) return out; + const { results } = await env.DB.prepare("SELECT shot_id, step_key, hash FROM step_bakes WHERE shot_id IN (SELECT value FROM json_each(?1))").bind(JSON.stringify(shotIds)).all<{ shot_id: string; step_key: string; hash: string }>(); + for (const r of results) out.set(r.shot_id, [...(out.get(r.shot_id) ?? []), { step_key: r.step_key, hash: r.hash, url: `/api/shots/${r.shot_id}/bakes/${r.step_key}?v=${r.hash}` }]); + return out; +} + +/** R2 keys of a shot's bakes (deleted with the shot). */ +export async function bakeKeysOf(env: Ctx["env"], shotId: string): Promise { + return (await env.DB.prepare("SELECT render_key AS k FROM step_bakes WHERE shot_id = ?1").bind(shotId).all<{ k: string }>()).results.map((r) => r.k); +} + +/** The step keys a shot has now: its stored steps, else its photos (vocab bakeKey). */ +async function stepKeysOf(env: Ctx["env"], shotId: string): Promise> { + const stored = (await env.DB.prepare("SELECT id FROM shot_steps WHERE shot_id = ?1").bind(shotId).all<{ id: string }>()).results.map((r) => r.id); + if (stored.length) return new Set(stored); + return new Set((await env.DB.prepare("SELECT id FROM photos WHERE shot_id = ?1").bind(shotId).all<{ id: string }>()).results.map((r) => r.id)); +} + +/** Bakes for steps that no longer exist go, with their images. */ +export async function pruneBakes(env: Ctx["env"], shotId: string): Promise { + const keys = await stepKeysOf(env, shotId); + const gone = (await env.DB.prepare("SELECT step_key, render_key FROM step_bakes WHERE shot_id = ?1").bind(shotId).all<{ step_key: string; render_key: string }>()).results.filter((b) => !keys.has(b.step_key)); + if (!gone.length) return; + await env.DB.prepare("DELETE FROM step_bakes WHERE shot_id = ?1 AND step_key IN (SELECT value FROM json_each(?2))").bind(shotId, JSON.stringify(gone.map((b) => b.step_key))).run(); + await env.SHOTS_BUCKET.delete(gone.map((b) => b.render_key)).catch(() => {}); +} + +const MAX_BAKE_BYTES = 6 * 1024 * 1024; +const MAX_SHAPES_BYTES = 1024 * 1024; + +export function registerLayerRoutes(r: Router) { + const one = (env: Ctx["env"], id: string) => env.DB.prepare(`SELECT ${COLS}, shapes FROM layers WHERE id = ?1`).bind(id).first(); + + /** A shot's own layers with their shapes (the Studio's editor and baker). */ + r.on("GET", "/api/shots/:id/layers", async (ctx, { id }) => { + const sid = assertUuid(id, "id"); + await assertAccess(ctx, "shot", sid, "viewer"); + const { results } = await ctx.env.DB.prepare(`SELECT ${COLS}, shapes FROM layers WHERE shot_id = ?1 AND timeline_id IS NULL ORDER BY position, created_at, id`).bind(sid).all(); + return json({ layers: results.map(layerToApi) }); + }); + + r.on("GET", "/api/layers/:id", async (ctx, { id }) => { + const lid = assertUuid(id, "id"); + await assertAccess(ctx, "layer", lid, "viewer"); + const row = await one(ctx.env, lid); + if (!row) throw new HttpError(404, "layer not found"); + return json({ layer: layerToApi(row) }); + }); + + /** + * Upsert a layer: { shot_id, kind, name, description?, position, visible, locked, opacity, space, + * photo_id?, range?, shapes?, look?, timeline_id?, clip_id? }. The shot, the kind and the owner + * (shot or clip) are fixed at creation. A photo-space layer's photo is one of the shot's; a range + * lists stored steps of the shot; a clip owner is a timeline in the shot's project. + */ + r.on("PUT", "/api/layers/:id", async (ctx, { id }) => { + const { env, request } = ctx; + const lid = assertUuid(id, "id"); + assertBodySize(request, MAX_SHAPES_BYTES + 64 * 1024); + const b = await readJson>(request); + const err = validateLayerInput(b); + if (err) throw new HttpError(400, err); + const l = b as unknown as LayerInput; + await assertAccess(ctx, "shot", l.shot_id, "editor"); + await accessIfExists(ctx, "layer", lid, "editor"); + const shot = await env.DB.prepare("SELECT project_id FROM shots WHERE id = ?1").bind(l.shot_id).first<{ project_id: string }>(); + if (!shot) throw new HttpError(404, "shot not found"); + const cur = await env.DB.prepare("SELECT shot_id, kind, timeline_id, clip_id FROM layers WHERE id = ?1").bind(lid).first<{ shot_id: string; kind: string; timeline_id: string | null; clip_id: string | null }>(); + if (cur && (cur.shot_id !== l.shot_id || cur.kind !== l.kind || (cur.timeline_id ?? null) !== (l.timeline_id ?? null) || (cur.clip_id ?? null) !== (l.clip_id ?? null))) throw new HttpError(409, "a layer cannot change its shot, kind or owner"); + if (l.photo_id && !(await env.DB.prepare("SELECT 1 FROM photos WHERE id = ?1 AND shot_id = ?2").bind(l.photo_id, l.shot_id).first())) throw new HttpError(400, "photo_id is not a photo of the shot"); + if (l.range?.length) { + const known = (await env.DB.prepare("SELECT count(*) AS n FROM shot_steps WHERE shot_id = ?1 AND id IN (SELECT value FROM json_each(?2))").bind(l.shot_id, JSON.stringify(l.range)).first<{ n: number }>())?.n ?? 0; + if (known !== l.range.length) throw new HttpError(400, "range lists a step that is not a stored step of the shot"); + } + if (l.timeline_id) { + await assertAccess(ctx, "timeline", l.timeline_id, "editor"); + const t = await env.DB.prepare("SELECT project_id FROM timelines WHERE id = ?1").bind(l.timeline_id).first<{ project_id: string }>(); + if (!t || t.project_id !== shot.project_id) throw new HttpError(400, "the shot is not in the timeline's project"); + } + if (!cur) { + const n = (await env.DB.prepare(`SELECT count(*) AS n FROM layers WHERE shot_id = ?1 AND ${l.timeline_id ? "clip_id = ?2" : "timeline_id IS NULL"}`).bind(l.shot_id, ...(l.timeline_id ? [l.clip_id] : [])).first<{ n: number }>())?.n ?? 0; + if (n >= MAX_LAYERS) throw new HttpError(413, `at most ${MAX_LAYERS} layers`); + } + const shapes = l.kind === "drawing" ? JSON.stringify(l.shapes ?? []) : null; + if (shapes && shapes.length > MAX_SHAPES_BYTES) throw new HttpError(413, "shapes too large"); + const now = new Date().toISOString(); + await env.DB.prepare( + `INSERT INTO layers (id, shot_id, kind, name, description, position, visible, locked, opacity, space, photo_id, range, shapes, look, timeline_id, clip_id, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17) + ON CONFLICT(id) DO UPDATE SET name = ?4, description = ?5, position = ?6, visible = ?7, locked = ?8, opacity = ?9, space = ?10, photo_id = ?11, range = ?12, shapes = ?13, look = ?14, updated_at = ?17`, + ).bind(lid, l.shot_id, l.kind, l.name.trim(), l.description?.trim() || null, l.position, l.visible ? 1 : 0, l.locked ? 1 : 0, l.opacity, l.space, l.photo_id ?? null, + l.range == null ? null : JSON.stringify(l.range), shapes, l.kind === "adjustment" ? JSON.stringify(l.look) : null, l.timeline_id ?? null, l.clip_id ?? null, now).run(); + return json({ layer: layerToApi((await one(env, lid))!), shot: await loadShot(env, l.shot_id) }, cur ? 200 : 201); + }); + + r.on("DELETE", "/api/layers/:id", async (ctx, { id }) => { + const { env } = ctx; + const lid = assertUuid(id, "id"); + await assertAccess(ctx, "layer", lid, "editor"); + const row = await env.DB.prepare("SELECT shot_id FROM layers WHERE id = ?1").bind(lid).first<{ shot_id: string }>(); + if (!row) throw new HttpError(404, "layer not found"); + await env.DB.prepare("DELETE FROM layers WHERE id = ?1").bind(lid).run(); + return json({ deleted: lid, shot: await loadShot(env, row.shot_id) }); + }); + + /** Copy a layer as the own layer of a timeline clip: { id, timeline_id, clip_id } (a duplicated clip gets its own). */ + r.on("POST", "/api/layers/:id/copy", async (ctx, { id }) => { + const { env, request } = ctx; + const from = assertUuid(id, "id"); + await assertAccess(ctx, "layer", from, "viewer"); + const b = await readJson>(request); + const to = assertUuid(b.id, "id"); + const timelineId = assertUuid(b.timeline_id, "timeline_id"); + const clipId = assertUuid(b.clip_id, "clip_id"); + await assertAccess(ctx, "timeline", timelineId, "editor"); + const src = await env.DB.prepare("SELECT l.*, s.project_id FROM layers l JOIN shots s ON s.id = l.shot_id WHERE l.id = ?1").bind(from).first(); + if (!src) throw new HttpError(404, "layer not found"); + const t = await env.DB.prepare("SELECT project_id FROM timelines WHERE id = ?1").bind(timelineId).first<{ project_id: string }>(); + if (!t || t.project_id !== src.project_id) throw new HttpError(400, "the layer's shot is not in the timeline's project"); + if (await env.DB.prepare("SELECT 1 FROM layers WHERE id = ?1").bind(to).first()) throw new HttpError(409, "a layer with that id exists"); + await env.DB.prepare( + `INSERT INTO layers (id, shot_id, kind, name, description, position, visible, locked, opacity, space, photo_id, range, shapes, look, timeline_id, clip_id, updated_at) + SELECT ?1, shot_id, kind, name, description, position, visible, locked, opacity, space, photo_id, NULL, shapes, look, ?2, ?3, ?4 FROM layers WHERE id = ?5`, + ).bind(to, timelineId, clipId, new Date().toISOString(), from).run(); + return json({ layer: layerToApi((await one(env, to))!) }, 201); + }); + + /** + * A step's bake: multipart `metadata` { hash } + `image` (JPEG, PNG or WebP by its bytes). The key + * is a stored step of the shot, or one of its photos when it has no stored steps. Bakes of steps + * that are gone are removed on the way. Returns { shot }. + */ + r.on("PUT", "/api/shots/:id/bakes/:key", async (ctx, { id, key }) => { + const { env, request } = ctx; + const sid = assertUuid(id, "id"); + const k = assertUuid(key, "key"); + const ws = await assertAccess(ctx, "shot", sid, "editor"); + assertBodySize(request, MAX_BAKE_BYTES + 64 * 1024); + const ct = request.headers.get("content-type") ?? ""; + if (!ct.startsWith("multipart/form-data")) throw new HttpError(415, "expected multipart/form-data"); + const form = await request.formData(); + let hash: unknown; + try { hash = (JSON.parse(String(form.get("metadata") ?? "{}")) as { hash?: unknown }).hash; } catch { throw new HttpError(400, "metadata is not JSON"); } + if (typeof hash !== "string" || !/^[0-9a-f]{16}$/.test(hash)) throw new HttpError(400, "hash must be 16 hex characters"); + const image = form.get("image"); + if (!(image instanceof File) || image.size === 0 || image.size > MAX_BAKE_BYTES) throw new HttpError(413, "image missing, empty or too large"); + const type = await imageTypeOf(image, "image"); + if (!(await stepKeysOf(env, sid)).has(k)) throw new HttpError(400, "key is not a step of the shot"); + const old = await env.DB.prepare("SELECT render_key FROM step_bakes WHERE shot_id = ?1 AND step_key = ?2").bind(sid, k).first<{ render_key: string }>(); + const r2 = `bakes/${sid}/${k}-${hash}.${imageExt(type)}`; + await env.SHOTS_BUCKET.put(r2, image.stream(), { httpMetadata: { contentType: type }, customMetadata: { shot_id: sid, step_key: k, workspace_id: ws } }); + await env.DB.prepare( + `INSERT INTO step_bakes (shot_id, step_key, hash, render_key, updated_at) VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(shot_id, step_key) DO UPDATE SET hash = ?3, render_key = ?4, updated_at = ?5`, + ).bind(sid, k, hash, r2, new Date().toISOString()).run(); + if (old && old.render_key !== r2) await env.SHOTS_BUCKET.delete(old.render_key).catch(() => {}); + await pruneBakes(env, sid); + return json({ shot: await loadShot(env, sid) }); + }); + + r.on("GET", "/api/shots/:id/bakes/:key", async (ctx, { id, key }) => { + const { env, request } = ctx; + const sid = assertUuid(id, "id"); + const k = assertUuid(key, "key"); + await assertAccess(ctx, "shot", sid, "viewer"); + const row = await env.DB.prepare("SELECT render_key FROM step_bakes WHERE shot_id = ?1 AND step_key = ?2").bind(sid, k).first<{ render_key: string }>(); + if (!row) throw new HttpError(404, "no bake for this step"); + const obj = await env.SHOTS_BUCKET.get(row.render_key, { onlyIf: request.headers }); + if (!obj) throw new HttpError(404, "bake missing in storage"); + if (!("body" in obj) && preconditionFailed(request, obj)) throw new HttpError(412, "precondition failed"); + const headers = new Headers(); + obj.writeHttpMetadata(headers); + headers.set("etag", obj.httpEtag); + headers.set("cache-control", "private, max-age=31536000, immutable"); + headers.set("x-content-type-options", "nosniff"); + if (!("body" in obj) || !obj.body) return new Response(null, { status: 304, headers }); + return new Response(obj.body, { headers }); + }); +} + diff --git a/apps/worker/src/promote.ts b/apps/worker/src/promote.ts index 725b523..9d1d25e 100644 --- a/apps/worker/src/promote.ts +++ b/apps/worker/src/promote.ts @@ -9,13 +9,14 @@ import { withClips, type TimelineRow } from "./timelines.ts"; * Promotion (issue #31): what a timeline clip changed for itself becomes the shot's. * - framing: the clip's own re-frame becomes a saved framing of its photo (an identical one is * linked instead of duplicated); the clip then points at it. - * - overlay: the clip's own overlay becomes the photo's; the clip keeps pointing at it. + * - layer: one of the clip's own layers becomes a layer of the shot, on the clip's step (or on + * every step when the clip has none); the clip shows it from its step from then on. * - sketch: the clip's own sketch becomes a sketch of a shot in the project. That is a sketch * clip's sketch, or (`sketch_id`) the one a clip kept after a shot filled its spot. * The clip must be saved (the timeline autosaves); the response carries both sides. */ -interface Clip { id: string; photo_id: string | null; overlay_id: string | null; presentation: string | null; sketch_id: string | null } +interface Clip { id: string; photo_id: string | null; step_id: string | null; presentation: string | null; sketch_id: string | null } const EPS = 1e-4; const sameFrame = (a: Frame, b: Frame) => { const x = withCentre(a), y = withCentre(b); @@ -23,7 +24,7 @@ const sameFrame = (a: Frame, b: Frame) => { }; export function registerPromote(r: Router) { - /** JSON { what: "framing" | "overlay" | "sketch", name? (framing), shot_id? + sketch_id? (sketch) }. Returns { timeline, shot }. */ + /** JSON { what: "framing" | "layer" | "sketch", name? (framing), layer_id (layer), shot_id? + sketch_id? (sketch) }. Returns { timeline, shot }. */ r.on("POST", "/api/timelines/:id/clips/:clip/promote", async (ctx, { id, clip }) => { const { env, request } = ctx; const tid = assertUuid(id, "id"); @@ -33,7 +34,7 @@ export function registerPromote(r: Router) { const b = await readJson>(request); const t = await env.DB.prepare("SELECT * FROM timelines WHERE id = ?1").bind(tid).first(); if (!t) throw new HttpError(404, "timeline not found"); - const c = await env.DB.prepare("SELECT id, photo_id, overlay_id, presentation, sketch_id FROM timeline_clips WHERE id = ?1 AND timeline_id = ?2").bind(cid, tid).first(); + const c = await env.DB.prepare("SELECT id, photo_id, step_id, presentation, sketch_id FROM timeline_clips WHERE id = ?1 AND timeline_id = ?2").bind(cid, tid).first(); if (!c) throw new HttpError(404, "clip not found (is the timeline saved?)"); const now = new Date().toISOString(); let shotId: string; @@ -62,13 +63,14 @@ export function registerPromote(r: Router) { } const next: Presentation = { ...pres, frame, framing_id: fid, label: name }; await env.DB.prepare("UPDATE timeline_clips SET presentation = ?1 WHERE id = ?2").bind(JSON.stringify(next), cid).run(); - } else if (b.what === "overlay") { - if (!c.overlay_id) throw new HttpError(400, "the clip shows no overlay"); - const o = await env.DB.prepare("SELECT o.timeline_id, o.clip_id, o.photo_id, ph.shot_id FROM overlays o JOIN photos ph ON ph.id = o.photo_id WHERE o.id = ?1").bind(c.overlay_id).first<{ timeline_id: string | null; clip_id: string | null; photo_id: string; shot_id: string }>(); - if (!o || o.timeline_id !== tid || o.clip_id !== cid) throw new HttpError(409, "the overlay is not this clip's own"); - shotId = o.shot_id; - const position = (await env.DB.prepare("SELECT coalesce(max(position), -1) + 1 AS p FROM overlays WHERE photo_id = ?1 AND timeline_id IS NULL").bind(o.photo_id).first<{ p: number }>())?.p ?? 0; - await env.DB.prepare("UPDATE overlays SET timeline_id = NULL, clip_id = NULL, position = ?1, updated_at = ?2 WHERE id = ?3").bind(position, now, c.overlay_id).run(); + } else if (b.what === "layer") { + const layerId = assertUuid(b.layer_id, "layer_id"); + const l = await env.DB.prepare("SELECT shot_id, timeline_id, clip_id FROM layers WHERE id = ?1").bind(layerId).first<{ shot_id: string; timeline_id: string | null; clip_id: string | null }>(); + if (!l || l.timeline_id !== tid || l.clip_id !== cid) throw new HttpError(409, "the layer is not this clip's own"); + shotId = l.shot_id; + const position = (await env.DB.prepare("SELECT coalesce(max(position), -1) + 1 AS p FROM layers WHERE shot_id = ?1 AND timeline_id IS NULL").bind(shotId).first<{ p: number }>())?.p ?? 0; + await env.DB.prepare("UPDATE layers SET timeline_id = NULL, clip_id = NULL, range = ?1, position = ?2, updated_at = ?3 WHERE id = ?4") + .bind(c.step_id ? JSON.stringify([c.step_id]) : null, Math.min(position, 1000), now, layerId).run(); } else if (b.what === "sketch") { const sketchId = b.sketch_id === undefined || b.sketch_id === null ? c.sketch_id : assertUuid(b.sketch_id, "sketch_id"); if (!sketchId) throw new HttpError(400, "the clip has no sketch"); @@ -79,7 +81,7 @@ export function registerPromote(r: Router) { if (!k || k.timeline_id !== tid || k.clip_id !== cid) throw new HttpError(409, "the sketch is not this clip's own"); const position = (await env.DB.prepare("SELECT coalesce(max(position), -1) + 1 AS p FROM sketches WHERE shot_id = ?1").bind(shotId).first<{ p: number }>())?.p ?? 0; await env.DB.prepare("UPDATE sketches SET shot_id = ?1, timeline_id = NULL, clip_id = NULL, position = ?2, updated_at = ?3 WHERE id = ?4").bind(shotId, position, now, sketchId).run(); - } else throw new HttpError(400, "what must be framing, overlay or sketch"); + } else throw new HttpError(400, "what must be framing, layer or sketch"); await env.DB.prepare("UPDATE timelines SET updated_at = ?1 WHERE id = ?2").bind(now, tid).run(); const row = (await env.DB.prepare("SELECT * FROM timelines WHERE id = ?1").bind(tid).first())!; diff --git a/apps/worker/src/routes.ts b/apps/worker/src/routes.ts index 8dc473e..97ca339 100644 --- a/apps/worker/src/routes.ts +++ b/apps/worker/src/routes.ts @@ -13,6 +13,7 @@ import { registerFileRoutes } from "./files.ts"; import { registerFixtureRoutes } from "./fixtures.ts"; import { registerFramingRoutes } from "./framings.ts"; import type { Ctx } from "./index.ts"; +import { registerLayerRoutes } from "./layers.ts"; import { registerLayoutRoutes } from "./layouts.ts"; import { registerLocationRoutes } from "./locations.ts"; import { registerPresetRoutes } from "./presets.ts"; @@ -75,6 +76,7 @@ registerComposeRoutes(router); registerTimelineRoutes(router); registerFramingRoutes(router); registerStepRoutes(router); +registerLayerRoutes(router); registerLayoutRoutes(router); registerDeviceRoutes(router); registerPreviewRoutes(router); diff --git a/apps/worker/src/shots.ts b/apps/worker/src/shots.ts index a095531..cb6f41a 100644 --- a/apps/worker/src/shots.ts +++ b/apps/worker/src/shots.ts @@ -1,5 +1,6 @@ import { CAMERA_SUPPORTS, INT_EXT, isOneOf, LIGHT, PHOTO_SOURCES, MAX_DESCRIPTION, MOVEMENTS, fitExtra, patchExtra, pruneExtra, SHOT_SIZES, SHOT_STATES, validateExtra, validateValues, WEATHER, type Extra, type FieldDef } from "@openclapper/vocab"; -import { clipOverlaysLeaving, composeFor, deleteClipCompose, type OverlayApi, type SketchApi } from "./compose.ts"; +import { clipLayersLeaving, composeFor, deleteClipCompose, type SketchApi } from "./compose.ts"; +import { bakesFor, layersFor } from "./layers.ts"; import { framingsFor, type FramingApi } from "./framings.ts"; import { stepsFor } from "./steps.ts"; import { projectFieldDefs } from "./fields.ts"; @@ -103,11 +104,13 @@ function photoToApi(p: PhotoRow, framings: Map) { }; } -type Compose = Awaited> & { framings: Map; steps: Awaited> }; -/** Overlays, sketches, framings and steps of these shots in one go. */ +type Compose = Awaited> & { + framings: Map; steps: Awaited>; layers: Awaited>; bakes: Awaited>; +}; +/** Sketches, framings, steps, layers and bakes of these shots in one go. */ async function extrasFor(env: Ctx["env"], ids: string[]): Promise { - const [c, framings, steps] = await Promise.all([composeFor(env, ids), framingsFor(env, ids), stepsFor(env, ids)]); - return { ...c, framings, steps }; + const [c, framings, steps, layers, bakes] = await Promise.all([composeFor(env, ids), framingsFor(env, ids), stepsFor(env, ids), layersFor(env, ids), bakesFor(env, ids)]); + return { ...c, framings, steps, layers, bakes }; } function shotToApi(s: ShotRow, photos: PhotoRow[], compose: Compose) { @@ -139,10 +142,12 @@ function shotToApi(s: ShotRow, photos: PhotoRow[], compose: Compose) { created_at: s.created_at, updated_at: s.updated_at, photos: photos.map((p) => photoToApi(p, compose.framings)), - overlays: (compose.overlays.get(s.id) ?? []) as OverlayApi[], sketches: (compose.sketches.get(s.id) ?? []) as SketchApi[], /** Stored steps (issue #266), raw: clients resolve them with `stepsOf`; empty = the photos are the steps. */ steps: compose.steps.get(s.id) ?? [], + /** The composition (epic #275): the shot's own layers in stack order (without shapes) and the stored bakes per step. */ + layers: compose.layers.get(s.id) ?? [], + bakes: compose.bakes.get(s.id) ?? [], }; } @@ -499,8 +504,8 @@ export function registerShotRoutes(r: Router) { env.DB.prepare("DELETE FROM timeline_clips WHERE photo_id IN (SELECT id FROM photos WHERE shot_id = ?1) AND timeline_id IN (SELECT id FROM timelines WHERE project_id != ?2)").bind(sid, movedTo), ...fileRefCleanup(env, "shot", JSON.stringify([sid]), movedTo), ]); - // Their clips' own overlays go with them (issue #31). - if (movedTo) await deleteClipCompose(env, clipOverlaysLeaving(env, JSON.stringify([sid]), movedTo)); + // Their clips' own layers go with them (issues #31, #275). + if (movedTo) await deleteClipCompose(env, clipLayersLeaving(env, JSON.stringify([sid]), movedTo)); return json({ shot: await loadShot(env, sid) }); }); @@ -598,7 +603,7 @@ export function registerShotRoutes(r: Router) { stmts.push(...fileRefCleanup(env, "shot", list, movedTo)); } await env.DB.batch(stmts); - if (movedTo) await deleteClipCompose(env, clipOverlaysLeaving(env, list, movedTo)); + if (movedTo) await deleteClipCompose(env, clipLayersLeaving(env, list, movedTo)); const { results } = await env.DB.prepare(`${SHOT_SQL} WHERE s.id IN (SELECT value FROM json_each(?1))`).bind(list).all(); const [photos, compose] = await Promise.all([photosFor(env, ids), extrasFor(env, ids)]); diff --git a/apps/worker/src/steps.ts b/apps/worker/src/steps.ts index 78673bc..1c2a446 100644 --- a/apps/worker/src/steps.ts +++ b/apps/worker/src/steps.ts @@ -1,13 +1,13 @@ -import { stepsProblem, validateStepInput, type ShotStep, type StepInput } from "@openclapper/vocab"; +import { layersAt, stepsProblem, validateStepInput, type ShotStep, type StepInput } from "@openclapper/vocab"; import { assertUuid, HttpError, json, readJson, type Router } from "@openclapper/service-kit"; -import { copyRender } from "./compose.ts"; import { fileRefCleanup } from "./files.ts"; import type { Ctx } from "./index.ts"; +import { bakeKeysOf, layerToApi, pruneBakes } from "./layers.ts"; import { loadShot, MAX_PHOTOS_PER_SHOT, objectKey } from "./shots.ts"; import { assertAccess } from "./tenancy.ts"; /** - * Steps (issue #266): a sequence as a progression, see packages/vocab/src/steps.ts. Studio only + * Steps (issue #266): the time axis of a shot's composition (epic #275), see packages/vocab/src/steps.ts. Studio only * (interactive, strict). Shots embed their rows (`stepsFor`); the clients resolve them with * `stepsOf`. This module also moves photos between shots (the merge behind "Make a sequence") * and copies one photo out as a shot of its own ("Extract"), the two ways a progression is built @@ -19,7 +19,7 @@ export async function stepsFor(env: Ctx["env"], shotIds: string[]): Promise(); if (shotIds.length === 0) return out; const { results } = await env.DB.prepare( - "SELECT id, shot_id, ordinal, photo_id, framing_id, overlay_id FROM shot_steps WHERE shot_id IN (SELECT value FROM json_each(?1)) ORDER BY shot_id, ordinal", + "SELECT id, shot_id, ordinal, photo_id, framing_id FROM shot_steps WHERE shot_id IN (SELECT value FROM json_each(?1)) ORDER BY shot_id, ordinal", ).bind(JSON.stringify(shotIds)).all(); for (const s of results) out.set(s.shot_id, [...(out.get(s.shot_id) ?? []), s]); return out; @@ -34,18 +34,10 @@ async function liveShot(env: Ctx["env"], id: string): Promise<{ project_id: stri return row; } -/** What steps may reference: the shot's photos, their framings, their shot overlays (clip-owned ones marked). */ +/** What steps may reference: the shot's photos and their framings. */ async function referable(env: Ctx["env"], photoIds: string[]) { - const j = list(photoIds); - const [framings, overlays] = await Promise.all([ - env.DB.prepare("SELECT id, photo_id FROM framings WHERE photo_id IN (SELECT value FROM json_each(?1))").bind(j).all<{ id: string; photo_id: string }>(), - env.DB.prepare("SELECT id, photo_id, timeline_id FROM overlays WHERE photo_id IN (SELECT value FROM json_each(?1))").bind(j).all<{ id: string; photo_id: string; timeline_id: string | null }>(), - ]); - return { - photos: new Set(photoIds), - framingPhoto: new Map(framings.results.map((f) => [f.id, f.photo_id])), - overlayPhoto: new Map(overlays.results.map((o) => [o.id, { photo_id: o.photo_id, clip_owned: o.timeline_id !== null }])), - }; + const framings = await env.DB.prepare("SELECT id, photo_id FROM framings WHERE photo_id IN (SELECT value FROM json_each(?1))").bind(list(photoIds)).all<{ id: string; photo_id: string }>(); + return { photos: new Set(photoIds), framingPhoto: new Map(framings.results.map((f) => [f.id, f.photo_id])) }; } /** The body's `steps`, checked structurally and against what the shot (will) hold. Undefined when absent. */ @@ -63,13 +55,17 @@ async function parseSteps(env: Ctx["env"], v: unknown, photoIds: string[]): Prom function replaceSteps(env: Ctx["env"], shotId: string, steps: StepInput[]): D1PreparedStatement[] { return [ env.DB.prepare("DELETE FROM shot_steps WHERE shot_id = ?1").bind(shotId), - ...steps.map((s, i) => env.DB.prepare("INSERT INTO shot_steps (id, shot_id, ordinal, photo_id, framing_id, overlay_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6)") - .bind(s.id, shotId, i, s.photo_id, s.framing_id ?? null, s.overlay_id ?? null)), + ...steps.map((s, i) => env.DB.prepare("INSERT INTO shot_steps (id, shot_id, ordinal, photo_id, framing_id) VALUES (?1, ?2, ?3, ?4, ?5)") + .bind(s.id, shotId, i, s.photo_id, s.framing_id ?? null)), ]; } export function registerStepRoutes(r: Router) { - /** `{ steps: [{ id, photo_id, framing_id?, overlay_id? }] }` replaces the list; `[]` = the photos are the steps again. Returns `{ shot }`. */ + /** + * `{ steps: [{ id, photo_id, framing_id? }] }` replaces the list; `[]` = the photos are the steps + * again. Layer ranges keep ids of removed steps (they show nowhere then); bakes of removed steps go. + * Returns `{ shot }`. + */ r.on("PUT", "/api/shots/:id/steps", async (ctx, { id }) => { const { env, request } = ctx; const sid = assertUuid(id, "id"); @@ -79,15 +75,17 @@ export function registerStepRoutes(r: Router) { const photoIds = (await env.DB.prepare("SELECT id FROM photos WHERE shot_id = ?1").bind(sid).all<{ id: string }>()).results.map((p) => p.id); const steps = await parseSteps(env, b.steps ?? [], photoIds); await env.DB.batch([...replaceSteps(env, sid, steps ?? []), env.DB.prepare("UPDATE shots SET updated_at = ?1 WHERE id = ?2").bind(new Date().toISOString(), sid)]); + await pruneBakes(env, sid); return json({ shot: await loadShot(env, sid) }); }); /** * Move photos of other shots into this one (the merge behind "Make a sequence"): `{ photo_ids, steps? }`. * The photos must be in live shots of the same project; they are appended in the given order with - * their framings and overlays (photo-keyed, so nothing to do). A source shot left without photos is - * deleted for good, its sketches moved here (a photo-less shot in the bin could not be restored to - * anything). Timeline clips of the moved photos leave their sequence blocks (a block holds one + * their framings (photo-keyed) and their photo-space layers (on every step of the photo now), and + * their clips' own layers follow the clips. A source shot left without photos is deleted for good: + * its sketches move here, its frame-space layers too (hidden, to switch on where wanted), since a + * photo-less shot in the bin could not be restored to anything. Timeline clips of the moved photos leave their sequence blocks (a block holds one * shot's photos; the Studio regroups what it is working on). Returns `{ shot, removed_shot_ids }`. */ r.on("POST", "/api/shots/:id/photos", async (ctx, { id }) => { @@ -123,9 +121,16 @@ export function registerStepRoutes(r: Router) { env.DB.prepare("DELETE FROM shot_steps WHERE photo_id IN (SELECT value FROM json_each(?1))").bind(list(moving)), env.DB.prepare("UPDATE timeline_clips SET group_id = NULL WHERE photo_id IN (SELECT value FROM json_each(?1))").bind(list(moving)), ]; + stmts.push( + env.DB.prepare("UPDATE layers SET shot_id = ?1, range = NULL, updated_at = ?2 WHERE photo_id IN (SELECT value FROM json_each(?3))").bind(sid, now, list(moving)), + env.DB.prepare("UPDATE layers SET shot_id = ?1, updated_at = ?2 WHERE timeline_id IS NOT NULL AND clip_id IN (SELECT id FROM timeline_clips WHERE photo_id IN (SELECT value FROM json_each(?3)))").bind(sid, now, list(moving)), + ); + const staleBakes: string[] = []; + for (const e of emptied) staleBakes.push(...(await bakeKeysOf(env, e))); if (emptied.length) { const j = list(emptied); stmts.push( + env.DB.prepare("UPDATE layers SET shot_id = ?1, visible = 0, range = NULL, updated_at = ?2 WHERE shot_id IN (SELECT value FROM json_each(?3))").bind(sid, now, j), env.DB.prepare("UPDATE sketches SET shot_id = ?1, position = position + ?2 WHERE shot_id IN (SELECT value FROM json_each(?3))").bind(sid, sketchBase, j), env.DB.prepare("DELETE FROM day_shots WHERE shot_id IN (SELECT value FROM json_each(?1))").bind(j), ...fileRefCleanup(env, "shot", j), @@ -135,16 +140,19 @@ export function registerStepRoutes(r: Router) { stmts.push(env.DB.prepare("UPDATE shots SET captured_at = (SELECT min(timestamp) FROM photos WHERE shot_id = ?1), updated_at = ?2 WHERE id = ?1").bind(sid, now)); if (steps) stmts.push(...replaceSteps(env, sid, steps)); await env.DB.batch(stmts); + if (staleBakes.length) await env.SHOTS_BUCKET.delete(staleBakes).catch(() => {}); + for (const sh of [sid, ...sources.filter((x) => keep.has(x))]) await pruneBakes(env, sh); return json({ shot: await loadShot(env, sid), removed_shot_ids: emptied }); }); /** - * Copy one photo out as a shot of its own ("Extract", a copy, never a move): `{ id?, framing_id?, overlay_id? }`. + * Copy one photo out as a shot of its own ("Extract", a copy, never a move): `{ id?, framing_id?, step_id? }`. * The new shot (`id`, else a fresh one) takes the tags, description and state; the photo row is * copied with its own R2 object (one object per photo stays the rule, so neither shot's delete - * touches the other), and so are its framings and shot overlays with their renders. `framing_id` - * (a framing of the photo, say the step's) becomes the copy's root, else the photo's root carries - * over. No link back. Returns `{ shot }`, 201. + * touches the other), and so are its framings. The layers the step shows (`step_id`, a stored + * step of that photo; else the photo as a step) are copied onto every step of the new shot. + * `framing_id` (a framing of the photo, say the step's) becomes the copy's root, else the photo's + * root carries over. No link back; the Studio bakes the copy. Returns `{ shot }`, 201. */ r.on("POST", "/api/shots/:id/photos/:photo/extract", async (ctx, { id, photo }) => { const { env, request } = ctx; @@ -155,18 +163,21 @@ export function registerStepRoutes(r: Router) { const b = await readJson>(request); const newShot = b.id === undefined || b.id === null ? crypto.randomUUID() : assertUuid(b.id, "id"); const framingId = b.framing_id == null ? null : assertUuid(b.framing_id, "framing_id"); - const overlayId = b.overlay_id == null ? null : assertUuid(b.overlay_id, "overlay_id"); + const stepId = b.step_id == null ? null : assertUuid(b.step_id, "step_id"); if (await env.DB.prepare("SELECT 1 FROM shots WHERE id = ?1").bind(newShot).first()) throw new HttpError(409, "a shot with this id exists"); const src = await env.DB.prepare("SELECT * FROM photos WHERE id = ?1 AND shot_id = ?2").bind(pid, sid).first & { ordinal: number; r2_object_key: string; root_framing_id: string | null; timestamp: string }>(); if (!src) throw new HttpError(404, "photo not found"); const shot = (await env.DB.prepare("SELECT * FROM shots WHERE id = ?1").bind(sid).first & { name: string | null }>())!; - const [framings, overlays] = await Promise.all([ + const [framings, layerRows] = await Promise.all([ env.DB.prepare("SELECT * FROM framings WHERE photo_id = ?1 ORDER BY position, created_at").bind(pid).all & { id: string; frame: string }>(), - env.DB.prepare("SELECT * FROM overlays WHERE photo_id = ?1 AND timeline_id IS NULL ORDER BY position, created_at").bind(pid).all & { id: string; render_key: string | null; presentation: string }>(), + env.DB.prepare("SELECT * FROM layers WHERE shot_id = ?1 AND timeline_id IS NULL").bind(sid).all & Parameters[0]>(), ]); if (framingId && !framings.results.some((f) => f.id === framingId)) throw new HttpError(400, "framing_id is not a framing of this photo"); - if (overlayId && !overlays.results.some((o) => o.id === overlayId)) throw new HttpError(400, "overlay_id is not an overlay of this photo"); + if (stepId && !(await env.DB.prepare("SELECT 1 FROM shot_steps WHERE id = ?1 AND shot_id = ?2 AND photo_id = ?3").bind(stepId, sid, pid).first())) throw new HttpError(400, "step_id is not a step of this photo"); + // The layers the step shows, by the same rule the clients use. + const shown = new Set(layersAt(layerRows.results.map((r) => layerToApi(r)), stepId ? { id: stepId, stored: true, photo: { id: pid } } : { id: `photo:${pid}`, stored: false, photo: { id: pid } }).map((l) => l.id)); + const layers = layerRows.results.filter((l) => shown.has(l.id)); const obj = await env.SHOTS_BUCKET.get(src.r2_object_key); if (!obj) throw new HttpError(404, "image missing in storage"); @@ -175,15 +186,8 @@ export function registerStepRoutes(r: Router) { const key = objectKey(newPhoto, ext); const written: string[] = [key]; const framingIds = new Map(framings.results.map((f) => [f.id, crypto.randomUUID()])); - const overlayIds = new Map(overlays.results.map((o) => [o.id, crypto.randomUUID()])); try { await env.SHOTS_BUCKET.put(key, obj.body, { httpMetadata: obj.httpMetadata, customMetadata: { shot_id: newShot, photo_id: newPhoto, workspace_id: ws } }); - const renders = new Map(); - for (const o of overlays.results) { - const k = await copyRender(env, "overlays", overlayIds.get(o.id)!, o.render_key); - if (k) written.push(k); - renders.set(o.id, k); - } const now = new Date().toISOString(); const name = shot.name ? `${shot.name} · photo ${src.ordinal + 1}`.slice(0, 120) : null; const root = framingId ?? src.root_framing_id; @@ -197,11 +201,7 @@ export function registerStepRoutes(r: Router) { insertCopy("shots", shot, { id: newShot, name, captured_at: src.timestamp, created_at: now, updated_at: now, deleted_at: null, deleted_with: null }, []), insertCopy("photos", src, { id: newPhoto, shot_id: newShot, ordinal: 0, r2_object_key: key, root_framing_id: null, created_at: now }, []), ...framings.results.map((f) => insertCopy("framings", f, { id: framingIds.get(f.id), photo_id: newPhoto, created_at: now, updated_at: now }, [])), - ...overlays.results.map((o) => { - const pres = JSON.parse(o.presentation) as { framing_id?: string | null }; - if (pres.framing_id) pres.framing_id = framingIds.get(pres.framing_id) ?? null; - return insertCopy("overlays", o, { id: overlayIds.get(o.id), photo_id: newPhoto, presentation: JSON.stringify(pres), render_key: renders.get(o.id) ?? null, created_at: now, updated_at: now }, []); - }), + ...layers.map((l) => insertCopy("layers", l, { id: crypto.randomUUID(), shot_id: newShot, photo_id: l.photo_id ? newPhoto : null, range: null, created_at: now, updated_at: now }, [])), ]; if (root && framingIds.has(root)) stmts.push(env.DB.prepare("UPDATE photos SET root_framing_id = ?1 WHERE id = ?2").bind(framingIds.get(root), newPhoto)); await env.DB.batch(stmts); diff --git a/apps/worker/src/tenancy.ts b/apps/worker/src/tenancy.ts index 367e04d..e1f551d 100644 --- a/apps/worker/src/tenancy.ts +++ b/apps/worker/src/tenancy.ts @@ -53,8 +53,7 @@ const WORKSPACE_OF = { day: "SELECT p.workspace_id AS ws FROM shooting_days d JOIN projects p ON p.id = d.project_id WHERE d.id = ?1", timeline: "SELECT p.workspace_id AS ws FROM timelines t JOIN projects p ON p.id = t.project_id WHERE t.id = ?1", folder: "SELECT p.workspace_id AS ws FROM folders f JOIN projects p ON p.id = f.project_id WHERE f.id = ?1", - overlay: `SELECT p.workspace_id AS ws FROM overlays o JOIN photos ph ON ph.id = o.photo_id JOIN shots s ON s.id = ph.shot_id - JOIN projects p ON p.id = s.project_id WHERE o.id = ?1`, + layer: "SELECT p.workspace_id AS ws FROM layers l JOIN shots s ON s.id = l.shot_id JOIN projects p ON p.id = s.project_id WHERE l.id = ?1", // A sketch is on a shot, or a timeline clip's own. sketch: `SELECT p.workspace_id AS ws FROM sketches k LEFT JOIN shots s ON s.id = k.shot_id LEFT JOIN timelines t ON t.id = k.timeline_id JOIN projects p ON p.id = coalesce(s.project_id, t.project_id) WHERE k.id = ?1`, diff --git a/apps/worker/src/timelines.ts b/apps/worker/src/timelines.ts index cd75911..c734727 100644 --- a/apps/worker/src/timelines.ts +++ b/apps/worker/src/timelines.ts @@ -4,13 +4,14 @@ import type { Ctx } from "./index.ts"; import { accessIfExists, assertAccess, assertProjectRef, inWorkspaces } from "./tenancy.ts"; import { binResource } from "./trash.ts"; import { clipComposeFor, deleteClipCompose } from "./compose.ts"; +import { clipLayersFor } from "./layers.ts"; import { registerPromote } from "./promote.ts"; /** * Timelines (issue #12, part 2): a project's rough cuts out of scouted photos. Studio only. * Client-owned ids; PUT replaces the whole timeline like a shooting day. Clips whose photo left the - * project (or was deleted) are dropped silently; an overlay that does not belong to the clip's photo - * is nulled. A clip without a photo is a placeholder (issue #31): a title holds the spot until a + * project (or was deleted) are dropped silently. A photo clip shows a step of its shot (`step_id`, + * epic #275: the step's layers come along; a step of another photo is nulled) plus its own layers. A clip without a photo is a placeholder (issue #31): a title holds the spot until a * shot is dropped onto it. A sequence's clips form a block (issue #37): `groups` on the timeline * holds each block's length settings, `group_id` on the clips the membership; a block's clips sit * together and show one shot, and a "total" block's durations are recomputed here on every save. @@ -18,12 +19,12 @@ import { registerPromote } from "./promote.ts"; */ export interface TimelineRow { id: string; project_id: string; name: string; notes: string | null; lock_mode: string | null; groups: string; created_at: string; updated_at: string | null } -interface ClipRow { id: string; timeline_id: string; position: number; photo_id: string | null; shot_id: string | null; overlay_id: string | null; presentation: string | null; duration_ms: number; notes: string | null; title: string | null; sketch_id: string | null; group_id: string | null } +interface ClipRow { id: string; timeline_id: string; position: number; photo_id: string | null; shot_id: string | null; step_id: string | null; presentation: string | null; duration_ms: number; notes: string | null; title: string | null; sketch_id: string | null; group_id: string | null } const MAX_CLIPS = 500; const MIN_MS = 100, MAX_MS = 3_600_000; -const clipToApi = (c: ClipRow) => ({ id: c.id, photo_id: c.photo_id, shot_id: c.shot_id, overlay_id: c.overlay_id, presentation: c.presentation === null ? null : JSON.parse(c.presentation) as unknown, duration_ms: c.duration_ms, notes: c.notes, title: c.title, sketch_id: c.sketch_id, group_id: c.group_id }); +const clipToApi = (c: ClipRow) => ({ id: c.id, photo_id: c.photo_id, shot_id: c.shot_id, step_id: c.step_id, presentation: c.presentation === null ? null : JSON.parse(c.presentation) as unknown, duration_ms: c.duration_ms, notes: c.notes, title: c.title, sketch_id: c.sketch_id, group_id: c.group_id }); export async function withClips(env: Ctx["env"], rows: TimelineRow[]) { if (rows.length === 0) return []; @@ -31,17 +32,17 @@ export async function withClips(env: Ctx["env"], rows: TimelineRow[]) { `SELECT c.*, ph.shot_id FROM timeline_clips c LEFT JOIN photos ph ON ph.id = c.photo_id WHERE c.timeline_id IN (SELECT value FROM json_each(?1)) ORDER BY c.timeline_id, c.position`, ).bind(JSON.stringify(rows.map((t) => t.id))).all(); - const own = await clipComposeFor(env, rows.map((t) => t.id)); - // The clips' own overlays and sketches (issue #31) come along, without drawings. - return rows.map((t) => ({ ...t, groups: JSON.parse(t.groups) as ClipGroup[], clips: results.filter((c) => c.timeline_id === t.id).map(clipToApi), overlays: own.overlays.get(t.id) ?? [], sketches: own.sketches.get(t.id) ?? [] })); + const ids = rows.map((t) => t.id); + const [own, layers] = await Promise.all([clipComposeFor(env, ids), clipLayersFor(env, ids)]); + // The clips' own layers and sketches (issues #31, #275) come along, without shapes or drawings. + return rows.map((t) => ({ ...t, groups: JSON.parse(t.groups) as ClipGroup[], clips: results.filter((c) => c.timeline_id === t.id).map(clipToApi), layers: layers.get(t.id) ?? [], sketches: own.sketches.get(t.id) ?? [] })); } -/** Reads + deletes for the clip-owned overlays and sketches matching `where` (on both tables). */ +/** Reads + deletes for the clip-owned layers and sketches matching `where` (on both tables); sketches have renders. */ export function ownedBy(env: Ctx["env"], where: string, args: unknown[]) { return { - overlays: env.DB.prepare(`SELECT render_key AS k FROM overlays WHERE ${where}`).bind(...args), - sketches: env.DB.prepare(`SELECT render_key AS k FROM sketches WHERE ${where}`).bind(...args), - deletes: [env.DB.prepare(`DELETE FROM overlays WHERE ${where}`).bind(...args), env.DB.prepare(`DELETE FROM sketches WHERE ${where}`).bind(...args)], + reads: [env.DB.prepare(`SELECT render_key AS k FROM sketches WHERE ${where}`).bind(...args)], + deletes: [env.DB.prepare(`DELETE FROM layers WHERE ${where}`).bind(...args), env.DB.prepare(`DELETE FROM sketches WHERE ${where}`).bind(...args)], }; } @@ -71,7 +72,7 @@ export function registerTimelineRoutes(r: Router) { return json({ timeline: (await withClips(ctx.env, [row]))[0] }); }); - /** JSON { project_id, name, notes?, lock_mode?, groups?, clips: [...] } — the whole timeline. `groups` are the sequence blocks' settings; a photo clip may carry `group_id`. `lock_mode` forces one presentation mode on every clip (null = per clip). A clip is { id, photo_id, overlay_id?, presentation, duration_ms, notes? } or a placeholder { id, title, duration_ms, notes? }. */ + /** JSON { project_id, name, notes?, lock_mode?, groups?, clips: [...] } — the whole timeline. `groups` are the sequence blocks' settings; a photo clip may carry `group_id`. `lock_mode` forces one presentation mode on every clip (null = per clip). A clip is { id, photo_id, step_id?, presentation, duration_ms, notes? } or a placeholder { id, title, duration_ms, notes? }. */ r.on("PUT", "/api/timelines/:id", async (ctx, { id }) => { const { env, request } = ctx; const tid = assertUuid(id, "id"); @@ -104,16 +105,16 @@ export function registerTimelineRoutes(r: Router) { }; if (base.group_id && !groups.has(base.group_id)) throw new HttpError(400, `clips[${i}].group_id is not in groups`); if (o.photo_id === undefined || o.photo_id === null) { - // A placeholder holds the spot: no photo, overlay or presentation, but a title. With a sketch it is a sketch clip. + // A placeholder holds the spot: no photo, step or presentation, but a title. With a sketch it is a sketch clip. const sketchId = o.sketch_id === undefined || o.sketch_id === null ? null : assertUuid(o.sketch_id, `clips[${i}].sketch_id`); - return { ...base, photo_id: null, overlay_id: null, presentation: null, title: assertString(o.title, `clips[${i}].title`, 120), sketch_id: sketchId as string | null }; + return { ...base, photo_id: null, step_id: null, presentation: null, title: assertString(o.title, `clips[${i}].title`, 120), sketch_id: sketchId as string | null }; } const perr = validatePresentation(o.presentation); if (perr) throw new HttpError(400, `clips[${i}].presentation: ${perr}`); return { ...base, photo_id: assertUuid(o.photo_id, `clips[${i}].photo_id`) as string | null, - overlay_id: o.overlay_id === undefined || o.overlay_id === null ? null : assertUuid(o.overlay_id, `clips[${i}].overlay_id`), + step_id: (o.step_id === undefined || o.step_id === null ? null : assertUuid(o.step_id, `clips[${i}].step_id`)) as string | null, presentation: JSON.stringify(o.presentation) as string | null, title: null as string | null, sketch_id: null as string | null, @@ -126,18 +127,15 @@ export function registerTimelineRoutes(r: Router) { const photoIdList = [...new Set(clips.map((c) => c.photo_id).filter((x): x is string => !!x))]; const shotOf = new Map(); if (photoIdList.length) { - // Photos deleted or moved out of the project meanwhile are dropped (placeholders have none); an overlay of another photo is nulled. + // Photos deleted or moved out of the project meanwhile are dropped (placeholders have none); a step of another photo is nulled. for (const x of (await env.DB.prepare("SELECT ph.id, ph.shot_id FROM photos ph JOIN shots s ON s.id = ph.shot_id WHERE s.project_id = ?1 AND s.deleted_at IS NULL AND ph.id IN (SELECT value FROM json_each(?2))") .bind(projectId, JSON.stringify(photoIdList)).all<{ id: string; shot_id: string }>()).results) shotOf.set(x.id, x.shot_id); clips = clips.filter((c) => c.photo_id === null || shotOf.has(c.photo_id)); - const overlayIds = [...new Set(clips.map((c) => c.overlay_id).filter((x): x is string => !!x))]; - if (overlayIds.length) { - // A clip may show its photo's shot overlays or its own one, never another clip's. - const rows = new Map((await env.DB.prepare("SELECT id, photo_id, timeline_id, clip_id FROM overlays WHERE id IN (SELECT value FROM json_each(?1))").bind(JSON.stringify(overlayIds)).all<{ id: string; photo_id: string; timeline_id: string | null; clip_id: string | null }>()).results.map((o) => [o.id, o])); - for (const c of clips) { - const o = c.overlay_id ? rows.get(c.overlay_id) : undefined; - if (c.overlay_id && (!o || o.photo_id !== c.photo_id || (o.timeline_id !== null && (o.timeline_id !== tid || o.clip_id !== c.id)))) c.overlay_id = null; - } + const stepIds = [...new Set(clips.map((c) => c.step_id).filter((x): x is string => !!x))]; + if (stepIds.length) { + // A clip shows a stored step of its own photo; anything else (deleted, another photo) falls back to none. + const steps = new Map((await env.DB.prepare("SELECT id, photo_id FROM shot_steps WHERE id IN (SELECT value FROM json_each(?1))").bind(JSON.stringify(stepIds)).all<{ id: string; photo_id: string }>()).results.map((x) => [x.id, x.photo_id])); + for (const c of clips) if (c.step_id && steps.get(c.step_id) !== c.photo_id) c.step_id = null; } } const sketchIds = [...new Set(clips.map((c) => c.sketch_id).filter((x): x is string => !!x))]; @@ -158,7 +156,7 @@ export function registerTimelineRoutes(r: Router) { const members = clips.filter((c) => c.group_id === g.id); splitDuration(g.total_ms, members.length, g.split, g.edge_weight).forEach((ms, i) => { members[i].duration_ms = ms; }); } - // Clips that leave the timeline take their own overlays and sketches along (clips not yet saved have none). + // Clips that leave the timeline take their own layers and sketches along (clips not yet saved have none). const before = (await env.DB.prepare("SELECT id FROM timeline_clips WHERE timeline_id = ?1").bind(tid).all<{ id: string }>()).results.map((x) => x.id); const kept = new Set(clips.map((c) => c.id)); const removed = JSON.stringify(before.filter((x) => !kept.has(x))); @@ -169,8 +167,8 @@ export function registerTimelineRoutes(r: Router) { ON CONFLICT(id) DO UPDATE SET project_id = ?2, name = ?3, notes = ?4, lock_mode = ?5, groups = ?7, updated_at = ?6`, ).bind(tid, projectId, name, notes, lockMode, now, JSON.stringify(used)), env.DB.prepare("DELETE FROM timeline_clips WHERE timeline_id = ?1").bind(tid), - ...clips.map((c, i) => env.DB.prepare("INSERT INTO timeline_clips (id, timeline_id, position, photo_id, overlay_id, presentation, duration_ms, notes, title, sketch_id, group_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)") - .bind(c.id, tid, i, c.photo_id, c.overlay_id, c.presentation, c.duration_ms, c.notes, c.title, c.sketch_id, c.group_id)), + ...clips.map((c, i) => env.DB.prepare("INSERT INTO timeline_clips (id, timeline_id, position, photo_id, step_id, presentation, duration_ms, notes, title, sketch_id, group_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)") + .bind(c.id, tid, i, c.photo_id, c.step_id, c.presentation, c.duration_ms, c.notes, c.title, c.sketch_id, c.group_id)), ]); if (removed !== "[]") await deleteClipCompose(env, ownedBy(env, "timeline_id = ?1 AND clip_id IN (SELECT value FROM json_each(?2))", [tid, removed])); const row = await env.DB.prepare("SELECT * FROM timelines WHERE id = ?1").bind(tid).first(); diff --git a/apps/worker/src/trash.ts b/apps/worker/src/trash.ts index f96ae84..ca41507 100644 --- a/apps/worker/src/trash.ts +++ b/apps/worker/src/trash.ts @@ -21,13 +21,12 @@ import { ownedBy } from "./timelines.ts"; const now = () => new Date().toISOString(); const ids = (list: readonly string[]) => JSON.stringify(list); -/** Statements removing a shot's timeline clips (and the clips' own overlays) and day places. */ +/** Statements removing a shot's timeline clips (and the clips' own layers) and day places. */ async function shotsLeave(env: Env, shotIds: string): Promise { const onPhotos = "photo_id IN (SELECT id FROM photos WHERE shot_id IN (SELECT value FROM json_each(?1)))"; await deleteClipCompose(env, { - overlays: env.DB.prepare(`SELECT render_key AS k FROM overlays WHERE ${onPhotos} AND timeline_id IS NOT NULL`).bind(shotIds), - sketches: null, - deletes: [env.DB.prepare(`DELETE FROM overlays WHERE ${onPhotos} AND timeline_id IS NOT NULL`).bind(shotIds)], + reads: [], + deletes: [env.DB.prepare("DELETE FROM layers WHERE shot_id IN (SELECT value FROM json_each(?1)) AND timeline_id IS NOT NULL").bind(shotIds)], }); return [ env.DB.prepare(`DELETE FROM timeline_clips WHERE ${onPhotos}`).bind(shotIds), @@ -154,9 +153,9 @@ async function purgeFiles(env: Env, type: FileResourceType, list: string[]): Pro keys = (await env.DB.prepare("SELECT r2_object_key AS k FROM photos WHERE shot_id IN (SELECT value FROM json_each(?1))").bind(j).all<{ k: string }>()).results.map((r) => r.k); for (const sid of chunk) keys.push(...(await renderKeysOf(env, sid))); } - // A timeline's clips own overlays and sketches with renders of their own (issue #31). + // A timeline's clips own layers, and sketches with renders of their own (issues #31, #275). if (type === "timeline") await deleteClipCompose(env, ownedBy(env, "timeline_id IN (SELECT value FROM json_each(?1))", [j])); - // Photos, overlays, sketches, clips and spots cascade; placements and shortcuts go here. + // Photos, layers, bakes, sketches, clips and spots cascade; placements and shortcuts go here. await env.DB.batch([env.DB.prepare(`DELETE FROM ${table} WHERE deleted_at IS NOT NULL AND id IN (SELECT value FROM json_each(?1))`).bind(j), ...fileRefCleanup(env, type, j)]); if (keys.length) await env.SHOTS_BUCKET.delete(keys); } diff --git a/apps/worker/test/routes/helpers.ts b/apps/worker/test/routes/helpers.ts index 7acab92..008c917 100644 --- a/apps/worker/test/routes/helpers.ts +++ b/apps/worker/test/routes/helpers.ts @@ -84,7 +84,7 @@ export interface World { user: User; mark: string; workspace: string; member: string; memberEmail: string; project: string; preset: string; location: string; spot: string; field: string; view: string; binnedView: string; shot: string; photo: string; shot2: string; photo2: string; step: string; day: string; framing: string; timeline: string; clip: string; - overlay: string; sketch: string; folder: string; desktop: string; device: string; layout: string; + layer: string; sketch: string; folder: string; desktop: string; device: string; layout: string; meshNetwork: string; fixture: string; } @@ -92,14 +92,23 @@ const DEVICE_SPECS = { v: 1, model: "Test phone", viewport: { portrait: { width: export const FRAME = { width_fraction: 0.5, height_fraction: 0.5 }; export const deviceBody = (name: string) => ({ name, specs: DEVICE_SPECS }); export const layoutBody = (name: string) => ({ name, layout: DEFAULT_VIEWFINDER_LAYOUT }); -export const overlayBody = (photo: string, name: string) => ({ photo_id: photo, name, drawing: emptyDrawing(true), presentation: { mode: "fit" } }); +/** A drawing layer of a shot (epic #275); `extra` overrides (space, photo_id, range, clip owner…). */ +export const layerBody = (shot: string, name: string, extra: Record = {}) => + ({ shot_id: shot, kind: "drawing", name, position: 0, visible: true, locked: false, opacity: 1, space: "frame", shapes: [], ...extra }); +/** A step's bake: metadata with the hash, and an image. */ +export function bakeForm(hash = "0123456789abcdef"): FormData { + const form = new FormData(); + form.set("metadata", JSON.stringify({ hash })); + form.set("image", png("bake")); + return form; +} export const sketchBody = (shot: string, name: string) => ({ shot_id: shot, name, drawing: emptyDrawing(false), aspect: 1.5 }); /** An Aputure MC (a mesh kind); `extra` adds its network, unicast, workspace. */ export const fixtureBody = (label: string, extra: Record = {}) => ({ label, kind_id: "aputure-mc", address: "A4:C1:38:0B:2F:9E", ...extra }); export const timelineBody = (project: string, name: string, clip: string, photo: string) => ({ project_id: project, name, clips: [{ id: clip, photo_id: photo, presentation: { mode: "fit", frame: FRAME }, duration_ms: 2000 }] }); -/** A JSON body as multipart with a render image (overlays and sketches). */ +/** A JSON body as multipart with a render image (sketches). */ export function withRender(meta: unknown): FormData { const form = new FormData(); form.set("metadata", JSON.stringify(meta)); @@ -115,7 +124,7 @@ export async function buildWorld(user: User): Promise { const id = () => uuid(); const w = { user, mark, workspace: id(), project: id(), preset: id(), location: id(), spot: id(), field: id(), view: id(), binnedView: id(), - shot: id(), photo: id(), shot2: id(), photo2: id(), step: id(), day: id(), framing: id(), timeline: id(), clip: id(), overlay: id(), sketch: id(), folder: id(), + shot: id(), photo: id(), shot2: id(), photo2: id(), step: id(), day: id(), framing: id(), timeline: id(), clip: id(), layer: id(), sketch: id(), folder: id(), desktop: id(), device: id(), layout: id(), meshNetwork: id(), fixture: id(), } as World; memberUser ??= await newUser({ signIn: false }); @@ -138,8 +147,9 @@ export async function buildWorld(user: User): Promise { // A second shot (what a merge moves in) and a stored step on the first (issue #266). await ok(api(t, "POST", "/api/shots", uploadForm({ id: w.shot2, project_id: w.project, name: `${mark} shot two`, photos: [photoMeta(0, { id: w.photo2 })] })), 201); await ok(api(t, "PUT", `/api/shots/${w.shot}/steps`, { steps: [{ id: w.step, photo_id: w.photo, framing_id: w.framing }] })); + await ok(api(t, "PUT", `/api/layers/${w.layer}`, layerBody(w.shot, `${mark} layer`, { range: [w.step] })), 201); + await ok(api(t, "PUT", `/api/shots/${w.shot}/bakes/${w.step}`, bakeForm())); await ok(api(t, "PUT", `/api/timelines/${w.timeline}`, timelineBody(w.project, `${mark} timeline`, w.clip, w.photo))); - await ok(api(t, "PUT", `/api/overlays/${w.overlay}`, withRender(overlayBody(w.photo, `${mark} overlay`))), 201); await ok(api(t, "PUT", `/api/sketches/${w.sketch}`, withRender(sketchBody(w.shot, `${mark} sketch`))), 201); await ok(api(t, "PUT", `/api/folders/${w.folder}`, { project_id: w.project, name: `${mark} folder` })); await ok(api(t, "PUT", `/api/desktop/${w.desktop}`, { project_id: w.project, kind: "app", app_id: "gallery", label: `${mark} app`, x: 10, y: 10 })); diff --git a/apps/worker/test/routes/images.test.ts b/apps/worker/test/routes/images.test.ts index 3eca6ea..d364a64 100644 --- a/apps/worker/test/routes/images.test.ts +++ b/apps/worker/test/routes/images.test.ts @@ -1,4 +1,4 @@ -/** The image proxy (photos, overlay and sketch renders): headers and conditional requests. */ +/** The image proxy (photos, sketch renders, step bakes): headers and conditional requests. */ import { env } from "cloudflare:workers"; import { beforeAll, describe, expect, test } from "vitest"; import { api, buildWorld, newUser, ok, type User, type World } from "./helpers.ts"; @@ -14,7 +14,7 @@ const IMMUTABLE = "private, max-age=31536000, immutable"; describe.each([ ["photo", () => `/api/photos/${w.photo}/image`, "image/jpeg"], - ["overlay render", () => `/api/overlays/${w.overlay}/render`, "image/png"], + ["step bake", () => `/api/shots/${w.shot}/bakes/${w.step}`, "image/png"], ["sketch render", () => `/api/sketches/${w.sketch}/render`, "image/png"], ])("%s", (_what, path, type) => { test("is served with its type (never sniffed), an ETag and a year of private caching", async () => { diff --git a/apps/worker/test/routes/layers.test.ts b/apps/worker/test/routes/layers.test.ts new file mode 100644 index 0000000..4e4a3aa --- /dev/null +++ b/apps/worker/test/routes/layers.test.ts @@ -0,0 +1,133 @@ +/** + * Layers and bakes (epic #275): strict layer upserts, what shots and timelines embed, bakes per + * step, layers following merged photos, a clip's step and promoting a clip's own layer. + */ +import { env } from "cloudflare:workers"; +import { beforeEach, describe, expect, test } from "vitest"; +import { api, bakeForm, buildWorld, FRAME, layerBody, newUser, ok, photoMeta, snapshot, uploadForm, uuid, type User, type World } from "./helpers.ts"; + +interface Layer { id: string; shot_id: string; kind: string; photo_id: string | null; range: string[] | null; visible: boolean; shapes?: unknown[]; timeline_id: string | null; clip_id: string | null; position: number } +interface Shot { id: string; layers: Layer[]; bakes: { step_key: string; hash: string; url: string }[]; steps: { id: string }[] } +interface Timeline { clips: { id: string; step_id: string | null }[]; layers: Layer[] } + +let u: User; +let w: World; +beforeEach(async () => { + u ??= await newUser(); + w = await buildWorld(u); +}); + +const shot = (id: string) => ok<{ shot: Shot }>(api(u.token, "GET", `/api/shots/${id}`)).then((r) => r.shot); +const line = { id: "s1", type: "line", from: [0.1, 0.1], to: [0.5, 0.5], color: "#FFD60A", width: 0.006, opacity: 1 }; +const look = { exposure: -0.5, contrast: 0, saturation: 0, bw: false, tint: null, tint_opacity: 0.3, vignette: 0, blur: 0 }; + +describe("layers", () => { + test("a shot embeds its layers in stack order without shapes; the layer route and the shot's list carry them", async () => { + const adj = uuid(); + await ok(api(u.token, "PUT", `/api/layers/${adj}`, layerBody(w.shot, "Grade", { kind: "adjustment", shapes: null, look, position: -1 })), 201); + await ok(api(u.token, "PUT", `/api/layers/${w.layer}`, layerBody(w.shot, "Marks", { shapes: [line], range: [w.step] }))); + const s = await shot(w.shot); + expect(s.layers.map((l) => l.id)).toEqual([adj, w.layer]); + expect(s.layers.every((l) => !("shapes" in l))).toBe(true); + expect((await ok<{ layer: Layer }>(api(u.token, "GET", `/api/layers/${w.layer}`))).layer.shapes).toEqual([line]); + const full = await ok<{ layers: Layer[] }>(api(u.token, "GET", `/api/shots/${w.shot}/layers`)); + expect(full.layers.find((l) => l.id === w.layer)!.shapes).toEqual([line]); + }); + + test("is strict: a photo of another shot, a step that is not stored, a changed kind, another project's timeline, too many", async () => { + const other = await buildWorld(u); + const before = await snapshot(); + const cases: [string, unknown, number, RegExp][] = [ + [uuid(), layerBody(w.shot, "x", { space: "photo", photo_id: w.photo2 }), 400, /photo of the shot/], + [uuid(), layerBody(w.shot, "x", { range: [uuid()] }), 400, /stored step/], + [w.layer, layerBody(w.shot, "x", { kind: "adjustment", shapes: null, look }), 409, /kind/], + [w.layer, layerBody(w.shot2, "x"), 409, /shot/], + [uuid(), layerBody(w.shot, "x", { timeline_id: other.timeline, clip_id: other.clip }), 400, /timeline's project/], + [uuid(), layerBody(w.shot, "x", { look }), 400, /no look/], + ]; + for (const [id, body, status, re] of cases) { + const res = await api(u.token, "PUT", `/api/layers/${id}`, body); + expect(res.status, JSON.stringify(body).slice(0, 90)).toBe(status); + expect(await res.text()).toMatch(re); + } + expect(await snapshot()).toBe(before); + // At most 50 layers on a shot. + for (let i = 0; i < 49; i++) await ok(api(u.token, "PUT", `/api/layers/${uuid()}`, layerBody(w.shot2, `L${i}`)), 201); + await ok(api(u.token, "PUT", `/api/layers/${uuid()}`, layerBody(w.shot2, "one more")), 201); + await ok(api(u.token, "PUT", `/api/layers/${uuid()}`, layerBody(w.shot2, "too many")), 413); + }); + + test("deleting a layer answers with the shot", async () => { + const r = await ok<{ shot: Shot }>(api(u.token, "DELETE", `/api/layers/${w.layer}`)); + expect(r.shot.layers).toEqual([]); + await ok(api(u.token, "GET", `/api/layers/${w.layer}`), 404); + }); +}); + +describe("bakes", () => { + test("a bake is stored per step with its hash, replaces the previous image, and only for steps of the shot", async () => { + const objects = () => env.SHOTS_BUCKET.list({ prefix: `bakes/${w.shot}/` }).then((r) => r.objects.map((o) => o.key)); + expect(await objects()).toEqual([`bakes/${w.shot}/${w.step}-0123456789abcdef.png`]); + const r = await ok<{ shot: Shot }>(api(u.token, "PUT", `/api/shots/${w.shot}/bakes/${w.step}`, bakeForm("aaaaaaaaaaaaaaaa"))); + expect(r.shot.bakes).toEqual([{ step_key: w.step, hash: "aaaaaaaaaaaaaaaa", url: `/api/shots/${w.shot}/bakes/${w.step}?v=aaaaaaaaaaaaaaaa` }]); + expect(await objects()).toEqual([`bakes/${w.shot}/${w.step}-aaaaaaaaaaaaaaaa.png`]); + // The photo is not a step key while the shot has stored steps; another shot's step never is. + await ok(api(u.token, "PUT", `/api/shots/${w.shot}/bakes/${w.photo}`, bakeForm()), 400); + await ok(api(u.token, "PUT", `/api/shots/${w.shot2}/bakes/${w.step}`, bakeForm()), 400); + await ok(api(u.token, "PUT", `/api/shots/${w.shot}/bakes/${w.step}`, bakeForm("not-a-hash")), 400); + // A shot without stored steps bakes per photo. + await ok(api(u.token, "PUT", `/api/shots/${w.shot2}/bakes/${w.photo2}`, bakeForm())); + expect((await shot(w.shot2)).bakes.map((b) => b.step_key)).toEqual([w.photo2]); + }); +}); + +describe("merge and timelines", () => { + test("merged photos bring their photo-space layers along (on every step), and an emptied source's frame layers arrive hidden", async () => { + const photoLayer = uuid(), frameLayer = uuid(); + await ok(api(u.token, "PUT", `/api/layers/${photoLayer}`, layerBody(w.shot2, "On the photo", { space: "photo", photo_id: w.photo2 })), 201); + await ok(api(u.token, "PUT", `/api/layers/${frameLayer}`, layerBody(w.shot2, "On the frame")), 201); + const r = await ok<{ shot: Shot; removed_shot_ids: string[] }>(api(u.token, "POST", `/api/shots/${w.shot}/photos`, { photo_ids: [w.photo2] })); + expect(r.removed_shot_ids).toEqual([w.shot2]); + const by = new Map(r.shot.layers.map((l) => [l.id, l])); + expect(by.get(photoLayer)).toMatchObject({ shot_id: w.shot, photo_id: w.photo2, range: null, visible: true }); + expect(by.get(frameLayer)).toMatchObject({ shot_id: w.shot, range: null, visible: false }); + }); + + test("a clip shows a stored step of its own photo; another photo's step is nulled; timelines embed their clips' layers", async () => { + const clip2 = uuid(), own = uuid(); + const t = await ok<{ timeline: Timeline }>(api(u.token, "PUT", `/api/timelines/${w.timeline}`, { + project_id: w.project, name: "Cut", + clips: [ + { id: w.clip, photo_id: w.photo, step_id: w.step, presentation: { mode: "fit", frame: FRAME }, duration_ms: 1000 }, + { id: clip2, photo_id: w.photo2, step_id: w.step, presentation: { mode: "fit", frame: FRAME }, duration_ms: 1000 }, + ], + })); + expect(t.timeline.clips.map((c) => c.step_id)).toEqual([w.step, null]); + await ok(api(u.token, "PUT", `/api/layers/${own}`, layerBody(w.shot, "Clip marks", { timeline_id: w.timeline, clip_id: w.clip })), 201); + const again = await ok<{ timeline: Timeline }>(api(u.token, "GET", `/api/timelines/${w.timeline}`)); + expect(again.timeline.layers.map((l) => l.id)).toEqual([own]); + // Shots never embed a clip's own layers. + expect((await shot(w.shot)).layers.map((l) => l.id)).toEqual([w.layer]); + // A clip that leaves the timeline takes its layers along. + await ok(api(u.token, "PUT", `/api/timelines/${w.timeline}`, { project_id: w.project, name: "Cut", clips: [] })); + expect(await env.DB.prepare("SELECT 1 FROM layers WHERE id = ?1").bind(own).first()).toBeNull(); + }); + + test("promoting a clip's own layer makes it the shot's, on the clip's step", async () => { + await ok(api(u.token, "PUT", `/api/timelines/${w.timeline}`, { project_id: w.project, name: "Cut", clips: [{ id: w.clip, photo_id: w.photo, step_id: w.step, presentation: { mode: "fit", frame: FRAME }, duration_ms: 1000 }] })); + const own = uuid(); + await ok(api(u.token, "PUT", `/api/layers/${own}`, layerBody(w.shot, "Clip marks", { timeline_id: w.timeline, clip_id: w.clip })), 201); + const r = await ok<{ shot: Shot; timeline: Timeline }>(api(u.token, "POST", `/api/timelines/${w.timeline}/clips/${w.clip}/promote`, { what: "layer", layer_id: own })); + expect(r.shot.layers.find((l) => l.id === own)).toMatchObject({ range: [w.step], timeline_id: null, clip_id: null }); + expect(r.timeline.layers).toEqual([]); + await ok(api(u.token, "POST", `/api/timelines/${w.timeline}/clips/${w.clip}/promote`, { what: "layer", layer_id: own }), 409); + }); + + test("a clip's own layers can be copied for a duplicated clip", async () => { + const own = uuid(), copy = uuid(); + await ok(api(u.token, "PUT", `/api/layers/${own}`, layerBody(w.shot, "Clip marks", { timeline_id: w.timeline, clip_id: w.clip, shapes: [line] })), 201); + const r = await ok<{ layer: Layer }>(api(u.token, "POST", `/api/layers/${own}/copy`, { id: copy, timeline_id: w.timeline, clip_id: uuid() }), 201); + expect(r.layer).toMatchObject({ id: copy, timeline_id: w.timeline, shapes: [line] }); + void photoMeta; void uploadForm; + }); +}); diff --git a/apps/worker/test/routes/limits.test.ts b/apps/worker/test/routes/limits.test.ts index d920f91..943e074 100644 --- a/apps/worker/test/routes/limits.test.ts +++ b/apps/worker/test/routes/limits.test.ts @@ -4,7 +4,7 @@ */ import { env } from "cloudflare:workers"; import { beforeAll, describe, expect, test } from "vitest"; -import { api, buildWorld, jpeg, newUser, ok, overlayBody, photoMeta, png, uploadForm, uuid, type User, type World } from "./helpers.ts"; +import { api, buildWorld, jpeg, newUser, ok, photoMeta, png, sketchBody, uploadForm, uuid, type User, type World } from "./helpers.ts"; import { DEFAULT_VIEWFINDER_LAYOUT } from "@openclapper/vocab"; let u: User; @@ -79,17 +79,25 @@ describe("POST /api/shots", () => { describe("compose renders", () => { test("a declared body above the ceiling is 413", async () => { const form = new FormData(); - form.set("metadata", JSON.stringify(overlayBody(w.photo, "big"))); + form.set("metadata", JSON.stringify(sketchBody(w.shot, "big"))); form.set("render", png()); - await ok(api(u.token, "PUT", `/api/overlays/${uuid()}`, form, { "content-length": BIG }), 413); + await ok(api(u.token, "PUT", `/api/sketches/${uuid()}`, form, { "content-length": BIG }), 413); + const bake = new FormData(); + bake.set("metadata", JSON.stringify({ hash: "0123456789abcdef" })); + bake.set("image", png()); + await ok(api(u.token, "PUT", `/api/shots/${w.shot}/bakes/${w.step}`, bake, { "content-length": BIG }), 413); }); test("the render's type comes from its bytes; anything else is 415", async () => { const id = uuid(); - const form = (file: File) => { const f = new FormData(); f.set("metadata", JSON.stringify(overlayBody(w.photo, `typed ${id}`))); f.set("render", file); return f; }; - await ok(api(u.token, "PUT", `/api/overlays/${id}`, form(text())), 415); - await ok(api(u.token, "PUT", `/api/overlays/${id}`, form(webp())), 201); - expect(await objects(`renders/overlays/${id}`)).toEqual([`renders/overlays/${id}.webp`]); + const form = (file: File) => { const f = new FormData(); f.set("metadata", JSON.stringify(sketchBody(w.shot, `typed ${id}`))); f.set("render", file); return f; }; + await ok(api(u.token, "PUT", `/api/sketches/${id}`, form(text())), 415); + await ok(api(u.token, "PUT", `/api/sketches/${id}`, form(webp())), 201); + expect(await objects(`renders/sketches/${id}`)).toEqual([`renders/sketches/${id}.webp`]); + const bake = (file: File) => { const f = new FormData(); f.set("metadata", JSON.stringify({ hash: "0123456789abcdef" })); f.set("image", file); return f; }; + await ok(api(u.token, "PUT", `/api/shots/${w.shot}/bakes/${w.step}`, bake(text())), 415); + await ok(api(u.token, "PUT", `/api/shots/${w.shot}/bakes/${w.step}`, bake(webp()))); + expect(await objects(`bakes/${w.shot}/${w.step}`)).toEqual([`bakes/${w.shot}/${w.step}-0123456789abcdef.webp`]); }); }); diff --git a/apps/worker/test/routes/steps.test.ts b/apps/worker/test/routes/steps.test.ts index d457b74..bbd57f9 100644 --- a/apps/worker/test/routes/steps.test.ts +++ b/apps/worker/test/routes/steps.test.ts @@ -4,10 +4,10 @@ */ import { env } from "cloudflare:workers"; import { beforeEach, describe, expect, test } from "vitest"; -import { api, buildWorld, FRAME, newUser, ok, overlayBody, photoMeta, snapshot, timelineBody, uploadForm, uuid, withRender, type User, type World } from "./helpers.ts"; +import { api, buildWorld, FRAME, layerBody, newUser, ok, photoMeta, snapshot, timelineBody, uploadForm, uuid, type User, type World } from "./helpers.ts"; -interface Step { id: string; photo_id: string; framing_id: string | null; overlay_id: string | null; ordinal: number } -interface Shot { id: string; name: string | null; captured_at: string; photos: { id: string; ordinal: number; root_framing_id: string | null; r2_object_key?: string; framings: { id: string; name: string }[] }[]; overlays: { id: string; render_url: string | null; presentation: { framing_id?: string | null } }[]; sketches: { id: string }[]; steps: Step[] } +interface Step { id: string; photo_id: string; framing_id: string | null; ordinal: number } +interface Shot { id: string; name: string | null; captured_at: string; photos: { id: string; ordinal: number; root_framing_id: string | null; framings: { id: string; name: string }[] }[]; layers: { id: string; shot_id: string; photo_id: string | null; range: string[] | null; visible: boolean }[]; bakes: { step_key: string }[]; sketches: { id: string }[]; steps: Step[] } let u: User; let w: World; @@ -23,19 +23,18 @@ const keysOf = async (shotId: string) => (await env.DB.prepare("SELECT r2_object describe("PUT /api/shots/:id/steps", () => { test("stores the list in order, embeds it in the shot and the list, and [] resets", async () => { const a = uuid(), b = uuid(); - const r = await put(w.shot, [{ id: a, photo_id: w.photo, overlay_id: w.overlay }, { id: b, photo_id: w.photo, framing_id: w.framing }]); - expect(r.shot.steps.map((s) => [s.id, s.ordinal, s.framing_id, s.overlay_id])).toEqual([[a, 0, null, w.overlay], [b, 1, w.framing, null]]); + const r = await put(w.shot, [{ id: a, photo_id: w.photo }, { id: b, photo_id: w.photo, framing_id: w.framing }]); + expect(r.shot.steps.map((s) => [s.id, s.ordinal, s.framing_id])).toEqual([[a, 0, null], [b, 1, w.framing]]); const listed = await ok<{ shots: Shot[] }>(api(u.token, "GET", `/api/shots?project_id=${w.project}`)); expect(listed.shots.find((s) => s.id === w.shot)!.steps.map((s) => s.id)).toEqual([a, b]); expect((await put(w.shot, [])).shot.steps).toEqual([]); }); - test("is strict: another shot's photo, another photo's framing or overlay, a clip's overlay, duplicates, too many", async () => { + test("is strict: another shot's photo, another photo's framing, duplicates, too many", async () => { const before = await snapshot(); const cases: [unknown, RegExp][] = [ [[{ id: uuid(), photo_id: w.photo2 }], /not one of the shot's photos/], [[{ id: uuid(), photo_id: w.photo, framing_id: uuid() }], /framing/], - [[{ id: uuid(), photo_id: w.photo, overlay_id: uuid() }], /overlay is not/], [[{ id: w.step, photo_id: w.photo }, { id: w.step, photo_id: w.photo }], /repeats/], [Array.from({ length: 501 }, () => ({ id: uuid(), photo_id: w.photo })), /at most/], ["nope", /array/], @@ -45,22 +44,15 @@ describe("PUT /api/shots/:id/steps", () => { expect(res.status).toBe(400); expect(await res.text()).toMatch(re); } - // A timeline clip's own overlay is never a step. - const clipOverlay = uuid(); - await ok(api(u.token, "PUT", `/api/overlays/${clipOverlay}`, withRender({ ...overlayBody(w.photo, "clip's"), timeline_id: w.timeline, clip_id: w.clip })), 201); - const res = await api(u.token, "PUT", `/api/shots/${w.shot}/steps`, { steps: [{ id: uuid(), photo_id: w.photo, overlay_id: clipOverlay }] }); - expect(res.status).toBe(400); - expect(await res.text()).toMatch(/timeline clip/); - await ok(api(u.token, "DELETE", `/api/overlays/${clipOverlay}`)); expect(await snapshot()).toBe(before); }); - test("a deleted framing or overlay clears the reference; a binned shot is 404", async () => { - await put(w.shot, [{ id: uuid(), photo_id: w.photo, framing_id: w.framing, overlay_id: w.overlay }]); + test("a deleted framing clears the reference; removed steps lose their bakes; a binned shot is 404", async () => { + expect((await shot(w.shot)).bakes.map((b) => b.step_key)).toEqual([w.step]); + await put(w.shot, [{ id: uuid(), photo_id: w.photo, framing_id: w.framing }]); + expect((await shot(w.shot)).bakes).toEqual([]); await ok(api(u.token, "DELETE", `/api/framings/${w.framing}`)); expect((await shot(w.shot)).steps[0].framing_id).toBeNull(); - await ok(api(u.token, "DELETE", `/api/overlays/${w.overlay}`)); - expect((await shot(w.shot)).steps[0].overlay_id).toBeNull(); await ok(api(u.token, "DELETE", `/api/shots/${w.shot}`)); await put(w.shot, [], 404); }); @@ -118,10 +110,10 @@ describe("POST /api/shots/:id/photos (merge)", () => { }); describe("POST /api/shots/:id/photos/:photo/extract", () => { - test("copies the photo, its object, framings and overlays into a new shot; the source is untouched", async () => { + test("copies the photo, its object, framings and the layers the step shows into a new shot; the source is untouched", async () => { const before = await snapshot(); const id = uuid(); - const r = await ok<{ shot: Shot }>(api(u.token, "POST", `/api/shots/${w.shot}/photos/${w.photo}/extract`, { id, framing_id: w.framing }), 201); + const r = await ok<{ shot: Shot }>(api(u.token, "POST", `/api/shots/${w.shot}/photos/${w.photo}/extract`, { id, framing_id: w.framing, step_id: w.step }), 201); expect(r.shot.id).toBe(id); expect(r.shot.name).toMatch(/· photo 1$/); expect(r.shot.photos).toHaveLength(1); @@ -130,16 +122,16 @@ describe("POST /api/shots/:id/photos/:photo/extract", () => { expect(p.framings).toHaveLength(1); expect(p.framings[0].id).not.toBe(w.framing); expect(p.root_framing_id).toBe(p.framings[0].id); - expect(r.shot.overlays).toHaveLength(1); - expect(r.shot.overlays[0].id).not.toBe(w.overlay); - expect(r.shot.overlays[0].render_url).toBeTruthy(); + // The step's layer (ranged to w.step) is on every step of the copy. + expect(r.shot.layers).toHaveLength(1); + expect(r.shot.layers[0]).toMatchObject({ shot_id: id, range: null }); + expect(r.shot.layers[0].id).not.toBe(w.layer); expect(r.shot.steps).toEqual([]); // A new object for the copy, the source's still there; the copy serves. const [srcKey] = await keysOf(w.shot), [newKey] = await keysOf(id); expect(newKey).not.toBe(srcKey); expect(await env.SHOTS_BUCKET.head(srcKey)).not.toBeNull(); expect((await api(u.token, "GET", `/api/photos/${p.id}/image`)).status).toBe(200); - expect((await api(u.token, "GET", r.shot.overlays[0].render_url!)).status).toBe(200); // Source unchanged apart from nothing: deleting the copy leaves it whole. await ok(api(u.token, "DELETE", `/api/shots/${id}`)); await ok(api(u.token, "DELETE", `/api/trash/shot/${id}`)); @@ -150,7 +142,7 @@ describe("POST /api/shots/:id/photos/:photo/extract", () => { const before = await snapshot(); expect((await api(u.token, "POST", `/api/shots/${w.shot}/photos/${w.photo2}/extract`, {})).status).toBe(404); expect((await api(u.token, "POST", `/api/shots/${w.shot}/photos/${w.photo}/extract`, { framing_id: uuid() })).status).toBe(400); - expect((await api(u.token, "POST", `/api/shots/${w.shot}/photos/${w.photo}/extract`, { overlay_id: uuid() })).status).toBe(400); + expect((await api(u.token, "POST", `/api/shots/${w.shot}/photos/${w.photo}/extract`, { step_id: uuid() })).status).toBe(400); expect((await api(u.token, "POST", `/api/shots/${w.shot}/photos/${w.photo}/extract`, { id: w.shot2 })).status).toBe(409); expect(await snapshot()).toBe(before); }); @@ -158,3 +150,4 @@ describe("POST /api/shots/:id/photos/:photo/extract", () => { // `timelineBody` is what buildWorld uses; referenced so the import list stays honest if the helper changes. void timelineBody; +void layerBody; diff --git a/apps/worker/test/routes/tenancy.test.ts b/apps/worker/test/routes/tenancy.test.ts index 2e3599d..bee24cf 100644 --- a/apps/worker/test/routes/tenancy.test.ts +++ b/apps/worker/test/routes/tenancy.test.ts @@ -13,7 +13,7 @@ import { env } from "cloudflare:workers"; import { beforeAll, describe, expect, test } from "vitest"; import { publicRouter, router } from "../../src/routes.ts"; -import { api, buildWorld, deviceBody, fixtureBody, FRAME, idsOf, layoutBody, newUser, ok, overlayBody, photoMeta, sketchBody, snapshot, uploadForm, uuid, type User, type World } from "./helpers.ts"; +import { api, buildWorld, deviceBody, fixtureBody, FRAME, idsOf, layerBody, layoutBody, newUser, ok, bakeForm, photoMeta, sketchBody, snapshot, uploadForm, uuid, type User, type World } from "./helpers.ts"; interface IdCase { kind: "id"; @@ -111,12 +111,14 @@ const ROUTES: Record = { "DELETE /api/trash/:type/:id": { kind: "id", path: (a) => `/api/trash/view/${a.binnedView}` }, "DELETE /api/trash": { kind: "id", path: (a) => `/api/trash?project_id=${a.project}` }, - "GET /api/overlays/:id": { kind: "id", path: (a) => `/api/overlays/${a.overlay}` }, - "PUT /api/overlays/:id": { kind: "id", path: (a) => `/api/overlays/${a.overlay}`, body: (a, actor) => overlayBody(actor.photo, named(a, "overlay")) }, - "PATCH /api/overlays/:id": { kind: "id", path: (a) => `/api/overlays/${a.overlay}`, body: (a) => ({ name: named(a, "overlay") }) }, - "DELETE /api/overlays/:id": { kind: "id", path: (a) => `/api/overlays/${a.overlay}` }, - "POST /api/overlays/:id/copy": { kind: "id", path: (a) => `/api/overlays/${a.overlay}/copy`, body: (_a, actor) => ({ id: uuid(), timeline_id: actor.timeline, clip_id: actor.clip }) }, - "GET /api/overlays/:id/render": { kind: "id", path: (a) => `/api/overlays/${a.overlay}/render` }, + "GET /api/shots/:id/layers": { kind: "id", path: (a) => `/api/shots/${a.shot}/layers` }, + "GET /api/layers/:id": { kind: "id", path: (a) => `/api/layers/${a.layer}` }, + // B names B's own shot for A's layer id: refused on the layer (the upsert must not take it over). + "PUT /api/layers/:id": { kind: "id", path: (a) => `/api/layers/${a.layer}`, body: (a, actor) => layerBody(actor.shot, named(a, "layer")) }, + "DELETE /api/layers/:id": { kind: "id", path: (a) => `/api/layers/${a.layer}` }, + "POST /api/layers/:id/copy": { kind: "id", path: (a) => `/api/layers/${a.layer}/copy`, body: (_a, actor) => ({ id: uuid(), timeline_id: actor.timeline, clip_id: actor.clip }) }, + "PUT /api/shots/:id/bakes/:key": { kind: "id", path: (a) => `/api/shots/${a.shot}/bakes/${a.step}`, body: () => bakeForm("fedcba9876543210") }, + "GET /api/shots/:id/bakes/:key": { kind: "id", path: (a) => `/api/shots/${a.shot}/bakes/${a.step}` }, "GET /api/sketches/:id": { kind: "id", path: (a) => `/api/sketches/${a.sketch}` }, "PUT /api/sketches/:id": { kind: "id", path: (a) => `/api/sketches/${a.sketch}`, body: (a, actor) => sketchBody(actor.shot, named(a, "sketch")) }, "PATCH /api/sketches/:id": { kind: "id", path: (a) => `/api/sketches/${a.sketch}`, body: (a) => ({ name: named(a, "sketch") }) }, @@ -245,11 +247,13 @@ describe("another workspace's resources", () => { ["B's fixture moved into A's mesh network", "PUT", "/api/fixtures/{b.fixture}", (w) => fixtureBody("x", { mesh_network_id: w.meshNetwork, mesh_unicast: 9 })], ["a new framing on A's photo", "PUT", "/api/framings/{new}", (w) => ({ photo_id: w.photo, name: "x", lens_mm: 35, frame: FRAME })], ["a framing on B's photo with A's rig", "PUT", "/api/framings/{new}", (w) => ({ photo_id: bWorld.photo, name: "x", rig_id: w.preset, lens_mm: 35, frame: FRAME })], - ["a new overlay on A's photo", "PUT", "/api/overlays/{new}", (w) => overlayBody(w.photo, "x")], - ["an overlay owned by A's timeline clip", "PUT", "/api/overlays/{new}", (w) => ({ ...overlayBody(bWorld.photo, "x"), timeline_id: w.timeline, clip_id: w.clip })], + ["a new layer on A's shot", "PUT", "/api/layers/{new}", (w) => layerBody(w.shot, "x")], + ["B's layer on A's photo", "PUT", "/api/layers/{new}", (w) => layerBody(bWorld.shot, "x", { space: "photo", photo_id: w.photo })], + ["B's layer on A's steps", "PUT", "/api/layers/{new}", (w) => layerBody(bWorld.shot, "x", { range: [w.step] })], + ["B's layer owned by A's timeline clip", "PUT", "/api/layers/{new}", (w) => layerBody(bWorld.shot, "x", { timeline_id: w.timeline, clip_id: w.clip })], ["a new sketch on A's shot", "PUT", "/api/sketches/{new}", (w) => sketchBody(w.shot, "x")], ["B's sketch copied into A's timeline", "POST", "/api/sketches/{b.sketch}/copy", (w) => ({ id: uuid(), timeline_id: w.timeline, clip_id: w.clip })], - ["B's overlay copied into A's timeline", "POST", "/api/overlays/{b.overlay}/copy", (w) => ({ id: uuid(), timeline_id: w.timeline, clip_id: w.clip })], + ["B's layer copied into A's timeline", "POST", "/api/layers/{b.layer}/copy", (w) => ({ id: uuid(), timeline_id: w.timeline, clip_id: w.clip })], ["B's layout for A's device", "PUT", "/api/viewfinder-layouts/{new}", (w) => ({ ...layoutBody("x"), device_id: w.device })], ["B's shot into A's folder", "PUT", "/api/folder-items/shot/{b.shot}", (w) => ({ folder_id: w.folder })], ["A's fields imported into B's project order", "PUT", "/api/projects/{b.project}/fields", (w) => ({ field_ids: [w.field] })], diff --git a/apps/worker/test/routes/trash.test.ts b/apps/worker/test/routes/trash.test.ts index edeb167..93b9df3 100644 --- a/apps/worker/test/routes/trash.test.ts +++ b/apps/worker/test/routes/trash.test.ts @@ -20,7 +20,7 @@ const listed = async (path: string, id: string) => (await (await api(u.token, "G const trash = (project: string) => ok<{ entries: { type: string; id: string; name: string }[] }>(api(u.token, "GET", `/api/trash?project_id=${project}`)); const shotKeys = async (shot: string) => (await env.DB.prepare("SELECT r2_object_key AS k FROM photos WHERE shot_id = ?1").bind(shot).all<{ k: string }>()).results.map((r) => r.k); const renderKeys = async () => [ - (await env.DB.prepare("SELECT render_key AS k FROM overlays WHERE id = ?1").bind(w.overlay).first<{ k: string }>())!.k, + (await env.DB.prepare("SELECT render_key AS k FROM step_bakes WHERE shot_id = ?1").bind(w.shot).first<{ k: string }>())!.k, (await env.DB.prepare("SELECT render_key AS k FROM sketches WHERE id = ?1").bind(w.sketch).first<{ k: string }>())!.k, ]; @@ -81,8 +81,8 @@ describe("purge", () => { expect(await env.DB.prepare("SELECT 1 FROM shots WHERE id = ?1").bind(w.shot).first()).toBeNull(); expect(await env.DB.prepare("SELECT 1 FROM photos WHERE id = ?1").bind(w.photo).first()).toBeNull(); - for (const t of ["overlays", "sketches", "framings"]) { - expect(await env.DB.prepare(`SELECT 1 FROM ${t} WHERE id IN (?1, ?2, ?3)`).bind(w.overlay, w.sketch, w.framing).first(), t).toBeNull(); + for (const t of ["layers", "sketches", "framings"]) { + expect(await env.DB.prepare(`SELECT 1 FROM ${t} WHERE id IN (?1, ?2, ?3)`).bind(w.layer, w.sketch, w.framing).first(), t).toBeNull(); } expect(await env.DB.prepare("SELECT 1 FROM shot_steps WHERE id = ?1").bind(w.step).first(), "shot_steps").toBeNull(); for (const k of [...photos, ...renders]) expect(await env.SHOTS_BUCKET.head(k), k).toBeNull(); diff --git a/apps/worker/test/unit/migration-0030.test.ts b/apps/worker/test/unit/migration-0030.test.ts new file mode 100644 index 0000000..aea6b88 --- /dev/null +++ b/apps/worker/test/unit/migration-0030.test.ts @@ -0,0 +1,100 @@ +/** + * Migration 0030 (epic #275) on a real SQLite: every migration before it, then overlays in each + * shape its rules cover, then 0030 and what the layers, steps and clips look like afterwards. + */ +import { readdirSync, readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { DatabaseSync } from "node:sqlite"; +import { expect, test } from "vitest"; + +const dir = fileURLToPath(new URL("../../migrations/", import.meta.url)); +const files = readdirSync(dir).filter((f) => f.endsWith(".sql")).sort(); +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +const look = (patch: Record = {}) => ({ exposure: 0, contrast: 0, saturation: 0, bw: false, tint: null, tint_opacity: 0.3, vignette: 0, blur: 0, ...patch }); +const drawing = (shapes: unknown[], l: Record | null) => JSON.stringify({ v: 1, shapes, look: l }); +const shape = (id: string) => ({ id, type: "line", from: [0.1, 0.1], to: [0.5, 0.5], color: "#FFD60A", width: 0.006, opacity: 1 }); + +function before0030() { + const db = new DatabaseSync(":memory:"); + db.exec("PRAGMA foreign_keys = ON"); + for (const f of files) { if (f.startsWith("0030")) break; db.exec(readFileSync(dir + f, "utf8")); } + return db; +} + +test("0030 turns overlays into layers, gives overlay-only shots steps, re-points clips", () => { + const db = before0030(); + const run = (sql: string, ...args: (string | number | null)[]) => db.prepare(sql).run(...args); + run("INSERT INTO workspaces (id, name) VALUES ('w', 'W')"); + run("INSERT INTO projects (id, name, workspace_id) VALUES ('pr', 'P', 'w')"); + const shot = (id: string) => run("INSERT INTO shots (id, project_id, captured_at) VALUES (?1, 'pr', '2026-10-01T10:00:00Z')", id); + const photo = (id: string, s: string, ordinal: number) => run("INSERT INTO photos (id, shot_id, ordinal, timestamp, lens_mm, r2_object_key) VALUES (?1, ?2, ?3, '2026-10-01T10:00:00Z', 35, ?4)", id, s, ordinal, `photos/${id}.jpg`); + const overlay = (id: string, p: string, position: number, d: string, framingId: string | null = null, owner: [string, string] | null = null) => + run("INSERT INTO overlays (id, photo_id, name, drawing, presentation, position, timeline_id, clip_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, '2026-10-02T10:00:00Z', NULL)", + id, p, `Overlay ${id}`, d, JSON.stringify({ mode: "fit", frame: null, framing_id: framingId, label: null }), position, owner?.[0] ?? null, owner?.[1] ?? null); + + // Shot A: no stored steps; photo a1 has a lighting overlay (with a look) and a neutral one; a2 has none. + shot("A"); photo("a1", "A", 0); photo("a2", "A", 1); + run("INSERT INTO framings (id, photo_id, name, lens_mm, frame) VALUES ('fa', 'a1', 'Tight', 50, '{\"width_fraction\":0.4,\"height_fraction\":0.4}')"); + overlay("oA1", "a1", 0, drawing([shape("s1")], look({ exposure: -0.5, tint: "#0A84FF" })), "fa"); + overlay("oA2", "a1", 1, drawing([shape("s2")], look())); + // Shot B: curated steps showing overlay oB1; oB2 is left out of the progression. + shot("B"); photo("b1", "B", 0); + overlay("oB1", "b1", 0, drawing([shape("s3")], look())); + overlay("oB2", "b1", 1, drawing([], look({ bw: true }))); + run("INSERT INTO shot_steps (id, shot_id, ordinal, photo_id, overlay_id) VALUES ('stB1', 'B', 0, 'b1', NULL), ('stB2', 'B', 1, 'b1', 'oB1')"); + // Shot C: nothing composed, must stay untouched. + shot("C"); photo("c1", "C", 0); + // A cut: one clip shows oA1, one shows the left-out oB2, one shows its own overlay. + run("INSERT INTO timelines (id, project_id, name) VALUES ('t', 'pr', 'Cut')"); + const clip = (id: string, pos: number, p: string, o: string | null) => run("INSERT INTO timeline_clips (id, timeline_id, position, photo_id, overlay_id, presentation, duration_ms) VALUES (?1, 't', ?2, ?3, ?4, '{\"mode\":\"fit\",\"frame\":null,\"label\":null}', 2000)", id, pos, p, o); + clip("cA", 0, "a1", "oA1"); clip("cB", 1, "b1", "oB2"); clip("cOwn", 2, "c1", null); + overlay("oOwn", "c1", 0, drawing([shape("s4")], look({ contrast: 0.3 })), null, ["t", "cOwn"]); + run("UPDATE timeline_clips SET overlay_id = 'oOwn' WHERE id = 'cOwn'"); + + db.exec(readFileSync(dir + files.find((f) => f.startsWith("0030"))!, "utf8")); + + const all = (sql: string, ...args: string[]) => db.prepare(sql).all(...args) as T[]; + type L = { id: string; shot_id: string; kind: string; name: string; position: number; space: string; photo_id: string; range: string | null; shapes: string | null; look: string | null; timeline_id: string | null; clip_id: string | null }; + const layers = all("SELECT * FROM layers ORDER BY shot_id, position, kind"); + + // Shot A got stored steps: a1, then its two overlays (in position order), then a2. + const stepsA = all<{ id: string; ordinal: number; photo_id: string; framing_id: string | null; overlay_id: string | null }>("SELECT * FROM shot_steps WHERE shot_id = 'A' ORDER BY ordinal"); + expect(stepsA.map((s) => [s.photo_id, s.overlay_id, s.framing_id])).toEqual([["a1", null, null], ["a1", "oA1", "fa"], ["a1", "oA2", null], ["a2", null, null]]); + for (const s of stepsA) expect(s.id).toMatch(UUID_RE); + const stepOf = (o: string) => stepsA.find((s) => s.overlay_id === o)!.id; + // Shot B's curated steps were not touched. + expect(all<{ id: string }>("SELECT id FROM shot_steps WHERE shot_id = 'B' ORDER BY ordinal").map((s) => s.id)).toEqual(["stB1", "stB2"]); + expect(all("SELECT * FROM shot_steps WHERE shot_id = 'C'")).toEqual([]); + + const shotLayer = (id: string) => layers.find((l) => l.id === id)!; + // oA1: a drawing layer with its id and shapes, and an adjustment layer just below with the look; both on its step. + expect(shotLayer("oA1")).toMatchObject({ kind: "drawing", shot_id: "A", space: "photo", photo_id: "a1", position: 1, range: JSON.stringify([stepOf("oA1")]), look: null }); + expect(JSON.parse(shotLayer("oA1").shapes!)).toHaveLength(1); + const adjA = layers.filter((l) => l.kind === "adjustment" && l.shot_id === "A" && !l.timeline_id); + expect(adjA).toHaveLength(1); + expect(adjA[0]).toMatchObject({ position: 0, photo_id: "a1", range: JSON.stringify([stepOf("oA1")]), name: "Overlay oA1 · look" }); + expect(adjA[0].id).toMatch(UUID_RE); + expect(JSON.parse(adjA[0].look!)).toMatchObject({ exposure: -0.5, tint: "#0A84FF" }); + // oA2 has a neutral look: a drawing layer only. + expect(shotLayer("oA2")).toMatchObject({ kind: "drawing", position: 3, range: JSON.stringify([stepOf("oA2")]) }); + // Shot B: oB1 on the step that showed it; oB2 (bw look, no shapes) waits on no step. + expect(shotLayer("oB1").range).toBe(JSON.stringify(["stB2"])); + expect(shotLayer("oB2").range).toBe("[]"); + expect(layers.some((l) => l.kind === "adjustment" && l.shot_id === "B" && !l.timeline_id && l.range === "[]")).toBe(true); + expect(layers.filter((l) => l.shot_id === "C" && !l.timeline_id)).toEqual([]); + + const clips = all<{ id: string; step_id: string | null }>("SELECT id, step_id FROM timeline_clips ORDER BY position"); + // cA now shows oA1's step; cB had no step showing oB2, so oB2's layers became cB's own copies. + expect(clips.map((c) => c.step_id)).toEqual([stepOf("oA1"), null, null]); + const cB = layers.filter((l) => l.clip_id === "cB"); + expect(cB.map((l) => l.kind).sort()).toEqual(["adjustment", "drawing"]); + expect(cB.every((l) => l.timeline_id === "t" && l.shot_id === "B" && l.range === null && l.id !== "oB2")).toBe(true); + // The clip's own overlay is the clip's own layers (drawing with the same id, adjustment for the contrast). + const own = layers.filter((l) => l.clip_id === "cOwn"); + expect(own.find((l) => l.kind === "drawing")!.id).toBe("oOwn"); + expect(own.map((l) => l.kind).sort()).toEqual(["adjustment", "drawing"]); + // Nothing was deleted, and the helper tables are gone. + expect(all<{ n: number }>("SELECT count(*) AS n FROM overlays")[0].n).toBe(5); + expect(all("SELECT name FROM sqlite_master WHERE name LIKE '_m30%'")).toEqual([]); +}); diff --git a/docs/api.md b/docs/api.md index 86ee356..ae0638e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -41,12 +41,12 @@ Who a request is (`index.ts` `identify`): a session (bearer header, else cookie; | POST | `/api/shots` | Multipart: `metadata` (JSON) + one `photo.` file per photo not yet stored. See below. | | PATCH | `/api/shots/:id` | Any of `name, light, artificial, weather, int_ext, shot_size, camera_support, movement, location_id, extra, description, position_from_location, state, project_id`. `extra` is validated against the project's fields. Moving to another project removes the shot from the old project's shooting days and timelines. | | PATCH | `/api/shots` | Bulk edit: `{ ids: uuid[] (≤ 500), set?: { any single-PATCH field except extra }, extra?: { key: value \| null } }`. Only the given fields change. `extra` is per key and merged into each shot's values (groups child by child, null clears, dependent selects that no longer fit are dropped), then the edited keys are checked against each shot's (new) project. Unknown ids → 404, any error → nothing written (one transaction). Moving removes the shots from the old project's shooting days. Returns `{ shots }`. | -| DELETE | `/api/shots/:id` | Moves the shot to the [recycle bin](#recycle-bin-trashts-epic-90) (`{ deleted, binned: true }`); its timeline clips and day places go now. Purging deletes photos, overlays, sketches and their R2 objects | +| DELETE | `/api/shots/:id` | Moves the shot to the [recycle bin](#recycle-bin-trashts-epic-90) (`{ deleted, binned: true }`); its timeline clips and day places go now. Purging deletes photos, layers, bakes, sketches and their R2 objects | | GET | `/api/photos/:id/image` | Image from R2, immutable caching, conditional requests: `If-None-Match` hit `304`, a failed `If-Match` `412` (#224) | | PATCH | `/api/photos/:id` | `{ lat, lon, all_in_shot? }` position correction (also sets a position on photos captured without one); sets `position_corrected` | -| PUT | `/api/shots/:id/steps` | `{ steps: [{ id, photo_id, framing_id?, overlay_id? }] }` replaces the shot's stored progression ([sequences](features/sequences.md)); `[]` makes the photos the steps again. Strict (interactive): `400` for a photo not of the shot, a framing not of that photo, an overlay not of that photo or owned by a timeline clip, a repeated id, more than 500 steps; `404` for a binned shot. Returns `{ shot }` | -| POST | `/api/shots/:id/photos` | **Merge**: `{ photo_ids, steps? }` moves photos of other shots into this one, appended in the given order (framings and overlays go with them; `steps`, if given, replaces the list afterwards in the same transaction). The photos must be in live shots of the same project (`400` otherwise, also for unknown ids), not already here (`400`), total ≤ 200 (`413`). A source shot left without photos is **deleted for good** (its sketches move here, its day places and shortcuts go); timeline clips of the moved photos leave their sequence blocks. The only route that moves photos: `POST /api/shots` keeps answering `409`. Returns `{ shot, removed_shot_ids }` | -| POST | `/api/shots/:id/photos/:photo/extract` | **Extract** one photo as a shot of its own, a copy: `{ id?, framing_id?, overlay_id? }`. The new shot (`id`, else fresh; `409` when taken) copies the tags, description and state (name "… · photo n"), the photo row with **its own R2 object**, its framings and shot overlays with their renders (ids remapped); `framing_id` (a framing of the photo, `400` otherwise) becomes the copy's root, else the photo's root carries over. The source is untouched; no link back. Returns `{ shot }`, `201` | +| PUT | `/api/shots/:id/steps` | `{ steps: [{ id, photo_id, framing_id? }] }` replaces the shot's stored steps ([composition](features/composition.md)); `[]` makes the photos the steps again. Strict (interactive): `400` for a photo not of the shot, a framing not of that photo, a repeated id, more than 500 steps; `404` for a binned shot. Layer ranges keep ids of removed steps (those layers show nowhere then); bakes of removed steps are deleted. Returns `{ shot }` | +| POST | `/api/shots/:id/photos` | **Merge**: `{ photo_ids, steps? }` moves photos of other shots into this one, appended in the given order (framings and photo-space layers go with them, the layers on every step; their clips' own layers follow the clips; `steps`, if given, replaces the list afterwards in the same transaction). The photos must be in live shots of the same project (`400` otherwise, also for unknown ids), not already here (`400`), total ≤ 200 (`413`). A source shot left without photos is **deleted for good** (its sketches and frame-space layers move here, the layers hidden; its day places, shortcuts and bakes go); timeline clips of the moved photos leave their sequence blocks. The only route that moves photos: `POST /api/shots` keeps answering `409`. Returns `{ shot, removed_shot_ids }` | +| POST | `/api/shots/:id/photos/:photo/extract` | **Extract** one photo as a shot of its own, a copy: `{ id?, framing_id?, step_id? }`. The new shot (`id`, else fresh; `409` when taken) copies the tags, description and state (name "… · photo n"), the photo row with **its own R2 object**, its framings (ids remapped), and the layers the step shows (`step_id`, a stored step of that photo, `400` otherwise; without it the photo as a step), each on every step of the copy; `framing_id` (a framing of the photo, `400` otherwise) becomes the copy's root, else the photo's root carries over. The source is untouched; no link back; the Studio bakes the copy. Returns `{ shot }`, `201` | `POST /api/shots` metadata: @@ -73,34 +73,43 @@ Who a request is (`index.ts` `identify`): a session (bearer header, else cookie; | DELETE | `/api/framings/:id` | A deleted root falls back to as captured. Returns `{ deleted, shot }` | | PUT | `/api/photos/:id/root` | `{ framing_id: uuid \| null }` (null = as captured) → `{ shot }` | -Overlay and timeline-clip presentations accept `frame.x`/`frame.y` and `framing_id`. +Timeline-clip presentations accept `frame.x`/`frame.y` and `framing_id`. -## Overlays and sketches (`compose.ts`) +## Layers and bakes (`layers.ts`, epic #275) -Shots embed both lists without the drawing; see [compose](features/compose.md) for the model. +Shots embed their `layers` (stack order, without `shapes`) and `bakes` (`[{ step_key, hash, url }]`); see [composition](features/composition.md) for the model. | Method | Path | Notes | | --- | --- | --- | -| GET | `/api/overlays/:id` | With `drawing` | -| PUT | `/api/overlays/:id` | Upsert. JSON `{ photo_id, name, description?, drawing, presentation, position? }`, or `multipart/form-data` with `metadata` (that JSON) and `render` (JPEG, PNG or WebP by its magic bytes, else `415`; ≤ 6 MB; a declared `content-length` above 6 MB + 1 MB + 64 KB is `413` before the body is read, #224). `drawing` is validated by `validateDrawing(v, true)` (a look is required), `presentation` by `validatePresentation`. An overlay cannot move to another photo (`409`). Returns `{ overlay, shot }`; `201` when new. | -| PATCH | `/api/overlays/:id` | `{ name?, description?, position? }` → `{ overlay, shot }` | -| DELETE | `/api/overlays/:id` | Removes the render too → `{ deleted, shot }` | -| GET | `/api/overlays/:id/render` | The render; `render_url` on the overlay carries `?v=`, so it is cached as immutable. Conditional requests as for photos (`304`, `412`) | -| GET / PUT / PATCH / DELETE | `/api/sketches/:id` | Same, with `{ shot_id, name, kind?, description?, drawing (look null), aspect (0.25..4), position? }`; PATCH takes `kind` too | -| GET | `/api/sketches/:id/render` | | -| POST | `/api/overlays/:id/copy` · `/api/sketches/:id/copy` | `{ id, timeline_id, clip_id }`: copy (with the render) as a timeline clip's own overlay / sketch; used when a clip is duplicated. `201` | +| GET | `/api/shots/:id/layers` | The shot's own layers with their `shapes` (the Studio's editor and baker) | +| GET | `/api/layers/:id` | One layer with `shapes` | +| PUT | `/api/layers/:id` | Upsert `{ shot_id, kind: drawing \| adjustment, name, description?, position (−1000..1000; < 0 below the footage), visible, locked, opacity (0..1), space: frame \| photo, photo_id? (photo space: a photo of the shot), range? (null = every step, else stored step ids of the shot), shapes? (drawing), look? (adjustment: exposure … blur), timeline_id? + clip_id? (a timeline clip's own; the timeline in the shot's project) }`, checked by `validateLayerInput` (strict). The shot, the kind and the owner are fixed at creation (`409`). At most 50 layers per shot (or per clip), shapes ≤ 1 MB. Returns `{ layer, shot }`; `201` when new | +| DELETE | `/api/layers/:id` | → `{ deleted, shot }` | +| POST | `/api/layers/:id/copy` | `{ id, timeline_id, clip_id }`: copy as a timeline clip's own layer (a duplicated clip); the timeline must be in the layer's shot's project. `201` | +| PUT | `/api/shots/:id/bakes/:key` | A step's flattened image: multipart `metadata` `{ hash }` (16 hex, vocab `bakeHash`) + `image` (JPEG, PNG or WebP by its bytes, ≤ 6 MB). `key` is a stored step of the shot, or one of its photos when it has none (`400` otherwise). Stored at `bakes//-.`, replacing the previous image; bakes of steps that are gone are removed. → `{ shot }` | +| GET | `/api/shots/:id/bakes/:key` | The bake; the embedded `url` carries `?v=`, so it is cached as immutable. Conditional requests as for photos | -**Clip-owned overlays and sketches** (issue #31): with `timeline_id` + `clip_id` in the PUT body (instead of `shot_id` for a sketch) the row belongs to that timeline clip. The owner is fixed at creation (`409` on a change); the timeline must be in the photo's project. Shots never embed clip-owned rows, so the phone does not see them; they carry `timeline_id`/`clip_id` (null when shot-owned). A sketch PUT/PATCH/DELETE of a clip-owned sketch returns `shot: null`. +## Sketches (`compose.ts`) + +| Method | Path | Notes | +| --- | --- | --- | +| GET / PUT / PATCH / DELETE | `/api/sketches/:id` | Upsert `{ shot_id, name, kind?, description?, drawing (look null), aspect (0.25..4), position? }` as JSON, or multipart with `metadata` (that JSON) and `render` (JPEG, PNG or WebP by its magic bytes, else `415`; ≤ 6 MB; a declared `content-length` above 6 MB + 1 MB + 64 KB is `413` before the body is read, #224). PATCH takes `name`, `kind`, `description`, `position` | +| GET | `/api/sketches/:id/render` | The render; `render_url` carries `?v=`, so it is cached as immutable. Conditional requests as for photos (`304`, `412`) | +| POST | `/api/sketches/:id/copy` | `{ id, timeline_id, clip_id }`: copy (with the render) as a timeline clip's own sketch; used when a clip is duplicated. `201` | + +**Clip-owned sketches** (issue #31): with `timeline_id` + `clip_id` instead of `shot_id` the sketch belongs to that timeline clip. The owner is fixed at creation (`409` on a change). Shots never embed clip-owned rows. A PUT/PATCH/DELETE of a clip-owned sketch returns `shot: null`. + +The old overlay routes are gone (migration 0030 copied overlays into layers). ## Timelines (`timelines.ts`) | Method | Path | Notes | | --- | --- | --- | -| GET | `/api/timelines?project_id=` | Each with `clips: [{ id, photo_id, shot_id, overlay_id, presentation, duration_ms, notes, title, sketch_id, group_id }]` in order, `groups: [{ id, mode, total_ms, split, edge_weight }]` (sequence blocks, issue #37), plus `overlays` and `sketches`: the clips' own (issue #31), without drawings. A placeholder clip has `photo_id`/`shot_id`/`overlay_id`/`presentation` null and a `title`. | +| GET | `/api/timelines?project_id=` | Each with `clips: [{ id, photo_id, shot_id, step_id, presentation, duration_ms, notes, title, sketch_id, group_id }]` in order, `groups: [{ id, mode, total_ms, split, edge_weight }]` (sequence blocks, issue #37), plus `layers` and `sketches`: the clips' own (issues #31, #275), without shapes or drawings. A placeholder clip has `photo_id`/`shot_id`/`step_id`/`presentation` null and a `title`. | | GET | `/api/timelines/:id` | | -| PUT | `/api/timelines/:id` | `{ project_id, name, notes?, lock_mode?, groups?, clips: [...] }` replaces the whole timeline (≤ 500 clips). `lock_mode` (a presentation mode or null) forces one mode on every clip. A clip is `{ id, photo_id, overlay_id?, presentation, duration_ms, notes? }` or a placeholder `{ id, title, duration_ms, notes? }`; a placeholder with `sketch_id` is a **sketch clip**. A clip may show a shot overlay of its photo or its own overlay, never another clip's (nulled); a sketch clip's sketch must be its own or a shot sketch in the project (else it falls back to a placeholder). Clips that leave the timeline take their own overlays and sketches (and renders) along. Clips whose photo is not in the project are dropped silently; an overlay that is not the photo's is nulled. **Sequence blocks**: a photo clip may carry `group_id` of one of `groups` (`mode` `each` · `total`, `total_ms`, `split` `even` · `edges`, `edge_weight` 1..3, rules in `packages/vocab/src/timeline.ts`). A block's clips must be adjacent and show photos of one shot (400 otherwise); blocks without clips are dropped, and a `total` block's clip durations are recomputed with `splitDuration` on every save. | -| DELETE | `/api/timelines/:id` | To the recycle bin. Purging deletes the clips' own overlays and sketches with their renders too. | -| POST | `/api/timelines/:id/clips/:clip/promote` | `{ what: "framing" \| "overlay" \| "sketch", name?, shot_id? }`: the clip's own re-frame becomes a saved framing of its photo (an identical framing — same rig, lens and frame — is linked instead; the clip then references it), its own overlay becomes the photo's (the clip keeps pointing at it), or a sketch clip's own sketch becomes a sketch of `shot_id` (a shot in the project). The clip must be saved. → `{ timeline, shot }` | +| PUT | `/api/timelines/:id` | `{ project_id, name, notes?, lock_mode?, groups?, clips: [...] }` replaces the whole timeline (≤ 500 clips). `lock_mode` (a presentation mode or null) forces one mode on every clip. A clip is `{ id, photo_id, step_id?, presentation, duration_ms, notes? }` or a placeholder `{ id, title, duration_ms, notes? }`; a placeholder with `sketch_id` is a **sketch clip**. `step_id` is a stored step of the clip's own photo (anything else is nulled): the clip shows that step's layers, plus its own; a sketch clip's sketch must be its own or a shot sketch in the project (else it falls back to a placeholder). Clips that leave the timeline take their own layers and sketches (and renders) along. Clips whose photo is not in the project are dropped silently. **Sequence blocks**: a photo clip may carry `group_id` of one of `groups` (`mode` `each` · `total`, `total_ms`, `split` `even` · `edges`, `edge_weight` 1..3, rules in `packages/vocab/src/timeline.ts`). A block's clips must be adjacent and show photos of one shot (400 otherwise); blocks without clips are dropped, and a `total` block's clip durations are recomputed with `splitDuration` on every save. | +| DELETE | `/api/timelines/:id` | To the recycle bin. Purging deletes the clips' own layers and sketches (with their renders) too. | +| POST | `/api/timelines/:id/clips/:clip/promote` | `{ what: "framing" \| "layer" \| "sketch", name?, layer_id?, shot_id? }`: the clip's own re-frame becomes a saved framing of its photo (an identical framing — same rig, lens and frame — is linked instead; the clip then references it), one of its own layers (`layer_id`) becomes a layer of the shot on the clip's step (every step when the clip has none), or a sketch clip's own sketch becomes a sketch of `shot_id` (a shot in the project). The clip must be saved. → `{ timeline, shot }` | ## Locations, rigs, views diff --git a/docs/data-model.md b/docs/data-model.md index 333055e..31b7918 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -6,8 +6,9 @@ D1 (SQLite) database `openclapper-db`, schema in `apps/worker/migrations/`. Imag | File | What | | --- | --- | +| `0030_composition.sql` | `layers` (a shot's or a clip's drawing and adjustment layers), `step_bakes`, `timeline_clips.step_id`. Copies every overlay into layers (a drawing layer with its id, an adjustment layer for a look that is not neutral), gives shots without stored steps that had overlays stored steps (each photo, then one step per overlay), ranges each layer to the steps that showed its overlay, and points clips at those steps (or copies the layers as the clip's own). The `overlays` table and `shot_steps.overlay_id` stay, unread, until a later release drops them (epic #275; see [composition](features/composition.md)) | | `0029_rig_vignetting.sql` | `presets.vignetting` (JSON, nullable): lens vignetting per focal length. Additive (issue #266; see [capture](features/capture.md)) | -| `0028_shot_steps.sql` | `shot_steps`: a shot's stored progression (photo + optional framing + optional overlay per step). Additive, no backfill: a shot without rows shows its photos as steps (issue #266; see [sequences](features/sequences.md)) | +| `0028_shot_steps.sql` | `shot_steps`: a shot's stored steps (photo + optional framing; the `overlay_id` column is unused since 0030). Additive, no backfill: a shot without rows shows its photos as steps (issue #266) | | `0027_fixtures.sql` | `mesh_networks` and `fixtures`: the lights a workspace controls from the phone and the Bluetooth Mesh networks the phone provisions. Additive (epic #206, issue #208; see [lights](features/lights.md)) | | `0026_devices_per_user.sql` | `devices` keyed `(user_id, id)` instead of `id`; `viewfinder_layouts` rebuilt with `device_id` as a composite reference to `devices(user_id, id)` (the layout owner's device) and an index on `(user_id, device_id)`. Both tables rebuilt, every row copied with its owner; a `device_id` that was not one of the layout owner's devices is cleared (#224) | | `0025_admin_audit.sql` | `admin_audit`: every change an operator makes in the admin plane, written in the same batch as the change. Additive (#224; see [admin](features/admin.md#audit-trail-224)) | @@ -63,13 +64,15 @@ Every project and rig belongs to a workspace, every device and viewfinder layout `photos(id, shot_id → shots CASCADE, ordinal UNIQUE per shot, source camera|upload|drawn (non-camera photos: no framing, lens_mm 0), timestamp, lat, lon (both NULL when captured without GPS), gps_accuracy_m, position_corrected, preset_id → presets SET NULL, lens_mm, r2_object_key UNIQUE, width, height, framing JSON, device JSON, created_at, size_bytes)`. `size_bytes` (the stored image, for storage accounting) is NULL for photos uploaded before migration 0021 -`overlays(id, photo_id → photos CASCADE, name, description, drawing JSON, presentation JSON, render_key UNIQUE, position, created_at, updated_at)`: a drawing + look over one photo. `drawing` = `{ v: 1, shapes: [...], look: {...} }`, `presentation` = `{ mode, frame: { width_fraction, height_fraction } | null, label, rig_id?, lens_mm? }` (shapes in `packages/vocab/src/compose.ts`). `render_key` is the flattened image in R2 (`renders/overlays/.`). See [compose](features/compose.md). `timeline_id → timelines CASCADE` + `clip_id` (no FK) make it a timeline clip's own overlay (issue #31); shots do not embed those. +`overlays(…)`: the drawings of issue #12 before epic #275. Migration 0030 copied them into `layers`; nothing reads the table any more except the purge (its old renders under `renders/overlays/`), and a later release drops it. + +`layers(id, shot_id → shots CASCADE, kind drawing|adjustment, name, description, position (< 0 below the footage), visible, locked, opacity, space frame|photo, photo_id → photos CASCADE (photo space), range JSON (null = every step, else stored step ids), shapes JSON (drawing), look JSON (adjustment), timeline_id → timelines CASCADE, clip_id, created_at, updated_at)`: the composition (epic #275). A layer with `timeline_id` + `clip_id` (no FK) is a timeline clip's own; shots embed only their own, without `shapes`. `step_bakes(shot_id → shots CASCADE, step_key, hash, render_key, updated_at, PK (shot_id, step_key))`: one flattened image per step, made by the Studio; `step_key` is the stored step's id, or the photo's id for a shot without stored steps; `hash` is vocab `bakeHash` of what it was made from. See [composition](features/composition.md). `sketches(id, shot_id → shots CASCADE | null, name, kind, description, drawing JSON (look null), aspect REAL w/h, render_key UNIQUE, position, created_at, updated_at, timeline_id → timelines CASCADE, clip_id)`: a free canvas on the shot, or (shot null, timeline + clip set; a CHECK enforces one of the two) a sketch clip's own canvas. `kind` is free text; `SKETCH_KINDS` are only suggestions. `framings(id, photo_id → photos CASCADE, name, rig_id → presets SET NULL (null = as shot), lens_mm, frame JSON { width_fraction, height_fraction, x, y }, position, created_at, updated_at)` and `photos.root_framing_id → framings SET NULL` (null = as captured). The API embeds `framings` and `root_framing_id` in every photo. See [framing](features/rig-explorer.md). -`shot_steps(id, shot_id → shots CASCADE, ordinal UNIQUE per shot, photo_id → photos CASCADE, framing_id → framings SET NULL, overlay_id → overlays SET NULL, created_at)`: a sequence as a **progression** (issue #266). A step shows one of the shot's photos, through a saved framing when `framing_id` is set (a zoom step) and as one of its shot overlays when `overlay_id` is set (a light step). **A shot without rows shows its photos as steps**; phone captures never write rows, only the Studio's Sequence stage does. Deleting a framing or overlay clears the reference and the step falls back (framing → the overlay's presentation framing → the photo's root); a photo moved to another shot takes its framings and overlays along and loses its steps. The API embeds `steps` (raw rows) in every shot; both clients resolve them with `stepsOf` (`packages/vocab/src/steps.ts`). See [sequences](features/sequences.md). +`shot_steps(id, shot_id → shots CASCADE, ordinal UNIQUE per shot, photo_id → photos CASCADE, framing_id → framings SET NULL, created_at)`: the time axis of the composition (issue #266, epic #275). A step shows one of the shot's photos, through a saved framing when `framing_id` is set (a zoom step); what is drawn over it comes from the layers whose range includes it. **A shot without rows shows its photos as steps**; phone captures never write rows, only the Studio does. Deleting a framing clears the reference (the step falls back to the photo's root); a photo moved to another shot loses its steps. The API embeds `steps` (raw rows); both clients resolve them with `stepsOf` (`packages/vocab/src/steps.ts`). `viewfinder_layouts(id, user_id → users, name, layout JSON, device_id, created_at, updated_at)`, `(user_id, device_id) → devices(user_id, id)`: phone layouts made in the Studio; a user's own. The shipped default is in code. `device_id` is the device the layout is designed for, always one of the layout owner's own devices. The composite key has no ON DELETE action (SET NULL would clear `user_id` too): deleting a device clears `device_id` in the same batch (`devices.ts`). @@ -91,7 +94,7 @@ Every project and rig belongs to a workspace, every device and viewfinder layout `shooting_days(id, project_id → CASCADE, date YYYY-MM-DD, title, notes, …)` and `day_shots(day_id, shot_id, position, planned_time HH:MM, notes)`. -`timelines(id, project_id → projects CASCADE, name, notes, lock_mode, groups JSON, created_at, updated_at)` and `timeline_clips(id, timeline_id → CASCADE, position, photo_id → photos CASCADE, overlay_id → overlays SET NULL, presentation JSON, duration_ms 100..3600000, notes, title, group_id)`. `groups` holds the sequence blocks' settings (`[{ id, mode each|total, total_ms, split even|edges, edge_weight }]`); `group_id` (no FK, checked on PUT) puts a photo clip in one. A block's clips are adjacent and of one shot; a `total` block's `duration_ms` are the computed split. A clip is either a photo clip (`photo_id` + `presentation`) or a placeholder (`title` only, no photo/overlay/presentation; a CHECK enforces the two shapes); a placeholder with `sketch_id → sketches SET NULL` is a sketch clip (deleting the sketch turns it back into a placeholder). See [timeline](features/timeline.md). +`timelines(id, project_id → projects CASCADE, name, notes, lock_mode, groups JSON, created_at, updated_at)` and `timeline_clips(id, timeline_id → CASCADE, position, photo_id → photos CASCADE, step_id → shot_steps SET NULL, presentation JSON, duration_ms 100..3600000, notes, title, group_id)`. `groups` holds the sequence blocks' settings (`[{ id, mode each|total, total_ms, split even|edges, edge_weight }]`); `group_id` (no FK, checked on PUT) puts a photo clip in one. A block's clips are adjacent and of one shot; a `total` block's `duration_ms` are the computed split. A photo clip may show a stored step of its photo (`step_id`: that step's layers come along). A clip is either a photo clip (`photo_id` + `presentation`) or a placeholder (`title` only, no photo/step/presentation; a CHECK enforces the two shapes); a placeholder with `sketch_id → sketches SET NULL` is a sketch clip (deleting the sketch turns it back into a placeholder). See [timeline](features/timeline.md). ## Monitoring @@ -118,4 +121,4 @@ Every project and rig belongs to a workspace, every device and viewfinder layout ## API shapes -The Worker returns a shot with its photos embedded (`shotToApi` in `apps/worker/src/shots.ts`); `light` as array, `artificial` as boolean, `extra` as object, plus joined `project_name`, `location_name` and per photo `preset_name` and `image_url`. It also embeds `overlays` and `sketches` (ordered, without their `drawing`; each with `render_url` or null) and `steps` (the stored progression, raw rows; empty = the photos are the steps, [sequences](features/sequences.md)). Client types: `apps/studio/src/api.ts`, `apps/mobile/src/api.ts`. +The Worker returns a shot with its photos embedded (`shotToApi` in `apps/worker/src/shots.ts`); `light` as array, `artificial` as boolean, `extra` as object, plus joined `project_name`, `location_name` and per photo `preset_name` and `image_url`. It also embeds `sketches` (ordered, without their `drawing`; each with `render_url` or null), `steps` (raw rows; empty = the photos are the steps), `layers` (the shot's own, in stack order, without `shapes`) and `bakes` (`[{ step_key, hash, url }]`), see [composition](features/composition.md). Client types: `apps/studio/src/api.ts`, `apps/mobile/src/api.ts`. diff --git a/packages/vocab/src/index.ts b/packages/vocab/src/index.ts index 43f61ac..f3bf572 100644 --- a/packages/vocab/src/index.ts +++ b/packages/vocab/src/index.ts @@ -12,3 +12,4 @@ export * from "./files.ts"; export * from "./lighting.ts"; export * from "./vignetting.ts"; export * from "./steps.ts"; +export * from "./layers.ts"; diff --git a/packages/vocab/src/layers.test.ts b/packages/vocab/src/layers.test.ts new file mode 100644 index 0000000..15a0eeb --- /dev/null +++ b/packages/vocab/src/layers.test.ts @@ -0,0 +1,52 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { NEUTRAL_LOOK } from "./compose.ts"; +import { bakeHash, bakeKey, clipLayersOf, freshBake, layersAt, validateLayerInput, type Layer } from "./layers.ts"; + +const U = (n: number) => `11111111-2222-4333-8444-${String(n).padStart(12, "0")}`; +const layer = (id: string, patch: Partial = {}): Layer => ({ + id, shot_id: "s", kind: "drawing", name: id, description: null, position: 0, visible: true, locked: false, opacity: 1, space: "frame", photo_id: null, + range: null, look: null, timeline_id: null, clip_id: null, created_at: "2026-10-27T10:00:00Z", updated_at: "2026-10-27T10:00:00Z", ...patch, +}); +const s1 = { id: "st1", stored: true, photo: { id: "p1" } }, s2 = { id: "st2", stored: true, photo: { id: "p2" } }; +const derived = { id: "photo:p1", stored: false, photo: { id: "p1" } }; + +test("which layers a step shows: visible, in range, on the step's photo in photo space; bottom to top", () => { + const ls = [layer("top", { position: 5 }), layer("hidden", { visible: false }), layer("ranged", { range: ["st2"] }), layer("p2only", { space: "photo", photo_id: "p2" }), layer("below", { position: -1 }), layer("clip", { timeline_id: "t", clip_id: "c" })]; + assert.deepEqual(layersAt(ls, s1).map((l) => l.id), ["below", "top"]); + assert.deepEqual(layersAt(ls, s2).map((l) => l.id), ["below", "p2only", "ranged", "top"]); + // A derived step (no stored steps) only shows layers on every step. + assert.deepEqual(layersAt([layer("ranged", { range: [] }), layer("all")], derived).map((l) => l.id), ["all"]); + assert.deepEqual(clipLayersOf(ls, "c").map((l) => l.id), ["clip"]); +}); + +test("bake hash: null without layers, changes with the frame, a version or the order", () => { + const a = layer("a"), b = layer("b", { position: 1 }); + const step = { ...s1, frame: { width_fraction: 0.5, height_fraction: 0.5 } }; + assert.equal(bakeHash(step, []), null); + const h = bakeHash(step, [a, b])!; + assert.match(h, /^[0-9a-f]{16}$/); + assert.equal(bakeHash(step, [b, a]), h); + assert.notEqual(bakeHash({ ...step, frame: { width_fraction: 0.4, height_fraction: 0.5 } }, [a, b]), h); + assert.notEqual(bakeHash(step, [a, { ...b, updated_at: "2026-10-27T11:00:00Z" }]), h); + assert.notEqual(bakeHash(step, [a, { ...b, position: -1 }]), h); + assert.equal(bakeKey(s1), "st1"); + assert.equal(bakeKey(derived), "p1"); + assert.equal(freshBake([{ step_key: "st1", hash: h, url: "/x" }], s1, h)?.url, "/x"); + assert.equal(freshBake([{ step_key: "st1", hash: "old", url: "/x" }], s1, h), null); +}); + +test("input validation", () => { + const ok = { shot_id: U(1), kind: "drawing", name: "Marks", position: 0, visible: true, locked: false, opacity: 1, space: "frame", shapes: [] }; + assert.equal(validateLayerInput(ok), null); + assert.equal(validateLayerInput({ ...ok, kind: "adjustment", shapes: null, look: { ...NEUTRAL_LOOK, exposure: 0.5 } }), null); + assert.match(validateLayerInput({ ...ok, kind: "adjustment", look: null })!, /look/); + assert.match(validateLayerInput({ ...ok, look: NEUTRAL_LOOK })!, /no look/); + assert.match(validateLayerInput({ ...ok, space: "photo" })!, /photo_id/); + assert.match(validateLayerInput({ ...ok, photo_id: U(2) })!, /photo_id/); + assert.match(validateLayerInput({ ...ok, range: [U(3), U(3)] })!, /twice/); + assert.match(validateLayerInput({ ...ok, position: 1.5 })!, /position/); + assert.match(validateLayerInput({ ...ok, opacity: 2 })!, /opacity/); + assert.match(validateLayerInput({ ...ok, timeline_id: U(4) })!, /together/); + assert.match(validateLayerInput({ ...ok, name: " " })!, /name/); +}); diff --git a/packages/vocab/src/layers.ts b/packages/vocab/src/layers.ts new file mode 100644 index 0000000..cbf22ed --- /dev/null +++ b/packages/vocab/src/layers.ts @@ -0,0 +1,153 @@ +import { validateDrawing, validateLook, MAX_COMPOSE_NAME, MAX_DESCRIPTION, type Look, type Shape } from "./compose.ts"; +import { withCentre, type Frame } from "./framing.ts"; + +/** + * A shot is a composition (epic #275): an ordered stack of **layers** over its footage, shown at + * every **step** (one frame of the sequence; a single shot has one). A layer has + * - an explicit **order** (`position`): below the footage when negative, above it otherwise, so a + * plate can sit under the photo and marks over a grade; + * - a **range**: the steps it shows on (`null` = every step, else stored step ids); + * - a **space**: `frame` = fractions of the rig frame a step is shown with (stays put through a zoom + * or a pan), `photo` = fractions of one photo's pixels (only shows on that photo's steps). + * A `drawing` layer holds shapes (compose.ts), an `adjustment` layer a look that changes everything + * below it. Timeline clips own layers of their own (`timeline_id` + `clip_id`), drawn over the clip. + * + * The Studio composes the stack live and bakes one flattened image per step for the phone + * (`bakeHash` says when a bake is stale). Both clients and the Worker share these rules. + */ + +export const LAYER_KINDS = ["drawing", "adjustment"] as const; +export type LayerKind = (typeof LAYER_KINDS)[number]; +export const LAYER_SPACES = ["frame", "photo"] as const; +export type LayerSpace = (typeof LAYER_SPACES)[number]; + +export interface Layer { + id: string; + shot_id: string; + kind: LayerKind; + name: string; + description: string | null; + /** Stack order: < 0 below the footage, >= 0 above it; higher is on top. */ + position: number; + visible: boolean; + locked: boolean; + /** 0..1, the whole layer. */ + opacity: number; + space: LayerSpace; + /** The photo a `photo`-space layer belongs to; null in frame space. */ + photo_id: string | null; + /** Stored step ids the layer shows on; null = every step. Ignored for clip-owned layers. */ + range: string[] | null; + /** Adjustment layers: exposure, contrast, saturation, black and white, tint, vignette, softness. */ + look: Look | null; + /** Drawing layers; only present when fetched with shapes (shots embed layers without them). */ + shapes?: Shape[]; + /** A timeline clip's own layer (both set), else a layer of the shot. */ + timeline_id: string | null; + clip_id: string | null; + created_at: string; + updated_at: string | null; +} + +export const MAX_LAYERS = 50; +export const MAX_LAYER_POSITION = 1000; + +/** What a PUT carries. */ +export interface LayerInput { + shot_id: string; kind: LayerKind; name: string; description?: string | null; position: number; visible: boolean; locked: boolean; opacity: number; + space: LayerSpace; photo_id?: string | null; range?: string[] | null; shapes?: Shape[] | null; look?: Look | null; + timeline_id?: string | null; clip_id?: string | null; +} + +const isUuid = (v: unknown) => typeof v === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v); +const num = (v: unknown, min: number, max: number) => typeof v === "number" && Number.isFinite(v) && v >= min && v <= max; + +/** Structural check of a layer body (interactive edits: strict). Null = fine. */ +export function validateLayerInput(v: unknown): string | null { + if (typeof v !== "object" || v === null || Array.isArray(v)) return "layer must be an object"; + const l = v as Record; + if (!isUuid(l.shot_id)) return "shot_id must be a UUID"; + if (!LAYER_KINDS.includes(l.kind as LayerKind)) return `kind must be one of ${LAYER_KINDS.join(", ")}`; + if (typeof l.name !== "string" || !l.name.trim() || l.name.length > MAX_COMPOSE_NAME) return `name must be 1..${MAX_COMPOSE_NAME} characters`; + if (l.description != null && (typeof l.description !== "string" || l.description.length > MAX_DESCRIPTION)) return `description must be a string (max ${MAX_DESCRIPTION})`; + if (!Number.isInteger(l.position) || !num(l.position, -MAX_LAYER_POSITION, MAX_LAYER_POSITION)) return `position must be a whole number within ±${MAX_LAYER_POSITION}`; + if (typeof l.visible !== "boolean" || typeof l.locked !== "boolean") return "visible and locked must be true or false"; + if (!num(l.opacity, 0, 1)) return "opacity must be 0..1"; + if (!LAYER_SPACES.includes(l.space as LayerSpace)) return `space must be one of ${LAYER_SPACES.join(", ")}`; + if (l.space === "photo" ? !isUuid(l.photo_id) : l.photo_id != null) return "photo_id is required in photo space and absent in frame space"; + if (l.range != null) { + if (!Array.isArray(l.range) || l.range.length > 500 || !l.range.every(isUuid)) return "range must be null or up to 500 step ids"; + if (new Set(l.range).size !== l.range.length) return "range lists a step twice"; + } + if (l.kind === "drawing") { + if (l.look != null) return "a drawing layer has no look"; + const err = validateDrawing({ v: 1, shapes: l.shapes ?? [], look: null }, false); + if (err) return err; + } else { + if (l.shapes != null && (!Array.isArray(l.shapes) || l.shapes.length)) return "an adjustment layer has no shapes"; + const err = validateLook(l.look); + if (err) return err; + } + if ((l.timeline_id == null) !== (l.clip_id == null)) return "timeline_id and clip_id go together"; + if (l.timeline_id != null && (!isUuid(l.timeline_id) || !isUuid(l.clip_id))) return "timeline_id and clip_id must be UUIDs"; + return null; +} + +/** Stack order: position, then creation, then id (stable for equal positions). */ +export const byPosition = >(a: L, b: L) => + a.position - b.position || (a.created_at < b.created_at ? -1 : a.created_at > b.created_at ? 1 : 0) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0); + +/** The step a layer is checked against: its key and its photo. */ +export interface StepRef { id: string; stored: boolean; photo: { id: string } } + +/** True when a shot layer shows on this step (visible, in range, on the step's photo in photo space). */ +export function layerOnStep(l: Pick, step: StepRef): boolean { + if (!l.visible || l.timeline_id) return false; + if (l.space === "photo" && l.photo_id !== step.photo.id) return false; + if (l.range === null) return true; + return step.stored && l.range.includes(step.id); +} + +/** The shot's layers a step shows, bottom to top. */ +export const layersAt = (layers: readonly L[], step: StepRef): L[] => layers.filter((l) => layerOnStep(l, step)).sort(byPosition); + +/** A clip's own layers, bottom to top (clips show their step's layers under these). */ +export const clipLayersOf = (layers: readonly L[], clipId: string): L[] => layers.filter((l) => l.clip_id === clipId && l.visible).sort(byPosition); + +/** The key a step's bake is stored under: the stored step's id, or the photo's id for a shot without stored steps. */ +export const bakeKey = (step: StepRef): string => (step.stored ? step.id : step.photo.id); + +/** FNV-1a, 64 bits as two 32-bit halves (no crypto needed: this only tells versions apart). */ +function fnv64(s: string): string { + let a = 0x811c9dc5, b = 0x050c5d1f; + for (let i = 0; i < s.length; i++) { + const c = s.charCodeAt(i); + a = Math.imul(a ^ c, 0x01000193) >>> 0; + b = Math.imul(b ^ c, 0x01000193) >>> 0; + b = (b + (a >>> 7)) >>> 0; + } + return a.toString(16).padStart(8, "0") + b.toString(16).padStart(8, "0"); +} + +/** + * What a step's bake depends on: the photo, the frame it is shown with and every layer it shows + * (id, version, order). Equal hashes = the bake is fresh. Null when the step shows no layers: then + * nothing needs baking and the photo is the picture. + */ +export function bakeHash(step: StepRef & { frame: Frame | null }, layers: readonly Pick[]): string | null { + const on = layers.filter((l) => layerOnStep(l, step)).sort(byPosition); + if (!on.length) return null; + const f = step.frame ? withCentre(step.frame) : null; + const frame = f ? [f.width_fraction, f.height_fraction, f.x, f.y].map((n) => n.toFixed(4)).join(",") : "none"; + return fnv64(["bake.v1", step.photo.id, frame, ...on.map((l) => `${l.id}:${l.updated_at ?? l.created_at}:${l.position}`)].join("|")); +} + +/** A stored bake as shots embed it. */ +export interface StepBake { step_key: string; hash: string; url: string } + +/** The bake to show for a step: only when its hash matches what the step shows now. */ +export function freshBake(bakes: readonly StepBake[] | undefined, step: StepRef, hash: string | null): StepBake | null { + if (!hash) return null; + const b = bakes?.find((x) => x.step_key === bakeKey(step)); + return b && b.hash === hash ? b : null; +} diff --git a/packages/vocab/src/steps.test.ts b/packages/vocab/src/steps.test.ts index 94ad76b..cf952f5 100644 --- a/packages/vocab/src/steps.test.ts +++ b/packages/vocab/src/steps.test.ts @@ -7,27 +7,19 @@ const fr = (id: string, photo: string, frame: Framing["frame"]): Framing => ({ i const captured = { frame: { width_fraction: 0.6, height_fraction: 0.45 } }; const p1 = { id: "p1", framing: captured, framings: [fr("f1", "p1", { width_fraction: 0.3, height_fraction: 0.2, x: 0.3, y: 0.4 })], root_framing_id: null }; const p2 = { id: "p2", framing: captured, framings: [], root_framing_id: null }; -const o1 = { id: "o1", photo_id: "p1", presentation: { mode: "fit", frame: { width_fraction: 0.5, height_fraction: 0.5, x: 0.7 }, framing_id: "gone", label: null }, render_url: "/r/o1" }; -const o2 = { id: "o2", photo_id: "p1", presentation: { mode: "fit", frame: null, framing_id: "f1", label: null }, render_url: null }; -const step = (id: string, ordinal: number, photo_id: string, framing_id: string | null = null, overlay_id: string | null = null): ShotStep => ({ id, shot_id: "s", ordinal, photo_id, framing_id, overlay_id }); +const step = (id: string, ordinal: number, photo_id: string, framing_id: string | null = null): ShotStep => ({ id, shot_id: "s", ordinal, photo_id, framing_id }); test("without stored steps the photos are the steps, in order", () => { - const out = stepsOf({ photos: [p1, p2], overlays: [o1] }); + const out = stepsOf({ photos: [p1, p2] }); assert.deepEqual(out.map((s) => [s.id, s.stored, s.frame?.width_fraction]), [["photo:p1", false, 0.6], ["photo:p2", false, 0.6]]); }); -test("stored steps resolve framing and overlay, sorted by ordinal; dangling ids read as null", () => { - const out = stepsOf({ photos: [p1, p2], overlays: [o1, o2], steps: [step("b", 1, "p1", null, "o1"), step("a", 0, "p1", "f1"), step("c", 2, "p2", "nope", "o1")] }); - assert.deepEqual(out.map((s) => s.id), ["a", "b", "c"]); - assert.equal(out[0].framing?.id, "f1"); +test("stored steps resolve their framing, sorted by ordinal; a dangling framing reads as null", () => { + const out = stepsOf({ photos: [p1, p2], steps: [step("b", 1, "p2", "nope"), step("a", 0, "p1", "f1")] }); + assert.deepEqual(out.map((s) => s.id), ["a", "b"]); assert.deepEqual(out[0].frame, { width_fraction: 0.3, height_fraction: 0.2, x: 0.3, y: 0.4 }); - assert.equal(out[1].overlay?.id, "o1"); - // The overlay's framing is gone: its kept frame copy applies. - assert.equal(out[1].frame?.x, 0.7); - // p2 has no framing "nope" and o1 is p1's: both null, the root frame applies. - assert.equal(out[2].framing, null); - assert.equal(out[2].overlay, null); - assert.equal(out[2].frame?.width_fraction, 0.6); + assert.equal(out[1].framing, null); + assert.equal(out[1].frame?.width_fraction, 0.6); assert.equal(out.every((s) => s.stored), true); }); @@ -35,11 +27,11 @@ test("steps whose photos are all gone fall back to the photos", () => { assert.equal(stepsOf({ photos: [p1], steps: [step("a", 0, "deleted")] })[0].id, "photo:p1"); }); -test("step frame: framing, else the overlay's presentation framing, else root", () => { - assert.equal(stepFrame(p1, null, o2)?.width_fraction, 0.3); - assert.equal(stepFrame(p1, null, { ...o2, presentation: { mode: "fit", frame: null, label: null } })?.width_fraction, 0.6); - assert.equal(stepFrame({ id: "u", framing: null }, null, null), null); - assert.equal(stepKey({ photo: p1, framing: p1.framings[0], overlay: null }), "p1|f1|"); +test("step frame: framing, else root; key tells pictures apart", () => { + assert.equal(stepFrame(p1, p1.framings[0])?.width_fraction, 0.3); + assert.equal(stepFrame(p1, null)?.width_fraction, 0.6); + assert.equal(stepFrame({ id: "u", framing: null }, null), null); + assert.equal(stepKey({ photo: p1, framing: p1.framings[0] }), "p1|f1"); }); test("input validation", () => { @@ -48,15 +40,13 @@ test("input validation", () => { assert.match(validateStepInput({})!, /array/); assert.match(validateStepInput([{ id: "x", photo_id: id2 }])!, /id must be a UUID/); assert.match(validateStepInput([{ id, photo_id: id2 }, { id, photo_id: id2 }])!, /repeats/); - assert.match(validateStepInput([{ id, photo_id: id2, overlay_id: 3 }])!, /overlay_id/); + assert.match(validateStepInput([{ id, photo_id: id2, framing_id: 3 }])!, /framing_id/); assert.match(validateStepInput(Array.from({ length: 501 }, () => ({ id, photo_id: id2 })))!, /at most/); }); -test("membership: photo of the shot, framing of that photo, shot overlay of that photo", () => { - const have = { photos: new Set(["p1", "p2"]), framingPhoto: new Map([["f1", "p1"]]), overlayPhoto: new Map([["o1", { photo_id: "p1", clip_owned: false }], ["oc", { photo_id: "p1", clip_owned: true }]]) }; - assert.equal(stepsProblem([{ id: "a", photo_id: "p1", framing_id: "f1", overlay_id: "o1" }], have), null); +test("membership: photo of the shot, framing of that photo", () => { + const have = { photos: new Set(["p1", "p2"]), framingPhoto: new Map([["f1", "p1"]]) }; + assert.equal(stepsProblem([{ id: "a", photo_id: "p1", framing_id: "f1" }], have), null); assert.match(stepsProblem([{ id: "a", photo_id: "p9" }], have)!, /not one of the shot's photos/); assert.match(stepsProblem([{ id: "a", photo_id: "p2", framing_id: "f1" }], have)!, /framing/); - assert.match(stepsProblem([{ id: "a", photo_id: "p2", overlay_id: "o1" }], have)!, /overlay is not/); - assert.match(stepsProblem([{ id: "a", photo_id: "p1", overlay_id: "oc" }], have)!, /timeline clip/); }); diff --git a/packages/vocab/src/steps.ts b/packages/vocab/src/steps.ts index 55b6c88..60c9b27 100644 --- a/packages/vocab/src/steps.ts +++ b/packages/vocab/src/steps.ts @@ -1,12 +1,12 @@ -import type { Presentation } from "./compose.ts"; -import { referencedFrame, rootFrame, withCentre, type Frame, type Framing } from "./framing.ts"; +import { rootFrame, withCentre, type Frame, type Framing } from "./framing.ts"; /** - * Steps (issue #266): a sequence as a progression. A step shows one of the shot's photos, - * optionally through a saved framing (a zoom step) and optionally as one of its overlays (a light - * step). A shot without stored steps shows its photos as steps, so every strip, scrub, badge and - * timeline block iterates `stepsOf(shot)` and never `photos` directly. Photos stay the unit for - * capture, the cover, positions and the per-photo stages. + * Steps (issue #266): the time axis of a shot's composition (epic #275). A step shows one of the + * shot's photos, optionally through a saved framing (a zoom step); what is drawn over it comes from + * the shot's layers (layers.ts), whose ranges pick the steps they show on. A shot without stored + * steps shows its photos as steps, so every strip, scrub, badge and timeline block iterates + * `stepsOf(shot)` and never `photos` directly. Photos stay the unit for capture, the cover, + * positions and the per-photo stages. */ export interface ShotStep { @@ -15,66 +15,52 @@ export interface ShotStep { ordinal: number; photo_id: string; framing_id: string | null; - overlay_id: string | null; } /** What the Studio sends: the whole list, in order. */ -export interface StepInput { id: string; photo_id: string; framing_id?: string | null; overlay_id?: string | null } +export interface StepInput { id: string; photo_id: string; framing_id?: string | null } export const MAX_STEPS = 500; /** The part of a photo a step needs; the phone's and the Studio's photo types both fit. */ export interface StepPhoto { id: string; framing: Record | null; framings?: readonly Framing[]; root_framing_id?: string | null } -/** The part of a shot overlay a step needs. */ -export interface StepOverlay { id: string; photo_id: string; presentation: unknown; render_url: string | null } -export interface ResolvedStep

{ +export interface ResolvedStep

{ /** The step's id, or `photo:` for a step derived from a photo. */ id: string; photo: P; framing: Framing | null; - overlay: O | null; /** The frame the step is shown with (see `stepFrame`); null for photos without a frame (uploads, drawings). */ frame: Required | null; /** False when the shot has no stored steps and this one stands for a photo. */ stored: boolean; } -/** - * The frame a step is shown with: its framing, else the frame its overlay was drawn for (the - * overlay's presentation framing, or the copy kept when that was deleted), else the photo's root. - */ -export function stepFrame(photo: StepPhoto, framing: Framing | null, overlay: StepOverlay | null): Required | null { - if (framing) return withCentre(framing.frame); - if (overlay && overlay.presentation && typeof overlay.presentation === "object") { - const p = overlay.presentation as Presentation; - const f = referencedFrame(photo, { framing_id: p.framing_id, frame: p.frame ?? null }); - if (f) return f; - } - return rootFrame(photo); +/** The frame a step is shown with: its framing, else the photo's root. */ +export function stepFrame(photo: StepPhoto, framing: Framing | null): Required | null { + return framing ? withCentre(framing.frame) : rootFrame(photo); } /** * The steps a shot shows, in order. Without stored steps (or when none of them resolves to a - * photo) the photos are the steps. A framing or overlay id that no longer resolves reads as null, - * so a stale payload never breaks a view; the server clears those references on delete anyway. + * photo) the photos are the steps. A framing id that no longer resolves reads as null, so a stale + * payload never breaks a view; the server clears those references on delete anyway. */ -export function stepsOf

(shot: { photos: readonly P[]; overlays?: readonly O[]; steps?: readonly ShotStep[] | null }): ResolvedStep[] { - const fromPhotos = () => shot.photos.map((photo) => ({ id: `photo:${photo.id}`, photo, framing: null, overlay: null, frame: rootFrame(photo), stored: false })); +export function stepsOf

(shot: { photos: readonly P[]; steps?: readonly ShotStep[] | null }): ResolvedStep

[] { + const fromPhotos = () => shot.photos.map((photo) => ({ id: `photo:${photo.id}`, photo, framing: null, frame: rootFrame(photo), stored: false })); if (!shot.steps?.length) return fromPhotos(); - const out: ResolvedStep[] = []; + const out: ResolvedStep

[] = []; for (const s of [...shot.steps].sort((a, b) => a.ordinal - b.ordinal)) { const photo = shot.photos.find((p) => p.id === s.photo_id); if (!photo) continue; const framing = (s.framing_id && photo.framings?.find((f) => f.id === s.framing_id)) || null; - const overlay = (s.overlay_id && shot.overlays?.find((o) => o.id === s.overlay_id && o.photo_id === photo.id)) || null; - out.push({ id: s.id, photo, framing, overlay, frame: stepFrame(photo, framing, overlay), stored: true }); + out.push({ id: s.id, photo, framing, frame: stepFrame(photo, framing), stored: true }); } return out.length ? out : fromPhotos(); } /** What makes two steps the same picture (a photo may repeat in a progression). */ -export const stepKey = (s: { photo: { id: string }; framing: { id: string } | null; overlay: { id: string } | null }) => `${s.photo.id}|${s.framing?.id ?? ""}|${s.overlay?.id ?? ""}`; +export const stepKey = (s: { photo: { id: string }; framing: { id: string } | null }) => `${s.photo.id}|${s.framing?.id ?? ""}`; const isUuid = (v: unknown) => typeof v === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v); @@ -90,29 +76,17 @@ export function validateStepInput(v: unknown): string | null { if (ids.has(s.id as string)) return `steps[${i}].id repeats`; ids.add(s.id as string); if (!isUuid(s.photo_id)) return `steps[${i}].photo_id must be a UUID`; - for (const k of ["framing_id", "overlay_id"] as const) if (s[k] != null && !isUuid(s[k])) return `steps[${i}].${k} must be a UUID or null`; + if (s.framing_id != null && !isUuid(s.framing_id)) return `steps[${i}].framing_id must be a UUID or null`; } return null; } -/** - * Membership check, pure so it is testable: every step on a photo of the shot, every framing a - * framing of that photo, every overlay a shot overlay of that photo (a timeline clip's own - * overlay is never a step: shots never show those). - */ -export function stepsProblem( - steps: readonly StepInput[], - have: { photos: ReadonlySet; framingPhoto: ReadonlyMap; overlayPhoto: ReadonlyMap }, -): string | null { +/** Membership check, pure so it is testable: every step on a photo of the shot, every framing a framing of that photo. */ +export function stepsProblem(steps: readonly StepInput[], have: { photos: ReadonlySet; framingPhoto: ReadonlyMap }): string | null { for (let i = 0; i < steps.length; i++) { const s = steps[i]; if (!have.photos.has(s.photo_id)) return `steps[${i}]: the photo is not one of the shot's photos`; if (s.framing_id && have.framingPhoto.get(s.framing_id) !== s.photo_id) return `steps[${i}]: the framing is not a framing of that photo`; - if (s.overlay_id) { - const o = have.overlayPhoto.get(s.overlay_id); - if (!o || o.photo_id !== s.photo_id) return `steps[${i}]: the overlay is not an overlay of that photo`; - if (o.clip_owned) return `steps[${i}]: the overlay belongs to a timeline clip`; - } } return null; } From 1aa370a19e1606eaec6df1245450890d05531c70 Mon Sep 17 00:00:00 2001 From: bubulux Date: Tue, 27 Oct 2026 13:07:41 +0100 Subject: [PATCH 2/4] Studio: layers editor, live composition, bakes, timeline clips (#275) - Compose stage merges the old Compose and Sequence stages: the steps in the foot, the selected step composed live on the stage, the layer stack top to bottom with the footage in it, drawing on the selected layer, ranges (every step, this step, chosen steps), frame or photo anchoring, adjustment looks; sketches autosave like everything else - live composition: adjustment layers as backdrop-filter, drawing layers as SVG in photo or frame space; strips and cards show a step's bake, with a mark while it is stale - the bakery bakes stale steps in the background from the shell - timeline: clips show a step (its layers) plus their own layers, edited inline with the same layer editor; Make a sequence keeps the steps it can and moves clip layers onto the new steps Co-Authored-By: Claude Opus 5.5 --- apps/studio/src/ClipEditing.tsx | 154 +++++---- apps/studio/src/ClipPicture.tsx | 44 +++ apps/studio/src/Framed.tsx | 23 +- apps/studio/src/FromTimelines.tsx | 28 +- apps/studio/src/Inspector.tsx | 4 +- apps/studio/src/SequenceStage.tsx | 168 --------- apps/studio/src/SettingsPage.tsx | 4 +- apps/studio/src/ShotCard.tsx | 4 +- apps/studio/src/ShotView.tsx | 53 ++- apps/studio/src/Timeline.tsx | 54 +-- apps/studio/src/TimelineParts.tsx | 108 +++--- apps/studio/src/api.ts | 71 ++-- apps/studio/src/apps/ShotApp.tsx | 2 +- apps/studio/src/compose/Compose.tsx | 294 ---------------- apps/studio/src/compose/ComposeStage.tsx | 413 +++++++++++++++++++++++ apps/studio/src/compose/baker.ts | 67 ++++ apps/studio/src/compose/layers.tsx | 210 ++++++++++++ apps/studio/src/compose/render.ts | 55 ++- apps/studio/src/compose/stack.tsx | 129 +++++++ apps/studio/src/format.ts | 18 +- apps/studio/src/router.ts | 8 +- apps/studio/src/shortcuts.ts | 21 +- apps/studio/src/styles.css | 20 +- apps/studio/src/vignette.ts | 6 +- apps/studio/src/wm/Shell.tsx | 3 + docs/shortcuts.md | 19 +- 26 files changed, 1232 insertions(+), 748 deletions(-) create mode 100644 apps/studio/src/ClipPicture.tsx delete mode 100644 apps/studio/src/SequenceStage.tsx delete mode 100644 apps/studio/src/compose/Compose.tsx create mode 100644 apps/studio/src/compose/ComposeStage.tsx create mode 100644 apps/studio/src/compose/baker.ts create mode 100644 apps/studio/src/compose/layers.tsx create mode 100644 apps/studio/src/compose/stack.tsx diff --git a/apps/studio/src/ClipEditing.tsx b/apps/studio/src/ClipEditing.tsx index 5151e38..3016788 100644 --- a/apps/studio/src/ClipEditing.tsx +++ b/apps/studio/src/ClipEditing.tsx @@ -1,13 +1,13 @@ import type { ComponentChildren } from "preact"; import { useEffect, useRef, useState } from "preact/hooks"; -import { lookFilter, NEUTRAL_LOOK, SKETCH_ASPECTS, withCentre, type Drawing, type Look, type Presentation, type Shape } from "@openclapper/vocab"; -import { deleteClipSketch, deleteOverlay, fetchOverlay, fetchSketch, putClipOverlay, putClipSketch, type Photo, type Preset, type Timeline, type TimelineClip } from "./api"; +import { byPosition, layersAt, SKETCH_ASPECTS, withCentre, type Drawing, type Layer, type Presentation, type Shape } from "@openclapper/vocab"; +import { deleteClipSketch, fetchLayer, fetchSketch, putClipSketch, type Photo, type Preset, type Timeline, type TimelineClip } from "./api"; import { DrawSurface } from "./compose/DrawSurface"; -import { DrawToolbar, drawingKeys, LookSection, ShapeSection, useDrawingEditor } from "./compose/editor"; -import type { CanvasRect } from "./compose/geometry"; -import { BLUR_FRACTION, renderOverlay, renderSketch, vignetteCss } from "./compose/render"; -import { frameModeLabel } from "./format"; -import { Framed, framedLayout, type MaskMode } from "./Framed"; +import { DrawToolbar, drawingKeys, ShapeSection, useDrawingEditor } from "./compose/editor"; +import { LayerList, LayerProps, useLayerDocs } from "./compose/layers"; +import { renderSketch } from "./compose/render"; +import { Composition, layerRect, stackAreas, useShotLayers } from "./compose/stack"; +import { Framed, type MaskMode } from "./Framed"; import { FramingSelect, presentationFrame, rootPresentation } from "./FramingSelect"; import { bind, NUDGE, useKeys } from "./keys"; import { AS_SHOT, choiceLabel, DragFrame, frameForChoice, lensesFor, Pickers, type RigChoice } from "./RigExplorer"; @@ -17,18 +17,16 @@ import { Button, confirmDialog, Empty, Icon, Input, Panel, PanelBody, PanelHead, /** * Inline clip editing on the timeline stage (issue #31), like a video editor's inspector: the * frame handle or the drawing canvas goes onto the stage, the settings into the right drawer. - * Everything is timeline-scoped: a re-frame writes only the clip's presentation, an overlay or a - * sketch belongs to the clip until it is promoted to the shot (shot view → From timelines). - * Changes save on their own: the clip with the timeline's autosave, a drawing 1.2 s after the last - * stroke and when the edit ends. + * Everything is timeline-scoped: a re-frame writes only the clip's presentation, the clip's own + * layers (epic #275) and a sketch clip's sketch belong to the clip until they are promoted to the + * shot (shot view → Compose → From timelines). Changes save on their own: the clip with the + * timeline's autosave, layers a moment after the last change, a sketch 1.2 s after the last stroke. */ -export type EditKind = "reframe" | "overlay" | "sketch"; +export type EditKind = "reframe" | "layers" | "sketch"; - -type OverlayDoc = { kind: "overlay"; id: string; name: string; shapes: Shape[]; look: Look; isNew: boolean }; type SketchDoc = { kind: "sketch"; id: string; name: string; shapes: Shape[]; aspect: number; isNew: boolean }; -type Doc = OverlayDoc | SketchDoc; +type Doc = SketchDoc; interface Options { timeline: Timeline; @@ -66,6 +64,22 @@ export function useClipEditing({ timeline, clips, presets, onChange }: Options): const c = editing ? clips.find((x) => x.clip.id === editing.clipId) ?? null : null; const cRef = useRef(c); cRef.current = c; + + // ----- the clip's own layers (epic #275): the same editing as the shot's Compose stage ----- + const layersClip = editing?.kind === "layers" && c?.kind === "photo" ? c : null; + const docs = useLayerDocs({ + load: async () => (layersClip ? Promise.all(layersClip.ownLayers.map((l) => fetchLayer(l.id))) : []), + deps: [layersClip?.clip.id ?? null], + onSaved: (r) => { const t = tRef.current; onChange({ ...t, layers: [...t.layers.filter((x) => x.id !== r.layer.id), { ...r.layer, shapes: undefined }] }); }, + onDeleted: (id) => { const t = tRef.current; onChange({ ...t, layers: t.layers.filter((x) => x.id !== id) }); }, + }); + const shotLayers = useShotLayers(layersClip?.shot ?? null); + const [layerSel, setLayerSel] = useState(null); + useEffect(() => { setLayerSel(null); }, [layersClip?.clip.id]); + const picked = docs.list.find((l) => l.id === layerSel) ?? null; + const drawingLayer = picked && picked.kind === "drawing" && !picked.locked && picked.visible ? picked : null; + const led = useDrawingEditor(drawingLayer?.shapes ?? [], (shapes) => { if (drawingLayer) docs.update(drawingLayer.id, { shapes }); }, false); + useEffect(() => { led.reset(false); }, [drawingLayer?.id]); // The clip went away (removed, or its shot deleted): the edit ends without saving. useEffect(() => { if (editing && !c) { setEditing(null); setDoc(null); } }, [editing, c]); @@ -83,13 +97,7 @@ export function useClipEditing({ timeline, clips, presets, onChange }: Options): setSave("saving"); try { const owner = { timeline_id: t.id, clip_id: cl.clip.id }; - if (d.kind === "overlay" && cl.kind === "photo") { - const drawing: Drawing = { v: 1, shapes: d.shapes, look: d.look }; - const res = await putClipOverlay(d.id, { photo_id: cl.photo.id, name: d.name.trim() || "Clip overlay", description: null, drawing, presentation: cl.pres, ...owner }, await renderOverlay(cl.photo, drawing)); - const { drawing: _drawing, ...row } = res.overlay; - const now = tRef.current; - patchClip(cl.clip.id, { overlay_id: row.id }, { overlays: [...now.overlays.filter((o) => o.id !== row.id), row] }); - } else if (d.kind === "sketch") { + if (d.kind === "sketch") { const drawing: Drawing = { v: 1, shapes: d.shapes, look: null }; const name = d.name.trim() || "Sketch"; const res = await putClipSketch(d.id, { name, kind: null, description: null, drawing, aspect: d.aspect, ...owner }, await renderSketch(drawing, d.aspect)); @@ -120,6 +128,11 @@ export function useClipEditing({ timeline, clips, presets, onChange }: Options): async function stop(): Promise { if (!editing) return true; + if (editing.kind === "layers") { + if (!(await docs.flush()) && !(await confirmDialog({ title: "The layers are not saved", body: "Saving them failed. Leave the edit anyway? The changes since the last save are lost.", confirmLabel: "Leave without saving", danger: true, cancelLabel: "Keep editing" }))) return false; + setEditing(null); setLayerSel(null); + return true; + } const ok = await saveNow(); if (!ok) { const r = await confirmDialog({ title: "The drawing is not saved", body: "Saving it failed. Leave the edit anyway? The changes since the last save are lost.", confirmLabel: "Leave without saving", danger: true, cancelLabel: "Keep editing" }); @@ -136,20 +149,17 @@ export function useClipEditing({ timeline, clips, presets, onChange }: Options): setEditing({ kind, clipId: cl.clip.id }); return; } - if (kind === "overlay" && cl.kind !== "photo") return; - if (kind === "sketch" && cl.kind === "photo") return; + if (kind === "layers") { + if (cl.kind !== "photo") return; + setEditing({ kind, clipId: cl.clip.id }); + return; + } + if (cl.kind === "photo") return; setEditing({ kind, clipId: cl.clip.id }); setLoading(true); try { let d: Doc; - if (kind === "overlay" && cl.kind === "photo") { - const own = cl.overlay?.clip_id === cl.clip.id; - if (cl.overlay) { - // A shot overlay is edited as a copy for this clip: the shot keeps its own. - const o = await fetchOverlay(cl.overlay.id); - d = { kind: "overlay", id: own ? o.id : crypto.randomUUID(), name: own ? o.name : `${o.name} · clip`.slice(0, 80), shapes: o.drawing.shapes, look: o.drawing.look ?? { ...NEUTRAL_LOOK }, isNew: !own }; - } else d = { kind: "overlay", id: crypto.randomUUID(), name: "Clip overlay", shapes: [], look: { ...NEUTRAL_LOOK }, isNew: true }; - } else { + { const sk = cl.kind === "sketch" ? cl.sketch : null; const own = sk?.clip_id === cl.clip.id; if (sk) { @@ -171,29 +181,30 @@ export function useClipEditing({ timeline, clips, presets, onChange }: Options): async function discard() { const d = docRef.current, cl = cRef.current; if (!d || !cl) return; - const ok = await confirmDialog({ title: `Delete “${d.name}”?`, body: d.kind === "overlay" ? "The clip's own overlay is removed; the clip shows the photo as is." : "The clip's own sketch is removed; the clip becomes a placeholder again.", confirmLabel: "Delete", danger: true }); + const ok = await confirmDialog({ title: `Delete “${d.name}”?`, body: "The clip's own sketch is removed; the clip becomes a placeholder again.", confirmLabel: "Delete", danger: true }); if (!ok) return; if (timer.current) { clearTimeout(timer.current); timer.current = null; } try { const t = tRef.current; if (!d.isNew || json(d) !== savedJson.current) { - if (d.kind === "overlay" && t.overlays.some((o) => o.id === d.id)) await deleteOverlay(d.id); - if (d.kind === "sketch" && t.sketches.some((k) => k.id === d.id)) await deleteClipSketch(d.id); + if (t.sketches.some((k) => k.id === d.id)) await deleteClipSketch(d.id); } const now = tRef.current; - if (d.kind === "overlay") patchClip(cl.clip.id, cl.clip.overlay_id === d.id ? { overlay_id: null } : {}, { overlays: now.overlays.filter((o) => o.id !== d.id) }); - else patchClip(cl.clip.id, cl.clip.sketch_id === d.id ? { sketch_id: null } : {}, { sketches: now.sketches.filter((k) => k.id !== d.id) }); + patchClip(cl.clip.id, cl.clip.sketch_id === d.id ? { sketch_id: null } : {}, { sketches: now.sketches.filter((k) => k.id !== d.id) }); savedJson.current = "null"; setEditing(null); setDoc(null); setSave("idle"); } catch (e) { toast(`Delete failed: ${(e as Error).message}`, "danger"); } } // ----- keys: the edit mode owns them, the timeline's map is off meanwhile (modal scopes in shortcuts.ts) ----- - const drawing = !!editing && editing.kind !== "reframe"; useKeys(drawingKeys(ed, { save: () => { void saveNow(); }, escape: () => { if (ed.textEdit) { ed.setTextEdit(null); return; } if (ed.selected) { ed.setSelected(null); return; } void stop(); }, - }), drawing && !!doc); + }), editing?.kind === "sketch" && !!doc); + useKeys(drawingKeys(led, { + save: () => { void docs.flush(); }, + escape: () => { if (led.textEdit) { led.setTextEdit(null); return; } if (led.selected) { led.setSelected(null); return; } if (layerSel) { setLayerSel(null); return; } void stop(); }, + }), !!layersClip); const reframe = editing?.kind === "reframe" && c?.kind === "photo" ? c : null; const rf = reframeOf(reframe, presets, (patch) => reframe && patchClip(reframe.clip.id, { presentation: { ...reframe.pres, ...patch } })); useKeys(bind("reframe", { @@ -233,41 +244,49 @@ export function useClipEditing({ timeline, clips, presets, onChange }: Options): ); - } else if (loading || !doc) { - stage =

; - panel = {done}; - } else if (doc.kind === "overlay" && c.kind === "photo") { - const pres = c.pres; - const frame = presentationFrame(c.photo, pres); - const layout = framedLayout(c.photo, pres.mode, frame); - const canvasOf = (w: number, h: number): CanvasRect => ({ x: (layout.img.left / 100) * w, y: (layout.img.top / 100) * h, w: (layout.img.width / 100) * w, h: (layout.img.height / 100) * h }); - const look = doc.look; - stage = ( - ( - - {look.tint && look.tint_opacity > 0 &&
} - {look.vignette > 0 &&
} - - )} /> - ); + } else if (layersClip) { + const cl = layersClip; + const pres = cl.pres; + const frame = presentationFrame(cl.photo, pres); + const mode: MaskMode = pres.mode === "off" ? "fit" : pres.mode; + const areas = stackAreas(cl.photo, mode, frame); + const own = [...docs.list].sort(byPosition).filter((l) => l.visible); + const base: Layer[] = layersAt(shotLayers, cl.step); + stage = drawingLayer ? ( + layerRect(drawingLayer, areas, w, h) ?? { x: 0, y: 0, w, h }} aspect={areas.aspect} maxHeight={STAGE_H} sketch={false} + textEdit={led.textEdit} onTextEdit={led.setTextEdit} onTextDone={led.textDone} + background={() => l.id !== drawingLayer.id)} className="compose__photo" />} /> + ) : ; + const add = async (kind: Layer["kind"]) => { + const l = await docs.add(kind, { shot_id: cl.shot.id, space: "photo", photo_id: cl.photo.id, range: null, timeline_id: tRef.current.id, clip_id: cl.clip.id }); + if (l) setLayerSel(l.id); + }; panel = ( - - void saveNow()} savedLabel={doc.isNew ? "Not saved yet" : "Saved"} />{done} + + {done} -
+ {drawingLayer &&
}
-
setDoc({ ...doc, name: (e.target as HTMLInputElement).value })} />
- Drawn in {frameModeLabel(pres.mode)} · {pres.label ?? "as shot"}; it stays on the photo in every frame. - {promoteHint("overlay")} + {docs.loading ? : } + {!docs.loading && !docs.list.length && This clip has no layers of its own. They go on top of what its step shows.} +
+ + +
+ {promoteHint("layer")}
- setDoc({ ...doc, look: l })} /> - -
+ {picked && ( + docs.update(picked.id, { space, photo_id: space === "photo" ? cl.photo.id : null })}> + {picked.kind === "drawing" && } + + )}
); + } else if (loading || !doc) { + stage =
; + panel = {done}; } else if (doc.kind === "sketch") { stage = ( void start(k, cl), stop, stage:
{stage}
, panel }; } -const pct = (r: { left: number; top: number; width: number; height: number }, extra: Record) => ({ left: `${r.left}%`, top: `${r.top}%`, width: `${r.width}%`, height: `${r.height}%`, ...extra }); /** The re-frame of one clip, derived from its presentation: rig + lens give the size, the drag gives the centre. */ function reframeOf(c: Extract | null, presets: Preset[], onPatch: (p: Partial) => void) { diff --git a/apps/studio/src/ClipPicture.tsx b/apps/studio/src/ClipPicture.tsx new file mode 100644 index 0000000..491b3c5 --- /dev/null +++ b/apps/studio/src/ClipPicture.tsx @@ -0,0 +1,44 @@ +import { useEffect, useState } from "preact/hooks"; +import { clipLayersOf, layersAt, type Layer } from "@openclapper/vocab"; +import { fetchLayer } from "./api"; +import { Composition, useShotLayers } from "./compose/stack"; +import { stepBake } from "./format"; +import { Framed, type MaskMode } from "./Framed"; +import { presentationFrame } from "./FramingSelect"; +import type { ResolvedClip } from "./TimelineParts"; +import { presentationVignette } from "./vignette"; + +type PhotoClip = Extract; + +const shapes = new Map(); +const key = (l: Layer) => `${l.id}:${l.updated_at ?? l.created_at}`; + +/** A clip's own layers with their shapes (fetched once per version). */ +export function useClipLayers(own: readonly Layer[]): Layer[] { + const k = own.map(key).join(","); + const [list, setList] = useState(() => own.map((l) => shapes.get(key(l)) ?? l)); + useEffect(() => { + let live = true; + void Promise.all(own.map(async (l) => shapes.get(key(l)) ?? fetchLayer(l.id).then((full) => { shapes.set(key(l), full); return full; }).catch(() => l))) + .then((all) => { if (live) setList(all); }); + return () => { live = false; }; + }, [k]); + return list; +} + +/** + * A photo clip's picture (epic #275): live on the stage and in the clip panel (the step's layers + * from the shot, then the clip's own), its step's bake in the strip (a layers glyph says it has + * its own on top). + */ +export function ClipPicture({ c, mode, maxHeight, live }: { c: PhotoClip; mode: MaskMode; maxHeight?: string; live?: boolean }) { + const frame = presentationFrame(c.photo, c.pres); + if (!live) return ; + return ; +} + +function LivePicture({ c, mode, maxHeight }: { c: PhotoClip; mode: MaskMode; maxHeight?: string }) { + const layers = useShotLayers(c.shot); + const own = useClipLayers(c.ownLayers); + return ; +} diff --git a/apps/studio/src/Framed.tsx b/apps/studio/src/Framed.tsx index 8b9ac09..2786663 100644 --- a/apps/studio/src/Framed.tsx +++ b/apps/studio/src/Framed.tsx @@ -1,7 +1,8 @@ import type { ComponentChildren, JSX } from "preact"; import { withCentre } from "@openclapper/vocab"; import type { Photo } from "./api"; -import { frameLayout, frameOf, imageAspect, stepSrc, type FrameGeometry, type FrameMode, type PctRect, type Step } from "./format"; +import { frameLayout, frameOf, imageAspect, stepSrc, stepStale, type FrameGeometry, type FrameMode, type PctRect, type Step } from "./format"; +import type { Shot } from "./api"; import { cx, Img } from "@openclapper/ui"; import { photoVignette, stepVignette, vignetteCss } from "./vignette"; import type { Vignette } from "@openclapper/vocab"; @@ -22,7 +23,7 @@ const pct = (r: PctRect) => ({ left: `${r.left}%`, top: `${r.top}%`, width: `${r * The box always has the photo's aspect ratio (or the frame's in fit mode) so the percentage * geometry of frame and tints lines up with the image. */ -export function Framed({ photo, mode, className, maxHeight, frame, imgStyle, src, vignette, children }: { photo: Photo; mode: MaskMode; className?: string; maxHeight?: string; /** Draw this frame instead of the stored one (rig explorer). */ frame?: FrameGeometry | null; /** Extra style on the (the compose editor's look filter). */ imgStyle?: JSX.CSSProperties; /** Another image of the same size (an overlay render). */ src?: string; /** Lens vignetting over the frame (issue #266); undefined = the photo's root frame's, so a caller that passes `frame` says which. */ vignette?: Vignette | null; /** Tags drawn on the photo (positioned inside the box). */ children?: ComponentChildren }) { +export function Framed({ photo, mode, className, maxHeight, frame, imgStyle, src, vignette, below, above, children }: { photo: Photo; mode: MaskMode; className?: string; maxHeight?: string; /** Draw this frame instead of the stored one (rig explorer). */ frame?: FrameGeometry | null; /** Extra style on the (the compose editor's look filter). */ imgStyle?: JSX.CSSProperties; /** Another image of the same size (a step's bake). */ src?: string; /** Layers under and over the photo (epic #275), positioned inside the box; under the mask and the frame line. */ below?: ComponentChildren; above?: ComponentChildren; /** Lens vignetting over the frame (issue #266); undefined = the photo's root frame's, so a caller that passes `frame` says which. */ vignette?: Vignette | null; /** Tags drawn on the photo (positioned inside the box). */ children?: ComponentChildren }) { const f = frame ?? frameOf(photo); const vig = vignette === undefined ? (frame === undefined ? photoVignette(photo) : null) : vignette; const img = src ?? photo.image_url; @@ -32,12 +33,14 @@ export function Framed({ photo, mode, className, maxHeight, frame, imgStyle, src // No inline height: thumbnails fix theirs in CSS; elsewhere the aspect ratio gives it. The width is // explicit with maxHeight, so the box never depends on a shrink-to-fit parent (its children are absolute). const style = { aspectRatio: String(aspect), width: maxHeight ? `min(100%, calc(${maxHeight} * ${aspect}))` : undefined }; - if (!l) return
{children}
; + if (!l) return
{below}{above}{children}
; const fullImg = l.img.left === 0 && l.img.width === 100 && l.img.height === 100; return (
+ {below} + {above} {mode === "mask" && l.frame && ( <> {/* Four bands around the frame; a re-framed frame is off-centre, so each side has its own size. */} @@ -54,9 +57,17 @@ export function Framed({ photo, mode, className, maxHeight, frame, imgStyle, src ); } -/** A step of a sequence (issue #266): the photo, or its overlay's render, in the frame the step is shown with. */ -export function StepFramed({ step, mode, className, maxHeight, children }: { step: Step; mode: MaskMode; className?: string; maxHeight?: string; children?: ComponentChildren }) { - return {children}; +/** + * A step of a shot as thumbnails and strips show it (epic #275): its fresh bake, else the photo with + * a small "not baked" mark while the Studio's baker catches up. The stage composes live instead. + */ +export function StepFramed({ shot, step, mode, className, maxHeight, children }: { shot: Shot; step: Step; mode: MaskMode; className?: string; maxHeight?: string; children?: ComponentChildren }) { + return ( + + {stepStale(shot, step) && ◌} + {children} + + ); } /** Where the photo sits inside a Framed box (% of the box) and the box's aspect ratio, for things drawn in photo coordinates. */ diff --git a/apps/studio/src/FromTimelines.tsx b/apps/studio/src/FromTimelines.tsx index f7ceb01..4802a95 100644 --- a/apps/studio/src/FromTimelines.tsx +++ b/apps/studio/src/FromTimelines.tsx @@ -1,19 +1,19 @@ import { useEffect, useState } from "preact/hooks"; -import { promoteClip, type Overlay, type Photo, type Shot, type Sketch, type Timeline, type TimelineClip } from "./api"; +import { promoteClip, type Layer, type Photo, type Shot, type Sketch, type Timeline, type TimelineClip } from "./api"; import { ownReframe } from "./TimelineParts"; import { Framed } from "./Framed"; import { invalidateTimelines, projectTimelines } from "./Inspector"; import { Button, Icon, Popover, promptDialog, toast } from "@openclapper/ui"; /** - * "From timelines" (issue #31): what timeline clips changed for themselves on this photo or shot — - * their own re-frames, overlays and sketches — with the action that makes it the shot's. The clip + * "From timelines" (issues #31, #275): what timeline clips changed for themselves on this photo or + * shot — their own re-frames, layers and sketches — with the action that makes it the shot's. The clip * then points at the shot's copy; nothing is duplicated (an identical saved framing is linked). */ type Item = | { kind: "framing"; t: Timeline; clip: TimelineClip; index: number } - | { kind: "overlay"; t: Timeline; overlay: Overlay; index: number } + | { kind: "layer"; t: Timeline; layer: Layer; index: number } | { kind: "sketch"; t: Timeline; sketch: Sketch; index: number }; const clipIndex = (t: Timeline, clipId: string | null | undefined) => t.clips.findIndex((c) => c.id === clipId); @@ -22,7 +22,7 @@ function itemsOf(all: Timeline[], photo: Photo, kinds: Item["kind"][]): Item[] { const out: Item[] = []; for (const t of all) { if (kinds.includes("framing")) t.clips.forEach((clip, index) => { if (clip.photo_id === photo.id && clip.presentation && ownReframe(clip.presentation)) out.push({ kind: "framing", t, clip, index }); }); - if (kinds.includes("overlay")) for (const overlay of t.overlays) if (overlay.photo_id === photo.id) out.push({ kind: "overlay", t, overlay, index: clipIndex(t, overlay.clip_id) }); + if (kinds.includes("layer")) for (const layer of t.layers) if (layer.shot_id === photo.shot_id) out.push({ kind: "layer", t, layer, index: clipIndex(t, layer.clip_id) }); if (kinds.includes("sketch")) for (const sketch of t.sketches) out.push({ kind: "sketch", t, sketch, index: clipIndex(t, sketch.clip_id) }); } return out; @@ -48,9 +48,9 @@ export function FromTimelines({ shot, photo, kinds, onUpdated }: { shot: Shot; p r = await promoteClip(it.t.id, it.clip.id, { what: "framing", name }); const linked = r.timeline.clips.find((c) => c.id === it.clip.id)?.presentation?.label; toast(linked && linked !== name ? `Linked to the existing framing “${linked}”` : `Saved framing “${name}”; the clip uses it now`); - } else if (it.kind === "overlay") { - r = await promoteClip(it.t.id, it.overlay.clip_id!, { what: "overlay" }); - toast(`“${it.overlay.name}” is now an overlay of the photo`); + } else if (it.kind === "layer") { + r = await promoteClip(it.t.id, it.layer.clip_id!, { what: "layer", layer_id: it.layer.id }); + toast(`“${it.layer.name}” is now a layer of the shot, on the clip's step`); } else { r = await promoteClip(it.t.id, it.sketch.clip_id!, { what: "sketch", shot_id: shot.id, sketch_id: it.sketch.id }); toast(`“${it.sketch.name}” is now a sketch of this shot`); @@ -63,7 +63,7 @@ export function FromTimelines({ shot, photo, kinds, onUpdated }: { shot: Shot; p return ( - - - ); -} - -export { emptyDrawing }; diff --git a/apps/studio/src/compose/ComposeStage.tsx b/apps/studio/src/compose/ComposeStage.tsx new file mode 100644 index 0000000..e5ecc3b --- /dev/null +++ b/apps/studio/src/compose/ComposeStage.tsx @@ -0,0 +1,413 @@ +/** + * Shot view → stage **Compose** (epic #275): the shot as a composition. The foot holds the steps + * (the time axis: reorder, add, zoom sequence, reset, extract) and the sketches; the stage shows the + * selected step with the layer stack composed live, and draws on the selected drawing layer; the + * panel lists the stack top to bottom with the footage in it, and the selected layer's settings + * (order, opacity, anchoring, the steps it shows on, its look or shapes). Everything saves on its + * own: steps 600 ms and layers 900 ms after the last change, sketches 1.2 s; leaving sends the rest. + */ +import type { ComponentChildren } from "preact"; +import { useEffect, useMemo, useRef, useState } from "preact/hooks"; +import { emptyDrawing, layersAt, SKETCH_ASPECTS, SKETCH_KIND_LABELS, SKETCH_KINDS, stepsOf, type Drawing, type Layer, type Shape, type StepInput } from "@openclapper/vocab"; +import { deleteSketch, fetchShotLayers, fetchSketch, putSketch, putSteps, type Photo, type Preset, type Shot } from "../api"; +import { steps as stepsOfShot, type Step } from "../format"; +import { StepFramed, type MaskMode } from "../Framed"; +import { FromTimelines } from "../FromTimelines"; +import { bind, useKeys } from "../keys"; +import { ZoomSequenceDialog } from "../ZoomSequenceDialog"; +import { Button, Combobox, confirmDialog, cx, Empty, Icon, IconButton, Input, Kbd, MarkdownField, SaveStatus, Seg, Select, Spinner, toast, ToolbarSpacer, type SaveState } from "@openclapper/ui"; +import { DrawSurface } from "./DrawSurface"; +import { DrawToolbar, drawingKeys, ShapeSection, useDrawingEditor } from "./editor"; +import { FOOTAGE, LayerList, LayerProps, useLayerDocs, type LayerDocs } from "./layers"; +import { renderSketch } from "./render"; +import { Composition, layerRect, primeShotLayers, stackAreas } from "./stack"; + +interface Props { + shot: Shot; presets: Preset[]; mode: MaskMode; + /** The step to start on (the Photo stage's). */ + initialStep: number; + onUpdated: (s: Shot) => void; + /** The step on stage changed: its photo is the inspector's. */ + onStep: (i: number, photo: Photo) => void; + onBack: () => void; + onExtract: (step: Step) => void; +} + +// ---------- the steps: a draft list, autosaved whole ---------- + +const asInput = (s: Step): StepInput => ({ id: crypto.randomUUID(), photo_id: s.photo.id, framing_id: s.framing?.id ?? null }); +const fromShot = (shot: Shot): StepInput[] => (shot.steps.length + ? [...shot.steps].sort((a, b) => a.ordinal - b.ordinal).map((s) => ({ id: s.id, photo_id: s.photo_id, framing_id: s.framing_id })) + : stepsOfShot(shot).map(asInput)); + +function useStepsDraft(shot: Shot, onUpdated: (s: Shot) => void) { + const [draft, setDraft] = useState(() => fromShot(shot)); + const [stored, setStored] = useState(shot.steps.length > 0); + const [save, setSave] = useState("idle"); + const timer = useRef | null>(null); + const inflight = useRef>(Promise.resolve()); + const latest = useRef({ draft, shot }); latest.current = { draft, shot }; + useEffect(() => { setDraft(fromShot(shot)); setStored(shot.steps.length > 0); setSave("idle"); }, [shot.id]); + // The steps the stage works on: the draft once stored (or being stored), else the photos. + const resolved: Step[] = useMemo(() => (stored + ? stepsOf({ ...shot, steps: draft.map((d, i) => ({ id: d.id, shot_id: shot.id, ordinal: i, photo_id: d.photo_id, framing_id: d.framing_id ?? null })) }).filter((s) => s.stored) + : stepsOfShot(shot)), [shot, draft, stored]); + + const send = (list: StepInput[]) => { + timer.current = null; + setSave("saving"); + return (inflight.current = putSteps(latest.current.shot.id, list).then((s) => { onUpdated(s); setSave(timer.current ? "dirty" : "saved"); }) + .catch((e: Error) => { setSave("error"); toast(`Steps not saved: ${e.message}`, "danger"); throw e; })); + }; + const change = (list: StepInput[]) => { + setDraft(list); setStored(true); setSave("dirty"); + if (timer.current) clearTimeout(timer.current); + timer.current = setTimeout(() => void send(list).catch(() => {}), 600); + }; + /** Pending steps go out now (a layer range may name them). */ + const flush = async () => { if (timer.current) { clearTimeout(timer.current); await send(latest.current.draft); } else await inflight.current; }; + /** The steps as stored rows: a shot without them stores its photos as steps first. */ + const ensureStored = async (): Promise => { + if (!stored || !latest.current.shot.steps.length) { const list = latest.current.draft; setStored(true); setDraft(list); await send(list); return list; } + await flush(); + return latest.current.draft; + }; + useEffect(() => () => { if (timer.current) { clearTimeout(timer.current); void putSteps(latest.current.shot.id, latest.current.draft).then(onUpdated).catch(() => {}); } }, []); + const reset = async () => { + if (timer.current) { clearTimeout(timer.current); timer.current = null; } + setSave("saving"); + try { const s = await putSteps(shot.id, []); onUpdated(s); setDraft(fromShot(s)); setStored(false); setSave("saved"); } catch (e) { setSave("error"); toast(`Reset failed: ${(e as Error).message}`, "danger"); } + }; + return { draft, resolved, stored, save, change, flush, ensureStored, reset, retry: () => void send(latest.current.draft).catch(() => {}) }; +} + +// ---------- the stage ---------- + +type Sel = { kind: "layer"; id: string } | { kind: "footage" } | { kind: "sketch"; id: string }; + +export function ComposeStage({ shot, presets, mode, initialStep, onUpdated, onStep, onBack, onExtract }: Props) { + const st = useStepsDraft(shot, onUpdated); + const [at, setAt] = useState(Math.max(0, Math.min(initialStep, st.resolved.length - 1))); + const [sel, setSel] = useState({ kind: "footage" }); + const [zoom, setZoom] = useState(false); + const [drop, setDrop] = useState<{ i: number; after: boolean } | null>(null); + const dragging = useRef(null); + const i = Math.min(at, Math.max(0, st.resolved.length - 1)); + const cur = st.resolved[i] ?? null; + useEffect(() => { if (cur) onStep(i, cur.photo); }, [cur?.id, i]); + + const docs = useLayerDocs({ + load: () => fetchShotLayers(shot.id), deps: [shot.id], + beforeSave: st.flush, + onSaved: (r) => { onUpdated(r.shot); }, + onDeleted: (id, s) => { onUpdated(s); if (sel.kind === "layer" && sel.id === id) setSel({ kind: "footage" }); }, + }); + // What the editor knows is newer than the cache: the strips and the timeline compose from it. + useEffect(() => { if (!docs.loading) primeShotLayers(shot.id, docs.list); }, [docs.list, docs.loading]); + const layer = sel.kind === "layer" ? docs.list.find((l) => l.id === sel.id) ?? null : null; + const shown = cur ? layersAt(docs.list, cur) : []; + const drawing = layer && layer.kind === "drawing" && !layer.locked && cur && shown.some((l) => l.id === layer.id) ? layer : null; + + const setShapes = (shapes: Shape[]) => { if (drawing) docs.update(drawing.id, { shapes }); }; + const ed = useDrawingEditor(drawing?.shapes ?? [], setShapes, false); + useEffect(() => { ed.reset(false); }, [drawing?.id]); + + // ----- steps ----- + const move = (from: number, to: number) => { + if (to < 0 || to >= st.draft.length || from === to) return; + const list = [...st.draft]; const [d] = list.splice(from, 1); list.splice(to, 0, d); st.change(list); setAt(to); + }; + const addStep = () => { const base = st.draft[i] ?? (cur ? asInput(cur) : null); if (!base) return; st.change([...st.draft.slice(0, i + 1), { ...base, id: crypto.randomUUID() }, ...st.draft.slice(i + 1)]); setAt(i + 1); }; + const removeStep = () => { if (st.draft.length < 2) return; st.change(st.draft.filter((_, k) => k !== i)); setAt(Math.max(0, i - 1)); }; + const patchStep = (p: Partial) => st.change(st.draft.map((d, k) => (k === i ? { ...d, ...p } : d))); + async function reset() { + if (!(await confirmDialog({ title: "Reset to photos?", body: "Every step goes; the shot shows its photos again, one step each. Layers shown on chosen steps then show on none until you pick their steps again.", confirmLabel: "Reset", danger: true }))) return; + await st.reset(); setAt(0); + } + const onZoom = (framingIds: string[]) => { + const photo = cur?.photo ?? shot.photos[0]; + const made = framingIds.map((fid) => ({ id: crypto.randomUUID(), photo_id: photo.id, framing_id: fid })); + // Without stored steps the implicit photo list should not linger: the zoom replaces it; otherwise it is appended. + const list = st.stored ? [...st.draft, ...made] : made; + st.change(list); + setAt(st.stored ? st.draft.length : 0); + }; + + // ----- layers ----- + const addLayer = async (kind: Layer["kind"]) => { + const l = await docs.add(kind, { shot_id: shot.id, space: "frame", photo_id: null, range: null, timeline_id: null, clip_id: null }); + if (l) setSel({ kind: "layer", id: l.id }); + }; + const badge = (l: Layer) => { + if (l.range === null) return null; + if (!l.range.length) return "no step"; + if (cur && l.range.includes(cur.id)) return l.range.length === 1 ? "this step" : `${l.range.length} steps`; + return "not here"; + }; + /** Pick the steps a layer shows on: every step, this one, or chosen ones (stored steps first). */ + const setRange = async (l: Layer, kind: "all" | "this" | "some") => { + if (kind === "all") { docs.update(l.id, { range: null }); return; } + const list = await st.ensureStored(); + const here = list[i]?.id; + if (!here) return; + docs.update(l.id, { range: kind === "this" ? [here] : l.range && l.range.length ? l.range : [here] }); + }; + const toggleStep = async (l: Layer, stepIndex: number) => { + const list = await st.ensureStored(); + const id = list[stepIndex]?.id; + if (!id) return; + const r = l.range ?? list.map((x) => x.id); + docs.update(l.id, { range: r.includes(id) ? r.filter((x) => x !== id) : [...r, id] }); + }; + + // ----- keys ----- + useKeys(bind("compose", { + prevStep: () => setAt(Math.max(0, i - 1)), + nextStep: () => setAt(Math.min(st.resolved.length - 1, i + 1)), + }), !zoom); + useKeys(drawingKeys(ed, { + save: () => { void docs.flush(); }, + escape: () => { if (ed.textEdit) { ed.setTextEdit(null); return; } if (ed.selected) { ed.setSelected(null); return; } if (sel.kind !== "footage") { setSel({ kind: "footage" }); return; } onBack(); }, + }), !zoom && sel.kind !== "sketch"); + + // ----- sketches (their own editor) ----- + const sketch = useSketchEditor(shot, sel.kind === "sketch" ? sel.id : null, onUpdated, () => setSel({ kind: "footage" })); + + // ----- the stage ----- + const photo = cur?.photo ?? shot.photos[0]; + const frame = cur?.frame ?? null; + const areas = stackAreas(photo, mode, frame); + let stage: ComponentChildren; + if (sel.kind === "sketch") stage = sketch.stage; + else if (!cur) stage = Add a step, or reset so the photos are the steps again.; + else if (drawing) { + const others = shown.filter((l) => l.id !== drawing.id); + stage = ( + layerRect(drawing, areas, w, h) ?? { x: 0, y: 0, w, h }} aspect={areas.aspect} maxHeight="calc(100cqh - 300px)" sketch={false} + textEdit={ed.textEdit} onTextEdit={ed.setTextEdit} onTextDone={ed.textDone} + background={() => } /> + ); + } else stage = ; + + const n = st.resolved.length; + const root = photo.framings.find((f) => f.id === photo.root_framing_id); + const d = st.draft[i]; + const stepLabel = (s: Step, k: number) => `Step ${k + 1}: photo ${s.photo.ordinal + 1}${s.framing ? ` · ${s.framing.name}` : ""}`; + const saveState: SaveState = [st.save, docs.save, sketch.save].find((x) => x === "error") ?? [st.save, docs.save, sketch.save].find((x) => x === "saving" || x === "dirty") ?? (st.save === "saved" || docs.save === "saved" || sketch.save === "saved" ? "saved" : "idle"); + + return ( + <> +
+
+ {sel.kind === "sketch" ? sketch.bar : drawing ? : ( + <> + {n ? `Step ${i + 1} of ${n}` : "No steps"} + {shown.length ? `${shown.length} layer${shown.length === 1 ? "" : "s"} on this step` : "No layers on this step"}{layer && layer.kind === "drawing" && !drawing ? (layer.locked ? " · the selected layer is locked" : " · the selected layer is not on this step") : ""} + + )} + + { st.retry(); docs.retry(); sketch.retry(); }} /> + {sel.kind !== "sketch" && } + +
+
{stage}
+
+
setDrop(null)}> + {st.resolved.map((s, k) => ( + + ))} +
+
+ + + +
+ +
+ {shot.sketches.map((k) => ( + + ))} + +
+
+
+ + + {zoom && setZoom(false)} onUpdated={onUpdated} onDone={onZoom} />} + + ); +} + +/** Which steps a layer shows on: every step, this one, or chosen ones (chips per step). */ +function RangeControl({ layer, steps, at, onKind, onToggle }: { layer: Layer; steps: Step[]; at: number; onKind: (k: "all" | "this" | "some") => void; onToggle: (i: number) => void }) { + const here = steps[at]; + const kind = layer.range === null ? "all" : layer.range.length === 1 && here && layer.range[0] === here.id ? "this" : "some"; + return ( +
+ Shows on + + {kind === "some" && ( +
+ {steps.map((s, k) => { + const on = layer.range?.includes(s.id) ?? true; + return ; + })} +
+ )} + {layer.space === "photo" && Anchored to a photo: only on that photo's steps.} +
+ ); +} + +// ---------- sketches: a free canvas of the shot, autosaved with its render ---------- + +interface SketchDoc { id: string; name: string; kind: string | null; description: string; shapes: Shape[]; aspect: number; position: number } + +function useSketchEditor(shot: Shot, openId: string | null, onUpdated: (s: Shot) => void, onClose: () => void) { + const [doc, setDoc] = useState(null); + const [save, setSave] = useState("idle"); + const saved = useRef("null"); + const timer = useRef | null>(null); + const docRef = useRef(doc); docRef.current = doc; + const setShapes = (shapes: Shape[]) => setDoc((d) => (d ? { ...d, shapes } : d)); + const ed = useDrawingEditor(doc?.shapes ?? [], setShapes, true); + + const persist = async (): Promise => { + const d = docRef.current; + if (!d || JSON.stringify(d) === saved.current) return true; + const snapshot = JSON.stringify(d); + setSave("saving"); + try { + const drawing: Drawing = { ...emptyDrawing(false), shapes: d.shapes }; + const r = await putSketch(d.id, { shot_id: shot.id, name: d.name.trim() || "Sketch", kind: d.kind, description: d.description.trim() || null, drawing, aspect: d.aspect, position: d.position }, await renderSketch(drawing, d.aspect)); + onUpdated(r.shot); + saved.current = snapshot; + setSave(JSON.stringify(docRef.current) === snapshot ? "saved" : "dirty"); + return true; + } catch (e) { setSave("error"); toast(`Sketch not saved: ${(e as Error).message}`, "danger"); return false; } + }; + useEffect(() => { + if (!doc || JSON.stringify(doc) === saved.current) return; + setSave("dirty"); + if (timer.current) clearTimeout(timer.current); + timer.current = setTimeout(() => { timer.current = null; void persist(); }, 1200); + }, [doc]); + useEffect(() => () => { if (timer.current) { clearTimeout(timer.current); void persist(); } }, []); + // Open another sketch: the current one is saved first. + useEffect(() => { + let live = true; + if (timer.current) { clearTimeout(timer.current); timer.current = null; void persist(); } + if (!openId) { setDoc(null); return; } + if (docRef.current?.id === openId) return; + void fetchSketch(openId).then((k) => { + if (!live) return; + const d: SketchDoc = { id: k.id, name: k.name, kind: k.kind, description: k.description ?? "", shapes: k.drawing.shapes, aspect: k.aspect, position: k.position }; + saved.current = JSON.stringify(d); setDoc(d); ed.reset(true); setSave("saved"); + }).catch((e: Error) => toast(`Could not open the sketch: ${e.message}`, "danger")); + return () => { live = false; }; + }, [openId]); + + useKeys(drawingKeys(ed, { + save: () => { void persist(); }, + escape: () => { if (ed.textEdit) { ed.setTextEdit(null); return; } if (ed.selected) { ed.setSelected(null); return; } onClose(); }, + }), !!openId); + + const create = async (): Promise => { + const d: SketchDoc = { id: crypto.randomUUID(), name: `Sketch ${shot.sketches.length + 1}`, kind: null, description: "", shapes: [], aspect: 16 / 9, position: shot.sketches.length }; + docRef.current = d; setDoc(d); saved.current = "null"; ed.reset(true); + return (await persist()) ? d.id : null; + }; + const remove = async () => { + const d = docRef.current; + if (!d || !(await confirmDialog({ title: `Delete “${d.name}”?`, body: "The sketch and its render are removed.", confirmLabel: "Delete", danger: true }))) return; + if (timer.current) { clearTimeout(timer.current); timer.current = null; } + try { const r = await deleteSketch(d.id); onUpdated(r.shot); saved.current = "null"; setDoc(null); onClose(); } catch (e) { toast(`Delete failed: ${(e as Error).message}`, "danger"); } + }; + + const stage = doc + ? ({ x: 0, y: 0, w, h })} aspect={doc.aspect} maxHeight="calc(100cqh - 300px)" sketch background={() => null} + textEdit={ed.textEdit} onTextEdit={ed.setTextEdit} onTextDone={ed.textDone} /> + : ; + const bar = doc ? : null; + const panel = doc && ( + <> +
+

Sketch

+
+ + setDoc({ ...doc, name: (e.target as HTMLInputElement).value })} /> + + ({ value: k, label: SKETCH_KIND_LABELS[k] }))} value={doc.kind} placeholder="Floor plan, lighting…" onChange={(v) => setDoc({ ...doc, kind: v })} onCreate={(t) => setDoc({ ...doc, kind: t.slice(0, 40) })} /> + + Math.abs(a.aspect - doc.aspect) < 0.01)?.id ?? "16:9"} onChange={(id) => setDoc({ ...doc, aspect: SKETCH_ASPECTS.find((a) => a.id === id)!.aspect })} options={SKETCH_ASPECTS.map((a) => ({ id: a.id, label: a.id }))} /> +
+
+
+ +
+
Description
+ setDoc({ ...doc, description: v })} rows={5} placeholder="What the sketch shows…" /> +
+ + ); + return { stage, bar, panel, save, create, retry: () => void persist() }; +} + +export type { LayerDocs }; diff --git a/apps/studio/src/compose/baker.ts b/apps/studio/src/compose/baker.ts new file mode 100644 index 0000000..939ea51 --- /dev/null +++ b/apps/studio/src/compose/baker.ts @@ -0,0 +1,67 @@ +/** + * The bakery (epic #275): one flattened image per step for the phone, made here because only the + * Studio can draw the stack. It watches the loaded shots and bakes every step whose bake is + * missing or stale (vocab `bakeHash`), one at a time in the background, so a layer edit, a + * framing change or migrated data reach the phone without anyone opening the shot. A step that + * shows no layers needs no bake. + */ +import { bakeHash, bakeKey, layersAt } from "@openclapper/vocab"; +import { putBake, type Shot } from "../api"; +import * as data from "../data"; +import { steps, stepStale } from "../format"; +import { renderStep } from "./render"; +import { loadShotLayers } from "./stack"; + +/** Bake the stale steps of one shot; returns the shot as the server last answered (or the input). */ +export async function bakeShot(shot: Shot, onShot: (s: Shot) => void): Promise { + const stale = steps(shot).filter((st) => stepStale(shot, st)); + if (!stale.length) return shot; + const layers = await loadShotLayers(shot); + let cur = shot; + for (const st of stale) { + // The hash of what is drawn: the fetched layers, which the server stores with the same versions. + const hash = bakeHash(st, layers); + if (!hash) continue; + const blob = await renderStep(st.photo, st.frame, layersAt(layers, st)); + cur = await putBake(shot.id, bakeKey(st), hash, blob); + onShot(cur); + } + return cur; +} + +const failed = new Map(); +let running = false; +let timer: ReturnType | null = null; + +/** Look for stale steps a moment after the shots changed; at most one shot is baked at a time. */ +function schedule(delay = 1500) { + if (timer) clearTimeout(timer); + timer = setTimeout(() => { timer = null; void run(); }, delay); +} + +async function run() { + if (running) return; + running = true; + try { + for (;;) { + const now = Date.now(); + // A shot that failed waits a few minutes before the next attempt (a missing photo, a network error). + const next = (data.getData().shots ?? []).find((s) => (failed.get(s.id) ?? 0) < now && steps(s).some((st) => stepStale(s, st))); + if (!next) break; + // The shot list only, not data.updated: a bake changes no counts. + try { await bakeShot(next, (s) => data.setShots((cur) => (cur ?? []).map((x) => (x.id === s.id ? s : x)))); failed.delete(next.id); } + catch { failed.set(next.id, Date.now() + 5 * 60_000); } + } + } finally { running = false; } +} + +/** Start watching the data store (once, from the shell); returns the stop function. */ +export function startBakery(): () => void { + let last = data.getData().shots; + schedule(3000); + const id = setInterval(() => { + const now = data.getData().shots; + if (now !== last) { last = now; schedule(); } + }, 1000); + return () => { clearInterval(id); if (timer) clearTimeout(timer); }; +} diff --git a/apps/studio/src/compose/layers.tsx b/apps/studio/src/compose/layers.tsx new file mode 100644 index 0000000..6426dca --- /dev/null +++ b/apps/studio/src/compose/layers.tsx @@ -0,0 +1,210 @@ +/** + * Editing a layer stack (epic #275), shared by the shot's Compose stage and a timeline clip's own + * layers: the layers as local documents with their shapes, saved one by one a moment after the last + * change (like the timeline's inline drawings), the list with the footage row, and the selected + * layer's settings. Order is explicit: the list runs top to bottom with the footage in it, and + * moving a layer past the footage puts it below the photo. + */ +import type { ComponentChildren } from "preact"; +import { useEffect, useRef, useState } from "preact/hooks"; +import { byPosition, NEUTRAL_LOOK, type Layer, type LayerInput, type LayerKind, type Look } from "@openclapper/vocab"; +import { deleteLayer, putLayer, type Shot } from "../api"; +import { Button, cx, Icon, IconButton, Input, MarkdownField, Seg, toast, type SaveState } from "@openclapper/ui"; +import { LookSection, Range } from "./editor"; + +export const FOOTAGE = "__footage__"; +const SAVE_DELAY = 900; + +export const toInput = (l: Layer): LayerInput => ({ + shot_id: l.shot_id, kind: l.kind, name: l.name.trim() || "Layer", description: l.description, position: l.position, visible: l.visible, locked: l.locked, opacity: l.opacity, + space: l.space, photo_id: l.photo_id, range: l.range, shapes: l.kind === "drawing" ? l.shapes ?? [] : null, look: l.kind === "adjustment" ? l.look : null, + timeline_id: l.timeline_id, clip_id: l.clip_id, +}); + +export interface LayerDocs { + /** Bottom to top. */ + list: Layer[]; + loading: boolean; + save: SaveState; + update: (id: string, patch: Partial) => void; + /** A new layer on top (drawing: empty; adjustment: neutral look), saved at once. */ + add: (kind: LayerKind, base: Pick) => Promise; + remove: (id: string) => Promise; + /** One slot up (+1, towards the top) or down (−1), the footage counting as a slot. */ + move: (id: string, d: 1 | -1) => void; + /** Send what is pending now; resolves false when a save failed. */ + flush: () => Promise; + retry: () => void; +} + +/** + * The layers of one owner (a shot, or a clip). `load` gives them with shapes; every save hands the + * server's answer to `onSaved` (the shot, for the shot's payload and the baker). `beforeSave` runs + * first (the shot's steps must exist before a range names them). + */ +export function useLayerDocs({ load, deps, onSaved, onDeleted, beforeSave }: { + load: () => Promise; deps: unknown[]; + onSaved: (r: { layer: Layer; shot: Shot }) => void; onDeleted?: (id: string, shot: Shot) => void; beforeSave?: () => Promise; +}): LayerDocs { + const [list, setList] = useState([]); + const [loading, setLoading] = useState(true); + const [save, setSave] = useState("idle"); + const listRef = useRef(list); listRef.current = list; + const pending = useRef(new Map>()); + const chain = useRef>(Promise.resolve(true)); + + useEffect(() => { + let live = true; + setLoading(true); + void load().then((l) => { if (live) { setList([...l].sort(byPosition)); setLoading(false); } }).catch((e: Error) => { if (live) { setLoading(false); toast(`Could not load the layers: ${e.message}`, "danger"); } }); + return () => { live = false; }; + }, deps); + + const send = (id: string): Promise => (chain.current = chain.current.then(async () => { + const l = listRef.current.find((x) => x.id === id); + if (!l) return true; + setSave("saving"); + try { + await beforeSave?.(); + const r = await putLayer(id, toInput(l)); + setList((cur) => cur.map((x) => (x.id === id ? { ...x, updated_at: r.layer.updated_at, created_at: r.layer.created_at } : x))); + onSaved(r); + setSave(pending.current.size ? "dirty" : "saved"); + return true; + } catch (e) { + setSave("error"); + toast(`Layer not saved: ${(e as Error).message}`, "danger"); + return false; + } + })); + const schedule = (id: string) => { + setSave("dirty"); + const t = pending.current.get(id); + if (t) clearTimeout(t); + pending.current.set(id, setTimeout(() => { pending.current.delete(id); void send(id); }, SAVE_DELAY)); + }; + const flush = async () => { + const ids = [...pending.current.keys()]; + for (const id of ids) { clearTimeout(pending.current.get(id)!); pending.current.delete(id); } + const results = await Promise.all(ids.map(send)); + await chain.current; + return results.every(Boolean); + }; + // Leaving saves what is pending. + useEffect(() => () => { for (const [id, t] of pending.current) { clearTimeout(t); void send(id); } pending.current.clear(); }, []); + + const update = (id: string, patch: Partial) => { setList((cur) => cur.map((x) => (x.id === id ? { ...x, ...patch } : x))); listRef.current = listRef.current.map((x) => (x.id === id ? { ...x, ...patch } : x)); schedule(id); }; + const add: LayerDocs["add"] = async (kind, base) => { + const top = listRef.current.reduce((m, l) => Math.max(m, l.position), -1); + const n = listRef.current.filter((l) => l.kind === kind).length + 1; + const l: Layer = { + ...base, id: crypto.randomUUID(), kind, name: kind === "drawing" ? `Drawing ${n}` : `Adjustment ${n}`, description: null, + position: Math.max(0, top + 1), visible: true, locked: false, opacity: 1, look: kind === "adjustment" ? { ...NEUTRAL_LOOK } : null, + shapes: kind === "drawing" ? [] : undefined, created_at: new Date().toISOString(), updated_at: null, + }; + setList((cur) => [...cur, l].sort(byPosition)); + listRef.current = [...listRef.current, l].sort(byPosition); + return (await send(l.id)) ? l : null; + }; + const remove = async (id: string) => { + const t = pending.current.get(id); + if (t) { clearTimeout(t); pending.current.delete(id); } + try { + const r = await deleteLayer(id); + setList((cur) => cur.filter((x) => x.id !== id)); + onDeleted?.(id, r.shot); + } catch (e) { toast(`Delete failed: ${(e as Error).message}`, "danger"); } + }; + const move = (id: string, d: 1 | -1) => { + // The stack bottom to top with the footage as a slot; swap, then renumber: below the footage + // −n..−1, above it 0..m−1, and save the layers whose position changed. + const below = listRef.current.filter((l) => l.position < 0), above = listRef.current.filter((l) => l.position >= 0); + const slots: (Layer | typeof FOOTAGE)[] = [...below, FOOTAGE, ...above]; + const i = slots.findIndex((s) => s !== FOOTAGE && s.id === id); + const j = i + d; + if (i < 0 || j < 0 || j >= slots.length) return; + [slots[i], slots[j]] = [slots[j], slots[i]]; + const f = slots.indexOf(FOOTAGE); + const next = slots.flatMap((s, k) => (s === FOOTAGE ? [] : [{ ...s, position: k < f ? k - f : k - f - 1 }])); + const changed = next.filter((l) => listRef.current.find((x) => x.id === l.id)!.position !== l.position); + setList(next.sort(byPosition)); + listRef.current = next; + for (const l of changed) schedule(l.id); + }; + return { list, loading, save, update, add, remove, move, flush, retry: () => { for (const l of listRef.current) schedule(l.id); } }; +} + +/** The stack, top to bottom, with the footage row between the layers above and below it. */ +export function LayerList({ docs, selected, onSelect, footage, badge }: { + docs: LayerDocs; selected: string | null; onSelect: (id: string) => void; + /** The footage row's label (the step on stage); null hides the row (a clip's own layers). */ + footage: string | null; + /** A short note per layer: on which steps it shows. */ + badge?: (l: Layer) => string | null; +}) { + const top = [...docs.list].reverse(); + const rows: (Layer | typeof FOOTAGE)[] = footage === null ? top : [...top.filter((l) => l.position >= 0), FOOTAGE, ...top.filter((l) => l.position < 0)]; + return ( +
    + {rows.map((r) => r === FOOTAGE ? ( +
  • onSelect(FOOTAGE)}> + {footage}footage +
  • + ) : ( +
  • onSelect(r.id)}> + { e.stopPropagation(); docs.update(r.id, { visible: !r.visible }); }} /> + + {r.name} + {badge?.(r) && {badge(r)}} + {r.space === "photo" && } + {r.locked && } +
  • + ))} +
+ ); +} + +/** The selected layer's settings: name, order, opacity, lock, space, then its look or its description. */ +export function LayerProps({ layer, docs, range, space, children }: { + layer: Layer; docs: LayerDocs; + /** The range control (shots only). */ + range?: ComponentChildren; + /** Switch between frame and photo space; null when it cannot change (a clip's layer on its photo). */ + space?: ((s: Layer["space"]) => void) | null; + /** Shape section etc. (drawing layers). */ + children?: ComponentChildren; +}) { + const set = (patch: Partial) => docs.update(layer.id, patch); + return ( + <> +
+
+ + set({ name: (e.target as HTMLInputElement).value })} /> +
+ set({ opacity: v })} /> +
+ docs.move(layer.id, 1)} /> + docs.move(layer.id, -1)} /> + + + +
+ {layer.position < 0 && Under the photo: it shows only where there is no picture (beyond the capture), and not on the phone.} + {space !== null && space && ( +
+ Anchored to + +
+ )} + {range} +
+ {layer.kind === "adjustment" && set({ look })} />} + {children} +
+
Description
+ set({ description: v || null })} rows={4} placeholder="What this layer shows…" /> +
+ + ); +} diff --git a/apps/studio/src/compose/render.ts b/apps/studio/src/compose/render.ts index 2ca0280..6acebc5 100644 --- a/apps/studio/src/compose/render.ts +++ b/apps/studio/src/compose/render.ts @@ -1,9 +1,9 @@ /** - * Flattened renders. An overlay render is the raw photo at its stored size with the look applied - * and the drawing on top, so the phone can frame it exactly like the photo. A sketch render is the + * Flattened renders. A step's bake (epic #275) is the raw photo at its stored size with the layers + * the step shows on top, so the phone frames it exactly like the photo. A sketch render is the * canvas on white. Both draw the same shapes as the SVG (Canvas.tsx), on a 2D context. */ -import { lookFilter, type Drawing, type Look, type Shape } from "@openclapper/vocab"; +import { lookFilter, withCentre, type Drawing, type Frame, type Layer, type Look, type Shape } from "@openclapper/vocab"; import type { Photo } from "../api"; import { arrowPoints } from "./Canvas"; import { arrowHead, sizePx, strokePx, toPx, type CanvasRect } from "./geometry"; @@ -119,8 +119,14 @@ export const vignetteCss = (v: number) => `radial-gradient(ellipse at center, rg const toBlob = (canvas: HTMLCanvasElement, type: string, quality?: number) => new Promise((res, rej) => canvas.toBlob((b) => (b ? res(b) : rej(new Error("render failed"))), type, quality)); -/** The photo with the look and the drawing, at the photo's own size, as JPEG. */ -export async function renderOverlay(photo: Photo, drawing: Drawing): Promise { +/** + * A step's bake: the photo at its stored size, then the step's layers bottom to top. Layers below + * the footage are covered by the photo (they only show beyond it, which a photo-sized image has no + * room for). An adjustment layer re-draws what is there through its look, blended by its opacity; + * a drawing layer is drawn on its own canvas, then laid on with its opacity. Frame-space layers sit + * in (and are cut at) the frame rect; photo-space layers span the photo. + */ +export async function renderStep(photo: Photo, frame: Frame | null, layers: readonly Layer[]): Promise { const res = await fetch(photo.image_url, { credentials: "same-origin" }); if (!res.ok) throw new Error(`photo ${res.status}`); const bitmap = await createImageBitmap(await res.blob()); @@ -128,13 +134,42 @@ export async function renderOverlay(photo: Photo, drawing: Drawing): Promise 0) { ctx.globalAlpha = l.opacity * look.tint_opacity; ctx.fillStyle = look.tint; ctx.fillRect(c.x, c.y, c.w, c.h); } + if (look.vignette > 0) { + ctx.globalAlpha = l.opacity; + const g = ctx.createRadialGradient(c.x + c.w / 2, c.y + c.h / 2, Math.min(c.w, c.h) * 0.35, c.x + c.w / 2, c.y + c.h / 2, Math.hypot(c.w, c.h) / 2); + g.addColorStop(0, "rgba(0,0,0,0)"); + g.addColorStop(1, `rgba(0,0,0,${look.vignette.toFixed(3)})`); + ctx.fillStyle = g; ctx.fillRect(c.x, c.y, c.w, c.h); + } + } else if (l.kind === "drawing" && l.shapes?.length) { + const own = document.createElement("canvas"); + own.width = w; own.height = h; + drawShapes(own.getContext("2d")!, l.shapes, c); + ctx.globalAlpha = l.opacity; + ctx.drawImage(own, 0, 0); + } + ctx.restore(); + } return toBlob(canvas, "image/jpeg", 0.9); } diff --git a/apps/studio/src/compose/stack.tsx b/apps/studio/src/compose/stack.tsx new file mode 100644 index 0000000..52ff32f --- /dev/null +++ b/apps/studio/src/compose/stack.tsx @@ -0,0 +1,129 @@ +/** + * The composition on screen (epic #275): a step's photo with the layer stack composed live. An + * adjustment layer is an element with `backdrop-filter` (what is below it gets the look), a drawing + * layer an SVG in its space: the photo (fractions of the raw image) or the frame (fractions of the + * rig frame the step is shown with). The bake (render.ts `renderStep`) draws the same stack into + * pixels for the phone. Shapes are not in the shot payload: `useShotLayers` fetches them. + */ +import type { ComponentChildren } from "preact"; +import { useEffect, useRef, useState } from "preact/hooks"; +import { lookFilter, type Layer } from "@openclapper/vocab"; +import { fetchShotLayers, type Photo, type Shot } from "../api"; +import { frameLayout, frameOf, imageAspect, type FrameGeometry, type PctRect } from "../format"; +import { Framed, type MaskMode } from "../Framed"; +import { DrawingSvg } from "./Canvas"; +import type { CanvasRect } from "./geometry"; +import { BLUR_FRACTION, vignetteCss } from "./render"; + +const FULL: PctRect = { left: 0, top: 0, width: 100, height: 100 }; + +/** Where the photo and the frame sit in a Framed box (% of the box); the frame is null in Raw (frame-space layers hide there). */ +export function stackAreas(photo: Photo, mode: MaskMode, frame: FrameGeometry | null): { img: PctRect; frame: PctRect | null; aspect: number } { + const photoAspect = imageAspect(photo); + const l = frame && mode !== "off" ? frameLayout(frame, mode, photoAspect) : null; + if (!l) return { img: FULL, frame: frame ? null : FULL, aspect: photoAspect }; + return { img: l.img, frame: l.frame ?? FULL, aspect: l.aspect ?? photoAspect }; +} + +/** A layer's area in px of a box `w × h`; null when it does not show in this mode. */ +export function layerRect(l: Pick, areas: { img: PctRect; frame: PctRect | null }, w: number, h: number): CanvasRect | null { + const r = l.space === "photo" ? areas.img : areas.frame; + return r ? { x: (r.left / 100) * w, y: (r.top / 100) * h, w: (r.width / 100) * w, h: (r.height / 100) * h } : null; +} + +/** Measures the box it fills and renders its children at that size. */ +function Measured({ children }: { children: (w: number, h: number) => ComponentChildren }) { + const ref = useRef(null); + const [size, setSize] = useState({ w: 0, h: 0 }); + useEffect(() => { + const el = ref.current; + if (!el) return; + const ro = new ResizeObserver(() => { const r = el.getBoundingClientRect(); setSize((s) => (Math.abs(s.w - r.width) < 0.5 && Math.abs(s.h - r.height) < 0.5 ? s : { w: r.width, h: r.height })); }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + return
{size.w > 0 && children(size.w, size.h)}
; +} + +/** One layer, live, in a box of `w × h` px. */ +export function LayerView({ layer, areas, w, h }: { layer: Layer; areas: { img: PctRect; frame: PctRect | null }; w: number; h: number }) { + const c = layerRect(layer, areas, w, h); + if (!c) return null; + const box = { left: `${c.x}px`, top: `${c.y}px`, width: `${c.w}px`, height: `${c.h}px` }; + if (layer.kind === "adjustment") { + const look = layer.look; + if (!look) return null; + const filter = lookFilter(look, c.w * BLUR_FRACTION); + return ( +
+ {look.tint && look.tint_opacity > 0 &&
} + {look.vignette > 0 &&
} +
+ ); + } + // A frame-space drawing is cut at the frame; a photo-space one may run into the area beyond the photo. + const clip = layer.space === "frame" ? `inset(${c.y}px ${w - c.x - c.w}px ${h - c.y - c.h}px ${c.x}px)` : undefined; + return ; +} + +/** Layers in a Framed box: positioned against the box the slot fills. */ +export function LayerStack({ layers, areas }: { layers: readonly Layer[]; areas: { img: PctRect; frame: PctRect | null } }) { + if (!layers.length) return null; + return {(w, h) => layers.map((l) => )}; +} + +/** + * A photo with layers composed live (bottom to top): those with `position < 0` under the photo, + * the rest over it; `extra` (a clip's own layers) on top of everything. + */ +export function Composition({ photo, frame, mode, layers, extra, maxHeight, className, vignette, children }: { + photo: Photo; frame: FrameGeometry | null; mode: MaskMode; layers: readonly Layer[]; extra?: readonly Layer[]; maxHeight?: string; className?: string; + vignette?: Parameters[0]["vignette"]; children?: ComponentChildren; +}) { + const f = frame ?? frameOf(photo); + const areas = stackAreas(photo, mode, f); + const below = layers.filter((l) => l.position < 0); + const above = [...layers.filter((l) => l.position >= 0), ...(extra ?? [])]; + return ( + } above={}> + {children} + + ); +} + +// ---------- the shot's layers with their shapes ---------- + +const cache = new Map(); +const versionKey = (s: Shot) => s.layers.map((l) => `${l.id}:${l.updated_at ?? l.created_at}`).join(","); + +/** The shot's layers with shapes (fetched once per version of the list); `[]` while loading or without layers. */ +export function useShotLayers(shot: Shot | null): Layer[] { + const key = shot ? versionKey(shot) : ""; + const hit = shot ? cache.get(shot.id) : undefined; + const [layers, setLayers] = useState(hit && hit.key === key ? hit.layers : []); + useEffect(() => { + if (!shot || !shot.layers.length) { setLayers([]); return; } + const c = cache.get(shot.id); + if (c && c.key === key) { setLayers(c.layers); return; } + let live = true; + void loadShotLayers(shot).then((l) => { if (live) setLayers(l); }).catch(() => {}); + return () => { live = false; }; + }, [shot?.id, key]); + return layers; +} + +/** The shot's layers with shapes, from the cache when its version list is unchanged. */ +export async function loadShotLayers(shot: Shot): Promise { + const key = versionKey(shot); + const c = cache.get(shot.id); + if (c && c.key === key) return c.layers; + if (!shot.layers.length) return []; + const layers = await fetchShotLayers(shot.id); + // The fetched list may be newer than the shot we were handed; key it by what came back. + cache.set(shot.id, { key: layers.map((l) => `${l.id}:${l.updated_at ?? l.created_at}`).join(","), layers }); + return layers; +} + +/** Forget a shot's cached layers (after an edit the editor knows better). */ +export const primeShotLayers = (shotId: string, layers: Layer[]) => cache.set(shotId, { key: layers.map((l) => `${l.id}:${l.updated_at ?? l.created_at}`).join(","), layers }); diff --git a/apps/studio/src/format.ts b/apps/studio/src/format.ts index 9fbe5e3..311813c 100644 --- a/apps/studio/src/format.ts +++ b/apps/studio/src/format.ts @@ -1,15 +1,21 @@ import type { RigLens } from "@openclapper/fov-math"; -import { cameraLabel, extraLabel, label, lightLabel, rootFrame, stepsOf, withCentre, type FilterableShot, type ResolvedStep } from "@openclapper/vocab"; -import type { Overlay, Photo, Shot } from "./api"; +import { bakeHash, cameraLabel, extraLabel, freshBake, label, lightLabel, rootFrame, stepsOf, withCentre, type FilterableShot, type ResolvedStep, type StepBake } from "@openclapper/vocab"; +import type { Photo, Shot } from "./api"; /** The photo that stands for the shot in lists, maps and filters: the first one. */ export const cover = (s: Shot): Photo => s.photos[0]; -/** The steps a shot shows (issue #266): the stored progression, else its photos. Every strip, scrub and badge counts these. */ -export type Step = ResolvedStep; +/** The steps a shot shows (issue #266): the stored steps, else its photos. Every strip, scrub and badge counts these. */ +export type Step = ResolvedStep; export const steps = (s: Shot): Step[] => stepsOf(s); export const seqCount = (s: Shot): number => (s.steps.length ? steps(s).length : s.photos.length); -/** What a step shows: the overlay's render when it has one, else the photo. */ -export const stepSrc = (st: Step): string => st.overlay?.render_url ?? st.photo.image_url; +/** What a step's bake must be made from (epic #275); null when the step shows no layers (the photo is the picture). */ +export const stepHash = (s: Shot, st: Step): string | null => bakeHash(st, s.layers); +/** The step's bake when it is fresh. */ +export const stepBake = (s: Shot, st: Step): StepBake | null => freshBake(s.bakes, st, stepHash(s, st)); +/** A step that shows layers but has no fresh bake yet (the Studio bakes it in the background). */ +export const stepStale = (s: Shot, st: Step): boolean => stepHash(s, st) !== null && !stepBake(s, st); +/** What a step shows when not composed live: its fresh bake, else the photo. */ +export const stepSrc = (s: Shot, st: Step): string => stepBake(s, st)?.url ?? st.photo.image_url; /** "Uploaded image" / "Drawn sketch" for photos that did not come from the phone camera. */ export const sourceLabel = (p: Photo): string | null => (p.source === "upload" ? "Uploaded image" : p.source === "drawn" ? "Drawn sketch" : null); diff --git a/apps/studio/src/router.ts b/apps/studio/src/router.ts index 6a2e5f4..518fc61 100644 --- a/apps/studio/src/router.ts +++ b/apps/studio/src/router.ts @@ -1,4 +1,4 @@ -export type Stage = "photo" | "rigs" | "position" | "compose" | "sequence"; +export type Stage = "photo" | "rigs" | "position" | "compose"; export type LibrarySection = "projects" | "fields" | "rigs" | "locations" | "viewfinder"; export const LIBRARY_SECTIONS: readonly LibrarySection[] = ["projects", "fields", "rigs", "locations", "viewfinder"]; @@ -8,13 +8,13 @@ export const LIBRARY_SECTIONS: readonly LibrarySection[] = ["projects", "fields" */ import { META_FIELDS, type MetaField } from "./MetadataPage"; -export type CutTool = "reframe" | "overlay" | "sketch"; +export type CutTool = "reframe" | "layers" | "sketch"; export interface CutPosition { ms: number; tool: CutTool | null } function parseCutPosition(q: URLSearchParams): CutPosition | null { const t = Number(q.get("t")); const tool = q.get("tool"); - const ok = tool === "reframe" || tool === "overlay" || tool === "sketch" ? tool : null; + const ok = tool === "reframe" || tool === "layers" || tool === "sketch" ? tool : null; if (!q.has("t") && !ok) return null; return { ms: Number.isFinite(t) && t > 0 ? Math.round(t * 10) * 100 : 0, tool: ok }; } @@ -51,7 +51,7 @@ export function parseRoute(hash: string): Route { case "shots": if (parts[1]) { const st = q.get("stage"); - return { page: "shot", shotId: parts[1], stage: st === "rigs" || st === "position" || st === "compose" || st === "sequence" ? st : "photo" }; + return { page: "shot", shotId: parts[1], stage: st === "rigs" || st === "position" || st === "compose" ? st : "photo" }; } return { page: "shots", viewId: q.get("view") }; case "plan": return { page: "plan", dayId: parts[1] ?? null }; diff --git a/apps/studio/src/shortcuts.ts b/apps/studio/src/shortcuts.ts index 4858242..9d1c4af 100644 --- a/apps/studio/src/shortcuts.ts +++ b/apps/studio/src/shortcuts.ts @@ -10,7 +10,7 @@ */ export type ScopeId = - | "global" | "wm" | "shots" | "shots.grid" | "shots.list" | "map" | "shot" | "framing" | "sequence" | "compose" | "draw" | "reframe" + | "global" | "wm" | "shots" | "shots.grid" | "shots.list" | "map" | "shot" | "framing" | "compose" | "draw" | "reframe" | "timeline" | "browser" | "clip" | "plan" | "metadata" | "rigs" | "rig" | "locations" | "fields" | "position" | "newshot" | "combobox" | "files" | "desktop" | "trash"; export interface Scope { @@ -32,9 +32,8 @@ export const SCOPES: Record = { map: { label: "Shots · map", with: ["shots"] }, shot: { label: "Shot view", with: [] }, framing: { label: "Shot view · Framing", with: [] }, - sequence: { label: "Shot view · Sequence", with: [] }, - compose: { label: "Shot view · Compose", with: ["draw"] }, - draw: { label: "Drawing (Compose, and overlays or sketches on the timeline stage)", with: ["compose"] }, + compose: { label: "Shot view · Compose (steps, layers, sketches)", with: ["draw"] }, + draw: { label: "Drawing (Compose, and a clip's layers or sketch on the timeline stage)", with: ["compose"] }, reframe: { label: "Re-framing a clip on the timeline stage", with: [] }, timeline: { label: "Timeline", with: ["browser", "clip"] }, browser: { label: "Timeline · shot browser", with: ["timeline"] }, @@ -129,8 +128,7 @@ export const SHORTCUTS = [ { scope: "shot", action: "approve", keys: ["a"], label: "Approve" }, { scope: "shot", action: "archive", keys: ["e"], label: "Archive (archived: back to review)" }, { scope: "shot", action: "framing", keys: ["r"], label: "Framing stage" }, - { scope: "shot", action: "compose", keys: ["c"], label: "Compose stage" }, - { scope: "shot", action: "sequence", keys: ["s"], label: "Sequence stage" }, + { scope: "shot", action: "compose", keys: ["c"], label: "Compose stage (steps, layers, sketches)" }, { scope: "shot", action: "frameMode", keys: ["m"], label: "Cycle the frame mode" }, { scope: "shot", action: "back", keys: ["Escape"], label: "Back to where the shot was opened" }, { scope: "framing", action: "drag", keys: ["Drag"], info: true, label: "Move the frame" }, @@ -139,15 +137,8 @@ export const SHORTCUTS = [ { scope: "framing", action: "nudge", keys: arrows.map((k) => `Shift+${k}`), display: "⇧ arrows", label: "Nudge the frame by 1 %" }, { scope: "framing", action: "save", keys: ["Mod+s"], label: "Save the framing" }, { scope: "framing", action: "back", keys: ["Escape"], label: "Back to the photo" }, - { scope: "sequence", action: "prevStep", keys: [","], label: "Previous step" }, - { scope: "sequence", action: "nextStep", keys: ["."], label: "Next step" }, - { scope: "sequence", action: "earlier", keys: ["Alt+ArrowLeft"], label: "Move the step earlier" }, - { scope: "sequence", action: "later", keys: ["Alt+ArrowRight"], label: "Move the step later" }, - { scope: "sequence", action: "remove", keys: ["Delete"], label: "Remove the step" }, - { scope: "sequence", action: "save", keys: ["Mod+s"], label: "Save now (changes autosave)" }, - { scope: "sequence", action: "back", keys: ["Escape"], label: "Back to the photo" }, - { scope: "compose", action: "prevPhoto", keys: [","], label: "Previous photo of a sequence" }, - { scope: "compose", action: "nextPhoto", keys: ["."], label: "Next photo of a sequence" }, + { scope: "compose", action: "prevStep", keys: [","], label: "Previous step" }, + { scope: "compose", action: "nextStep", keys: ["."], label: "Next step" }, { scope: "position", action: "drag", keys: ["Drag"], info: true, label: "Move the pin" }, { scope: "position", action: "save", keys: ["Enter"], label: "Save the position" }, { scope: "position", action: "cancel", keys: ["Escape"], label: "Cancel" }, diff --git a/apps/studio/src/styles.css b/apps/studio/src/styles.css index 8fff42d..bb438a0 100644 --- a/apps/studio/src/styles.css +++ b/apps/studio/src/styles.css @@ -572,7 +572,7 @@ button { font: inherit; } .trash__icon img { width: 100%; height: 100%; object-fit: cover; } .trash__soon { color: var(--danger); font-weight: var(--weight-semibold); } -/* Sequence stage (issue #266): step strips carry framing / overlay glyphs; dragging a step shows an accent drop marker. */ +/* Compose stage steps (issue #266): step strips carry a framing glyph; dragging a step shows an accent drop marker. */ .f-strip__g { position: absolute; right: 4px; top: 4px; display: flex; gap: 2px; pointer-events: none; } .f-strip__g .mdi { font-size: 12px; background: #000; color: #fff; border-radius: 2px; padding: 2px; } .seq-strip .f-strip__it.is-drop-before { box-shadow: -3px 0 0 var(--accent), 0 0 0 1px var(--border); } @@ -595,3 +595,21 @@ button { font: inherit; } .vig-preview { position: relative; width: 100%; aspect-ratio: 3 / 2; border-radius: var(--radius-sm); overflow: hidden; background: repeating-linear-gradient(0deg, var(--surface-sunken) 0 1px, transparent 1px 24px), repeating-linear-gradient(90deg, var(--surface-sunken) 0 1px, transparent 1px 24px), linear-gradient(135deg, #9db3c8, #e9d9c3); } .vig-preview__layer { position: absolute; inset: 0; } .vig-preview__lbl { position: absolute; left: 8px; bottom: 6px; font: var(--weight-bold) var(--text-caption)/1 var(--font-sans); color: #fff; text-shadow: 0 0 3px #000; } + +/* The composition (epic #275): layers composed live in a Framed box, the layer list, step ranges. */ +.stack-fill { position: absolute; inset: 0; pointer-events: none; } +.stack-adj { position: absolute; pointer-events: none; } +.stack-adj__part { position: absolute; inset: 0; } +.bake-mark { position: absolute; right: 4px; bottom: 4px; z-index: 2; background: #000; color: #fff; border-radius: 2px; padding: 0 4px; font: var(--weight-bold) 11px/16px var(--font-sans); pointer-events: auto; } +.layer-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; } +.layer-row { display: flex; align-items: center; gap: 6px; min-height: 36px; padding: 0 8px 0 2px; border-radius: var(--radius-sm); border: var(--bw) solid transparent; cursor: pointer; color: var(--text); } +.layer-row:hover { background: var(--surface-sunken); } +.layer-row.is-sel { border-color: var(--accent); background: var(--accent-tint); } +.layer-row.is-hidden .layer-row__name { color: var(--text-dim); text-decoration: line-through; } +.layer-row--footage { padding-left: 10px; border-style: dashed; border-color: var(--border); } +.layer-row__name { flex: 1; min-width: 0; font-size: var(--text-small); font-weight: var(--weight-semibold); } +.layer-row__badge { flex: none; font-size: var(--text-caption); color: var(--text-dim); border: var(--bw) solid var(--border); border-radius: var(--radius-pill); padding: 0 6px; } +.range-chips { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; } +.range-chip { min-width: 28px; height: 28px; border-radius: var(--radius-sm); border: var(--bw) solid var(--border); background: var(--surface); color: var(--text); font: var(--weight-bold) var(--text-small)/1 var(--font-sans); cursor: pointer; } +.range-chip.is-on { background: var(--accent); border-color: var(--accent); color: var(--on-accent); } +.range-chip.is-here { box-shadow: 0 0 0 2px var(--border-strong); } diff --git a/apps/studio/src/vignette.ts b/apps/studio/src/vignette.ts index 94c6ecb..ee12af5 100644 --- a/apps/studio/src/vignette.ts +++ b/apps/studio/src/vignette.ts @@ -31,7 +31,7 @@ export function presentationVignette(photo: Photo, pres: Presentation): Vignette if (pres.frame) return vignetteOf(pres.rig_id ?? photo.preset_id, pres.lens_mm ?? photo.lens_mm); return photoVignette(photo); } -/** A step's: its framing, else its overlay's presentation, else the photo's root. */ -export const stepVignette = (st: Step): Vignette | null => (st.framing ? framingVignette(st.photo, st.framing) : st.overlay ? presentationVignette(st.photo, st.overlay.presentation) : photoVignette(st.photo)); +/** A step's: its framing, else the photo's root. */ +export const stepVignette = (st: Step): Vignette | null => (st.framing ? framingVignette(st.photo, st.framing) : photoVignette(st.photo)); /** The focal length a step is shown at, for the "Vignetting at n mm" chip. */ -export const stepLensMm = (st: Step): number => st.framing?.lens_mm ?? (st.overlay?.presentation.lens_mm || null) ?? st.photo.lens_mm; +export const stepLensMm = (st: Step): number => st.framing?.lens_mm ?? st.photo.framings.find((f) => f.id === st.photo.root_framing_id)?.lens_mm ?? st.photo.lens_mm; diff --git a/apps/studio/src/wm/Shell.tsx b/apps/studio/src/wm/Shell.tsx index 8d3dc3b..9c4c304 100644 --- a/apps/studio/src/wm/Shell.tsx +++ b/apps/studio/src/wm/Shell.tsx @@ -26,6 +26,7 @@ import { WindowGhosts } from "./WindowGhosts"; import { Window } from "./Window"; import { goToWorkspace } from "./switchWorkspace"; import * as wm from "./wm"; +import { startBakery } from "../compose/baker"; setFocusSource(wm.getFocusedId); @@ -90,6 +91,8 @@ function SignedIn() { setReady(valid); }, [valid]); useEffect(() => (ready ? startRouteBridge() : undefined), [!!ready]); + // Steps with layers need a flattened image for the phone (epic #275): baked in the background. + useEffect(() => (ready ? startBakery() : undefined), [!!ready]); useEffect(() => { const flush = () => desktopState.flush(); window.addEventListener("pagehide", flush); diff --git a/docs/shortcuts.md b/docs/shortcuts.md index 52d53c7..1e9fd9b 100644 --- a/docs/shortcuts.md +++ b/docs/shortcuts.md @@ -86,8 +86,7 @@ The same list is in the app: `?`, and Settings › Shortcuts. Single keys never | A | Approve | | E | Archive (archived: back to review) | | R | Framing stage | -| C | Compose stage | -| S | Sequence stage | +| C | Compose stage (steps, layers, sketches) | | M | Cycle the frame mode | | Esc | Back to where the shot was opened | @@ -102,26 +101,14 @@ The same list is in the app: `?`, and Settings › Shortcuts. Single keys never | ⌘S | Save the framing | | Esc | Back to the photo | -## Shot view · Sequence +## Shot view · Compose (steps, layers, sketches) | Keys | What | | --- | --- | | , | Previous step | | . | Next step | -| Alt ← | Move the step earlier | -| Alt → | Move the step later | -| Del | Remove the step | -| ⌘S | Save now (changes autosave) | -| Esc | Back to the photo | - -## Shot view · Compose - -| Keys | What | -| --- | --- | -| , | Previous photo of a sequence | -| . | Next photo of a sequence | -## Drawing (Compose, and overlays or sketches on the timeline stage) +## Drawing (Compose, and a clip's layers or sketch on the timeline stage) | Keys | What | | --- | --- | From 65b48546c3da49cfd08962cdc8da9b9777f3aa2f Mon Sep 17 00:00:00 2001 From: bubulux Date: Tue, 27 Oct 2026 13:11:26 +0100 Subject: [PATCH 3/4] Phone: steps show the Studio's bakes, offline too (#275) A step with layers shows its fresh bake (the offline copy first), else the photo with a "Layers not baked yet" tag. Offline days and projects download the fresh bakes; the compose strip and viewer keep sketches only (the layers travel in the bakes). Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/api.ts | 29 ++++++------ apps/mobile/src/components/ComposeParts.tsx | 51 +++++++-------------- apps/mobile/src/components/PhotoStrip.tsx | 2 +- apps/mobile/src/components/ShotFrame.tsx | 38 ++++++++------- apps/mobile/src/components/ShotParts.tsx | 8 ++-- apps/mobile/src/localShots.ts | 3 +- apps/mobile/src/offline.ts | 15 ++++-- 7 files changed, 68 insertions(+), 78 deletions(-) diff --git a/apps/mobile/src/api.ts b/apps/mobile/src/api.ts index c84dc82..a6d8b94 100644 --- a/apps/mobile/src/api.ts +++ b/apps/mobile/src/api.ts @@ -3,9 +3,9 @@ import { getSession, getToken, setSession } from "./auth"; import { API_URL, isConfigured } from "./config"; import { log } from "./log"; import { isOfflineMode, markOnline } from "./net"; -import type { DeviceSpecs, Extra, FieldDefinition, Framing, Presentation } from "@openclapper/vocab"; +import type { DeviceSpecs, Extra, FieldDefinition, Framing } from "@openclapper/vocab"; import type { VignettePoint } from "@openclapper/vocab"; -import { stepsOf, type ResolvedStep, type ShotStep } from "@openclapper/vocab"; +import { bakeHash, freshBake, stepsOf, type Layer, type ResolvedStep, type ShotStep, type StepBake } from "@openclapper/vocab"; import type { Fixture } from "./fixtures/types"; import type { LocationEntry, Preset, ProjectEntry, ShotMetadata, ShotTags, SpotEntry, LayoutEntry } from "./types"; @@ -189,21 +189,20 @@ export interface Shot { updated_at: string | null; /** Ordered; never empty. */ photos: Photo[]; - /** Drawings over single photos (Studio-made); the phone shows their renders. */ - overlays: Overlay[]; /** Free canvases on the shot (floor plans, diagrams); the phone shows their renders. */ sketches: Sketch[]; - /** The stored progression (issue #266), raw rows; absent or empty = the photos are the steps (queued shots never have any). */ + /** The stored steps (issue #266), raw rows; absent or empty = the photos are the steps (queued shots never have any). */ steps?: ShotStep[]; + /** The composition (epic #275): the shot's layers (no shapes; the phone never draws them) and the Studio's bakes per step. */ + layers?: Layer[]; + bakes?: StepBake[]; /** Only on the phone: still in the upload queue (see localShots.ts). */ queued?: boolean; } -/** A drawing + look over one photo, as a flattened render the size of the photo (issue #12). */ -export interface Overlay { id: string; photo_id: string; shot_id: string; name: string; description: string | null; presentation: Presentation; position: number; render_url: string | null; created_at: string; updated_at: string | null } /** A sketch of the shot (floor plan, lighting diagram, …), rendered on white. */ export interface Sketch { id: string; shot_id: string; name: string; kind: string | null; description: string | null; aspect: number; position: number; render_url: string | null; created_at: string; updated_at: string | null } -/** Either kind, for strips and the viewer. */ -export type Composed = ({ type: "overlay" } & Overlay) | ({ type: "sketch" } & Sketch); +/** What the compose strip and viewer show: the shot's sketches. */ +export type Composed = { type: "sketch" } & Sketch; /** A shooting day of a project with its planned shots in order (planned in the Studio). */ export interface DayShot { shot_id: string; planned_time: string | null; notes: string | null } @@ -211,9 +210,13 @@ export interface ShootingDay { id: string; project_id: string; date: string; tit /** The photo that stands for the shot in lists, maps and filters: the first one. */ export const cover = (s: Shot): Photo => s.photos[0]; -/** The steps a shot shows (issue #266): its stored progression, else its photos. Strips, badges and Step-through count these. */ -export type Step = ResolvedStep; -export const steps = (s: Shot): Step[] => stepsOf({ photos: s.photos, overlays: s.overlays ?? [], steps: s.steps }); +/** The steps a shot shows (issue #266): its stored steps, else its photos. Strips, badges and Step-through count these. */ +export type Step = ResolvedStep; +export const steps = (s: Shot): Step[] => stepsOf({ photos: s.photos, steps: s.steps }); +/** A step's bake when it is fresh (epic #275): the Studio's flattened picture of the step with its layers. */ +export const stepBakeOf = (s: Shot, st: Step): StepBake | null => freshBake(s.bakes, st, bakeHash(st, s.layers ?? [])); +/** A step that shows layers but has no fresh bake yet (the Studio makes it); the phone then shows the photo. */ +export const stepStale = (s: Shot, st: Step): boolean => bakeHash(st, s.layers ?? []) !== null && !stepBakeOf(s, st); export const seqCount = (s: Shot): number => steps(s).length; /** * Where a shot is on a map: the location's pin when the shot is synced to it (and the pin is set), @@ -235,7 +238,7 @@ interface ShotsPage { shots: Shot[]; next: Cursor } /** Headers for requests to the API (the session token). */ export const imageHeaders = (): Record => headers(); export const imageUri = (photo: Photo) => `${API_URL}${photo.image_url}`; -/** Absolute URL of an overlay or sketch render; null before the Studio saved one. */ +/** Absolute URL of a sketch render or a bake; null before the Studio saved one. */ export const renderUri = (item: { render_url: string | null }) => (item.render_url ? `${API_URL}${item.render_url}` : null); export const api = { diff --git a/apps/mobile/src/components/ComposeParts.tsx b/apps/mobile/src/components/ComposeParts.tsx index 7f3fc59..bb7025d 100644 --- a/apps/mobile/src/components/ComposeParts.tsx +++ b/apps/mobile/src/components/ComposeParts.tsx @@ -2,20 +2,17 @@ import { useState } from "react"; import { Modal, Pressable, ScrollView, Text, useWindowDimensions, View } from "react-native"; import { Image, type ImageSource } from "expo-image"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { referencedFrame, sketchKindLabel } from "@openclapper/vocab"; -import { imageHeaders, renderUri, type Composed, type Overlay, type Photo, type Shot, type Sketch } from "../api"; +import { sketchKindLabel } from "@openclapper/vocab"; +import { imageHeaders, renderUri, type Composed, type Photo, type Shot } from "../api"; import { isOfflineMode } from "../net"; import { offlineRenderUri } from "../offline"; import type { ViewSettings } from "../types"; import { BORDER, FIXED, Icon, IconButton, ListRow, makeStyles, RADIUS, SectionLabel, type } from "../ui"; -import { FramedImage } from "./FramedImage"; -import { fitWidth, FrameModeSeg, frameOf, imageAspect, type FrameMode } from "./ShotFrame"; import { MarkdownText } from "./Markdown"; /** - * Overlays and sketches on the phone (issue #12): read-only renders made in the Studio. An - * overlay render is the photo's size, so it takes the frame modes like the photo (opening in the - * presentation it was drawn in); a sketch is shown as is. Offline copies are used when present. + * Sketches on the phone (issue #12): read-only renders made in the Studio, shown as they are. + * Offline copies are used when present. Layers are not here: a step's bake carries them. */ /** Image source of a render: the offline copy, else the authenticated proxy (nothing in offline mode). */ @@ -26,24 +23,21 @@ export function renderSource(item: { id: string; render_url: string | null }): I return isOfflineMode() || !uri ? { uri: "" } : { uri, headers: imageHeaders() }; } -export const composedOf = (shot: Shot, photo: Photo): Composed[] => [ - ...shot.overlays.filter((o) => o.photo_id === photo.id).map((o): Composed => ({ type: "overlay", ...o })), - ...shot.sketches.map((s): Composed => ({ type: "sketch", ...s })), -]; +export const composedOf = (shot: Shot, _photo: Photo): Composed[] => shot.sketches.map((s): Composed => ({ type: "sketch", ...s })); -/** Thumbnails of the photo's overlays and the shot's sketches; nothing when the shot has none. */ +/** Thumbnails of the shot's sketches; nothing when the shot has none. */ export function ComposeStrip({ shot, photo, onOpen }: { shot: Shot; photo: Photo; onOpen: (c: Composed) => void }) { const s = useStyles(); const items = composedOf(shot, photo); if (!items.length) return null; return ( - {shot.photos.length > 1 ? `Overlays · photo ${photo.ordinal + 1} · Sketches` : "Overlays · Sketches"} + Sketches {items.map((it) => ( onOpen(it)} style={({ pressed }) => [s.tile, pressed && { opacity: 0.8 }]} accessibilityRole="button" accessibilityLabel={`${it.name}, ${kindLabel(it)}`}> - - {it.render_url ? : } + + {it.render_url ? : } {it.name} {kindLabel(it)} @@ -54,51 +48,38 @@ export function ComposeStrip({ shot, photo, onOpen }: { shot: Shot; photo: Photo ); } -export const kindLabel = (c: Composed) => (c.type === "overlay" ? "Overlay" : sketchKindLabel(c.kind) || "Sketch"); +export const kindLabel = (c: Composed) => sketchKindLabel(c.kind) || "Sketch"; /** Rows for a sheet (step-through Details). */ export function ComposeRows({ shot, photo, onOpen }: { shot: Shot; photo: Photo; onOpen: (c: Composed) => void }) { const items = composedOf(shot, photo); if (!items.length) return null; - return {items.map((it) => onOpen(it)} />)}; + return {items.map((it) => onOpen(it)} />)}; } -/** - * Full screen: the render on black (overlays with the frame modes, opening in the presentation they - * were drawn in), the name and kind, and the description on an opaque card. - */ -export function ComposeViewer({ item, photo, settings, onClose }: { item: Composed; photo: Photo; settings: ViewSettings; onClose: () => void }) { +/** Full screen: the sketch on white, the name and kind, and the description on an opaque card. */ +export function ComposeViewer({ item, onClose }: { item: Composed; photo?: Photo; settings?: ViewSettings; onClose: () => void }) { const s = useStyles(); const { width, height } = useWindowDimensions(); const insets = useSafeAreaInsets(); - const overlay = item.type === "overlay" ? (item as Overlay) : null; - const [mode, setMode] = useState(overlay?.presentation.mode ?? "off"); const [notes, setNotes] = useState(true); const landscape = width > height; const imgMaxH = height - (landscape ? 110 : 220); const source = renderSource(item); - // The framing the overlay references (follows its edits), else the frame it kept, else the photo's root. - const ref = overlay ? referencedFrame({ framings: photo.framings }, overlay.presentation) : null; - const frame = overlay ? (ref ? { width: ref.width_fraction, height: ref.height_fraction, x: ref.x, y: ref.y } : frameOf(photo)) : null; - const w = overlay - ? fitWidth({ ...photo, framings: [], root_framing_id: null, framing: frame ? { ...photo.framing, frame: { width_fraction: frame.width, height_fraction: frame.height } } : photo.framing }, mode, width, imgMaxH) - : Math.max(40, Math.min(width, imgMaxH * (item as Sketch).aspect)); + const w = Math.max(40, Math.min(width, imgMaxH * item.aspect)); return ( - {overlay - ? - : {item.render_url ? : No render yet}} + {{item.render_url ? : No render yet}} {item.name} - {kindLabel(item)}{overlay?.presentation.label ? ` · ${overlay.presentation.label}` : ""} + {kindLabel(item)} {!!item.description && setNotes(!notes)} />} - {overlay && } {!!item.description && notes && } diff --git a/apps/mobile/src/components/PhotoStrip.tsx b/apps/mobile/src/components/PhotoStrip.tsx index 55f8963..a3c6c95 100644 --- a/apps/mobile/src/components/PhotoStrip.tsx +++ b/apps/mobile/src/components/PhotoStrip.tsx @@ -14,7 +14,7 @@ export function PhotoStrip({ shot, index, onPick }: { shot: Shot; index: number; {list.map((st, i) => ( onPick(i)} style={[s.thumb, i === index && s.thumbOn]} accessibilityRole="button" accessibilityLabel={`${st.stored ? "Step" : "Photo"} ${i + 1}`} accessibilityState={{ selected: i === index }}> - + {i + 1} ))} diff --git a/apps/mobile/src/components/ShotFrame.tsx b/apps/mobile/src/components/ShotFrame.tsx index 8b81509..13ef1ef 100644 --- a/apps/mobile/src/components/ShotFrame.tsx +++ b/apps/mobile/src/components/ShotFrame.tsx @@ -1,8 +1,8 @@ import type { StyleProp, ViewStyle } from "react-native"; -import { rootFrame, vignetteAt, type Framing, type Presentation, type Vignette } from "@openclapper/vocab"; -import { imageHeaders, imageUri, renderUri, type Photo, type Step } from "../api"; +import { rootFrame, vignetteAt, type Framing, type Vignette } from "@openclapper/vocab"; +import { imageHeaders, imageUri, renderUri, stepBakeOf, type Photo, type Shot, type Step } from "../api"; import { isOfflineMode } from "../net"; -import { offlinePhotoUri, offlineRenderUri } from "../offline"; +import { bakeRender, offlinePhotoUri, offlineRenderUri } from "../offline"; import { getRigs } from "../rigs"; import type { ViewSettings } from "../types"; import { FramedImage, type FrameFractions, type FrameMode } from "./FramedImage"; @@ -33,16 +33,9 @@ export function photoVignette(photo: Photo): Vignette | null { const root = photo.root_framing_id ? photo.framings?.find((f) => f.id === photo.root_framing_id) : undefined; return root ? framingVignette(photo, root) : vignetteOf(photo.preset_id, photo.lens_mm); } -/** A step's: its framing, else the frame its overlay was drawn for, else the photo's root. */ +/** A step's: its framing, else the photo's root. */ export function stepVignette(step: Step): Vignette | null { - if (step.framing) return framingVignette(step.photo, step.framing); - if (step.overlay) { - const pres = step.overlay.presentation as Presentation; - const f = pres.framing_id ? step.photo.framings?.find((x) => x.id === pres.framing_id) : undefined; - if (f) return framingVignette(step.photo, f); - if (pres.frame) return vignetteOf(pres.rig_id ?? step.photo.preset_id, pres.lens_mm ?? step.photo.lens_mm); - } - return photoVignette(step.photo); + return step.framing ? framingVignette(step.photo, step.framing) : photoVignette(step.photo); } /** Photo aspect ratio (w/h) as uploaded; 4:3 when unknown. */ @@ -85,20 +78,25 @@ export function ShotFrame({ photo, width, settings, mode, style }: Props) { ); } -/** What a step shows (issue #266): its overlay's render (offline copy first), else the photo; an overlay without a render yet falls back to the photo. */ -export function stepSource(step: Step): { uri: string; headers?: Record } { - if (step.overlay?.render_url) { - const local = offlineRenderUri(step.overlay); +/** + * What a step shows (epic #275): its fresh bake (the Studio's picture of the step with its layers; + * the offline copy first), else the photo. A stale or missing bake falls back to the photo. + */ +export function stepSource(shot: Shot, step: Step): { uri: string; headers?: Record } { + const bake = stepBakeOf(shot, step); + if (bake) { + const r = bakeRender(shot.id, bake); + const local = offlineRenderUri(r); if (local) return { uri: local }; - if (!isOfflineMode()) return { uri: renderUri(step.overlay)!, headers: imageHeaders() }; + if (!isOfflineMode()) return { uri: renderUri(r)!, headers: imageHeaders() }; } return photoSource(step.photo); } const fractions = (f: Step["frame"]): FrameFractions | null => (f ? { width: f.width_fraction, height: f.height_fraction, x: f.x, y: f.y } : null); -/** A step of a sequence: the photo, or its overlay's render, in the frame the step is shown with (same layout rules as a photo). */ -export function StepFrame({ step, width, settings, mode, style }: { step: Step; width: number; settings: ViewSettings; mode: FrameMode; style?: StyleProp }) { - return ; +/** A step of a shot: its bake (or the photo) in the frame the step is shown with (same layout rules as a photo). */ +export function StepFrame({ shot, step, width, settings, mode, style }: { shot: Shot; step: Step; width: number; settings: ViewSettings; mode: FrameMode; style?: StyleProp }) { + return ; } /** Width at which a step (in this mode) fits inside maxW × maxH. */ diff --git a/apps/mobile/src/components/ShotParts.tsx b/apps/mobile/src/components/ShotParts.tsx index b34c3d5..7d6e73c 100644 --- a/apps/mobile/src/components/ShotParts.tsx +++ b/apps/mobile/src/components/ShotParts.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { Modal, Text, useWindowDimensions, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { label } from "@openclapper/vocab"; -import { api, positionOf, steps, type Photo, type Shot } from "../api"; +import { api, positionOf, steps, stepStale, type Photo, type Shot } from "../api"; import { editShot, isWaiting } from "../localShots"; import { isOfflineMode, isOnline } from "../net"; import { discardPending } from "../uploads"; @@ -115,7 +115,8 @@ export function ShotPhoto({ shot, index, mode, settings, maxW, maxH, badges = tr const pan = useSwipe({ width: maxW, next: onNext, prev: onPrev, doubleTap: onOpen }); return ( - + + {stepStale(shot, step) && Layers not baked yet} {badges && n > 1 && } {badges && n > 1 && {stepTag(shot, index, n)}} @@ -136,7 +137,7 @@ export function FullPhoto({ shot, index, mode, onMode, settings, onClose }: { sh return ( - + @@ -155,6 +156,7 @@ const useStyles = makeStyles((c) => ({ photoBox: { backgroundColor: FIXED.photoBg, alignItems: "center", justifyContent: "center", overflow: "hidden" }, tl: { position: "absolute", top: 10, left: 10 }, tr: { position: "absolute", top: 10, right: 10 }, + bl: { position: "absolute", bottom: 10, left: 10 }, full: { flex: 1, backgroundColor: FIXED.photoBg, alignItems: "center", justifyContent: "center" }, fullClose: { position: "absolute", left: 12 }, fullCard: { position: "absolute", alignSelf: "center", alignItems: "center", gap: 6, padding: 10, borderRadius: RADIUS.md, backgroundColor: c.chromeBg, borderWidth: BORDER.badge, borderColor: c.chromeBorder }, diff --git a/apps/mobile/src/localShots.ts b/apps/mobile/src/localShots.ts index b31b0f2..83f2140 100644 --- a/apps/mobile/src/localShots.ts +++ b/apps/mobile/src/localShots.ts @@ -48,7 +48,8 @@ export function queuedToShot(entry: PendingUpload): Shot { lens_mm: p.lens_mm, width: p.width, height: p.height, framing: p.framing, device: p.device, image_url: "", created_at: p.timestamp, local_uri: entry.files[p.id], })), - overlays: [], + layers: [], + bakes: [], sketches: [], queued: true, }; diff --git a/apps/mobile/src/offline.ts b/apps/mobile/src/offline.ts index 2c14375..30b982e 100644 --- a/apps/mobile/src/offline.ts +++ b/apps/mobile/src/offline.ts @@ -1,6 +1,6 @@ import { File } from "expo-file-system"; import { dataDir, kv as Storage } from "./kv"; -import { imageHeaders, imageUri, renderUri, type Overlay, type Photo, type Shot, type ShootingDay, type Sketch } from "./api"; +import { imageHeaders, imageUri, renderUri, stepBakeOf, steps, type Photo, type Shot, type ShootingDay } from "./api"; import { API_URL } from "./config"; import { log } from "./log"; @@ -26,19 +26,24 @@ const photoFile = (id: string) => new File(dir, `${id}.jpg`); // Renders change when the Studio saves again, so the file carries the version from render_url (?v=…). const renderVersion = (item: { render_url: string | null }) => (item.render_url?.split("v=")[1] ?? "").replace(/[^0-9A-Za-z]/g, "").slice(0, 24); const renderFile = (item: { id: string; render_url: string | null }) => new File(dir, `r-${item.id}-${renderVersion(item)}.jpg`); -const rendersOf = (shots: Shot[]): (Overlay | Sketch)[] => shots.flatMap((s) => [...s.overlays, ...s.sketches]).filter((r) => !!r.render_url); +/** A render to keep on the phone: a sketch's, or a step's bake (epic #275) under an id of its own. */ +interface Render { id: string; render_url: string | null } +/** The bake of a step as a render: one file per shot and step, its version from the hash in the URL. */ +export const bakeRender = (shotId: string, bake: { step_key: string; url: string }): Render => ({ id: `b-${shotId}-${bake.step_key}`, render_url: bake.url }); +const bakesOf = (s: Shot): Render[] => steps(s).flatMap((st) => { const b = stepBakeOf(s, st); return b ? [bakeRender(s.id, b)] : []; }); +const rendersOf = (shots: Shot[]): Render[] => shots.flatMap((s) => [...s.sketches, ...bakesOf(s)]).filter((r) => !!r.render_url); /** Local copy of a photo if one was saved for an offline day, else null. */ export function offlinePhotoUri(photo: Photo): string | null { try { const f = photoFile(photo.id); return f.exists ? f.uri : null; } catch { return null; } } -/** Local copy of an overlay or sketch render (this exact version), else null. */ +/** Local copy of a sketch render or a bake (this exact version), else null. */ export function offlineRenderUri(item: { id: string; render_url: string | null }): string | null { if (!item.render_url) return null; try { const f = renderFile(item); return f.exists ? f.uri : null; } catch { return null; } } /** Download one render and drop older versions of it. */ -async function fetchRender(item: Overlay | Sketch) { +async function fetchRender(item: Render) { const f = renderFile(item); if (f.exists) return; await File.downloadFileAsync(renderUri(item)!, f, { headers: imageHeaders(), idempotent: true }); @@ -85,7 +90,7 @@ function deleteUnused(shots: Shot[]) { if (keep.has(p.id)) continue; try { const f = photoFile(p.id); if (f.exists) f.delete(); } catch { /* best effort */ } } - for (const r of [...s.overlays, ...s.sketches]) { + for (const r of [...s.sketches, ...bakesOf(s)]) { if (keepRenders.has(r.id)) continue; try { for (const f of dir.list()) if (f instanceof File && f.name.startsWith(`r-${r.id}-`)) f.delete(); } catch { /* best effort */ } } From e2428d0e826477841a6d54bf166130bd27fd286b Mon Sep 17 00:00:00 2001 From: bubulux Date: Tue, 27 Oct 2026 13:27:24 +0100 Subject: [PATCH 4/4] Docs: composition, layers and bakes across the feature pages (#275) Co-Authored-By: Claude Opus 5.5 --- apps/worker/test/routes/layers.test.ts | 7 +- docs/README.md | 6 +- docs/features/capture.md | 2 +- docs/features/compose.md | 100 +++++++++++--------- docs/features/composition.md | 126 +++++++++++++++++++++++++ docs/features/day-mode-offline.md | 6 +- docs/features/new-shot.md | 2 +- docs/features/phone-app.md | 2 +- docs/features/review-and-tagging.md | 15 ++- docs/features/rig-explorer.md | 6 +- docs/features/sequences.md | 104 -------------------- docs/features/timeline.md | 38 ++++---- docs/known-issues.md | 12 +++ 13 files changed, 235 insertions(+), 191 deletions(-) create mode 100644 docs/features/composition.md delete mode 100644 docs/features/sequences.md diff --git a/apps/worker/test/routes/layers.test.ts b/apps/worker/test/routes/layers.test.ts index 4e4a3aa..501736d 100644 --- a/apps/worker/test/routes/layers.test.ts +++ b/apps/worker/test/routes/layers.test.ts @@ -51,8 +51,11 @@ describe("layers", () => { expect(await res.text()).toMatch(re); } expect(await snapshot()).toBe(before); - // At most 50 layers on a shot. - for (let i = 0; i < 49; i++) await ok(api(u.token, "PUT", `/api/layers/${uuid()}`, layerBody(w.shot2, `L${i}`)), 201); + }); + + test("a shot holds at most 50 layers", async () => { + // 49 straight into D1 (the route is what is tested here, not 49 round trips). + await env.DB.batch(Array.from({ length: 49 }, (_, i) => env.DB.prepare("INSERT INTO layers (id, shot_id, kind, name, space, shapes) VALUES (?1, ?2, 'drawing', ?3, 'frame', '[]')").bind(uuid(), w.shot2, `L${i}`))); await ok(api(u.token, "PUT", `/api/layers/${uuid()}`, layerBody(w.shot2, "one more")), 201); await ok(api(u.token, "PUT", `/api/layers/${uuid()}`, layerBody(w.shot2, "too many")), 413); }); diff --git a/docs/README.md b/docs/README.md index 841e7ba..2b577bd 100644 --- a/docs/README.md +++ b/docs/README.md @@ -32,10 +32,10 @@ Reference documentation for the whole app. Start with [architecture](architectur | [features/positions.md](features/positions.md) | GPS modes (High · Low · Off), photos without a position, manual pin correction, location pins and shots synced to them | | [features/rig-explorer.md](features/rig-explorer.md) | Framing: re-frame a photo (other rig + lens, moved), saved framings, the root frame, Compare A / B | | [features/new-shot.md](features/new-shot.md) | New shot in the Studio from uploaded images or a drawn sketch | -| [features/compose.md](features/compose.md) | Shot descriptions, overlays (drawing + look on a photo), sketches (floor plans, diagrams); Studio editor, phone viewer, renders | +| [features/compose.md](features/compose.md) | Shot descriptions, the drawing model, sketches (floor plans, diagrams); Studio editor, phone viewer, renders | | [features/schedule.md](features/schedule.md) | Shooting days, daylight phases, forecast, shootable windows | -| [features/timeline.md](features/timeline.md) | Timelines: rough cuts of photos and overlays with hold times, playback (Studio only) | -| [features/sequences.md](features/sequences.md) | Sequences as progressions: steps (photo + framing + overlay), the Sequence stage, zoom and light progressions, Make a sequence from clips (merge), Extract as own shot | +| [features/timeline.md](features/timeline.md) | Timelines: rough cuts of steps and clip layers with hold times, playback (Studio only) | +| [features/composition.md](features/composition.md) | A shot as a composition: steps, layers (drawing and adjustment, with order, range and space), bakes for the phone, the Compose stage, merge and extract, the migration from overlays | | [features/day-mode-offline.md](features/day-mode-offline.md) | Phone "Day" tab, offline days, offline project, offline mode, edits kept on the phone | | [features/phone-app.md](features/phone-app.md) | Phone shell: 5 tabs, gates, pushed screens, header and sync state, Setup hub, shared components | | [features/files.md](features/files.md) | Files on the desktop: folders, the Desktop folder, the Files app, desktop icons and selection, the recycle bin | diff --git a/docs/features/capture.md b/docs/features/capture.md index d2d35f6..80ca79b 100644 --- a/docs/features/capture.md +++ b/docs/features/capture.md @@ -78,7 +78,7 @@ It then becomes a **draft** (`CaptureDraft = { shotId, photos: DraftPhoto[] }`), - **Direct upload** (Setup → Capture): no Tag screen; the shot is queued untagged ("Queued untagged · tag it later in History"). If queuing fails, Tag opens instead. - **Sequence mode:** every capture while Seq is on is appended to one shot (`addToSequence`). A red "SEQ · n" badge with a dot sits on the preview. Turning Seq off turns the set into one draft: Tag (with a photo strip), or the queue with direct upload. -A capture never creates **steps**: a sequence uploaded from the phone shows its photos as steps until the Studio builds a progression ([sequences](sequences.md)). +A capture never creates **steps**: a sequence uploaded from the phone shows its photos as steps until the Studio stores steps and layers ([composition](composition.md)). Drafts and running sequences are stored in the kv-store (`captureDraft.v1`, `sequence.v1`), so an app kill loses nothing: Tag or the sequence comes back on the next launch (Tag once the sign-in and project gates are through). A capture that finishes after Seq was switched off becomes a single shot, never part of the ended sequence. diff --git a/docs/features/compose.md b/docs/features/compose.md index 638a60e..d65ee90 100644 --- a/docs/features/compose.md +++ b/docs/features/compose.md @@ -1,49 +1,57 @@ -# Compose: descriptions, overlays and sketches - -From [GitHub issue #12](https://github.com/OpenClapper-dev/core/issues/12) (first half; the timeline composer is the second). A shot can carry a written description, **overlays** (a drawing plus a look over one of its photos) and **sketches** (free canvases such as a floor plan). Everything is made in the Studio; the phone shows read-only renders. Nothing here depends on the review state: unreviewed shots compose like approved ones. - -## Model (`packages/vocab/src/compose.ts`, `markdown.ts`) - -- **Overlay**: belongs to one **photo** (a sequence has different framing per photo). Holds a `drawing` (shapes in the raw photo's coordinate space: 0..1 across the image, values outside allowed for the black area shown when the rig saw more than the phone), a `look`, a default `presentation`, a name, a description and a position. Because the drawing lives in photo coordinates, every frame mode and every rig or lens is only a crop or a frame over it: the drawing never moves. The presentation (`mode`, the rig `frame` fractions, a `label`, and the `rig_id` / `lens_mm` it came from) is only how the overlay opens. -- A **step** of a sequence can show an overlay instead of its photo ([sequences](sequences.md)): a light progression is one step per overlay of a photo. The step opens in the frame the overlay was drawn for unless it picks a framing of its own. -- **Sketch**: belongs to the **shot**. A drawing on a blank canvas of a chosen aspect ratio (16:9, 4:3, 1:1, 3:4), a free-text `kind` (Floor plan, Lighting diagram, Blocking and Storyboard are offered first), name, description, position. -- **Shapes**: `path` (freehand, points simplified on save), `line`, `arrow`, `rect`, `ellipse` (optionally filled), `text` and `stencil`: 67 stroke glyphs in 24 × 24 (`STENCILS` / `STENCIL_GROUPS` in vocab, paths in `apps/studio/src/compose/stencils.ts`): the four hand-drawn diagram glyphs (camera top view, light, actor, flag) and, since issue #46, a curated set vendored from Lucide (lucide-static 1.53.0, ISC; each icon's elements joined into one stroke path) in groups Camera & sound, Light, People & animals, Vehicles, Set & props, Nature & weather, Marks. No icon dependency at runtime. Any shape can carry a **mask** (`mask: [{ width, points }]`, issue #46): eraser strokes that cut parts out of that shape only; moving the shape moves them, resizing or rotating does not. Stroke width is a fraction of the canvas width, text and stencil size a fraction of its height, so a drawing scales with the display and the render. Ten fixed colours (`COMPOSE_PALETTE`), drawn over photos, so they never take the theme. -- **Look** (overlays only): exposure (−3..3; brightness 4^value, so one unit is 2 EV and the slider spans ±6 EV, issue #46; the old ±1 ends render exactly as before), contrast, saturation (−1..1), black-and-white, a tint colour with opacity, vignette and softness (0..1). On the Studio it is CSS (`lookFilter()` plus a tint layer and a radial gradient); the render bakes the same into the pixels. -- **Description** (shot, overlay, sketch): a Markdown subset: `#`/`##`/`###` headings, paragraphs, `-` and `1.` lists, `**bold**`, `*italic*`, `` `code` ``, `[links](https://…)`. `parseMarkdown()` gives a block tree both clients render; nothing is stored as HTML, and only `http(s)` links are recognised. -- Validation (`validateDrawing`, `validatePresentation`) is strict: these are interactive edits. Limits: 3000 shapes, 4000 points a path, 200 characters a text, 1 MB of drawing JSON, 20 000 characters a description, 6 MB a render (JPEG, PNG or WebP, recognised by its bytes, #224). - -## Renders - -On every save the Studio flattens the item in the browser (`apps/studio/src/compose/render.ts`) and uploads it with the JSON (one multipart `PUT`): -- an overlay render is the **raw photo at its stored size** with the look and the drawing, as JPEG. Being photo-sized, the phone frames it with the same geometry as the photo (mask, frame, fit, raw). -- a sketch render is the canvas on white, 1600 px wide, as PNG. - -Renders live in R2 under `renders/overlays/.` and `renders/sketches/.`. `render_url` carries the save time (`?v=`), so it changes with every save and the image route can cache for a year. Deleting a shot removes its photos and renders together. - -## Studio (`apps/studio/src/compose/`) - -Shot view → stage **Compose** (`C`; route `#/shots/?stage=compose`). The stage takes the whole body: tools and the drawing surface where the photo was, the item strip in the foot, and a panel on the right instead of the inspector. - -- **Item strip** (foot): the overlays of the photo on stage, "+ Overlay", a divider, the shot's sketches, "+ Sketch". For sequences, `,` `.` and the ‹ › buttons switch the photo (overlays are per photo). Nothing is saved until **Save** (`⌘S`); the status next to it says Not saved yet · Saving… · Saved · Not saved + Retry. -- **Tools** (stage bar): Select/move `V`, Freehand `P`, Line `L`, Arrow `A`, Rectangle `R`, Ellipse `O`, Text `T`, Stencil `S`, Eraser `E`; the colour swatches (`1`–`0`), stroke width S/M/L, Fill (rectangles and ellipses), the **stencil picker** (a searchable grid by group), Undo/Redo (`⌘Z` / `⌘⇧Z`). Shift makes squares, circles and 45° lines. Colour and width apply to the next shape, or to the selected one. Text: click to place, type, Enter; double-click a text to edit it. Select: click a shape, drag to move, arrow keys nudge, `Del` removes, Esc deselects. A selected stencil shows its box with a **corner handle** (drag to resize) and a **knob** above it (drag to rotate; Shift snaps to 15°). -- **Panel**: name; for sketches the kind (combobox, custom names allowed) and the canvas aspect; move up/down among siblings, Delete (confirms) or Discard for a new item. Overlays: **Presentation** (frame mode and a **Framing**: As captured or a saved framing from the [Framing stage](rig-explorer.md), followed when it is edited; new overlays start on the root; the drawing stays put, this is only how it opens) and **Look** (sliders; double-click a slider to reset it; Reset for all). **Shape** shows the selected shape's opacity, size, rotation (stencils), text, its **Mask** (Paint away: drags erase parts of this shape only, with an S/M/L brush, Done or Esc ends it; Restore removes the mask), Duplicate and Remove. **Description** is a Markdown field (toolbar with bold, italic, heading, lists, link, code; Write · Preview). -- **Leaving** with unsaved changes (another stage, shot or photo, Back, Esc) asks: Discard · Save · Keep editing. Closing the tab asks too. -- **From timelines · n** (stage bar, when no item is open): overlays drawn inline on this photo in the project's timelines, and the timelines' sketches (issue #31). They belong to their clip and are not listed in the item strip; **Make the shot's** moves an overlay to the photo (its clip keeps showing it), **Attach here** makes a sketch this shot's. In a timeline the same tools work inline on the stage, see [timeline](timeline.md). -- The **inspector** (other stages) has a **Description** section for the shot (saved 0.7 s after the last edit, or on leaving the field) and a **Compose** section listing the overlay and sketch names with Open (`C`). The shot's delete confirmation counts them. - -Every key is listed under `?`. - -## Phone (`apps/mobile/src/components/ComposeParts.tsx`, `Markdown.tsx`) - -Read-only, from the renders: -- **Shot details**: the shot's description (rendered Markdown) under the summary, then a strip "Overlays · photo n · Sketches" with a thumbnail, name and kind per item. Tap opens the **viewer**: the render on black; overlays take the frame modes and open in the presentation they were drawn in, sketches show as they are; the name, kind and rig label, and the description on an opaque card (toggle). -- **Step-through** Details sheet: the description and a row per overlay and sketch; tap opens the same viewer. -- **Offline**: making a day or a project offline downloads the renders too (`offline-photos/r--.jpg`); a newer save replaces the old version on the next update. In offline mode a render without a local copy stays black, like photos. - -Queued shots (not uploaded yet) have no description, overlays or sketches. +# Compose: descriptions, drawings and sketches + +From [GitHub issue #12](https://github.com/OpenClapper-dev/core/issues/12). A shot carries a written description, +**sketches** (free canvases such as a floor plan) and, since [epic #275](https://github.com/OpenClapper-dev/core/issues/275), +**layers** over its photos ([composition](composition.md): drawings and looks on steps, baked for the phone). This page +covers the drawing model the layers and sketches share, the sketches, and the descriptions. Everything is made in the +Studio; the phone shows read-only renders. Nothing here depends on the review state. + +## Drawing model (`packages/vocab/src/compose.ts`, `markdown.ts`) + +- **Shapes**: `path` (freehand, points simplified on save), `line`, `arrow`, `rect`, `ellipse` (optionally filled), + `text` and `stencil`: 67 stroke glyphs in 24 × 24 (`STENCILS` / `STENCIL_GROUPS` in vocab, paths in + `apps/studio/src/compose/stencils.ts`): the four hand-drawn diagram glyphs (camera top view, light, actor, flag) + and, since issue #46, a curated set vendored from Lucide (lucide-static 1.53.0, ISC; each icon's elements joined + into one stroke path) in groups Camera & sound, Light, People & animals, Vehicles, Set & props, Nature & weather, + Marks. No icon dependency at runtime. Any shape can carry a **mask** (`mask: [{ width, points }]`, issue #46): + eraser strokes that cut parts out of that shape only; moving the shape moves them, resizing or rotating does not. + Coordinates are fractions of the canvas (a sketch's box, a photo, or a rig frame for a layer in frame space). + Stroke width is a fraction of the canvas width, text and stencil size a fraction of its height, so a drawing + scales with the display and the render. Ten fixed colours (`COMPOSE_PALETTE`), drawn over photos, so they never + take the theme. +- **Look** (adjustment layers): exposure (−3..3; brightness 4^value, so one unit is 2 EV and the slider spans + ±6 EV), contrast, saturation (−1..1), black-and-white, a tint colour with opacity, vignette and softness (0..1). + On the Studio it is CSS (`lookFilter()`), in a bake the same filter on a canvas. +- **Description** (shot, layer, sketch): a Markdown subset: `#`/`##`/`###` headings, paragraphs, `-` and `1.` lists, + `**bold**`, `*italic*`, `` `code` ``, `[links](https://…)`. `parseMarkdown()` gives a block tree both clients + render; nothing is stored as HTML, and only `http(s)` links are recognised. +- Validation (`validateDrawing`, `validateLook`) is strict: these are interactive edits. Limits: 3000 shapes, 4000 + points a path, 200 characters a text, 1 MB of drawing JSON, 20 000 characters a description, 6 MB a render (JPEG, + PNG or WebP, recognised by its bytes, #224). + +## Sketches + +- A **sketch** belongs to the **shot**: a drawing on a blank canvas of a chosen aspect ratio (16:9, 4:3, 1:1, 3:4), + a free-text `kind` (Floor plan, Lighting diagram, Blocking and Storyboard are offered first), name, description, + position. +- **Studio**: in the shot view's Compose stage ([composition](composition.md)), the sketches sit at the end of the + foot with **+ Sketch**; opening one puts its canvas on the stage and its settings in the panel (name, kind, + canvas, shape, description, Delete). It saves on its own 1.2 s after the last stroke, with a render: the canvas + on white, 1600 px wide, PNG (`renderSketch` in `apps/studio/src/compose/render.ts`), under + `renders/sketches/.`; `render_url` carries the save time (`?v=`), so the image route can cache for a + year. +- A placeholder clip in a timeline can carry a sketch of its own (a sketch clip, [timeline](timeline.md)); + **Attach to shot** makes it a shot's. +- **Phone** (`apps/mobile/src/components/ComposeParts.tsx`, `Markdown.tsx`): Shot details shows the shot's + description (rendered Markdown) under the summary, then a **Sketches** strip; tap opens the viewer (the render on + white, the name and kind, the description on an opaque card). The Step-through Details sheet has the description + and a row per sketch. Offline days and projects download the renders; in offline mode a render without a local + copy stays black. Queued shots have no description or sketches. + +The inspector (other stages) has a **Description** section for the shot (saved 0.7 s after the last edit, or on +leaving the field) and a **Compose** section listing the layer and sketch names with Open (`C`). ## Not done -- No overlays or sketches are created on the phone; no editing there. -- The timeline composer (second half of #12) is a separate PR. -- Creating a shot from a sketch or an external image is a separate issue. +- Nothing is drawn or edited on the phone. +- Creating a shot from a sketch or an external image is [new shot](new-shot.md). diff --git a/docs/features/composition.md b/docs/features/composition.md new file mode 100644 index 0000000..b8f3e93 --- /dev/null +++ b/docs/features/composition.md @@ -0,0 +1,126 @@ +# Composition: steps, layers, bakes + +From [epic #275](https://github.com/OpenClapper-dev/core/issues/275), building on steps ([#266](https://github.com/OpenClapper-dev/core/issues/266)). +A **shot is a composition**: its **footage** (the photos, through framings) shown at a series of **steps**, +with an ordered **stack of layers** drawn over (or under) it. A single shot has one step. A sequence shows what +the shot will look like in practice: a pan in steps, a zoom in steps, a light that moves from one place to the next, +a grade that changes over the day. + +## Steps (`packages/vocab/src/steps.ts`, table `shot_steps`) + +- A **step** shows one of the shot's **photos**, optionally through one of that photo's saved **framings** + (a zoom step, [framing](rig-explorer.md)). `{ id, shot_id, ordinal, photo_id, framing_id | null }`. +- **A shot without stored steps shows its photos as steps.** Phone captures never create steps; the Studio does + (the Compose stage below). "Reset to photos" deletes them all. +- Every surface iterates `stepsOf(shot)`, never `photos`: strips, hover scrub, the "i / n" and SEQ badges, + Step-through on the phone, and the clips a sequence contributes to a cut. Photos stay the unit for capture, + the cover (positions, filters, map pins) and the per-photo stages Framing and Position. Thumbnails that are not + strips (map popup, Plan rows, Files, Trash) keep the first photo. +- **The frame a step is shown with** (`stepFrame`): its framing, else the photo's root. A deleted framing clears the + reference (`SET NULL`), a deleted photo takes its steps; a framing edit shows in every step that uses it. + +## Layers (`packages/vocab/src/layers.ts`, table `layers`) + +| Property | What | +| --- | --- | +| kind | **drawing** (shapes, the drawing model of [compose](compose.md)) or **adjustment** (a look: exposure, contrast, saturation, black and white, tint, vignette, softness; it changes everything below it) | +| position | the stack order. Layers with `position < 0` sit **below the footage** (they only show where there is no picture: beyond the capture, Studio only), the rest above it; higher is on top | +| range | the steps it shows on: `null` = every step, else stored step ids. Explicit ranges need stored steps: giving a layer a range stores the photos as steps first | +| space | **frame**: shapes are fractions of the rig frame a step is shown with, so a mark stays put through a zoom or a pan (and stretches with a frame of another aspect); **photo**: fractions of one photo's pixels (`photo_id`), so it shows only on that photo's steps | +| opacity, visible, locked, name, description | the usual | + +A layer with `timeline_id` + `clip_id` is a **timeline clip's own** (drawn over that clip only, [timeline](timeline.md)). +`layersAt(layers, step)` is the one rule both clients and the Worker use: visible, a shot's own, in range, on the +step's photo in photo space, bottom to top. At most 50 layers per shot. + +## Bakes (`step_bakes`, R2 `bakes//-.`) + +The phone never draws layers. For every step that shows layers, the Studio flattens the photo and the stack into +one **bake**: the photo at its stored size, then the layers above the footage (`renderStep` in +`apps/studio/src/compose/render.ts`). Being photo-sized, the phone frames it like the photo in every view mode. +Content beyond the photo is not baked (layers below the footage, shapes outside the picture). + +- **Fresh or stale**: `bakeHash(step, layers)` hashes the photo, the step's frame and every layer the step shows + (id, version, order). A bake stored with that hash is fresh; anything else is stale. A step without layers needs + no bake: the photo is the picture. +- **The bakery** (`compose/baker.ts`): whenever the Studio is open it bakes the stale steps of the loaded shots in + the background, one shot at a time (a failed shot waits five minutes). So a layer edit, a framing change, a + merge or migrated data reach the phone without anyone opening the shot. Until then strips show the photo with + a "◌" mark (Studio) or a "Layers not baked yet" tag (phone). +- Bakes of steps that no longer exist are deleted (when steps are replaced and on every bake upload). + +## Building a progression + +- **Steps and layers on a shot**: the Compose stage (below). A zoom sequence = n framings of one photo (one per + lens) and n steps; a light progression = drawing layers each on one step; a grade over the day = adjustment + layers on chosen steps. +- **Make a sequence from timeline clips** (`POST /api/shots/:id/photos`, the **merge**): adjacent clips of a cut + become one shot's steps. Clips of one shot keep their stored steps (and the layers on them); clips of several + shots move their photos into the first clip's shot, appended in clip order, with their framings and photo-space + layers (on every step from then on). A source shot left without photos is **deleted for good**: its sketches and + its frame-space layers move to the target (the layers hidden, to switch on where wanted), since a photo-less shot + in the recycle bin could not be restored to anything. The Studio confirms this first. A clip's own re-frame is + promoted first so the step can reference it; its own layers become the shot's, on the clip's new step. +- **Extract as own shot** (`POST /api/shots/:id/photos/:photo/extract`): one step becomes a shot of its own, as a + **copy**: the tags, description and state (name "… · photo n"), the photo row with its own R2 object (deleting + either shot never touches the other), its framings, and the layers that step shows (each on every step of the + copy); the step's framing becomes the copy's root. No link back. The bakery bakes the copy. + +## Studio: the Compose stage (`apps/studio/src/compose/ComposeStage.tsx`) + +Shot view → stage **Compose** (`C`; route `#/shots/?stage=compose`). The inspector is replaced by the +Layers panel; the step on stage is the inspector's photo when you go back. + +- **Stage**: the step composed live (`compose/stack.tsx`): adjustment layers are `backdrop-filter` elements, drawing + layers SVG in their space, in the window's frame mode. With a drawing layer selected that shows on this step (and + is not locked) the stage is its drawing surface: the compose tools (V P L A R O T S E, colours 1–0, ⌘Z) in the + bar, the other layers as the background. +- **Foot**: the **step strip** (`,` `.`; drag to reorder; a crop glyph marks a framing; each thumb is the step's + bake), **+** (a copy of this step, after it), **Zoom…** (below), **Reset to photos** (confirms), then the + **sketches** of the shot and **+ Sketch**. +- **Panel**: the **Layers** list top to bottom with the **footage row** in it (the step on stage); each layer with + its eye, kind, a badge for its range ("this step", "n steps", "not here", "no step") and glyphs for photo space + and lock; **+ Drawing**, **+ Adjustment** (a new layer on top, every step, frame space). The selected layer: + name, opacity, up / down in the stack (past the footage: under the photo), lock, delete, **Anchored to** Frame · + Photo, **Shows on** Every step · This step · Chosen steps (chips per step), its **Look** (adjustment) or **Shape** + section (drawing), and a Markdown description. The footage row shows the step: its photo and framing, earlier / + later, remove, **Extract…**. +- **Saving**: everything saves on its own: steps 600 ms after the last change (the PUT replaces the whole list), + each layer 900 ms after its last change, a sketch 1.2 s after its last stroke (with its render); leaving the + stage or the shot sends what is pending. A shot without stored steps starts from its photos; the first change + to the steps (or a layer range) stores them. +- **Zoom…** (`ZoomSequenceDialog.tsx`): a rig, the lenses to tick (presets inside the rig's range plus the photo's + own and custom mm), the direction (in: wide → tight, or out), and a preview at the photo's root centre. Adds one + saved framing per lens (an identical one is reused) and one step each; without stored steps the zoom replaces + the implicit photo list, otherwise it is appended. +- **From timelines** (bar): re-frames, layers and sketches made in the project's timelines for this shot, with + **Make the shot's** (a clip's layer becomes the shot's on the clip's step). + +Elsewhere the Photo stage composes the step live too, and strips, cards, scrub and badges count steps and show +their bakes. + +## Timeline + +- A sequence dropped into a cut contributes **one clip per step**; a stored step becomes the clip's `step_id` (its + layers come along) and its framing the clip's presentation framing. "Restore all photos" matches clips to steps + by step. +- A clip's **Shows** select picks the step of its photo it shows (or the photo with the layers on every step). + Its **own layers** (Layers, `C`) go on top, edited inline with the same layer list and settings. +- **Shift-click** range → **Make a sequence…**, and **Extract as own shot…** in the clip menu, as above. Details in + [timeline](timeline.md). + +## Phone + +Read-only: the phone never draws a layer. `steps(shot)` drives Shot details, History, Step-through, the strip and +the SEQ badges; a step shows its **fresh bake** (the offline copy first), else the photo with "Layers not baked +yet" while the Studio catches up. "Step i / n" once the Studio stored steps, "Photo i / n" for a plain capture +sequence. Offline days and projects download the fresh bakes with the photos and sketch renders. + +## Migration 0030 (from overlays) + +Overlays (issue #12) were drawings with a look over one photo, shown one at a time. Migration 0030 copied them: +each became a drawing layer (same id, photo space) and, when its look changed the photo, an adjustment layer below +it. A shot without stored steps that had overlays got stored steps (each photo, then one step per overlay of it), +and each overlay's layers show on its step; in a shot with curated steps an overlay shows on the steps that showed +it, or on none (it waits in the list). A clip that showed an overlay points at its step, or got the layers as its +own. The old `overlays` table and its renders stay, unread, until a later release drops them. diff --git a/docs/features/day-mode-offline.md b/docs/features/day-mode-offline.md index 08cf876..8d9c6d7 100644 --- a/docs/features/day-mode-offline.md +++ b/docs/features/day-mode-offline.md @@ -14,18 +14,18 @@ The **Day** tab (`apps/mobile/src/screens/Day.tsx`, step-through in `StepThrough - The **offline control** (below). - Shots grouped by location in plan order: each group shows its combined window; each row shows a fit thumbnail, the name, the light requirement and the planned time (red with "outside window" when it falls outside). - Tap a row to step through from it, or "Start from the first shot" (pinned). -3. **Step-through**: immersive (no tab bar, no header, status bar hidden, the screen kept on with `expo-keep-awake`). A sequence's "Photo i / n ›" button cycles its steps ("Step i / n" once the Studio stored a progression, [sequences](sequences.md)). +3. **Step-through**: immersive (no tab bar, no header, status bar hidden, the screen kept on with `expo-keep-awake`). A sequence's "Photo i / n ›" button cycles its steps ("Step i / n" once the Studio stored steps, [composition](composition.md)). - Landscape: **Prev** as a 96 dp column at the left thumb, **Next** as a 112 dp accent column at the right thumb; both are disabled (dashed) at the ends. - The photo (fit mode by default; the mode is in the Details sheet and remembered in `stepMode.v1`) with a "3 / 9" badge. Sequences have a "Photo 2 / 5 ›" button that cycles. Swipe on the photo = prev/next shot. - The info panel: name, location, planned time, window and light requirement (green, or red with an alert when the plan falls outside). - Portrait: the photo on top, the info below, and Prev | Next as a 120 dp bar at the bottom (1 : 1.4). - - **Details** (ⓘ sheet): planned time and window, location, tags, rig, extra fields, the planned shot's notes, the shot's description and a row per overlay and sketch (tap = full-screen viewer, [compose](compose.md)), the frame mode, Show on map (online only), Leave step-through. Android back also leaves. + - **Details** (ⓘ sheet): planned time and window, location, tags, rig, extra fields, the planned shot's notes, the shot's description and a row per sketch (tap = full-screen viewer, [compose](compose.md)), the frame mode, Show on map (online only), Leave step-through. Android back also leaves. Light is computed on the phone (`@openclapper/vocab` `dayLight`), so it works offline. ## Offline -- **Make offline** (the overview's control, or long-press in the list) stores the day and its shots with all metadata, and downloads every photo and every overlay/sketch render to `Documents/offline-photos/` (`apps/mobile/src/offline.ts`, `File.downloadFileAsync` with the auth header; renders as `r--.jpg`, older versions replaced). The control shows: +- **Make offline** (the overview's control, or long-press in the list) stores the day and its shots with all metadata, and downloads every photo and every sketch render and every fresh step bake (the steps' layers, [composition](composition.md)) to `Documents/offline-photos/` (`apps/mobile/src/offline.ts`, `File.downloadFileAsync` with the auth header; renders as `r--.jpg`, older versions replaced). The control shows: - progress (n / m photos) while it runs; the rest of the screen stays usable - "Offline · up to date" with **Remove** (photos shared with another offline day stay; Remove confirms) - "Offline copy out of date" with **Update offline** when the plan changed on the server (`updated_at` differs) diff --git a/docs/features/new-shot.md b/docs/features/new-shot.md index 60a8696..684f6e1 100644 --- a/docs/features/new-shot.md +++ b/docs/features/new-shot.md @@ -9,7 +9,7 @@ ## What such a shot is -A normal shot whose photos have `source` `upload` or `drawn` instead of `camera` (`photos.source`, migration 0009). They have no rig framing, no position and `lens_mm` 0. Everywhere a rig would be named they say "Uploaded image" or "Drawn sketch" (`rigLabel` on both clients); the inspector's Camera section shows the source and size. Frame modes show the image as is; the rig explorer says it has no framing data; Compose and Timeline hide the rig and lens pickers for them. Tags, overlays, sketches, shooting days and timelines work as for any shot, and the phone shows them like other photos. A position comes from a location pin ([positions](positions.md)) or Set position. +A normal shot whose photos have `source` `upload` or `drawn` instead of `camera` (`photos.source`, migration 0009). They have no rig framing, no position and `lens_mm` 0. Everywhere a rig would be named they say "Uploaded image" or "Drawn sketch" (`rigLabel` on both clients); the inspector's Camera section shows the source and size. Frame modes show the image as is; the rig explorer says it has no framing data; Compose and Timeline hide the rig and lens pickers for them. Tags, layers, sketches, shooting days and timelines work as for any shot, and the phone shows them like other photos. A position comes from a location pin ([positions](positions.md)) or Set position. ## Not done diff --git a/docs/features/phone-app.md b/docs/features/phone-app.md index f2b2fb7..b4d09e8 100644 --- a/docs/features/phone-app.md +++ b/docs/features/phone-app.md @@ -65,7 +65,7 @@ One app, four installs side by side, each with its own package id, storage and i - `ui/chrome.tsx`: `AppHeader`, `PushScreen`, `Block`, `OfflineBanner`, `SyncIcon`, `SyncCard`, `Badge`, `FieldRow`, `ActionBar`. - `components/TagEditor.tsx`: the one tag editor (Tag, History edit, Shot details edit), see [review and tagging](review-and-tagging.md). `components/OptionSheet.tsx`: chips up to 12 options, a search list above, checkboxes for multiple. - `components/gestures.ts`: `useSwipe` (PanResponder: horizontal swipe, double-tap). -- `components/ComposeParts.tsx`: overlay/sketch strip, sheet rows and the full-screen viewer; `components/Markdown.tsx`: native rendering of descriptions ([compose](compose.md)). +- `components/ComposeParts.tsx`: the sketch strip, sheet rows and the full-screen viewer; `components/ShotFrame.tsx` `StepFrame`: a step's bake or photo ([composition](composition.md)); `components/Markdown.tsx`: native rendering of descriptions ([compose](compose.md)). - Data: `src/shots.ts` (`useShots`: server, phone copy, queue and waiting edits merged; labels), `src/localShots.ts` (queued shots as shots, `editShot`, `setPosition`, waiting edits), `src/rigs.ts` (rigs and the one in use, shared by Shoot and Setup), `src/storage.ts` (`usePref` for per-screen choices), `src/device.ts` (this phone as a device: its id, the measured Shoot sizes, its specs and their sync, issue #68), `src/kv.ts` (the kv store and photo folders, scoped per server: `make mobile` keeps local data apart from production). ## Not done (from the designs) diff --git a/docs/features/review-and-tagging.md b/docs/features/review-and-tagging.md index 72f2b93..63a9988 100644 --- a/docs/features/review-and-tagging.md +++ b/docs/features/review-and-tagging.md @@ -33,12 +33,11 @@ The light model feeds the [schedule](schedule.md): phases are defined by sun ele The shot view (`ShotView.tsx`) is the **Shot** app on the desktop (`apps/ShotApp.tsx`): one window per shot, so two shots can sit side by side (opening a shot that is already open focuses its window). It keeps the list it was opened from (the Gallery's result, the map, Plan, Timeline or ⌘K); ←/→ move this window on to the next shot, and from a deep link without a list they walk the project's shots, newest first. -- **Toolbar**: back (Esc) closes the window and focuses the one it was opened from, "7 of 212" with the list's description (state · view or project · sort), and ←/→; the **stage switch** Photo · Framing (`R`) · Compose (`C`) · Sequence (`S`) · Position, and the frame-mode switch (hidden in Compose, where the overlay's own presentation applies). The frame mode starts from the global mode; a change sticks for ←/→ until the window closes (`frameMode.ts` holds the global one). `M` cycles it. +- **Toolbar**: back (Esc) closes the window and focuses the one it was opened from, "7 of 212" with the list's description (state · view or project · sort), and ←/→; the **stage switch** Photo · Framing (`R`) · Compose (`C`) · Position, and the frame-mode switch (hidden in Compose, where the overlay's own presentation applies). The frame mode starts from the global mode; a change sticks for ←/→ until the window closes (`frameMode.ts` holds the global one). `M` cycles it. - **Stage**: - - **Photo**: the step as large as fits (a sequence is a progression of **steps**, [sequences](sequences.md): a photo, or a photo through a framing or as an overlay), with prev/next buttons beside it and nothing drawn over the picture (rig and lens are in the inspector). The foot has the step strip for sequences (`,` `.`; a crop or layers glyph marks a step with a framing or an overlay; picking a step also picks its photo for the other stages) and the actions Download crop (the step cropped to its frame, in the browser), Original, **Extract…** (a copy of the photo as a shot of its own, [sequences](sequences.md)), Show on map, and Delete (confirm dialog, offering Archive instead). + - **Photo**: the step as large as fits (a sequence is a progression of **steps**, [composition](composition.md): a photo, or a photo through a framing) composed live with the layers it shows, with prev/next buttons beside it and nothing drawn over the picture (rig and lens are in the inspector). The foot has the step strip for sequences (`,` `.`; each thumb is the step's bake; a crop glyph marks a step with a framing; picking a step also picks its photo for the other stages) and the actions Download crop (the step, from its bake, cropped to its frame, in the browser), Original, **Extract…** (a copy of the photo as a shot of its own, [sequences](sequences.md)), Show on map, and Delete (confirm dialog, offering Archive instead). - **Framing**: re-frame the photo, saved framings, the root frame ([framing](rig-explorer.md)). - - **Compose**: overlays and sketches ([compose](compose.md)); the inspector is replaced by the compose panel. - - **Sequence**: the shot's steps as a progression: reorder, add, remove, a zoom sequence, a light progression from overlays ([sequences](sequences.md)). + - **Compose**: the shot as a composition: its steps, the layer stack drawn live on the step, and its sketches; the inspector is replaced by the Layers panel ([composition](composition.md), [compose](compose.md)). - **Position**: the [position correction](positions.md) map. - **Inspector** (`Inspector.tsx`, 380 px, scrolls on its own): - the name, state marker and a summary line @@ -54,7 +53,7 @@ The shot view (`ShotView.tsx`) is the **Shot** app on the desktop (`apps/ShotApp - Shot size and Support: comboboxes; Movement: chips (any number). - Extra fields: saved 0.7 s after the last change. - **Description**: a Markdown field ([compose](compose.md)), saved 0.7 s after the last edit or on leaving it. - - **Compose**: the names of the shot's overlays and sketches, with Open (`C`). + - **Compose**: the names of the shot's layers and sketches, with Open (`C`). - **Position** of the photo on stage, with "Correct", and the switch "Use the location's position" ([positions](positions.md)). A photo captured without GPS says "No position · captured without GPS" and offers "Set position" (the same map stage, starting at another photo of the shot or Berlin). - **Shot**: captured, photos (and the number of stored steps), uploaded, the **shooting days** it is planned on (link to Plan) and the **timelines** it is cut into (link to Timeline). - **Camera** of the photo on stage: rig, the frame shown (root framing or As captured, and how many framings), body, format, sensor, lens + FF-equivalent ("· vignetting" when the rig darkens the corners at it, [framing](rig-explorer.md#vignetting-issue-266)), FOV, time, GPS accuracy, fix age and altitude, phone. @@ -66,7 +65,7 @@ Reviewing is finding the shots that carry a sequence. On the Studio it happens i ## History on the phone The **History** tab (`screens/History.tsx`, was Review until issue #48) steps through the project's shots one at a time (queued ones included, newest first; the header button switches to oldest first and starts at the top of that order, remembered in `reviewOrder.v1`), photo first, with the decisions at the thumb. **All shots · To review** (remembered in `historyFilter.v1`) narrows it to the unreviewed queue; the tab badge counts the unreviewed shots either way: -- The photo spans the width (up to 262 dp), with the SEQ badge and "Photo i / n" (or "Step i / n" once the Studio stored a progression, [sequences](sequences.md)) for sequences and the step strip under it. **Swipe** on the photo = next/prev shot; the strip moves between steps. **Double-tap** opens the photo full screen (mode switch on an opaque card; swipe = photos). +- The photo spans the width (up to 262 dp), with the SEQ badge and "Photo i / n" (or "Step i / n" once the Studio stored steps, [composition](composition.md)) for sequences and the step strip under it; a step with layers shows the Studio's bake, or the photo with "Layers not baked yet". **Swipe** on the photo = next/prev shot; the strip moves between steps. **Double-tap** opens the photo full screen (mode switch on an opaque card; swipe = photos). - The frame-view switch (remembered in `reviewMode.v1`). - The summary: - the name (or the rig when untagged), the state and location @@ -79,8 +78,8 @@ The **History** tab (`screens/History.tsx`, was Review until issue #48) steps th - Landscape: the photo fills the left with the mode switch floating on it; a 292 dp side panel holds the strip, the filter, the summary and the decisions at the bottom. **Shot details** (Shots → a shot, or ⋯ → Shot details; `screens/ShotDetails.tsx`) is a full screen: -- the step (swipe = the neighbours in the list it was opened from), the step strip and frame view ([sequences](sequences.md)) -- the summary, the **description** (when set) and the **overlays · sketches** strip ([compose](compose.md)), **Tags** with Edit, **Position** per photo (coordinates, ±accuracy or "corrected") with Show on map and Correct, **Camera** per photo, and **Raw metadata** (expands) +- the step (swipe = the neighbours in the list it was opened from), the step strip and frame view ([composition](composition.md)) +- the summary, the **description** (when set) and the **sketches** strip ([compose](compose.md)), **Tags** with Edit, **Position** per photo (coordinates, ±accuracy or "corrected") with Show on map and Correct, **Camera** per photo, and **Raw metadata** (expands) - ⋯ holds Delete (Discard for a queued shot; deleting an uploaded shot needs a connection) - Pinned: the two states that aren't the current one (unreviewed → Archive | Approve; approved → Back to review | Archive; archived → Back to review | Approve) diff --git a/docs/features/rig-explorer.md b/docs/features/rig-explorer.md index 878901a..c00ce78 100644 --- a/docs/features/rig-explorer.md +++ b/docs/features/rig-explorer.md @@ -13,7 +13,7 @@ Studio shot view → stage **Framing** (`R`; `apps/studio/src/RigExplorer.tsx`; - **Bar**: Framing · Compare A / B · Stacked; the framing picker (As captured, the saved ones, "· root" marks the root); rig and lens pickers; "Edited" / "Not saved" when the frame differs from what is selected. - **Stage**: the photo in Mask (or Frame) mode with the frame as a **drag handle**. Fit and Raw are not used here because they cannot show where the frame sits. `⇧` + arrows nudge by 1 %, `←/→` step the lens (the centre is kept, clamped to the new size). -- **Actions**: **Save as new…** (asks for a name), **Update** (`⌘S`) for a saved framing, **Set as root** (saves first when needed; "Root frame" when it already is), delete (confirms; overlays and clips keep their copy). For sequences, **All n photos** saves the same rig, lens and centre on every photo, each with its own size from its own capture. +- **Actions**: **Save as new…** (asks for a name), **Update** (`⌘S`) for a saved framing, **Set as root** (saves first when needed; "Root frame" when it already is), delete (confirms; clips keep their copy). For sequences, **All n photos** saves the same rig, lens and centre on every photo, each with its own size from its own capture. - **Foot**: the facts (FF-equivalent, horizontal and vertical FOV, "sees more than this photo", "vignetting" when the rig darkens the corners at that lens, issue #266), a hint, and the **lens strip** with the frame at the current centre for every lens of the rig (a lens with vignetting is labelled in amber and its thumb shows the falloff). - **Compare A / B**: two rigs side by side as before; A uses the current centre. - **Stacked** (issue #47, `StackedFrames.tsx`): one photo with any number of rig + lens frames on it, each in its own colour (the fixed compose palette, so it reads on any photo; click a swatch for the next colour). **+ Frame** adds one (it starts as a copy of the last), × removes it. **Rig names** labels each frame with its rig and lens, at a chosen corner, inside or outside the frame. Frames share the Framing view's centre (clamped per frame); a frame wider than the photo shrinks the photo and draws dashed. Nothing is saved. @@ -22,8 +22,8 @@ Studio shot view → stage **Framing** (`R`; `apps/studio/src/RigExplorer.tsx`; ## Who uses a framing - `frameOf()` on both clients returns the root (`rootFrame()` in `packages/vocab/src/framing.ts`); `frameLayout()` (Studio) and `FramedImage` (phone) place an off-centre frame and crop Fit around it. -- **Steps** of a sequence ([sequences](sequences.md)) pick a saved framing: the step is then shown through it and follows its edits; deleting the framing drops the step back to the root frame. "Zoom sequence…" on the Sequence stage saves one framing per lens. -- Overlay presentations and timeline clips pick a framing (As captured or a saved one) in a **Framing** select. They store `framing_id` and follow its edits, plus a copy of the frame that is used when the framing is deleted (`referencedFrame()`). New overlays and clips start on the root. Presentations from before #29 (a rig + lens without a framing) show as "Kept: …" until another framing is picked. +- **Steps** of a sequence ([composition](composition.md)) pick a saved framing: the step is then shown through it and follows its edits; deleting the framing drops the step back to the root frame. "Zoom…" in the Compose stage saves one framing per lens. +- Timeline clips pick a framing (As captured or a saved one) in a **Framing** select. They store `framing_id` and follow its edits, plus a copy of the frame that is used when the framing is deleted (`referencedFrame()`). New clips start on the root. A clip's presentation from before #29 (a rig + lens without a framing) shows as "Kept: …" until another framing is picked. - **Timeline clips re-framed inline** (issue #31) keep their frame in the clip only (no framing on the photo). The Framing stage bar shows **From timelines · n** for the photo on stage: each such re-frame with its timeline and clip, and **Make the shot's** (asks for a name) turns it into a saved framing of the photo; the clip then references it. A re-frame identical to an existing framing (same rig, lens and frame) links that one instead of saving a twin. ## Vignetting (issue #266) diff --git a/docs/features/sequences.md b/docs/features/sequences.md deleted file mode 100644 index 21e7eaf..0000000 --- a/docs/features/sequences.md +++ /dev/null @@ -1,104 +0,0 @@ -# Sequences: steps, progressions, merge and extract - -From [GitHub issue #266](https://github.com/OpenClapper-dev/core/issues/266). A **sequence** is a shot with -more than one picture, and its job is to show what the shot will look like in practice: a pan in steps, a -zoom in steps, a light that moves from one position to the next. Until #266 a sequence step was always a -captured photo, so only pans existed; a zoom was a row of framings and a light progression a row of overlays, -both only visible in a Timeline cut. Now a shot carries **steps**. - -## Model (`packages/vocab/src/steps.ts`, table `shot_steps`) - -- A **step** shows one of the shot's **photos**, optionally through one of that photo's saved **framings** (a - zoom step, [framing](rig-explorer.md)) and optionally as one of its shot **overlays** (a light step, [compose](compose.md)). - `{ id, shot_id, ordinal, photo_id, framing_id | null, overlay_id | null }`. -- **A shot without stored steps shows its photos as steps.** Phone captures never create steps; the Studio's - Sequence stage does. "Reset to photos" deletes them all. -- Every surface iterates `stepsOf(shot)`, never `photos`: the photo strip, hover scrub, the "i / n" and SEQ - badges, Step-through on the phone, and the clips a sequence contributes to a cut. Photos stay the unit for - capture, the cover (positions, filters, map pins), and the per-photo stages Framing, Compose and Position. - Thumbnails that are not strips (map popup, Plan rows, Files, Trash) keep the first photo. -- **The frame a step is shown with** (`stepFrame`): its framing, else the frame its overlay was drawn for (the - overlay's presentation framing, or the copy kept when that framing was deleted), else the photo's root. - **Its image**: the overlay's render when it has one, else the photo. The viewing surface's frame mode applies. -- **Deletes**: a deleted framing or overlay clears the reference (`SET NULL`) and the step falls through the - order above; a deleted photo takes its steps. A framing edit shows in every step that uses it. -- `stepKey()` tells steps apart by picture (a photo may repeat in a progression), e.g. for a cut's "Restore all photos". - -API ([api](../api.md)): every shot embeds `steps` (raw rows); `PUT /api/shots/:id/steps` replaces the list, -strictly (interactive edits): a photo of the shot, a framing of that photo, a shot overlay of that photo (a -timeline clip's own overlay is never a step). - -## Building a progression - -Three ways, two of them moving pictures between shots: - -- **Steps on a shot**: the Sequence stage (Studio, below). A zoom sequence = n framings of one photo (one per - lens) and n steps; a light progression = n steps on one photo, each showing another overlay. -- **Make a sequence from timeline clips** (`POST /api/shots/:id/photos`, the **merge**): adjacent clips of a cut - become one shot's steps. Clips of one shot only need steps; clips of several shots move their photos into the - first clip's shot, appended in clip order, with their framings and overlays. A source shot left without photos is - **deleted for good** (its sketches move to the target, its day places and shortcuts go): a photo-less shot in the - recycle bin could not be restored to anything, so the bin would promise an undo it cannot keep. The Studio - confirms this first. Timeline clips of the moved photos leave their sequence blocks (a block holds one shot's photos). -- **Extract as own shot** (`POST /api/shots/:id/photos/:photo/extract`): one photo (or step) of a sequence - becomes a shot of its own, as a **copy**: the tags, description and state are copied (name "… · photo n"), the - photo row gets its own R2 object (one object per photo stays the rule, so deleting either shot never touches the - other), its framings and overlays are copied with their renders, and the step's framing becomes the copy's - root. No link back: from then on it is an ordinary shot, and the sequence is unchanged. Costs one image of storage. - -## Studio: the Sequence stage (`apps/studio/src/SequenceStage.tsx`) - -Shot view → stage **Sequence** (`S`; route `#/shots/?stage=sequence`). The inspector stays and follows the -selected step's photo. - -- **Bar**: "Step i of n", then for the selected step the **Photo** (one of the shot's), the **Framing** (the - root frame, or a saved framing of that photo; with an overlay the default is the frame the overlay was drawn - for), the **Overlay** (Photo as is, or one of the photo's overlays), Move earlier / later (`Alt+←/→`), Remove - (`Del`), **Extract as own shot…**, the save status, Back (`Esc`). -- **Stage**: the step large, in the window's frame mode. -- **Foot**: the **step strip** (`,` `.` select; drag a thumb to reorder, an accent line marks the drop; a crop - or layers glyph marks a framing or an overlay), then **+ Step** (a copy of the selected step, after it), - **From overlays · n** (one step per overlay of the selected photo, after it: a light progression), - **Zoom sequence…** and **Reset to photos** (confirms; deletes every step). -- **Saving**: the list is a draft that **autosaves 600 ms after the last change and when leaving** the stage - (`Mod+S` saves now), like a cut: the PUT replaces the whole list, so a retry is idempotent. A shot without - stored steps starts from its photos ("Not stored yet"); the first change stores them. -- **Zoom sequence…** (`ZoomSequenceDialog.tsx`): a rig (As shot or a preset, default the photo's), the lenses to - tick (the presets inside the rig's range plus the photo's own and any custom mm), the direction (zoom in: wide → - tight, or out), and a preview strip of the frames at the photo's **root centre** (move it in the Framing stage - first). Adds one saved framing per lens (an identical existing framing is reused, named like "6K FULL · 24 mm") - and one step each. Without stored steps the zoom **replaces** the implicit photo list; otherwise it is appended. - Lenses wider than the photo are allowed: the step shows the hatch beyond the capture. -- **Extract** (Photo stage foot, Sequence bar): confirms, then `POST …/extract` with the step's framing (which - becomes the copy's root) and overlay; the new shot joins the lists and a toast offers **Open** (its own window). - -Everywhere else in the Studio the strips, hover scrub and SEQ badges count **steps**: the Photo stage's strip -(`,` `.`), the Gallery's and the Metadata board's cards, the Timeline browser, the map and the list's badge -(the list's "photos" column stays photos). The inspector's Shot section says "n photos · m steps". - -## Timeline (`apps/studio/src/Timeline.tsx`, `TimelineParts.tsx`) - -- A sequence dropped into a cut contributes **one clip per step**: the step's framing becomes the clip's - presentation framing (`framing_id` + the frame copy), its overlay the clip's overlay. "Restore all photos" - matches clips to steps by what they show (`photo | framing | overlay`), so a repeated photo is told apart. - The block band and the clip labels count steps ("2 / 4" is the place in the block). -- **Shift-click** selects a range of clips (whole blocks, contiguous; an accent ring); right-click in it → - **Make a sequence… (n clips)**. One shot: `PUT …/steps` replaces its steps. Several shots: a confirm names - the target (the first clip's shot) and the shots whose photos move into it, then `POST …/photos` merges and - deletes the emptied sources. Before either, a clip's own re-frame or clip-owned overlay is **promoted** - (`promote.ts`) so the step can reference a saved framing or shot overlay. The range becomes one block - (per-photo lengths kept); the moved photos' clips in other cuts leave their blocks (the server nulls their - `group_id`). Placeholders and sketch clips cannot be in the range; the menu item says why. -- **Extract as own shot…** in the clip menu: the clip's photo copied out with the clip's saved framing as root. - -## Phone (`apps/mobile/src/components/ShotFrame.tsx` `StepFrame`, `ShotParts.tsx`) - -Read-only: the phone shows a progression, it never edits one. `steps(shot)` (`api.ts`, the same `stepsOf`) -drives **Shot details**, **History**, **Step-through**, the photo strip and the SEQ badges on Shots and Day: -- a step shows its photo, or its overlay's **render** (the offline copy when there is one; an overlay without - a render yet falls back to the photo), through the step's frame in the screen's frame mode (`StepFrame`); -- the tag reads **"Step i / n"** once the Studio stored a progression and "Photo i / n" for a plain capture - sequence; the strip's thumbnails are the steps; Step-through's button cycles the steps; -- the Position and Camera sections, Correct position and the overlays strip belong to the **step's photo**; -- queued shots have no steps (their photos are the steps). Offline days and projects already download every - shot overlay's render, and steps only reference shot overlays, so nothing more is fetched. diff --git a/docs/features/timeline.md b/docs/features/timeline.md index 557fe60..3daa743 100644 --- a/docs/features/timeline.md +++ b/docs/features/timeline.md @@ -5,9 +5,9 @@ Second half of [GitHub issue #12](https://github.com/OpenClapper-dev/core/issues ## Model A project has any number of named timelines. A **clip** holds: -- one **photo** (of a shot in the project); a sequence contributes one clip per **step** ([sequences](sequences.md): a photo, through the step's framing and showing its overlay when it has one), grouped as a **sequence block** (below) -- optionally one **overlay** of that photo ([compose](compose.md)): the clip then shows the overlay's render instead of the photo -- a **presentation** (frame mode plus the rig frame, the same shape as an overlay's), so the same photo can appear raw, framed or fitted, with any rig and lens +- one **photo** (of a shot in the project); a sequence contributes one clip per **step** ([composition](composition.md): a photo through the step's framing), grouped as a **sequence block** (below) +- optionally the **step** of its shot it shows (`step_id`, a stored step of its photo, epic #275): the clip then shows that step's layers; without one it shows the layers on every step. Its **own layers** (clip-owned, below) go on top +- a **presentation** (frame mode plus the rig frame), so the same photo can appear raw, framed or fitted, with any rig and lens - `duration_ms` (100 ms .. 1 h), entered in seconds with one decimal - notes @@ -15,7 +15,7 @@ The timeline can **lock a presentation mode** (`lock_mode`): every clip then sho ### Sequence blocks (issue #37) -A sequence added to a cut (dragged in, "Add to timeline", the Add shots panel, or dropped onto a placeholder) is **one block**: its clips stay together in step order (one clip per step since #266, so a zoom or light progression plays as it was built; a step's framing becomes the clip's presentation framing and its overlay the clip's overlay). In the strip a solid band (SEQ · n · name · length) spans the block and its clips are capped in the same colour; each clip shows "2 / 4" (its place in the block). The block moves as a whole (drag the band or any of its clips, `Alt+←/→`, Move earlier/later), and nothing can be dropped inside it (the drop moves to its nearer edge). Each photo stays its own clip for framing, overlay, notes and inline editing. +A sequence added to a cut (dragged in, "Add to timeline", the Add shots panel, or dropped onto a placeholder) is **one block**: its clips stay together in step order (one clip per step since #266, so a zoom or light progression plays as it was built; a step's framing becomes the clip's presentation framing and a stored step the clip's step, with its layers). In the strip a solid band (SEQ · n · name · length) spans the block and its clips are capped in the same colour; each clip shows "2 / 4" (its place in the block). The block moves as a whole (drag the band or any of its clips, `Alt+←/→`, Move earlier/later), and nothing can be dropped inside it (the drop moves to its nearer edge). Each step stays its own clip for framing, layers, notes and inline editing. - **Length**: **Per photo** (each photo its own hold time, the block is the sum) or **Total**: one length split across the photos, **Even** or **Edges** (the first and last photo get *w* shares, the rest one; the edge weight 1–3×, default 1.5×, is per block). Shares are in 100 ms steps, the remainder goes to the last photo, every photo keeps at least 100 ms. Switching to Total starts from the block's current length. In Total the photos' hold-time fields are read-only. - **Remove** on a block's clip drops just that step (the split recomputes); **Restore all photos** brings removed steps back in order (matched by what each clip shows, since a photo may repeat in a progression). Settings → Timeline → "Removing a sequence's photo" can make Remove take the whole sequence. @@ -29,12 +29,12 @@ A **placeholder** clip (issue #31) has no photo: it reserves time for a shot tha Changes made to a clip in the timeline belong to the timeline, like in a video editor; the shot is only touched when an edit is **promoted**: - **Re-frame**: the clip's own presentation gets the frame (rig + lens give the size, the drag gives the centre; no saved framing on the photo). The clip panel marks it "This clip's own re-frame". -- **Overlay**: drawn inline, it belongs to the clip (`overlays.timeline_id` + `clip_id`). Editing a *shot* overlay inline edits a copy for the clip; the shot keeps its own. "Show as" lists the clip's own overlays and the shot's. +- **Layers**: a clip's own layers (`layers.timeline_id` + `clip_id`, epic #275) are drawn over the clip only; they are edited inline with the same layer list and settings as the shot's Compose stage. The shot's layers on the clip's step come along by reference. - **Sketch**: a sketch clip's sketch belongs to the clip. When a shot fills a sketch clip the clip keeps the sketch; its panel then offers **Attach to shot** (the sketch becomes that shot's). -- Promotion of re-frames and overlays is in the shot view (Framing / Compose → **From timelines**, see [framing](rig-explorer.md) and [compose](compose.md)). Shots never show clip-owned overlays or sketches, and the phone never sees them. -- **Duplicate** (a clip or a whole timeline) copies the clip's own overlay and sketch, so the copies never share a drawing. Removing a clip deletes them. +- Promotion of re-frames and layers is in the shot view (Framing / Compose → **From timelines**, see [framing](rig-explorer.md) and [composition](composition.md)): a clip's layer becomes the shot's, on the clip's step. Shots never show clip-owned layers or sketches, and the phone never sees them. +- **Duplicate** (a clip or a whole timeline) copies the clip's own layers and sketch, so the copies never share a drawing. Removing a clip deletes them. -The same photo can be in a timeline several times. Deleting a shot removes its clips everywhere (the shot's delete dialog says "Used in n timelines"); deleting an overlay keeps the clip, which then shows the photo as is. A shot moved to another project leaves that project's timelines, like it leaves its shooting days. +The same photo can be in a timeline several times. Deleting a shot removes its clips everywhere (the shot's delete dialog says "Used in n timelines"); deleting a step keeps the clip, which then shows the photo with the layers on every step. A shot moved to another project leaves that project's timelines, like it leaves its shooting days. ## Using it (`apps/studio/src/Timeline.tsx`, route `#/timeline/`) @@ -42,30 +42,30 @@ On the desktop Timeline is one window (`apps/TimelineApp.tsx`) for all of the pr - **Left**: the project's timelines with length and clip count; **New** (`⇧N`) makes "Cut n"; right-click for Rename, Duplicate (copies the clips' own drawings too), Delete. The rail **collapses** to a strip of numbered buttons (remembered). Collapsing and expanding morph one rail into the other (a View Transition on the rail only, #169). - **Head**: name, total length, the **Presentation lock** ("Per clip" or one mode for all), **Review**: "Approve all · n" while shots in the cut are unreviewed (approves those), else "Back to review · n" for the approved ones (archived shots are left alone; one bulk request, Undo in the toast), save status, menu (Rename…, Delete timeline…). Changes **autosave** 600 ms after the last edit and at once when leaving the page; the server's answer replaces the clip list, so clips of deleted photos disappear on the next save. -- **Preview**: the selected clip, large, in its presentation (the overlay's render when one is chosen; a sketch clip's sketch on white). The stage keeps **one height** for every clip (`--tl-stage-h`): shots, sketches and placeholders letterbox into it, so the layout never jumps. The grip under the stage **resizes** it (remembered per browser in `localStorage["timelineStageH"]`; double-click goes back to the default, which fills the window). Transport: first · previous · **Play/Pause** (`Space`) · next · last, the playhead clock over the total, "clip n of m · 3.0 s", **Placeholder** (`P`) and **Shots** (`N`: the browser ⇄ the clip panel). +- **Preview**: the selected clip, large, in its presentation, composed live (its step's layers, then its own; a sketch clip's sketch on white). The stage keeps **one height** for every clip (`--tl-stage-h`): shots, sketches and placeholders letterbox into it, so the layout never jumps. The grip under the stage **resizes** it (remembered per browser in `localStorage["timelineStageH"]`; double-click goes back to the default, which fills the window). Transport: first · previous · **Play/Pause** (`Space`) · next · last, the playhead clock over the total, "clip n of m · 3.0 s", **Placeholder** (`P`) and **Shots** (`N`: the browser ⇄ the clip panel). Playback steps through the clips at their hold times (100 ms ticks) and stops at the end; Play at the end starts over. -- **Strip**: one block per clip, its width proportional to the hold time (44 px a second by default; zoomed out a clip keeps at least 20–64 px), with a thumbnail (a photo clip's carries its shot's state marker), the shot's title (and photo number for sequences; a layers icon marks an overlay) and the hold time. Placeholders are dashed. A ruler labels the time (the spacing adapts to the zoom); **dragging along the ruler scrubs** (the preview follows; playback, if running, continues from there). +- **Strip**: one block per clip, its width proportional to the hold time (44 px a second by default; zoomed out a clip keeps at least 20–64 px), with a thumbnail (its step's bake; a photo clip's carries its shot's state marker), the shot's title (and its place in the block for sequences; a layers icon marks a clip with layers of its own) and the hold time. Placeholders are dashed. A ruler labels the time (the spacing adapts to the zoom); **dragging along the ruler scrubs** (the preview follows; playback, if running, continues from there). - **Zoom**: Ctrl/⌘ + scroll over the strip zooms around the pointer; the bar above has −, a slider (double-click: default), +, and **Fit** (the whole cut in the width). Remembered in `localStorage["timelineZoom"]`. - **Playhead**: one red line across ruler and clips with a **knob above the strip**; drag the knob to scrub. - - **Height**: the grip under the strip sets the thumbnail height (double-click resets; `localStorage["timelineStripH"]`). Click selects, double-click opens the shot view with the timeline's shots as its list, right-click opens the clip menu (Re-frame, Overlay or Sketch it, Approve / Archive / Back to review for the clip's shot, Open shot, Duplicate, Remove). **Drag a clip** to reorder (an accent line marks the drop position); shots drop in from the browser. - - **Range** (issue #266): **Shift-click** another clip to select everything from the selected clip to it (whole blocks; an accent ring marks the range; a plain click clears it). Right-click inside the range → **Make a sequence… (n clips)**: the clips become one shot's **steps** ([sequences](sequences.md)). Clips of one shot replace its steps; clips of several shots **merge**: the photos move into the first clip's shot, a shot left without photos is deleted for good (its sketches move along), and the dialog says so before it happens. A clip's own re-frame or overlay is promoted first so the step can reference it; the range then forms one block, and the moved photos' clips in other cuts leave their blocks. Placeholders and sketch clips cannot be in the range. + - **Height**: the grip under the strip sets the thumbnail height (double-click resets; `localStorage["timelineStripH"]`). Click selects, double-click opens the shot view with the timeline's shots as its list, right-click opens the clip menu (Re-frame, Layers or Sketch it, Approve / Archive / Back to review for the clip's shot, Open shot, Extract as own shot…, Duplicate, Remove). **Drag a clip** to reorder (an accent line marks the drop position); shots drop in from the browser. + - **Range** (issue #266): **Shift-click** another clip to select everything from the selected clip to it (whole blocks; an accent ring marks the range; a plain click clears it). Right-click inside the range → **Make a sequence… (n clips)**: the clips become one shot's **steps** ([composition](composition.md)). Clips of one shot replace its steps (a clip that shows a stored step keeps it, with the layers on it); clips of several shots **merge**: the photos move into the first clip's shot, a shot left without photos is deleted for good (its sketches move along), and the dialog says so before it happens. A clip's own re-frame is promoted first so the step can reference it, and its own layers become the shot's on its new step; the range then forms one block, and the moved photos' clips in other cuts leave their blocks. Placeholders and sketch clips cannot be in the range. - **Extract as own shot…** (clip menu, issue #266): a copy of the clip's photo as a shot of its own, with the clip's saved framing as its root; the cut is unchanged and a toast offers to open the copy. -- **Clip panel** (right, for the selected clip, `N` switches to the browser; a block's clip adds the **Sequence** section: length mode, total, split, edge weight, Restore all photos, Ungroup; the drawer is **resizable**, its width is remembered): the clip tools **Re-frame** (`R`) and **Overlay** (`C`), see Inline editing; thumbnail, the shot's **state** with its decisions (issue #35, Undo in the toast), **Hold time** (−0.5 s · field · +0.5 s; `D` focuses it), **Show as** (Photo as is, or one of the photo's overlays; picking an overlay adopts the presentation it was drawn in), **Presentation** (frame mode and a **Framing**: As captured or a saved framing of the photo, see [framing](rig-explorer.md); a clip follows its framing's edits and starts on the root), notes, Move earlier/later (`Alt+←/→`), Duplicate, Open shot (`↵`), Remove (`Del`). A placeholder's panel has the wanted-shot title, **Sketch it** (`C`), hold time and notes instead; a sketch clip's shows the sketch and **Edit the sketch**. -- **Inline editing** (`ClipEditing.tsx`): the clip tools put the editor **on the stage** and the settings in the drawer. Re-frame: drag the frame on the stage, rig and lens in the drawer (⇧ + arrows nudge, ←/→ lens, "Start from" a saved framing, "Back to the root frame"). Overlay and sketch: the drawing canvas on the stage, the tool bar, look and shape sections in the drawer (the compose keys apply: V P L A R O T S E, 1–0, ⌘Z). Drawings **save on their own** 1.2 s after the last change and when the edit ends (Done or Esc); "Delete overlay…" / "Back to a placeholder…" remove the clip's own drawing. While editing, the editor owns the keys (a modal scope in the [shortcut registry](../shortcuts.md)) and the playhead is **pinned** to the clip, or, with Settings → Timeline → "Leave ends the edit", selecting, stepping or scrubbing away ends the edit first. With Settings → Timeline → **Open overlays right away** (issue #51, off by default), landing on a clip that shows an overlay opens it in the inline overlay editor without `C` (not during playback; after Esc it stays closed until you move to another clip), and moving away always ends the edit. +- **Clip panel** (right, for the selected clip, `N` switches to the browser; a block's clip adds the **Sequence** section: length mode, total, split, edge weight, Restore all photos, Ungroup; the drawer is **resizable**, its width is remembered): the clip tools **Re-frame** (`R`) and **Layers** (`C`), see Inline editing; the clip composed live, the shot's **state** with its decisions (issue #35, Undo in the toast), **Hold time** (−0.5 s · field · +0.5 s; `D` focuses it), **Shows** (the photo with the layers on every step, or one of the stored steps of its photo; picking a step adopts its framing), **Presentation** (frame mode and a **Framing**: As captured or a saved framing of the photo, see [framing](rig-explorer.md); a clip follows its framing's edits and starts on the root), notes, Move earlier/later (`Alt+←/→`), Duplicate, Open shot (`↵`), Remove (`Del`). A placeholder's panel has the wanted-shot title, **Sketch it** (`C`), hold time and notes instead; a sketch clip's shows the sketch and **Edit the sketch**. +- **Inline editing** (`ClipEditing.tsx`): the clip tools put the editor **on the stage** and the settings in the drawer. Re-frame: drag the frame on the stage, rig and lens in the drawer (⇧ + arrows nudge, ←/→ lens, "Start from" a saved framing, "Back to the root frame"). Layers: the clip's own layers in a list with **+ Drawing** / **+ Adjustment** (new ones sit on the photo), the selected layer's settings (as in the shot's Compose stage, without a range), and with a drawing layer selected its canvas on the stage over the clip (the compose keys apply: V P L A R O T S E, 1–0, ⌘Z). Sketch: the canvas on the stage. Layers save 900 ms after their last change, a sketch 1.2 s after its last stroke, and both when the edit ends (Done or Esc); "Back to a placeholder…" removes a sketch clip's own sketch. While editing, the editor owns the keys (a modal scope in the [shortcut registry](../shortcuts.md)) and the playhead is **pinned** to the clip, or, with Settings → Timeline → "Leave ends the edit", selecting, stepping or scrubbing away ends the edit first. With Settings → Timeline → **Open layers right away** (issue #51, off by default), landing on a clip with layers of its own opens them inline without `C` (not during playback; after Esc it stays closed until you move to another clip), and moving away always ends the edit. - **Shot browser** (right, a resizable drawer; the default when no clip is selected). Shots added from it start at 3.0 s (a sequence as a block, Per photo) in the view's current frame mode with the rig the photo was shot with. - - **Cards**: cover in the global frame mode, SEQ · n on sequences top left like the gallery, state marker top right, name, location · photos · overlays), two per row or **one per row** for bigger covers (the switch in the head), filtered by state ("To review + Approved" by default), location, **timeline** (in this timeline or not) and a search field, newest first. The layout and filters are remembered per browser (`localStorage["reviewBrowser"]`), so they survive leaving the page; a remembered location of another project shows as All locations. + - **Cards**: cover in the global frame mode, SEQ · n on sequences top left like the gallery, state marker top right, name, location · photos · layers), two per row or **one per row** for bigger covers (the switch in the head), filtered by state ("To review + Approved" by default), location, **timeline** (in this timeline or not) and a search field, newest first. The layout and filters are remembered per browser (`localStorage["reviewBrowser"]`), so they survive leaving the page; a remembered location of another project shows as All locations. - **Hover scrub** (issue #39, like Resolve): moving the mouse across a sequence card shows the photo under the pointer (n equal zones, left = first) with a red line and "3 / 7" next to the SEQ badge; leaving shows the cover. The photos preload on the first move. Same on the Shots grid (`ScrubCover` in `ShotCard.tsx`). Dragging the card still adds the whole sequence. - - **In this timeline** (issue #35): a badge next to the state marker (top right) marks the shots the cut on stage uses, with "×n" when it is used more than once (a sequence block counts once). A shot counts when any clip uses it: a clip of any of its photos (with or without an overlay) or a sketch clip showing one of its sketches. Clicking the badge selects the shot's first clip (a block's first) in the strip, and each further click the next use; the browser stays open. "New shot" opens the [New shot](new-shot.md) dialog. Click selects (`A` approve / `E` archive then act on it, with an Undo toast), double-click opens the shot view, the card's foot always shows the two decisions the shot is not in (Approve green, Archive / Back to review grey), right-click offers Add to timeline, Approve/Archive/Back to review, Open shot, Show on map. + - **In this timeline** (issue #35): a badge next to the state marker (top right) marks the shots the cut on stage uses, with "×n" when it is used more than once (a sequence block counts once). A shot counts when any clip uses it: a clip of any of its photos (whatever step or layers it shows) or a sketch clip showing one of its sketches. Clicking the badge selects the shot's first clip (a block's first) in the strip, and each further click the next use; the browser stays open. "New shot" opens the [New shot](new-shot.md) dialog. Click selects (`A` approve / `E` archive then act on it, with an Undo toast), double-click opens the shot view, the card's foot always shows the two decisions the shot is not in (Approve green, Archive / Back to review grey), right-click offers Add to timeline, Approve/Archive/Back to review, Open shot, Show on map. - **Drag and drop**: drag a card into the strip (an accent line marks the insert position, never inside a sequence block; a sequence comes in as a [block](timeline.md)) or **onto a placeholder** to fill it — the hold time and notes stay; a sequence fills it as a block split across that time. Dropping an unreviewed shot offers "Approve" in a toast: it earned its place. Clips reorder by drag too. - A shot dropped onto a sketch clip fills it; the clip keeps the sketch and offers **Attach to shot**. -- **URL**: `#/timeline/?t=12.5&tool=overlay` keeps the playhead and the inline tool; reloading restores both (the tool only when it fits the clip under the playhead). On the desktop the position goes into the window's route, so the address bar shows it only while the Timeline window has focus, and a restored desktop brings it back. -- Keys: `←/→` clip, `Home`/`End`, `Space`, `Alt+←/→`, `D`, `Del`, `N` browser ⇄ clip panel, `P`, `R` re-frame, `C` overlay / sketch, `A`/`E` on the clip's shot (clip panel) or the browser's selection, `⇧N`, `↵`. `G T` goes to Timeline from anywhere. +- **URL**: `#/timeline/?t=12.5&tool=layers` keeps the playhead and the inline tool (`reframe`, `layers`, `sketch`); reloading restores both (the tool only when it fits the clip under the playhead). On the desktop the position goes into the window's route, so the address bar shows it only while the Timeline window has focus, and a restored desktop brings it back. +- Keys: `←/→` clip, `Home`/`End`, `Space`, `Alt+←/→`, `D`, `Del`, `N` browser ⇄ clip panel, `P`, `R` re-frame, `C` layers / sketch, `A`/`E` on the clip's shot (clip panel) or the browser's selection, `⇧N`, `↵`. `G T` goes to Timeline from anywhere. The inspector's **Shot** section lists the timelines a shot is in (links). ## Storage -`timelines` + `timeline_clips` (`apps/worker/src/timelines.ts`), the clips' own overlays and sketches in `overlays` / `sketches` with `timeline_id` + `clip_id`, promotion in `promote.ts`. `PUT /api/timelines/:id` replaces the whole timeline; clips whose photo is not in the project are dropped silently, an overlay that does not belong to the clip's photo (or is another clip's) is nulled, and clips that leave take their own drawings along. See [api](../api.md) and [data model](../data-model.md). +`timelines` + `timeline_clips` (`apps/worker/src/timelines.ts`), the clips' own layers and sketches in `layers` / `sketches` with `timeline_id` + `clip_id`, promotion in `promote.ts`. `PUT /api/timelines/:id` replaces the whole timeline; clips whose photo is not in the project are dropped silently, a `step_id` that is not a stored step of the clip's photo is nulled, and clips that leave take their own layers and sketch along. See [api](../api.md) and [data model](../data-model.md). The strip, preview, clip panel, playback and autosave live in `apps/studio/src/TimelineParts.tsx`; the page and the browser in `Timeline.tsx`. diff --git a/docs/known-issues.md b/docs/known-issues.md index 6b47a6c..3b33546 100644 --- a/docs/known-issues.md +++ b/docs/known-issues.md @@ -55,3 +55,15 @@ A rig's vignetting (Library › Rigs) is three numbers per focal length, interpo ## The Bluetooth plugin's location permissions are removed (lights #208) `@sfourdrinier/react-native-ble-plx` with `neverForLocation` declares the location permissions a second time, capped at Android 11. Google Play rejects a permission declared twice with different `maxSdkVersion`s, so `withUncappedLocation` in `apps/mobile/app.config.ts` removes the capped copies; the app keeps them uncapped for GPS. A plugin update that declares them another way would bring the rejection back: Build phone app now fails when an upload is rejected (it used to pass, because the step piped into `tee`). + +## Layers need the Studio to reach the phone (epic #275) + +The phone shows a step's layers only through its bake, and only the Studio makes bakes (it draws the stack in the browser; a Worker has no canvas). The Studio's bakery bakes every stale step of the loaded shots in the background whenever it is open. Until then the phone shows the photo with "Layers not baked yet". Right after migration 0030, every shot that had overlays has stale steps: the first Studio session bakes them (a few seconds per step, one shot at a time). A server-side baker would remove this dependency; it is not built. + +## What a bake leaves out (epic #275) + +A bake is photo-sized, so the phone frames it like the photo in every view mode. Anything beyond the photo is not in it: layers below the footage (they only show in the area beyond the capture) and shapes drawn outside the picture. A timeline clip's own layers are Studio-only (the phone never reads timelines). A clip re-framed differently from its step shows its strip thumbnail from the step's bake (frame-space layers at the step's frame); the stage and the clip panel compose the clip's own frame live. + +## Old overlays are kept, unread (epic #275) + +Migration 0030 copied the overlays into layers and left the `overlays` table, its renders under `renders/overlays/` and `shot_steps.overlay_id` in place. Nothing reads them any more (the purge still deletes their renders with a shot). A later release drops them once the migration has proven itself on production.