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 = `
})
`;
+ }
+ }
+ }) 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(