Merge branch 'dev'
CI/CD / deploy (push) Successful in 2m43s
Details
CI/CD / deploy (push) Successful in 2m43s
Details
This commit is contained in:
commit
2d6226630a
|
|
@ -324,6 +324,14 @@ export class FolkImageGen extends FolkShape {
|
||||||
this.dispatchEvent(new CustomEvent("close"));
|
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;
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -361,6 +369,7 @@ export class FolkImageGen extends FolkShape {
|
||||||
|
|
||||||
this.#images.unshift(image);
|
this.#images.unshift(image);
|
||||||
this.#renderImages();
|
this.#renderImages();
|
||||||
|
this.setPortValue("image", image.url);
|
||||||
this.dispatchEvent(new CustomEvent("image-generated", { detail: { image } }));
|
this.dispatchEvent(new CustomEvent("image-generated", { detail: { image } }));
|
||||||
|
|
||||||
// Clear input
|
// Clear input
|
||||||
|
|
|
||||||
|
|
@ -147,7 +147,7 @@ const styles = css`
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.duration-select {
|
.duration-select, .model-select, .resolution-select, .aspect-select {
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
border: 2px solid #e2e8f0;
|
border: 2px solid #e2e8f0;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
|
@ -156,6 +156,27 @@ const styles = css`
|
||||||
cursor: pointer;
|
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 {
|
.generate-btn {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
|
|
@ -314,8 +335,14 @@ export class FolkVideoGen extends FolkShape {
|
||||||
#progress = 0;
|
#progress = 0;
|
||||||
#error: string | null = null;
|
#error: string | null = null;
|
||||||
|
|
||||||
|
#model = "seedance";
|
||||||
#promptInput: HTMLTextAreaElement | null = null;
|
#promptInput: HTMLTextAreaElement | null = null;
|
||||||
#durationSelect: HTMLSelectElement | 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;
|
#videoArea: HTMLElement | null = null;
|
||||||
#generateBtn: HTMLButtonElement | null = null;
|
#generateBtn: HTMLButtonElement | null = null;
|
||||||
#imageUpload: HTMLElement | null = null;
|
#imageUpload: HTMLElement | null = null;
|
||||||
|
|
@ -352,12 +379,34 @@ export class FolkVideoGen extends FolkShape {
|
||||||
<input type="file" class="hidden-input" accept="image/*" />
|
<input type="file" class="hidden-input" accept="image/*" />
|
||||||
<textarea class="prompt-input" placeholder="Describe the motion/action for the video..." rows="2"></textarea>
|
<textarea class="prompt-input" placeholder="Describe the motion/action for the video..." rows="2"></textarea>
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<select class="duration-select">
|
<select class="model-select">
|
||||||
<option value="4">4 seconds</option>
|
<option value="seedance">Seedance 2.0</option>
|
||||||
<option value="5">5 seconds</option>
|
<option value="seedance-fast">Seedance 2.0 Fast</option>
|
||||||
|
<option value="kling">Kling (i2v)</option>
|
||||||
|
<option value="wan">WAN (t2v)</option>
|
||||||
</select>
|
</select>
|
||||||
<button class="generate-btn">Generate Video</button>
|
<button class="generate-btn">Generate Video</button>
|
||||||
</div>
|
</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>
|
||||||
<div class="video-area">
|
<div class="video-area">
|
||||||
<div class="placeholder">
|
<div class="placeholder">
|
||||||
|
|
@ -377,6 +426,11 @@ export class FolkVideoGen extends FolkShape {
|
||||||
|
|
||||||
this.#promptInput = wrapper.querySelector(".prompt-input");
|
this.#promptInput = wrapper.querySelector(".prompt-input");
|
||||||
this.#durationSelect = wrapper.querySelector(".duration-select");
|
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.#videoArea = wrapper.querySelector(".video-area");
|
||||||
this.#generateBtn = wrapper.querySelector(".generate-btn");
|
this.#generateBtn = wrapper.querySelector(".generate-btn");
|
||||||
this.#imageUpload = wrapper.querySelector(".image-upload");
|
this.#imageUpload = wrapper.querySelector(".image-upload");
|
||||||
|
|
@ -415,12 +469,42 @@ export class FolkVideoGen extends FolkShape {
|
||||||
// Prevent drag on inputs
|
// Prevent drag on inputs
|
||||||
this.#promptInput?.addEventListener("pointerdown", (e) => e.stopPropagation());
|
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
|
// Close button
|
||||||
closeBtn.addEventListener("click", (e) => {
|
closeBtn.addEventListener("click", (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.dispatchEvent(new CustomEvent("close"));
|
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;
|
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) {
|
#handleImageUpload(file: File) {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = (e) => {
|
reader.onload = (e) => {
|
||||||
|
|
@ -463,10 +562,15 @@ export class FolkVideoGen extends FolkShape {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const endpoint = this.#mode === "i2v" ? "/api/video-gen/i2v" : "/api/video-gen/t2v";
|
const endpoint = this.#mode === "i2v" ? "/api/video-gen/i2v" : "/api/video-gen/t2v";
|
||||||
const body =
|
const body: Record<string, any> =
|
||||||
this.#mode === "i2v"
|
this.#mode === "i2v"
|
||||||
? { image: this.#sourceImage, prompt, duration }
|
? { image: this.#sourceImage, prompt, duration }
|
||||||
: { 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, {
|
const submitRes = await fetch(endpoint, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -522,6 +626,7 @@ export class FolkVideoGen extends FolkShape {
|
||||||
|
|
||||||
this.#videos.unshift(video);
|
this.#videos.unshift(video);
|
||||||
this.#renderVideos();
|
this.#renderVideos();
|
||||||
|
this.setPortValue("video", video.url);
|
||||||
this.dispatchEvent(new CustomEvent("video-generated", { detail: { video } }));
|
this.dispatchEvent(new CustomEvent("video-generated", { detail: { video } }));
|
||||||
if (this.#promptInput) this.#promptInput.value = "";
|
if (this.#promptInput) this.#promptInput.value = "";
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -1168,6 +1168,11 @@ interface VideoGenJob {
|
||||||
completedAt?: number;
|
completedAt?: number;
|
||||||
queuePosition?: number;
|
queuePosition?: number;
|
||||||
falStatus?: string;
|
falStatus?: string;
|
||||||
|
model?: string;
|
||||||
|
duration?: string;
|
||||||
|
resolution?: string;
|
||||||
|
aspectRatio?: string;
|
||||||
|
generateAudio?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const videoGenJobs = new Map<string, VideoGenJob>();
|
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" };
|
const falHeaders = { Authorization: `Key ${FAL_KEY}`, "Content-Type": "application/json" };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const MODEL = job.type === "i2v"
|
let MODEL: string;
|
||||||
? "fal-ai/kling-video/v1/standard/image-to-video"
|
let body: Record<string, any>;
|
||||||
: "fal-ai/wan-t2v";
|
const m = job.model || (job.type === "i2v" ? "kling" : "wan");
|
||||||
|
|
||||||
const body = job.type === "i2v"
|
switch (m) {
|
||||||
? { image_url: job.sourceImage, prompt: job.prompt || "", duration: 5, aspect_ratio: "16:9" }
|
case "seedance":
|
||||||
: { prompt: job.prompt, num_frames: 81, resolution: "480p" };
|
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
|
// Submit to fal.ai queue
|
||||||
const submitRes = await fetch(`https://queue.fal.run/${MODEL}`, {
|
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) => {
|
app.post("/api/video-gen/t2v", async (c) => {
|
||||||
if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503);
|
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);
|
if (!prompt) return c.json({ error: "prompt required" }, 400);
|
||||||
|
|
||||||
const jobId = crypto.randomUUID();
|
const jobId = crypto.randomUUID();
|
||||||
const job: VideoGenJob = {
|
const job: VideoGenJob = {
|
||||||
id: jobId, status: "pending", type: "t2v",
|
id: jobId, status: "pending", type: "t2v",
|
||||||
prompt, createdAt: Date.now(),
|
prompt, createdAt: Date.now(),
|
||||||
|
model: model || "wan",
|
||||||
|
duration, resolution, aspectRatio: aspect_ratio, generateAudio: generate_audio,
|
||||||
};
|
};
|
||||||
videoGenJobs.set(jobId, job);
|
videoGenJobs.set(jobId, job);
|
||||||
processVideoGenJob(job);
|
processVideoGenJob(job);
|
||||||
|
|
@ -1807,7 +1839,7 @@ app.post("/api/video-gen/t2v", async (c) => {
|
||||||
app.post("/api/video-gen/i2v", async (c) => {
|
app.post("/api/video-gen/i2v", async (c) => {
|
||||||
if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503);
|
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);
|
if (!image) return c.json({ error: "image required" }, 400);
|
||||||
|
|
||||||
// Stage the source image if it's a data URL
|
// 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 = {
|
const job: VideoGenJob = {
|
||||||
id: jobId, status: "pending", type: "i2v",
|
id: jobId, status: "pending", type: "i2v",
|
||||||
prompt: prompt || "", sourceImage: imageUrl, createdAt: Date.now(),
|
prompt: prompt || "", sourceImage: imageUrl, createdAt: Date.now(),
|
||||||
|
model: model || "kling",
|
||||||
|
duration, resolution, aspectRatio: aspect_ratio, generateAudio: generate_audio,
|
||||||
};
|
};
|
||||||
videoGenJobs.set(jobId, job);
|
videoGenJobs.set(jobId, job);
|
||||||
processVideoGenJob(job);
|
processVideoGenJob(job);
|
||||||
|
|
|
||||||
|
|
@ -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
|
// Copy docs CSS
|
||||||
mkdirSync(resolve(__dirname, "dist/modules/rdocs"), { recursive: true });
|
mkdirSync(resolve(__dirname, "dist/modules/rdocs"), { recursive: true });
|
||||||
copyFileSync(
|
copyFileSync(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue