diff --git a/clips/migrations/0012_sound_label.py b/clips/migrations/0012_sound_label.py deleted file mode 100644 index 7baee07..0000000 --- a/clips/migrations/0012_sound_label.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.2.17 on 2026-10-01 04:38 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('clips', '0011_occurrence_schema'), - ] - - operations = [ - migrations.AddField( - model_name='sound', - name='label', - field=models.CharField(blank=True, help_text='what a person called it; the filename when empty. Separate from `filename` because the name on disk is a fact about the upload and renaming must not rewrite it', max_length=200), - ), - ] diff --git a/clips/models.py b/clips/models.py index 7b56a99..6933782 100644 --- a/clips/models.py +++ b/clips/models.py @@ -60,12 +60,6 @@ class Sound(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) blob = models.ForeignKey(Blob, on_delete=models.PROTECT, related_name="sound_for") filename = models.CharField(max_length=255) - label = models.CharField( - max_length=200, blank=True, - help_text="what a person called it; the filename when empty. Separate " - "from `filename` because the name on disk is a fact about the " - "upload and renaming must not rewrite it", - ) duration = models.FloatField(help_text="seconds, as ffprobe reports it") created = models.DateTimeField(auto_now_add=True) diff --git a/clips/tests/test_api.py b/clips/tests/test_api.py index 2d58317..6925190 100644 --- a/clips/tests/test_api.py +++ b/clips/tests/test_api.py @@ -657,64 +657,6 @@ class FootageTests(TestCase): with self.assertRaisesMessage(CommandError, "refusing an inaccurate footage"): call_command("ingest_bundle", str(root), stdout=StringIO()) - def test_footage_can_be_renamed_and_falls_back_when_cleared(self): - """A LABEL IS THE ONE FIELD A CLIENT MAY WRITE ON FOOTAGE. The rest is a - description of bytes that are content-addressed and immutable, so a - rename that could reach `frames` or `digest` would let the pool's name - for a clip contradict the clip.""" - footage = self.ingest(self.bundle()) - self.assertEqual("IMG_8608.MOV", self.client.get( - f"/api/footage/{footage.id}").json()["label"]) - - renamed = self.client.patch(f"/api/footage/{footage.id}", - json.dumps({"label": " the long take "}), - content_type="application/json") - self.assertEqual(200, renamed.status_code, renamed.content) - self.assertEqual("the long take", renamed.json()["label"]) - # On the row, so every project listing this footage sees the new name. - footage.refresh_from_db() - self.assertEqual("the long take", footage.label) - self.assertEqual("the long take", - self.client.get("/api/footage").json()["footage"][0]["label"]) - - # Cleared gives back the name it was ingested under rather than nothing. - cleared = self.client.patch(f"/api/footage/{footage.id}", - json.dumps({"label": ""}), - content_type="application/json") - self.assertEqual("IMG_8608.MOV", cleared.json()["label"]) - self.assertEqual(3, Footage.objects.get().frames) - - def test_a_rename_that_names_no_label_is_refused(self): - footage = self.ingest(self.bundle()) - refused = self.client.patch(f"/api/footage/{footage.id}", - json.dumps({"frames": 900}), - content_type="application/json") - self.assertEqual(400, refused.status_code) - self.assertEqual("a rename needs a label", refused.json()["error"]) - self.assertEqual(3, Footage.objects.get().frames) - - def test_a_sound_is_renamed_without_losing_the_name_it_arrived_as(self): - """`filename` is a fact about the upload and `label` is what a person - called it, which is why renaming does not write over the first one.""" - digest, size = blobs.write_stream([b"RIFF....WAVEfmt "]) - blob = Blob.objects.create(digest=digest, size=size, media_type="audio/wav") - sound = Sound.objects.create(blob=blob, filename="rec0012.wav", duration=2.5) - - renamed = self.client.patch(f"/api/sounds/{sound.id}", - json.dumps({"label": "arthur, line 4"}), - content_type="application/json") - self.assertEqual(200, renamed.status_code, renamed.content) - self.assertEqual("arthur, line 4", renamed.json()["label"]) - self.assertEqual("rec0012.wav", renamed.json()["filename"]) - sound.refresh_from_db() - self.assertEqual("rec0012.wav", sound.filename) - self.assertEqual("arthur, line 4", sound.label) - - self.client.patch(f"/api/sounds/{sound.id}", json.dumps({"label": " "}), - content_type="application/json") - self.assertEqual("rec0012.wav", - self.client.get(f"/api/sounds/{sound.id}").json()["label"]) - def test_the_footage_list_does_not_carry_every_url(self): # A list of takes should not be a list of six hundred URLs each. self.ingest(self.bundle()) diff --git a/clips/views.py b/clips/views.py index 11c785e..44edd2d 100644 --- a/clips/views.py +++ b/clips/views.py @@ -226,31 +226,10 @@ def sources(request): def _sound_json(row): - return {"id": str(row.id), "label": row.label or row.filename, - "filename": row.filename, "duration": row.duration, + return {"id": str(row.id), "label": row.filename, "duration": row.duration, "audio": f"/blob/{row.blob_id}"} -def _relabel(request, row): - """PATCH one asset's display name. - - A LABEL IS THE ONLY FIELD EITHER ROW LETS A CLIENT WRITE, and the body is - read for that key alone. Footage is content-addressed and its frame count, - rate and digest are facts about the bytes; an endpoint that merged whatever - it was sent would let a rename quietly contradict them. Blank clears it, - which puts the row back to the name it was uploaded under rather than - leaving it nameless. - """ - data = _body(request) - if "label" not in data: - raise Bad("a rename needs a label") - label = str(data["label"] or "").strip()[:200] - if label != row.label: - row.label = label - row.save(update_fields=["label"]) - return row - - @require_http_methods(["GET", "POST"]) def sounds(request): if request.method == "GET": @@ -273,18 +252,12 @@ def sounds(request): return JsonResponse({"error": str(exc)}, status=400) -@require_http_methods(["GET", "PATCH"]) +@require_http_methods(["GET"]) def sound_detail(request, sound_id): try: - row = Sound.objects.get(id=sound_id) + return JsonResponse(_sound_json(Sound.objects.get(id=sound_id))) except Sound.DoesNotExist: return JsonResponse({"error": "no such sound"}, status=404) - try: - if request.method == "PATCH": - row = _relabel(request, row) - except Bad as exc: - return _error(exc) - return JsonResponse(_sound_json(row)) def _extraction_json(row): @@ -394,17 +367,12 @@ def symbols(request): return JsonResponse({"symbols": rows}) -@require_http_methods(["GET", "PATCH"]) +@require_http_methods(["GET"]) def footage_detail(request, footage_id): try: footage = Footage.objects.select_related("audio", "video", "stream").get(id=footage_id) except Footage.DoesNotExist: return JsonResponse({"error": "no such footage"}, status=404) - try: - if request.method == "PATCH": - footage = _relabel(request, footage) - except Bad as exc: - return _error(exc) return JsonResponse(_footage_json(footage)) diff --git a/docs/correction-authoring-plan.md b/docs/correction-authoring-plan.md deleted file mode 100644 index 5823fc5..0000000 --- a/docs/correction-authoring-plan.md +++ /dev/null @@ -1,232 +0,0 @@ -# Correction authoring implementation plan - -Written against `2f1c9b9` (2026-09-30), following the lane handoff in -`7a54bfc`. Implemented on `codex/correction-authoring`; this now records the -scope and acceptance criteria of that implementation. -Read [lane-handoff.md](lane-handoff.md) and the correction section of -[lane-model.md](lane-model.md) first. Their ownership and document rules remain -the foundation. The choices below settle the first implementation's scope. - -## Outcome - -A person can select a lane or cel, specify a range, and apply Constant -adjustment, Ramp, or Return motion to rotation or position. The result is one -correction layer and one undo step. It works from either timing view. A lane -correction crosses drawing boundaries; a cel correction travels with its cel. -Regeneration preserves the hand work and presents incompatible layers for an -explicit decision. Frames outside the support evaluate exactly as before. - -Finish this vertical slice before adding more property types or gestures. -Numeric range fields and an Apply button are sufficient for this pass. Dragging -a range or manipulating a peak on the stage can later issue the same command. - -## 1. Fix sheet targeting first - -In `ui/timeline.cljs`, `cel-sheet` currently drops each lane row's `:select`. -An occupied cell selects its cel; a gap only seeks, leaving the previous target -selected. Thus clicking lane B's gap after selecting lane A can send an insert -or overwrite to A. - -Carry the row's complete selection address into its column and gap cells. -An occupied cell selects its cel; a gap selects its lane. Make the column header -select the lane too: this is the explicit way to author across drawings. -Preserve full paths, not just `(peek path)`, as view identity. Keep seek and -selection dispatch order deterministic. - -Add a two-lane browser case: select A, click a gap in B, overwrite, assert that -only B changes, and undo once. Add a header-selection assertion. Retain the -existing occupied-cell hold test. Do not redesign sheet rendering in this step. - -## 2. Make stack compatibility consistent - -There is a concrete discrepancy at this HEAD: - -- `channel/problems` uses `stack-conflict`, accounting for prior replacements. -- `channel/conflicts` and `flow/regenerate.cljs`'s `rebased` use - `conflict-with`, comparing an offset directly with the base. - -A two-component base, a covering three-component replacement, then a -three-component offset is valid and evaluates correctly, but the latter paths -can report or mark that offset incompatible. Conversely a replacement can make -an offset incompatible even when it fits the original base. - -Extract one ordered-stack compatibility operation and use it for validation, -conflict discovery, regeneration, and resolution. Keep `conflict-with` if useful -for the narrower question its name/docstring describe. Do not use it alone to -decide whether a stacked layer is applicable. - -Compute compatibility against the values that can actually reach a layer over -its support. Partition at overlapping support boundaries if needed: two adjacent -replacements can jointly cover an offset even though neither covers it alone. -An empty replacement channel does not supply a value and must not erase the -possible input shape. Preserve the evaluator's absence behavior. Explicitly -marked conflicts are skipped, so later layers must be checked against the stack -that actually runs. During regeneration, recompute compatibility in order, using -each preceding layer's resulting active/conflicted state. Preserve IDs, values, -support, and order; update compatibility reasons without dropping hand work. - -Use structural shape reasoning for dense data rather than requiring every block -to be sampled. If unknown shape or missing samples limit what can be proven, -retain the current absence contract and document that limit; do not claim an -unconditional proof of runtime safety from incomplete metadata. - -Tests: covering replacement of a different shape; partial coverage; adjacent -covering replacements; empty replacement; inactive conflicted replacement; -regeneration changing base shape; and the same cases through cursor evaluation. -Assert that an accepted compatible stack is not listed as a conflict, and that -an incompatible regenerated layer remains persisted but is skipped. - -## 3. Pure correction commands - -Add `frontend/src/arthur/domain/correction.cljs`. It owns authoring and resolving -corrections; `channel.cljs` continues to own evaluation and compatibility. -Suggested API (names may follow repository conventions): - -```clojure -(add clip sid node-id channel-path - {:id layer-id :support [a b] :motion :return - :start 0 :peak angle :peak-frame p}) -(remove-layer clip sid node-id channel-path layer-id) -(retry-layer clip sid node-id channel-path layer-id) -``` - -Return `{:clip updated :selection node-id}` or `{:refused reason}`. IDs come -from the event caller (`random-uuid`), never from the pure command. Reject a nil -ID or one already used within that channel stack. Address layers by the full -symbol/node/channel/layer tuple; no global layer registry is needed. - -Resolve the base via `node/channels`, which supplies defaults. A lane with no -explicit rotation channel already has a zero rotation; materialize that channel -with its new `:over`. Preserve every existing base field, generated provenance, -and previous layer. Never route this through a setter that bakes the correction -into base keys. Append to the ordered stack and validate the resulting document. -Do not run correction edits through `lane/finish`, whose extent policy belongs -to cel arrangement. Use `clip/problems` for the candidate document instead. - -First authoring properties: `[:xform :rot]` (scalar radians) and `[:xform :pos]` -(two numeric components). First blend operation: `:offset`. UI labels must say -offset/delta, since a target offset of 20 degrees does not mean an absolute -rotation of 20 degrees. Keep existing `:replace` evaluation and loaded stacks; -there is no new replacement-authoring UI in this slice. - -All command support endpoints are finite integer OWNER frames, `[a b)`, with -`a < b`. Do not ban negative owner frames merely because displayed shot frames -start at zero. Validate all supplied values for finite numbers and exact shape. -Refuse unknown targets, unsupported properties/motions, malformed ranges, and -incompatible stacks with useful messages. Refusal must not mutate store/history. - -Motion construction uses existing channels only: - -| Command | Values | Minimum samples | -| --- | --- | --- | -| Constant adjustment | `(ch/framed delta)` | 1 | -| Ramp | `(ch/keyed {a start, (dec b) end} :linear)` | 2 | -| Return motion | `(ch/keyed {a start, p peak, (dec b) start} :linear)` | 3 | - -For Return, require integer `a < p < b-1`. Default the UI peak to -`a + floor((b-a-1)/2)`; on an even-length range the earlier middle sample wins. -Expose the peak frame so this is visible and adjustable. Never place an endpoint -at `b`: it is outside the selected samples. `[10 13)` with start 0 and peak 0.5 -must yield offsets `0, 0.5, 0` at 10, 11, 12. Support controls the boundary; -there is no need to insert zero keys into the base before/after it. - -## 4. Owner and range UI - -Add a Corrections section to the right pane (`ui/params.cljs`), extracting a -`ui/corrections.cljs` component if that keeps the pane readable. Provide an -explicit target readout (symbol, lane or cel), Rotation/Position, motion choice, -From/Through fields, relevant value fields, peak frame for Return, and Apply. -Offer a selected cel's owning lane as an explicit target choice. Do not silently -promote a cel edit to a lane edit. Shared drawing content is outside this first -UI; it has different sharing consequences. - -For this first pass the range fields explicitly read **owner frames**, with -inclusive From/Through converted to `[from, through+1)`. This is a deliberate -UI scope choice, not a claim that a displayed shot range and owner range are -interchangeable. It lets nested and retimed owners be addressed without an -unproven range conversion. Match the existing zero-based numbering and show the -owner beside the range. The handoff must record that displayed-range dragging -is still outstanding. - -Use an explicit three-sample initial draft in owner coordinates; for a cel, -prefer its span start where it is integral. Show the range, allow adjustment, -and do not extend a cel or shot to make the correction visible. Reset the draft -when the target changes. Rotation is shown in degrees and converted to radians -at the event boundary, following the existing inspector convention. Position -uses x/y inputs in the owner's transform coordinates. - -Draft inputs must not write document state, start history groups, or invoke the -existing inspector `number-input`'s hold/settle behavior. Apply dispatches one -event; success uses `edit/transaction` once and preserves the selected node's -full address. Do not use a layer ID as node selection. Validate again at Apply, -since the target/document may have changed since the draft was opened. - -If adding a “use playhead” convenience, prove its mapping separately. The clock -of a cel's transform is its own node clock, not the drawing source clock selected -by `:playback`. `nest/inside` on the complete cel path enters the source and is -therefore the wrong shortcut. The existing `selection-frame` resolves only the -owning symbol; node/ancestor time conversion remains necessary. Floors, loops, -and nonintegral mappings must never silently snap an authored range. Omit this -convenience rather than expanding the first pass into a new timing system. - -## 5. Conflict actions and regeneration proof - -Show a document-wide list from `clip/conflicts` in the pane, including symbol, -node, property, layer ID, and reason. Keep it accessible even when a different -node is selected. Also list the selected target's layers in stack order with -support, motion values, and status; do not require a new persisted motion label. - -Provide Remove correction and Retry compatibility. Remove is explicit and -undoable; Retry rechecks the complete candidate stack and clears a conflict only -when it is valid. If retry would invalidate a downstream offset, refuse and say -why. Similarly, removing a replacement that makes a later offset invalid must -refuse, rather than commit an invalid document or silently remove more layers. -A retry that changes nothing must not manufacture an undo step. - -These are minimal resolution actions, not topology remapping. Automatic geometry -remapping, reordering layers, editing arbitrary stored vector values, and resolving -removed targets are separate work. Preserve all existing generated-data behavior. - -Exercise the actual `flow/regenerate.cljs` entry points in integration tests: -author a correction, regenerate compatible base data, and confirm the correction -survives with its ID/support/values intact and affects the new base. Then change -topology on a geometry fixture and assert persisted actionable conflicts. The -geometry fixture can use an existing layer directly; geometry authoring is not -required to expose and resolve a conflict already present in a document. - -## 6. Verification and completion - -Use focused tests that establish observable promises: - -- Domain: each motion's sample values; refusal on short ranges/nonfinite values; - default channel materialization; unchanged base and prior layers; duplicate ID. -- Evaluation: compare before/after on every frame outside support, including - neighboring interpolated frames. Cursor/spec agreement in nonmonotonic order. -- Ownership: a lane Return crosses a drawing boundary; a cel correction moves - with the cel and survives split/trim. Neither alters another use of its drawing. -- Sampling: picture-rate/pose selection changes the generated base frame while - the authored correction still reads owner time. Keep HEAD's regression tests. -- Events: one Apply is one undo step; undo/redo restores complete layer data; - refusal leaves clip/history unchanged; stale target refuses; selection survives. -- Persistence: leaf and Transit round trips retain layers, order, IDs, conflicts. -- Browser: both views can select a target and apply the same correction using - actual controls. Assert evaluated results and history, not only a layer count. - Include the two-lane gap-targeting case from step 1. -- Regeneration and conflict actions: use the real flow and test undoable removal, - valid retry, invalid retry, and removal that would break a downstream layer. - -Run the suites documented in `lane-handoff.md`: CLJS tests, lane and take browser -flows, Django tests, and optimized frontend build. Restore the dev app bundle -after the release build. Note that `take.mjs` writes a local project. Report -actual results and any unrun checks; do not copy previous test counts as evidence. - -Suggested commit sequence: sheet targeting; consistent stack compatibility; -pure correction commands; pane/events plus browser proof; updated handoff. -Keep each commit coherent and tested. No schema version bump should be needed: -the layers already have a persisted representation. - -Update `lane-handoff.md` and `lane-model.md` with what shipped, the owner-frame -range UI limitation, conflict actions available, and verified test counts. Done -means a person can author and undo the correction, regenerate its base, and see -either their preserved edit or a useful conflict. A constructor without reachable -controls, or controls without that regeneration proof, does not finish this work. diff --git a/docs/lane-handoff.md b/docs/lane-handoff.md index 66bec20..0357e74 100644 --- a/docs/lane-handoff.md +++ b/docs/lane-handoff.md @@ -1,11 +1,10 @@ # Lane and cel handoff -Status (2026-09-30): the lane model is implemented through its commands, its -first two views, and correction authoring. Cels are ordinary nodes with their -own playback clock; the timeline draws them as one row and the cel sheet draws -frames down and lanes across. Both views issue the same commands. Rotation and -position corrections can be authored as Constant, Ramp, or Return motion on a -lane or cel, survive regeneration, and expose conflicts for removal or retry. +Status (2026-09-30): the lane model is implemented through its commands and its +first two views. Cels are ordinary nodes with their own playback clock, the +timeline draws them as one row, the cel sheet draws frames down and lanes across, +and both views issue the same commands. Correction layers evaluate and survive +regeneration. What is missing is the commands that make a correction. The commits beginning at `3d3c1bb` are the argument for the model and are worth reading before touching what they did — they are the design record, more than @@ -88,18 +87,18 @@ decision, not a cleanup. ## Next steps, in order -The implemented correction slice and its remaining UI limits are recorded in -[Correction authoring](correction-authoring-plan.md). - -1. **Slip source and retime.** Both have real design questions open and the doc +1. **The commands that make a correction** — Constant adjustment, Ramp, Return + motion over a selected range, per `lane-model.md`. The evaluator is done and + has no opinion about how a range or a motion shape is chosen, which is now a + view question. A panel also needs to offer `clip/conflicts` for resolution. + Note the one open question: a correction needs a stable `:id` from somewhere, + and cel ids come from the caller because this namespace is pure. +2. **Slip source and retime.** Both have real design questions open and the doc says to refuse rather than approximate: retime needs a defined warp and interpolation behaviour, and is not moving keys whose numbers happen to fall inside a selection. -2. **Deleting reused content.** Reference discovery exists (`node/sources`, +3. **Deleting reused content.** Reference discovery exists (`node/sources`, `clip/places`, `clip/contains-symbol?`); the policy does not. -3. **Displayed-range correction gestures.** The first correction panel asks for - explicit owner frames. Dragging a range in a retimed/nested view still needs - a proved mapping; do not make it snap through floors or loops. 4. **Collaboration.** `lane-model.md` is explicit that one leaf per channel does NOT solve two people editing different keys of the same channel. No conflict policy exists for that. @@ -127,12 +126,6 @@ The implemented correction slice and its remaining UI limits are recorded in - **Generated sampling applies to the base, not the hand correction.** Picture rate and pose selection may choose an earlier generated frame; correction support and values still read the node's current authored frame. -- **Correction commands live in `domain/correction.cljs`.** IDs come from the - event caller; the pure command materializes default transform channels, - appends one layer, and validates the complete document. The inspector authors - rotation and position offsets in explicit owner frames. One Apply is one undo - step. `channel/reconcile` is the shared ordered-stack compatibility rule used - by validation, conflict reporting, and regeneration. - **Two test patterns worth copying.** `the-cursor-agrees-with-the-specification-in-any-frame-order` holds the optimized cursor to `value-at` in forward, backward and random order — add a case to it for any new channel shape. And `drawn` in `lane_test` @@ -172,7 +165,7 @@ The implemented correction slice and its remaining UI limits are recorded in From `frontend/`: - npx shadow-cljs compile test && node out/node-tests.js # 437 tests, 5,804 assertions + npx shadow-cljs compile test && node out/node-tests.js # 429 tests, 5,767 assertions npx shadow-cljs compile app # the bundle Django serves npx shadow-cljs release app # then `compile app` again — see above diff --git a/docs/lane-model.md b/docs/lane-model.md index 1593170..6bdd9f3 100644 --- a/docs/lane-model.md +++ b/docs/lane-model.md @@ -2,8 +2,8 @@ Revised 2026-09-30. Target design. Cel ownership, source playback, the content and cel commands, placement anywhere in a lane, overwrite, a one-row cel -strip, a frame-down cel sheet, correction evaluation, and correction authoring -for rotation and position are implemented. Retiming commands are not. +strip, a frame-down cel sheet and the correction-layer evaluator are implemented; +the commands that produce a correction and the retiming commands are not. See the status note under [Proof obligations](#proof-obligations-and-implementation-order). @@ -536,23 +536,17 @@ retime, and deleting reused content. A lane cannot hold AUDIO cels — `lane-pro requires visual ones, though this document says a lane may hold either and should reject only a mixture. -`domain/correction.cljs` now produces Constant adjustment, Ramp, and Return -motion layers for rotation and position. The inspector exposes them on a selected -lane or cel using an explicit range in that owner's frames; this deliberately -leaves displayed-range dragging through nested or retimed owners for later. One -Apply is one undo step. Conflicted layers are listed, can be removed, and can be -retried when the complete ordered stack is compatible again. Validation, -conflict reporting, and regeneration share that ordered-stack rule, including -coverage by adjacent replacement layers. Slip source and retime are still not -implemented; a refusal is the current behavior where the model demands an -explicit choice nobody has made yet. +What is NOT implemented is a command that produces a layer — the doc's Constant +adjustment, Ramp and Return motion — and with it the question of how a view +offers those three over a selected range, and how it offers a conflict for +resolution. Slip source and retime are also not implemented; a refusal is the +current behavior where the model demands an explicit choice nobody has made yet. The cel sheet is the same projected cels and selection addresses with its axes turned: frames down and lanes across, so commands selected there and in the -timeline have identical targets; a gap selects its column's lane rather than -retaining a stale selection from another column. The suite stands at 437 tests and 5,804 +timeline have identical targets. The suite stands at 429 tests and 5,767 assertions, with `frontend/test/browser/lane.mjs` driving the editor through create, hold, overflow, undo, reuse, make unique, duplicate, split, insert, -trim, move, blank, correction authoring, and two-lane sheet targeting. Rewrite tests that encode superseded +trim, move and blank. Rewrite tests that encode superseded behavior rather than preserving behavior to keep them green. Build small adversarial documents and test their domain operations before diff --git a/frontend/src/arthur/domain/channel.cljs b/frontend/src/arthur/domain/channel.cljs index 9871b17..f0fdfbc 100644 --- a/frontend/src/arthur/domain/channel.cljs +++ b/frontend/src/arthur/domain/channel.cljs @@ -178,61 +178,27 @@ (when (= :offset (:op l)) (shape-conflict (value-shape base) (value-shape (:values l))))) -(defn- support-of [l] - (let [s (:support l)] - (when (and (vector? s) (= 2 (count s)) - (every? number? s) (< (first s) (second s))) - s))) - -(defn stack-conflict - "Why layer `i` can encounter a value of the wrong shape after the active - layers before it, or nil. - - Replacement coverage is considered at every interval boundary. This matters - when adjacent replacements jointly cover an offset: neither covers its whole - support, but the base can never reach it. A conflicted replacement is skipped, - exactly as the evaluator skips it." - [ch i] - (let [l (nth (:over ch) i nil)] - (when (and (= :offset (:op l)) (support-of l)) - (let [[a b] (support-of l) - prior (take i (:over ch)) - cuts (->> prior - (keep support-of) - (mapcat identity) - (filter #(< a % b)) - (into [a b]) - distinct sort) - ;; Shape at a point is the last active, nonempty replacement's - ;; shape, or the base shape when no replacement supplies a value. - at (fn [f] - (or (last (keep (fn [p] - (let [s (support-of p) - v (value-shape (:values p))] - (when (and (= :replace (:op p)) - (not (:conflict p)) v s - (covers? s f)) - v))) - prior)) - (value-shape ch))) - shapes (into #{} (map (fn [[x y]] (at (/ (+ x y) 2)))) - (partition 2 1 cuts)) - v (value-shape (:values l))] - (some #(shape-conflict % v) shapes))))) - -(defn reconcile - "Recheck an ordered layer stack against this channel's base. - - Old conflict marks are findings from an earlier base, so they are cleared and - recomputed in order. A newly conflicted replacement is then invisible to the - layers after it, matching evaluation. Nothing is dropped or reordered." - [ch] - (let [layers (mapv #(dissoc % :conflict) (:over ch))] - (reduce (fn [out l] - (let [candidate (assoc ch :over (conj out l)) - why (stack-conflict candidate (count out))] - (conj out (cond-> l why (assoc :conflict why))))) - [] layers))) +(defn- stack-conflict + "Why layer `i` can encounter a value of the wrong shape after the layers + before it. A replace covering all of this layer's support becomes the only + possible input; a partly overlapping replace adds another possible input." + [ch i l] + (when (and (= :offset (:op l)) + (vector? (:support l)) (= 2 (count (:support l)))) + (let [[a b] (:support l) + shapes (reduce + (fn [possible prior] + (let [[c d] (when (and (vector? (:support prior)) + (= 2 (count (:support prior)))) + (:support prior))] + (if (and c d (not (:conflict prior)) (= :replace (:op prior)) + (< a d) (< c b)) + (let [s (value-shape (:values prior))] + (if (and (<= c a) (<= b d)) #{s} (conj possible s))) + possible))) + #{(value-shape ch)} (take i (:over ch))) + v (value-shape (:values l))] + (some #(shape-conflict % v) shapes)))) (defn conflicts "Corrections on `ch` that cannot apply to its base, as `[{:id :why}]`. @@ -243,8 +209,8 @@ not load. `flow/regenerate` records one on the layer, a conflicted layer is not applied, and this is how a view finds them to offer." [ch] - (vec (for [[i l] (map-indexed vector (:over ch)) - :let [why (or (:conflict l) (stack-conflict ch i))] + (vec (for [l (:over ch) + :let [why (or (:conflict l) (conflict-with ch l))] :when why] {:id (:id l) :why why}))) @@ -592,7 +558,7 @@ (and (map? ch) (vector? (:over ch))) (into (for [[i l] (map-indexed vector (:over ch)) :when (not (:conflict l)) - :let [why (stack-conflict ch i)] + :let [why (stack-conflict ch i l)] :when why] (str "correction " (pr-str (:id l)) " " why))) diff --git a/frontend/src/arthur/domain/correction.cljs b/frontend/src/arthur/domain/correction.cljs deleted file mode 100644 index ba99948..0000000 --- a/frontend/src/arthur/domain/correction.cljs +++ /dev/null @@ -1,114 +0,0 @@ -(ns arthur.domain.correction - "Pure commands that author and resolve correction layers. - - Evaluation belongs to `channel`; this namespace only constructs a layer, - places it on its owning node, and refuses a document that would not be valid." - (:require [arthur.domain.channel :as ch] - [arthur.domain.clip :as clip] - [arthur.domain.node :as node])) - -(def ^:private supported-paths #{[:xform :rot] [:xform :pos]}) -(def ^:private motions #{:constant :ramp :return}) - -(defn- finite? [x] (and (number? x) (js/Number.isFinite x))) - -(defn- numeric-value? [v] - (or (finite? v) - (and (vector? v) (pos? (count v)) (every? finite? v)))) - -(defn- same-shape? [a b] - (or (and (number? a) (number? b)) - (and (vector? a) (vector? b) (= (count a) (count b))))) - -(defn- expected-value? [path v] - (case path - [:xform :rot] (finite? v) - [:xform :pos] (and (vector? v) (= 2 (count v)) (every? finite? v)) - false)) - -(defn- values-channel - [{:keys [motion support delta start end peak peak-frame]}] - (let [[a b] support] - (case motion - :constant (ch/framed delta) - :ramp (ch/keyed {a start, (dec b) end} :linear) - :return (ch/keyed {a start, peak-frame peak, (dec b) start} :linear) - nil))) - -(defn- invalid - [path {:keys [id support motion delta start end peak peak-frame]} existing] - (let [[a b] (when (and (vector? support) (= 2 (count support))) support) - samples (case motion :constant [delta] :ramp [start end] - :return [start peak] [])] - (cond - (nil? id) "a correction needs an ID" - (some #(= id (:id %)) existing) "the correction ID is already used on this channel" - (not (contains? supported-paths path)) "that property does not support correction authoring" - (not (contains? motions motion)) "choose constant, ramp, or return motion" - (not (and (integer? a) (integer? b) (< a b))) - "support must be an increasing [in out) of whole owner frames" - (not-every? numeric-value? samples) "correction values must be finite numbers" - (not-every? #(expected-value? path %) samples) - "correction values do not have the property's shape" - (and (= :ramp motion) (< (- b a) 2)) "a ramp needs at least two samples" - (and (= :return motion) (< (- b a) 3)) "return motion needs at least three samples" - (and (= :return motion) - (not (and (integer? peak-frame) (< a peak-frame (dec b))))) - "the return peak must be a whole owner frame inside both endpoints" - (and (#{:ramp :return} motion) (not (same-shape? start (if (= :ramp motion) end peak)))) - "motion endpoints must have the same shape"))) - -(defn- finish [candidate selection] - (if-let [why (first (clip/problems candidate))] - {:refused why} - {:clip candidate :selection selection})) - -(defn add - "Append one offset correction to a node channel. - - Support and value keys are in the selected node's own frames. Defaults are - materialized through `node/channels`, so correcting an unkeyed transform does - not need a special representation." - [document sid node-id path spec] - (let [n (get-in document [:symbols sid :nodes node-id]) - base (when n (get (node/channels n) path)) - existing (:over base) - why (cond - (nil? (clip/symbol document sid)) "the owning symbol does not exist" - (nil? n) "the correction target does not exist" - (nil? base) "the correction target has no such channel" - :else (invalid path spec existing))] - (if why - {:refused why} - (let [layer (ch/layer (:id spec) (:support spec) :offset (values-channel spec)) - corrected (update base :over (fnil conj []) layer) - candidate (assoc-in document [:symbols sid :nodes node-id :channels path] corrected)] - (finish candidate node-id))))) - -(defn remove-layer - "Remove one named layer, refusing when a later layer depended on its shape." - [document sid node-id path layer-id] - (let [at [:symbols sid :nodes node-id :channels path] - c (get-in document at) - layers (:over c)] - (cond - (nil? c) {:refused "the correction channel does not exist"} - (not-any? #(= layer-id (:id %)) layers) {:refused "the correction does not exist"} - :else (finish (assoc-in document at - (assoc c :over (vec (remove #(= layer-id (:id %)) layers)))) - node-id)))) - -(defn retry-layer - "Clear one recorded conflict when the complete resulting stack is valid." - [document sid node-id path layer-id] - (let [at [:symbols sid :nodes node-id :channels path] - c (get-in document at) - found (some #(when (= layer-id (:id %)) %) (:over c))] - (cond - (nil? found) {:refused "the correction does not exist"} - (nil? (:conflict found)) {:refused "the correction has no recorded conflict"} - :else - (finish (update-in document (conj at :over) - (fn [layers] - (mapv #(if (= layer-id (:id %)) (dissoc % :conflict) %) layers))) - node-id)))) diff --git a/frontend/src/arthur/events/footage.cljs b/frontend/src/arthur/events/footage.cljs index 32ff694..d353d29 100644 --- a/frontend/src/arthur/events/footage.cljs +++ b/frontend/src/arthur/events/footage.cljs @@ -394,57 +394,6 @@ ::choose (fn [db [_ id]] (assoc-in db [:footage :chosen] id))) -;; --------------------------------------------------------------------------- -;; renaming an asset -;; -;; A SERVER WRITE, NOT A DOCUMENT EDIT, and so not on the undo list. Footage and -;; sounds live beside projects rather than inside one — the pool's ALL ASSETS -;; folder is exactly that — so a label is shared by every project that uses the -;; row, and undoing an edit to this document must not reach out and rename -;; something another one is showing. -;; -;; Written through optimistically. The lists in app-db are what the pool draws -;; from; waiting for the round trip would leave the old name under the cursor for -;; as long as the request takes, and the failure is visible and recoverable — -;; `::failed` says so, and `::refresh` puts back whatever the server actually -;; holds. - -(defn- relabelled - "Replace one row's `:label` in a list held by id." - [rows id label] - (mapv #(cond-> % (= id (:id %)) (assoc :label label)) rows)) - -(rf/reg-fx - ::relabel! - (fn [{:keys [url label]}] - (-> (http/PATCH url #js {:label label}) - (.then (fn [_] - ;; Only a CLEARED label needs the answer. The server's fallback - ;; is the name the file was uploaded under, which this client - ;; cannot reconstruct — footage falls back to its source and a - ;; sound to its filename — so the one case the optimistic write - ;; cannot guess is the one case that re-lists. - (when (empty? label) (rf/dispatch [::refresh])))) - (.catch (fn [error] - (rf/dispatch [::failed (or (ex-message error) (str error))]) - (rf/dispatch [::refresh])))))) - -(rf/reg-event-fx - ::relabel - (fn [{:keys [db]} [_ kind id value]] - ;; `kind` is `:footage` or `:sound`: two resources with one field between - ;; them, and one event rather than two that differ by a path and a URL. - (let [label (string/trim (str value)) - [key url] (case kind - :footage [:available (str "/api/footage/" id)] - :sound [:sounds (str "/api/sounds/" id)] - [nil nil])] - (if (or (nil? id) (nil? key)) - {} - {:db (cond-> db - (seq label) (update-in [:footage key] relabelled id label)) - ::relabel! {:url url :label label}})))) - (rf/reg-event-db ::progress (fn [db [_ message]] (assoc-in db [:footage :status] message))) diff --git a/frontend/src/arthur/events/project.cljs b/frontend/src/arthur/events/project.cljs index 18a76bb..fe3f87b 100644 --- a/frontend/src/arthur/events/project.cljs +++ b/frontend/src/arthur/events/project.cljs @@ -601,23 +601,6 @@ (cond-> {:db db'} (= key :fps) (assoc ::pb/seek! [value (pb/frames db') frame])))))) -(rf/reg-event-db - ::rename-symbol - (fn [db [_ sid value]] - ;; A transaction, so one rename is one undo step: `edit/edit` alone would let - ;; a rename coalesce with whatever edit happened next. - ;; - ;; BLANK REMOVES THE NAME rather than storing an empty one. `clip/symbol-name` - ;; falls back to the id, so a symbol cleared of its name reads as `main` - ;; again instead of as a row with nothing on it — and the document carries no - ;; field it did not need. - (let [value (not-empty (str/trim (str value)))] - (if-not (clip/symbol (:clip (store/entry (:clip/current db))) sid) - db - (edit/transaction db #(if value - (assoc-in % [:symbols sid :name] value) - (update-in % [:symbols sid] dissoc :name))))))) - (rf/reg-event-db ::symbol-setting (fn [db [_ sid key value]] diff --git a/frontend/src/arthur/events/ui.cljs b/frontend/src/arthur/events/ui.cljs index 9c20019..37e45bb 100644 --- a/frontend/src/arthur/events/ui.cljs +++ b/frontend/src/arthur/events/ui.cljs @@ -6,7 +6,6 @@ there should not be one: an editor's own state is the cheapest thing in the app to change and the most expensive to have two copies of." (:require [arthur.domain.clip :as clip] - [arthur.domain.correction :as correction] [arthur.domain.gesture :as gesture] [arthur.domain.nest :as nest] [arthur.domain.node :as node] @@ -59,37 +58,6 @@ (assoc-in [:ui :selection] [:node sid (:selection result) (conj prefix (:selection result))]) (update :ui dissoc :lane-retry))))) -(defn apply-correction-command - "Commit one correction command while keeping the complete row address that - selected its owner. A refusal changes only the visible status." - [db result] - (if-let [why (:refused result)] - (assoc-in db [:project :status] why) - (-> db - (edit/transaction (constantly (:clip result))) - (update :project merge {:status "edited · unsaved"})))) - -(rf/reg-event-db - ::add-correction - (fn [db [_ sid id path spec]] - (let [clip (:clip (store/entry (:clip/current db)))] - (apply-correction-command - db (correction/add clip sid id path (assoc spec :id (random-uuid))))))) - -(rf/reg-event-db - ::remove-correction - (fn [db [_ sid id path layer-id]] - (let [clip (:clip (store/entry (:clip/current db)))] - (apply-correction-command - db (correction/remove-layer clip sid id path layer-id))))) - -(rf/reg-event-db - ::retry-correction - (fn [db [_ sid id path layer-id]] - (let [clip (:clip (store/entry (:clip/current db)))] - (apply-correction-command - db (correction/retry-layer clip sid id path layer-id))))) - (rf/reg-event-db ::new-lane (fn [db _] diff --git a/frontend/src/arthur/flow/regenerate.cljs b/frontend/src/arthur/flow/regenerate.cljs index 009249f..03e58a8 100644 --- a/frontend/src/arthur/flow/regenerate.cljs +++ b/frontend/src/arthur/flow/regenerate.cljs @@ -45,7 +45,12 @@ topology has resolved it." [old fresh] (if-let [over (seq (:over old))] - (assoc fresh :over (ch/reconcile (assoc fresh :over (vec over)))) + (assoc fresh :over + (mapv (fn [l] + (if-let [why (ch/conflict-with fresh l)] + (assoc l :conflict why) + (dissoc l :conflict))) + over)) fresh)) (defn- bases diff --git a/frontend/src/arthur/fx/http.cljs b/frontend/src/arthur/fx/http.cljs index f4e3650..1e5ac43 100644 --- a/frontend/src/arthur/fx/http.cljs +++ b/frontend/src/arthur/fx/http.cljs @@ -57,4 +57,3 @@ (defn POST [url body] (request! "POST" url body)) (defn POST-form [url body] (request! "POST" url body)) (defn PUT [url body] (request! "PUT" url body)) -(defn PATCH [url body] (request! "PATCH" url body)) diff --git a/frontend/src/arthur/subs/ui.cljs b/frontend/src/arthur/subs/ui.cljs index 76a35f2..4b97d3d 100644 --- a/frontend/src/arthur/subs/ui.cljs +++ b/frontend/src/arthur/subs/ui.cljs @@ -70,22 +70,6 @@ (when-let [pl (nest/placement clip st open (or path [id]) f)] (assoc pl :node n :bounds ((pick/bounds-of clip st n) (:frame pl)))))))) -(rf/reg-sub - ::settled-clip - :<- [::render/clip-id] - :<- [::render/paint-revision] - (fn [[id _] _] - ;; The document AS WRITTEN, not as a drag currently has it — the same choice - ;; `::render/sounds` makes, for a sharper version of the same reason. - ;; - ;; `::render/clip` yields a fresh document on every pointer move of a timeline - ;; slide so the stage can follow it. The media pool draws its symbols as - ;; RASTERISED PICTURES, so subscribing to that would re-resolve and re-encode - ;; every thumbnail in the pool thirty times a second, to show a change no - ;; thumbnail of a symbol has any way to show. Nothing the pool lists — - ;; a symbol's name, its length, what places it — moves during a drag. - (:clip (store/entry id)))) - (rf/reg-sub ::project-footage :<- [::render/clip-id] diff --git a/frontend/src/arthur/ui/canvas.cljs b/frontend/src/arthur/ui/canvas.cljs index 91fc948..681c1b2 100644 --- a/frontend/src/arthur/ui/canvas.cljs +++ b/frontend/src/arthur/ui/canvas.cljs @@ -43,34 +43,3 @@ img (image-data-for ctx el w h)] (raster/->rgba r palette-rgb 1 (.-data img)) (.putImageData ctx img 0 0))))) - -(defn ->png - "An indexed raster as a PNG data URL, expanded through `palette-rgb`. - - For a THUMBNAIL, which is the one picture in this app that is not the preview. - Two consequences, and both are departures from the rule at the top of this - namespace: - - An `` rather than the canvas itself, because a pool of them is a list that - rebuilds whenever anything in the document changes, and a data URL is a value - the caller can cache against the document it was drawn from — a canvas is an - element that has to be found and repainted. - - And therefore SMOOTHLY downscaled, because `image-rendering: pixelated` is a - rule about canvases. At a tenth of the stage's size nearest neighbour samples - one pixel in a hundred, and a drawing made of flat shapes a few pixels across - reduces to speckle; the silhouette is what makes a thumbnail recognisable, and - smoothing is what keeps it. The pixelated rule is load-bearing for the preview - that JUDGES the output. This is a picture to tell one name from another by." - [{:keys [w h] :as r} palette-rgb] - (let [el (js/document.createElement "canvas")] - (set! (.-width el) w) - (set! (.-height el) h) - ;; Deliberately not through `image-data-for`: that cache is keyed by element - ;; and this element is thrown away, so every thumbnail would leave a quarter - ;; of a megabyte in it that nothing can ever find again. - (let [ctx (.getContext el "2d") - img (.createImageData ctx w h)] - (raster/->rgba r palette-rgb 1 (.-data img)) - (.putImageData ctx img 0 0)) - (.toDataURL el "image/png"))) diff --git a/frontend/src/arthur/ui/params.cljs b/frontend/src/arthur/ui/params.cljs index af3f96d..6d4889e 100644 --- a/frontend/src/arthur/ui/params.cljs +++ b/frontend/src/arthur/ui/params.cljs @@ -235,139 +235,6 @@ [channel-control sid id path ch frame] [:dd (channel-state ch)])]))])])) -;; --------------------------------------------------------------------------- -;; corrections - -(defn- correction-initial [clip sid id] - (let [n (get-in clip [:symbols sid :nodes id]) - [a b] (or (:span n) [0 3]) - a (if (integer? a) a 0) - through (max a (min (dec (if (integer? b) b 3)) (+ a 2)))] - {:target id :path [:xform :rot] :motion :constant - :from a :through through :peak-frame (min (dec through) (inc a)) - :delta 0 :delta-x 0 :delta-y 0 - :start 0 :start-x 0 :start-y 0 - :end 0 :end-x 0 :end-y 0 - :peak 0 :peak-x 0 :peak-y 0})) - -(defn- draft-number [draft key label integer?] - [:label.inspector-field label - [:input {:type "number" :step (if integer? 1 "any") - :value (or (get @draft key) "") - :on-change (fn [e] - (let [s (.. e -target -value) - n ((if integer? js/parseInt js/parseFloat) s 10)] - (swap! draft assoc key (when-not (js/isNaN n) n))))}]]) - -(defn- value-inputs [draft prefix label] - (if (= [:xform :rot] (:path @draft)) - [draft-number draft prefix (str label " (degrees)") false] - [:<> - [draft-number draft (keyword (str (name prefix) "-x")) (str label " x") false] - [draft-number draft (keyword (str (name prefix) "-y")) (str label " y") false]])) - -(defn- correction-value [d prefix] - (if (= [:xform :rot] (:path d)) - (some-> (get d prefix) (* (/ js/Math.PI 180))) - [(get d (keyword (str (name prefix) "-x"))) - (get d (keyword (str (name prefix) "-y")))])) - -(defn- correction-spec [d] - (let [base {:support [(:from d) (when (number? (:through d)) (inc (:through d)))] - :motion (:motion d)}] - (case (:motion d) - :constant (assoc base :delta (correction-value d :delta)) - :ramp (assoc base :start (correction-value d :start) - :end (correction-value d :end)) - :return (assoc base :start (correction-value d :start) - :peak (correction-value d :peak) - :peak-frame (:peak-frame d)) - base))) - -(defn- correction-layers [clip sid id] - (for [[path c] (get-in clip [:symbols sid :nodes id :channels]) - l (:over c)] - {:path path :layer l})) - -(defn- correction-section [[sid selected-id selected]] - (let [clip @(rf/subscribe [::render/clip]) - parent (get-in clip [:symbols sid :nodes (:parent selected)]) - targets (cond - (node/lane? selected) [selected-id] - (node/lane? parent) [selected-id (:id parent)] - :else [])] - (when (seq targets) - (r/with-let [draft (r/atom (correction-initial clip sid selected-id))] - (let [target (:target @draft) - motion (:motion @draft) - conflicts (clip-domain/conflicts clip) - layers (correction-layers clip sid target)] - [section "corrections" - [:div.correction-grid - [:label.inspector-field "owner" - [:select {:value (or (first (keep-indexed #(when (= %2 target) %1) targets)) 0) - :on-change (fn [e] - (let [i (js/parseInt (.. e -target -value) 10) - id (nth targets i)] - (reset! draft (correction-initial clip sid id))))} - (doall (for [[i id] (map-indexed vector targets)] - ^{:key (str id)} - [:option {:value i} - (str (if (= id selected-id) "selected · " "lane · ") (brief id))]))]] - [:label.inspector-field "property" - [:select {:value (if (= [:xform :rot] (:path @draft)) "rotation" "position") - :on-change #(swap! draft assoc :path - (if (= "rotation" (.. % -target -value)) - [:xform :rot] [:xform :pos]))} - [:option {:value "rotation"} "rotation offset"] - [:option {:value "position"} "position offset"]]] - [:label.inspector-field "motion" - [:select {:value (name motion) - :on-change #(swap! draft assoc :motion (keyword (.. % -target -value)))} - [:option {:value "constant"} "constant"] - [:option {:value "ramp"} "ramp"] - [:option {:value "return"} "return"]]] - [:div] - [draft-number draft :from "from owner frame" true] - [draft-number draft :through "through owner frame" true] - (case motion - :constant [value-inputs draft :delta "offset"] - :ramp [:<> [value-inputs draft :start "start offset"] - [value-inputs draft :end "end offset"]] - :return [:<> [value-inputs draft :start "start offset"] - [value-inputs draft :peak "peak offset"] - [draft-number draft :peak-frame "peak owner frame" true]] - nil)] - [:div.row {:style {:margin-top "6px"}} - [:button {:on-click #(rf/dispatch [::ui/add-correction sid target - (:path @draft) (correction-spec @draft)])} - "apply correction"]] - (when (seq layers) - [:div.correction-list - (doall - (for [{:keys [path layer]} layers] - ^{:key (str path (:id layer))} - [:div.correction-item - [:span {:title (pr-str (:id layer))} - (str (str/join " " (map name path)) " · " - (pr-str (:support layer)) - (when (:conflict layer) " · conflict"))] - (when (:conflict layer) - [:button {:on-click #(rf/dispatch [::ui/retry-correction - sid target path (:id layer)])} - "retry"]) - [:button {:on-click #(rf/dispatch [::ui/remove-correction - sid target path (:id layer)])} - "remove"]]))]) - (when (seq conflicts) - [:div.correction-conflicts - [:div.dim "document conflicts"] - (doall - (for [{:keys [symbol node channel id why]} conflicts] - ^{:key (str symbol node channel id)} - [:div {:title why} (str (brief node) " · " - (str/join " " (map name channel)) " · " why)]))])]))))) - ;; --------------------------------------------------------------------------- ;; tracing a face ;; @@ -588,8 +455,6 @@ [:div {:style {:min-height 0}} [clip-section] (when node [node-section node]) - (when node ^{:key (str (first node) "/" (second node))} - [correction-section node]) (when (and face (or (trace/traceable? clip face) (seq faces))) [tracing-section face faces path]) (when (= :symbol (first selection)) [symbol-section (second selection)]) diff --git a/frontend/src/arthur/ui/pool.cljs b/frontend/src/arthur/ui/pool.cljs index 852bed2..d9fc04e 100644 --- a/frontend/src/arthur/ui/pool.cljs +++ b/frontend/src/arthur/ui/pool.cljs @@ -1,5 +1,5 @@ (ns arthur.ui.pool - "The media pool: what can be put into the open symbol, in two scopes. + "The media pool: what can be put into the open symbol, in two folders. THIS PROJECT is the open document's own: every symbol in it — the open one included, because none is special — and the video it uses or was given this @@ -8,21 +8,6 @@ the split is the point: opening a project REPLACES what is on screen, where everything in here is a thing to put INTO it. - TABS RATHER THAN TWO OPEN FOLDERS. Both scopes expanded cost eight lines of - heading before the first row, in a pane 210px wide; you are either looking at - what the document has or shopping what the server has, and the one case where - that costs you is covered — a search counts its hits in the scope you are not - looking at and puts the number on its tab. - - THE MAIN TIMELINE LEADS, and it is `clip/opens-on`'s answer rather than a - name: the longest symbol nothing else places is the one the project plays and - the one the work happens in, so it gets the top of the pane and a row drawn at - a size you can read a pose off. Every other symbol is the list under it. That - is one list and not two — every symbol here IS a timeline, and heading a - section TIMELINES and the next one SYMBOLS would name the same thing twice. - None of it is `:main` being special either: rename it, place it inside - something else, and the pool follows the document. - Every row is a drag source, and what it carries says what it is: `symbol:` for a symbol of this document, `import:||` for one of another's, and `footage:` for video. The stage and the timeline are @@ -31,13 +16,6 @@ A SOUND — mp3, wav — is `sound:`, and is dropped on the timeline, where it becomes an audio node in the open symbol from the frame it lands on. - RENAMING IS TWO DIFFERENT OPERATIONS behind one affordance. A symbol's name is - a field of this document: it is an undoable edit, and blank gives it back its - id. Footage and sounds live beside projects rather than inside one, so their - label is a server write shared by every project that uses the row — see - `events.footage/relabel`. A symbol of ANOTHER project is renameable where it - lives and not from here. - THE WHOLE PANE IS THE DROP TARGET for a file. A video dropped anywhere in it uploads and lands in THIS PROJECT's media, and goes no further: which frames of it become a symbol, and what that symbol is called, is asked when it is dropped @@ -45,9 +23,6 @@ footage stay separate records on the server, so dropping the same file twice does not decode it twice." (:require [arthur.domain.clip :as clip] - [arthur.domain.node :as node] - [arthur.domain.raster :as raster] - [arthur.export :as export] [arthur.events.footage :as footage] [arthur.events.playback :as pb] [arthur.events.project :as project] @@ -55,117 +30,21 @@ [arthur.subs.playback :as playback] [arthur.subs.render :as render] [arthur.subs.ui :as sub] - [arthur.ui.canvas :as canvas] [arthur.ui.drag :as drag] [clojure.string :as str] [re-frame.core :as rf] [reagent.core :as r])) -;; --------------------------------------------------------------------------- -;; pictures - -(defonce ^:private thumbs - ;; `{:clip :urls {sid url-or-nil}}`, - ;; compared by IDENTITY. A document is an immutable value, so the same object - ;; means the same pictures and a different one means an edit landed — at which - ;; point the whole map goes rather than being diffed, because the cheap test is - ;; the wrong one: a symbol unchanged in itself changes picture when any symbol - ;; it places changes, and following that is `clip/resolver`'s job, not a - ;; cache's. - (atom {:clip nil :urls {}})) - -(defn- placed-in - "Where `sid` is placed, as `[host-symbol node-id]`, or nil for one nothing - places. Lowest ids first, so a symbol placed several times gets one picture and - the same one every time." - [document sid] - (first (for [h (sort-by str (keys (:symbols document))) - n (sort-by str (keys (get-in document [:symbols h :nodes]))) - :when (contains? (node/sources (get-in document [:symbols h :nodes n])) sid)] - [h n]))) - -(defn- draw-symbol - "`sid`'s first frame as a PNG data URL, through the resolver and the rasteriser - the stage uses — so the picture in the pool is the picture, not a sketch of it. - - THROUGH THE PLACEMENT, WHERE THERE IS ONE, and this is the whole subtlety. - Rooting at a symbol renders its DRAWING, in its own local space; a rotoscoped - face is stored head-local in units of one image height, so its numbers are - around 0..1 and the source-to-stage scale that turns them into stage pixels — - several hundred — lives on the `:face` group of the symbol that PLACES it. See - `freeze/face-placement` and \"What space geometry is in\" in - docs/animation-model.md. Rendered rooted at itself, a face is therefore correct - and under a pixel across, which is a true picture of nothing anybody wants to - look at. - - So a placed symbol is drawn where it is placed, with everything else in that - host isolated away — `export/isolate`, whose docstring draws the same - distinction for the same reason. A symbol nothing places has no placement to - borrow and is rendered rooted at itself, which for the main timeline is exactly - right because the stage is its own. - - nil rather than a throw when the frame will not resolve — a cycle, a missing - block, an instance naming a symbol that has gone. A pool is a list of names, - and a symbol that cannot be drawn today is still one you want listed, named - and draggable. - - THE FIRST FRAME, unlike `video-thumb` below, which seeks past the black leader - most phone footage opens on. A symbol's frame 0 is authored: what is on it is - there because somebody put it there, and the frame a person names a symbol by - is the one it starts on." - [document sid store palette ramp] - (try - (when (clip/symbol document sid) - (let [[host id] (placed-in document sid) - document (cond-> document - host (update-in [:symbols host] export/isolate id)) - root (or host sid) - resolve (clip/resolver document root store palette {}) - [w h] (clip/stage document root)] - (-> (raster/make w h) - (raster/clear! (get palette :bg 0)) - (raster/draw-ops! (resolve 0)) - (canvas/->png ramp)))) - (catch :default _ nil))) - -(defn- symbol-thumb - "`draw-symbol`, once per symbol per version of the document. Rasterising and - PNG-encoding the stage is milliseconds, and the pool redraws on every selection - click." - [document sid store palette ramp] - (when-not (identical? document (:clip @thumbs)) - (reset! thumbs {:clip document :urls {}})) - (let [urls (:urls @thumbs)] - (if (contains? urls sid) - (get urls sid) - (let [url (draw-symbol document sid store palette ramp)] - (swap! thumbs assoc-in [:urls sid] url) - url)))) - -(defn- picture - "A row's leading picture. One box at the stage's own proportions for all three - kinds of row, so the names line up down the list whatever is beside them, and - a row with no picture to show still holds the column open." - [url] - (if url - [:img.thumb {:src url :alt "" :draggable false}] - [:span.thumb])) - -(defn- video-thumb - "One frame of a video, from its proxy. `#t=` seeks a paused, muted element to a - frame that is not the black leader most phone footage opens on; nothing is - played and nothing is decoded past it." - [{:keys [video]}] - (if video - ;; Sized here as well as in the stylesheet: a video element with no size - ;; is as big as its footage, and a portrait phone clip is 1440×1920. - [:video.thumb {:src (str video "#t=0.2") :muted true :preload "metadata" - :plays-inline true :tab-index -1 - :style {:width 32 :height 20 :max-width 32 :max-height 20}}] - [:span.thumb])) - -;; --------------------------------------------------------------------------- -;; the row +(defn- item + "One row. `opts` is merged last so a caller can add its handlers without this + function growing a parameter per affordance." + [{:keys [label sub on? disabled? thumb] :as opts}] + [:button (merge {:class (str "pool-item" (when on? " on") (when thumb " media")) + :disabled (boolean disabled?) + :title label} + (dissoc opts :label :sub :on? :disabled? :thumb)) + thumb + [:span.text label (when sub [:span.sub sub])]]) (defn- carrying "The drag handlers for a row. `text` is what makes it a drag at all and what @@ -179,341 +58,130 @@ (start!)) :on-drag-end (fn [_] (drag/done!))}) -(defn- row - "One row of the pool: a picture, a name, and one number. +(defn- thumbnail + "One frame of a video, from its proxy. `#t=` seeks a paused, muted element to a + frame that is not the black leader most phone footage opens on; nothing is + played and nothing is decoded past it." + [{:keys [video]}] + (if video + ;; Sized here as well as in the stylesheet: a video element with no size + ;; is as big as its footage, and a portrait phone clip is 1440×1920. + [:video.thumb {:src (str video "#t=0.2") :muted true :preload "metadata" + :plays-inline true :tab-index -1 + :style {:width 40 :height 30 :max-width 40 :max-height 30}}] + [:span.thumb])) - ONE LINE, because the pane is 210px wide and the pane under it is the timeline - — vertical space spent here is spent on something. The picture carries what the - row is, the name carries which one, and the single number is the one a person - needs before dragging it somewhere: how long it is. Everything else the row - knows goes in `:title`, where a pointer can ask for it. - - THE WHOLE ROW IS THE BUTTON. Clicking selects and double-clicking opens, and - the target for both is the row — not the name, which is a word a few characters - wide that you would have to aim at. - - WHICH IS WHY THE PENCIL IS A SPAN. It belongs beside the name, where it reads - as acting on that word rather than on the row; inside the button is the only - place that can be, and a button inside a button is not a thing HTML has. A span - with a click handler is, and it costs one thing: the pencil is not a tab stop. - F2 on the row is the keyboard path, which is the conventional one anyway, and - `aria-keyshortcuts` is what announces it. Stopping propagation is what keeps a - click on the pencil from also selecting the row underneath it. - - `opts` beyond the keys destructured here is merged onto that drag surface, so a - caller adds its handlers without this growing a parameter per affordance. - - THE MAIN TIMELINE is the one exception, and the only one. Border, fill and size - each say \"separate thing\", so spending them on every row flattens the list - into wallpaper; spent on the one row where the work happens, they say so. It - gets a picture big enough to read a pose off, and both its facts on a line of - its own under the name — `sub2` — rather than one of them out at the edge. - - `rename` is `{:key :value :begin! :commit!}` and is what makes a row - renameable at all: a row without one has no pencil and does not answer F2." - [{:keys [label sub sub2 title thumb on? open? main? disabled? rename] :as opts}] - (let [{:keys [key value begin! commit!]} rename - editing? (boolean (and key (= key (:editing rename))))] - [:div.pool-row {:class (str (when main? "main ") (when on? "on ") - (when open? "open ") (when disabled? "disabled ") - (when editing? "editing"))} - (if editing? - ;; The input REPLACES the row rather than floating over it, so the list - ;; does not change height while you type in it. - [:input.pool-name - {:auto-focus true - :default-value value - :aria-label (str "rename " label) - :on-focus (fn [^js e] (.select (.-target e))) - ;; Blur commits, as the project title in `ui/topbar` does: clicking away - ;; from a half-finished rename means the name you typed, not nothing. - :on-blur (fn [^js e] (commit! (.. e -target -value))) - :on-key-down (fn [^js e] - (case (.-key e) - "Enter" (do (.preventDefault e) (commit! (.. e -target -value))) - ;; Escape has to stop editing WITHOUT the blur that - ;; follows it committing the draft. - "Escape" (do (.preventDefault e) (begin! nil) (.blur (.-target e))) - nil))}] - [:<> - [:button.pool-item - (merge {:disabled (boolean disabled?) - :title (or title label) - :aria-keyshortcuts (when begin! "F2") - :on-key-down (when begin! - (fn [^js e] - (when (= "F2" (.-key e)) - (.preventDefault e) - (begin! key))))} - (dissoc opts :label :sub :sub2 :title :on? :open? :main? - :disabled? :thumb :rename)) - thumb - [:span.text - ;; The label is `.text`'s first child and stays that way: it is how - ;; the browser test finds a row by the name a person reads. - [:span.name label] - (when (and main? sub2) [:span.detail sub2])] - (when begin! - [:span.pool-rename - {:title (str "rename " label " (F2)") - ;; Mouse-only by design — see the docstring. A `tabindex` here would - ;; make it interactive content inside a button, which is the thing - ;; being avoided, and F2 already reaches it from the keyboard. - :on-click (fn [^js e] (.stopPropagation e) (begin! key))} - "\u270E"]) - ;; The main row says both its facts under the name instead. - (when (and sub (not main?)) [:span.sub sub])]])])) - -;; --------------------------------------------------------------------------- -;; the rows of each kind - -(defn- symbol-row [document sid {:keys [clip-id store palette ramp selection open main rename]}] - (let [sym (clip/symbol document sid) - label (clip/symbol-name document sid) - nodes (count (:nodes sym))] - ^{:key (str sid)} - [row (merge {:label label - :sub (str (:frames sym) "f") - :title (str label " · " (:frames sym) " frames · " nodes - (if (= 1 nodes) " node" " nodes") - (when (= sid open) " · open") - " — double-click to open, drag to place") - :thumb [picture (symbol-thumb document sid store palette ramp)] - :sub2 (str (:frames sym) "f · " nodes (if (= 1 nodes) " node" " nodes")) - :on? (= selection [:symbol sid]) - :open? (= sid open) - :main? (= sid main) - :rename (assoc rename - :key [:symbol sid] - :value label - :commit! (fn [value] - ((:begin! rename) nil) - (rf/dispatch [::project/rename-symbol sid value]))) - :on-click #(rf/dispatch [::ui/select [:symbol sid]]) - :on-double-click #(rf/dispatch [::pb/open-symbol sid])} - (carrying (str "symbol:" (subs (str sid) 1)) - #(drag/symbol! clip-id sid open)))])) - -(defn- footage-row [{:keys [id label frames fps video] :as f} chosen rename] - ^{:key id} - [row (merge {:label label - :sub (str frames "f") - :title (str label " · " frames " frames @ " fps "fps" - " — drag onto the stage to make a symbol of it") - :thumb [video-thumb f] - :on? (= id chosen) - :rename (assoc rename - :key [:footage id] - :value label - :commit! (fn [value] - ((:begin! rename) nil) - (rf/dispatch [::footage/relabel :footage id value]))) - :on-click #(rf/dispatch [::footage/choose id])} - (carrying (str "footage:" id) - #(drag/other! {:kind :footage :id id :label label - :frames frames :fps fps :video video})))]) +(defn- footage-row [{:keys [id label frames fps video] :as f} chosen] + [item (merge {:label label + :sub (str frames "f @ " fps) + :thumb [thumbnail f] + :on? (= id chosen) + :on-click #(rf/dispatch [::footage/choose id])} + (carrying (str "footage:" id) + #(drag/other! {:kind :footage :id id :label label + :frames frames :fps fps :video video})))]) (defn- sound-row "An uploaded sound, or with `:footage?` a video's own — which is how a take's sound goes back into its symbol after it was deleted there." - [{:keys [id label duration footage? fps]} project-fps rename] + [{:keys [id label duration footage? fps]} project-fps] (let [frames (js/Math.ceil (* duration project-fps)) [source length rate] (if footage? [{:footage id} (js/Math.round (* duration fps)) (/ fps project-fps)] [{:sound id} frames 1])] - ^{:key (str (when footage? "f") id)} - [row (merge {:label label - :sub (str (.toFixed duration 1) "s") - :title (str label " · " (.toFixed duration 1) "s · " frames " frames" - (when footage? " · this video's own sound") - " — drag onto the timeline") - :thumb [:span.thumb.sound "♪"] - ;; A video's own sound is named by the video. Renaming it here - ;; would rename the footage row two sections up, which is not - ;; what the pencil on a sound looks like it does. - :rename (when-not footage? - (assoc rename - :key [:sound id] - :value label - :commit! (fn [value] - ((:begin! rename) nil) - (rf/dispatch [::footage/relabel :sound id value]))))} - (carrying (str "sound:" id) - #(drag/other! {:kind :sound :source source :label label - :length length :rate rate :frames frames})))])) + [item (merge {:label label + :sub (str (.toFixed duration 1) "s · " frames "f") + :thumb [:span.thumb.sound "♪"]} + (carrying (str "sound:" id) + #(drag/other! {:kind :sound :source source :label label + :length length :rate rate :frames frames})))])) -(defn- import-row [{:keys [cid symbol name frames]} pid] - ^{:key (str cid symbol)} - [row (merge {:label name - :sub (str frames "f") - :title (str name " · " frames " frames — drag to copy it into this project") - :thumb [picture nil]} - (carrying (str "import:" (str/join "|" [pid cid symbol])) - #(drag/other! {:kind :import :label name :frames frames - :project pid :cid cid :symbol symbol})))]) +(defn- folder [title & children] + (into [:details.pool-folder {:open true} [:summary title]] children)) -;; --------------------------------------------------------------------------- -;; sections +(defn- group [title & children] + (into [:div.pool-group {:class title} [:h2 title]] children)) -(defn- hit? - "Is `label` a hit for `query`? A case-folded substring, which is the whole of - what a list of a few dozen names needs." - [query label] - (or (str/blank? query) - (str/includes? (str/lower-case (str label)) query))) +(defn- this-project [] + (let [document @(rf/subscribe [::render/clip]) + clip-id @(rf/subscribe [::render/clip-id]) + selection @(rf/subscribe [::sub/selection]) + open @(rf/subscribe [::render/open]) + media @(rf/subscribe [::sub/project-footage]) + ;; The project's videos' sounds first, then its uploaded ones. + sounds (into (mapv (fn [{:keys [id label frames fps]}] + {:id id :label label :footage? true :fps fps + :duration (/ frames fps)}) + media) + @(rf/subscribe [::sub/project-sounds])) + {:keys [chosen]} @(rf/subscribe [::playback/footage])] + (folder "this project" + (group "symbols" + (doall + (for [sid (sort-by str (keys (:symbols document))) + :let [sym (clip/symbol document sid)]] + ^{:key (str sid)} + [item (merge {:label (clip/symbol-name document sid) + :sub (str (:frames sym) "f · " (count (:nodes sym)) " nodes" + (when (= sid open) " · open")) + :on? (= selection [:symbol sid]) + :on-click #(rf/dispatch [::ui/select [:symbol sid]]) + :on-double-click #(rf/dispatch [::pb/open-symbol sid])} + (carrying (str "symbol:" (subs (str sid) 1)) + #(drag/symbol! clip-id sid open)))])) + [:div.dim "double-click to open · drag to place"]) + (group "media" + (if (empty? media) + [:div.dim "drop a video here"] + (doall (for [f media] ^{:key (:id f)} [footage-row f chosen])))) + (group "sounds" + (if (empty? sounds) + [:div.dim "drop an mp3 or wav here"] + (doall (for [s sounds] ^{:key (:id s)} [sound-row s (:fps document)]))))))) -(defn- section - "A heading and its rows. - - NOTHING AT ALL while a search is running and this section has no hit. A column - of headings over emptiness is the worst thing a filter can show you: the answer - to \"where is it\" should be the one section still holding rows." - [{:keys [title rows blank searching?]}] - (cond - (seq rows) [:div.pool-section [:h2 title] (into [:div.pool-rows] rows)] - searching? nil - :else [:div.pool-section [:h2 title] [:div.dim blank]])) - -(defn- sections - "Draw a scope's sections, or the one line that says a search found nothing in - it. `rows` is the scope's total so the empty answer can be given once rather - than per section." - [searching? rows children] - (if (and searching? (zero? rows)) - ;; Its own class rather than `.dim`: a `.dim` directly inside `.pane-body` - ;; is how the page says what loading, saving and opening are doing — see - ;; the status line at the foot of `view` — and a search result is not that. - [:div.pool-empty "no match in this scope"] - (into [:<>] (map section) children))) - -(defn- this-project - "The open document's own, with the main timeline at the top and every other - symbol under it. - - NOT \"TIMELINES AND SYMBOLS\". Every symbol in this model IS a timeline — it - has frames and nodes and you open it and work in it — so a pair of headings - naming those two things names one thing twice, and invites a reader to go - looking for the difference. There is one list of symbols. What is true is that - exactly one of them is where the work happens: `clip/opens-on`'s answer, the - longest symbol nothing else places. That gets the top of the pane and a row - drawn like the thing it is, and the rest of the library is the list below it. - - Still not `:main` being special. The document says which symbol that is by its - structure; rename it, place it inside something else, and the pool follows." - [{:keys [document query searching? media sounds chosen rename main] :as ctx}] - (let [named? #(hit? query (clip/symbol-name document %)) - top (when (and main (named? main)) main) - rest (filterv #(and (named? %) (not= main %)) - (sort-by str (keys (:symbols document)))) - media (filterv #(hit? query (:label %)) media) - sounds (filterv #(hit? query (:label %)) sounds)] - [sections searching? - (+ (if top 1 0) (count rest) (count media) (count sounds)) - [{:title "project" :searching? searching? - :blank "nothing to open yet" - :rows (when top [(symbol-row document top ctx)])} - {:title "symbols" :searching? searching? - :blank "nothing else in the library" - :rows (mapv #(symbol-row document % ctx) rest)} - {:title "media" :searching? searching? - :blank "drop a video here" - :rows (mapv #(footage-row % chosen rename) media)} - {:title "sounds" :searching? searching? - :blank "drop an mp3 or wav here" - :rows (mapv #(sound-row % (:fps document) rename) sounds)}]])) - -(defn- all-assets - "Everything the server holds. Other projects' symbols stay grouped by project - and closed: a server holds many, and a wall of every symbol in every one buries - the one you want." - [{:keys [document query searching? rename chosen available all-sounds symbols - project-id]}] - (let [media (filterv #(hit? query (:label %)) available) - sounds (filterv #(hit? query (:label %)) all-sounds) - others (filterv #(and (not= project-id (:project %)) (hit? query (:name %))) - symbols) - grouped (sort-by (comp str second key) (group-by (juxt :project :project-name) others))] - [sections searching? - (+ (count media) (count sounds) (count others)) - [{:title "media" :searching? searching? - :blank "nothing uploaded yet" - :rows (mapv #(footage-row % chosen rename) media)} - {:title "sounds" :searching? searching? - :blank "no sounds uploaded yet" - :rows (mapv #(sound-row % (:fps document) rename) sounds)} - {:title "symbols" :searching? searching? - :blank "no other saved projects" - :rows (for [[[pid pname] rows] grouped] - ^{:key (str pid)} - ;; Open while a search is running: you asked for these by name, - ;; and a hit behind a closed twisty is a hit you cannot see. - [:details.pool-project {:open (boolean searching?)} - [:summary (str pname " · " (count rows) - (if (= 1 (count rows)) " symbol" " symbols"))] - (into [:div.pool-rows] (map #(import-row % pid)) rows)])}]])) - -;; --------------------------------------------------------------------------- - -(defn- counts - "How many rows each scope holds for `query`. - - This is what keeps the tabs from hiding anything. A tab shows its tally only - while a search is running and only on the scope you are NOT looking at, so the - one cost of collapsing two folders into two tabs — that a hit could be behind - the tab you did not pick — is paid off by a number, counted over the same - labels the rows are filtered by." - [{:keys [document query media sounds available all-sounds symbols project-id]}] - (let [n (fn [labels] (count (filter #(hit? query %) labels)))] - {:project (+ (n (map #(clip/symbol-name document %) (keys (:symbols document)))) - (n (map :label media)) - (n (map :label sounds))) - :assets (+ (n (map :label available)) - (n (map :label all-sounds)) - (n (map :name (remove #(= project-id (:project %)) symbols))))})) +(defn- all-assets [] + (let [{:keys [available chosen sounds]} @(rf/subscribe [::playback/footage]) + fps (:fps @(rf/subscribe [::render/clip])) + {:keys [symbols]} @(rf/subscribe [::project/assets]) + {:keys [id]} @(rf/subscribe [::playback/project])] + (folder "all assets" + (group "media" + (if (empty? available) + [:div.dim "nothing uploaded yet"] + (doall (for [f available] ^{:key (:id f)} [footage-row f chosen])))) + (group "sounds" + (if (empty? sounds) + [:div.dim "no sounds uploaded yet"] + (doall (for [s sounds] ^{:key (:id s)} [sound-row s fps])))) + (group "symbols" + (let [others (remove #(= id (:project %)) symbols)] + (if (empty? others) + [:div.dim "no other saved projects"] + (doall + (for [[[pid pname] rows] (group-by (juxt :project :project-name) others)] + ^{:key pid} + ;; Closed: a server holds many projects, and a wall of + ;; every symbol in every one buries the one you want. + [:details.pool-project + [:summary (str pname " · " (count rows) + (if (= 1 (count rows)) " symbol" " symbols"))] + (doall + (for [{:keys [cid symbol name frames]} rows] + ^{:key (str cid symbol)} + [item (merge {:label name :sub (str frames "f")} + (carrying (str "import:" (str/join "|" [pid cid symbol])) + #(drag/other! {:kind :import :label name + :frames frames + :project pid :cid cid + :symbol symbol})))]))])))))))) (defn view [] (r/with-let [;; Counted, not a boolean. `dragenter`/`dragleave` fire for every ;; child element the pointer crosses, so a flag set on enter and ;; cleared on leave flickers off the moment the drag passes over a ;; row — the depth counter is what makes the outline steady. - depth (r/atom 0) - query (r/atom "") - scope (r/atom :project) - ;; Which row is being renamed, as `[kind id]`. One atom rather - ;; than a flag per row: exactly one name is ever being edited, and - ;; opening a second input has to close the first. - editing (r/atom nil)] - (let [{:keys [loading? status available sounds chosen]} @(rf/subscribe [::playback/footage]) - document @(rf/subscribe [::sub/settled-clip]) - clip-id @(rf/subscribe [::render/clip-id]) - store @(rf/subscribe [::render/store]) - palette @(rf/subscribe [::render/palette]) - ramp @(rf/subscribe [::render/ramp]) - selection @(rf/subscribe [::sub/selection]) - open @(rf/subscribe [::render/open]) - media @(rf/subscribe [::sub/project-footage]) - {:keys [symbols]} @(rf/subscribe [::project/assets]) - {project-id :id} @(rf/subscribe [::playback/project]) - ;; The project's videos' sounds first, then its uploaded ones. - own-sounds (into (mapv (fn [{:keys [id label frames fps]}] - {:id id :label label :footage? true :fps fps - :duration (/ frames fps)}) - media) - @(rf/subscribe [::sub/project-sounds])) - needle (str/lower-case (str/trim @query)) - searching? (boolean (seq needle)) - ctx {:document document :clip-id clip-id :store store :palette palette - :ramp ramp :selection selection :open open - :media media :sounds own-sounds :chosen chosen - :available (vec available) :all-sounds (vec sounds) - :symbols symbols :project-id project-id - :query needle :searching? searching? - ;; `clip/opens-on` and not `:main`: the longest symbol nothing - ;; else places is the timeline the work happens in, and it is the - ;; one row in this pane drawn big enough to read a pose off. - :main (clip/opens-on document) - :rename {:editing @editing :begin! #(reset! editing %)}} - tallies (counts ctx) + depth (r/atom 0)] + (let [{:keys [loading? status]} @(rf/subscribe [::playback/footage]) files? (fn [^js e] (some #{"Files"} (array-seq (.. e -dataTransfer -types))))] [:section.pane.pool {:class (when (pos? @depth) "dropping") @@ -540,27 +208,7 @@ (when-let [file (aget (.. event -target -files) 0)] (rf/dispatch [::footage/upload file]) (set! (.. event -target -value) "")))}] - [:div.pool-find - [:input.pool-search - {:id "pool-search" :type "search" :value @query - :placeholder "search" :aria-label "search the media pool" - :on-change #(reset! query (.. % -target -value)) - :on-key-down (fn [^js e] (when (= "Escape" (.-key e)) (reset! query "")))}] - (when searching? - [:button.pool-clear {:title "clear the search" :aria-label "clear the search" - :on-click #(reset! query "")} - "×"])] - [:div.tabs - (doall - (for [[k label] [[:project "this project"] [:assets "all assets"]]] - ^{:key (str k)} - [:button.tab {:class (when (= k @scope) "on") - :on-click #(reset! scope k)} - label - ;; Only while searching, and only on the tab you are not looking at: - ;; it exists to say "the thing you asked for is over here". - (when (and searching? (not= k @scope)) - [:span.count (get tallies k)])]))] [:div.pane-body - (if (= :project @scope) [this-project ctx] [all-assets ctx]) + [this-project] + [all-assets] (when status [:div.dim status])]]))) diff --git a/frontend/src/arthur/ui/timeline.cljs b/frontend/src/arthur/ui/timeline.cljs index d6a1124..9c57e26 100644 --- a/frontend/src/arthur/ui/timeline.cljs +++ b/frontend/src/arthur/ui/timeline.cljs @@ -682,16 +682,14 @@ It reuses `rows`, so its spans and selection addresses are exactly the ones the timeline presents rather than a second interpretation of the document." [clip sid frames] - (mapv (fn [{:keys [path label cels select]}] + (mapv (fn [{:keys [path label cels]}] {:id (peek path) - :path path - :select select :label label :cells (mapv (fn [f] (let [cel (some (fn [{[in out] :span :as cel}] (when (and (<= in f) (< f out)) cel)) cels)] - {:frame f :lane (peek path) :lane-select select :cel cel})) + {:frame f :lane (peek path) :cel cel})) (range frames))}) (filter :cels (rows clip sid #{})))) @@ -706,11 +704,8 @@ (str "52px repeat(" (max 1 (count columns)) ", minmax(110px, 1fr))")}] [:div.cel-sheet {:style style} [:div.cs-head.cs-frame "frame"] - (doall (for [{:keys [path label select]} columns] - ^{:key (str "head-" path)} - [:button.cs-head {:class (when (= select selection) "selected") - :on-click #(rf/dispatch [::ui/select select])} - label])) + (doall (for [{:keys [id label]} columns] + ^{:key (str "head-" id)} [:div.cs-head label])) (doall (for [f (range frames) item (cons {:frame-label? true} @@ -719,8 +714,7 @@ ^{:key (str "frame-" f)} [:button.cs-frame {:class (when (= f frame) "on") :on-click #(rf/dispatch [::pb/seek f])} f] - (let [{:keys [id label select]} (:cel item) - target (or select (:lane-select item))] + (let [{:keys [id label select]} (:cel item)] ^{:key (str f "-" (:lane item) "-" (or id "gap"))} [:button.cs-cell {:class (str (when (= f frame) " current") @@ -728,7 +722,7 @@ :title (if id (str label " · frame " f) (str "gap · frame " f)) :on-click (fn [] (rf/dispatch [::pb/seek f]) - (rf/dispatch [::ui/select target]))} + (when select (rf/dispatch [::ui/select select])))} (or label "—")]))))])) (defn view [] diff --git a/frontend/test/arthur/domain/channel_test.cljs b/frontend/test/arthur/domain/channel_test.cljs index f5aa2d9..854ec64 100644 --- a/frontend/test/arthur/domain/channel_test.cljs +++ b/frontend/test/arthur/domain/channel_test.cljs @@ -287,30 +287,6 @@ "a covering replacement establishes the shape seen by later layers") (is (= [2 3 4] (ch/value-at (corrected base put3 add3) 0 nil))))) -(deftest stack-compatibility-follows-adjacent-replacements-and-skips-conflicts - (let [base (ch/framed [0 0]) - left (ch/layer :left [0 2] :replace (ch/framed [1 2 3])) - right (ch/layer :right [2 4] :replace (ch/framed [4 5 6])) - add3 (ch/layer :add [0 4] :offset (ch/framed [1 1 1])) - valid (corrected base left right add3) - broken (corrected base (assoc left :conflict "skip it") right add3)] - (is (empty? (ch/problems valid)) - "adjacent replacements jointly prevent the base shape reaching the offset") - (is (seq (ch/problems broken)) - "a conflicted replacement is absent from the effective stack") - (is (empty? (ch/conflicts valid))) - (is (= [:left :add] (mapv :id (ch/conflicts broken)))))) - -(deftest reconciliation-recomputes-the-complete-stack-in-order - (let [old (assoc (ch/framed [0 0]) :over - [(assoc (ch/layer :put [0 3] :replace (ch/framed [1 2 3])) - :conflict "old") - (ch/layer :add [0 3] :offset (ch/framed [1 1 1]))]) - layers (ch/reconcile old)] - (is (nil? (:conflict (first layers))) "a stale mark is cleared") - (is (nil? (:conflict (second layers))) "the later offset sees that replacement") - (is (= [2 3 4] (ch/value-at (assoc old :over layers) 1 nil))))) - (deftest generated-base-time-and-authored-correction-time-can-differ (let [c (corrected (ch/keyed {0 0, 2 20} :hold) (ch/layer :nudge [1 2] :offset (ch/framed 3))) diff --git a/frontend/test/arthur/domain/correction_test.cljs b/frontend/test/arthur/domain/correction_test.cljs deleted file mode 100644 index fdefb3b..0000000 --- a/frontend/test/arthur/domain/correction_test.cljs +++ /dev/null @@ -1,86 +0,0 @@ -(ns arthur.domain.correction-test - (:require [cljs.test :refer [deftest is testing]] - [arthur.domain.channel :as ch] - [arthur.domain.clip :as clip] - [arthur.domain.correction :as correction] - [arthur.domain.leaf :as leaf] - [arthur.domain.lane-test :as fixture])) - -(defn- channel [doc id path] - (get-in doc [:symbols :main :nodes id :channels path])) - -(deftest authors-the-three-motions-as-ordinary-layer-channels - (let [doc (fixture/document) - constant (correction/add doc :main :girl [:xform :rot] - {:id :flat :support [2 5] :motion :constant :delta 1}) - ramp (correction/add (:clip constant) :main :girl [:xform :rot] - {:id :ramp :support [6 9] :motion :ramp :start 0 :end 2}) - returned (correction/add (:clip ramp) :main :girl [:xform :rot] - {:id :return :support [9 12] :motion :return - :start 0 :peak 3 :peak-frame 10}) - c (channel (:clip returned) :girl [:xform :rot])] - (is (= :girl (:selection returned))) - (is (= [:flat :ramp :return] (mapv :id (:over c)))) - (is (= [0 0 1 1 1 0 0 1 2 0 3 0] - (mapv #(ch/value-at c % nil) (range 12)))) - (is (empty? (clip/problems (:clip returned)))))) - -(deftest materializes-defaults-and-refuses-incomplete-intent - (let [doc (fixture/document) - result (correction/add doc :main :a [:xform :rot] - {:id :nudge :support [0 3] :motion :return - :start 0 :peak 0.5 :peak-frame 1})] - (is (= 0.5 (ch/value-at (channel (:clip result) :a [:xform :rot]) 1 nil))) - (is (= (dissoc (get-in doc [:symbols :main :nodes :a]) :channels) - (dissoc (get-in (:clip result) [:symbols :main :nodes :a]) :channels))) - (doseq [spec [{:id :x :support [0 1] :motion :ramp :start 0 :end 1} - {:id :x :support [0 2] :motion :return :start 0 :peak 1 :peak-frame 1} - {:id :x :support [0 3] :motion :constant :delta js/NaN} - {:id :x :support [0 3] :motion :constant :delta [1 2]}]] - (let [r (correction/add doc :main :a [:xform :rot] spec)] - (is (:refused r)) - (is (not (contains? r :clip))))))) - -(deftest removal-and-retry-never-leave-an-invalid-stack - (let [doc (fixture/document) - base (ch/framed [0 0]) - put3 (ch/layer :put3 [0 2] :replace (ch/framed [1 2 3])) - add3 (ch/layer :add3 [0 2] :offset (ch/framed [1 1 1])) - stacked (assoc base :over [put3 add3]) - doc (assoc-in doc [:symbols :main :nodes :girl :channels [:xform :pos]] stacked)] - (is (:refused (correction/remove-layer doc :main :girl [:xform :pos] :put3)) - "removing the replacement would expose a wrong-shaped base") - (let [conflicted (assoc-in doc [:symbols :main :nodes :girl :channels [:xform :pos] :over 1 :conflict] - "old topology") - retried (correction/retry-layer conflicted :main :girl [:xform :pos] :add3)] - (is (:clip retried)) - (is (nil? (get-in (:clip retried) - [:symbols :main :nodes :girl :channels [:xform :pos] :over 1 :conflict])))) - (let [without-replacement (-> doc - (assoc-in [:symbols :main :nodes :girl :channels [:xform :pos] :over] - [(assoc add3 :conflict "old topology")]))] - (is (:refused (correction/retry-layer without-replacement :main :girl - [:xform :pos] :add3)) - "retry refuses when the current effective base still has the wrong shape")))) - -(deftest a-cel-correction-travels-with-its-owner - (let [doc (fixture/document) - result (correction/add doc :main :b [:xform :pos] - {:id :nudge :support [1 3] :motion :constant :delta [4 0]}) - c (channel (:clip result) :b [:xform :pos])] - (is (= [6 0] (ch/value-at c 1 nil))) - (is (= [0 0] (ch/value-at c 0 nil))) - (is (= [2 0] (ch/value-at c 3 nil))))) - -(deftest corrections-round-trip-with-identity-support-and-order - (let [doc (fixture/document) - one (:clip (correction/add doc :main :girl [:xform :rot] - {:id :one :support [0 3] :motion :constant :delta 1})) - two (:clip (correction/add one :main :girl [:xform :rot] - {:id :two :support [3 6] :motion :ramp - :start 0 :end 2})) - back (leaf/clip "u" (leaf/leaves "u" two))] - (is (= two back)) - (is (= [:one :two] - (mapv :id (get-in back [:symbols :main :nodes :girl - :channels [:xform :rot] :over])))))) diff --git a/frontend/test/arthur/events/lane_test.cljs b/frontend/test/arthur/events/lane_test.cljs index e489d8b..8cc3a63 100644 --- a/frontend/test/arthur/events/lane_test.cljs +++ b/frontend/test/arthur/events/lane_test.cljs @@ -1,7 +1,6 @@ (ns arthur.events.lane-test (:require [cljs.test :refer [deftest is]] [arthur.domain.lane-test :as fixture] - [arthur.domain.correction :as correction] [arthur.domain.lane :as lane] [arthur.events.ui :as ui] [arthur.domain.history :as history] @@ -25,8 +24,6 @@ column (first (timeline/cel-sheet doc :main 12)) cells (:cells column)] (is (= :girl (:id column))) - (is (= [:node :main :girl [:girl]] (:select column))) - (is (every? #(= (:select column) (:lane-select %)) cells)) (is (= [:a :b :insert] (mapv #(get-in cells [% :cel :id]) [0 4 8]))) (is (= [[:node :main :a [:a]] [:node :main :b [:b]] @@ -68,23 +65,3 @@ (is (= (:clip r1) (leaf/clip "u" (:leaves undo)))) (is (= doc (leaf/clip "u" (:leaves undo2)))) (is (= [:node :main :a [:a]] (get-in db2 [:ui :selection])))))) - -(deftest a-correction-is-one-step-and-keeps-the-full-selection-address - (let [doc (fixture/document) - id (store/install! {:clip doc :store {}} "correction-event-test") - selection [:node :main :a [:outer :a]] - db {:clip/current id :paint/revision 0 - :ui {:open :outer :selection selection}} - result (correction/add doc :main :a [:xform :rot] - {:id :nudge :support [0 3] :motion :return - :start 0 :peak 0.5 :peak-frame 1}) - after (ui/apply-correction-command db result) - entry (store/entry id)] - (is (= selection (get-in after [:ui :selection]))) - (is (= 1 (count (get-in entry [:history :done])))) - (is (= 0.5 (get-in (:clip entry) - [:symbols :main :nodes :a :channels [:xform :rot] - :over 0 :values :keys 1]))) - (let [refused (ui/apply-correction-command after {:refused "nope"})] - (is (= "nope" (get-in refused [:project :status]))) - (is (= 1 (count (get-in (store/entry id) [:history :done]))))))) diff --git a/frontend/test/browser/lane.mjs b/frontend/test/browser/lane.mjs index ec069c4..9cffb1e 100644 --- a/frontend/test/browser/lane.mjs +++ b/frontend/test/browser/lane.mjs @@ -255,54 +255,10 @@ try { assert.deepEqual(placed(s), [[0, 3], [3, 4], [6, 7], [7, 8], [8, 9]], 'a command selected in the sheet has the timeline command semantics'); assert.equal(s.history.done.length, before.history.done.length + 9); - - // Correction authoring is reachable from the same selection. The range is - // in this cel's own frames and Apply is one isolated history transaction. - assert(await evaluate(`(() => { - const section = [...document.querySelectorAll('.section')] - .find(s => s.querySelector('h2')?.textContent.trim() === 'corrections'); - const label = [...section.querySelectorAll('label')] - .find(l => l.textContent.trim().startsWith('offset (degrees)')); - const input = label?.querySelector('input'); - if (!input) return false; - const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set; - set.call(input, '15'); - input.dispatchEvent(new Event('input', {bubbles: true})); - input.dispatchEvent(new Event('change', {bubbles: true})); - return true; - })()`), 'rotation correction value is editable'); - await sleep(100); - await click('apply correction'); - s = await shot(); - const selectedCel = instances(s).find(n => n.time.at === 0); - const rot = Object.values(selectedCel.channels).find(c => c.over?.length); - assert.equal(rot.over.length, 1); - assert(Math.abs(rot.over[0].values.value - Math.PI / 12) < 1e-9, - 'the inspector converts the authored degree offset to radians'); - assert.equal(s.history.done.length, before.history.done.length + 10); - - // A gap targets its column's lane. This catches the stale-selection bug that - // only appears once a sheet has more than one lane. await click('timeline'); assert.equal(await evaluate('document.querySelectorAll(".tl-cel").length'), 5); - await click('+ lane'); - await click('cel sheet'); - assert.equal(await evaluate('document.querySelectorAll(".cs-head:not(.cs-frame)").length'), 2); - await evaluate(`([...document.querySelectorAll('.cs-cell')].slice(0, 2) - .find(c => c.textContent.trim() !== '—')).click()`); - await sleep(100); - await evaluate(`([...document.querySelectorAll('.cs-cell')].slice(0, 2) - .find(c => c.textContent.trim() === '—')).click()`); - await sleep(100); - await click('overwrite'); - s = await shot(); - assert(Object.values(s.clip.symbols.main.nodes) - .some(n => n.kind === 'instance' && n.parent !== 'girl' && n.time.at === 0), - 'clicking a gap selects that column before overwrite'); - assert.equal(s.history.done.length, before.history.done.length + 12, - 'correction, lane creation, and overwrite are separate undo steps'); assert.equal(errors.length, 0, JSON.stringify(errors)); - console.log('PASS: lane commands and corrections agree from timeline and cel sheet; no server writes'); + console.log('PASS: lane commands agree from timeline and cel sheet; no server writes'); } finally { if (ws?.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ id: 999999, method: 'Browser.close' })); diff --git a/static/arthur/app.css b/static/arthur/app.css index 2dcc35f..38dca0b 100644 --- a/static/arthur/app.css +++ b/static/arthur/app.css @@ -416,249 +416,67 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); } .peer.guest { background: var(--dim); } /* -------------------------------------------------------------------------- - media pool + media pool */ - A FILE LIST, AND IT IS 210px WIDE. Every row is one line — a picture at the - stage's own proportions, a name, one number — because the pane under this one - is the timeline and a row that costs two lines costs timeline rows. The row is - --row tall, which is a timeline row, so a pool of symbols and the rows they - end up on are the same size object. - - One exception, deliberately: the main timeline. See `.pool-row.main`. */ - -/* The search strip, between the pane head and the tabs. Its own band rather - than crowded into the head, which is one line and already holds the + . */ -.pool-find { - position: sticky; - top: 21px; - z-index: 2; - display: flex; - align-items: center; - gap: 4px; - padding: 3px 5px; - background: var(--chrome); - border-bottom: 1px solid var(--hair); -} - -.pool-search { - flex: 1; - min-width: 0; - height: 17px; - padding: 0 5px; - font: inherit; - color: var(--fg); - background: var(--pane); - border: 1px solid var(--line); - border-radius: 2px; -} - -/* Safari draws its own clear button inside a search input, in its own idiom and - at its own size. The one next to it is this file's. */ -.pool-search::-webkit-search-decoration, -.pool-search::-webkit-search-cancel-button { -webkit-appearance: none; } - -.pool-clear { - flex: 0 0 auto; - width: 17px; - height: 17px; - padding: 0; - border: 0; - border-radius: 2px; - background: none; - color: var(--dim); +/* Two folders, THIS PROJECT and ALL ASSETS, each holding groups. */ +.pool-folder { margin-bottom: 8px; } +.pool-folder > summary { cursor: pointer; + padding: 2px 0 4px; + font-weight: 600; + color: var(--fg); } -.pool-clear:hover { background: var(--hair); color: var(--fg); } +.pool-folder > .pool-group { padding-left: 8px; } +.pool-project { margin-bottom: 2px; } +.pool-project > summary { cursor: pointer; color: var(--dim); padding: 1px 0; } +.pool-project > .pool-item { margin-left: 10px; width: calc(100% - 10px); } -/* The scope tabs, in the vocabulary `ui/tabs` already established for symbols — - the same object, so two rows of tabs on one screen read as one idea. */ -.pool > .tabs { top: 45px; position: sticky; z-index: 2; } +.pool-group { margin-bottom: 9px; } -/* A tally, shown only on the tab you are NOT looking at and only while a search - is running: it says "what you asked for is over here". */ -.tab .count { - padding: 0 4px; - border-radius: 7px; - background: var(--sel-bg); - color: var(--sel); - font-variant-numeric: tabular-nums; -} +/* The one-line gloss under a group heading. Small and quiet: it answers "what + is this list" once, for someone who has not read the model. */ +.pool-group > h2 + .dim { margin-bottom: 4px; } -/* MEDIA, SOUNDS, TIMELINES. A heading is one line and it sticks, so scrolling a - long list never leaves you looking at rows with no idea what kind they are. */ -.pool-section { margin-bottom: 7px; } - -.pool-section > h2 { - position: sticky; - top: 69px; - z-index: 1; - margin: 0 0 2px; - padding: 1px 5px; +.pool-group > h2 { + margin: 0 0 3px; font: inherit; font-weight: 600; color: var(--dim); text-transform: uppercase; letter-spacing: .07em; - background: var(--pane); } -.pool-section > .dim { padding: 0 5px 2px; } - -.pool-rows { display: flex; flex-direction: column; } - -/* What a search found nothing to say. */ -.pool-empty { padding: 6px 5px; color: var(--dim); } - -.pool-project { margin: 0 0 1px 5px; } -.pool-project > summary { cursor: pointer; color: var(--dim); padding: 1px 0; } -.pool-project > .pool-rows { margin-left: 8px; } - -/* THE ROW IS THE BUTTON. Click selects, double-click opens, and drag starts - anywhere across it — the whole strip, not the few characters of the name. The - wrapper carries the state classes and the frame; the button inside it fills - that frame edge to edge, so there is no dead strip anywhere along the row. */ -.pool-row { - display: flex; - align-items: center; - gap: 2px; - min-width: 0; - height: var(--row); - padding: 0 4px; - border: 1px solid transparent; - border-radius: 2px; -} - -.pool-row:hover:not(.disabled) { background: #fff; } -.pool-row.on { background: var(--sel-bg); border-color: var(--sel); } - -/* WHICH ONE IS OPEN, as a mark and not as the word "open" — a list this narrow - cannot spend twenty pixels of every name on a word that is true of one row. - The accent, because selection is the only thing it is ever used for and the - open symbol is what the rest of the screen is showing. */ -.pool-row.open { box-shadow: inset 2px 0 0 var(--sel); } - .pool-item { - display: flex; - align-items: center; - gap: 5px; - flex: 1; - min-width: 0; - padding: 0; + display: block; + width: 100%; text-align: left; - border: 0; + padding: 2px 6px; + border: 1px solid transparent; + border-radius: 2px; background: none; - color: inherit; - font: inherit; cursor: grab; -} - -/* Shrinks to the name, so the pencil sits against the end of the word rather - than out at the edge of the pane — and ellipses rather than pushing the - number off the row. */ -.pool-item .text { display: flex; flex-direction: column; flex: 0 1 auto; min-width: 0; } -.pool-item .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - -/* The number, at the far edge and never moving: `auto` on the left is what - pushes it there, and tabular figures are what stop it shuffling as the rows - scroll past. */ -.pool-item > .sub { - flex: 0 0 auto; - margin-left: auto; - padding-left: 4px; - color: var(--dim); - font-variant-numeric: tabular-nums; -} - -/* Every row leads with a picture, and they are all this box, so the names line - up down the list whatever is beside them. 16:10 is the stage's own ratio at - 320x200 — a thumbnail that is not the picture's shape is a thumbnail you have - to think about. */ -.pool-item .thumb { - flex: 0 0 32px; - width: 32px; - height: 20px; - object-fit: cover; - background: var(--stage); - border-radius: 1px; - pointer-events: none; -} - -.pool-item .thumb.sound { - display: flex; - align-items: center; - justify-content: center; - color: #d9d9d9; -} - -/* THE MAIN TIMELINE, under the PROJECT heading and at the top of the pane. - `clip/opens-on`'s answer: the longest symbol nothing else places, which is - where the work happens. The one row in the pane given size, a second line and - a surface of its own — spent here rather than spread over every row, which is - what keeps the list below it a list. */ -.pool-row.main { - height: auto; - margin-bottom: 5px; - padding: 4px; - background: var(--sunk); - border-color: var(--hair); -} - -.pool-row.main .thumb { - flex: 0 0 64px; - width: 64px; - height: 40px; -} - -.pool-row.main .name { font-weight: 600; } - -/* Both facts on one line under the name. Beside a 64px picture there is room, - and this is the one row where a person wants more than the length before - they click. */ -.pool-row.main .detail { - color: var(--dim); - font-variant-numeric: tabular-nums; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -/* The pencil. A span and not a button — see `ui/pool`'s `row` — so it needs its - own centring and cursor rather than inheriting a control's. +.pool-item:hover:not(:disabled) { background: #fff; } +.pool-item.on { background: var(--sel-bg); border-color: var(--sel); } +.pool-item .sub { display: block; color: var(--dim); } +.pool-item .text { display: block; overflow: hidden; text-overflow: ellipsis; } - Hidden until the row is under the pointer or holds focus, because a column of - twenty pencils is a column of twenty pencils; `visibility` rather than - `display`, so it keeps its width and the name beside it does not reflow as the - pointer crosses the list. */ -.pool-rename { - display: flex; - align-items: center; - justify-content: center; - flex: 0 0 15px; - width: 15px; - height: 15px; - border-radius: 2px; - color: var(--dim); - line-height: 1; - cursor: pointer; - visibility: hidden; -} - -.pool-row:hover .pool-rename, -.pool-row:focus-within .pool-rename { visibility: visible; } -.pool-rename:hover { background: var(--hair); color: var(--fg); } - -/* Renaming. The input REPLACES the row at the same height, so a list does not - jump while you type in it. */ -.pool-name { - flex: 1; - min-width: 0; - height: 17px; - padding: 0 4px; - font: inherit; - color: var(--fg); - background: #fff; - border: 1px solid var(--sel); +/* A media row leads with one frame of the video. */ +.pool-item.media { display: flex; align-items: center; gap: 6px; } +.pool-item .thumb { + flex: 0 0 40px; + width: 40px; + height: 30px; + max-width: 40px; + max-height: 30px; + object-fit: cover; + background: var(--stage); border-radius: 2px; + pointer-events: none; } /* The whole pane is the drop target, so the cue has to be the pane and not a @@ -830,12 +648,6 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); } .inspector-field { display: grid; gap: 2px; color: var(--dim); } .inspector-field input { width: 100%; color: var(--fg); } -.correction-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; } -.correction-grid select, .correction-grid input { width: 100%; color: var(--fg); } -.correction-list, .correction-conflicts { display: grid; gap: 4px; margin-top: 7px; } -.correction-item { display: flex; align-items: center; gap: 4px; min-width: 0; } -.correction-item > span { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - /* Label / value, once, for every read-only fact in the pane. */ .facts { display: grid; grid-template-columns: auto 1fr; gap: 2px 8px; margin: 0; } .facts dt { color: var(--dim); } @@ -1150,7 +962,6 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); } align-items: center; background: var(--chrome); font-weight: 600; - cursor: pointer; } .cs-head.cs-frame { z-index: 3; } @@ -1159,7 +970,6 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); } .cs-cell { text-align: left; cursor: pointer; } .cs-cell:hover { background: var(--sel-bg); } .cs-cell.selected { background: var(--sel-bg); color: var(--sel); font-weight: 600; } -.cs-head.selected { background: var(--sel-bg); color: var(--sel); } /* -------------------------------------------------------------------------- the video -> symbol dialog */