@a-company/atelier 0.37.0 → 0.38.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.
Files changed (45) hide show
  1. package/dist/{chunk-JPZ4F4PW.js → chunk-3ARBOSWY.js} +64 -5
  2. package/dist/chunk-3ARBOSWY.js.map +1 -0
  3. package/dist/cli.js +11469 -413
  4. package/dist/cli.js.map +1 -1
  5. package/dist/{dist-M67UZGFQ.js → dist-3YQK6PI6.js} +2 -2
  6. package/dist/index.cjs +3193 -227
  7. package/dist/index.cjs.map +1 -1
  8. package/dist/index.d.cts +701 -8
  9. package/dist/index.d.ts +701 -8
  10. package/dist/index.js +7237 -72
  11. package/dist/index.js.map +1 -1
  12. package/dist/mcp.js +2898 -507
  13. package/dist/mcp.js.map +1 -1
  14. package/package.json +6 -6
  15. package/src/web/inline-app.ts +55 -4
  16. package/src/web/timeline-state-types.ts +28 -0
  17. package/src/web/timeline-view.test.ts +99 -0
  18. package/src/web/timeline-view.ts +339 -0
  19. package/src/web/workspace-app.ts +3146 -0
  20. package/templates/workspace/.claude/agents/atelier-iris.md +75 -0
  21. package/templates/workspace/.claude/agents/atelier-lux.md +67 -0
  22. package/templates/workspace/.claude/agents/atelier-quill.md +61 -0
  23. package/templates/workspace/.gitignore +30 -0
  24. package/templates/workspace/.paradigm/personas/_shared/cascade-merge.md +172 -0
  25. package/templates/workspace/CLAUDE.md +93 -0
  26. package/templates/workspace/README.md +75 -0
  27. package/templates/workspace/SETUP.md +127 -0
  28. package/templates/workspace/_brand/.atelier-brand.yaml +34 -0
  29. package/templates/workspace/_brand/DESIGN.md +56 -0
  30. package/templates/workspace/_brand/SCRIPT.md +41 -0
  31. package/templates/workspace/_brand/STORYBOARD.md +33 -0
  32. package/templates/workspace/_packs/README.md +54 -0
  33. package/templates/workspace/projects/README.md +49 -0
  34. package/templates/workspace/workspace.atelier +22 -0
  35. package/university/index.yaml +1 -1
  36. package/dist/chunk-5QQESXI6.js +0 -4432
  37. package/dist/chunk-5QQESXI6.js.map +0 -1
  38. package/dist/chunk-JPZ4F4PW.js.map +0 -1
  39. package/dist/cli.cjs +0 -6313
  40. package/dist/cli.cjs.map +0 -1
  41. package/dist/cli.d.cts +0 -1
  42. package/dist/cli.d.ts +0 -1
  43. package/dist/mcp.cjs +0 -5462
  44. package/dist/mcp.cjs.map +0 -1
  45. /package/dist/{dist-M67UZGFQ.js.map → dist-3YQK6PI6.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a-company/atelier",
3
- "version": "0.37.0",
3
+ "version": "0.38.0",
4
4
  "description": "CLI tool — validate, preview, render, info commands",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -27,7 +27,6 @@
27
27
  "universityPack": "university/"
28
28
  },
29
29
  "dependencies": {
30
- "@a-company/atelier-studio": ">=0.25.1",
31
30
  "@modelcontextprotocol/sdk": "^1.0.0",
32
31
  "@napi-rs/canvas": "^0.1.0",
33
32
  "commander": "^13.0.0",
@@ -35,7 +34,8 @@
35
34
  "ws": "^8.20.1",
36
35
  "yaml": "^2.7.0",
37
36
  "zod": "^3.25.0",
38
- "@a-company/atelier-mcp": "0.27.2"
37
+ "@a-company/atelier-mcp": "0.27.2",
38
+ "@a-company/atelier-studio": "^0.28.1"
39
39
  },
