@nebutra/atelier-canvas 1.0.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.
@@ -0,0 +1,148 @@
1
+ /**
2
+ * The single agent tool that closes the loop: text prompt → generated asset
3
+ * → server-placed on the tenant's canvas → patch handed back for broadcast.
4
+ *
5
+ * Generation reuses the env-key-gated modality in `@nebutra/agents` (mock when
6
+ * no provider key is present). Placement + persistence reuse this package's
7
+ * own engine (relative imports — same capability, no cross-package hop).
8
+ *
9
+ * `onPlaced` lets the app broadcast the patch (e.g. via pusher) WITHOUT this
10
+ * package taking a realtime dependency — broadcast is a WRAP at the app layer.
11
+ */
12
+
13
+ import {
14
+ type AgentTool,
15
+ type GenerationContext,
16
+ generateImage,
17
+ generateVideo,
18
+ } from "@nebutra/agents";
19
+ import { placeGeneratedAsset } from "../service";
20
+ import type { CanvasStore, ScenePatch } from "../types";
21
+
22
+ export interface AtelierToolDeps {
23
+ /** Tenant-scoped persistence (InMemory for demo, Prisma in prod). */
24
+ readonly store: CanvasStore;
25
+ /** Broadcast hook — invoked after the patch is durably persisted. */
26
+ readonly onPlaced?: (patch: ScenePatch) => void | Promise<void>;
27
+ }
28
+
29
+ interface GenerateInput {
30
+ canvasId: string;
31
+ prompt: string;
32
+ modality?: "image" | "video";
33
+ width?: number;
34
+ height?: number;
35
+ inputImages?: string[];
36
+ durationSeconds?: number;
37
+ }
38
+
39
+ function parseInput(raw: unknown): GenerateInput {
40
+ const o = (raw ?? {}) as Record<string, unknown>;
41
+ if (typeof o.canvasId !== "string" || o.canvasId.length === 0) {
42
+ throw new Error("atelier_generate: 'canvasId' is required");
43
+ }
44
+ if (typeof o.prompt !== "string" || o.prompt.trim().length === 0) {
45
+ throw new Error("atelier_generate: 'prompt' is required");
46
+ }
47
+ const modality = o.modality === "video" ? "video" : "image";
48
+ return {
49
+ canvasId: o.canvasId,
50
+ prompt: o.prompt,
51
+ modality,
52
+ ...(typeof o.width === "number" ? { width: o.width } : {}),
53
+ ...(typeof o.height === "number" ? { height: o.height } : {}),
54
+ ...(Array.isArray(o.inputImages)
55
+ ? { inputImages: o.inputImages.filter((x): x is string => typeof x === "string") }
56
+ : {}),
57
+ ...(typeof o.durationSeconds === "number" ? { durationSeconds: o.durationSeconds } : {}),
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Build the `atelier_generate` tool. One call = one asset (the agent loops
63
+ * itself for batches, per the batching rule in the system prompt).
64
+ */
65
+ export function createAtelierGenerationTool(deps: AtelierToolDeps): AgentTool {
66
+ return {
67
+ name: "atelier_generate",
68
+ description:
69
+ "Generate one image or video from a prompt and place it on the canvas. " +
70
+ "Returns the placed element's position so you can reason about layout. " +
71
+ "Call once per asset; for batches, call repeatedly.",
72
+ inputSchema: {
73
+ type: "object",
74
+ properties: {
75
+ canvasId: { type: "string", description: "Target canvas id" },
76
+ prompt: { type: "string", description: "Detailed generation prompt" },
77
+ modality: { type: "string", enum: ["image", "video"] },
78
+ width: { type: "number" },
79
+ height: { type: "number" },
80
+ durationSeconds: { type: "number", description: "Video length (s)" },
81
+ inputImages: {
82
+ type: "array",
83
+ items: { type: "string" },
84
+ description: "Reference file ids / URLs for edits & variations",
85
+ },
86
+ },
87
+ required: ["canvasId", "prompt"],
88
+ },
89
+ execute: async (rawInput, context) => {
90
+ const input = parseInput(rawInput);
91
+ const genCtx: GenerationContext = {
92
+ tenantId: context.tenantId,
93
+ userId: context.userId,
94
+ conversationId: context.conversationId,
95
+ };
96
+
97
+ const result =
98
+ input.modality === "video"
99
+ ? await generateVideo(
100
+ {
101
+ prompt: input.prompt,
102
+ ...(input.width ? { width: input.width } : {}),
103
+ ...(input.height ? { height: input.height } : {}),
104
+ ...(input.durationSeconds ? { durationSeconds: input.durationSeconds } : {}),
105
+ ...(input.inputImages?.[0] ? { inputImage: input.inputImages[0] } : {}),
106
+ },
107
+ genCtx,
108
+ )
109
+ : await generateImage(
110
+ {
111
+ prompt: input.prompt,
112
+ ...(input.width ? { width: input.width } : {}),
113
+ ...(input.height ? { height: input.height } : {}),
114
+ ...(input.inputImages ? { inputImages: input.inputImages } : {}),
115
+ },
116
+ genCtx,
117
+ );
118
+
119
+ const { patch } = await placeGeneratedAsset(deps.store, context.tenantId, input.canvasId, {
120
+ modality: result.modality,
121
+ mimeType: result.mimeType,
122
+ url: result.url,
123
+ width: result.width,
124
+ height: result.height,
125
+ meta: {
126
+ prompt: input.prompt,
127
+ model: result.model,
128
+ provider: result.providerName,
129
+ },
130
+ });
131
+
132
+ // Persisted — now safe to broadcast.
133
+ await deps.onPlaced?.(patch);
134
+
135
+ return {
136
+ ok: true,
137
+ placed: {
138
+ elementId: patch.element.id,
139
+ x: patch.element.x,
140
+ y: patch.element.y,
141
+ modality: result.modality,
142
+ },
143
+ provider: result.providerName,
144
+ model: result.model,
145
+ };
146
+ },
147
+ };
148
+ }
package/src/index.ts ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * @nebutra/atelier-canvas — server-authoritative creative-canvas engine.
3
+ *
4
+ * The browser owns the rich editing surface; this package owns the two
5
+ * properties that make an agent-driven canvas correct under concurrency and
6
+ * multi-tenancy: deterministic server-side placement, and persist-then-
7
+ * broadcast consistency. Tenant isolation is structural — every store method
8
+ * is scoped by `tenantId`.
9
+ */
10
+
11
+ export { _resetCanvasLocks, withCanvasLock } from "./lock";
12
+ export { findNextPosition } from "./placement";
13
+ export {
14
+ type GeneratedAsset,
15
+ placeGeneratedAsset,
16
+ } from "./service";
17
+ export { InMemoryCanvasStore } from "./store/memory";
18
+ export {
19
+ type AtelierCanvasDelegate,
20
+ PrismaCanvasStore,
21
+ type TenantDbLike,
22
+ } from "./store/prisma";
23
+ export type {
24
+ AtelierCanvas,
25
+ CanvasElement,
26
+ CanvasElementType,
27
+ CanvasFile,
28
+ CanvasScene,
29
+ CanvasStore,
30
+ ElementSize,
31
+ Placement,
32
+ ScenePatch,
33
+ } from "./types";
package/src/lock.ts ADDED
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Per-canvas serialization.
3
+ *
4
+ * The generic per-(tenant, resource) serializer now lives in the neutral
5
+ * lower layer `@nebutra/tenant-store` (so `@nebutra/reel` and future
6
+ * tenant-scoped features share it without depending on this package). This
7
+ * module keeps the original `withCanvasLock` / `_resetCanvasLocks` names as
8
+ * thin, back-compatible aliases so existing callers (e.g. apps/web) keep
9
+ * working unchanged.
10
+ *
11
+ * @deprecated Import `withTenantLock` / `_resetTenantLocks` from
12
+ * `@nebutra/tenant-store` directly in new code. These aliases are retained
13
+ * only for the existing public surface.
14
+ */
15
+
16
+ import { _resetTenantLocks, withTenantLock } from "@nebutra/tenant-store";
17
+
18
+ /**
19
+ * Run `fn` with exclusive access to a single (tenant, canvas). Calls for the
20
+ * same canvas run strictly in submission order; different canvases run freely.
21
+ *
22
+ * @deprecated Use `withTenantLock` from `@nebutra/tenant-store`.
23
+ */
24
+ export function withCanvasLock<T>(
25
+ tenantId: string,
26
+ canvasId: string,
27
+ fn: () => Promise<T>,
28
+ ): Promise<T> {
29
+ return withTenantLock(tenantId, canvasId, fn);
30
+ }
31
+
32
+ /**
33
+ * Test helper — clears all lock chains.
34
+ *
35
+ * @deprecated Use `_resetTenantLocks` from `@nebutra/tenant-store`.
36
+ */
37
+ export function _resetCanvasLocks(): void {
38
+ _resetTenantLocks();
39
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Server-authoritative non-overlap placement.
3
+ *
4
+ * Ported in spirit from the source product's `find_next_best_element_position`:
5
+ * the server — not the client — decides where a freshly generated asset lands,
6
+ * so concurrent generations never stack and every client converges on the same
7
+ * coordinates. Deterministic given (existing elements, size, gap): identical
8
+ * inputs always yield the identical position, which keeps placement tests and
9
+ * websocket-sync reproducible.
10
+ */
11
+
12
+ import type { CanvasElement, ElementSize, Placement } from "./types";
13
+
14
+ const DEFAULT_GAP = 40;
15
+ /** Columns scanned before wrapping to the next row. Bounds worst-case work. */
16
+ const MAX_COLS = 12;
17
+
18
+ interface Box {
19
+ x: number;
20
+ y: number;
21
+ width: number;
22
+ height: number;
23
+ }
24
+
25
+ function overlaps(a: Box, b: Box, gap: number): boolean {
26
+ return !(
27
+ a.x + a.width + gap <= b.x ||
28
+ b.x + b.width + gap <= a.x ||
29
+ a.y + a.height + gap <= b.y ||
30
+ b.y + b.height + gap <= a.y
31
+ );
32
+ }
33
+
34
+ /**
35
+ * Find the first free top-left position for an element of `size`, scanning
36
+ * left→right then top→down on a grid sized to the largest existing element.
37
+ * Returns `{0,0}` for an empty canvas.
38
+ */
39
+ export function findNextPosition(
40
+ existing: readonly CanvasElement[],
41
+ size: ElementSize,
42
+ gap: number = DEFAULT_GAP,
43
+ ): Placement {
44
+ if (existing.length === 0) return { x: 0, y: 0 };
45
+
46
+ // Step = widest/tallest placed element (+gap) so the grid never collides
47
+ // with large neighbours; bounded below by the incoming element's own size.
48
+ const stepX = Math.max(size.width, ...existing.map((e) => e.width)) + gap;
49
+ const stepY = Math.max(size.height, ...existing.map((e) => e.height)) + gap;
50
+
51
+ const candidate: Box = { x: 0, y: 0, width: size.width, height: size.height };
52
+
53
+ for (let row = 0; row < existing.length + 1; row++) {
54
+ for (let col = 0; col < MAX_COLS; col++) {
55
+ candidate.x = col * stepX;
56
+ candidate.y = row * stepY;
57
+ const clash = existing.some((e) =>
58
+ overlaps(candidate, { x: e.x, y: e.y, width: e.width, height: e.height }, gap),
59
+ );
60
+ if (!clash) return { x: candidate.x, y: candidate.y };
61
+ }
62
+ }
63
+
64
+ // Pathological fallback: drop below everything.
65
+ const maxBottom = Math.max(...existing.map((e) => e.y + e.height));
66
+ return { x: 0, y: maxBottom + gap };
67
+ }
package/src/service.ts ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The write-then-broadcast placement service — the core consistency
3
+ * invariant absorbed from the source product.
4
+ *
5
+ * For one (tenant, canvas), under the canvas lock:
6
+ * 1. load the authoritative scene,
7
+ * 2. compute a non-overlapping position server-side,
8
+ * 3. append the element/file and **persist** the new scene,
9
+ * 4. return a {@link ScenePatch}.
10
+ *
11
+ * The caller broadcasts the returned patch over the realtime channel *after*
12
+ * this resolves. Persisting before broadcasting is what makes the websocket
13
+ * a pure optimization: a client that never receives the event recovers the
14
+ * identical state on reload. Reversing the order reintroduces the
15
+ * lost-update bug.
16
+ */
17
+
18
+ import { logger } from "@nebutra/logger";
19
+ import { withCanvasLock } from "./lock";
20
+ import { findNextPosition } from "./placement";
21
+ import type {
22
+ AtelierCanvas,
23
+ CanvasElement,
24
+ CanvasFile,
25
+ CanvasScene,
26
+ CanvasStore,
27
+ ScenePatch,
28
+ } from "./types";
29
+
30
+ const log = logger.child({ module: "atelier-canvas/service" });
31
+
32
+ /** A generated asset, shaped to match `@nebutra/agents` GenerationResult. */
33
+ export interface GeneratedAsset {
34
+ readonly modality: "image" | "video";
35
+ readonly mimeType: string;
36
+ readonly url: string;
37
+ readonly width: number;
38
+ readonly height: number;
39
+ /** Carried into element.meta for provenance (prompt, model, provider). */
40
+ readonly meta?: Readonly<Record<string, unknown>>;
41
+ }
42
+
43
+ function newId(prefix: string): string {
44
+ return `${prefix}_${crypto.randomUUID()}`;
45
+ }
46
+
47
+ /**
48
+ * Place a generated asset on a canvas. Creates the canvas on first write.
49
+ * Concurrency-safe per (tenant, canvas) via {@link withCanvasLock}.
50
+ */
51
+ export async function placeGeneratedAsset(
52
+ store: CanvasStore,
53
+ tenantId: string,
54
+ canvasId: string,
55
+ asset: GeneratedAsset,
56
+ ): Promise<{ patch: ScenePatch; canvas: AtelierCanvas }> {
57
+ return withCanvasLock(tenantId, canvasId, async () => {
58
+ const current =
59
+ (await store.get(tenantId, canvasId)) ?? (await store.create(tenantId, canvasId, canvasId));
60
+
61
+ const position = findNextPosition(current.scene.elements, {
62
+ width: asset.width,
63
+ height: asset.height,
64
+ });
65
+
66
+ // Images carry a file the client resolves by id; videos embed by URL.
67
+ let file: CanvasFile | undefined;
68
+ let ref: string;
69
+ if (asset.modality === "image") {
70
+ file = { id: newId("file"), mimeType: asset.mimeType, dataURL: asset.url };
71
+ ref = file.id;
72
+ } else {
73
+ ref = asset.url;
74
+ }
75
+
76
+ const element: CanvasElement = {
77
+ id: newId("el"),
78
+ type: asset.modality === "image" ? "image" : "embeddable",
79
+ x: position.x,
80
+ y: position.y,
81
+ width: asset.width,
82
+ height: asset.height,
83
+ ref,
84
+ ...(asset.meta ? { meta: asset.meta } : {}),
85
+ };
86
+
87
+ const nextScene: CanvasScene = {
88
+ elements: [...current.scene.elements, element],
89
+ files: file ? [...current.scene.files, file] : current.scene.files,
90
+ };
91
+
92
+ // Thumbnail = latest asset (matches source product's grid-preview rule).
93
+ const thumbnail = asset.url;
94
+
95
+ // (3) Persist BEFORE returning — broadcast happens in the caller, after.
96
+ const canvas = await store.save(tenantId, canvasId, nextScene, thumbnail);
97
+
98
+ log.debug("placed generated asset", {
99
+ tenantId,
100
+ canvasId,
101
+ modality: asset.modality,
102
+ x: position.x,
103
+ y: position.y,
104
+ });
105
+
106
+ const patch: ScenePatch = {
107
+ canvasId,
108
+ tenantId,
109
+ element,
110
+ ...(file ? { file } : {}),
111
+ thumbnail,
112
+ };
113
+ return { patch, canvas };
114
+ });
115
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * In-memory CanvasStore — the default for tests and the flag-gated demo.
3
+ *
4
+ * Storage mechanics (tenant-keyed map, never returning rows across tenants,
5
+ * tenant-filtered listing) are delegated to `InMemoryTenantStore` from
6
+ * `@nebutra/tenant-store`; this class only adds atelier's typed `create` /
7
+ * `save` domain shape, mirroring what RLS does in the Prisma store.
8
+ */
9
+
10
+ import { InMemoryTenantStore } from "@nebutra/tenant-store";
11
+ import type { AtelierCanvas, CanvasScene, CanvasStore } from "../types";
12
+
13
+ const EMPTY_SCENE: CanvasScene = { elements: [], files: [] };
14
+
15
+ export class InMemoryCanvasStore implements CanvasStore {
16
+ private readonly base = new InMemoryTenantStore<AtelierCanvas>();
17
+
18
+ async get(tenantId: string, canvasId: string): Promise<AtelierCanvas | null> {
19
+ return this.base.read(tenantId, canvasId);
20
+ }
21
+
22
+ async create(tenantId: string, canvasId: string, name: string): Promise<AtelierCanvas> {
23
+ const row: AtelierCanvas = {
24
+ id: canvasId,
25
+ tenantId,
26
+ name,
27
+ scene: EMPTY_SCENE,
28
+ updatedAt: new Date(),
29
+ };
30
+ return this.base.write(tenantId, canvasId, row);
31
+ }
32
+
33
+ async save(
34
+ tenantId: string,
35
+ canvasId: string,
36
+ scene: CanvasScene,
37
+ thumbnail?: string,
38
+ ): Promise<AtelierCanvas> {
39
+ const existing = await this.base.read(tenantId, canvasId);
40
+ const row: AtelierCanvas = {
41
+ id: canvasId,
42
+ tenantId,
43
+ name: existing?.name ?? canvasId,
44
+ scene,
45
+ ...(thumbnail !== undefined ? { thumbnail } : {}),
46
+ updatedAt: new Date(),
47
+ };
48
+ return this.base.write(tenantId, canvasId, row);
49
+ }
50
+
51
+ async list(tenantId: string): Promise<readonly AtelierCanvas[]> {
52
+ return this.base.listByTenant(tenantId);
53
+ }
54
+
55
+ /** Test helper. */
56
+ _clear(): void {
57
+ this.base.clear();
58
+ }
59
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Prisma-backed CanvasStore — the production persistence adapter.
3
+ *
4
+ * Decoupled from `@nebutra/db`'s generated client by a narrow structural
5
+ * interface, so this package typechecks and tests without running
6
+ * `prisma generate`. Wire it in the app by passing a `getDb` that returns
7
+ * `getTenantDb(tenantId)` (RLS-scoped) — that is what enforces tenant
8
+ * isolation at the database layer; this adapter never calls `getSystemDb()`.
9
+ *
10
+ * The `AtelierCanvas` Prisma model (see packages/platform/db/prisma/
11
+ * schema.prisma) stores the scene as a single JSON column — the same
12
+ * one-blob-per-canvas shape the source product used, but with an
13
+ * `organization_id` column + RLS instead of an implicit single user.
14
+ */
15
+
16
+ import type { AtelierCanvas, CanvasScene, CanvasStore } from "../types";
17
+
18
+ /** Row shape as persisted (scene/files as JSON). */
19
+ interface AtelierCanvasRow {
20
+ id: string;
21
+ organizationId: string;
22
+ name: string;
23
+ scene: unknown;
24
+ thumbnail: string | null;
25
+ updatedAt: Date;
26
+ }
27
+
28
+ /** The subset of the generated delegate this adapter needs. */
29
+ export interface AtelierCanvasDelegate {
30
+ findUnique(args: {
31
+ where: { organizationId_id: { organizationId: string; id: string } };
32
+ }): Promise<AtelierCanvasRow | null>;
33
+ upsert(args: {
34
+ where: { organizationId_id: { organizationId: string; id: string } };
35
+ create: Omit<AtelierCanvasRow, "updatedAt">;
36
+ update: Partial<Omit<AtelierCanvasRow, "id" | "organizationId">>;
37
+ }): Promise<AtelierCanvasRow>;
38
+ findMany(args: { where: { organizationId: string } }): Promise<AtelierCanvasRow[]>;
39
+ }
40
+
41
+ export interface TenantDbLike {
42
+ atelierCanvas: AtelierCanvasDelegate;
43
+ }
44
+
45
+ const EMPTY_SCENE: CanvasScene = { elements: [], files: [] };
46
+
47
+ function toDomain(row: AtelierCanvasRow): AtelierCanvas {
48
+ const scene =
49
+ row.scene && typeof row.scene === "object" ? (row.scene as CanvasScene) : EMPTY_SCENE;
50
+ return {
51
+ id: row.id,
52
+ tenantId: row.organizationId,
53
+ name: row.name,
54
+ scene,
55
+ ...(row.thumbnail ? { thumbnail: row.thumbnail } : {}),
56
+ updatedAt: row.updatedAt,
57
+ };
58
+ }
59
+
60
+ export class PrismaCanvasStore implements CanvasStore {
61
+ /**
62
+ * @param getDb returns an RLS-scoped tenant client. In the app:
63
+ * `new PrismaCanvasStore((t) => getTenantDb(t) as unknown as TenantDbLike)`
64
+ */
65
+ constructor(private readonly getDb: (tenantId: string) => Promise<TenantDbLike> | TenantDbLike) {}
66
+
67
+ async get(tenantId: string, canvasId: string): Promise<AtelierCanvas | null> {
68
+ const db = await this.getDb(tenantId);
69
+ const row = await db.atelierCanvas.findUnique({
70
+ where: { organizationId_id: { organizationId: tenantId, id: canvasId } },
71
+ });
72
+ return row ? toDomain(row) : null;
73
+ }
74
+
75
+ async create(tenantId: string, canvasId: string, name: string): Promise<AtelierCanvas> {
76
+ const db = await this.getDb(tenantId);
77
+ const row = await db.atelierCanvas.upsert({
78
+ where: { organizationId_id: { organizationId: tenantId, id: canvasId } },
79
+ create: {
80
+ id: canvasId,
81
+ organizationId: tenantId,
82
+ name,
83
+ scene: EMPTY_SCENE,
84
+ thumbnail: null,
85
+ },
86
+ update: {},
87
+ });
88
+ return toDomain(row);
89
+ }
90
+
91
+ async save(
92
+ tenantId: string,
93
+ canvasId: string,
94
+ scene: CanvasScene,
95
+ thumbnail?: string,
96
+ ): Promise<AtelierCanvas> {
97
+ const db = await this.getDb(tenantId);
98
+ const row = await db.atelierCanvas.upsert({
99
+ where: { organizationId_id: { organizationId: tenantId, id: canvasId } },
100
+ create: {
101
+ id: canvasId,
102
+ organizationId: tenantId,
103
+ name: canvasId,
104
+ scene,
105
+ thumbnail: thumbnail ?? null,
106
+ },
107
+ update: { scene, thumbnail: thumbnail ?? null },
108
+ });
109
+ return toDomain(row);
110
+ }
111
+
112
+ async list(tenantId: string): Promise<readonly AtelierCanvas[]> {
113
+ const db = await this.getDb(tenantId);
114
+ const rows = await db.atelierCanvas.findMany({
115
+ where: { organizationId: tenantId },
116
+ });
117
+ return rows.map(toDomain);
118
+ }
119
+ }
package/src/types.ts ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Scene model for the creative canvas.
3
+ *
4
+ * A narrow, transport-stable subset of the Excalidraw element shape — enough
5
+ * to place agent-generated media deterministically on the server without
6
+ * pulling Excalidraw as a server dependency. The browser holds the full
7
+ * Excalidraw scene; the server is authoritative only for *placement* and
8
+ * *persistence*, exactly the property that makes refresh == realtime.
9
+ */
10
+
11
+ export type CanvasElementType = "image" | "embeddable" | "text";
12
+
13
+ export interface CanvasElement {
14
+ readonly id: string;
15
+ readonly type: CanvasElementType;
16
+ /** Top-left x in scene coordinates. */
17
+ readonly x: number;
18
+ /** Top-left y in scene coordinates. */
19
+ readonly y: number;
20
+ readonly width: number;
21
+ readonly height: number;
22
+ /**
23
+ * For `image` — a file id resolvable in `scene.files`.
24
+ * For `embeddable` — the media URL (video).
25
+ * For `text` — the literal string.
26
+ */
27
+ readonly ref: string;
28
+ /** Free-form, provider/agent attribution (prompt, model, etc.). */
29
+ readonly meta?: Readonly<Record<string, unknown>>;
30
+ }
31
+
32
+ export interface CanvasFile {
33
+ readonly id: string;
34
+ readonly mimeType: string;
35
+ /** `data:` URI or remote URL. */
36
+ readonly dataURL: string;
37
+ }
38
+
39
+ export interface CanvasScene {
40
+ readonly elements: readonly CanvasElement[];
41
+ readonly files: readonly CanvasFile[];
42
+ }
43
+
44
+ export interface AtelierCanvas {
45
+ readonly id: string;
46
+ /** Owning organization — every read/write is scoped by this. */
47
+ readonly tenantId: string;
48
+ readonly name: string;
49
+ readonly scene: CanvasScene;
50
+ /** Latest generated asset, for list/grid previews. */
51
+ readonly thumbnail?: string;
52
+ readonly updatedAt: Date;
53
+ }
54
+
55
+ /** Size of an element to be placed. */
56
+ export interface ElementSize {
57
+ readonly width: number;
58
+ readonly height: number;
59
+ }
60
+
61
+ /** Resolved top-left position in scene coordinates. */
62
+ export interface Placement {
63
+ readonly x: number;
64
+ readonly y: number;
65
+ }
66
+
67
+ /**
68
+ * The minimal patch produced by a server-side placement. Persisted *before*
69
+ * it is broadcast, so the websocket message is a pure UI optimization: a
70
+ * client that missed it recovers the identical state on reload.
71
+ */
72
+ export interface ScenePatch {
73
+ readonly canvasId: string;
74
+ readonly tenantId: string;
75
+ readonly element: CanvasElement;
76
+ readonly file?: CanvasFile;
77
+ readonly thumbnail?: string;
78
+ }
79
+
80
+ /** Tenant-scoped persistence boundary (repository pattern). */
81
+ export interface CanvasStore {
82
+ get(tenantId: string, canvasId: string): Promise<AtelierCanvas | null>;
83
+ create(tenantId: string, canvasId: string, name: string): Promise<AtelierCanvas>;
84
+ /** Replace the persisted scene + thumbnail for a canvas. */
85
+ save(
86
+ tenantId: string,
87
+ canvasId: string,
88
+ scene: CanvasScene,
89
+ thumbnail?: string,
90
+ ): Promise<AtelierCanvas>;
91
+ list(tenantId: string): Promise<readonly AtelierCanvas[]>;
92
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": "../../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "target": "esnext",
7
+ "types": ["node"],
8
+ "incremental": false
9
+ },
10
+ "include": ["src"],
11
+ "exclude": ["node_modules", "dist"]
12
+ }