From b70c13dc0fb07d28c2dbead183a3e88b8b31d7b3 Mon Sep 17 00:00:00 2001 From: bubulux Date: Tue, 27 Oct 2026 14:44:12 +0100 Subject: [PATCH] Timeline: edit the shot's sequence layers inline (#275) A block's Sequence section and Shift+C open the shot's layers on the timeline stage, with the same list and settings as the Compose stage; every clip of the shot follows at once. Co-Authored-By: Claude Opus 5.5 --- apps/studio/src/ClipEditing.tsx | 94 ++++++++++++++++++++++++++----- apps/studio/src/Timeline.tsx | 3 +- apps/studio/src/TimelineParts.tsx | 15 +++-- apps/studio/src/router.ts | 4 +- apps/studio/src/shortcuts.ts | 3 +- docs/features/composition.md | 3 + docs/features/timeline.md | 4 +- docs/shortcuts.md | 3 +- 8 files changed, 105 insertions(+), 24 deletions(-) diff --git a/apps/studio/src/ClipEditing.tsx b/apps/studio/src/ClipEditing.tsx index 3016788..60f3166 100644 --- a/apps/studio/src/ClipEditing.tsx +++ b/apps/studio/src/ClipEditing.tsx @@ -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"; @@ -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; @@ -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(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]); @@ -128,8 +144,8 @@ 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; + 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; } @@ -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; @@ -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", { @@ -284,6 +300,58 @@ export function useClipEditing({ timeline, clips, presets, onChange }: Options): ); + } 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 ? ( + 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)} extra={own} className="compose__photo" />} /> + ) : ; + // "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 ( +
+ Shows on + { if (k === "all") sdocs.update(l.id, { range: null }); else if (k === "this" && here) sdocs.update(l.id, { range: [here] }); }} options={options} /> + {l.space === "photo" ? "Anchored to a photo: only on that photo's steps. " : ""}Pick single steps in the shot's Compose stage. +
+ ); + }; + panel = ( + + {done} + + {drawingLayer &&
} +
+ The layers of “{shotTitle(cl.shot)}”: every clip of the shot shows them, here and everywhere else. + {sdocs.loading ? : setLayerSel(id === FOOTAGE ? null : id)} footage="Footage" badge={badge} />} +
+ + +
+
+ {picked && ( + sdocs.update(picked.id, { space, photo_id: space === "photo" ? cl.photo.id : null })}> + {picked.kind === "drawing" && } + + )} +
+
+ ); } else if (loading || !doc) { stage =
; panel = {done}; diff --git a/apps/studio/src/Timeline.tsx b/apps/studio/src/Timeline.tsx index ddfd9a5..4c32099 100644 --- a/apps/studio/src/Timeline.tsx +++ b/apps/studio/src/Timeline.tsx @@ -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. @@ -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 ? 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))} diff --git a/apps/studio/src/TimelineParts.tsx b/apps/studio/src/TimelineParts.tsx index a314de0..7873edb 100644 --- a/apps/studio/src/TimelineParts.tsx +++ b/apps/studio/src/TimelineParts.tsx @@ -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; @@ -848,7 +850,7 @@ 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; onGroup: (g: ClipGroup) => void; onUngroup?: () => void; onRestore?: () => void }) { +function SequenceSection({ c, onGroup, onUngroup, onRestore, onShotLayers }: { c: Extract; 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); @@ -856,6 +858,11 @@ function SequenceSection({ c, onGroup, onUngroup, onRestore }: { c: Extract
Step {seq.i + 1} of {seq.n} in this block{missing > 0 ? ` · ${missing} removed` : ""}
+ {onShotLayers && ( +
+ +
+ )} onGroup({ ...g, mode })} options={[{ id: "each", label: "Per photo" }, { id: "total", label: "Total" }]} /> @@ -885,7 +892,7 @@ function SequenceSection({ c, onGroup, onUngroup, onRestore }: { c: Extract{c.ownLayers.length ? `Layers · ${c.ownLayers.length}` : "Layers"}} )} - {c.group && seq && onGroup && } + {c.group && seq && onGroup && } {holdTime}