diff --git a/docker-compose.yml b/docker-compose.yml
index 2618d540..b6588399 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -283,6 +283,33 @@ services:
retries: 5
start_period: 10s
+ # ── Blender Multi-User replication server (always-on, persistent TCP) ──
+ blender-multiuser:
+ image: registry.gitlab.com/slumber/multi-user/multi-user-server:0.5.8
+ container_name: blender-multiuser
+ restart: unless-stopped
+ mem_limit: 512m
+ cpus: 1
+ ports:
+ - "5555:5555"
+ - "5556:5556"
+ - "5557:5557"
+ - "5558:5558"
+ environment:
+ - port=5555
+ - password=${BLENDER_MULTIUSER_PASSWORD}
+ - timeout=5000
+ - log_level=INFO
+ - log_file=multiuser_server.log
+ networks:
+ - rspace-internal
+ healthcheck:
+ test: ["CMD-SHELL", "python3 -c 'import socket; s=socket.socket(); s.settimeout(2); s.connect((\"localhost\",5555)); s.close()' || exit 1"]
+ interval: 30s
+ timeout: 5s
+ retries: 3
+ start_period: 10s
+
# ── On-demand sidecars (started/stopped by server/sidecar-manager.ts) ──
# Build: docker compose --profile sidecar build
# Create: docker compose --profile sidecar create
diff --git a/lib/folk-blender.ts b/lib/folk-blender.ts
index 1c47cd88..ad15281d 100644
--- a/lib/folk-blender.ts
+++ b/lib/folk-blender.ts
@@ -289,6 +289,94 @@ const styles = css`
font-size: 13px;
margin: 12px;
}
+
+ .multiplayer-area {
+ flex: 1;
+ overflow: auto;
+ padding: 12px;
+ display: none;
+ }
+
+ .mp-status {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 12px;
+ font-size: 13px;
+ font-weight: 600;
+ }
+
+ .mp-dot {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ background: #94a3b8;
+ }
+
+ .mp-dot.online { background: #22c55e; }
+ .mp-dot.offline { background: #ef4444; }
+
+ .mp-field {
+ margin-bottom: 10px;
+ }
+
+ .mp-field label {
+ display: block;
+ font-size: 11px;
+ font-weight: 600;
+ color: #64748b;
+ margin-bottom: 4px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ }
+
+ .mp-conn {
+ display: flex;
+ gap: 6px;
+ }
+
+ .mp-conn input {
+ flex: 1;
+ padding: 7px 10px;
+ border: 1px solid #e2e8f0;
+ border-radius: 6px;
+ font-size: 13px;
+ font-family: monospace;
+ background: #f8fafc;
+ color: #1e293b;
+ }
+
+ .mp-copy-btn {
+ padding: 7px 12px;
+ border: 1px solid #e2e8f0;
+ border-radius: 6px;
+ background: #f8fafc;
+ cursor: pointer;
+ font-size: 13px;
+ }
+
+ .mp-copy-btn:hover { background: #e2e8f0; }
+
+ .mp-instructions {
+ font-size: 12px;
+ color: #64748b;
+ line-height: 1.6;
+ margin-top: 12px;
+ padding: 10px;
+ background: #f8fafc;
+ border-radius: 6px;
+ border: 1px solid #e2e8f0;
+ }
+
+ .mp-instructions ol {
+ margin: 4px 0 0 16px;
+ padding: 0;
+ }
+
+ .mp-instructions a {
+ color: #ea580c;
+ text-decoration: underline;
+ }
`;
declare global {
@@ -318,11 +406,12 @@ export class FolkBlender extends FolkShape {
#renderUrl: string | null = null;
#script: string | null = null;
#blendUrl: string | null = null;
- #activeTab: "preview" | "code" = "preview";
+ #activeTab: "preview" | "code" | "multiplayer" = "preview";
#promptInput: HTMLTextAreaElement | null = null;
#generateBtn: HTMLButtonElement | null = null;
#previewArea: HTMLElement | null = null;
#codeArea: HTMLElement | null = null;
+ #multiplayerArea: HTMLElement | null = null;
#downloadRow: HTMLElement | null = null;
override createRenderRoot() {
@@ -350,6 +439,7 @@ export class FolkBlender extends FolkShape {
+
@@ -361,6 +451,28 @@ export class FolkBlender extends FolkShape {
// Blender Python script will appear here
+
+
+
+ Checking...
+
+
+
+
How to connect:
+
+ - Install Multi-User addon in Blender 4.3+
+ - Open sidebar → Multi-User tab
+ - Paste the connection address above
+ - Click Connect — you're in the shared session!
+
+
+
⬇️ .blend
@@ -378,6 +490,7 @@ export class FolkBlender extends FolkShape {
this.#generateBtn = wrapper.querySelector(".generate-btn");
this.#previewArea = wrapper.querySelector(".render-preview");
this.#codeArea = wrapper.querySelector(".code-area");
+ this.#multiplayerArea = wrapper.querySelector(".multiplayer-area");
this.#downloadRow = wrapper.querySelector(".download-row");
const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
@@ -385,12 +498,14 @@ export class FolkBlender extends FolkShape {
wrapper.querySelectorAll(".tab").forEach((tab) => {
tab.addEventListener("click", (e) => {
e.stopPropagation();
- const tabName = (tab as HTMLElement).dataset.tab as "preview" | "code";
+ const tabName = (tab as HTMLElement).dataset.tab as "preview" | "code" | "multiplayer";
this.#activeTab = tabName;
wrapper.querySelectorAll(".tab").forEach((t) => t.classList.remove("active"));
tab.classList.add("active");
if (this.#previewArea) this.#previewArea.style.display = tabName === "preview" ? "flex" : "none";
if (this.#codeArea) this.#codeArea.style.display = tabName === "code" ? "block" : "none";
+ if (this.#multiplayerArea) this.#multiplayerArea.style.display = tabName === "multiplayer" ? "block" : "none";
+ if (tabName === "multiplayer") this.#fetchMultiplayerStatus();
});
});
@@ -430,9 +545,42 @@ export class FolkBlender extends FolkShape {
}
}).catch(() => {});
+ // Multiplayer copy button
+ const copyBtn = wrapper.querySelector(".mp-copy-btn") as HTMLButtonElement;
+ const connInput = wrapper.querySelector(".mp-conn-input") as HTMLInputElement;
+ copyBtn?.addEventListener("click", (e) => {
+ e.stopPropagation();
+ if (connInput?.value) {
+ navigator.clipboard.writeText(connInput.value).then(() => {
+ copyBtn.textContent = "✅";
+ setTimeout(() => { copyBtn.textContent = "📋"; }, 1500);
+ });
+ }
+ });
+
return root;
}
+ async #fetchMultiplayerStatus() {
+ const dot = this.#multiplayerArea?.querySelector(".mp-dot");
+ const statusText = this.#multiplayerArea?.querySelector(".mp-status-text");
+ const connInput = this.#multiplayerArea?.querySelector(".mp-conn-input") as HTMLInputElement | null;
+
+ try {
+ const res = await fetch("/api/blender-multiuser/status");
+ const data = await res.json() as { available: boolean; host: string; port: number };
+ if (dot) {
+ dot.classList.toggle("online", data.available);
+ dot.classList.toggle("offline", !data.available);
+ }
+ if (statusText) statusText.textContent = data.available ? "Server Online" : "Server Offline";
+ if (connInput) connInput.value = `${data.host}:${data.port}`;
+ } catch {
+ if (dot) { dot.classList.remove("online"); dot.classList.add("offline"); }
+ if (statusText) statusText.textContent = "Unable to check";
+ }
+ }
+
async #generate() {
const prompt = this.#promptInput?.value.trim();
if (!prompt || this.#isLoading) return;
diff --git a/modules/rnetwork/components/folk-graph-viewer.ts b/modules/rnetwork/components/folk-graph-viewer.ts
index 9502f774..3bbc2688 100644
--- a/modules/rnetwork/components/folk-graph-viewer.ts
+++ b/modules/rnetwork/components/folk-graph-viewer.ts
@@ -280,10 +280,12 @@ class FolkGraphViewer extends HTMLElement {
}
} catch { /* storage unavailable */ }
+ const token = localStorage.getItem("encryptid-token");
+ const headers: Record
= token ? { Authorization: `Bearer ${token}` } : {};
const [wsRes, infoRes, graphRes] = await Promise.all([
- fetch(`${base}/api/workspaces`),
- fetch(`${base}/api/info`),
- graphData ? Promise.resolve(null) : fetch(`${base}/api/graph${trustParam}`),
+ fetch(`${base}/api/workspaces`, { headers }),
+ fetch(`${base}/api/info`, { headers }),
+ graphData ? Promise.resolve(null) : fetch(`${base}/api/graph${trustParam}`, { headers }),
]);
if (wsRes.ok) this.workspaces = await wsRes.json();
if (infoRes.ok) this.info = await infoRes.json();
diff --git a/server/index.ts b/server/index.ts
index da5806da..a94cd1f8 100644
--- a/server/index.ts
+++ b/server/index.ts
@@ -2121,6 +2121,33 @@ Output ONLY the Python code, no explanations or comments outside the code.`);
}
});
+// ── Blender Multi-User server status ──
+const MULTIUSER_HOST = process.env.MULTIUSER_HOST || "159.195.32.209";
+const MULTIUSER_PORT = 5555;
+
+app.get("/api/blender-multiuser/status", async (c) => {
+ let available = false;
+ try {
+ const net = await import("node:net");
+ available = await new Promise((resolve) => {
+ const sock = net.createConnection({ host: "blender-multiuser", port: MULTIUSER_PORT }, () => {
+ sock.destroy();
+ resolve(true);
+ });
+ sock.setTimeout(2000);
+ sock.on("timeout", () => { sock.destroy(); resolve(false); });
+ sock.on("error", () => resolve(false));
+ });
+ } catch {}
+
+ return c.json({
+ available,
+ host: MULTIUSER_HOST,
+ port: MULTIUSER_PORT,
+ instructions: "Install Multi-User addon from extensions.blender.org, connect to host:port in Blender",
+ });
+});
+
// ── Design Agent (Scribus DTP via Gemini tool loop) ──
const SCRIBUS_BRIDGE_URL = process.env.SCRIBUS_BRIDGE_URL || "http://scribus-novnc:8765";
const SCRIBUS_BRIDGE_SECRET = process.env.SCRIBUS_BRIDGE_SECRET || "";