@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,69 @@
1
+ import { AgentTool, AgentConfig } from '@nebutra/agents';
2
+ import { a as CanvasStore, S as ScenePatch } from '../types-Dw9o09h2.js';
3
+
4
+ /**
5
+ * The single agent tool that closes the loop: text prompt → generated asset
6
+ * → server-placed on the tenant's canvas → patch handed back for broadcast.
7
+ *
8
+ * Generation reuses the env-key-gated modality in `@nebutra/agents` (mock when
9
+ * no provider key is present). Placement + persistence reuse this package's
10
+ * own engine (relative imports — same capability, no cross-package hop).
11
+ *
12
+ * `onPlaced` lets the app broadcast the patch (e.g. via pusher) WITHOUT this
13
+ * package taking a realtime dependency — broadcast is a WRAP at the app layer.
14
+ */
15
+
16
+ interface AtelierToolDeps {
17
+ /** Tenant-scoped persistence (InMemory for demo, Prisma in prod). */
18
+ readonly store: CanvasStore;
19
+ /** Broadcast hook — invoked after the patch is durably persisted. */
20
+ readonly onPlaced?: (patch: ScenePatch) => void | Promise<void>;
21
+ }
22
+ /**
23
+ * Build the `atelier_generate` tool. One call = one asset (the agent loops
24
+ * itself for batches, per the batching rule in the system prompt).
25
+ */
26
+ declare function createAtelierGenerationTool(deps: AtelierToolDeps): AgentTool;
27
+
28
+ /**
29
+ * The creative-canvas agent, as a single Sailor `AgentConfig`.
30
+ *
31
+ * The source product ran a two-node LangGraph swarm (planner ⇄ creator). Both
32
+ * roles are expressed here as one prompt with two phases plus one tool — the
33
+ * planner/creator split was orchestration overhead, not product value, and
34
+ * Sailor's `BaseAgent` tool-loop already provides the step iteration the swarm
35
+ * was emulating. `maxSteps` is the `@nebutra/agents` AgentConfig field (the
36
+ * provider maps it to `stopWhen: stepCountIs(...)` internally) — not an AI SDK
37
+ * option.
38
+ */
39
+
40
+ interface CreateAtelierAgentOptions extends AtelierToolDeps {
41
+ /** Model id / preset. Defaults to the "flagship" preset. */
42
+ readonly model?: string;
43
+ /** Max tool-loop iterations (batch generation needs headroom). */
44
+ readonly maxSteps?: number;
45
+ }
46
+ declare function createAtelierAgent(opts: CreateAtelierAgentOptions): AgentConfig;
47
+
48
+ /**
49
+ * The creative-direction prompt strategy.
50
+ *
51
+ * This is the differentiated IP absorbed from the source product, re-expressed
52
+ * in Sailor's voice: a two-phase loop (plan → create), a structured "Design
53
+ * Strategy" the model must author before generating, and the operational
54
+ * invariants that make multi-asset generation reliable — exact-quantity
55
+ * preservation, bounded batches, reference-image parsing, and act-without-
56
+ * asking. These rules, not the model choice, are what make output consistent.
57
+ *
58
+ * Lives under the `/agent` subpath, not a package parallel to
59
+ * `@nebutra/agents`: it produces an `AgentConfig` *consumed by* the agents
60
+ * runtime, it is not a second runtime.
61
+ */
62
+ /** Phase 1 — decompose intent into an ordered plan. */
63
+ declare const ATELIER_PLANNER_RULES = "You are a creative-canvas director. Reply in the SAME LANGUAGE as the user.\n\nPLAN FIRST:\n- For any multi-step request, write a short ordered plan (high-level steps)\n before generating anything. For a single image with no extra steps, a plan\n is optional.\n- Then proceed to generate immediately. Do NOT ask the user to approve the\n plan \u2014 acting is expected.\n\nQUANTITY IS A CONTRACT:\n- If the user states a count (\"20 images\", \"make 15 frames\"), carry that\n EXACT number through the plan and the generation calls. Never silently\n change or drop it. If no count is given, assume 1.";
64
+ /** Phase 2 — author a design strategy, then generate. */
65
+ declare const ATELIER_CREATOR_RULES = "BEFORE GENERATING AN IMAGE, author a brief Design Strategy in the user's\nlanguage covering, as bullets:\n \u2022 Recommended resolution (and why it fits the use case)\n \u2022 Style & mood\n \u2022 Key visual element(s)\n \u2022 Composition & layout\n \u2022 Colour palette (concrete values)\n \u2022 Typography (only if the piece carries text)\n\nThen call the generation tool with ONE detailed, professional prompt derived\nfrom that strategy. The strategy is scaffolding for the prompt \u2014 keep it tight,\nnot an essay.\n\nREFERENCE IMAGES:\n- The user message may carry references as XML:\n <input_images><image file_id=\"...\"/></input_images>\n- When present, extract every file_id and pass them to the tool's\n `inputImages` argument so edits/variations are grounded in the source.\n\nBATCHING:\n- Need more than 10 assets? Generate in batches of at most 10, finishing and\n acknowledging each batch (\"Batch 1 of 3 done\") before starting the next.\n\nVIDEO:\n- For video, you may first generate the key still(s) and animate from them, or\n generate directly from a text prompt \u2014 choose what the brief implies.";
66
+ /** Composed system prompt for a single-agent creative-canvas operator. */
67
+ declare const ATELIER_SYSTEM_PROMPT = "You are a creative-canvas director. Reply in the SAME LANGUAGE as the user.\n\nPLAN FIRST:\n- For any multi-step request, write a short ordered plan (high-level steps)\n before generating anything. For a single image with no extra steps, a plan\n is optional.\n- Then proceed to generate immediately. Do NOT ask the user to approve the\n plan \u2014 acting is expected.\n\nQUANTITY IS A CONTRACT:\n- If the user states a count (\"20 images\", \"make 15 frames\"), carry that\n EXACT number through the plan and the generation calls. Never silently\n change or drop it. If no count is given, assume 1.\n\nBEFORE GENERATING AN IMAGE, author a brief Design Strategy in the user's\nlanguage covering, as bullets:\n \u2022 Recommended resolution (and why it fits the use case)\n \u2022 Style & mood\n \u2022 Key visual element(s)\n \u2022 Composition & layout\n \u2022 Colour palette (concrete values)\n \u2022 Typography (only if the piece carries text)\n\nThen call the generation tool with ONE detailed, professional prompt derived\nfrom that strategy. The strategy is scaffolding for the prompt \u2014 keep it tight,\nnot an essay.\n\nREFERENCE IMAGES:\n- The user message may carry references as XML:\n <input_images><image file_id=\"...\"/></input_images>\n- When present, extract every file_id and pass them to the tool's\n `inputImages` argument so edits/variations are grounded in the source.\n\nBATCHING:\n- Need more than 10 assets? Generate in batches of at most 10, finishing and\n acknowledging each batch (\"Batch 1 of 3 done\") before starting the next.\n\nVIDEO:\n- For video, you may first generate the key still(s) and animate from them, or\n generate directly from a text prompt \u2014 choose what the brief implies.";
68
+
69
+ export { ATELIER_CREATOR_RULES, ATELIER_PLANNER_RULES, ATELIER_SYSTEM_PROMPT, type AtelierToolDeps, type CreateAtelierAgentOptions, createAtelierAgent, createAtelierGenerationTool };
@@ -0,0 +1,166 @@
1
+ import {
2
+ placeGeneratedAsset
3
+ } from "../chunk-JYV7NRGL.js";
4
+
5
+ // src/agent/prompts.ts
6
+ var ATELIER_PLANNER_RULES = `You are a creative-canvas director. Reply in the SAME LANGUAGE as the user.
7
+
8
+ PLAN FIRST:
9
+ - For any multi-step request, write a short ordered plan (high-level steps)
10
+ before generating anything. For a single image with no extra steps, a plan
11
+ is optional.
12
+ - Then proceed to generate immediately. Do NOT ask the user to approve the
13
+ plan \u2014 acting is expected.
14
+
15
+ QUANTITY IS A CONTRACT:
16
+ - If the user states a count ("20 images", "make 15 frames"), carry that
17
+ EXACT number through the plan and the generation calls. Never silently
18
+ change or drop it. If no count is given, assume 1.`;
19
+ var ATELIER_CREATOR_RULES = `BEFORE GENERATING AN IMAGE, author a brief Design Strategy in the user's
20
+ language covering, as bullets:
21
+ \u2022 Recommended resolution (and why it fits the use case)
22
+ \u2022 Style & mood
23
+ \u2022 Key visual element(s)
24
+ \u2022 Composition & layout
25
+ \u2022 Colour palette (concrete values)
26
+ \u2022 Typography (only if the piece carries text)
27
+
28
+ Then call the generation tool with ONE detailed, professional prompt derived
29
+ from that strategy. The strategy is scaffolding for the prompt \u2014 keep it tight,
30
+ not an essay.
31
+
32
+ REFERENCE IMAGES:
33
+ - The user message may carry references as XML:
34
+ <input_images><image file_id="..."/></input_images>
35
+ - When present, extract every file_id and pass them to the tool's
36
+ \`inputImages\` argument so edits/variations are grounded in the source.
37
+
38
+ BATCHING:
39
+ - Need more than 10 assets? Generate in batches of at most 10, finishing and
40
+ acknowledging each batch ("Batch 1 of 3 done") before starting the next.
41
+
42
+ VIDEO:
43
+ - For video, you may first generate the key still(s) and animate from them, or
44
+ generate directly from a text prompt \u2014 choose what the brief implies.`;
45
+ var ATELIER_SYSTEM_PROMPT = `${ATELIER_PLANNER_RULES}
46
+
47
+ ${ATELIER_CREATOR_RULES}`;
48
+
49
+ // src/agent/tools.ts
50
+ import {
51
+ generateImage,
52
+ generateVideo
53
+ } from "@nebutra/agents";
54
+ function parseInput(raw) {
55
+ const o = raw ?? {};
56
+ if (typeof o.canvasId !== "string" || o.canvasId.length === 0) {
57
+ throw new Error("atelier_generate: 'canvasId' is required");
58
+ }
59
+ if (typeof o.prompt !== "string" || o.prompt.trim().length === 0) {
60
+ throw new Error("atelier_generate: 'prompt' is required");
61
+ }
62
+ const modality = o.modality === "video" ? "video" : "image";
63
+ return {
64
+ canvasId: o.canvasId,
65
+ prompt: o.prompt,
66
+ modality,
67
+ ...typeof o.width === "number" ? { width: o.width } : {},
68
+ ...typeof o.height === "number" ? { height: o.height } : {},
69
+ ...Array.isArray(o.inputImages) ? { inputImages: o.inputImages.filter((x) => typeof x === "string") } : {},
70
+ ...typeof o.durationSeconds === "number" ? { durationSeconds: o.durationSeconds } : {}
71
+ };
72
+ }
73
+ function createAtelierGenerationTool(deps) {
74
+ return {
75
+ name: "atelier_generate",
76
+ description: "Generate one image or video from a prompt and place it on the canvas. Returns the placed element's position so you can reason about layout. Call once per asset; for batches, call repeatedly.",
77
+ inputSchema: {
78
+ type: "object",
79
+ properties: {
80
+ canvasId: { type: "string", description: "Target canvas id" },
81
+ prompt: { type: "string", description: "Detailed generation prompt" },
82
+ modality: { type: "string", enum: ["image", "video"] },
83
+ width: { type: "number" },
84
+ height: { type: "number" },
85
+ durationSeconds: { type: "number", description: "Video length (s)" },
86
+ inputImages: {
87
+ type: "array",
88
+ items: { type: "string" },
89
+ description: "Reference file ids / URLs for edits & variations"
90
+ }
91
+ },
92
+ required: ["canvasId", "prompt"]
93
+ },
94
+ execute: async (rawInput, context) => {
95
+ const input = parseInput(rawInput);
96
+ const genCtx = {
97
+ tenantId: context.tenantId,
98
+ userId: context.userId,
99
+ conversationId: context.conversationId
100
+ };
101
+ const result = input.modality === "video" ? await generateVideo(
102
+ {
103
+ prompt: input.prompt,
104
+ ...input.width ? { width: input.width } : {},
105
+ ...input.height ? { height: input.height } : {},
106
+ ...input.durationSeconds ? { durationSeconds: input.durationSeconds } : {},
107
+ ...input.inputImages?.[0] ? { inputImage: input.inputImages[0] } : {}
108
+ },
109
+ genCtx
110
+ ) : await generateImage(
111
+ {
112
+ prompt: input.prompt,
113
+ ...input.width ? { width: input.width } : {},
114
+ ...input.height ? { height: input.height } : {},
115
+ ...input.inputImages ? { inputImages: input.inputImages } : {}
116
+ },
117
+ genCtx
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
+ await deps.onPlaced?.(patch);
132
+ return {
133
+ ok: true,
134
+ placed: {
135
+ elementId: patch.element.id,
136
+ x: patch.element.x,
137
+ y: patch.element.y,
138
+ modality: result.modality
139
+ },
140
+ provider: result.providerName,
141
+ model: result.model
142
+ };
143
+ }
144
+ };
145
+ }
146
+
147
+ // src/agent/agent.ts
148
+ function createAtelierAgent(opts) {
149
+ return {
150
+ id: "atelier",
151
+ name: "Atelier",
152
+ description: "Plans and generates images/videos onto a creative canvas from a brief.",
153
+ model: opts.model ?? "flagship",
154
+ instructions: ATELIER_SYSTEM_PROMPT,
155
+ tools: [createAtelierGenerationTool(opts)],
156
+ maxSteps: opts.maxSteps ?? 30
157
+ };
158
+ }
159
+ export {
160
+ ATELIER_CREATOR_RULES,
161
+ ATELIER_PLANNER_RULES,
162
+ ATELIER_SYSTEM_PROMPT,
163
+ createAtelierAgent,
164
+ createAtelierGenerationTool
165
+ };
166
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/agent/prompts.ts","../../src/agent/tools.ts","../../src/agent/agent.ts"],"sourcesContent":["/**\n * The creative-direction prompt strategy.\n *\n * This is the differentiated IP absorbed from the source product, re-expressed\n * in Sailor's voice: a two-phase loop (plan → create), a structured \"Design\n * Strategy\" the model must author before generating, and the operational\n * invariants that make multi-asset generation reliable — exact-quantity\n * preservation, bounded batches, reference-image parsing, and act-without-\n * asking. These rules, not the model choice, are what make output consistent.\n *\n * Lives under the `/agent` subpath, not a package parallel to\n * `@nebutra/agents`: it produces an `AgentConfig` *consumed by* the agents\n * runtime, it is not a second runtime.\n */\n\n/** Phase 1 — decompose intent into an ordered plan. */\nexport const ATELIER_PLANNER_RULES = `\\\nYou are a creative-canvas director. Reply in the SAME LANGUAGE as the user.\n\nPLAN FIRST:\n- For any multi-step request, write a short ordered plan (high-level steps)\n before generating anything. For a single image with no extra steps, a plan\n is optional.\n- Then proceed to generate immediately. Do NOT ask the user to approve the\n plan — acting is expected.\n\nQUANTITY IS A CONTRACT:\n- If the user states a count (\"20 images\", \"make 15 frames\"), carry that\n EXACT number through the plan and the generation calls. Never silently\n change or drop it. If no count is given, assume 1.`;\n\n/** Phase 2 — author a design strategy, then generate. */\nexport const ATELIER_CREATOR_RULES = `\\\nBEFORE GENERATING AN IMAGE, author a brief Design Strategy in the user's\nlanguage covering, as bullets:\n • Recommended resolution (and why it fits the use case)\n • Style & mood\n • Key visual element(s)\n • Composition & layout\n • Colour palette (concrete values)\n • Typography (only if the piece carries text)\n\nThen call the generation tool with ONE detailed, professional prompt derived\nfrom that strategy. The strategy is scaffolding for the prompt — keep it tight,\nnot an essay.\n\nREFERENCE IMAGES:\n- The user message may carry references as XML:\n <input_images><image file_id=\"...\"/></input_images>\n- When present, extract every file_id and pass them to the tool's\n \\`inputImages\\` argument so edits/variations are grounded in the source.\n\nBATCHING:\n- Need more than 10 assets? Generate in batches of at most 10, finishing and\n acknowledging each batch (\"Batch 1 of 3 done\") before starting the next.\n\nVIDEO:\n- For video, you may first generate the key still(s) and animate from them, or\n generate directly from a text prompt — choose what the brief implies.`;\n\n/** Composed system prompt for a single-agent creative-canvas operator. */\nexport const ATELIER_SYSTEM_PROMPT = `${ATELIER_PLANNER_RULES}\n\n${ATELIER_CREATOR_RULES}`;\n","/**\n * The single agent tool that closes the loop: text prompt → generated asset\n * → server-placed on the tenant's canvas → patch handed back for broadcast.\n *\n * Generation reuses the env-key-gated modality in `@nebutra/agents` (mock when\n * no provider key is present). Placement + persistence reuse this package's\n * own engine (relative imports — same capability, no cross-package hop).\n *\n * `onPlaced` lets the app broadcast the patch (e.g. via pusher) WITHOUT this\n * package taking a realtime dependency — broadcast is a WRAP at the app layer.\n */\n\nimport {\n type AgentTool,\n type GenerationContext,\n generateImage,\n generateVideo,\n} from \"@nebutra/agents\";\nimport { placeGeneratedAsset } from \"../service\";\nimport type { CanvasStore, ScenePatch } from \"../types\";\n\nexport interface AtelierToolDeps {\n /** Tenant-scoped persistence (InMemory for demo, Prisma in prod). */\n readonly store: CanvasStore;\n /** Broadcast hook — invoked after the patch is durably persisted. */\n readonly onPlaced?: (patch: ScenePatch) => void | Promise<void>;\n}\n\ninterface GenerateInput {\n canvasId: string;\n prompt: string;\n modality?: \"image\" | \"video\";\n width?: number;\n height?: number;\n inputImages?: string[];\n durationSeconds?: number;\n}\n\nfunction parseInput(raw: unknown): GenerateInput {\n const o = (raw ?? {}) as Record<string, unknown>;\n if (typeof o.canvasId !== \"string\" || o.canvasId.length === 0) {\n throw new Error(\"atelier_generate: 'canvasId' is required\");\n }\n if (typeof o.prompt !== \"string\" || o.prompt.trim().length === 0) {\n throw new Error(\"atelier_generate: 'prompt' is required\");\n }\n const modality = o.modality === \"video\" ? \"video\" : \"image\";\n return {\n canvasId: o.canvasId,\n prompt: o.prompt,\n modality,\n ...(typeof o.width === \"number\" ? { width: o.width } : {}),\n ...(typeof o.height === \"number\" ? { height: o.height } : {}),\n ...(Array.isArray(o.inputImages)\n ? { inputImages: o.inputImages.filter((x): x is string => typeof x === \"string\") }\n : {}),\n ...(typeof o.durationSeconds === \"number\" ? { durationSeconds: o.durationSeconds } : {}),\n };\n}\n\n/**\n * Build the `atelier_generate` tool. One call = one asset (the agent loops\n * itself for batches, per the batching rule in the system prompt).\n */\nexport function createAtelierGenerationTool(deps: AtelierToolDeps): AgentTool {\n return {\n name: \"atelier_generate\",\n description:\n \"Generate one image or video from a prompt and place it on the canvas. \" +\n \"Returns the placed element's position so you can reason about layout. \" +\n \"Call once per asset; for batches, call repeatedly.\",\n inputSchema: {\n type: \"object\",\n properties: {\n canvasId: { type: \"string\", description: \"Target canvas id\" },\n prompt: { type: \"string\", description: \"Detailed generation prompt\" },\n modality: { type: \"string\", enum: [\"image\", \"video\"] },\n width: { type: \"number\" },\n height: { type: \"number\" },\n durationSeconds: { type: \"number\", description: \"Video length (s)\" },\n inputImages: {\n type: \"array\",\n items: { type: \"string\" },\n description: \"Reference file ids / URLs for edits & variations\",\n },\n },\n required: [\"canvasId\", \"prompt\"],\n },\n execute: async (rawInput, context) => {\n const input = parseInput(rawInput);\n const genCtx: GenerationContext = {\n tenantId: context.tenantId,\n userId: context.userId,\n conversationId: context.conversationId,\n };\n\n const result =\n input.modality === \"video\"\n ? await generateVideo(\n {\n prompt: input.prompt,\n ...(input.width ? { width: input.width } : {}),\n ...(input.height ? { height: input.height } : {}),\n ...(input.durationSeconds ? { durationSeconds: input.durationSeconds } : {}),\n ...(input.inputImages?.[0] ? { inputImage: input.inputImages[0] } : {}),\n },\n genCtx,\n )\n : await generateImage(\n {\n prompt: input.prompt,\n ...(input.width ? { width: input.width } : {}),\n ...(input.height ? { height: input.height } : {}),\n ...(input.inputImages ? { inputImages: input.inputImages } : {}),\n },\n genCtx,\n );\n\n const { patch } = await placeGeneratedAsset(deps.store, context.tenantId, input.canvasId, {\n modality: result.modality,\n mimeType: result.mimeType,\n url: result.url,\n width: result.width,\n height: result.height,\n meta: {\n prompt: input.prompt,\n model: result.model,\n provider: result.providerName,\n },\n });\n\n // Persisted — now safe to broadcast.\n await deps.onPlaced?.(patch);\n\n return {\n ok: true,\n placed: {\n elementId: patch.element.id,\n x: patch.element.x,\n y: patch.element.y,\n modality: result.modality,\n },\n provider: result.providerName,\n model: result.model,\n };\n },\n };\n}\n","/**\n * The creative-canvas agent, as a single Sailor `AgentConfig`.\n *\n * The source product ran a two-node LangGraph swarm (planner ⇄ creator). Both\n * roles are expressed here as one prompt with two phases plus one tool — the\n * planner/creator split was orchestration overhead, not product value, and\n * Sailor's `BaseAgent` tool-loop already provides the step iteration the swarm\n * was emulating. `maxSteps` is the `@nebutra/agents` AgentConfig field (the\n * provider maps it to `stopWhen: stepCountIs(...)` internally) — not an AI SDK\n * option.\n */\n\nimport type { AgentConfig } from \"@nebutra/agents\";\nimport { ATELIER_SYSTEM_PROMPT } from \"./prompts\";\nimport { type AtelierToolDeps, createAtelierGenerationTool } from \"./tools\";\n\nexport interface CreateAtelierAgentOptions extends AtelierToolDeps {\n /** Model id / preset. Defaults to the \"flagship\" preset. */\n readonly model?: string;\n /** Max tool-loop iterations (batch generation needs headroom). */\n readonly maxSteps?: number;\n}\n\nexport function createAtelierAgent(opts: CreateAtelierAgentOptions): AgentConfig {\n return {\n id: \"atelier\",\n name: \"Atelier\",\n description: \"Plans and generates images/videos onto a creative canvas from a brief.\",\n model: opts.model ?? \"flagship\",\n instructions: ATELIER_SYSTEM_PROMPT,\n tools: [createAtelierGenerationTool(opts)],\n maxSteps: opts.maxSteps ?? 30,\n };\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,43 @@
1
+ // src/store/memory.ts
2
+ import { InMemoryTenantStore } from "@nebutra/tenant-store";
3
+ var EMPTY_SCENE = { elements: [], files: [] };
4
+ var InMemoryCanvasStore = class {
5
+ base = new InMemoryTenantStore();
6
+ async get(tenantId, canvasId) {
7
+ return this.base.read(tenantId, canvasId);
8
+ }
9
+ async create(tenantId, canvasId, name) {
10
+ const row = {
11
+ id: canvasId,
12
+ tenantId,
13
+ name,
14
+ scene: EMPTY_SCENE,
15
+ updatedAt: /* @__PURE__ */ new Date()
16
+ };
17
+ return this.base.write(tenantId, canvasId, row);
18
+ }
19
+ async save(tenantId, canvasId, scene, thumbnail) {
20
+ const existing = await this.base.read(tenantId, canvasId);
21
+ const row = {
22
+ id: canvasId,
23
+ tenantId,
24
+ name: existing?.name ?? canvasId,
25
+ scene,
26
+ ...thumbnail !== void 0 ? { thumbnail } : {},
27
+ updatedAt: /* @__PURE__ */ new Date()
28
+ };
29
+ return this.base.write(tenantId, canvasId, row);
30
+ }
31
+ async list(tenantId) {
32
+ return this.base.listByTenant(tenantId);
33
+ }
34
+ /** Test helper. */
35
+ _clear() {
36
+ this.base.clear();
37
+ }
38
+ };
39
+
40
+ export {
41
+ InMemoryCanvasStore
42
+ };
43
+ //# sourceMappingURL=chunk-AVQEQBLX.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/store/memory.ts"],"sourcesContent":["/**\n * In-memory CanvasStore — the default for tests and the flag-gated demo.\n *\n * Storage mechanics (tenant-keyed map, never returning rows across tenants,\n * tenant-filtered listing) are delegated to `InMemoryTenantStore` from\n * `@nebutra/tenant-store`; this class only adds atelier's typed `create` /\n * `save` domain shape, mirroring what RLS does in the Prisma store.\n */\n\nimport { InMemoryTenantStore } from \"@nebutra/tenant-store\";\nimport type { AtelierCanvas, CanvasScene, CanvasStore } from \"../types\";\n\nconst EMPTY_SCENE: CanvasScene = { elements: [], files: [] };\n\nexport class InMemoryCanvasStore implements CanvasStore {\n private readonly base = new InMemoryTenantStore<AtelierCanvas>();\n\n async get(tenantId: string, canvasId: string): Promise<AtelierCanvas | null> {\n return this.base.read(tenantId, canvasId);\n }\n\n async create(tenantId: string, canvasId: string, name: string): Promise<AtelierCanvas> {\n const row: AtelierCanvas = {\n id: canvasId,\n tenantId,\n name,\n scene: EMPTY_SCENE,\n updatedAt: new Date(),\n };\n return this.base.write(tenantId, canvasId, row);\n }\n\n async save(\n tenantId: string,\n canvasId: string,\n scene: CanvasScene,\n thumbnail?: string,\n ): Promise<AtelierCanvas> {\n const existing = await this.base.read(tenantId, canvasId);\n const row: AtelierCanvas = {\n id: canvasId,\n tenantId,\n name: existing?.name ?? canvasId,\n scene,\n ...(thumbnail !== undefined ? { thumbnail } : {}),\n updatedAt: new Date(),\n };\n return this.base.write(tenantId, canvasId, row);\n }\n\n async list(tenantId: string): Promise<readonly AtelierCanvas[]> {\n return this.base.listByTenant(tenantId);\n }\n\n /** Test helper. */\n _clear(): void {\n this.base.clear();\n }\n}\n"],"mappings":";AASA,SAAS,2BAA2B;AAGpC,IAAM,cAA2B,EAAE,UAAU,CAAC,GAAG,OAAO,CAAC,EAAE;AAEpD,IAAM,sBAAN,MAAiD;AAAA,EACrC,OAAO,IAAI,oBAAmC;AAAA,EAE/D,MAAM,IAAI,UAAkB,UAAiD;AAC3E,WAAO,KAAK,KAAK,KAAK,UAAU,QAAQ;AAAA,EAC1C;AAAA,EAEA,MAAM,OAAO,UAAkB,UAAkB,MAAsC;AACrF,UAAM,MAAqB;AAAA,MACzB,IAAI;AAAA,MACJ;AAAA,MACA;AAAA,MACA,OAAO;AAAA,MACP,WAAW,oBAAI,KAAK;AAAA,IACtB;AACA,WAAO,KAAK,KAAK,MAAM,UAAU,UAAU,GAAG;AAAA,EAChD;AAAA,EAEA,MAAM,KACJ,UACA,UACA,OACA,WACwB;AACxB,UAAM,WAAW,MAAM,KAAK,KAAK,KAAK,UAAU,QAAQ;AACxD,UAAM,MAAqB;AAAA,MACzB,IAAI;AAAA,MACJ;AAAA,MACA,MAAM,UAAU,QAAQ;AAAA,MACxB;AAAA,MACA,GAAI,cAAc,SAAY,EAAE,UAAU,IAAI,CAAC;AAAA,MAC/C,WAAW,oBAAI,KAAK;AAAA,IACtB;AACA,WAAO,KAAK,KAAK,MAAM,UAAU,UAAU,GAAG;AAAA,EAChD;AAAA,EAEA,MAAM,KAAK,UAAqD;AAC9D,WAAO,KAAK,KAAK,aAAa,QAAQ;AAAA,EACxC;AAAA;AAAA,EAGA,SAAe;AACb,SAAK,KAAK,MAAM;AAAA,EAClB;AACF;","names":[]}
@@ -0,0 +1,96 @@
1
+ // src/lock.ts
2
+ import { _resetTenantLocks, withTenantLock } from "@nebutra/tenant-store";
3
+ function withCanvasLock(tenantId, canvasId, fn) {
4
+ return withTenantLock(tenantId, canvasId, fn);
5
+ }
6
+ function _resetCanvasLocks() {
7
+ _resetTenantLocks();
8
+ }
9
+
10
+ // src/placement.ts
11
+ var DEFAULT_GAP = 40;
12
+ var MAX_COLS = 12;
13
+ function overlaps(a, b, gap) {
14
+ return !(a.x + a.width + gap <= b.x || b.x + b.width + gap <= a.x || a.y + a.height + gap <= b.y || b.y + b.height + gap <= a.y);
15
+ }
16
+ function findNextPosition(existing, size, gap = DEFAULT_GAP) {
17
+ if (existing.length === 0) return { x: 0, y: 0 };
18
+ const stepX = Math.max(size.width, ...existing.map((e) => e.width)) + gap;
19
+ const stepY = Math.max(size.height, ...existing.map((e) => e.height)) + gap;
20
+ const candidate = { x: 0, y: 0, width: size.width, height: size.height };
21
+ for (let row = 0; row < existing.length + 1; row++) {
22
+ for (let col = 0; col < MAX_COLS; col++) {
23
+ candidate.x = col * stepX;
24
+ candidate.y = row * stepY;
25
+ const clash = existing.some(
26
+ (e) => overlaps(candidate, { x: e.x, y: e.y, width: e.width, height: e.height }, gap)
27
+ );
28
+ if (!clash) return { x: candidate.x, y: candidate.y };
29
+ }
30
+ }
31
+ const maxBottom = Math.max(...existing.map((e) => e.y + e.height));
32
+ return { x: 0, y: maxBottom + gap };
33
+ }
34
+
35
+ // src/service.ts
36
+ import { logger } from "@nebutra/logger";
37
+ var log = logger.child({ module: "atelier-canvas/service" });
38
+ function newId(prefix) {
39
+ return `${prefix}_${crypto.randomUUID()}`;
40
+ }
41
+ async function placeGeneratedAsset(store, tenantId, canvasId, asset) {
42
+ return withCanvasLock(tenantId, canvasId, async () => {
43
+ const current = await store.get(tenantId, canvasId) ?? await store.create(tenantId, canvasId, canvasId);
44
+ const position = findNextPosition(current.scene.elements, {
45
+ width: asset.width,
46
+ height: asset.height
47
+ });
48
+ let file;
49
+ let ref;
50
+ if (asset.modality === "image") {
51
+ file = { id: newId("file"), mimeType: asset.mimeType, dataURL: asset.url };
52
+ ref = file.id;
53
+ } else {
54
+ ref = asset.url;
55
+ }
56
+ const element = {
57
+ id: newId("el"),
58
+ type: asset.modality === "image" ? "image" : "embeddable",
59
+ x: position.x,
60
+ y: position.y,
61
+ width: asset.width,
62
+ height: asset.height,
63
+ ref,
64
+ ...asset.meta ? { meta: asset.meta } : {}
65
+ };
66
+ const nextScene = {
67
+ elements: [...current.scene.elements, element],
68
+ files: file ? [...current.scene.files, file] : current.scene.files
69
+ };
70
+ const thumbnail = asset.url;
71
+ const canvas = await store.save(tenantId, canvasId, nextScene, thumbnail);
72
+ log.debug("placed generated asset", {
73
+ tenantId,
74
+ canvasId,
75
+ modality: asset.modality,
76
+ x: position.x,
77
+ y: position.y
78
+ });
79
+ const patch = {
80
+ canvasId,
81
+ tenantId,
82
+ element,
83
+ ...file ? { file } : {},
84
+ thumbnail
85
+ };
86
+ return { patch, canvas };
87
+ });
88
+ }
89
+
90
+ export {
91
+ withCanvasLock,
92
+ _resetCanvasLocks,
93
+ findNextPosition,
94
+ placeGeneratedAsset
95
+ };
96
+ //# sourceMappingURL=chunk-JYV7NRGL.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/lock.ts","../src/placement.ts","../src/service.ts"],"sourcesContent":["/**\n * Per-canvas serialization.\n *\n * The generic per-(tenant, resource) serializer now lives in the neutral\n * lower layer `@nebutra/tenant-store` (so `@nebutra/reel` and future\n * tenant-scoped features share it without depending on this package). This\n * module keeps the original `withCanvasLock` / `_resetCanvasLocks` names as\n * thin, back-compatible aliases so existing callers (e.g. apps/web) keep\n * working unchanged.\n *\n * @deprecated Import `withTenantLock` / `_resetTenantLocks` from\n * `@nebutra/tenant-store` directly in new code. These aliases are retained\n * only for the existing public surface.\n */\n\nimport { _resetTenantLocks, withTenantLock } from \"@nebutra/tenant-store\";\n\n/**\n * Run `fn` with exclusive access to a single (tenant, canvas). Calls for the\n * same canvas run strictly in submission order; different canvases run freely.\n *\n * @deprecated Use `withTenantLock` from `@nebutra/tenant-store`.\n */\nexport function withCanvasLock<T>(\n tenantId: string,\n canvasId: string,\n fn: () => Promise<T>,\n): Promise<T> {\n return withTenantLock(tenantId, canvasId, fn);\n}\n\n/**\n * Test helper — clears all lock chains.\n *\n * @deprecated Use `_resetTenantLocks` from `@nebutra/tenant-store`.\n */\nexport function _resetCanvasLocks(): void {\n _resetTenantLocks();\n}\n","/**\n * Server-authoritative non-overlap placement.\n *\n * Ported in spirit from the source product's `find_next_best_element_position`:\n * the server — not the client — decides where a freshly generated asset lands,\n * so concurrent generations never stack and every client converges on the same\n * coordinates. Deterministic given (existing elements, size, gap): identical\n * inputs always yield the identical position, which keeps placement tests and\n * websocket-sync reproducible.\n */\n\nimport type { CanvasElement, ElementSize, Placement } from \"./types\";\n\nconst DEFAULT_GAP = 40;\n/** Columns scanned before wrapping to the next row. Bounds worst-case work. */\nconst MAX_COLS = 12;\n\ninterface Box {\n x: number;\n y: number;\n width: number;\n height: number;\n}\n\nfunction overlaps(a: Box, b: Box, gap: number): boolean {\n return !(\n a.x + a.width + gap <= b.x ||\n b.x + b.width + gap <= a.x ||\n a.y + a.height + gap <= b.y ||\n b.y + b.height + gap <= a.y\n );\n}\n\n/**\n * Find the first free top-left position for an element of `size`, scanning\n * left→right then top→down on a grid sized to the largest existing element.\n * Returns `{0,0}` for an empty canvas.\n */\nexport function findNextPosition(\n existing: readonly CanvasElement[],\n size: ElementSize,\n gap: number = DEFAULT_GAP,\n): Placement {\n if (existing.length === 0) return { x: 0, y: 0 };\n\n // Step = widest/tallest placed element (+gap) so the grid never collides\n // with large neighbours; bounded below by the incoming element's own size.\n const stepX = Math.max(size.width, ...existing.map((e) => e.width)) + gap;\n const stepY = Math.max(size.height, ...existing.map((e) => e.height)) + gap;\n\n const candidate: Box = { x: 0, y: 0, width: size.width, height: size.height };\n\n for (let row = 0; row < existing.length + 1; row++) {\n for (let col = 0; col < MAX_COLS; col++) {\n candidate.x = col * stepX;\n candidate.y = row * stepY;\n const clash = existing.some((e) =>\n overlaps(candidate, { x: e.x, y: e.y, width: e.width, height: e.height }, gap),\n );\n if (!clash) return { x: candidate.x, y: candidate.y };\n }\n }\n\n // Pathological fallback: drop below everything.\n const maxBottom = Math.max(...existing.map((e) => e.y + e.height));\n return { x: 0, y: maxBottom + gap };\n}\n","/**\n * The write-then-broadcast placement service — the core consistency\n * invariant absorbed from the source product.\n *\n * For one (tenant, canvas), under the canvas lock:\n * 1. load the authoritative scene,\n * 2. compute a non-overlapping position server-side,\n * 3. append the element/file and **persist** the new scene,\n * 4. return a {@link ScenePatch}.\n *\n * The caller broadcasts the returned patch over the realtime channel *after*\n * this resolves. Persisting before broadcasting is what makes the websocket\n * a pure optimization: a client that never receives the event recovers the\n * identical state on reload. Reversing the order reintroduces the\n * lost-update bug.\n */\n\nimport { logger } from \"@nebutra/logger\";\nimport { withCanvasLock } from \"./lock\";\nimport { findNextPosition } from \"./placement\";\nimport type {\n AtelierCanvas,\n CanvasElement,\n CanvasFile,\n CanvasScene,\n CanvasStore,\n ScenePatch,\n} from \"./types\";\n\nconst log = logger.child({ module: \"atelier-canvas/service\" });\n\n/** A generated asset, shaped to match `@nebutra/agents` GenerationResult. */\nexport interface GeneratedAsset {\n readonly modality: \"image\" | \"video\";\n readonly mimeType: string;\n readonly url: string;\n readonly width: number;\n readonly height: number;\n /** Carried into element.meta for provenance (prompt, model, provider). */\n readonly meta?: Readonly<Record<string, unknown>>;\n}\n\nfunction newId(prefix: string): string {\n return `${prefix}_${crypto.randomUUID()}`;\n}\n\n/**\n * Place a generated asset on a canvas. Creates the canvas on first write.\n * Concurrency-safe per (tenant, canvas) via {@link withCanvasLock}.\n */\nexport async function placeGeneratedAsset(\n store: CanvasStore,\n tenantId: string,\n canvasId: string,\n asset: GeneratedAsset,\n): Promise<{ patch: ScenePatch; canvas: AtelierCanvas }> {\n return withCanvasLock(tenantId, canvasId, async () => {\n const current =\n (await store.get(tenantId, canvasId)) ?? (await store.create(tenantId, canvasId, canvasId));\n\n const position = findNextPosition(current.scene.elements, {\n width: asset.width,\n height: asset.height,\n });\n\n // Images carry a file the client resolves by id; videos embed by URL.\n let file: CanvasFile | undefined;\n let ref: string;\n if (asset.modality === \"image\") {\n file = { id: newId(\"file\"), mimeType: asset.mimeType, dataURL: asset.url };\n ref = file.id;\n } else {\n ref = asset.url;\n }\n\n const element: CanvasElement = {\n id: newId(\"el\"),\n type: asset.modality === \"image\" ? \"image\" : \"embeddable\",\n x: position.x,\n y: position.y,\n width: asset.width,\n height: asset.height,\n ref,\n ...(asset.meta ? { meta: asset.meta } : {}),\n };\n\n const nextScene: CanvasScene = {\n elements: [...current.scene.elements, element],\n files: file ? [...current.scene.files, file] : current.scene.files,\n };\n\n // Thumbnail = latest asset (matches source product's grid-preview rule).\n const thumbnail = asset.url;\n\n // (3) Persist BEFORE returning — broadcast happens in the caller, after.\n const canvas = await store.save(tenantId, canvasId, nextScene, thumbnail);\n\n log.debug(\"placed generated asset\", {\n tenantId,\n canvasId,\n modality: asset.modality,\n x: position.x,\n y: position.y,\n });\n\n const patch: ScenePatch = {\n canvasId,\n tenantId,\n element,\n ...(file ? { file } : {}),\n thumbnail,\n };\n return { patch, canvas };\n });\n}\n"],"mappings":";AAeA,SAAS,mBAAmB,sBAAsB;AAQ3C,SAAS,eACd,UACA,UACA,IACY;AACZ,SAAO,eAAe,UAAU,UAAU,EAAE;AAC9C;AAOO,SAAS,oBAA0B;AACxC,oBAAkB;AACpB;;;ACzBA,IAAM,cAAc;AAEpB,IAAM,WAAW;AASjB,SAAS,SAAS,GAAQ,GAAQ,KAAsB;AACtD,SAAO,EACL,EAAE,IAAI,EAAE,QAAQ,OAAO,EAAE,KACzB,EAAE,IAAI,EAAE,QAAQ,OAAO,EAAE,KACzB,EAAE,IAAI,EAAE,SAAS,OAAO,EAAE,KAC1B,EAAE,IAAI,EAAE,SAAS,OAAO,EAAE;AAE9B;AAOO,SAAS,iBACd,UACA,MACA,MAAc,aACH;AACX,MAAI,SAAS,WAAW,EAAG,QAAO,EAAE,GAAG,GAAG,GAAG,EAAE;AAI/C,QAAM,QAAQ,KAAK,IAAI,KAAK,OAAO,GAAG,SAAS,IAAI,CAAC,MAAM,EAAE,KAAK,CAAC,IAAI;AACtE,QAAM,QAAQ,KAAK,IAAI,KAAK,QAAQ,GAAG,SAAS,IAAI,CAAC,MAAM,EAAE,MAAM,CAAC,IAAI;AAExE,QAAM,YAAiB,EAAE,GAAG,GAAG,GAAG,GAAG,OAAO,KAAK,OAAO,QAAQ,KAAK,OAAO;AAE5E,WAAS,MAAM,GAAG,MAAM,SAAS,SAAS,GAAG,OAAO;AAClD,aAAS,MAAM,GAAG,MAAM,UAAU,OAAO;AACvC,gBAAU,IAAI,MAAM;AACpB,gBAAU,IAAI,MAAM;AACpB,YAAM,QAAQ,SAAS;AAAA,QAAK,CAAC,MAC3B,SAAS,WAAW,EAAE,GAAG,EAAE,GAAG,GAAG,EAAE,GAAG,OAAO,EAAE,OAAO,QAAQ,EAAE,OAAO,GAAG,GAAG;AAAA,MAC/E;AACA,UAAI,CAAC,MAAO,QAAO,EAAE,GAAG,UAAU,GAAG,GAAG,UAAU,EAAE;AAAA,IACtD;AAAA,EACF;AAGA,QAAM,YAAY,KAAK,IAAI,GAAG,SAAS,IAAI,CAAC,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC;AACjE,SAAO,EAAE,GAAG,GAAG,GAAG,YAAY,IAAI;AACpC;;;ACjDA,SAAS,cAAc;AAYvB,IAAM,MAAM,OAAO,MAAM,EAAE,QAAQ,yBAAyB,CAAC;AAa7D,SAAS,MAAM,QAAwB;AACrC,SAAO,GAAG,MAAM,IAAI,OAAO,WAAW,CAAC;AACzC;AAMA,eAAsB,oBACpB,OACA,UACA,UACA,OACuD;AACvD,SAAO,eAAe,UAAU,UAAU,YAAY;AACpD,UAAM,UACH,MAAM,MAAM,IAAI,UAAU,QAAQ,KAAO,MAAM,MAAM,OAAO,UAAU,UAAU,QAAQ;AAE3F,UAAM,WAAW,iBAAiB,QAAQ,MAAM,UAAU;AAAA,MACxD,OAAO,MAAM;AAAA,MACb,QAAQ,MAAM;AAAA,IAChB,CAAC;AAGD,QAAI;AACJ,QAAI;AACJ,QAAI,MAAM,aAAa,SAAS;AAC9B,aAAO,EAAE,IAAI,MAAM,MAAM,GAAG,UAAU,MAAM,UAAU,SAAS,MAAM,IAAI;AACzE,YAAM,KAAK;AAAA,IACb,OAAO;AACL,YAAM,MAAM;AAAA,IACd;AAEA,UAAM,UAAyB;AAAA,MAC7B,IAAI,MAAM,IAAI;AAAA,MACd,MAAM,MAAM,aAAa,UAAU,UAAU;AAAA,MAC7C,GAAG,SAAS;AAAA,MACZ,GAAG,SAAS;AAAA,MACZ,OAAO,MAAM;AAAA,MACb,QAAQ,MAAM;AAAA,MACd;AAAA,MACA,GAAI,MAAM,OAAO,EAAE,MAAM,MAAM,KAAK,IAAI,CAAC;AAAA,IAC3C;AAEA,UAAM,YAAyB;AAAA,MAC7B,UAAU,CAAC,GAAG,QAAQ,MAAM,UAAU,OAAO;AAAA,MAC7C,OAAO,OAAO,CAAC,GAAG,QAAQ,MAAM,OAAO,IAAI,IAAI,QAAQ,MAAM;AAAA,IAC/D;AAGA,UAAM,YAAY,MAAM;AAGxB,UAAM,SAAS,MAAM,MAAM,KAAK,UAAU,UAAU,WAAW,SAAS;AAExE,QAAI,MAAM,0BAA0B;AAAA,MAClC;AAAA,MACA;AAAA,MACA,UAAU,MAAM;AAAA,MAChB,GAAG,SAAS;AAAA,MACZ,GAAG,SAAS;AAAA,IACd,CAAC;AAED,UAAM,QAAoB;AAAA,MACxB;AAAA,MACA;AAAA,MACA;AAAA,MACA,GAAI,OAAO,EAAE,KAAK,IAAI,CAAC;AAAA,MACvB;AAAA,IACF;AACA,WAAO,EAAE,OAAO,OAAO;AAAA,EACzB,CAAC;AACH;","names":[]}
@@ -0,0 +1,71 @@
1
+ // src/store/prisma.ts
2
+ var EMPTY_SCENE = { elements: [], files: [] };
3
+ function toDomain(row) {
4
+ const scene = row.scene && typeof row.scene === "object" ? row.scene : EMPTY_SCENE;
5
+ return {
6
+ id: row.id,
7
+ tenantId: row.organizationId,
8
+ name: row.name,
9
+ scene,
10
+ ...row.thumbnail ? { thumbnail: row.thumbnail } : {},
11
+ updatedAt: row.updatedAt
12
+ };
13
+ }
14
+ var PrismaCanvasStore = class {
15
+ /**
16
+ * @param getDb returns an RLS-scoped tenant client. In the app:
17
+ * `new PrismaCanvasStore((t) => getTenantDb(t) as unknown as TenantDbLike)`
18
+ */
19
+ constructor(getDb) {
20
+ this.getDb = getDb;
21
+ }
22
+ async get(tenantId, canvasId) {
23
+ const db = await this.getDb(tenantId);
24
+ const row = await db.atelierCanvas.findUnique({
25
+ where: { organizationId_id: { organizationId: tenantId, id: canvasId } }
26
+ });
27
+ return row ? toDomain(row) : null;
28
+ }
29
+ async create(tenantId, canvasId, name) {
30
+ const db = await this.getDb(tenantId);
31
+ const row = await db.atelierCanvas.upsert({
32
+ where: { organizationId_id: { organizationId: tenantId, id: canvasId } },
33
+ create: {
34
+ id: canvasId,
35
+ organizationId: tenantId,
36
+ name,
37
+ scene: EMPTY_SCENE,
38
+ thumbnail: null
39
+ },
40
+ update: {}
41
+ });
42
+ return toDomain(row);
43
+ }
44
+ async save(tenantId, canvasId, scene, thumbnail) {
45
+ const db = await this.getDb(tenantId);
46
+ const row = await db.atelierCanvas.upsert({
47
+ where: { organizationId_id: { organizationId: tenantId, id: canvasId } },
48
+ create: {
49
+ id: canvasId,
50
+ organizationId: tenantId,
51
+ name: canvasId,
52
+ scene,
53
+ thumbnail: thumbnail ?? null
54
+ },
55
+ update: { scene, thumbnail: thumbnail ?? null }
56
+ });
57
+ return toDomain(row);
58
+ }
59
+ async list(tenantId) {
60
+ const db = await this.getDb(tenantId);
61
+ const rows = await db.atelierCanvas.findMany({
62
+ where: { organizationId: tenantId }
63
+ });
64
+ return rows.map(toDomain);
65
+ }
66
+ };
67
+
68
+ export {
69
+ PrismaCanvasStore
70
+ };
71
+ //# sourceMappingURL=chunk-X5VVODXM.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/store/prisma.ts"],"sourcesContent":["/**\n * Prisma-backed CanvasStore — the production persistence adapter.\n *\n * Decoupled from `@nebutra/db`'s generated client by a narrow structural\n * interface, so this package typechecks and tests without running\n * `prisma generate`. Wire it in the app by passing a `getDb` that returns\n * `getTenantDb(tenantId)` (RLS-scoped) — that is what enforces tenant\n * isolation at the database layer; this adapter never calls `getSystemDb()`.\n *\n * The `AtelierCanvas` Prisma model (see packages/platform/db/prisma/\n * schema.prisma) stores the scene as a single JSON column — the same\n * one-blob-per-canvas shape the source product used, but with an\n * `organization_id` column + RLS instead of an implicit single user.\n */\n\nimport type { AtelierCanvas, CanvasScene, CanvasStore } from \"../types\";\n\n/** Row shape as persisted (scene/files as JSON). */\ninterface AtelierCanvasRow {\n id: string;\n organizationId: string;\n name: string;\n scene: unknown;\n thumbnail: string | null;\n updatedAt: Date;\n}\n\n/** The subset of the generated delegate this adapter needs. */\nexport interface AtelierCanvasDelegate {\n findUnique(args: {\n where: { organizationId_id: { organizationId: string; id: string } };\n }): Promise<AtelierCanvasRow | null>;\n upsert(args: {\n where: { organizationId_id: { organizationId: string; id: string } };\n create: Omit<AtelierCanvasRow, \"updatedAt\">;\n update: Partial<Omit<AtelierCanvasRow, \"id\" | \"organizationId\">>;\n }): Promise<AtelierCanvasRow>;\n findMany(args: { where: { organizationId: string } }): Promise<AtelierCanvasRow[]>;\n}\n\nexport interface TenantDbLike {\n atelierCanvas: AtelierCanvasDelegate;\n}\n\nconst EMPTY_SCENE: CanvasScene = { elements: [], files: [] };\n\nfunction toDomain(row: AtelierCanvasRow): AtelierCanvas {\n const scene =\n row.scene && typeof row.scene === \"object\" ? (row.scene as CanvasScene) : EMPTY_SCENE;\n return {\n id: row.id,\n tenantId: row.organizationId,\n name: row.name,\n scene,\n ...(row.thumbnail ? { thumbnail: row.thumbnail } : {}),\n updatedAt: row.updatedAt,\n };\n}\n\nexport class PrismaCanvasStore implements CanvasStore {\n /**\n * @param getDb returns an RLS-scoped tenant client. In the app:\n * `new PrismaCanvasStore((t) => getTenantDb(t) as unknown as TenantDbLike)`\n */\n constructor(private readonly getDb: (tenantId: string) => Promise<TenantDbLike> | TenantDbLike) {}\n\n async get(tenantId: string, canvasId: string): Promise<AtelierCanvas | null> {\n const db = await this.getDb(tenantId);\n const row = await db.atelierCanvas.findUnique({\n where: { organizationId_id: { organizationId: tenantId, id: canvasId } },\n });\n return row ? toDomain(row) : null;\n }\n\n async create(tenantId: string, canvasId: string, name: string): Promise<AtelierCanvas> {\n const db = await this.getDb(tenantId);\n const row = await db.atelierCanvas.upsert({\n where: { organizationId_id: { organizationId: tenantId, id: canvasId } },\n create: {\n id: canvasId,\n organizationId: tenantId,\n name,\n scene: EMPTY_SCENE,\n thumbnail: null,\n },\n update: {},\n });\n return toDomain(row);\n }\n\n async save(\n tenantId: string,\n canvasId: string,\n scene: CanvasScene,\n thumbnail?: string,\n ): Promise<AtelierCanvas> {\n const db = await this.getDb(tenantId);\n const row = await db.atelierCanvas.upsert({\n where: { organizationId_id: { organizationId: tenantId, id: canvasId } },\n create: {\n id: canvasId,\n organizationId: tenantId,\n name: canvasId,\n scene,\n thumbnail: thumbnail ?? null,\n },\n update: { scene, thumbnail: thumbnail ?? null },\n });\n return toDomain(row);\n }\n\n async list(tenantId: string): Promise<readonly AtelierCanvas[]> {\n const db = await this.getDb(tenantId);\n const rows = await db.atelierCanvas.findMany({\n where: { organizationId: tenantId },\n });\n return rows.map(toDomain);\n }\n}\n"],"mappings":";AA4CA,IAAM,cAA2B,EAAE,UAAU,CAAC,GAAG,OAAO,CAAC,EAAE;AAE3D,SAAS,SAAS,KAAsC;AACtD,QAAM,QACJ,IAAI,SAAS,OAAO,IAAI,UAAU,WAAY,IAAI,QAAwB;AAC5E,SAAO;AAAA,IACL,IAAI,IAAI;AAAA,IACR,UAAU,IAAI;AAAA,IACd,MAAM,IAAI;AAAA,IACV;AAAA,IACA,GAAI,IAAI,YAAY,EAAE,WAAW,IAAI,UAAU,IAAI,CAAC;AAAA,IACpD,WAAW,IAAI;AAAA,EACjB;AACF;AAEO,IAAM,oBAAN,MAA+C;AAAA;AAAA;AAAA;AAAA;AAAA,EAKpD,YAA6B,OAAmE;AAAnE;AAAA,EAAoE;AAAA,EAEjG,MAAM,IAAI,UAAkB,UAAiD;AAC3E,UAAM,KAAK,MAAM,KAAK,MAAM,QAAQ;AACpC,UAAM,MAAM,MAAM,GAAG,cAAc,WAAW;AAAA,MAC5C,OAAO,EAAE,mBAAmB,EAAE,gBAAgB,UAAU,IAAI,SAAS,EAAE;AAAA,IACzE,CAAC;AACD,WAAO,MAAM,SAAS,GAAG,IAAI;AAAA,EAC/B;AAAA,EAEA,MAAM,OAAO,UAAkB,UAAkB,MAAsC;AACrF,UAAM,KAAK,MAAM,KAAK,MAAM,QAAQ;AACpC,UAAM,MAAM,MAAM,GAAG,cAAc,OAAO;AAAA,MACxC,OAAO,EAAE,mBAAmB,EAAE,gBAAgB,UAAU,IAAI,SAAS,EAAE;AAAA,MACvE,QAAQ;AAAA,QACN,IAAI;AAAA,QACJ,gBAAgB;AAAA,QAChB;AAAA,QACA,OAAO;AAAA,QACP,WAAW;AAAA,MACb;AAAA,MACA,QAAQ,CAAC;AAAA,IACX,CAAC;AACD,WAAO,SAAS,GAAG;AAAA,EACrB;AAAA,EAEA,MAAM,KACJ,UACA,UACA,OACA,WACwB;AACxB,UAAM,KAAK,MAAM,KAAK,MAAM,QAAQ;AACpC,UAAM,MAAM,MAAM,GAAG,cAAc,OAAO;AAAA,MACxC,OAAO,EAAE,mBAAmB,EAAE,gBAAgB,UAAU,IAAI,SAAS,EAAE;AAAA,MACvE,QAAQ;AAAA,QACN,IAAI;AAAA,QACJ,gBAAgB;AAAA,QAChB,MAAM;AAAA,QACN;AAAA,QACA,WAAW,aAAa;AAAA,MAC1B;AAAA,MACA,QAAQ,EAAE,OAAO,WAAW,aAAa,KAAK;AAAA,IAChD,CAAC;AACD,WAAO,SAAS,GAAG;AAAA,EACrB;AAAA,EAEA,MAAM,KAAK,UAAqD;AAC9D,UAAM,KAAK,MAAM,KAAK,MAAM,QAAQ;AACpC,UAAM,OAAO,MAAM,GAAG,cAAc,SAAS;AAAA,MAC3C,OAAO,EAAE,gBAAgB,SAAS;AAAA,IACpC,CAAC;AACD,WAAO,KAAK,IAAI,QAAQ;AAAA,EAC1B;AACF;","names":[]}
@@ -0,0 +1,88 @@
1
+ import { C as CanvasElement, E as ElementSize, P as Placement, a as CanvasStore, S as ScenePatch, A as AtelierCanvas } from './types-Dw9o09h2.js';
2
+ export { b as CanvasElementType, c as CanvasFile, d as CanvasScene } from './types-Dw9o09h2.js';
3
+ export { InMemoryCanvasStore } from './store/memory.js';
4
+ export { AtelierCanvasDelegate, PrismaCanvasStore, TenantDbLike } from './store/prisma.js';
5
+
6
+ /**
7
+ * Per-canvas serialization.
8
+ *
9
+ * The generic per-(tenant, resource) serializer now lives in the neutral
10
+ * lower layer `@nebutra/tenant-store` (so `@nebutra/reel` and future
11
+ * tenant-scoped features share it without depending on this package). This
12
+ * module keeps the original `withCanvasLock` / `_resetCanvasLocks` names as
13
+ * thin, back-compatible aliases so existing callers (e.g. apps/web) keep
14
+ * working unchanged.
15
+ *
16
+ * @deprecated Import `withTenantLock` / `_resetTenantLocks` from
17
+ * `@nebutra/tenant-store` directly in new code. These aliases are retained
18
+ * only for the existing public surface.
19
+ */
20
+ /**
21
+ * Run `fn` with exclusive access to a single (tenant, canvas). Calls for the
22
+ * same canvas run strictly in submission order; different canvases run freely.
23
+ *
24
+ * @deprecated Use `withTenantLock` from `@nebutra/tenant-store`.
25
+ */
26
+ declare function withCanvasLock<T>(tenantId: string, canvasId: string, fn: () => Promise<T>): Promise<T>;
27
+ /**
28
+ * Test helper — clears all lock chains.
29
+ *
30
+ * @deprecated Use `_resetTenantLocks` from `@nebutra/tenant-store`.
31
+ */
32
+ declare function _resetCanvasLocks(): void;
33
+
34
+ /**
35
+ * Server-authoritative non-overlap placement.
36
+ *
37
+ * Ported in spirit from the source product's `find_next_best_element_position`:
38
+ * the server — not the client — decides where a freshly generated asset lands,
39
+ * so concurrent generations never stack and every client converges on the same
40
+ * coordinates. Deterministic given (existing elements, size, gap): identical
41
+ * inputs always yield the identical position, which keeps placement tests and
42
+ * websocket-sync reproducible.
43
+ */
44
+
45
+ /**
46
+ * Find the first free top-left position for an element of `size`, scanning
47
+ * left→right then top→down on a grid sized to the largest existing element.
48
+ * Returns `{0,0}` for an empty canvas.
49
+ */
50
+ declare function findNextPosition(existing: readonly CanvasElement[], size: ElementSize, gap?: number): Placement;
51
+
52
+ /**
53
+ * The write-then-broadcast placement service — the core consistency
54
+ * invariant absorbed from the source product.
55
+ *
56
+ * For one (tenant, canvas), under the canvas lock:
57
+ * 1. load the authoritative scene,
58
+ * 2. compute a non-overlapping position server-side,
59
+ * 3. append the element/file and **persist** the new scene,
60
+ * 4. return a {@link ScenePatch}.
61
+ *
62
+ * The caller broadcasts the returned patch over the realtime channel *after*
63
+ * this resolves. Persisting before broadcasting is what makes the websocket
64
+ * a pure optimization: a client that never receives the event recovers the
65
+ * identical state on reload. Reversing the order reintroduces the
66
+ * lost-update bug.
67
+ */
68
+
69
+ /** A generated asset, shaped to match `@nebutra/agents` GenerationResult. */
70
+ interface GeneratedAsset {
71
+ readonly modality: "image" | "video";
72
+ readonly mimeType: string;
73
+ readonly url: string;
74
+ readonly width: number;
75
+ readonly height: number;
76
+ /** Carried into element.meta for provenance (prompt, model, provider). */
77
+ readonly meta?: Readonly<Record<string, unknown>>;
78
+ }
79
+ /**
80
+ * Place a generated asset on a canvas. Creates the canvas on first write.
81
+ * Concurrency-safe per (tenant, canvas) via {@link withCanvasLock}.
82
+ */
83
+ declare function placeGeneratedAsset(store: CanvasStore, tenantId: string, canvasId: string, asset: GeneratedAsset): Promise<{
84
+ patch: ScenePatch;
85
+ canvas: AtelierCanvas;
86
+ }>;
87
+
88
+ export { AtelierCanvas, CanvasElement, CanvasStore, ElementSize, type GeneratedAsset, Placement, ScenePatch, _resetCanvasLocks, findNextPosition, placeGeneratedAsset, withCanvasLock };
package/dist/index.js ADDED
@@ -0,0 +1,21 @@
1
+ import {
2
+ _resetCanvasLocks,
3
+ findNextPosition,
4
+ placeGeneratedAsset,
5
+ withCanvasLock
6
+ } from "./chunk-JYV7NRGL.js";
7
+ import {
8
+ InMemoryCanvasStore
9
+ } from "./chunk-AVQEQBLX.js";
10
+ import {
11
+ PrismaCanvasStore
12
+ } from "./chunk-X5VVODXM.js";
13
+ export {
14
+ InMemoryCanvasStore,
15
+ PrismaCanvasStore,
16
+ _resetCanvasLocks,
17
+ findNextPosition,
18
+ placeGeneratedAsset,
19
+ withCanvasLock
20
+ };
21
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}