From 46c326278af79ee63b17cdb0198cb082a118238a Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 3 Apr 2026 14:25:48 -0700 Subject: [PATCH] feat: ASCII art canvas tool, video gen fixes, scribus/notebook sidecars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add folk-ascii-gen canvas shape with pattern/palette selectors - Add POST /api/ascii-gen proxy to ascii-art service - Register create_ascii_art in canvas tools + triage panel - Fix WAN 2.1 t2v endpoint URL (fal-ai/wan/v2.1 → fal-ai/wan-t2v) - Convert video gen to async job queue (avoids Cloudflare timeouts) - Fix Docker API Content-Type bug in sidecar-manager - Convert scribus-novnc and open-notebook to on-demand sidecars - Add ensureSidecar("scribus-novnc") to rDesign bridge proxy - Fix Hono ContextVariableMap and handleTransakMessage type errors Co-Authored-By: Claude Opus 4.6 --- docker-compose.yml | 33 +-- lib/canvas-tools.ts | 36 ++++ lib/folk-ascii-gen.ts | 433 ++++++++++++++++++++++++++++++++++++++ lib/folk-video-gen.ts | 60 ++++-- lib/index.ts | 1 + lib/mi-triage-panel.ts | 1 + modules/rdesign/mod.ts | 2 + server/index.ts | 246 +++++++++++++++++++--- server/mi-media.ts | 6 +- server/sidecar-manager.ts | 24 ++- types/hono.d.ts | 1 + 11 files changed, 765 insertions(+), 78 deletions(-) create mode 100644 lib/folk-ascii-gen.ts diff --git a/docker-compose.yml b/docker-compose.yml index 4121c44..75a5d9e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -318,12 +318,13 @@ services: networks: - rspace-internal - # ── Scribus noVNC (rDesign DTP workspace) ── + # ── Scribus noVNC (rDesign DTP workspace) — on-demand sidecar ── scribus-novnc: build: context: ./docker/scribus-novnc container_name: scribus-novnc - restart: unless-stopped + restart: "no" + profiles: ["sidecar"] mem_limit: 512m cpus: 1 volumes: @@ -342,22 +343,15 @@ services: timeout: 5s retries: 3 start_period: 30s - labels: - - "traefik.enable=true" - - "traefik.http.routers.scribus-novnc.rule=Host(`design.rspace.online`)" - - "traefik.http.routers.scribus-novnc.entrypoints=web" - - "traefik.http.routers.scribus-novnc.priority=150" - - "traefik.http.services.scribus-novnc.loadbalancer.server.port=6080" - - "traefik.docker.network=traefik-public" networks: - - traefik-public - rspace-internal - # ── Open Notebook (NotebookLM-like RAG service) ── + # ── Open Notebook (NotebookLM-like RAG service) — on-demand sidecar ── open-notebook: image: ghcr.io/lfnovo/open-notebook:v1-latest-single container_name: open-notebook - restart: always + restart: "no" + profiles: ["sidecar"] mem_limit: 1g cpus: 1 env_file: ./open-notebook.env @@ -365,21 +359,8 @@ services: - open-notebook-data:/app/data - open-notebook-db:/mydata networks: - - traefik-public + - rspace-internal - ai-internal - labels: - - "traefik.enable=true" - - "traefik.docker.network=traefik-public" - # Frontend UI - - "traefik.http.routers.rspace-notebook.rule=Host(`notebook.rspace.online`)" - - "traefik.http.routers.rspace-notebook.entrypoints=web" - - "traefik.http.routers.rspace-notebook.tls.certresolver=letsencrypt" - - "traefik.http.services.rspace-notebook.loadbalancer.server.port=8502" - # API endpoint (used by rNotes integration) - - "traefik.http.routers.rspace-notebook-api.rule=Host(`notebook-api.rspace.online`)" - - "traefik.http.routers.rspace-notebook-api.entrypoints=web" - - "traefik.http.routers.rspace-notebook-api.tls.certresolver=letsencrypt" - - "traefik.http.services.rspace-notebook-api.loadbalancer.server.port=5055" volumes: rspace-data: diff --git a/lib/canvas-tools.ts b/lib/canvas-tools.ts index 45c4261..4efd152 100644 --- a/lib/canvas-tools.ts +++ b/lib/canvas-tools.ts @@ -470,6 +470,42 @@ registry.push( }, ); +// ── ASCII Art Tool ── +registry.push({ + declaration: { + name: "create_ascii_art", + description: "Generate ASCII art from patterns like plasma, mandelbrot, spiral, waves, nebula, kaleidoscope, aurora, lava, crystals, or fractal_tree.", + parameters: { + type: "object", + properties: { + prompt: { type: "string", description: "Pattern name or description of what to generate" }, + pattern: { + type: "string", + description: "Pattern type", + enum: ["plasma", "mandelbrot", "spiral", "waves", "nebula", "kaleidoscope", "aurora", "lava", "crystals", "fractal_tree", "random"], + }, + palette: { + type: "string", + description: "Character palette to use", + enum: ["classic", "blocks", "braille", "dots", "shades", "emoji", "cosmic", "runes", "geometric", "kanji", "hieroglyph", "alchemical"], + }, + width: { type: "number", description: "Width in characters (default 80)" }, + height: { type: "number", description: "Height in characters (default 40)" }, + }, + required: ["prompt"], + }, + }, + tagName: "folk-ascii-gen", + buildProps: (args) => ({ + prompt: args.prompt, + ...(args.pattern ? { pattern: args.pattern } : {}), + ...(args.palette ? { palette: args.palette } : {}), + ...(args.width ? { width: args.width } : {}), + ...(args.height ? { height: args.height } : {}), + }), + actionLabel: (args) => `Generating ASCII art: ${args.prompt?.slice(0, 50) || args.pattern || "random"}`, +}); + // ── Design Agent Tool ── registry.push({ declaration: { diff --git a/lib/folk-ascii-gen.ts b/lib/folk-ascii-gen.ts new file mode 100644 index 0000000..6e69051 --- /dev/null +++ b/lib/folk-ascii-gen.ts @@ -0,0 +1,433 @@ +import { FolkShape } from "./folk-shape"; +import { css, html } from "./tags"; + +const PATTERNS = [ + "plasma", "mandelbrot", "spiral", "waves", "nebula", "kaleidoscope", + "aurora", "lava", "crystals", "fractal_tree", "random", +] as const; + +const PALETTES = [ + "classic", "blocks", "braille", "dots", "shades", "hires", "ultra", "dense", + "emoji", "cosmic", "mystic", "runes", "geometric", "flora", "weather", + "wingdings", "zodiac", "chess", "arrows", "music", "box", "math", + "kanji", "thai", "arabic", "devanagari", "hieroglyph", "cuneiform", + "alchemical", "dominos", "mahjong", "dingbats", "playing", "yijing", +] as const; + +const styles = css` + :host { + background: var(--rs-bg-surface, #fff); + color: var(--rs-text-primary, #1e293b); + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + min-width: 380px; + min-height: 420px; + } + + .header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: linear-gradient(135deg, #22c55e, #14b8a6); + color: white; + border-radius: 8px 8px 0 0; + font-size: 12px; + font-weight: 600; + cursor: move; + } + + .header-title { + display: flex; + align-items: center; + gap: 6px; + } + + .header-actions { + display: flex; + gap: 4px; + } + + .header-actions button { + background: transparent; + border: none; + color: white; + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; + font-size: 14px; + } + + .header-actions button:hover { + background: rgba(255, 255, 255, 0.2); + } + + .content { + display: flex; + flex-direction: column; + height: calc(100% - 36px); + overflow: hidden; + } + + .prompt-area { + padding: 12px; + border-bottom: 1px solid var(--rs-border, #e2e8f0); + } + + .prompt-input { + width: 100%; + padding: 8px 10px; + border: 2px solid var(--rs-input-border, #e2e8f0); + border-radius: 6px; + font-size: 13px; + resize: none; + outline: none; + font-family: inherit; + background: var(--rs-input-bg, #fff); + color: var(--rs-input-text, inherit); + box-sizing: border-box; + } + + .prompt-input:focus { + border-color: #14b8a6; + } + + .controls { + display: flex; + gap: 6px; + margin-top: 8px; + flex-wrap: wrap; + } + + .controls select { + padding: 5px 8px; + border: 2px solid var(--rs-input-border, #e2e8f0); + border-radius: 6px; + font-size: 11px; + background: var(--rs-input-bg, #fff); + color: var(--rs-input-text, inherit); + cursor: pointer; + } + + .size-input { + width: 52px; + padding: 5px 6px; + border: 2px solid var(--rs-input-border, #e2e8f0); + border-radius: 6px; + font-size: 11px; + background: var(--rs-input-bg, #fff); + color: var(--rs-input-text, inherit); + text-align: center; + } + + .generate-btn { + padding: 6px 14px; + background: linear-gradient(135deg, #22c55e, #14b8a6); + color: white; + border: none; + border-radius: 6px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: opacity 0.2s; + margin-left: auto; + } + + .generate-btn:hover { opacity: 0.9; } + .generate-btn:disabled { opacity: 0.5; cursor: not-allowed; } + + .preview-area { + flex: 1; + overflow: auto; + padding: 8px; + display: flex; + flex-direction: column; + gap: 8px; + } + + .placeholder { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: #94a3b8; + text-align: center; + gap: 8px; + } + + .placeholder-icon { font-size: 48px; opacity: 0.5; } + + .ascii-output { + font-family: "Courier New", Consolas, monospace; + font-size: 10px; + line-height: 1.1; + white-space: pre; + overflow: auto; + padding: 8px; + border-radius: 6px; + background: #1e1e2e; + color: #cdd6f4; + max-height: 100%; + } + + .ascii-output.light-bg { + background: #f8fafc; + color: #1e293b; + } + + .actions-bar { + display: flex; + gap: 6px; + padding: 6px 12px; + border-top: 1px solid var(--rs-border, #e2e8f0); + justify-content: flex-end; + } + + .action-btn { + padding: 4px 10px; + border: 1px solid var(--rs-border, #e2e8f0); + border-radius: 4px; + font-size: 11px; + background: var(--rs-bg-surface, #fff); + color: var(--rs-text-primary, #1e293b); + cursor: pointer; + } + + .action-btn:hover { + background: var(--rs-bg-hover, #f1f5f9); + } + + .loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 24px; + gap: 12px; + } + + .spinner { + width: 28px; + height: 28px; + border: 3px solid #e2e8f0; + border-top-color: #14b8a6; + border-radius: 50%; + animation: spin 0.8s linear infinite; + } + + @keyframes spin { to { transform: rotate(360deg); } } + + .error { + color: #ef4444; + padding: 12px; + background: #fef2f2; + border-radius: 6px; + font-size: 13px; + } +`; + +declare global { + interface HTMLElementTagNameMap { + "folk-ascii-gen": FolkAsciiGen; + } +} + +export class FolkAsciiGen extends FolkShape { + static override tagName = "folk-ascii-gen"; + + static override portDescriptors = [ + { name: "prompt", type: "text" as const, direction: "input" as const }, + { name: "ascii", type: "text" as const, direction: "output" as const }, + ]; + + static { + const sheet = new CSSStyleSheet(); + const parentRules = Array.from(FolkShape.styles.cssRules).map((r) => r.cssText).join("\n"); + const childRules = Array.from(styles.cssRules).map((r) => r.cssText).join("\n"); + sheet.replaceSync(`${parentRules}\n${childRules}`); + this.styles = sheet; + } + + #isLoading = false; + #error: string | null = null; + #htmlOutput: string | null = null; + #textOutput: string | null = null; + #promptInput: HTMLTextAreaElement | null = null; + #patternSelect: HTMLSelectElement | null = null; + #paletteSelect: HTMLSelectElement | null = null; + #widthInput: HTMLInputElement | null = null; + #heightInput: HTMLInputElement | null = null; + #previewArea: HTMLElement | null = null; + #generateBtn: HTMLButtonElement | null = null; + #actionsBar: HTMLElement | null = null; + + override createRenderRoot() { + const root = super.createRenderRoot(); + + const wrapper = document.createElement("div"); + wrapper.innerHTML = html` +
+ + + ASCII Art + +
+ +
+
+
+
+ +
+ + + + × + + +
+
+
+
+ + Pick a pattern and click Generate +
+
+ +
+ `; + + const slot = root.querySelector("slot"); + const containerDiv = slot?.parentElement as HTMLElement; + if (containerDiv) containerDiv.replaceWith(wrapper); + + this.#promptInput = wrapper.querySelector(".prompt-input"); + this.#patternSelect = wrapper.querySelector(".pattern-select"); + this.#paletteSelect = wrapper.querySelector(".palette-select"); + this.#widthInput = wrapper.querySelector('input[title="Width"]'); + this.#heightInput = wrapper.querySelector('input[title="Height"]'); + this.#previewArea = wrapper.querySelector(".preview-area"); + this.#generateBtn = wrapper.querySelector(".generate-btn"); + this.#actionsBar = wrapper.querySelector(".actions-bar"); + + const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement; + const copyTextBtn = wrapper.querySelector(".copy-text-btn") as HTMLButtonElement; + const copyHtmlBtn = wrapper.querySelector(".copy-html-btn") as HTMLButtonElement; + + this.#generateBtn?.addEventListener("click", (e) => { + e.stopPropagation(); + this.#generate(); + }); + + this.#promptInput?.addEventListener("keydown", (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + this.#generate(); + } + }); + + copyTextBtn?.addEventListener("click", (e) => { + e.stopPropagation(); + if (this.#textOutput) navigator.clipboard.writeText(this.#textOutput); + }); + + copyHtmlBtn?.addEventListener("click", (e) => { + e.stopPropagation(); + if (this.#htmlOutput) navigator.clipboard.writeText(this.#htmlOutput); + }); + + closeBtn?.addEventListener("click", (e) => { + e.stopPropagation(); + this.dispatchEvent(new CustomEvent("close")); + }); + + // Prevent canvas drag + this.#previewArea?.addEventListener("pointerdown", (e) => e.stopPropagation()); + this.#promptInput?.addEventListener("pointerdown", (e) => e.stopPropagation()); + + return root; + } + + async #generate() { + if (this.#isLoading) return; + + const pattern = this.#patternSelect?.value || "plasma"; + const prompt = this.#promptInput?.value.trim() || pattern; + const palette = this.#paletteSelect?.value || "classic"; + const width = parseInt(this.#widthInput?.value || "80") || 80; + const height = parseInt(this.#heightInput?.value || "40") || 40; + + this.#isLoading = true; + this.#error = null; + if (this.#generateBtn) this.#generateBtn.disabled = true; + this.#renderLoading(); + + try { + const res = await fetch("/api/ascii-gen", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ prompt, pattern, palette, width, height, output_format: "html" }), + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({ error: `HTTP ${res.status}` })); + throw new Error(data.error || `HTTP ${res.status}`); + } + + const data = await res.json(); + this.#htmlOutput = data.html || null; + this.#textOutput = data.text || null; + this.#renderResult(); + + // Emit to output port + if (this.#textOutput) { + this.dispatchEvent(new CustomEvent("port-output", { + detail: { port: "ascii", value: this.#textOutput }, + bubbles: true, + })); + } + } catch (e: any) { + this.#error = e.message || "Generation failed"; + this.#renderError(); + } finally { + this.#isLoading = false; + if (this.#generateBtn) this.#generateBtn.disabled = false; + } + } + + #renderLoading() { + if (!this.#previewArea) return; + this.#previewArea.innerHTML = `
Generating...
`; + if (this.#actionsBar) this.#actionsBar.style.display = "none"; + } + + #renderError() { + if (!this.#previewArea) return; + this.#previewArea.innerHTML = `
${this.#error}
`; + if (this.#actionsBar) this.#actionsBar.style.display = "none"; + } + + #renderResult() { + if (!this.#previewArea) return; + if (this.#htmlOutput) { + this.#previewArea.innerHTML = `
${this.#htmlOutput}
`; + } else if (this.#textOutput) { + const el = document.createElement("div"); + el.className = "ascii-output"; + el.textContent = this.#textOutput; + this.#previewArea.innerHTML = ""; + this.#previewArea.appendChild(el); + } else { + this.#previewArea.innerHTML = `
No output received
`; + } + if (this.#actionsBar) this.#actionsBar.style.display = "flex"; + } +} diff --git a/lib/folk-video-gen.ts b/lib/folk-video-gen.ts index 207988b..8a62021 100644 --- a/lib/folk-video-gen.ts +++ b/lib/folk-video-gen.ts @@ -468,33 +468,59 @@ export class FolkVideoGen extends FolkShape { ? { image: this.#sourceImage, prompt, duration } : { prompt, duration }; - const response = await fetch(endpoint, { + const submitRes = await fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); - if (!response.ok) { - throw new Error(`Generation failed: ${response.statusText}`); + if (!submitRes.ok) { + const err = await submitRes.json().catch(() => ({})); + throw new Error(err.error || `Generation failed: ${submitRes.statusText}`); } - const result = await response.json(); + const submitData = await submitRes.json(); - const video: GeneratedVideo = { - id: crypto.randomUUID(), - prompt, - url: result.url || result.video_url, - sourceImage: this.#mode === "i2v" ? this.#sourceImage || undefined : undefined, - duration, - timestamp: new Date(), - }; + // Poll for job completion (up to 5 minutes) + const jobId = submitData.job_id; + if (!jobId) throw new Error("No job ID returned"); - this.#videos.unshift(video); - this.#renderVideos(); - this.dispatchEvent(new CustomEvent("video-generated", { detail: { video } })); + const deadline = Date.now() + 300_000; + let elapsed = 0; - // Clear input - if (this.#promptInput) this.#promptInput.value = ""; + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 3000)); + elapsed += 3; + this.#progress = Math.min(90, (elapsed / 120) * 100); + this.#renderLoading(); + + const pollRes = await fetch(`/api/video-gen/${jobId}`); + if (!pollRes.ok) continue; + const pollData = await pollRes.json(); + + if (pollData.status === "complete") { + const video: GeneratedVideo = { + id: crypto.randomUUID(), + prompt, + url: pollData.url || pollData.video_url, + sourceImage: this.#mode === "i2v" ? this.#sourceImage || undefined : undefined, + duration, + timestamp: new Date(), + }; + + this.#videos.unshift(video); + this.#renderVideos(); + this.dispatchEvent(new CustomEvent("video-generated", { detail: { video } })); + if (this.#promptInput) this.#promptInput.value = ""; + return; + } + + if (pollData.status === "failed") { + throw new Error(pollData.error || "Video generation failed"); + } + } + + throw new Error("Video generation timed out"); } catch (error) { this.#error = error instanceof Error ? error.message : "Generation failed"; this.#renderError(); diff --git a/lib/index.ts b/lib/index.ts index 3d6f9d7..d293d0e 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -51,6 +51,7 @@ export * from "./folk-drawfast"; export * from "./folk-freecad"; export * from "./folk-kicad"; export * from "./folk-design-agent"; +export * from "./folk-ascii-gen"; // Advanced Shapes export * from "./folk-video-chat"; diff --git a/lib/mi-triage-panel.ts b/lib/mi-triage-panel.ts index 35960a1..2f5ec91 100644 --- a/lib/mi-triage-panel.ts +++ b/lib/mi-triage-panel.ts @@ -39,6 +39,7 @@ const SHAPE_ICONS: Record = { "folk-freecad": { icon: "🔧", label: "CAD" }, "folk-kicad": { icon: "🔌", label: "PCB" }, "folk-design-agent": { icon: "🖨️", label: "Design" }, + "folk-ascii-gen": { icon: "▦", label: "ASCII Art" }, // Social "folk-social-post": { icon: "📣", label: "Social Post" }, "folk-social-thread": { icon: "🧵", label: "Thread" }, diff --git a/modules/rdesign/mod.ts b/modules/rdesign/mod.ts index 93020c6..c2f8e81 100644 --- a/modules/rdesign/mod.ts +++ b/modules/rdesign/mod.ts @@ -10,6 +10,7 @@ import { renderShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import { designAgentRoutes } from "./design-agent-route"; +import { ensureSidecar } from "../../server/sidecar-manager"; const routes = new Hono(); @@ -25,6 +26,7 @@ routes.get("/api/health", (c) => { // Proxy bridge API calls from rspace to the Scribus container routes.all("/api/bridge/*", async (c) => { + await ensureSidecar("scribus-novnc"); const path = c.req.path.replace(/^.*\/api\/bridge/, "/api/scribus"); const bridgeSecret = process.env.SCRIBUS_BRIDGE_SECRET || ""; const headers: Record = { "Content-Type": "application/json" }; diff --git a/server/index.ts b/server/index.ts index 9ae3b22..9b91c3e 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1046,6 +1046,124 @@ setInterval(() => { } }, 30 * 60 * 1000); +// ── Video generation job queue (async to avoid Cloudflare timeouts) ── + +interface VideoGenJob { + id: string; + status: "pending" | "processing" | "complete" | "failed"; + type: "t2v" | "i2v"; + prompt: string; + sourceImage?: string; + resultUrl?: string; + error?: string; + createdAt: number; + completedAt?: number; +} + +const videoGenJobs = new Map(); + +// Clean up old video jobs every 30 minutes (keep for 6h) +setInterval(() => { + const cutoff = Date.now() - 6 * 60 * 60 * 1000; + for (const [id, job] of videoGenJobs) { + if (job.createdAt < cutoff) videoGenJobs.delete(id); + } +}, 30 * 60 * 1000); + +async function processVideoGenJob(job: VideoGenJob) { + job.status = "processing"; + const falHeaders = { Authorization: `Key ${FAL_KEY}`, "Content-Type": "application/json" }; + + try { + const MODEL = job.type === "i2v" + ? "fal-ai/kling-video/v1/standard/image-to-video" + : "fal-ai/wan-t2v"; + + const body = job.type === "i2v" + ? { image_url: job.sourceImage, prompt: job.prompt || "", duration: 5, aspect_ratio: "16:9" } + : { prompt: job.prompt, num_frames: 81, resolution: "480p" }; + + // Submit to fal.ai queue + const submitRes = await fetch(`https://queue.fal.run/${MODEL}`, { + method: "POST", + headers: falHeaders, + body: JSON.stringify(body), + }); + + if (!submitRes.ok) { + const errText = await submitRes.text(); + console.error(`[video-gen] fal.ai submit error (${job.type}):`, submitRes.status, errText); + job.status = "failed"; + job.error = "Video generation failed to start"; + job.completedAt = Date.now(); + return; + } + + const { request_id } = await submitRes.json() as { request_id: string }; + + // Poll for completion (up to 5 min) + const deadline = Date.now() + 300_000; + let responseUrl = ""; + let completed = false; + + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 3000)); + const statusRes = await fetch( + `https://queue.fal.run/${MODEL}/requests/${request_id}/status`, + { headers: falHeaders }, + ); + if (!statusRes.ok) continue; + const statusData = await statusRes.json() as { status: string; response_url?: string }; + console.log(`[video-gen] Poll ${job.id}: status=${statusData.status}`); + if (statusData.response_url) responseUrl = statusData.response_url; + if (statusData.status === "COMPLETED") { completed = true; break; } + if (statusData.status === "FAILED") { + job.status = "failed"; + job.error = "Video generation failed on fal.ai"; + job.completedAt = Date.now(); + return; + } + } + + if (!completed) { + job.status = "failed"; + job.error = "Video generation timed out"; + job.completedAt = Date.now(); + return; + } + + // Fetch result + const resultUrl = responseUrl || `https://queue.fal.run/${MODEL}/requests/${request_id}`; + const resultRes = await fetch(resultUrl, { headers: falHeaders }); + if (!resultRes.ok) { + job.status = "failed"; + job.error = "Failed to retrieve video"; + job.completedAt = Date.now(); + return; + } + + const data = await resultRes.json(); + const videoUrl = data.video?.url || data.output?.url; + if (!videoUrl) { + console.error(`[video-gen] No video URL in response:`, JSON.stringify(data).slice(0, 500)); + job.status = "failed"; + job.error = "No video returned"; + job.completedAt = Date.now(); + return; + } + + job.status = "complete"; + job.resultUrl = videoUrl; + job.completedAt = Date.now(); + console.log(`[video-gen] Job ${job.id} complete: ${videoUrl}`); + } catch (e: any) { + console.error("[video-gen] error:", e.message); + job.status = "failed"; + job.error = "Video generation failed"; + job.completedAt = Date.now(); + } +} + let splatMailTransport: ReturnType | null = null; if (process.env.SMTP_PASS) { splatMailTransport = createTransport({ @@ -1496,48 +1614,67 @@ app.post("/api/image-gen/img2img", async (c) => { return c.json({ error: `Unknown provider: ${provider}` }, 400); }); -// Text-to-video via fal.ai WAN 2.1 (delegates to shared helper) +// Text-to-video via fal.ai WAN 2.1 (async job queue to avoid Cloudflare timeouts) app.post("/api/video-gen/t2v", async (c) => { + if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503); + const { prompt } = await c.req.json(); if (!prompt) return c.json({ error: "prompt required" }, 400); - const { generateVideoViaFal } = await import("./mi-media"); - const result = await generateVideoViaFal(prompt); - if (!result.ok) return c.json({ error: result.error }, 502); - return c.json({ url: result.url, video_url: result.url }); + const jobId = crypto.randomUUID(); + const job: VideoGenJob = { + id: jobId, status: "pending", type: "t2v", + prompt, createdAt: Date.now(), + }; + videoGenJobs.set(jobId, job); + processVideoGenJob(job); + return c.json({ job_id: jobId, status: "pending" }); }); -// Image-to-video via fal.ai Kling +// Image-to-video via fal.ai Kling (async job queue) app.post("/api/video-gen/i2v", async (c) => { if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503); - const { image, prompt, duration } = await c.req.json(); + const { image, prompt } = await c.req.json(); if (!image) return c.json({ error: "image required" }, 400); - const res = await fetch("https://fal.run/fal-ai/kling-video/v1/standard/image-to-video", { - method: "POST", - headers: { - Authorization: `Key ${FAL_KEY}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - image_url: image, - prompt: prompt || "", - duration: duration === "5s" ? "5" : "5", - }), - }); - - if (!res.ok) { - const err = await res.text(); - console.error("[video-gen/i2v] fal.ai error:", err); - return c.json({ error: "Video generation failed" }, 502); + // Stage the source image if it's a data URL + let imageUrl = image; + if (image.startsWith("data:")) { + const url = await saveDataUrlToDisk(image, "vid-src"); + imageUrl = publicUrl(c, url); + } else if (image.startsWith("/")) { + imageUrl = publicUrl(c, image); } - const data = await res.json(); - const videoUrl = data.video?.url || data.output?.url; - if (!videoUrl) return c.json({ error: "No video returned" }, 502); + const jobId = crypto.randomUUID(); + const job: VideoGenJob = { + id: jobId, status: "pending", type: "i2v", + prompt: prompt || "", sourceImage: imageUrl, createdAt: Date.now(), + }; + videoGenJobs.set(jobId, job); + processVideoGenJob(job); + return c.json({ job_id: jobId, status: "pending" }); +}); - return c.json({ url: videoUrl, video_url: videoUrl }); +// Poll video generation job status +app.get("/api/video-gen/:jobId", async (c) => { + const jobId = c.req.param("jobId"); + const job = videoGenJobs.get(jobId); + if (!job) return c.json({ error: "Job not found" }, 404); + + const response: Record = { + job_id: job.id, status: job.status, created_at: job.createdAt, + }; + if (job.status === "complete") { + response.url = job.resultUrl; + response.video_url = job.resultUrl; + response.completed_at = job.completedAt; + } else if (job.status === "failed") { + response.error = job.error; + response.completed_at = job.completedAt; + } + return c.json(response); }); // Stage image for 3D generation (binary upload → HTTPS URL for fal.ai) @@ -1749,6 +1886,59 @@ Output ONLY the Python code, no explanations or comments outside the code.`); } }); +// ── ASCII Art Generation (proxies to ascii-art service on rspace-internal) ── +const ASCII_ART_URL = process.env.ASCII_ART_URL || "http://ascii-art:8000"; + +app.post("/api/ascii-gen", async (c) => { + const body = await c.req.json(); + const { prompt, width, height, palette, output_format } = body; + if (!prompt) return c.json({ error: "prompt required" }, 400); + + try { + const res = await fetch(`${ASCII_ART_URL}/api/pattern`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + prompt, + width: width || 80, + height: height || 40, + palette: palette || "ansi", + output_format: output_format || "html", + }), + signal: AbortSignal.timeout(30_000), + }); + if (!res.ok) { + const err = await res.text(); + return c.json({ error: `ASCII art service error: ${err}` }, res.status as any); + } + const data = await res.json(); + return c.json(data); + } catch (e: any) { + console.error("[ascii-gen] error:", e); + return c.json({ error: `ASCII art service unavailable: ${e.message}` }, 502); + } +}); + +app.post("/api/ascii-gen/render", async (c) => { + try { + const res = await fetch(`${ASCII_ART_URL}/api/render`, { + method: "POST", + headers: { "Content-Type": c.req.header("Content-Type") || "application/json" }, + body: await c.req.arrayBuffer(), + signal: AbortSignal.timeout(30_000), + }); + if (!res.ok) { + const err = await res.text(); + return c.json({ error: `ASCII render error: ${err}` }, res.status as any); + } + const data = await res.json(); + return c.json(data); + } catch (e: any) { + console.error("[ascii-gen] render error:", e); + return c.json({ error: `ASCII art service unavailable: ${e.message}` }, 502); + } +}); + // KiCAD PCB design — MCP StreamableHTTP bridge (sidecar container) import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; diff --git a/server/mi-media.ts b/server/mi-media.ts index 3b2c696..3d3e237 100644 --- a/server/mi-media.ts +++ b/server/mi-media.ts @@ -164,8 +164,8 @@ export async function generateVideoViaFal(prompt: string, source_image?: string) return { ok: true, url: videoUrl }; } - // Text-to-video via WAN 2.1 - const res = await fetch("https://fal.run/fal-ai/wan/v2.1", { + // Text-to-video via WAN 2.1 (fal.ai renamed endpoint from wan/v2.1 to wan-t2v) + const res = await fetch("https://fal.run/fal-ai/wan-t2v", { method: "POST", headers: { Authorization: `Key ${FAL_KEY}`, @@ -173,7 +173,7 @@ export async function generateVideoViaFal(prompt: string, source_image?: string) }, body: JSON.stringify({ prompt, - num_frames: 49, + num_frames: 81, resolution: "480p", }), }); diff --git a/server/sidecar-manager.ts b/server/sidecar-manager.ts index ca0da6e..3b47fa2 100644 --- a/server/sidecar-manager.ts +++ b/server/sidecar-manager.ts @@ -45,6 +45,18 @@ const SIDECARS: Record = { port: 11434, healthTimeout: 30_000, }, + "scribus-novnc": { + container: "scribus-novnc", + host: "scribus-novnc", + port: 8765, + healthTimeout: 30_000, + }, + "open-notebook": { + container: "open-notebook", + host: "open-notebook", + port: 5055, + healthTimeout: 45_000, + }, }; const lastUsed = new Map(); @@ -61,14 +73,17 @@ try { // ── Docker Engine API over Unix socket ── -function dockerApi(method: string, path: string): Promise<{ status: number; body: any }> { +function dockerApi(method: string, path: string, sendBody?: boolean): Promise<{ status: number; body: any }> { return new Promise((resolve, reject) => { + const headers: Record = {}; + // Only set Content-Type when we actually send a JSON body + if (sendBody) headers["Content-Type"] = "application/json"; const req = http.request( { socketPath: DOCKER_SOCKET, path: `/v1.43${path}`, method, - headers: { "Content-Type": "application/json" }, + headers, }, (res) => { let data = ""; @@ -100,10 +115,11 @@ async function isContainerRunning(name: string): Promise { } async function startContainer(name: string): Promise { - const { status } = await dockerApi("POST", `/containers/${name}/start`); + const { status, body } = await dockerApi("POST", `/containers/${name}/start`); // 204 = started, 304 = already running if (status !== 204 && status !== 304) { - throw new Error(`Failed to start ${name}: HTTP ${status}`); + const detail = typeof body === "object" ? JSON.stringify(body) : body; + throw new Error(`Failed to start ${name}: HTTP ${status} — ${detail}`); } } diff --git a/types/hono.d.ts b/types/hono.d.ts index fa5b728..f1c9623 100644 --- a/types/hono.d.ts +++ b/types/hono.d.ts @@ -5,6 +5,7 @@ declare module 'hono' { effectiveSpace: string; spaceRole: string; isOwner: boolean; + isSubdomain: boolean; x402Payment: string; x402Scheme: string; }