@nebutra/atelier-canvas 1.0.2 → 1.0.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nebutra/atelier-canvas",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "Server-authoritative creative-canvas engine: scene model, non-overlap placement, write-then-broadcast consistency, tenant-scoped locking",
5
5
  "private": false,
6
6
  "license": "MIT",
@@ -11,13 +11,14 @@
11
11
  "surface": "creative-surface",
12
12
  "summary": "Server-authoritative creative-canvas engine"
13
13
  },
14
- "main": "./src/index.ts",
15
- "types": "./src/index.ts",
14
+ "main": "./dist/index.js",
15
+ "types": "./dist/index.d.ts",
16
16
  "exports": {
17
- ".": "./src/index.ts",
18
- "./store/memory": "./src/store/memory.ts",
19
- "./store/prisma": "./src/store/prisma.ts",
20
- "./agent": "./src/agent/index.ts"
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "import": "./dist/index.js",
20
+ "default": "./dist/index.js"
21
+ }
21
22
  },
22
23
  "dependencies": {
23
24
  "@nebutra/logger": "0.1.2",
@@ -50,6 +51,12 @@
50
51
  "publishConfig": {
51
52
  "access": "public"
52
53
  },
54
+ "files": [
55
+ "dist",
56
+ "README.md",
57
+ "LICENSE",
58
+ "CHANGELOG.md"
59
+ ],
53
60
  "scripts": {
54
61
  "build": "tsup",
55
62
  "test": "vitest run",
@@ -1,33 +0,0 @@
1
-
2
- > @nebutra/atelier-canvas@1.0.2 build /Users/tseka_luk/Documents/Nebutra-SaaS-Lab/Nebutra-Sailor/packages/ai/atelier-canvas
3
- > tsup
4
-
5
- CLI Building entry: src/index.ts, src/agent/index.ts, src/store/memory.ts, src/store/prisma.ts
6
- CLI Using tsconfig: tsconfig.json
7
- CLI tsup v8.5.1
8
- CLI Using tsup config: /Users/tseka_luk/Documents/Nebutra-SaaS-Lab/Nebutra-Sailor/packages/ai/atelier-canvas/tsup.config.ts
9
- CLI Target: es2022
10
- CLI Cleaning output folder
11
- ESM Build start
12
- ESM dist/chunk-JYV7NRGL.js 3.00 KB
13
- ESM dist/index.js 410.00 B
14
- ESM dist/chunk-AVQEQBLX.js 1.10 KB
15
- ESM dist/store/memory.js 130.00 B
16
- ESM dist/store/prisma.js 126.00 B
17
- ESM dist/chunk-5UUHUVE4.js 2.00 KB
18
- ESM dist/agent/index.js 5.83 KB
19
- ESM dist/chunk-JYV7NRGL.js.map 9.68 KB
20
- ESM dist/store/memory.js.map 71.00 B
21
- ESM dist/chunk-AVQEQBLX.js.map 2.70 KB
22
- ESM dist/chunk-5UUHUVE4.js.map 5.57 KB
23
- ESM dist/store/prisma.js.map 71.00 B
24
- ESM dist/index.js.map 71.00 B
25
- ESM dist/agent/index.js.map 12.44 KB
26
- ESM ⚡️ Build success in 18ms
27
- DTS Build start
28
- DTS ⚡️ Build success in 1260ms
29
- DTS dist/index.d.ts 3.84 KB
30
- DTS dist/agent/index.d.ts 6.63 KB
31
- DTS dist/store/memory.d.ts 976.00 B
32
- DTS dist/store/prisma.d.ts 2.55 KB
33
- DTS dist/types-Dw9o09h2.d.ts 2.95 KB
@@ -1,15 +0,0 @@
1
-
2
- > @nebutra/atelier-canvas@1.0.1 test /Users/tseka_luk/Documents/Nebutra-SaaS-Lab/Nebutra-Sailor/packages/ai/atelier-canvas
3
- > vitest run
4
-
5
-
6
- RUN v4.1.4 /Users/tseka_luk/Documents/Nebutra-SaaS-Lab/Nebutra-Sailor/packages/ai/atelier-canvas
7
-
8
- ✓ src/__tests__/atelier-canvas.test.ts (7 tests) 73ms
9
- ✓ src/agent/__tests__/agent.test.ts (5 tests) 29ms
10
-
11
- Test Files 2 passed (2)
12
- Tests 12 passed (12)
13
- Start at 09:40:52
14
- Duration 3.29s (transform 1.73s, setup 0ms, import 2.96s, tests 102ms, environment 0ms)
15
-
@@ -1,4 +0,0 @@
1
-
2
- > @nebutra/atelier-canvas@1.0.2 typecheck /Users/tseka_luk/Documents/Nebutra-SaaS-Lab/Nebutra-Sailor/packages/ai/atelier-canvas
3
- > tsc --noEmit
4
-
@@ -1,96 +0,0 @@
1
- /**
2
- * Canvas engine: deterministic placement, tenant isolation, write-then-
3
- * broadcast consistency, and per-canvas serialization.
4
- */
5
-
6
- import { beforeEach, describe, expect, it } from "vitest";
7
- import { _resetCanvasLocks } from "../lock";
8
- import { findNextPosition } from "../placement";
9
- import { type GeneratedAsset, placeGeneratedAsset } from "../service";
10
- import { InMemoryCanvasStore } from "../store/memory";
11
- import type { CanvasElement } from "../types";
12
-
13
- const img = (over: Partial<GeneratedAsset> = {}): GeneratedAsset => ({
14
- modality: "image",
15
- mimeType: "image/svg+xml",
16
- url: "data:image/svg+xml;base64,AAAA",
17
- width: 512,
18
- height: 512,
19
- ...over,
20
- });
21
-
22
- beforeEach(() => {
23
- _resetCanvasLocks();
24
- });
25
-
26
- describe("findNextPosition", () => {
27
- it("places the first element at the origin", () => {
28
- expect(findNextPosition([], { width: 100, height: 100 })).toEqual({ x: 0, y: 0 });
29
- });
30
-
31
- it("never overlaps an existing element (respects gap)", () => {
32
- const existing: CanvasElement[] = [
33
- { id: "a", type: "image", x: 0, y: 0, width: 200, height: 200, ref: "f" },
34
- ];
35
- const p = findNextPosition(existing, { width: 200, height: 200 });
36
- const clash = p.x < 200 + 40 && p.x + 200 + 40 > 0 && p.y < 200 + 40 && p.y + 200 + 40 > 0;
37
- expect(clash).toBe(false);
38
- });
39
-
40
- it("is deterministic for identical input", () => {
41
- const existing: CanvasElement[] = [
42
- { id: "a", type: "image", x: 0, y: 0, width: 300, height: 100, ref: "f" },
43
- ];
44
- const a = findNextPosition(existing, { width: 100, height: 100 });
45
- const b = findNextPosition(existing, { width: 100, height: 100 });
46
- expect(a).toEqual(b);
47
- });
48
- });
49
-
50
- describe("placeGeneratedAsset", () => {
51
- it("persists before returning the patch (scene is durable)", async () => {
52
- const store = new InMemoryCanvasStore();
53
- const { patch } = await placeGeneratedAsset(store, "org_1", "c1", img());
54
-
55
- const reloaded = await store.get("org_1", "c1");
56
- expect(reloaded?.scene.elements).toHaveLength(1);
57
- expect(reloaded?.scene.elements[0]?.id).toBe(patch.element.id);
58
- expect(reloaded?.scene.files).toHaveLength(1);
59
- expect(reloaded?.thumbnail).toBe(patch.thumbnail);
60
- });
61
-
62
- it("isolates canvases by tenant", async () => {
63
- const store = new InMemoryCanvasStore();
64
- await placeGeneratedAsset(store, "org_a", "shared-id", img());
65
- await placeGeneratedAsset(store, "org_b", "shared-id", img());
66
-
67
- expect((await store.get("org_a", "shared-id"))?.scene.elements).toHaveLength(1);
68
- expect(await store.list("org_a")).toHaveLength(1);
69
- expect(await store.list("org_b")).toHaveLength(1);
70
- });
71
-
72
- it("embeds video by URL (no file entry)", async () => {
73
- const store = new InMemoryCanvasStore();
74
- const { patch } = await placeGeneratedAsset(
75
- store,
76
- "org_1",
77
- "c1",
78
- img({ modality: "video", url: "https://x.test/v.mp4", mimeType: "video/mp4" }),
79
- );
80
- expect(patch.element.type).toBe("embeddable");
81
- expect(patch.element.ref).toBe("https://x.test/v.mp4");
82
- expect(patch.file).toBeUndefined();
83
- });
84
-
85
- it("serializes concurrent placements on the same canvas (no stacking)", async () => {
86
- const store = new InMemoryCanvasStore();
87
- await Promise.all(
88
- Array.from({ length: 6 }, () => placeGeneratedAsset(store, "org_1", "c1", img())),
89
- );
90
- const scene = (await store.get("org_1", "c1"))?.scene;
91
- expect(scene?.elements).toHaveLength(6);
92
- // Every element occupies a distinct position.
93
- const positions = new Set(scene?.elements.map((e) => `${e.x},${e.y}`));
94
- expect(positions.size).toBe(6);
95
- });
96
- });
@@ -1,74 +0,0 @@
1
- /**
2
- * The /agent subpath closes the loop end-to-end (mock provider): prompt →
3
- * asset → server placement → durable scene → broadcast hook. Importing this
4
- * module pulls @nebutra/agents (optional peer); the core export does not.
5
- */
6
-
7
- import { createAgentContext } from "@nebutra/agents";
8
- import { describe, expect, it } from "vitest";
9
- import { InMemoryCanvasStore } from "../../store/memory";
10
- import type { ScenePatch } from "../../types";
11
- import { createAtelierAgent } from "../agent";
12
- import { ATELIER_SYSTEM_PROMPT } from "../prompts";
13
- import { createAtelierGenerationTool } from "../tools";
14
-
15
- const ctx = createAgentContext("org_1", "user_1", "conv_1");
16
-
17
- describe("atelier system prompt", () => {
18
- it("encodes the absorbed creative invariants", () => {
19
- expect(ATELIER_SYSTEM_PROMPT).toContain("Design Strategy");
20
- expect(ATELIER_SYSTEM_PROMPT).toContain("QUANTITY IS A CONTRACT");
21
- expect(ATELIER_SYSTEM_PROMPT).toContain("batches of at most 10");
22
- expect(ATELIER_SYSTEM_PROMPT).toContain("input_images");
23
- });
24
- });
25
-
26
- describe("createAtelierGenerationTool", () => {
27
- it("generates, places, persists, then fires the broadcast hook", async () => {
28
- const store = new InMemoryCanvasStore();
29
- const broadcasts: ScenePatch[] = [];
30
- const tool = createAtelierGenerationTool({
31
- store,
32
- onPlaced: (p) => {
33
- broadcasts.push(p);
34
- },
35
- });
36
-
37
- const out = (await tool.execute(
38
- { canvasId: "c1", prompt: "a serene alpine lake at dawn" },
39
- ctx,
40
- )) as { ok: boolean; placed: { elementId: string; modality: string } };
41
-
42
- expect(out.ok).toBe(true);
43
- expect(out.placed.modality).toBe("image");
44
-
45
- const canvas = await store.get("org_1", "c1");
46
- expect(canvas?.scene.elements).toHaveLength(1);
47
- expect(broadcasts).toHaveLength(1);
48
- expect(broadcasts[0]?.element.id).toBe(out.placed.elementId);
49
- });
50
-
51
- it("rejects missing required input", async () => {
52
- const tool = createAtelierGenerationTool({ store: new InMemoryCanvasStore() });
53
- await expect(tool.execute({ canvasId: "c1" }, ctx)).rejects.toThrow("prompt");
54
- await expect(tool.execute({ prompt: "x" }, ctx)).rejects.toThrow("canvasId");
55
- });
56
-
57
- it("places video as an embeddable (no file)", async () => {
58
- const store = new InMemoryCanvasStore();
59
- const tool = createAtelierGenerationTool({ store });
60
- await tool.execute({ canvasId: "c1", prompt: "loop of falling snow", modality: "video" }, ctx);
61
- const el = (await store.get("org_1", "c1"))?.scene.elements[0];
62
- expect(el?.type).toBe("embeddable");
63
- });
64
- });
65
-
66
- describe("createAtelierAgent", () => {
67
- it("produces a single-tool AgentConfig carrying the strategy prompt", () => {
68
- const agent = createAtelierAgent({ store: new InMemoryCanvasStore() });
69
- expect(agent.id).toBe("atelier");
70
- expect(agent.tools).toHaveLength(1);
71
- expect(agent.tools?.[0]?.name).toBe("atelier_generate");
72
- expect(agent.instructions).toBe(ATELIER_SYSTEM_PROMPT);
73
- });
74
- });
@@ -1,34 +0,0 @@
1
- /**
2
- * The creative-canvas agent, as a single Sailor `AgentConfig`.
3
- *
4
- * The source product ran a two-node LangGraph swarm (planner ⇄ creator). Both
5
- * roles are expressed here as one prompt with two phases plus one tool — the
6
- * planner/creator split was orchestration overhead, not product value, and
7
- * Sailor's `BaseAgent` tool-loop already provides the step iteration the swarm
8
- * was emulating. `maxSteps` is the `@nebutra/agents` AgentConfig field (the
9
- * provider maps it to `stopWhen: stepCountIs(...)` internally) — not an AI SDK
10
- * option.
11
- */
12
-
13
- import type { AgentConfig } from "@nebutra/agents";
14
- import { ATELIER_SYSTEM_PROMPT } from "./prompts";
15
- import { type AtelierToolDeps, createAtelierGenerationTool } from "./tools";
16
-
17
- export interface CreateAtelierAgentOptions extends AtelierToolDeps {
18
- /** Model id / preset. Defaults to the "flagship" preset. */
19
- readonly model?: string;
20
- /** Max tool-loop iterations (batch generation needs headroom). */
21
- readonly maxSteps?: number;
22
- }
23
-
24
- export function createAtelierAgent(opts: CreateAtelierAgentOptions): AgentConfig {
25
- return {
26
- id: "atelier",
27
- name: "Atelier",
28
- description: "Plans and generates images/videos onto a creative canvas from a brief.",
29
- model: opts.model ?? "flagship",
30
- instructions: ATELIER_SYSTEM_PROMPT,
31
- tools: [createAtelierGenerationTool(opts)],
32
- maxSteps: opts.maxSteps ?? 30,
33
- };
34
- }
@@ -1,16 +0,0 @@
1
- /**
2
- * @nebutra/atelier-canvas/agent — the canvas capability's agent face.
3
- *
4
- * Opt-in subpath: importing it pulls `@nebutra/agents` (an optional peer);
5
- * the package's core export (placement / store / consistency) stays free of
6
- * any agents dependency. This is a *consumer* of the agents runtime — it
7
- * yields an `AgentConfig` — never a runtime parallel to it.
8
- */
9
-
10
- export { type CreateAtelierAgentOptions, createAtelierAgent } from "./agent";
11
- export {
12
- ATELIER_CREATOR_RULES,
13
- ATELIER_PLANNER_RULES,
14
- ATELIER_SYSTEM_PROMPT,
15
- } from "./prompts";
16
- export { type AtelierToolDeps, createAtelierGenerationTool } from "./tools";
@@ -1,64 +0,0 @@
1
- /**
2
- * The creative-direction prompt strategy.
3
- *
4
- * This is the differentiated IP absorbed from the source product, re-expressed
5
- * in Sailor's voice: a two-phase loop (plan → create), a structured "Design
6
- * Strategy" the model must author before generating, and the operational
7
- * invariants that make multi-asset generation reliable — exact-quantity
8
- * preservation, bounded batches, reference-image parsing, and act-without-
9
- * asking. These rules, not the model choice, are what make output consistent.
10
- *
11
- * Lives under the `/agent` subpath, not a package parallel to
12
- * `@nebutra/agents`: it produces an `AgentConfig` *consumed by* the agents
13
- * runtime, it is not a second runtime.
14
- */
15
-
16
- /** Phase 1 — decompose intent into an ordered plan. */
17
- export const ATELIER_PLANNER_RULES = `\
18
- You are a creative-canvas director. Reply in the SAME LANGUAGE as the user.
19
-
20
- PLAN FIRST:
21
- - For any multi-step request, write a short ordered plan (high-level steps)
22
- before generating anything. For a single image with no extra steps, a plan
23
- is optional.
24
- - Then proceed to generate immediately. Do NOT ask the user to approve the
25
- plan — acting is expected.
26
-
27
- QUANTITY IS A CONTRACT:
28
- - If the user states a count ("20 images", "make 15 frames"), carry that
29
- EXACT number through the plan and the generation calls. Never silently
30
- change or drop it. If no count is given, assume 1.`;
31
-
32
- /** Phase 2 — author a design strategy, then generate. */
33
- export const ATELIER_CREATOR_RULES = `\
34
- BEFORE GENERATING AN IMAGE, author a brief Design Strategy in the user's
35
- language covering, as bullets:
36
- • Recommended resolution (and why it fits the use case)
37
- • Style & mood
38
- • Key visual element(s)
39
- • Composition & layout
40
- • Colour palette (concrete values)
41
- • Typography (only if the piece carries text)
42
-
43
- Then call the generation tool with ONE detailed, professional prompt derived
44
- from that strategy. The strategy is scaffolding for the prompt — keep it tight,
45
- not an essay.
46
-
47
- REFERENCE IMAGES:
48
- - The user message may carry references as XML:
49
- <input_images><image file_id="..."/></input_images>
50
- - When present, extract every file_id and pass them to the tool's
51
- \`inputImages\` argument so edits/variations are grounded in the source.
52
-
53
- BATCHING:
54
- - Need more than 10 assets? Generate in batches of at most 10, finishing and
55
- acknowledging each batch ("Batch 1 of 3 done") before starting the next.
56
-
57
- VIDEO:
58
- - For video, you may first generate the key still(s) and animate from them, or
59
- generate directly from a text prompt — choose what the brief implies.`;
60
-
61
- /** Composed system prompt for a single-agent creative-canvas operator. */
62
- export const ATELIER_SYSTEM_PROMPT = `${ATELIER_PLANNER_RULES}
63
-
64
- ${ATELIER_CREATOR_RULES}`;
@@ -1,148 +0,0 @@
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 DELETED
@@ -1,33 +0,0 @@
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 DELETED
@@ -1,39 +0,0 @@
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
- }
package/src/placement.ts DELETED
@@ -1,67 +0,0 @@
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 DELETED
@@ -1,115 +0,0 @@
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
- }
@@ -1,59 +0,0 @@
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
- }
@@ -1,119 +0,0 @@
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 DELETED
@@ -1,92 +0,0 @@
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 DELETED
@@ -1,12 +0,0 @@
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
- }
package/tsup.config.ts DELETED
@@ -1,11 +0,0 @@
1
- import { defineConfig } from "tsup";
2
-
3
- export default defineConfig({
4
- entry: ["src/index.ts", "src/store/memory.ts", "src/store/prisma.ts", "src/agent/index.ts"],
5
- format: ["esm"],
6
- dts: true,
7
- sourcemap: true,
8
- clean: true,
9
- target: "es2022",
10
- external: ["@nebutra/logger", "@nebutra/agents"],
11
- });