Merge branch 'dev'
CI/CD / deploy (push) Successful in 2m43s Details

This commit is contained in:
Jeff Emmett 2026-04-12 18:48:05 -04:00
commit 2d6226630a
4 changed files with 202 additions and 13 deletions

View File

@ -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

View File

@ -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 {
<input type="file" class="hidden-input" accept="image/*" />
<textarea class="prompt-input" placeholder="Describe the motion/action for the video..." rows="2"></textarea>
<div class="controls">
<select class="duration-select">
<option value="4">4 seconds</option>
<option value="5">5 seconds</option>
<select class="model-select">
<option value="seedance">Seedance 2.0</option>
<option value="seedance-fast">Seedance 2.0 Fast</option>
<option value="kling">Kling (i2v)</option>
<option value="wan">WAN (t2v)</option>
</select>
<button class="generate-btn">Generate Video</button>
</div>
<div class="seedance-options">
<select class="duration-select">
<option value="5">5s</option>
<option value="4">4s</option>
<option value="6">6s</option>
<option value="8">8s</option>
<option value="10">10s</option>
</select>
<select class="resolution-select">
<option value="480p">480p</option>
<option value="720p">720p</option>
</select>
<select class="aspect-select">
<option value="16:9">16:9</option>
<option value="9:16">9:16</option>
<option value="1:1">1:1</option>
<option value="4:3">4:3</option>
</select>
<label><input type="checkbox" class="audio-toggle" /> Audio</label>
</div>
</div>
<div class="video-area">
<div class="placeholder">
@ -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 = `<img class="uploaded-image" src="${this.#escapeHtml(value)}" alt="Source" />`;
}
}
}) as EventListener);
return root;
}
@ -431,6 +515,21 @@ export class FolkVideoGen extends FolkShape {
}
}
#updateModelUI(modeTabs: NodeListOf<Element>) {
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<string, any> =
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;

View File

@ -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<string, VideoGenJob>();
@ -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<string, any>;
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);

View File

@ -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(