40
40
  "peerDependencies": {
41
41
  "canvas": ">=2.0.0"
@@ -54,12 +54,12 @@
54
54
  "tsup": "^8.4.0",
55
55
  "typescript": "^5.7.0",
56
56
  "vitest": "^3.0.0",
57
- "@a-company/atelier-lottie": "0.25.1",
58
- "@a-company/atelier-schema": "0.28.0",
59
57
  "@a-company/atelier-canvas": "0.26.0",
58
+ "@a-company/atelier-lottie": "0.25.1",
60
59
  "@a-company/atelier-svg": "0.25.1",
61
60
  "@a-company/atelier-core": "0.26.0",
62
- "@a-company/atelier-types": "0.31.0"
61
+ "@a-company/atelier-types": "0.31.0",
62
+ "@a-company/atelier-schema": "0.28.0"
63
63
  },
64
64
  "scripts": {
65
65
  "build": "tsup",
@@ -72,11 +72,42 @@ interface FileEntry {
72
72
  }
73
73
 
74
74
  /**
75
- * Boot the in-browser Atelier Studio client. Wires up the file sidebar, the
75
+ * Boot the in-browser Atelier Studio client.
76
+ *
77
+ * Two modes:
78
+ * - **workspace** — when the server reports `/api/workspace` 200 OK, the
79
+ * workspace-aware UI (sidebar tree + main-panel adapter + drop-zone)
80
+ * boots from `workspace-app.ts`. This is the v1 UX bundle.
81
+ * - **project** — legacy single-document UI, untouched (see below).
82
+ *
83
+ * The dispatch happens BEFORE any of the legacy DOM is built so workspace mode
84
+ * gets a clean root; the legacy code path runs only when `/api/workspace`
85
+ * responds 404 (project mode or detached).
86
+ */
87
+ export async function bootStudioApp(config: StudioAppConfig): Promise<void> {
88
+ // Probe the workspace endpoint. A 200 means we're rooted at a workspace dir
89
+ // and should render the workspace UI; anything else falls through to the
90
+ // legacy single-document path.
91
+ try {
92
+ const probe = await fetch("/api/workspace");
93
+ if (probe.ok) {
94
+ const mod = await import("./workspace-app.js");
95
+ await mod.bootWorkspaceApp();
96
+ return;
97
+ }
98
+ } catch {
99
+ // Endpoint missing or network failure → legacy path. Non-fatal.
100
+ }
101
+
102
+ bootLegacyApp(config);
103
+ }
104
+
105
+ /**
106
+ * Legacy single-document client. Wires up the flat file sidebar, the
76
107
  * AtelierStudio editor + autosave, image drag-drop, the WS bridge, and the
77
- * export-all flow. Called once by the temp-dir entry shim.
108
+ * export-all flow. Behavior preserved exactly from pre-workspace days.
78
109
  */
79
- export function bootStudioApp(config: StudioAppConfig): void {
110
+ function bootLegacyApp(config: StudioAppConfig): void {
80
111
  // ── State ──
81
112
  let studio: AtelierStudio | null = null;
82
113
  let currentFile: string | null = null;
@@ -203,7 +234,7 @@ export function bootStudioApp(config: StudioAppConfig): void {
203
234
  const overlay = document.createElement("div");
204
235
  overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,0.75);display:flex;align-items:center;justify-content:center;z-index:10000";
205
236
  const card = document.createElement("div");
206
- card.style.cssText = "background:#333;border:1px solid #4A4A4A;border-radius:8px;padding:32px 40px;min-width:360px;color:#F5F0EB;font-family:'Cormorant Garamond',Georgia,serif";
237
+ card.style.cssText = "background:#333;border:1px solid #4A4A4A;border-radius:8px;padding:32px 40px;min-width:360px;color:#F5F0EB;font-family:'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif";
207
238
  overlay.appendChild(card);
208
239
  document.body.appendChild(overlay);
209
240
 
@@ -560,6 +591,13 @@ export function bootStudioApp(config: StudioAppConfig): void {
560
591
  const result = parseAtelier(content);
561
592
 
562
593
  if (!result.success) {
594
+ // Destroy the prior studio BEFORE wiping the container — otherwise the
595
+ // next loadFile's destroy() call would try to removeChild on a node
596
+ // that's already been wiped via innerHTML="" (NotFoundError).
597
+ if (studio) {
598
+ studio.destroy();
599
+ studio = null;
600
+ }
563
601
  editorContainer.innerHTML = "";
564
602
  const err = document.createElement("div");
565
603
  err.className = "main__empty";
@@ -647,6 +685,19 @@ export function bootStudioApp(config: StudioAppConfig): void {
647
685
  mode: "full",
648
686
  initialTab: "yaml",
649
687
  allowSave: true,
688
+ // Resolve doc-relative srcs against the currently-loaded file's
689
+ // directory. inline-app reuses one studio instance across file
690
+ // switches, so we read `currentFile` at call time (not closure time)
691
+ // — otherwise switching docs would leave the resolver pinned to the
692
+ // first one. See workspace-app for the same builder.
693
+ srcResolver: (src: string): string => {
694
+ if (/^([a-z]+:|\/|data:)/i.test(src)) return src;
695
+ const path = currentFile;
696
+ if (!path) return src;
697
+ const docDir = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "";
698
+ const full = docDir ? `${docDir}/${src}` : src;
699
+ return `/api/file?path=${encodeURIComponent(full)}`;
700
+ },
650
701
  onDocumentChange: (doc) => {
651
702
  // Auto-save with debounce — flip to "saving" immediately so the user
652
703
  // knows their edit was registered even while the timeout is pending.
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Browser-side shared state types for the bottom timeline UI (TD-2026-05-28).
3
+ *
4
+ * The timeline is a READ-ONLY view of the active doc's `timeline` block in
5
+ * v1.0 — click to select, no drag-to-reposition (that's v1.1). These types
6
+ * model the selection + frame-conversion helpers shared between the
7
+ * canvas-panel and the new TimelineView component.
8
+ */
9
+
10
+ /** What's currently selected on the timeline, if anything. */
11
+ export interface TimelineSelection {
12
+ /** Layer id of the selected clip — matches a layer in doc.layers. */
13
+ layerId: string;
14
+ /** Track id the clip belongs to (e.g. "track-video-1"). */
15
+ trackId: string;
16
+ }
17
+
18
+ /** Convert a composition frame to a horizontal pixel offset in the timeline. */
19
+ export function frameToPixel(frame: number, totalFrames: number, widthPx: number): number {
20
+ if (totalFrames <= 0) return 0;
21
+ return Math.max(0, Math.min(widthPx, (frame / totalFrames) * widthPx));
22
+ }
23
+
24
+ /** Inverse — convert a pixel offset back to a composition frame. */
25
+ export function pixelToFrame(px: number, totalFrames: number, widthPx: number): number {
26
+ if (widthPx <= 0) return 0;
27
+ return Math.max(0, Math.min(totalFrames, Math.round((px / widthPx) * totalFrames)));
28
+ }
@@ -0,0 +1,99 @@
1
+ // Tests for #bottom-timeline-view's pure layout helper (`computeClipPositions`).
2
+ //
3
+ // The DOM-bound parts of TimelineView are manually verified — there's no jsdom
4
+ // harness in this repo (Kit-D from an earlier session). Anything pure lands
5
+ // here so the math is regression-guarded.
6
+
7
+ import { describe, it, expect } from "vitest";
8
+
9
+ import { computeClipPositions } from "./timeline-view.js";
10
+
11
+ describe("computeClipPositions", () => {
12
+ it("places a single clip at the start of the lane", () => {
13
+ const out = computeClipPositions(
14
+ [{ layerId: "clip-1", startFrame: 0, endFrame: 30, source: "media/IMG_7346.MOV" }],
15
+ "track-video-1",
16
+ 150,
17
+ 1000,
18
+ );
19
+ expect(out).toHaveLength(1);
20
+ expect(out[0].leftPx).toBe(0);
21
+ // 30 / 150 * 1000 = 200
22
+ expect(out[0].widthPx).toBeCloseTo(200);
23
+ expect(out[0].trackId).toBe("track-video-1");
24
+ expect(out[0].layerId).toBe("clip-1");
25
+ });
26
+
27
+ it("derives the label from the source basename without extension", () => {
28
+ const out = computeClipPositions(
29
+ [{ layerId: "clip-1", startFrame: 0, endFrame: 30, source: "media/IMG_7346.MOV" }],
30
+ "track-video-1",
31
+ 150,
32
+ 1000,
33
+ );
34
+ expect(out[0].label).toBe("IMG_7346");
35
+ });
36
+
37
+ it("falls back to layerId when source is empty", () => {
38
+ const out = computeClipPositions(
39
+ [{ layerId: "clip-fallback", startFrame: 0, endFrame: 30, source: "" }],
40
+ "track-video-1",
41
+ 150,
42
+ 1000,
43
+ );
44
+ expect(out[0].label).toBe("clip-fallback");
45
+ });
46
+
47
+ it("positions sequential clips proportionally to startFrame/endFrame", () => {
48
+ const out = computeClipPositions(
49
+ [
50
+ { layerId: "clip-a", startFrame: 0, endFrame: 60, source: "a.mov" },
51
+ { layerId: "clip-b", startFrame: 60, endFrame: 120, source: "b.mov" },
52
+ ],
53
+ "track-video-1",
54
+ 120,
55
+ 600,
56
+ );
57
+ expect(out[0].leftPx).toBe(0);
58
+ expect(out[0].widthPx).toBeCloseTo(300);
59
+ expect(out[1].leftPx).toBeCloseTo(300);
60
+ expect(out[1].widthPx).toBeCloseTo(300);
61
+ });
62
+
63
+ it("clamps zero-length clips to a minimum 4px width", () => {
64
+ const out = computeClipPositions(
65
+ [{ layerId: "clip-zero", startFrame: 30, endFrame: 30, source: "z.mov" }],
66
+ "track-video-1",
67
+ 150,
68
+ 1000,
69
+ );
70
+ expect(out[0].widthPx).toBe(4);
71
+ });
72
+
73
+ it("returns an empty array for an empty clip list", () => {
74
+ const out = computeClipPositions([], "track-video-1", 150, 1000);
75
+ expect(out).toEqual([]);
76
+ });
77
+
78
+ it("handles totalFrames=0 by collapsing every clip to the lane origin", () => {
79
+ const out = computeClipPositions(
80
+ [{ layerId: "clip-1", startFrame: 0, endFrame: 30, source: "a.mov" }],
81
+ "track-video-1",
82
+ 0,
83
+ 1000,
84
+ );
85
+ expect(out[0].leftPx).toBe(0);
86
+ // endPx - leftPx === 0, so the 4px floor kicks in.
87
+ expect(out[0].widthPx).toBe(4);
88
+ });
89
+
90
+ it("strips only the last extension from a dotted basename", () => {
91
+ const out = computeClipPositions(
92
+ [{ layerId: "clip-1", startFrame: 0, endFrame: 30, source: "media/scene.take.1.mov" }],
93
+ "track-video-1",
94
+ 150,
95
+ 1000,
96
+ );
97
+ expect(out[0].label).toBe("scene.take.1");
98
+ });
99
+ });
@@ -0,0 +1,339 @@
1
+ /**
2
+ * #bottom-timeline-view — read-only timeline UI mounted BELOW the canvas in
3
+ * the workspace studio (TD-2026-05-28, Wave A — Unit B).
4
+ *
5
+ * v1.0 scope is viewer-only:
6
+ * - click a clip block to select it (emits `onSelectClip`)
7
+ * - click the ruler to seek (emits `onSeek`)
8
+ * - the canvas-panel's scrubber drives `setFrame()` to position the playhead
9
+ *
10
+ * Drag-to-reposition is v1.1 territory. The doc remains untouched by this
11
+ * component — selections and seeks are emitted upward; mutation is the
12
+ * caller's job (and not in scope for v1.0).
13
+ *
14
+ * Reads from `doc.timeline` — the derived summary block. When that block is
15
+ * missing or empty, the view renders a muted "No clips" placeholder so empty
16
+ * docs and image-only projects degrade gracefully.
17
+ *
18
+ * Vanilla DOM + inline styles to match the workspace-app convention (no
19
+ * framework, no new dep). All atel- prefixed classes are scoped via the
20
+ * injected stylesheet in workspace-app.ts.
21
+ */
22
+
23
+ import type { AtelierDocument } from "@a-company/atelier-types";
24
+
25
+ import {
26
+ type TimelineSelection,
27
+ frameToPixel,
28
+ pixelToFrame,
29
+ } from "./timeline-state-types.js";
30
+
31
+ export interface TimelineViewOptions {
32
+ /** Container element to mount into (typically below the canvas). */
33
+ container: HTMLElement;
34
+ /** Called when the user clicks a clip block to select it. */
35
+ onSelectClip: (selection: TimelineSelection | null) => void;
36
+ /** Called when the user clicks somewhere on the ruler to seek. */
37
+ onSeek: (frame: number) => void;
38
+ }
39
+
40
+ /** Per-clip layout slot, output of the pure `computeClipPositions` helper. */
41
+ export interface ClipPosition {
42
+ layerId: string;
43
+ trackId: string;
44
+ /** Display label — `layerId` or the source basename without extension. */
45
+ label: string;
46
+ startFrame: number;
47
+ endFrame: number;
48
+ leftPx: number;
49
+ widthPx: number;
50
+ }
51
+
52
+ interface ClipLike {
53
+ layerId: string;
54
+ startFrame: number;
55
+ endFrame: number;
56
+ source: string;
57
+ }
58
+
59
+ /**
60
+ * Pure layout helper — converts a clip array into pixel-positioned slots.
61
+ * Extracted so the math is unit-testable without a DOM harness (the project
62
+ * has no jsdom; everything else is verified manually).
63
+ *
64
+ * Each slot's `widthPx` clamps to a minimum of 4px so a zero-length clip
65
+ * stays clickable. A `leftPx + widthPx` past the lane edge stays anchored at
66
+ * the lane width — `frameToPixel` already clamps to `[0, widthPx]`.
67
+ */
68
+ export function computeClipPositions(
69
+ clips: ClipLike[],
70
+ trackId: string,
71
+ totalFrames: number,
72
+ laneWidthPx: number,
73
+ ): ClipPosition[] {
74
+ const out: ClipPosition[] = [];
75
+ for (const clip of clips) {
76
+ const leftPx = frameToPixel(clip.startFrame, totalFrames, laneWidthPx);
77
+ const endPx = frameToPixel(clip.endFrame, totalFrames, laneWidthPx);
78
+ const rawWidth = endPx - leftPx;
79
+ const widthPx = Math.max(4, rawWidth);
80
+ out.push({
81
+ layerId: clip.layerId,
82
+ trackId,
83
+ label: deriveClipLabel(clip),
84
+ startFrame: clip.startFrame,
85
+ endFrame: clip.endFrame,
86
+ leftPx,
87
+ widthPx,
88
+ });
89
+ }
90
+ return out;
91
+ }
92
+
93
+ function deriveClipLabel(clip: ClipLike): string {
94
+ // Prefer the source basename (without extension) — it's the artifact name
95
+ // the creator recognizes from the sidebar. Falls back to the layerId when
96
+ // the source is empty or odd.
97
+ if (clip.source && clip.source.length > 0) {
98
+ const base = clip.source.split("/").pop() ?? clip.source;
99
+ const dot = base.lastIndexOf(".");
100
+ return dot > 0 ? base.slice(0, dot) : base;
101
+ }
102
+ return clip.layerId;
103
+ }
104
+
105
+ /** Escape minimal HTML — duplicated locally to avoid a cross-module import. */
106
+ function esc(s: string): string {
107
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
108
+ }
109
+
110
+ export class TimelineView {
111
+ private readonly opts: TimelineViewOptions;
112
+ private rootEl: HTMLElement | null = null;
113
+ private rulerEl: HTMLElement | null = null;
114
+ private laneEl: HTMLElement | null = null;
115
+ private playheadEl: HTMLElement | null = null;
116
+ private emptyEl: HTMLElement | null = null;
117
+
118
+ private doc: AtelierDocument | null = null;
119
+ private selection: TimelineSelection | null = null;
120
+ private currentFrame: number = 0;
121
+
122
+ /** Cached clip DOM, keyed by `${trackId}::${layerId}` for selection updates. */
123
+ private clipEls = new Map<string, HTMLElement>();
124
+
125
+ constructor(opts: TimelineViewOptions) {
126
+ this.opts = opts;
127
+ }
128
+
129
+ /** Mount the timeline DOM into the container. Idempotent. */
130
+ mount(): void {
131
+ if (this.rootEl) return;
132
+
133
+ const root = document.createElement("div");
134
+ root.className = "atel-timeline-view";
135
+ this.rootEl = root;
136
+
137
+ // Ruler row — frame ticks + numbers (every fps frames = every second).
138
+ const ruler = document.createElement("div");
139
+ ruler.className = "atel-timeline-view__ruler";
140
+ ruler.addEventListener("click", (ev) => this.handleRulerClick(ev));
141
+ root.appendChild(ruler);
142
+ this.rulerEl = ruler;
143
+
144
+ // Track lane — single row (video track only in v1.0).
145
+ const lane = document.createElement("div");
146
+ lane.className = "atel-timeline-view__lane";
147
+ // Background click on the lane clears the selection.
148
+ lane.addEventListener("click", (ev) => {
149
+ if (ev.target === lane) {
150
+ this.setSelection(null);
151
+ this.opts.onSelectClip(null);
152
+ }
153
+ });
154
+ root.appendChild(lane);
155
+ this.laneEl = lane;
156
+
157
+ // Playhead — vertical line drawn above ruler + lane.
158
+ const playhead = document.createElement("div");
159
+ playhead.className = "atel-timeline-view__playhead";
160
+ playhead.style.display = "none";
161
+ root.appendChild(playhead);
162
+ this.playheadEl = playhead;
163
+
164
+ // Empty-state — shown when no doc or no clips.
165
+ const empty = document.createElement("div");
166
+ empty.className = "atel-timeline-view__empty";
167
+ empty.textContent = "No clips";
168
+ empty.style.display = "none";
169
+ root.appendChild(empty);
170
+ this.emptyEl = empty;
171
+
172
+ this.opts.container.appendChild(root);
173
+
174
+ // Recompute layout on resize so clip block widths track the lane width.
175
+ // The browser fires `resize` on the window; the workspace-main column is
176
+ // flex-driven, so a window resize is the only trigger we need to handle
177
+ // here (no internal resizers in v1.0).
178
+ this.resizeHandler = (): void => this.renderClips();
179
+ window.addEventListener("resize", this.resizeHandler);
180
+
181
+ this.renderAll();
182
+ }
183
+
184
+ private resizeHandler: (() => void) | null = null;
185
+
186
+ /** Update with a new doc. Pass null to clear. */
187
+ setDoc(doc: AtelierDocument | null): void {
188
+ this.doc = doc;
189
+ // Reset selection + playhead when the doc changes — they belong to the
190
+ // old doc's layer ids, which won't match the new one.
191
+ this.selection = null;
192
+ this.currentFrame = 0;
193
+ this.renderAll();
194
+ }
195
+
196
+ /** Position the playhead — typically driven by the canvas-panel's scrubber. */
197
+ setFrame(frame: number): void {
198
+ this.currentFrame = Math.max(0, frame);
199
+ this.renderPlayhead();
200
+ }
201
+
202
+ /** Highlight a clip as selected, or clear. */
203
+ setSelection(selection: TimelineSelection | null): void {
204
+ this.selection = selection;
205
+ this.applySelectionClasses();
206
+ }
207
+
208
+ /** Remove from DOM. */
209
+ destroy(): void {
210
+ if (this.resizeHandler) {
211
+ window.removeEventListener("resize", this.resizeHandler);
212
+ this.resizeHandler = null;
213
+ }
214
+ if (this.rootEl) {
215
+ this.rootEl.remove();
216
+ this.rootEl = null;
217
+ }
218
+ this.rulerEl = null;
219
+ this.laneEl = null;
220
+ this.playheadEl = null;
221
+ this.emptyEl = null;
222
+ this.clipEls.clear();
223
+ }
224
+
225
+ // ── render passes ─────────────────────────────────────────────────
226
+
227
+ private renderAll(): void {
228
+ this.renderRuler();
229
+ this.renderClips();
230
+ this.renderPlayhead();
231
+ }
232
+
233
+ private getTotalFrames(): number {
234
+ return this.doc?.timeline?.totalFrames ?? 0;
235
+ }
236
+
237
+ private getFps(): number {
238
+ return this.doc?.timeline?.fps ?? this.doc?.canvas?.fps ?? 30;
239
+ }
240
+
241
+ private renderRuler(): void {
242
+ if (!this.rulerEl) return;
243
+ this.rulerEl.innerHTML = "";
244
+ const totalFrames = this.getTotalFrames();
245
+ if (totalFrames <= 0) return;
246
+
247
+ const width = this.rulerEl.clientWidth || this.opts.container.clientWidth || 800;
248
+ const fps = this.getFps();
249
+ // One label per second — every `fps` frames.
250
+ const step = Math.max(1, fps);
251
+ for (let frame = 0; frame <= totalFrames; frame += step) {
252
+ const leftPx = frameToPixel(frame, totalFrames, width);
253
+ const tick = document.createElement("div");
254
+ tick.className = "atel-timeline-view__tick";
255
+ tick.style.left = `${leftPx}px`;
256
+ tick.textContent = String(frame);
257
+ this.rulerEl.appendChild(tick);
258
+ }
259
+ }
260
+
261
+ private renderClips(): void {
262
+ if (!this.laneEl) return;
263
+ this.laneEl.innerHTML = "";
264
+ this.clipEls.clear();
265
+
266
+ const totalFrames = this.getTotalFrames();
267
+ const tracks = this.doc?.timeline?.tracks ?? [];
268
+ const videoTracks = tracks.filter((t) => t.kind === "video");
269
+ const hasClips = videoTracks.some((t) => t.clips.length > 0);
270
+
271
+ if (!this.doc || totalFrames <= 0 || !hasClips) {
272
+ if (this.emptyEl) this.emptyEl.style.display = "";
273
+ this.renderRuler();
274
+ return;
275
+ }
276
+ if (this.emptyEl) this.emptyEl.style.display = "none";
277
+
278
+ const laneWidth = this.laneEl.clientWidth || this.opts.container.clientWidth || 800;
279
+ // Re-render the ruler now that we know the lane is laid out — the ruler
280
+ // and lane share the same width column, so a single render pass is fine.
281
+ this.renderRuler();
282
+
283
+ for (const track of videoTracks) {
284
+ const slots = computeClipPositions(track.clips, track.id, totalFrames, laneWidth);
285
+ for (const slot of slots) {
286
+ const el = document.createElement("div");
287
+ el.className = "atel-timeline-view__clip";
288
+ el.style.left = `${slot.leftPx}px`;
289
+ el.style.width = `${slot.widthPx}px`;
290
+ el.title = `${slot.label} · frames ${slot.startFrame}–${slot.endFrame}`;
291
+ el.innerHTML = `<span class="atel-timeline-view__clip-label">${esc(slot.label)}</span>`;
292
+ el.addEventListener("click", (ev) => {
293
+ ev.stopPropagation();
294
+ const next: TimelineSelection = { trackId: slot.trackId, layerId: slot.layerId };
295
+ this.setSelection(next);
296
+ this.opts.onSelectClip(next);
297
+ });
298
+ this.laneEl.appendChild(el);
299
+ this.clipEls.set(`${slot.trackId}::${slot.layerId}`, el);
300
+ }
301
+ }
302
+
303
+ this.applySelectionClasses();
304
+ }
305
+
306
+ private applySelectionClasses(): void {
307
+ for (const [key, el] of this.clipEls.entries()) {
308
+ const isSel = this.selection !== null &&
309
+ key === `${this.selection.trackId}::${this.selection.layerId}`;
310
+ el.classList.toggle("atel-timeline-view__clip--selected", isSel);
311
+ }
312
+ }
313
+
314
+ private renderPlayhead(): void {
315
+ if (!this.playheadEl || !this.rootEl) return;
316
+ const totalFrames = this.getTotalFrames();
317
+ if (totalFrames <= 0 || !this.doc) {
318
+ this.playheadEl.style.display = "none";
319
+ return;
320
+ }
321
+ const width = this.rootEl.clientWidth || 800;
322
+ const leftPx = frameToPixel(this.currentFrame, totalFrames, width);
323
+ this.playheadEl.style.display = "";
324
+ this.playheadEl.style.left = `${leftPx}px`;
325
+ }
326
+
327
+ // ── input handlers ────────────────────────────────────────────────
328
+
329
+ private handleRulerClick(ev: MouseEvent): void {
330
+ if (!this.rulerEl) return;
331
+ const totalFrames = this.getTotalFrames();
332
+ if (totalFrames <= 0) return;
333
+ const rect = this.rulerEl.getBoundingClientRect();
334
+ const xPx = ev.clientX - rect.left;
335
+ const frame = pixelToFrame(xPx, totalFrames, rect.width);
336
+ this.setFrame(frame);
337
+ this.opts.onSeek(frame);
338
+ }
339
+ }