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
94 changes: 81 additions & 13 deletions apps/studio/src/ClipEditing.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import type { ComponentChildren } from "preact";
import { useEffect, useRef, useState } from "preact/hooks";
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 { byPosition, clipLayersOf, layersAt, SKETCH_ASPECTS, withCentre, type Drawing, type Layer, type Presentation, type Shape } from "@openclapper/vocab";
import { deleteClipSketch, fetchLayer, fetchShotLayers, fetchSketch, putClipSketch, type Photo, type Preset, type Timeline, type TimelineClip } from "./api";
import { DrawSurface } from "./compose/DrawSurface";
import { DrawToolbar, drawingKeys, ShapeSection, useDrawingEditor } from "./compose/editor";
import { LayerList, LayerProps, useLayerDocs } from "./compose/layers";
import { FOOTAGE, LayerList, LayerProps, useLayerDocs } from "./compose/layers";
import { renderSketch } from "./compose/render";
import { Composition, layerRect, stackAreas, useShotLayers } from "./compose/stack";
import { Composition, layerRect, primeShotLayers, stackAreas, useShotLayers } from "./compose/stack";
import { useClipLayers } from "./ClipPicture";
import * as data from "./data";
import { shotTitle } from "./format";
import { Framed, type MaskMode } from "./Framed";
import { FramingSelect, presentationFrame, rootPresentation } from "./FramingSelect";
import { bind, NUDGE, useKeys } from "./keys";
Expand All @@ -23,7 +26,8 @@ import { Button, confirmDialog, Empty, Icon, Input, Panel, PanelBody, PanelHead,
* timeline's autosave, layers a moment after the last change, a sketch 1.2 s after the last stroke.
*/

export type EditKind = "reframe" | "layers" | "sketch";
/** `layers`: the clip's own; `shot-layers`: its shot's (sequence-wide), the same as the shot's Compose stage. */
export type EditKind = "reframe" | "layers" | "shot-layers" | "sketch";

type SketchDoc = { kind: "sketch"; id: string; name: string; shapes: Shape[]; aspect: number; isNew: boolean };
type Doc = SketchDoc;
Expand Down Expand Up @@ -74,11 +78,23 @@ export function useClipEditing({ timeline, clips, presets, onChange }: Options):
onDeleted: (id) => { const t = tRef.current; onChange({ ...t, layers: t.layers.filter((x) => x.id !== id) }); },
});
const shotLayers = useShotLayers(layersClip?.shot ?? null);
// ----- the shot's layers from the timeline: every clip of the shot (the whole sequence) shows them -----
const shotClip = editing?.kind === "shot-layers" && c?.kind === "photo" ? c : null;
const sdocs = useLayerDocs({
load: async () => (shotClip ? fetchShotLayers(shotClip.shot.id) : []),
deps: [shotClip?.shot.id ?? null],
onSaved: (r) => data.updated(r.shot),
onDeleted: (_id, s) => data.updated(s),
});
// The strip and the stage read the shot's layers from the cache: keep it on what is edited here.
useEffect(() => { if (shotClip && !sdocs.loading) primeShotLayers(shotClip.shot.id, sdocs.list); }, [sdocs.list, sdocs.loading]);
const shotClipOwn = useClipLayers(shotClip?.ownLayers ?? []);
const active = shotClip ? sdocs : docs;
const [layerSel, setLayerSel] = useState<string | null>(null);
useEffect(() => { setLayerSel(null); }, [layersClip?.clip.id]);
const picked = docs.list.find((l) => l.id === layerSel) ?? null;
useEffect(() => { setLayerSel(null); }, [layersClip?.clip.id, shotClip?.shot.id]);
const picked = active.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);
const led = useDrawingEditor(drawingLayer?.shapes ?? [], (shapes) => { if (drawingLayer) active.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]);
Expand Down Expand Up @@ -128,8 +144,8 @@ export function useClipEditing({ timeline, clips, presets, onChange }: Options):

