@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.
- package/dist/{chunk-JPZ4F4PW.js → chunk-3ARBOSWY.js} +64 -5
- package/dist/chunk-3ARBOSWY.js.map +1 -0
- package/dist/cli.js +11469 -413
- package/dist/cli.js.map +1 -1
- package/dist/{dist-M67UZGFQ.js → dist-3YQK6PI6.js} +2 -2
- package/dist/index.cjs +3193 -227
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +701 -8
- package/dist/index.d.ts +701 -8
- package/dist/index.js +7237 -72
- package/dist/index.js.map +1 -1
- package/dist/mcp.js +2898 -507
- package/dist/mcp.js.map +1 -1
- package/package.json +6 -6
- package/src/web/inline-app.ts +55 -4
- package/src/web/timeline-state-types.ts +28 -0
- package/src/web/timeline-view.test.ts +99 -0
- package/src/web/timeline-view.ts +339 -0
- package/src/web/workspace-app.ts +3146 -0
- package/templates/workspace/.claude/agents/atelier-iris.md +75 -0
- package/templates/workspace/.claude/agents/atelier-lux.md +67 -0
- package/templates/workspace/.claude/agents/atelier-quill.md +61 -0
- package/templates/workspace/.gitignore +30 -0
- package/templates/workspace/.paradigm/personas/_shared/cascade-merge.md +172 -0
- package/templates/workspace/CLAUDE.md +93 -0
- package/templates/workspace/README.md +75 -0
- package/templates/workspace/SETUP.md +127 -0
- package/templates/workspace/_brand/.atelier-brand.yaml +34 -0
- package/templates/workspace/_brand/DESIGN.md +56 -0
- package/templates/workspace/_brand/SCRIPT.md +41 -0
- package/templates/workspace/_brand/STORYBOARD.md +33 -0
- package/templates/workspace/_packs/README.md +54 -0
- package/templates/workspace/projects/README.md +49 -0
- package/templates/workspace/workspace.atelier +22 -0
- package/university/index.yaml +1 -1
- package/dist/chunk-5QQESXI6.js +0 -4432
- package/dist/chunk-5QQESXI6.js.map +0 -1
- package/dist/chunk-JPZ4F4PW.js.map +0 -1
- package/dist/cli.cjs +0 -6313
- package/dist/cli.cjs.map +0 -1
- package/dist/cli.d.cts +0 -1
- package/dist/cli.d.ts +0 -1
- package/dist/mcp.cjs +0 -5462
- package/dist/mcp.cjs.map +0 -1
- /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.
|
|
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",
|
package/src/web/inline-app.ts
CHANGED
|
@@ -72,11 +72,42 @@ interface FileEntry {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
/**
|
|
75
|
-
* Boot the in-browser Atelier Studio client.
|
|
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.
|
|
108
|
+
* export-all flow. Behavior preserved exactly from pre-workspace days.
|
|
78
109
|
*/
|
|
79
|
-
|
|
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:'
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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
|
+
}
|