{own.length > 0 ? (
diff --git a/apps/studio/src/api.ts b/apps/studio/src/api.ts
index 6622a63..3b8ef25 100644
--- a/apps/studio/src/api.ts
+++ b/apps/studio/src/api.ts
@@ -38,6 +38,8 @@ export interface Shot {
id: string;
project_id: string;
project_name: string | null;
+ /** The project bakes approved shots only (epic #275); see vocab `bakeWaits`. */
+ bake_approved_only: boolean;
location_id: string | null;
location_name: string | null;
/** A spot within the location (issue #44); always one of the shot's location. */
@@ -79,7 +81,7 @@ export interface Sketch {
id: string; shot_id: string; timeline_id?: string | null; clip_id?: string | null; name: string; kind: string | null; description: string | null;
drawing?: Drawing; aspect: number; position: number; render_url: string | null; created_at: string; updated_at: string | null;
}
-export interface Project { id: string; workspace_id: string; name: string; notes: string | null; created_at: string; updated_at: string | null; shot_count: number; /** Extra-field definitions the project uses, in order. */ field_ids: string[] }
+export interface Project { id: string; workspace_id: string; name: string; notes: string | null; created_at: string; updated_at: string | null; shot_count: number; /** Bakes for the phone wait until a shot is approved (epic #275). */ bake_approved_only: boolean; /** Extra-field definitions the project uses, in order. */ field_ids: string[] }
export interface Preset {
id: string;
name: string;
@@ -215,8 +217,8 @@ export async function fetchAllShots(): Promise {
export const fetchProjects = () => get<{ projects: Project[] }>("/api/projects").then((r) => r.projects);
/** Upsert; a 409 carries body.existing_id when another project already has the name. */
-export const putProject = (p: { id: string; name: string; notes: string | null; workspace_id?: string }) =>
- send<{ project: Project }>("PUT", `/api/projects/${p.id}`, { name: p.name, notes: p.notes, workspace_id: p.workspace_id }).then((r) => r.project);
+export const putProject = (p: { id: string; name: string; notes: string | null; workspace_id?: string; bake_approved_only?: boolean }) =>
+ send<{ project: Project }>("PUT", `/api/projects/${p.id}`, { name: p.name, notes: p.notes, workspace_id: p.workspace_id, bake_approved_only: p.bake_approved_only }).then((r) => r.project);
/** Listeners told when a project was created somewhere other than Library › Projects (App refreshes its list). */
const projectCreated = new Set<(p: Project) => void>();
export function onProjectCreated(cb: (p: Project) => void): () => void { projectCreated.add(cb); return () => { projectCreated.delete(cb); }; }
diff --git a/apps/studio/src/compose/baker.ts b/apps/studio/src/compose/baker.ts
index 939ea51..611573a 100644
--- a/apps/studio/src/compose/baker.ts
+++ b/apps/studio/src/compose/baker.ts
@@ -3,9 +3,9 @@
* 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.
+ * shows no layers needs no bake. A project that bakes approved shots only holds the others back.
*/
-import { bakeHash, bakeKey, layersAt } from "@openclapper/vocab";
+import { bakeHash, bakeKey, bakeWaits, layersAt } from "@openclapper/vocab";
import { putBake, type Shot } from "../api";
import * as data from "../data";
import { steps, stepStale } from "../format";
@@ -14,6 +14,7 @@ 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 {
+ if (bakeWaits(shot)) return shot;
const stale = steps(shot).filter((st) => stepStale(shot, st));
if (!stale.length) return shot;
const layers = await loadShotLayers(shot);
@@ -46,7 +47,7 @@ async function run() {
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)));
+ const next = (data.getData().shots ?? []).find((s) => (failed.get(s.id) ?? 0) < now && !bakeWaits(s) && 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); }
diff --git a/apps/worker/migrations/0031_bake_approved_only.sql b/apps/worker/migrations/0031_bake_approved_only.sql
new file mode 100644
index 0000000..3b2291a
--- /dev/null
+++ b/apps/worker/migrations/0031_bake_approved_only.sql
@@ -0,0 +1,3 @@
+-- Epic #275 follow-up: a project can hold its bakes (the phone's flattened step images) back until a
+-- shot is approved, to save the Studio's work and storage on shots that never make it.
+ALTER TABLE projects ADD COLUMN bake_approved_only INTEGER NOT NULL DEFAULT 0;
diff --git a/apps/worker/src/projects.ts b/apps/worker/src/projects.ts
index 8930632..16198c1 100644
--- a/apps/worker/src/projects.ts
+++ b/apps/worker/src/projects.ts
@@ -10,12 +10,14 @@ export interface ProjectRow {
notes: string | null;
created_at: string;
updated_at: string | null;
+ /** 1: bakes wait until a shot is approved (epic #275). */
+ bake_approved_only: number;
shot_count?: number;
/** JSON array of the project's own field definition ids, in order. */
field_ids?: string;
}
-const toApi = (p: ProjectRow) => ({ ...p, field_ids: JSON.parse(p.field_ids ?? "[]") as string[] });
+const toApi = (p: ProjectRow) => ({ ...p, bake_approved_only: !!p.bake_approved_only, field_ids: JSON.parse(p.field_ids ?? "[]") as string[] });
const LIST_SQL = `SELECT pr.*, (SELECT count(*) FROM shots s WHERE s.project_id = pr.id AND s.deleted_at IS NULL) AS shot_count,
(SELECT json_group_array(id) FROM (SELECT id FROM field_definitions WHERE project_id = pr.id AND deleted_at IS NULL ORDER BY position, key)) AS field_ids
@@ -41,6 +43,9 @@ export function registerProjectRoutes(r: Router) {
const b = await readJson>(request);
const name = assertString(b.name, "name", 80);
const notes = b.notes === undefined || b.notes === null || b.notes === "" ? null : assertString(b.notes, "notes", 4000);
+ // Optional: absent keeps the project's setting (older clients send only name and notes).
+ if (b.bake_approved_only !== undefined && typeof b.bake_approved_only !== "boolean") throw new HttpError(400, "bake_approved_only must be a boolean");
+ const bakeApproved = b.bake_approved_only === undefined ? null : b.bake_approved_only ? 1 : 0;
const ws = (await accessIfExists(ctx, "project", pid, "editor"))
?? (b.workspace_id == null ? await defaultWorkspace(ctx) : assertRank(ctx, assertUuid(b.workspace_id, "workspace_id"), "editor", "workspace not found"));
const clash = await env.DB.prepare("SELECT id FROM projects WHERE workspace_id = ?3 AND name = ?1 COLLATE NOCASE AND id != ?2")
@@ -48,9 +53,9 @@ export function registerProjectRoutes(r: Router) {
if (clash) return json({ error: "a project with this name already exists", existing_id: clash.id }, 409);
const now = new Date().toISOString();
await env.DB.prepare(
- `INSERT INTO projects (id, name, notes, updated_at, workspace_id) VALUES (?1, ?2, ?3, ?4, ?5)
- ON CONFLICT(id) DO UPDATE SET name = ?2, notes = ?3, updated_at = ?4`,
- ).bind(pid, name, notes, now, ws).run();
+ `INSERT INTO projects (id, name, notes, updated_at, workspace_id, bake_approved_only) VALUES (?1, ?2, ?3, ?4, ?5, COALESCE(?6, 0))
+ ON CONFLICT(id) DO UPDATE SET name = ?2, notes = ?3, updated_at = ?4, bake_approved_only = COALESCE(?6, bake_approved_only)`,
+ ).bind(pid, name, notes, now, ws, bakeApproved).run();
const row = await env.DB.prepare(`${LIST_SQL} WHERE pr.id = ?1`).bind(pid).first();
return json({ project: toApi(row!) });
});
diff --git a/apps/worker/src/shots.ts b/apps/worker/src/shots.ts
index cb6f41a..384c3b9 100644
--- a/apps/worker/src/shots.ts
+++ b/apps/worker/src/shots.ts
@@ -34,6 +34,8 @@ interface ShotRow {
created_at: string;
updated_at: string | null;
project_name: string | null;
+ /** The project's setting, carried on every shot so the clients need no project lookup. */
+ bake_approved_only: number;
location_name: string | null;
location_lat: number | null;
location_lon: number | null;
@@ -118,6 +120,7 @@ function shotToApi(s: ShotRow, photos: PhotoRow[], compose: Compose) {
id: s.id,
project_id: s.project_id,
project_name: s.project_name ?? null,
+ bake_approved_only: !!s.bake_approved_only,
// A location in the recycle bin reads as none (the shot keeps it, so a restore brings it back).
location_id: s.location_name === null ? null : s.location_id,
location_name: s.location_name ?? null,
@@ -151,7 +154,7 @@ function shotToApi(s: ShotRow, photos: PhotoRow[], compose: Compose) {
};
}
-const SHOT_SQL = `SELECT s.*, pr.name AS project_name, l.name AS location_name, l.lat AS location_lat, l.lon AS location_lon,
+const SHOT_SQL = `SELECT s.*, pr.name AS project_name, pr.bake_approved_only AS bake_approved_only, l.name AS location_name, l.lat AS location_lat, l.lon AS location_lon,
sp.name AS spot_name, sp.lat AS spot_lat, sp.lon AS spot_lon
FROM shots s JOIN projects pr ON pr.id = s.project_id LEFT JOIN locations l ON l.id = s.location_id AND l.deleted_at IS NULL LEFT JOIN spots sp ON sp.id = s.spot_id AND l.id IS NOT NULL`;
diff --git a/apps/worker/test/routes/layers.test.ts b/apps/worker/test/routes/layers.test.ts
index 501736d..38b27a0 100644
--- a/apps/worker/test/routes/layers.test.ts
+++ b/apps/worker/test/routes/layers.test.ts
@@ -84,6 +84,19 @@ describe("bakes", () => {
});
});
+describe("bake approved only (project setting)", () => {
+ test("shots carry their project's setting; a project PUT without it keeps it; it must be a boolean", async () => {
+ const name = `p-${uuid().slice(0, 8)}`;
+ const put = (body: Record) => api(u.token, "PUT", `/api/projects/${w.project}`, { name, ...body });
+ expect((await shot(w.shot) as Shot & { bake_approved_only: boolean }).bake_approved_only).toBe(false);
+ expect((await ok<{ project: { bake_approved_only: boolean } }>(put({ bake_approved_only: true }))).project.bake_approved_only).toBe(true);
+ expect((await shot(w.shot) as Shot & { bake_approved_only: boolean }).bake_approved_only).toBe(true);
+ expect((await ok<{ project: { bake_approved_only: boolean } }>(put({ notes: "kept" }))).project.bake_approved_only).toBe(true);
+ await ok(put({ bake_approved_only: "yes" }), 400);
+ expect((await ok<{ project: { bake_approved_only: boolean } }>(put({ bake_approved_only: false }))).project.bake_approved_only).toBe(false);
+ });
+});
+
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();
diff --git a/apps/worker/test/routes/tenancy.test.ts b/apps/worker/test/routes/tenancy.test.ts
index bee24cf..d4096f2 100644
--- a/apps/worker/test/routes/tenancy.test.ts
+++ b/apps/worker/test/routes/tenancy.test.ts
@@ -220,7 +220,8 @@ describe("another workspace's resources", () => {
});
test("lists do show the owner's own resources (the leak check can fail)", async () => {
- for (const path of ["/api/projects", "/api/presets", "/api/fixtures", "/api/mesh-networks", "/api/locations", "/api/spots", "/api/fields", "/api/views", "/api/days", "/api/timelines", "/api/shots", "/api/devices", "/api/viewfinder-layouts", "/api/workspaces"]) {
+ // /api/shots pages (100 by default) and every case above adds a world of A's with the same capture time: ask for all.
+ for (const path of ["/api/projects", "/api/presets", "/api/fixtures", "/api/mesh-networks", "/api/locations", "/api/spots", "/api/fields", "/api/views", "/api/days", "/api/timelines", "/api/shots?limit=500", "/api/devices", "/api/viewfinder-layouts", "/api/workspaces"]) {
const text = await (await api(a.token, "GET", path)).text();
expect(text, path).toContain(listWorld.mark);
}
diff --git a/docs/api.md b/docs/api.md
index ae0638e..8b6ba94 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -28,7 +28,7 @@ Who a request is (`index.ts` `identify`): a session (bearer header, else cookie;
| Method | Path | Notes |
| --- | --- | --- |
| GET | `/api/projects` | Each with `workspace_id`, `shot_count` and `field_ids` (ordered) |
-| PUT | `/api/projects/:id` | `{ name, notes?, workspace_id? }` upsert. A new project goes to `workspace_id` (editor there), default the caller's own workspace; an existing one keeps its workspace. Names are unique per workspace: clash → `409 { existing_id }` (of that workspace) |
+| PUT | `/api/projects/:id` | `{ name, notes?, workspace_id?, bake_approved_only? }` upsert (`bake_approved_only` a boolean; absent keeps the project's setting; every shot payload carries it too). A new project goes to `workspace_id` (editor there), default the caller's own workspace; an existing one keeps its workspace. Names are unique per workspace: clash → `409 { existing_id }` (of that workspace) |
| DELETE | `/api/projects/:id` | Only without live shots, else `409`; the project's recycle bin is emptied first |
| PUT | `/api/projects/:id/fields` | `{ field_ids: [...] }` reorders the project's own fields (must list exactly them) |
diff --git a/docs/data-model.md b/docs/data-model.md
index 31b7918..8846f72 100644
--- a/docs/data-model.md
+++ b/docs/data-model.md
@@ -6,6 +6,7 @@ D1 (SQLite) database `openclapper-db`, schema in `apps/worker/migrations/`. Imag
| File | What |
| --- | --- |
+| `0031_bake_approved_only.sql` | `projects.bake_approved_only` (0/1, default 0): the project's bakes wait until a shot is approved. Additive (epic #275; see [composition](features/composition.md)) |
| `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 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) |
diff --git a/docs/features/composition.md b/docs/features/composition.md
index 4f93cc3..4d84854 100644
--- a/docs/features/composition.md
+++ b/docs/features/composition.md
@@ -47,6 +47,10 @@ Content beyond the photo is not baked (layers below the footage, shapes outside
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).
+- **Approved shots only** (Library › Projects › "Phone images of compositions", `projects.bake_approved_only`, carried on
+ every shot payload; vocab `bakeWaits`): the bakery skips shots that are not approved. Their steps show the photo
+ with "Baked once approved" on the phone (the "◌" mark's tooltip says so in the Studio). Bakes made earlier are
+ kept and shown while they still match; approving a shot bakes it at the next pass. Off by default.
- Bakes of steps that no longer exist are deleted (when steps are replaced and on every bake upload).
## Building a progression
diff --git a/docs/features/projects.md b/docs/features/projects.md
index 3a8db28..1687a8d 100644
--- a/docs/features/projects.md
+++ b/docs/features/projects.md
@@ -8,7 +8,7 @@ Every shot belongs to exactly one project (a film, a commercial). Projects are a
- **Studio:**
- A first visit (or a remembered project that no longer exists) shows the full-window project picker (`ProjectGate` in `Projects.tsx`).
- The taskbar's project switcher lists the projects and "Manage projects…"; ⌘K → a project name switches too. Switching swaps the whole desktop: each project has its own windows (per browser) and its own icons and folders (on the server, [files](files.md)). Every app (Gallery, Timeline, Metadata, Plan, Fields, Locations, Files, saved views) shows the active project only.
- - Projects (an app; Library on the old sidebar): a list (shots, fields, "can be deleted") and a detail with name and notes (saved when leaving the field), the project's [extra fields](extra-fields.md) in order ("Edit fields" makes it active and opens Fields), "Show n shots" and "Make active". Only empty projects can be deleted; its locations, fields and views go with it.
+ - Projects (an app; Library on the old sidebar): a list (shots, fields, "can be deleted") and a detail with name and notes (saved when leaving the field), **Phone images of compositions** (Every shot, or Approved shots only: the Studio bakes a shot's layers for the phone once it is approved, see [composition](composition.md)), the project's [extra fields](extra-fields.md) in order ("Edit fields" makes it active and opens Fields), "Show n shots" and "Make active". Only empty projects can be deleted; its locations, fields and views go with it.
- **Inline creation** when moving shots: the inspector's Project box, the Move to project dialog and the bulk-edit panel offer "Create “…”" for a new name (`createProjectNamed` in `api.ts`). A name that exists already is taken instead (the 409 carries its id), so nothing is duplicated.
- A shot moves to another project via the Project box in the shot view's inspector (or bulk edit). Its **location follows by name**: the target project's location of that name, created when missing (with the pin). Extra values the target's fields don't accept are dropped. When the move leaves shooting days or drops extra values, a warning names them and Move confirms.
- **Phone:**
diff --git a/docs/features/review-and-tagging.md b/docs/features/review-and-tagging.md
index 63a9988..7cb723a 100644
--- a/docs/features/review-and-tagging.md
+++ b/docs/features/review-and-tagging.md
@@ -65,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 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 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" ("Baked once approved" in a project that bakes approved shots only). **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
diff --git a/packages/vocab/src/layers.test.ts b/packages/vocab/src/layers.test.ts
index 15a0eeb..9a26a4a 100644
--- a/packages/vocab/src/layers.test.ts
+++ b/packages/vocab/src/layers.test.ts
@@ -1,7 +1,7 @@
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";
+import { bakeHash, bakeKey, bakeWaits, 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 => ({
@@ -50,3 +50,10 @@ test("input validation", () => {
assert.match(validateLayerInput({ ...ok, timeline_id: U(4) })!, /together/);
assert.match(validateLayerInput({ ...ok, name: " " })!, /name/);
});
+
+test("bakeWaits: only when the project bakes approved shots and the shot is not approved", () => {
+ assert.equal(bakeWaits({ state: "unreviewed" }), false);
+ assert.equal(bakeWaits({ state: "unreviewed", bake_approved_only: true }), true);
+ assert.equal(bakeWaits({ state: "archived", bake_approved_only: true }), true);
+ assert.equal(bakeWaits({ state: "approved", bake_approved_only: true }), false);
+});
diff --git a/packages/vocab/src/layers.ts b/packages/vocab/src/layers.ts
index cbf22ed..578f191 100644
--- a/packages/vocab/src/layers.ts
+++ b/packages/vocab/src/layers.ts
@@ -114,6 +114,13 @@ export const layersAt = (layers: readonly L[], step: StepRef):
/** 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);
+/**
+ * Whether a shot's bakes wait for approval: its project bakes approved shots only (a project setting,
+ * to spare the Studio's work and storage on shots that never make it) and it is not approved. Bakes
+ * already made stay; the phone shows them while they are fresh.
+ */
+export const bakeWaits = (s: { state: string; bake_approved_only?: boolean }): boolean => !!s.bake_approved_only && s.state !== "approved";
+
/** 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);