async function stop(): Promise<boolean> {
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;
if (editing.kind === "layers" || editing.kind === "shot-layers") {
if (!(await (editing.kind === "layers" ? docs : sdocs).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;
}
Expand All @@ -149,7 +165,7 @@ export function useClipEditing({ timeline, clips, presets, onChange }: Options):
setEditing({ kind, clipId: cl.clip.id });
return;
}
if (kind === "layers") {
if (kind === "layers" || kind === "shot-layers") {
if (cl.kind !== "photo") return;
setEditing({ kind, clipId: cl.clip.id });
return;
Expand Down Expand Up @@ -202,9 +218,9 @@ export function useClipEditing({ timeline, clips, presets, onChange }: Options):
escape: () => { if (ed.textEdit) { ed.setTextEdit(null); return; } if (ed.selected) { ed.setSelected(null); return; } void stop(); },
}), editing?.kind === "sketch" && !!doc);
useKeys(drawingKeys(led, {
save: () => { void docs.flush(); },
save: () => { void active.flush(); },
escape: () => { if (led.textEdit) { led.setTextEdit(null); return; } if (led.selected) { led.setSelected(null); return; } if (layerSel) { setLayerSel(null); return; } void stop(); },
}), !!layersClip);
}), !!(layersClip || shotClip));
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", {
Expand Down Expand Up @@ -284,6 +300,58 @@ export function useClipEditing({ timeline, clips, presets, onChange }: Options):
</PanelBody>
</Panel>
);
} else if (shotClip) {
const cl = shotClip;
const frame = presentationFrame(cl.photo, cl.pres);
const mode: MaskMode = cl.pres.mode === "off" ? "fit" : cl.pres.mode;
const areas = stackAreas(cl.photo, mode, frame);
const shown = layersAt(sdocs.list, cl.step);
const own = clipLayersOf(shotClipOwn, cl.clip.id);
stage = drawingLayer ? (
<DrawSurface shapes={drawingLayer.shapes ?? []} onPreview={led.preview} onCommit={led.commit} selected={led.selected} onSelect={led.setSelected} tool={led.tool} style={led.style} mask={led.mask}
canvasOf={(w, h) => 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={() => <Composition photo={cl.photo} frame={frame} mode={mode} layers={shown.filter((l) => l.id !== drawingLayer.id)} extra={own} className="compose__photo" />} />
) : <Composition photo={cl.photo} frame={frame} mode={mode} layers={shown} extra={own} maxHeight={STAGE_H} />;
// "This step" needs the clip's step stored; chosen steps are picked in the shot's Compose stage.
const here = cl.step.stored ? cl.step.id : null;
const badge = (l: Layer) => (l.range === null ? null : here && l.range.includes(here) ? (l.range.length === 1 ? "this step" : `${l.range.length} steps`) : "not here");
const add = async (kind: Layer["kind"]) => {
const l = await sdocs.add(kind, { shot_id: cl.shot.id, space: "frame", photo_id: null, range: null, timeline_id: null, clip_id: null });
if (l) setLayerSel(l.id);
};
const range = (l: Layer) => {
const kind = l.range === null ? "all" : here && l.range.length === 1 && l.range[0] === here ? "this" : "some";
const options = [{ id: "all", label: "Every step" }, ...(here ? [{ id: "this", label: "This step" }] : []), ...(kind === "some" ? [{ id: "some", label: `${l.range?.length ?? 0} chosen` }] : [])];
return (
<div class="f-field">
<span class="f-field__label">Shows on</span>
<Seg label="Shows on" value={kind} onChange={(k) => { if (k === "all") sdocs.update(l.id, { range: null }); else if (k === "this" && here) sdocs.update(l.id, { range: [here] }); }} options={options} />
<span class="meta">{l.space === "photo" ? "Anchored to a photo: only on that photo's steps. " : ""}Pick single steps in the shot's Compose stage.</span>
</div>
);
};
panel = (
<Panel label="Shot layers" width="380px" resize={DRAWER_RESIZE}>
<PanelHead title="Shot layers"><SaveStatus state={sdocs.save} onRetry={sdocs.retry} />{done}</PanelHead>
<PanelBody flush>
{drawingLayer && <div class="f-sec tl-drawbar"><DrawToolbar ed={led} compact /></div>}
<div class="f-sec">
<span class="meta">The layers of “{shotTitle(cl.shot)}”: every clip of the shot shows them, here and everywhere else.</span>
{sdocs.loading ? <Spinner /> : <LayerList docs={sdocs} selected={layerSel} onSelect={(id) => setLayerSel(id === FOOTAGE ? null : id)} footage="Footage" badge={badge} />}
<div class="btn-row" style={{ gap: "6px" }}>
<Button kind="secondary" size="sm" icon="draw" onClick={() => void add("drawing")}>Drawing</Button>
<Button kind="secondary" size="sm" icon="tune-variant" onClick={() => void add("adjustment")}>Adjustment</Button>
</div>
</div>
{picked && (
<LayerProps layer={picked} docs={sdocs} range={range(picked)} space={(space) => sdocs.update(picked.id, { space, photo_id: space === "photo" ? cl.photo.id : null })}>
{picked.kind === "drawing" && <ShapeSection ed={led} />}
</LayerProps>
)}
</PanelBody>
</Panel>
);
} else if (loading || !doc) {
stage = <div class="tl-preview__empty"><Spinner /></div>;
panel = <Panel label="Loading" width="340px" resize={DRAWER_RESIZE}><PanelHead title="Opening…">{done}</PanelHead><PanelBody><Spinner /></PanelBody></Panel>;
Expand Down
3 changes: 2 additions & 1 deletion apps/studio/src/Timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,7 @@ function Workspace(p: WorkspaceProps) {
placeholder: addPlaceholder,
reframe: () => { if (cur?.kind === "photo") edit("reframe", cur); },
compose: () => { if (cur) edit(cur.kind === "photo" ? "layers" : "sketch", cur); },
shotLayers: () => { if (cur?.kind === "photo") edit("shot-layers", cur); },
open: () => { if (cur) open(cur); },
}), !inline.editing);
// The clip panel's keys: decisions on the clip's shot (issue #35 asked for them), hold time, remove.
Expand Down Expand Up @@ -345,7 +346,7 @@ function Workspace(p: WorkspaceProps) {
onExtract={(c) => void extractClip(c)} range={multi?.has(clipMenu.menu.ctx.clip.id) ? { n: rangeClips.length, problem: rangeProblem, onMake: () => void makeSequence() } : null} />}
{inline.panel ?? (panel === "clip" && cur
? <ClipPanel key={cur.clip.id} c={cur} count={clips.length} lock={t.lock_mode} durRef={durRef} onUpdate={(patch) => update(cur.clip.id, patch)} onMove={(d) => move(cur, d)} onRemove={() => removeClip(cur)} onDuplicate={() => void duplicate(cur)} onOpen={() => open(cur)}
onReframe={() => edit("reframe", cur)} onLayers={() => edit("layers", cur)} onSketch={() => edit("sketch", cur)} onAttachSketch={() => void attachSketch(cur)}
onReframe={() => edit("reframe", cur)} onLayers={() => edit("layers", cur)} onShotLayers={() => edit("shot-layers", cur)} onSketch={() => edit("sketch", cur)} onAttachSketch={() => void attachSketch(cur)}
onState={cur.kind === "photo" ? (to) => void setShotState(cur.shot, to, p.onUpdated) : undefined}
onGroup={(g) => change(setGroup(t, g))} onUngroup={() => cur.clip.group_id && change(ungroup(t, cur.clip.group_id))}
onRestore={() => cur.kind === "photo" && cur.clip.group_id && change(restorePhotos(t, cur.clip.group_id, cur.shot, mask))}
Expand Down
15 changes: 11 additions & 4 deletions apps/studio/src/TimelineParts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -813,6 +813,8 @@ export interface ClipPanelProps {
/** Inline editing (issues #31, #275): re-frame the clip, edit its own layers, draw a sketch for a placeholder or a sketch clip. */
onReframe?: () => void;
onLayers?: () => void;
/** The shot's layers (sequence-wide), edited inline (epic #275). */
onShotLayers?: () => void;
onSketch?: () => void;
/** A photo clip that replaced a sketch clip still owns that sketch: attach it to the clip's shot. */
onAttachSketch?: () => void;
Expand Down Expand Up @@ -848,14 +850,19 @@ function SecondsField({ label, ms, max, onCommit, disabled, inputRef, kbd, help
}

/** A sequence block's settings in the clip panel: how its length is made, Restore and Ungroup. */
function SequenceSection({ c, onGroup, onUngroup, onRestore }: { c: Extract<ResolvedClip, { kind: "photo" }>; onGroup: (g: ClipGroup) => void; onUngroup?: () => void; onRestore?: () => void }) {
function SequenceSection({ c, onGroup, onUngroup, onRestore, onShotLayers }: { c: Extract<ResolvedClip, { kind: "photo" }>; onGroup: (g: ClipGroup) => void; onUngroup?: () => void; onRestore?: () => void; onShotLayers?: () => void }) {
const g = c.group!, seq = c.seq!;
const missing = seqCount(c.shot) - seq.n;
const [weight, setWeight] = useState(g.edge_weight);
useEffect(() => setWeight(g.edge_weight), [g.edge_weight]);
return (
<div class="tl-seqbox" role="group" aria-label="Sequence">
<div class="tl-seqbox__head"><SeqBadge count={seqCount(c.shot)} small /><span class="meta">Step {seq.i + 1} of {seq.n} in this block{missing > 0 ? ` · ${missing} removed` : ""}</span></div>
{onShotLayers && (
<div class="btn-row">
<Button kind="secondary" size="sm" icon="layers-triple-outline" kbd="⇧C" title="The shot's layers: every step of the sequence, everywhere it is shown" onClick={onShotLayers}>{c.shot.layers.length ? `Sequence layers · ${c.shot.layers.length}` : "Sequence layers"}</Button>
</div>
)}
<Field label="Length" as="div">
<Seg label="How the block's length is made" value={g.mode} onChange={(mode) => onGroup({ ...g, mode })}
options={[{ id: "each", label: "Per photo" }, { id: "total", label: "Total" }]} />
Expand Down Expand Up @@ -885,7 +892,7 @@ function SequenceSection({ c, onGroup, onUngroup, onRestore }: { c: Extract<Reso
);
}

export function ClipPanel({ c, count, lock, durRef, onUpdate, onMove, onRemove, onDuplicate, onOpen, onReframe, onLayers, onSketch, onAttachSketch, onState, onGroup, onUngroup, onRestore, head }: ClipPanelProps) {
export function ClipPanel({ c, count, lock, durRef, onUpdate, onMove, onRemove, onDuplicate, onOpen, onReframe, onLayers, onShotLayers, onSketch, onAttachSketch, onState, onGroup, onUngroup, onRestore, head }: ClipPanelProps) {
const i = c.index;
const seq = c.kind === "photo" ? c.seq : null;
const inTotal = c.kind === "photo" && c.group?.mode === "total";
Expand Down Expand Up @@ -960,7 +967,7 @@ export function ClipPanel({ c, count, lock, durRef, onUpdate, onMove, onRemove,
{onLayers && <Button kind="secondary" size="sm" icon="layers-outline" kbd="C" onClick={onLayers}>{c.ownLayers.length ? `Layers · ${c.ownLayers.length}` : "Layers"}</Button>}
</div>
)}
{c.group && seq && onGroup && <SequenceSection c={c} onGroup={onGroup} onUngroup={onUngroup} onRestore={onRestore} />}
{c.group && seq && onGroup && <SequenceSection c={c} onGroup={onGroup} onUngroup={onUngroup} onRestore={onRestore} onShotLayers={onShotLayers} />}
{holdTime}
<Field label="Shows" as="div">
<Select label="Shows" width="100%" value={c.clip.step_id ?? ""} onChange={pickStep}
Expand Down Expand Up @@ -1046,7 +1053,7 @@ export function TimelineRail({ list, selectedId, onSelect, onCreate, onContext }

/** Whether an inline tool applies to a clip (re-frame needs a camera photo, a sketch a spot without a photo). */
export const toolFits = (tool: EditKind, c: ResolvedClip) =>
tool === "sketch" ? c.kind !== "photo" : c.kind === "photo" && (tool === "layers" || c.photo.source === "camera");
tool === "sketch" ? c.kind !== "photo" : c.kind === "photo" && (tool === "layers" || tool === "shot-layers" || c.photo.source === "camera");

/**
* Restores the URL's cut position once on mount, then keeps it in the URL 300 ms after the playhead
Expand Down
4 changes: 2 additions & 2 deletions apps/studio/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@ export const LIBRARY_SECTIONS: readonly LibrarySection[] = ["projects", "fields"
*/
import { META_FIELDS, type MetaField } from "./MetadataPage";

export type CutTool = "reframe" | "layers" | "sketch";
export type CutTool = "reframe" | "layers" | "shot-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 === "layers" || tool === "sketch" ? tool : null;
const ok = tool === "reframe" || tool === "layers" || tool === "shot-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 };
}
Expand Down
3 changes: 2 additions & 1 deletion apps/studio/src/shortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,8 @@ export const SHORTCUTS = [
{ scope: "timeline", action: "panel", keys: ["n"], label: "Shot browser ⇄ clip panel" },
{ scope: "timeline", action: "placeholder", keys: ["p"], label: "Placeholder clip" },
{ scope: "timeline", action: "reframe", keys: ["r"], label: "Re-frame the clip on the stage" },
{ scope: "timeline", action: "compose", keys: ["c"], label: "Draw the clip's overlay (or sketch it)" },
{ scope: "timeline", action: "compose", keys: ["c"], label: "The clip's own layers (or its sketch)" },
{ scope: "timeline", action: "shotLayers", keys: ["Shift+C"], label: "The shot's layers (sequence-wide)" },
{ scope: "timeline", action: "open", keys: ["Enter"], label: "Open the clip's shot" },
{ scope: "timeline", action: "range", keys: ["Shift+Click"], info: true, display: "⇧ click", label: "Select a range of clips (right-click: Make a sequence…)" },
{ scope: "timeline", action: "newTimeline", keys: ["Shift+N"], label: "New timeline" },
Expand Down
3 changes: 3 additions & 0 deletions docs/features/composition.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ their bakes.
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.
- **Sequence layers** (`Shift+C`, in a block's Sequence section): the **shot's** layers edited inline on the
timeline stage, the same list as the Compose stage. A change shows on every clip of the shot at once. Shows on
offers Every step, and This step when the clip shows a stored step; chosen steps are picked in the Compose stage.
- **Shift-click** range → **Make a sequence…**, and **Extract as own shot…** in the clip menu, as above. Details in
[timeline](timeline.md).

Expand Down
Loading
Loading