@a-company/atelier 0.36.0 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +13 -8
- package/university/content/notes/N-atel-001-first-render.md +114 -0
- package/university/content/notes/N-atel-001-install-and-launch.md +84 -0
- package/university/content/notes/N-atel-001-what-is-atelier.md +51 -0
- package/university/content/notes/N-atel-101-easings.md +97 -0
- package/university/content/notes/N-atel-101-layers.md +106 -0
- package/university/content/notes/N-atel-101-states-and-deltas.md +94 -0
- package/university/content/notes/N-atel-101-the-atelier-format.md +72 -0
- package/university/content/notes/N-atel-201-authoring-tools.md +141 -0
- package/university/content/notes/N-atel-201-mcp-overview.md +86 -0
- package/university/content/notes/N-atel-201-patterns.md +108 -0
- package/university/content/notes/N-atel-201-visual-and-effects.md +125 -0
- package/university/content/notes/N-atel-301-composition-and-overlays.md +141 -0
- package/university/content/notes/N-atel-301-effects.md +136 -0
- package/university/content/notes/N-atel-301-images-and-video.md +126 -0
- package/university/content/notes/N-atel-301-shapes-and-text.md +118 -0
- package/university/content/notes/N-atel-401-hierarchical-states.md +71 -0
- package/university/content/notes/N-atel-401-motion-deep-dive.md +106 -0
- package/university/content/notes/N-atel-401-presets-and-templates.md +98 -0
- package/university/content/notes/N-atel-401-transitions.md +94 -0
- package/university/content/notes/N-atel-501-detected-vs-user-edited.md +76 -0
- package/university/content/notes/N-atel-501-layer-tag-isolation.md +62 -0
- package/university/content/notes/N-atel-501-silence-trim.md +98 -0
- package/university/content/notes/N-atel-501-transcribe-and-captions.md +98 -0
- package/university/content/notes/N-atel-601-carousel.md +71 -0
- package/university/content/notes/N-atel-601-overlay-rules.md +96 -0
- package/university/content/notes/N-atel-601-recipe-tools-and-apply.md +84 -0
- package/university/content/notes/N-atel-601-studio-recipe.md +103 -0
- package/university/content/notes/N-atel-701-choosing-output.md +68 -0
- package/university/content/notes/N-atel-701-png-and-frames.md +84 -0
- package/university/content/notes/N-atel-701-vector.md +85 -0
- package/university/content/notes/N-atel-701-video.md +88 -0
- package/university/content/notes/N-atel-801-editing-surface.md +69 -0
- package/university/content/notes/N-atel-801-live-bridge.md +84 -0
- package/university/content/notes/N-atel-801-studio-app.md +72 -0
- package/university/content/notes/N-atel-801-symbiotic-loop.md +56 -0
- package/university/content/paths/LP-atel-001.yaml +21 -0
- package/university/content/paths/LP-atel-101.yaml +22 -0
- package/university/content/paths/LP-atel-201.yaml +23 -0
- package/university/content/paths/LP-atel-301.yaml +22 -0
- package/university/content/paths/LP-atel-401.yaml +22 -0
- package/university/content/paths/LP-atel-501.yaml +22 -0
- package/university/content/paths/LP-atel-601.yaml +22 -0
- package/university/content/paths/LP-atel-701.yaml +22 -0
- package/university/content/paths/LP-atel-801.yaml +22 -0
- package/university/content/quizzes/Q-atel-001-orientation.yaml +66 -0
- package/university/content/quizzes/Q-atel-101-document-model.yaml +66 -0
- package/university/content/quizzes/Q-atel-201-mcp-authoring.yaml +66 -0
- package/university/content/quizzes/Q-atel-301-visual-system.yaml +66 -0
- package/university/content/quizzes/Q-atel-401-state-machines.yaml +66 -0
- package/university/content/quizzes/Q-atel-501-video-pipeline.yaml +66 -0
- package/university/content/quizzes/Q-atel-601-recipes.yaml +66 -0
- package/university/content/quizzes/Q-atel-701-export.yaml +66 -0
- package/university/content/quizzes/Q-atel-801-studio-loop.yaml +66 -0
- package/university/index.yaml +720 -0
- package/university/pack.yaml +21 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: N-atel-401-transitions
|
|
3
|
+
title: Transitions and the interaction layer
|
|
4
|
+
type: note
|
|
5
|
+
track: ATEL-401
|
|
6
|
+
author: atelier
|
|
7
|
+
created: '2026-05-20'
|
|
8
|
+
updated: '2026-05-20'
|
|
9
|
+
tags:
|
|
10
|
+
- course
|
|
11
|
+
- atel-401
|
|
12
|
+
- transitions
|
|
13
|
+
- interactions
|
|
14
|
+
- state-machine
|
|
15
|
+
difficulty: intermediate
|
|
16
|
+
estimatedMinutes: 6
|
|
17
|
+
prerequisites:
|
|
18
|
+
- N-atel-101-easings
|
|
19
|
+
summary: A transition is the timed bridge from one state to another. Interactions are the triggers that fire those transitions. Together they turn a pile of states into an interactive state machine.
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## A transition is a bridge with its own timing
|
|
23
|
+
|
|
24
|
+
States are destinations. A transition is *how you get from A to B* — with a duration and easing that belong to the move, not to either state.
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
atelier_configure_transition({
|
|
28
|
+
id,
|
|
29
|
+
stateName: "default",
|
|
30
|
+
targetState: "hover",
|
|
31
|
+
transition: {
|
|
32
|
+
duration: 12,
|
|
33
|
+
easing: "ease-out",
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Read it as: "from `default`, the transition to `hover` takes 12 frames, eased-out." The `duration` is in frames (a positive integer) and the `easing` accepts the same forms you already know — `"ease-out"`, `{ type: "cubic-bezier", x1, y1, x2, y2 }`, `{ type: "spring", stiffness, damping }`, `{ type: "step", steps }`.
|
|
39
|
+
|
|
40
|
+
Transitions are **directional**. `default → hover` and `hover → default` are configured separately, and they usually differ — entrances ease-out, exits ease-in. Pass `transition: null` to remove a configured transition. A state cannot transition to itself, and the target state must already exist (the tool checks both).
|
|
41
|
+
|
|
42
|
+
## Interactive motion: default → hover → pressed
|
|
43
|
+
|
|
44
|
+
The classic button is three states and the transitions between them:
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
// states
|
|
48
|
+
atelier_add_state({ id, stateName: "hover", duration: 30 });
|
|
49
|
+
atelier_add_state({ id, stateName: "pressed", duration: 30 });
|
|
50
|
+
// (hover and pressed typically set parent: "default" — see hierarchical states)
|
|
51
|
+
|
|
52
|
+
// transitions, each with timing that fits the move
|
|
53
|
+
atelier_configure_transition({ id, stateName: "default", targetState: "hover", transition: { duration: 10, easing: "ease-out" } });
|
|
54
|
+
atelier_configure_transition({ id, stateName: "hover", targetState: "pressed", transition: { duration: 6, easing: "ease-in" } });
|
|
55
|
+
atelier_configure_transition({ id, stateName: "pressed", targetState: "hover", transition: { duration: 8, easing: "ease-out" } });
|
|
56
|
+
atelier_configure_transition({ id, stateName: "hover", targetState: "default", transition: { duration: 14, easing: "ease-in" } });
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
At this point the document *describes* a state machine — but nothing fires the transitions yet. States and transitions are pure data. Something has to say "go."
|
|
60
|
+
|
|
61
|
+
## The interaction layer — triggers that fire transitions
|
|
62
|
+
|
|
63
|
+
`atelier_add_interaction` attaches a **trigger → action** binding to a layer. The trigger is the event; the action is what happens.
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
atelier_add_interaction({
|
|
67
|
+
id,
|
|
68
|
+
layerId: "button",
|
|
69
|
+
interactionId: "to-hover",
|
|
70
|
+
trigger: { type: "hover" },
|
|
71
|
+
action: { type: "go-to-state", state: "hover" },
|
|
72
|
+
description: "Lift the button when the pointer enters",
|
|
73
|
+
});
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Triggers: `click`, `hover`, `pointerdown`, `pointerup`, `timer` (needs a `delay` in ms), `signal` (needs a `signal` name). Actions: `go-to-state` (needs a `state`), `emit-signal`, `set-variable`, `toggle-visibility`. For the button you wire `hover → go-to-state hover`, `pointerdown → go-to-state pressed`, `pointerup → go-to-state hover`.
|
|
77
|
+
|
|
78
|
+
A `go-to-state` action does **not** snap. It runs the **transition** you configured from the current state to the target — with that transition's duration and easing. Interactions decide *when*; transitions decide *how it looks getting there*. If no transition is configured for that pair, the runtime cuts to the target's frame 0.
|
|
79
|
+
|
|
80
|
+
## Two-trigger patterns worth knowing
|
|
81
|
+
|
|
82
|
+
- **Auto-advance:** a `timer` trigger with `delay` plus `go-to-state` — an intro state that walks itself to the main state after N milliseconds, no input required.
|
|
83
|
+
- **Decoupled choreography:** one layer's `click` action is `emit-signal`; another layer has a `signal` trigger that does `go-to-state`. The clicker doesn't know who's listening. This is how a single tap drives several layers into new states without hard-wiring them together.
|
|
84
|
+
|
|
85
|
+
## Inspecting an interaction
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
atelier_list_interactions({ id }); // every layer
|
|
89
|
+
atelier_list_interactions({ id, layerId: "button" }); // one layer
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The studio's interaction inspector shows each binding and lets you fire it manually to watch the transition play. If a click "does nothing," it is almost always a missing transition for that state pair — the action fired, but there was no bridge to animate across.
|
|
93
|
+
|
|
94
|
+
Next: the motion deep dive — springs, expressions, and motion paths.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: N-atel-501-detected-vs-user-edited
|
|
3
|
+
title: Detected vs. user-edited — why your fixes survive a re-run
|
|
4
|
+
type: note
|
|
5
|
+
track: ATEL-501
|
|
6
|
+
author: atelier
|
|
7
|
+
created: '2026-05-20'
|
|
8
|
+
updated: '2026-05-20'
|
|
9
|
+
tags:
|
|
10
|
+
- course
|
|
11
|
+
- atel-501
|
|
12
|
+
- video
|
|
13
|
+
- aspects
|
|
14
|
+
difficulty: intermediate
|
|
15
|
+
estimatedMinutes: 6
|
|
16
|
+
prerequisites:
|
|
17
|
+
- N-atel-101-layers
|
|
18
|
+
summary: The `~detected-vs-user-edited` aspect — detections are immutable, but your overrides (padding, userEdited/userAdded/hidden, corrected text) are re-attached across re-runs by matching on timing tolerance plus unchanged detected text.
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## The problem this aspect solves
|
|
22
|
+
|
|
23
|
+
The video pipeline is meant to be re-run. You re-transcribe after swapping the audio; you re-detect after a re-cut. But every re-run regenerates the machine's findings from scratch. If a re-run wiped your corrections, you'd never trust it — you'd transcribe once, then refuse to touch it.
|
|
24
|
+
|
|
25
|
+
`~detected-vs-user-edited` is the invariant that makes re-running safe:
|
|
26
|
+
|
|
27
|
+
> AI-detected content is immutable. User overrides are preserved across re-runs by matching fresh detections to existing entries on timing tolerance **plus** unchanged detected text.
|
|
28
|
+
|
|
29
|
+
Detected and edited values live in *separate fields* so the two never collide. A transcript word carries both:
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{ "detected": "atelyer", "text": "atelier", "start": 3.40, "end": 3.72, "userEdited": true }
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`detected` is what Whisper heard — the machine's column. `text` is what renders — yours. The same split exists on a cut: `rawStart`/`rawEnd` are detected, `paddingPre`/`paddingPost` are yours.
|
|
36
|
+
|
|
37
|
+
## How a re-run re-attaches your edits
|
|
38
|
+
|
|
39
|
+
When you re-run, the merge walks the **fresh** detections and asks, for each one: did the user override a matching old entry? "Matching" means two conditions, both required:
|
|
40
|
+
|
|
41
|
+
1. the timing is within a tolerance, **and**
|
|
42
|
+
2. the `detected` string is unchanged.
|
|
43
|
+
|
|
44
|
+
| Pipeline | Tolerance | Second condition |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| Transcript (`mergeTranscriptWithExisting`) | `0.3s` | `detected` string identical |
|
|
47
|
+
| Cuts (`mergeWithExisting`) | `0.5s` | both `rawStart` and `rawEnd` within tolerance |
|
|
48
|
+
|
|
49
|
+
If both hold, the old override (`text`, `userEdited`, `hidden`, or the cut's padding and label) is copied onto the fresh detection. If they don't, the fresh default wins — because the underlying audio genuinely changed there, and your old correction may no longer make sense.
|
|
50
|
+
|
|
51
|
+
Requiring **unchanged detected text** is the subtle part. Timing alone isn't enough: if Whisper now hears a different word at that timestamp, blindly carrying your old correction forward would graft a fix onto the wrong word. Matching the `detected` string guarantees you're editing the same finding you edited before.
|
|
52
|
+
|
|
53
|
+
## userAdded words are orphans — and they're kept
|
|
54
|
+
|
|
55
|
+
A word you inserted with `atelier transcript add` has no fresh detection to match — Whisper never heard it. These `userAdded` words are preserved as orphans and re-inserted into the right segment by their timing. Your additions don't evaporate just because they aren't in the new Whisper output.
|
|
56
|
+
|
|
57
|
+
`hidden` words (from `atelier transcript delete`) follow the same rule as edits: matched on timing + unchanged `detected`, then the `hidden` flag rides along.
|
|
58
|
+
|
|
59
|
+
## Concrete: fix a misheard word, re-transcribe, fix survives
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
# Whisper hears "atelyer" at word 12
|
|
63
|
+
atelier transcript fix ./proj --word 12 --text 'atelier'
|
|
64
|
+
# → word 12 now { detected: "atelyer", text: "atelier", userEdited: true }
|
|
65
|
+
|
|
66
|
+
# later — re-transcribe (swapped a louder take of the same audio)
|
|
67
|
+
atelier transcribe ./proj
|
|
68
|
+
# → fresh detection at ~3.40s still hears "atelyer"
|
|
69
|
+
# → matches on 0.3s timing + unchanged detected "atelyer"
|
|
70
|
+
# → your text:"atelier" + userEdited:true are re-attached
|
|
71
|
+
|
|
72
|
+
atelier transcript list ./proj
|
|
73
|
+
# [ 12] 3.40s "atelier" ← "atelyer" [edited]
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The same shape holds for cuts: nudge `--cut 3 --pad-post 0.4`, re-run `atelier trim`, and as long as cut #3's raw boundaries land within `0.5s` of where they were, your `0.4s` of trailing padding rides through. Re-detect freely; the human's editorial layer is sticky by construction.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: N-atel-501-layer-tag-isolation
|
|
3
|
+
title: Layer-tag isolation — pipelines that never step on each other
|
|
4
|
+
type: note
|
|
5
|
+
track: ATEL-501
|
|
6
|
+
author: atelier
|
|
7
|
+
created: '2026-05-20'
|
|
8
|
+
updated: '2026-05-20'
|
|
9
|
+
tags:
|
|
10
|
+
- course
|
|
11
|
+
- atel-501
|
|
12
|
+
- video
|
|
13
|
+
- aspects
|
|
14
|
+
difficulty: intermediate
|
|
15
|
+
estimatedMinutes: 5
|
|
16
|
+
prerequisites:
|
|
17
|
+
- N-atel-101-layers
|
|
18
|
+
summary: The `~layer-tag-isolation` aspect — each content pipeline owns a tag namespace and on re-run drops ONLY its own tagged layers, never touching another pipeline's output or user-authored layers. Proven by tests.
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## One document, many writers
|
|
22
|
+
|
|
23
|
+
A `project.atelier` is written by several actors at once: `atelier trim` owns the cut layers, `atelier transcribe` owns the caption layers, recipes own the overlay layers — and *you* hand-author whatever else the scene needs. They all live in the same `layers` array. Re-running any one of them must not disturb the others.
|
|
24
|
+
|
|
25
|
+
`~layer-tag-isolation` is the rule that makes that work:
|
|
26
|
+
|
|
27
|
+
> Every content pipeline owns a layer tag-namespace and, on re-run, drops ONLY its own tagged layers before re-adding them — never touching user-authored (untagged) layers or other pipelines' outputs.
|
|
28
|
+
|
|
29
|
+
This is the load-bearing invariant of the whole human-and-agent loop. Without it, every regenerate would be a gamble; with it, you can re-run a pipeline a hundred times and lose nothing you didn't ask it to touch.
|
|
30
|
+
|
|
31
|
+
## The namespaces
|
|
32
|
+
|
|
33
|
+
| Tag | Owner | Status |
|
|
34
|
+
|---|---|---|
|
|
35
|
+
| `silence-trim` | `atelier trim` | Actively written |
|
|
36
|
+
| `caption` | `atelier transcribe` / `atelier captions regenerate` | Actively written |
|
|
37
|
+
| `caption-word` | reserved for per-word caption layers | Reserved in the isolation contract |
|
|
38
|
+
| `overlay` | Studio Recipes (handle, page-number, decoration) | Written by the recipe pipeline |
|
|
39
|
+
|
|
40
|
+
`silence-trim` and `caption` are what the commands in this track produce. `caption-word` is a reserved slot in the isolation contract — the namespace is claimed so that a future per-word caption mode can't collide with phrase-level `caption` layers. `overlay` belongs to the recipe pipeline (ATEL-301 covers it). Anything **untagged** is yours, and no pipeline ever touches it.
|
|
41
|
+
|
|
42
|
+
## How a rewrite stays isolated
|
|
43
|
+
|
|
44
|
+
The mechanism is one line of intent: **filter by tag before rewriting.** When the caption builder rewrites, it partitions the document:
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
// keep everything that ISN'T a caption layer
|
|
48
|
+
const preserved = doc.layers.filter((l) => !(l.tags ?? []).includes("caption"));
|
|
49
|
+
// build fresh caption layers from the current transcript
|
|
50
|
+
const { layers: captionLayers } = buildCaptionLayers(transcript, doc.canvas, options);
|
|
51
|
+
return { ...doc, layers: [...preserved, ...captionLayers] };
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`silence-trim` layers, `overlay` layers, and your untagged layers all fall into `preserved` and pass through unchanged. Only the `caption` set is dropped and rebuilt. `atelier trim` does the identical thing for its `silence-trim` tag. Two pipelines, two namespaces, zero overlap.
|
|
55
|
+
|
|
56
|
+
The same care extends to deltas: when captions are rebuilt, only the deltas keyed to the caption layers being replaced are dropped — every other delta in the state survives.
|
|
57
|
+
|
|
58
|
+
## Proven by tests, not just by convention
|
|
59
|
+
|
|
60
|
+
Layer-tag isolation isn't an honor system. The test suite asserts the invariant directly: it seeds a document with a user-authored layer plus one pipeline's tagged layers, re-runs the *other* pipeline, and checks that the user layer and the sibling pipeline's layers are byte-for-byte present afterward. A regression that let `transcribe` clobber a `silence-trim` layer — or a hand-authored title — would turn the suite red.
|
|
61
|
+
|
|
62
|
+
That's the contract you get to lean on: tag a layer with a pipeline's namespace and that pipeline owns it; leave a layer untagged and it's untouchable. Regenerate as often as you like. The machine only ever cleans up after itself.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: N-atel-501-silence-trim
|
|
3
|
+
title: Silence trimming with parametric cuts
|
|
4
|
+
type: note
|
|
5
|
+
track: ATEL-501
|
|
6
|
+
author: atelier
|
|
7
|
+
created: '2026-05-20'
|
|
8
|
+
updated: '2026-05-20'
|
|
9
|
+
tags:
|
|
10
|
+
- course
|
|
11
|
+
- atel-501
|
|
12
|
+
- video
|
|
13
|
+
- trim
|
|
14
|
+
difficulty: intermediate
|
|
15
|
+
estimatedMinutes: 6
|
|
16
|
+
prerequisites:
|
|
17
|
+
- N-atel-101-layers
|
|
18
|
+
summary: How `atelier trim` turns ffmpeg silencedetect output into parametric video cuts — the immutable-boundary + tunable-padding model, the `--tighten`/`--loosen`/`--cut` overrides, and the `cuts.json` artifact.
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## What `atelier trim` does
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
atelier trim ./my-video-project
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
A VideoProject is a folder: a source clip, a `project.atelier` composition, and the pipeline artifacts (`cuts.json`, `transcript.json`). `atelier trim` reads the source, finds the silence, and rewrites the `silence-trim` tagged layers in `project.atelier` so the timeline plays only the parts where someone is talking.
|
|
28
|
+
|
|
29
|
+
It does this in a fixed pipeline:
|
|
30
|
+
|
|
31
|
+
1. Probe that ffmpeg has the `silencedetect` filter.
|
|
32
|
+
2. `ffprobe` the source duration.
|
|
33
|
+
3. Run `silencedetect` → silence intervals.
|
|
34
|
+
4. Invert silence into the gaps between it — the **speech intervals**.
|
|
35
|
+
5. Build a fresh `CutEntry[]` with default (or recipe) padding.
|
|
36
|
+
6. Merge with the existing `cuts.json` to preserve user padding (unless `--reset`).
|
|
37
|
+
7. Apply `--tighten` / `--loosen` / `--cut` overrides.
|
|
38
|
+
8. Resolve overlaps at silence midpoints.
|
|
39
|
+
9. Clamp boundaries to `[0, duration]`.
|
|
40
|
+
10. Write `cuts.json` and rewrite the `silence-trim` layers.
|
|
41
|
+
|
|
42
|
+
## The detect step
|
|
43
|
+
|
|
44
|
+
`silencedetect` is a threshold detector: anything quieter than `--noise` for at least `--min-silence` seconds is silence.
|
|
45
|
+
|
|
46
|
+
| Flag | Default | Meaning |
|
|
47
|
+
|---|---|---|
|
|
48
|
+
| `--noise <dB>` | `-30dB` | Below this level counts as silence |
|
|
49
|
+
| `--min-silence <seconds>` | `0.35` | Shortest gap that registers as silence |
|
|
50
|
+
|
|
51
|
+
Atelier never stores the silence directly — it inverts it. Leading silence (before the first detected gap) and trailing silence (after the last) are handled so the very start and end of a clip aren't accidentally cut.
|
|
52
|
+
|
|
53
|
+
## Immutable boundary, tunable padding
|
|
54
|
+
|
|
55
|
+
This is the core idea of the cut model. A `CutEntry` is four numbers:
|
|
56
|
+
|
|
57
|
+
```json
|
|
58
|
+
{ "rawStart": 4.21, "rawEnd": 9.83, "paddingPre": 0.08, "paddingPost": 0.12 }
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
- **`rawStart` / `rawEnd`** are the *detected* speech boundaries. They are the machine's findings and you don't edit them by hand.
|
|
62
|
+
- **`paddingPre` / `paddingPost`** are *yours*. They widen (or tighten) the cut around the raw boundary so a clip doesn't snap shut on the first or last syllable.
|
|
63
|
+
|
|
64
|
+
The effective cut is `[rawStart - paddingPre, rawEnd + paddingPost]`. Defaults are deliberately asymmetric — `paddingPre` is `0.08s`, `paddingPost` is `0.12s` — because a hair more room after a phrase reads more natural than room before it.
|
|
65
|
+
|
|
66
|
+
Why separate the two? Because re-running detection regenerates `rawStart`/`rawEnd` from the audio, but your padding is an editorial decision that should survive. That separation is the `~detected-vs-user-edited` aspect, and note 3 covers exactly how the padding gets re-attached after a re-detect.
|
|
67
|
+
|
|
68
|
+
## The three padding overrides
|
|
69
|
+
|
|
70
|
+
You tune padding without ever touching a raw boundary:
|
|
71
|
+
|
|
72
|
+
| Flag | Effect |
|
|
73
|
+
|---|---|
|
|
74
|
+
| `--tighten <ms>` | Reduce padding on **every** cut by N milliseconds |
|
|
75
|
+
| `--loosen <ms>` | Increase padding on **every** cut by N milliseconds |
|
|
76
|
+
| `--cut <index>` | Scope `--pad-pre` / `--pad-post` to **one** cut |
|
|
77
|
+
|
|
78
|
+
`--tighten` and `--loosen` are in **milliseconds** — they're converted to seconds (`ms / 1000`) and added to current padding, which is why they compose with whatever per-cut overrides already exist instead of clobbering them. Padding floors at zero; you can't tighten a cut into a negative span.
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
atelier trim ./proj --loosen 120 # everything breathes a bit more
|
|
82
|
+
atelier trim ./proj --cut 3 --pad-post 0.4 # cut #3 lingers; the rest unchanged
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`--pad-pre` / `--pad-post` without `--cut` set the *default* padding applied to freshly-detected cuts.
|
|
86
|
+
|
|
87
|
+
## Overlap and clamp
|
|
88
|
+
|
|
89
|
+
After overrides, two adjacent cuts can have padding that overruns the silence between them. `resolveOverlaps` splits the gap at its **midpoint** — neither cut wins, each takes its half. Then `clampBoundaries` pulls the first cut's `paddingPre` and the last cut's `paddingPost` back inside `[0, duration]` so nothing reaches past the clip.
|
|
90
|
+
|
|
91
|
+
## `cuts.json` is the source of truth
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
atelier trim ./proj --dry-run # print computed cuts, write nothing
|
|
95
|
+
atelier trim ./proj --json # emit the result as JSON for piping
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`cuts.json` (version `1.1`) is the durable artifact. The `silence-trim` layers in `project.atelier` are *derived* from it — a re-run drops and rebuilds them. Edit padding through the CLI, not by hand-editing the layers, and your intent persists across every re-detect.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: N-atel-501-transcribe-and-captions
|
|
3
|
+
title: Transcription, transcript editing, and captions
|
|
4
|
+
type: note
|
|
5
|
+
track: ATEL-501
|
|
6
|
+
author: atelier
|
|
7
|
+
created: '2026-05-20'
|
|
8
|
+
updated: '2026-05-20'
|
|
9
|
+
tags:
|
|
10
|
+
- course
|
|
11
|
+
- atel-501
|
|
12
|
+
- video
|
|
13
|
+
- captions
|
|
14
|
+
difficulty: intermediate
|
|
15
|
+
estimatedMinutes: 7
|
|
16
|
+
prerequisites:
|
|
17
|
+
- N-atel-101-layers
|
|
18
|
+
summary: '`atelier transcribe` (Whisper), the `atelier transcript` edit family (fix/add/delete/merge/split/list), and `atelier captions regenerate` — how spoken audio becomes word-level caption layers you can correct by hand.'
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Transcribe
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
atelier transcribe ./my-video-project
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
This runs Whisper over the source clip, writes `transcript.json`, and rewrites the `caption` tagged `TextVisual` layers in `project.atelier`. The pipeline:
|
|
28
|
+
|
|
29
|
+
1. Probe a Whisper backend.
|
|
30
|
+
2. Run Whisper → raw transcript JSON.
|
|
31
|
+
3. Parse into the `VideoTranscript` shape (segments → words).
|
|
32
|
+
4. Merge with the existing transcript to preserve your edits (unless `--reset`).
|
|
33
|
+
5. Write `transcript.json`.
|
|
34
|
+
6. Unless `--no-captions`: rebuild the caption layers.
|
|
35
|
+
|
|
36
|
+
### Backend probe order
|
|
37
|
+
|
|
38
|
+
The probe checks, in order:
|
|
39
|
+
|
|
40
|
+
1. **`whisper-cpp`** — if `whisper-cli` is on your PATH (`brew install whisper-cpp`).
|
|
41
|
+
2. **`openai-api`** — if `OPENAI_API_KEY` is set. Reserved for an opt-in path; it currently throws "not yet implemented." Install whisper.cpp for local transcription.
|
|
42
|
+
3. **`none`** — nothing available; the command throws with install guidance.
|
|
43
|
+
|
|
44
|
+
| Flag | Default | Meaning |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| `--model <name>` | `base.en` | `tiny`/`base`/`small`/`medium`/`large-v3` (and `.en` variants) |
|
|
47
|
+
| `--language <code>` | autodetect | BCP-47 hint; omit to autodetect |
|
|
48
|
+
| `--reset` | off | Discard existing edits; full fresh transcript |
|
|
49
|
+
| `--no-captions` | off | Write `transcript.json` only; skip caption layers |
|
|
50
|
+
|
|
51
|
+
whisper.cpp is run with `--word-thold 0.01` so it emits word-level timestamps. When only segment-level boundaries come back, word timing is synthesized by an even split across the segment — captions still get per-word timing to animate against.
|
|
52
|
+
|
|
53
|
+
## The transcript edit family
|
|
54
|
+
|
|
55
|
+
Whisper mishears. `atelier transcript` is the family of subcommands that correct it without re-running the model. Words are addressed by a **global index** across all segments — start with `list`:
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
atelier transcript list ./proj # every word, its index, start time, and flags
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
| Subcommand | What it does |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `fix` | Correct text — `--replace 'wrong=right'` (batch) or `--word <idx> --text '...'` (single) |
|
|
64
|
+
| `add` | Insert a user-added word — `--after-word <idx> --text '...'` (`--duration` default `0.15s`) |
|
|
65
|
+
| `delete` | Hide a word — `--word <idx>` (kept in transcript, dropped from captions) |
|
|
66
|
+
| `merge` | Join words `i` and `i+1` — `--word <i>` |
|
|
67
|
+
| `split` | Split one word at a fraction — `--word <idx> --at <0–1>` |
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
atelier transcript fix ./proj --replace 'atelyer=atelier' --replace 'lottie=Lottie'
|
|
71
|
+
atelier transcript fix ./proj --word 42 --text 'parametric'
|
|
72
|
+
atelier transcript delete ./proj --word 7
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Two behaviors worth burning in:
|
|
76
|
+
|
|
77
|
+
- **`delete` doesn't delete.** It sets `hidden` on the word. The word stays in `transcript.json` (so a re-transcribe can re-recognize it) but is skipped when captions are built. This is a soft, reversible cut.
|
|
78
|
+
- **`split --at` must be strictly between 0 and 1.** Exactly `0` or `1` is rejected — there's nothing to split off. Timing prorates across the split point; text defaults to slicing at the character midpoint, overridable with `--first` / `--second`.
|
|
79
|
+
|
|
80
|
+
Every edit subcommand regenerates the caption layers after writing the transcript, unless you pass `--no-regenerate`.
|
|
81
|
+
|
|
82
|
+
## Captions: phrases and word-level timing
|
|
83
|
+
|
|
84
|
+
Captions aren't one layer per word — words are grouped into **phrases**, one `caption` tagged layer each. A new phrase starts whenever any of these is true:
|
|
85
|
+
|
|
86
|
+
- the phrase reaches the max-words ceiling (default 5),
|
|
87
|
+
- the current word ends in `. ! ? , ; :`, or
|
|
88
|
+
- the gap to the next word exceeds the pause threshold (default `0.4s`).
|
|
89
|
+
|
|
90
|
+
Hidden words are skipped during grouping. Each phrase layer is a `TextVisual` placed in the lower third (`yRatio` default `0.85`) with `opacity: 0`, and gets a fade-in / fade-out delta pair keyed to its start and end times.
|
|
91
|
+
|
|
92
|
+
## Regenerate without re-running Whisper
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
atelier captions regenerate ./proj
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
When you only change *styling* — font, size, position, phrase grouping — there's no reason to transcribe again. `captions regenerate` re-derives the caption layers from the current `transcript.json`. Its only flag is `--recipe <name>`, which applies a Studio Recipe's `caption_style` + `caption_grouping`. Whisper stays untouched; your transcript edits stay intact.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: N-atel-601-carousel
|
|
3
|
+
title: The carousel driver — a folder of images into posts
|
|
4
|
+
type: note
|
|
5
|
+
track: ATEL-601
|
|
6
|
+
author: atelier
|
|
7
|
+
created: '2026-05-20'
|
|
8
|
+
updated: '2026-05-20'
|
|
9
|
+
tags:
|
|
10
|
+
- course
|
|
11
|
+
- atel-601
|
|
12
|
+
- carousel
|
|
13
|
+
- batch
|
|
14
|
+
difficulty: intermediate
|
|
15
|
+
estimatedMinutes: 5
|
|
16
|
+
prerequisites:
|
|
17
|
+
- N-atel-301-composition-and-overlays
|
|
18
|
+
summary: atelier carousel takes a recipe plus a folder of images and composes one PNG per image, threading currentIndex/totalCount into the page-number overlay so each post reads "i/N". The folder → posts → destination workflow, the 1-based index, fit-to-canvas composition, and zero-padded sortable output names.
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## The workflow: folder into posts
|
|
22
|
+
|
|
23
|
+
A carousel is the multi-image-post format — a swipeable deck where each frame is its own image with consistent branding and a "where am I in the deck" indicator. The `atelier carousel` command is the batch driver for exactly that:
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
atelier carousel <recipe> --inputs <glob> --out-dir <dir>
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The shape of the workflow is **folder → posts → destination.** Point `--inputs` at a folder of images, hand it a recipe, name a `--out-dir`, and it composes one recipe-overlaid PNG per image. Short forms `-i` / `-d` exist; `--width` / `--height` set the canvas (default 1080×1080), and `-f` / `--frame` pick the frame to render.
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
atelier carousel my-style --inputs ./shots --out-dir ./posts
|
|
33
|
+
atelier carousel my-style -i "shots/*.png" -d ./posts --width 1080 --height 1350
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`--inputs` accepts a directory (every image inside), a single file, or a single-segment `*`-glob in the final path component (`shots/*.png`). Multi-segment and recursive globs are out of scope — point it at a directory instead. Accepted extensions are `.png`, `.jpg`, `.jpeg`, `.webp`. The matched list is sorted lexicographically so ordering is stable and predictable.
|
|
37
|
+
|
|
38
|
+
## Per-image composition
|
|
39
|
+
|
|
40
|
+
For each image, `composeCarouselFrameDoc` builds a minimal single-frame document: a canvas-sized doc with a background and one fit-to-canvas `ImageVisual` layer (anchored centered at `{0.5, 0.5}`). The image's natural dimensions aren't known until node-canvas decodes the file, so the bounds start at the canvas extent and are re-fitted (`refitImageBounds`) once the real dimensions are available at render time.
|
|
41
|
+
|
|
42
|
+
Then the recipe's `overlay_rules` are applied on top of that base doc — the handle (if any) and, crucially, the page-number.
|
|
43
|
+
|
|
44
|
+
## currentIndex / totalCount threaded into page-number
|
|
45
|
+
|
|
46
|
+
This is what makes the carousel a carousel. The driver loops over the sorted inputs and, for image `n`, applies the recipe with a **1-based** index:
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
const index = n + 1; // 1-based
|
|
50
|
+
applyRecipeToOverlay(baseDoc, recipe, { currentIndex: index, totalCount: total });
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`totalCount` is the count of matched images. Because both context values are present, the page-number layer *is* emitted (unlike a single-frame `apply-recipe`), and `renderPageNumberFormat` substitutes them into the format string. A recipe whose `page_number.format` is `"{current:02d}/{total:02d}"` produces `01/05`, `02/05`, … across a five-image deck. This is the one place page-numbers come fully alive — the carousel batch is the natural carrier of the `i/N` context.
|
|
54
|
+
|
|
55
|
+
## Output names: zero-padded and sortable
|
|
56
|
+
|
|
57
|
+
Each composed PNG is written to `--out-dir` (created if missing) with a zero-padded, sortable name (`carouselFileName`):
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
03-myphoto.png # prefix = index, zero-padded to max(2, digits-in-N)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The prefix width is `Math.max(2, String(total).length)` — always at least two digits, widening when the deck has 100+ images — so the files sort in deck order in any file browser or upload picker, then carry the original stem and a `.png` extension. The page-number *content* and the filename *prefix* both derive from the same 1-based index, so what the viewer sees and how the files sort stay in agreement.
|
|
64
|
+
|
|
65
|
+
## Where it sits in the pipeline
|
|
66
|
+
|
|
67
|
+
The carousel driver renders through the same shared export-image path the rest of the CLI uses, so it inherits node-canvas's font and image handling. It needs node-canvas available; if the optional native renderer is missing, it fails with a clear `CanvasUnavailableError` rather than a cryptic crash.
|
|
68
|
+
|
|
69
|
+
The carousel is the culmination of the recipe story: one authored style, one folder of raw images, and a complete branded, numbered, swipeable deck out the other end — every overlay placed by the same `overlay_rules` translator and the same layer-tag isolation invariant that protects every other Atelier pipeline.
|
|
70
|
+
|
|
71
|
+
That closes ATEL-601. You can now author a Studio Recipe, apply it through the CLI and through MCP, attach anchored handle and page-number overlays, and batch a folder of images into a finished carousel.
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: N-atel-601-overlay-rules
|
|
3
|
+
title: Overlay rules — anchored handle and page-number presets
|
|
4
|
+
type: note
|
|
5
|
+
track: ATEL-601
|
|
6
|
+
author: atelier
|
|
7
|
+
created: '2026-05-20'
|
|
8
|
+
updated: '2026-05-20'
|
|
9
|
+
tags:
|
|
10
|
+
- course
|
|
11
|
+
- atel-601
|
|
12
|
+
- overlays
|
|
13
|
+
- recipe
|
|
14
|
+
difficulty: intermediate
|
|
15
|
+
estimatedMinutes: 6
|
|
16
|
+
prerequisites:
|
|
17
|
+
- N-atel-301-composition-and-overlays
|
|
18
|
+
summary: overlay_rules (Phase 1.5) declares two anchored text presets — handle and page_number. Page-number formats support {current}/{total} and zero-padded {current:02d}/{total:02d}. Anchors map to four corners via frame + anchorPoint. Overlays live in the overlay tag namespace and the translator builds a brand-new document (~copy-on-write-doc).
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## The overlay_rules block
|
|
22
|
+
|
|
23
|
+
`overlay_rules` was promoted from the reserved Phase 3 set into first-class **Phase 1.5**. It declares anchored text overlays that mark a composition. There are two sub-blocks, both optional:
|
|
24
|
+
|
|
25
|
+
```yaml
|
|
26
|
+
overlay_rules:
|
|
27
|
+
handle:
|
|
28
|
+
text: "@username"
|
|
29
|
+
anchor: "bottom-left"
|
|
30
|
+
margin: 32
|
|
31
|
+
style:
|
|
32
|
+
font_family: "Inter"
|
|
33
|
+
font_size: 36
|
|
34
|
+
font_weight: "bold"
|
|
35
|
+
color: "#FFFFFF"
|
|
36
|
+
page_number:
|
|
37
|
+
format: "{current:02d}/{total:02d}"
|
|
38
|
+
anchor: "bottom-right"
|
|
39
|
+
margin: 32
|
|
40
|
+
style:
|
|
41
|
+
font_family: "Inter"
|
|
42
|
+
font_size: 18
|
|
43
|
+
font_weight: "normal"
|
|
44
|
+
color: "#9CA3AF"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The translator that turns these rules into layers is `applyRecipeToOverlay` (in `packages/cli/src/lib/recipe.ts`). It builds at most one `handle` layer and one `page_number` layer, both `TextVisual`.
|
|
48
|
+
|
|
49
|
+
## handle — a static marker
|
|
50
|
+
|
|
51
|
+
A `handle` is a literal text overlay: a social handle, a credit, a watermark. Its `OverlayHandleRule` shape is `{ text, anchor, margin?, style? }`. The `text` is rendered verbatim — template variables are reserved for later, so today `text` is exactly the string that appears.
|
|
52
|
+
|
|
53
|
+
The built layer always gets the stable id **`overlay-handle`** and `tags: ["overlay"]`. The handle is *unconditional*: if `overlay_rules.handle` is present, the handle layer is always produced, on single frames and carousel pages alike.
|
|
54
|
+
|
|
55
|
+
## page_number — a parameterized template
|
|
56
|
+
|
|
57
|
+
A `page_number` is a template, not a literal. Its `OverlayPageNumberRule` shape is `{ format, anchor, margin?, style? }`, and `format` is a string with placeholders:
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
"{current}/{total}" -> "3/5"
|
|
61
|
+
"{current:02d}/{total:02d}" -> "03/05"
|
|
62
|
+
"Page {current} of {total}" -> "Page 3 of 5"
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Two placeholder forms are recognized: bare `{current}` / `{total}`, and the zero-padded `{current:0Nd}` / `{total:0Nd}` form (the `N` is the pad width, so `{current:02d}` pads to two digits). The schema (`^valid-recipe` gate) enforces the syntax at validate time and requires **at least one** of `{current}` / `{total}` to appear — a format with neither is rejected. Render-time substitution happens in `renderPageNumberFormat`, which is a regex replace over those two placeholder shapes.
|
|
66
|
+
|
|
67
|
+
The built layer gets the stable id **`overlay-page-number`** and `tags: ["overlay"]`.
|
|
68
|
+
|
|
69
|
+
**Page-number needs carousel context.** `applyRecipeToOverlay` only emits the page-number layer when both `currentIndex` and `totalCount` are passed in its context argument. On a single-frame apply (no carousel), the context is absent, so the layer is skipped with a one-shot warning — `01/01` on a standalone post is misleading, so skipping is by design. The handle is unaffected. The carousel driver (next note) is what threads `currentIndex`/`totalCount` in.
|
|
70
|
+
|
|
71
|
+
## The four-corner anchor math
|
|
72
|
+
|
|
73
|
+
`anchor` is one of `top-left | top-right | bottom-left | bottom-right` (the `OverlayAnchor` type). The translator's `anchorToFrame` maps each corner to a `frame` position plus an `anchorPoint`, with `margin` (default 24px) insetting from the anchored edges:
|
|
74
|
+
|
|
75
|
+
| `anchor` | `frame` | `anchorPoint` |
|
|
76
|
+
|---|---|---|
|
|
77
|
+
| `top-left` | `{ x: margin, y: margin }` | `{ x: 0, y: 0 }` |
|
|
78
|
+
| `top-right` | `{ x: canvas.width - margin, y: margin }` | `{ x: 1, y: 0 }` |
|
|
79
|
+
| `bottom-left` | `{ x: margin, y: canvas.height - margin }` | `{ x: 0, y: 1 }` |
|
|
80
|
+
| `bottom-right` | `{ x: canvas.width - margin, y: canvas.height - margin }` | `{ x: 1, y: 1 }` |
|
|
81
|
+
|
|
82
|
+
The pairing of `frame` and `anchorPoint` is what makes the corner stick. A `bottom-right` overlay anchors its own bottom-right corner (`anchorPoint {1, 1}`) at the canvas's bottom-right-minus-margin point — so the text grows inward, away from the edge, regardless of how long it is. The anchor also doubles as the rotation/scale pivot if you later animate the overlay.
|
|
83
|
+
|
|
84
|
+
The `style` block is a partial `OverlayTextStyle` (`font_family`, `font_size`, `font_weight`, `color`) and merges over a runtime baseline of `Inter / 24px / 600 / #F5F5F7` — so an overlay with no `style` still renders legibly.
|
|
85
|
+
|
|
86
|
+
## The overlay tag namespace and ~copy-on-write-doc
|
|
87
|
+
|
|
88
|
+
Two invariants govern how overlays touch a document.
|
|
89
|
+
|
|
90
|
+
**Layer-tag isolation (`~layer-tag-isolation`).** Every overlay layer carries `tags: ["overlay"]`. Before appending the freshly-derived overlays, `applyRecipeToOverlay` filters out *every* existing `overlay`-tagged layer: `doc.layers.filter((l) => !(l.tags ?? []).includes("overlay"))`. Re-applying a recipe drops the prior overlay set and re-adds — idempotent, repeatable. User-authored (untagged) layers, caption-pipeline layers (`tags: ["caption"]`), and silence-trim layers are never touched. The stable ids (`overlay-handle`, `overlay-page-number`) mean a re-apply replaces in place rather than stacking duplicates.
|
|
91
|
+
|
|
92
|
+
**Copy-on-write (`~copy-on-write-doc`).** The translator never mutates the input. It constructs a *new* document — spreads the doc, swaps in a new layers array — and returns it: `return { ...doc, layers: [...preserved, ...overlayLayers] }`. The input document is left untouched. This guarantees a concurrent reader (notably the studio's live WebSocket bridge) can never observe a half-mutated document mid-update.
|
|
93
|
+
|
|
94
|
+
## One translator, two homes
|
|
95
|
+
|
|
96
|
+
`applyRecipeToOverlay` exists in two places: the canonical version in `packages/cli/src/lib/recipe.ts` and a deliberate duplicate in `packages/mcp/src/tools/recipes.ts`. The MCP package does *not* depend on the CLI package — importing it would risk a cli↔mcp dependency cycle — so the overlay translator (and `renderPageNumberFormat`) are re-implemented on top of the shared schema package. The duplication is intentional and documented in the MCP file; both implementations honor the same two invariants above.
|