diff --git a/lib/folk-image-gen.ts b/lib/folk-image-gen.ts index f341b832..3f629ddd 100644 --- a/lib/folk-image-gen.ts +++ b/lib/folk-image-gen.ts @@ -324,6 +324,14 @@ export class FolkImageGen extends FolkShape { this.dispatchEvent(new CustomEvent("close")); }); + // Listen for incoming port data (arrow connections) + this.addEventListener("port-value-changed", ((e: CustomEvent) => { + const { name, value } = e.detail; + if (name === "prompt" && typeof value === "string" && this.#promptInput) { + this.#promptInput.value = value; + } + }) as EventListener); + return root; } @@ -361,6 +369,7 @@ export class FolkImageGen extends FolkShape { this.#images.unshift(image); this.#renderImages(); + this.setPortValue("image", image.url); this.dispatchEvent(new CustomEvent("image-generated", { detail: { image } })); // Clear input diff --git a/lib/folk-video-gen.ts b/lib/folk-video-gen.ts index 11666002..5ffc6ba8 100644 --- a/lib/folk-video-gen.ts +++ b/lib/folk-video-gen.ts @@ -147,7 +147,7 @@ const styles = css` align-items: center; } - .duration-select { + .duration-select, .model-select, .resolution-select, .aspect-select { padding: 6px 10px; border: 2px solid #e2e8f0; border-radius: 6px; @@ -156,6 +156,27 @@ const styles = css` cursor: pointer; } + .model-select { + min-width: 140px; + } + + .seedance-options { + display: flex; + gap: 6px; + margin-top: 6px; + flex-wrap: wrap; + align-items: center; + } + + .seedance-options label { + font-size: 12px; + color: #64748b; + display: flex; + align-items: center; + gap: 4px; + cursor: pointer; + } + .generate-btn { flex: 1; padding: 8px 16px; @@ -314,8 +335,14 @@ export class FolkVideoGen extends FolkShape { #progress = 0; #error: string | null = null; + #model = "seedance"; #promptInput: HTMLTextAreaElement | null = null; #durationSelect: HTMLSelectElement | null = null; + #modelSelect: HTMLSelectElement | null = null; + #resolutionSelect: HTMLSelectElement | null = null; + #aspectSelect: HTMLSelectElement | null = null; + #audioToggle: HTMLInputElement | null = null; + #seedanceOptions: HTMLElement | null = null; #videoArea: HTMLElement | null = null; #generateBtn: HTMLButtonElement | null = null; #imageUpload: HTMLElement | null = null; @@ -352,12 +379,34 @@ export class FolkVideoGen extends FolkShape {
- + + + +
+
+ + + + +
@@ -377,6 +426,11 @@ export class FolkVideoGen extends FolkShape { this.#promptInput = wrapper.querySelector(".prompt-input"); this.#durationSelect = wrapper.querySelector(".duration-select"); + this.#modelSelect = wrapper.querySelector(".model-select"); + this.#resolutionSelect = wrapper.querySelector(".resolution-select"); + this.#aspectSelect = wrapper.querySelector(".aspect-select"); + this.#audioToggle = wrapper.querySelector(".audio-toggle"); + this.#seedanceOptions = wrapper.querySelector(".seedance-options"); this.#videoArea = wrapper.querySelector(".video-area"); this.#generateBtn = wrapper.querySelector(".generate-btn"); this.#imageUpload = wrapper.querySelector(".image-upload"); @@ -415,12 +469,42 @@ export class FolkVideoGen extends FolkShape { // Prevent drag on inputs this.#promptInput?.addEventListener("pointerdown", (e) => e.stopPropagation()); + // Model selector + this.#modelSelect?.addEventListener("change", (e) => { + e.stopPropagation(); + this.#model = this.#modelSelect?.value || "seedance"; + this.#updateModelUI(modeTabs); + }); + this.#modelSelect?.addEventListener("pointerdown", (e) => e.stopPropagation()); + this.#resolutionSelect?.addEventListener("pointerdown", (e) => e.stopPropagation()); + this.#aspectSelect?.addEventListener("pointerdown", (e) => e.stopPropagation()); + this.#durationSelect?.addEventListener("pointerdown", (e) => e.stopPropagation()); + this.#updateModelUI(modeTabs); + // Close button closeBtn.addEventListener("click", (e) => { e.stopPropagation(); this.dispatchEvent(new CustomEvent("close")); }); + // Listen for incoming port data (arrow connections) + this.addEventListener("port-value-changed", ((e: CustomEvent) => { + const { name, value } = e.detail; + if (name === "prompt" && typeof value === "string" && this.#promptInput) { + this.#promptInput.value = value; + } else if (name === "image" && typeof value === "string") { + this.#sourceImage = value; + this.#setMode("i2v"); + modeTabs.forEach((t) => { + t.classList.toggle("active", (t as HTMLElement).dataset.mode === "i2v"); + }); + if (this.#imageUpload) { + this.#imageUpload.classList.add("has-image"); + this.#imageUpload.innerHTML = `Source`; + } + } + }) as EventListener); + return root; } @@ -431,6 +515,21 @@ export class FolkVideoGen extends FolkShape { } } + #updateModelUI(modeTabs: NodeListOf) { + const isSeedance = this.#model === "seedance" || this.#model === "seedance-fast"; + if (this.#seedanceOptions) { + this.#seedanceOptions.style.display = isSeedance ? "flex" : "none"; + } + // WAN is t2v-only, Kling is i2v-only — auto-switch mode + if (this.#model === "wan") { + this.#setMode("t2v"); + modeTabs.forEach((t) => t.classList.toggle("active", (t as HTMLElement).dataset.mode === "t2v")); + } else if (this.#model === "kling") { + this.#setMode("i2v"); + modeTabs.forEach((t) => t.classList.toggle("active", (t as HTMLElement).dataset.mode === "i2v")); + } + } + #handleImageUpload(file: File) { const reader = new FileReader(); reader.onload = (e) => { @@ -463,10 +562,15 @@ export class FolkVideoGen extends FolkShape { try { const endpoint = this.#mode === "i2v" ? "/api/video-gen/i2v" : "/api/video-gen/t2v"; - const body = + const body: Record = this.#mode === "i2v" ? { image: this.#sourceImage, prompt, duration } : { prompt, duration }; + body.model = this.#model; + body.duration = String(duration); + body.resolution = this.#resolutionSelect?.value || "480p"; + body.aspect_ratio = this.#aspectSelect?.value || "16:9"; + body.generate_audio = this.#audioToggle?.checked ?? false; const submitRes = await fetch(endpoint, { method: "POST", @@ -522,6 +626,7 @@ export class FolkVideoGen extends FolkShape { this.#videos.unshift(video); this.#renderVideos(); + this.setPortValue("video", video.url); this.dispatchEvent(new CustomEvent("video-generated", { detail: { video } })); if (this.#promptInput) this.#promptInput.value = ""; return; diff --git a/server/index.ts b/server/index.ts index c329487a..5f154548 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1168,6 +1168,11 @@ interface VideoGenJob { completedAt?: number; queuePosition?: number; falStatus?: string; + model?: string; + duration?: string; + resolution?: string; + aspectRatio?: string; + generateAudio?: boolean; } const videoGenJobs = new Map(); @@ -1185,13 +1190,38 @@ async function processVideoGenJob(job: VideoGenJob) { 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"; + let MODEL: string; + let body: Record; + const m = job.model || (job.type === "i2v" ? "kling" : "wan"); - 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" }; + switch (m) { + case "seedance": + case "seedance-fast": { + const variant = m === "seedance-fast" ? "seedance-2.0-fast" : "seedance-2.0"; + const mode = job.type === "i2v" ? "image-to-video" : "text-to-video"; + MODEL = `fal-ai/bytedance/${variant}/${mode}`; + body = { + prompt: job.prompt, + duration: job.duration || "5", + resolution: job.resolution || "480p", + aspect_ratio: job.aspectRatio || "16:9", + generate_audio: job.generateAudio ?? false, + }; + if (job.type === "i2v" && job.sourceImage) { + body.image_url = job.sourceImage; + } + break; + } + case "kling": + MODEL = "fal-ai/kling-video/v1/standard/image-to-video"; + body = { image_url: job.sourceImage, prompt: job.prompt || "", duration: 5, aspect_ratio: "16:9" }; + break; + case "wan": + default: + MODEL = "fal-ai/wan-t2v"; + body = { prompt: job.prompt, num_frames: 81, resolution: "480p" }; + break; + } // Submit to fal.ai queue const submitRes = await fetch(`https://queue.fal.run/${MODEL}`, { @@ -1790,13 +1820,15 @@ app.post("/api/image-gen/img2img", async (c) => { 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(); + const { prompt, model, duration, resolution, aspect_ratio, generate_audio } = await c.req.json(); if (!prompt) return c.json({ error: "prompt required" }, 400); const jobId = crypto.randomUUID(); const job: VideoGenJob = { id: jobId, status: "pending", type: "t2v", prompt, createdAt: Date.now(), + model: model || "wan", + duration, resolution, aspectRatio: aspect_ratio, generateAudio: generate_audio, }; videoGenJobs.set(jobId, job); processVideoGenJob(job); @@ -1807,7 +1839,7 @@ app.post("/api/video-gen/t2v", async (c) => { app.post("/api/video-gen/i2v", async (c) => { if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503); - const { image, prompt } = await c.req.json(); + const { image, prompt, model, duration, resolution, aspect_ratio, generate_audio } = await c.req.json(); if (!image) return c.json({ error: "image required" }, 400); // Stage the source image if it's a data URL @@ -1823,6 +1855,8 @@ app.post("/api/video-gen/i2v", async (c) => { const job: VideoGenJob = { id: jobId, status: "pending", type: "i2v", prompt: prompt || "", sourceImage: imageUrl, createdAt: Date.now(), + model: model || "kling", + duration, resolution, aspectRatio: aspect_ratio, generateAudio: generate_audio, }; videoGenJobs.set(jobId, job); processVideoGenJob(job); diff --git a/vite.config.ts b/vite.config.ts index b202254e..85d2f916 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -654,6 +654,47 @@ export default defineConfig({ }, }); + // Build docs module component (uses Automerge shim from shell-offline) + await wasmBuild({ + configFile: false, + root: resolve(__dirname, "modules/rdocs/components"), + build: { + emptyOutDir: false, + outDir: resolve(__dirname, "dist/modules/rdocs"), + lib: { + entry: resolve(__dirname, "modules/rdocs/components/folk-docs-app.ts"), + formats: ["es"], + fileName: () => "folk-docs-app.js", + }, + rollupOptions: { + output: { + entryFileNames: "folk-docs-app.js", + inlineDynamicImports: true, + }, + }, + }, + }); + + // Build voice recorder component + await wasmBuild({ + configFile: false, + root: resolve(__dirname, "modules/rdocs/components"), + build: { + emptyOutDir: false, + outDir: resolve(__dirname, "dist/modules/rdocs"), + lib: { + entry: resolve(__dirname, "modules/rdocs/components/folk-voice-recorder.ts"), + formats: ["es"], + fileName: () => "folk-voice-recorder.js", + }, + rollupOptions: { + output: { + entryFileNames: "folk-voice-recorder.js", + }, + }, + }, + }); + // Copy docs CSS mkdirSync(resolve(__dirname, "dist/modules/rdocs"), { recursive: true }); copyFileSync(