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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/mobile/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,8 @@ export interface Shot {
id: string;
project_id: string;
project_name: string | null;
/** The project bakes approved shots only (epic #275); absent on queued shots. */
bake_approved_only?: boolean;
location_id: string | null;
location_name: string | null;
spot_id: string | null;
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/src/components/ShotParts.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,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 { bakeWaits, label } from "@openclapper/vocab";
import { api, positionOf, steps, stepStale, type Photo, type Shot } from "../api";
import { editShot, isWaiting } from "../localShots";
import { isOfflineMode, isOnline } from "../net";
Expand Down Expand Up @@ -116,7 +116,7 @@ export function ShotPhoto({ shot, index, mode, settings, maxW, maxH, badges = tr
return (
<View style={[s.photoBox, { width: maxW, height: maxH }]} {...pan}>
<StepFrame shot={shot} step={step} width={fitWidthFor(step, mode, maxW, maxH)} settings={settings} mode={mode} />
{stepStale(shot, step) && <View style={s.bl} pointerEvents="none"><PhotoTag icon="progress-clock">Layers not baked yet</PhotoTag></View>}
{stepStale(shot, step) && <View style={s.bl} pointerEvents="none"><PhotoTag icon={bakeWaits(shot) ? "check-decagram-outline" : "progress-clock"}>{bakeWaits(shot) ? "Baked once approved" : "Layers not baked yet"}</PhotoTag></View>}
{badges && n > 1 && <View style={s.tl} pointerEvents="none"><SeqBadge count={n} /></View>}
{badges && n > 1 && <View style={s.tr} pointerEvents="none"><PhotoTag>{stepTag(shot, index, n)}</PhotoTag></View>}
</View>
Expand Down
4 changes: 2 additions & 2 deletions apps/studio/src/Framed.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ComponentChildren, JSX } from "preact";
import { withCentre } from "@openclapper/vocab";
import { bakeWaits, withCentre } from "@openclapper/vocab";
import type { Photo } from "./api";
import { frameLayout, frameOf, imageAspect, stepSrc, stepStale, type FrameGeometry, type FrameMode, type PctRect, type Step } from "./format";
import type { Shot } from "./api";
Expand Down Expand Up @@ -64,7 +64,7 @@ export function Framed({ photo, mode, className, maxHeight, frame, imgStyle, src
export function StepFramed({ shot, step, mode, className, maxHeight, children }: { shot: Shot; step: Step; mode: MaskMode; className?: string; maxHeight?: string; children?: ComponentChildren }) {
return (
<Framed photo={step.photo} frame={step.frame} src={stepSrc(shot, step)} vignette={stepVignette(step)} mode={mode} className={className} maxHeight={maxHeight}>
{stepStale(shot, step) && <span class="bake-mark" title="Layers not baked yet: the Studio is making the image">◌</span>}
{stepStale(shot, step) && <span class="bake-mark" title={bakeWaits(shot) ? "Not baked: this project bakes approved shots only" : "Layers not baked yet: the Studio is making the image"}>◌</span>}
{children}
</Framed>
);
Expand Down
20 changes: 19 additions & 1 deletion apps/studio/src/Projects.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect, useState } from "preact/hooks";
import { deleteProject, existingIdOf, putProject, type FieldDefinition, type Project } from "./api";
import { Button, confirmDialog, Empty, Field, Input, ListRow, Mark, MenuItem, Panel, PanelBody, PanelHead, ProjectTag, promptDialog, SaveStatus, toast, Toolbar, ToolbarSpacer, ToolbarTitle, type SaveState } from "@openclapper/ui";
import { Button, confirmDialog, Empty, Field, Input, ListRow, Mark, MenuItem, Panel, PanelBody, PanelHead, ProjectTag, promptDialog, SaveStatus, Seg, toast, Toolbar, ToolbarSpacer, ToolbarTitle, type SaveState } from "@openclapper/ui";
import * as data from "./data";
import { useUser } from "./user";

const sortByName = (list: Project[]) => [...list].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }));
Expand Down Expand Up @@ -89,6 +90,16 @@ function ProjectDetail({ project, projects, onChange, fields, active, onActivate
try { const saved = await putProject({ id: project.id, name: n.trim(), notes: t.trim() || null }); replace({ ...saved, field_ids: project.field_ids }); setSave("saved"); }
catch (err) { setSave("error"); setNameError(existingIdOf(err) ? `A project called “${n.trim()}” already exists. Names are unique, ignoring case.` : (err as Error).message); }
}
/** Bake for the phone only once approved (epic #275); the loaded shots carry the setting too, so the baker follows at once. */
async function setBakeApproved(on: boolean) {
setSave("saving");
try {
const saved = await putProject({ id: project.id, name: project.name, notes: project.notes, bake_approved_only: on });
replace({ ...saved, field_ids: project.field_ids });
data.setShots((cur) => (cur ?? []).map((x) => (x.project_id === project.id ? { ...x, bake_approved_only: on } : x)));
setSave("saved");
} catch (e) { setSave("error"); toast(`Not saved: ${(e as Error).message}`, "danger"); }
}
async function remove() {
const ok = await confirmDialog({ title: `Delete “${project.name}”?`, body: "The project is empty; nothing else is removed.", confirmLabel: "Delete project", danger: true });
if (!ok) return;
Expand All @@ -110,6 +121,13 @@ function ProjectDetail({ project, projects, onChange, fields, active, onActivate
<Input invalid={!!nameError} value={name} maxLength={80} onInput={(e) => setName((e.target as HTMLInputElement).value)} onBlur={() => void saveMeta()} onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }} />
</Field>
<Field label="Notes"><textarea class="f-textarea" rows={3} value={notes} onInput={(e) => setNotes((e.target as HTMLTextAreaElement).value)} onBlur={() => void saveMeta()} /></Field>
<Field label="Phone images of compositions" as="div">
<Seg label="Phone images of compositions" value={project.bake_approved_only ? "approved" : "all"} onChange={(v) => void setBakeApproved(v === "approved")}
options={[{ id: "all", label: "Every shot" }, { id: "approved", label: "Approved shots only" }]} />
<span class="f-field__help">{project.bake_approved_only
? "The Studio bakes a shot's layers into images for the phone once the shot is approved. Until then the phone shows the photo, tagged “Baked once approved”; images made before stay while they match."
: "The Studio bakes every shot's layers into images for the phone, in the background. Approved only saves that work and storage on shots that never make it."}</span>
</Field>
<div class="f-field" style={{ gap: "8px" }}>
<div class="btn-row"><span class="f-field__label" style={{ flex: 1 }}>Extra fields, in order</span><a href="#" style={{ fontSize: "var(--text-caption)" }} onClick={(e) => { e.preventDefault(); onEditFields(project.id); }}>{active ? "Edit fields" : "Make active and edit fields"}</a></div>
{own.length > 0 ? (
Expand Down
8 changes: 5 additions & 3 deletions apps/studio/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -215,8 +217,8 @@ export async function fetchAllShots(): Promise<Shot[]> {

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); }; }
Expand Down
7 changes: 4 additions & 3 deletions apps/studio/src/compose/baker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<Shot> {
if (bakeWaits(shot)) return shot;
const stale = steps(shot).filter((st) => stepStale(shot, st));
if (!stale.length) return shot;
const layers = await loadShotLayers(shot);
Expand Down Expand Up @@ -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); }
Expand Down
3 changes: 3 additions & 0 deletions apps/worker/migrations/0031_bake_approved_only.sql
Original file line number Diff line number Diff line change
@@ -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;
13 changes: 9 additions & 4 deletions apps/worker/src/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -41,16 +43,19 @@ export function registerProjectRoutes(r: Router<Ctx>) {
const b = await readJson<Record<string, unknown>>(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")
.bind(name, pid, ws).first<{ id: string }>();
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<ProjectRow>();
return json({ project: toApi(row!) });
});
Expand Down
5 changes: 4 additions & 1 deletion apps/worker/src/shots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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`;

Expand Down
13 changes: 13 additions & 0 deletions apps/worker/test/routes/layers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) => 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();
Expand Down
3 changes: 2 additions & 1 deletion apps/worker/test/routes/tenancy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
2 changes: 1 addition & 1 deletion docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |

Expand Down
Loading
Loading