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
29 changes: 16 additions & 13 deletions apps/mobile/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -189,31 +189,34 @@ 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 }
export interface ShootingDay { id: string; project_id: string; date: string; title: string | null; notes: string | null; updated_at: string | null; shots: DayShot[] }

/** 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<Photo, Overlay>;
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<Photo>;
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),
Expand All @@ -235,7 +238,7 @@ interface ShotsPage { shots: Shot[]; next: Cursor }
/** Headers for <Image> requests to the API (the session token). */
export const imageHeaders = (): Record<string, string> => 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 = {
Expand Down
51 changes: 16 additions & 35 deletions apps/mobile/src/components/ComposeParts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand All @@ -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 (
<View style={{ gap: 6 }}>
<View style={{ paddingHorizontal: 16 }}><SectionLabel>{shot.photos.length > 1 ? `Overlays · photo ${photo.ordinal + 1} · Sketches` : "Overlays · Sketches"}</SectionLabel></View>
<View style={{ paddingHorizontal: 16 }}><SectionLabel>Sketches</SectionLabel></View>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={{ gap: 10, paddingHorizontal: 16, paddingVertical: 4 }}>
{items.map((it) => (
<Pressable key={it.id} onPress={() => onOpen(it)} style={({ pressed }) => [s.tile, pressed && { opacity: 0.8 }]} accessibilityRole="button" accessibilityLabel={`${it.name}, ${kindLabel(it)}`}>
<View style={[s.thumb, it.type === "sketch" && { backgroundColor: FIXED.white }]}>
{it.render_url ? <Image source={renderSource(it)} style={{ width: "100%", height: "100%" }} contentFit={it.type === "sketch" ? "contain" : "cover"} cachePolicy="disk" /> : <Icon name={it.type === "overlay" ? "layers-outline" : "floor-plan"} color={FIXED.white} />}
<View style={[s.thumb, { backgroundColor: FIXED.white }]}>
{it.render_url ? <Image source={renderSource(it)} style={{ width: "100%", height: "100%" }} contentFit="contain" cachePolicy="disk" /> : <Icon name="floor-plan" color={FIXED.photoBg} />}
</View>
<Text style={s.tileName} numberOfLines={1}>{it.name}</Text>
<Text style={s.tileMeta} numberOfLines={1}>{kindLabel(it)}</Text>
Expand All @@ -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 <View style={{ marginHorizontal: -16 }}>{items.map((it) => <ListRow key={it.id} icon={it.type === "overlay" ? "layers-outline" : "floor-plan"} title={it.name} meta={[kindLabel(it), it.description ? "has notes" : null].filter(Boolean).join(" · ")} onPress={() => onOpen(it)} />)}</View>;
return <View style={{ marginHorizontal: -16 }}>{items.map((it) => <ListRow key={it.id} icon="floor-plan" title={it.name} meta={[kindLabel(it), it.description ? "has notes" : null].filter(Boolean).join(" · ")} onPress={() => onOpen(it)} />)}</View>;
}

/**
* 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<FrameMode>(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 (
<Modal visible animationType="fade" onRequestClose={onClose} statusBarTranslucent navigationBarTranslucent>
<View style={s.full}>
{overlay
? <FramedImage source={source} aspect={imageAspect(photo)} frame={frame} width={w} settings={settings} mode={mode} />
: <View style={{ width: w, height: w / (item as Sketch).aspect, backgroundColor: FIXED.white }}>{item.render_url ? <Image source={source} style={{ width: "100%", height: "100%" }} contentFit="contain" /> : <View style={s.noRender}><Icon name="image-off-outline" color={FIXED.white} /><Text style={s.noRenderText}>No render yet</Text></View>}</View>}
{<View style={{ width: w, height: w / item.aspect, backgroundColor: FIXED.white }}>{item.render_url ? <Image source={source} style={{ width: "100%", height: "100%" }} contentFit="contain" /> : <View style={s.noRender}><Icon name="image-off-outline" color={FIXED.white} /><Text style={s.noRenderText}>No render yet</Text></View>}</View>}
<View style={[s.close, { top: insets.top + 12 }]}><IconButton icon="close" label="Close" size={52} onPress={onClose} /></View>
<View style={[s.card, { bottom: Math.max(insets.bottom, 24), maxWidth: Math.min(width - 24, 560) }]}>
<View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
<View style={{ flex: 1 }}>
<Text style={s.name} numberOfLines={1}>{item.name}</Text>
<Text style={s.meta} numberOfLines={1}>{kindLabel(item)}{overlay?.presentation.label ? ` · ${overlay.presentation.label}` : ""}</Text>
<Text style={s.meta} numberOfLines={1}>{kindLabel(item)}</Text>
</View>
{!!item.description && <IconButton icon={notes ? "chevron-down" : "text-long"} label={notes ? "Hide notes" : "Show notes"} onPress={() => setNotes(!notes)} />}
</View>
{overlay && <FrameModeSeg value={mode} onChange={setMode} />}
{!!item.description && notes && <ScrollView style={{ maxHeight: landscape ? 90 : 160 }}><MarkdownText source={item.description} /></ScrollView>}
</View>
</View>
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/components/PhotoStrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export function PhotoStrip({ shot, index, onPick }: { shot: Shot; index: number;
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={{ gap: 8, paddingVertical: 8, paddingHorizontal: 2 }}>
{list.map((st, i) => (
<Pressable key={st.id} onPress={() => onPick(i)} style={[s.thumb, i === index && s.thumbOn]} accessibilityRole="button" accessibilityLabel={`${st.stored ? "Step" : "Photo"} ${i + 1}`} accessibilityState={{ selected: i === index }}>
<Image source={stepSource(st)} style={{ width: "100%", height: "100%" }} contentFit="cover" cachePolicy="disk" />
<Image source={stepSource(shot, st)} style={{ width: "100%", height: "100%" }} contentFit="cover" cachePolicy="disk" />
<Text style={[s.n, i === index && { backgroundColor: c.accent, color: c.onAccent }]}>{i + 1}</Text>
</Pressable>
))}
Expand Down
38 changes: 18 additions & 20 deletions apps/mobile/src/components/ShotFrame.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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<string, string> } {
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<string, string> } {
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<ViewStyle> }) {
return <FramedImage source={stepSource(step)} aspect={imageAspect(step.photo)} frame={fractions(step.frame)} vignette={stepVignette(step)} width={width} settings={settings} mode={mode} style={style} />;
/** 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<ViewStyle> }) {
return <FramedImage source={stepSource(shot, step)} aspect={imageAspect(step.photo)} frame={fractions(step.frame)} vignette={stepVignette(step)} width={width} settings={settings} mode={mode} style={style} />;
}

/** Width at which a step (in this mode) fits inside maxW × maxH. */
Expand Down
Loading
Loading