rspace-online/lib/folk-video-gen.ts

567 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { FolkShape } from "./folk-shape";
import { css, html } from "./tags";
const styles = css`
:host {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
min-width: 400px;
min-height: 500px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: linear-gradient(135deg, #f97316, #dc2626);
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;
}
.mode-tabs {
display: flex;
border-bottom: 1px solid #e2e8f0;
}
.mode-tab {
flex: 1;
padding: 10px;
border: none;
background: none;
cursor: pointer;
font-size: 13px;
font-weight: 500;
color: #64748b;
border-bottom: 2px solid transparent;
transition: all 0.2s;
}
.mode-tab:hover {
color: #1e293b;
}
.mode-tab.active {
color: #f97316;
border-bottom-color: #f97316;
}
.input-area {
padding: 12px;
border-bottom: 1px solid #e2e8f0;
}
.image-upload {
border: 2px dashed #e2e8f0;
border-radius: 8px;
padding: 24px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
}
.image-upload:hover {
border-color: #f97316;
background: #fff7ed;
}
.image-upload.has-image {
padding: 8px;
}
.upload-icon {
font-size: 32px;
margin-bottom: 8px;
}
.upload-text {
color: #64748b;
font-size: 13px;
}
.uploaded-image {
max-width: 100%;
max-height: 150px;
border-radius: 6px;
}
.prompt-input {
width: 100%;
padding: 10px 12px;
border: 2px solid #e2e8f0;
border-radius: 8px;
font-size: 13px;
resize: none;
outline: none;
font-family: inherit;
margin-top: 8px;
}
.prompt-input:focus {
border-color: #f97316;
}
.controls {
display: flex;
gap: 8px;
margin-top: 8px;
align-items: center;
}
.duration-select {
padding: 6px 10px;
border: 2px solid #e2e8f0;
border-radius: 6px;
font-size: 12px;
background: white;
cursor: pointer;
}
.generate-btn {
flex: 1;
padding: 8px 16px;
background: linear-gradient(135deg, #f97316, #dc2626);
color: white;
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
.generate-btn:hover {
opacity: 0.9;
}
.generate-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.video-area {
flex: 1;
padding: 12px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
}
.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;
}
.generated-video {
width: 100%;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.video-item {
position: relative;
}
.video-prompt {
font-size: 11px;
color: #64748b;
margin-top: 4px;
padding: 4px 8px;
background: #f1f5f9;
border-radius: 4px;
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px;
gap: 12px;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid #e2e8f0;
border-top-color: #f97316;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.progress-bar {
width: 200px;
height: 4px;
background: #e2e8f0;
border-radius: 2px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(135deg, #f97316, #dc2626);
transition: width 0.3s;
}
.error {
color: #ef4444;
padding: 12px;
background: #fef2f2;
border-radius: 6px;
font-size: 13px;
}
.hidden-input {
display: none;
}
`;
export interface GeneratedVideo {
id: string;
prompt: string;
url: string;
sourceImage?: string;
duration: number;
timestamp: Date;
}
declare global {
interface HTMLElementTagNameMap {
"folk-video-gen": FolkVideoGen;
}
}
export class FolkVideoGen extends FolkShape {
static override tagName = "folk-video-gen";
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;
}
#videos: GeneratedVideo[] = [];
#mode: "i2v" | "t2v" = "i2v";
#sourceImage: string | null = null;
#isLoading = false;
#progress = 0;
#error: string | null = null;
#promptInput: HTMLTextAreaElement | null = null;
#durationSelect: HTMLSelectElement | null = null;
#videoArea: HTMLElement | null = null;
#generateBtn: HTMLButtonElement | null = null;
#imageUpload: HTMLElement | null = null;
#fileInput: HTMLInputElement | null = null;
get videos() {
return this.#videos;
}
override createRenderRoot() {
const root = super.createRenderRoot();
const wrapper = document.createElement("div");
wrapper.innerHTML = html`
<div class="header">
<span class="header-title">
<span>🎬</span>
<span>Video Gen</span>
</span>
<div class="header-actions">
<button class="close-btn" title="Close">×</button>
</div>
</div>
<div class="content">
<div class="mode-tabs">
<button class="mode-tab active" data-mode="i2v">Image to Video</button>
<button class="mode-tab" data-mode="t2v">Text to Video</button>
</div>
<div class="input-area">
<div class="image-upload">
<div class="upload-icon">📷</div>
<div class="upload-text">Click to upload source image</div>
</div>
<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>
<button class="generate-btn">Generate Video</button>
</div>
</div>
<div class="video-area">
<div class="placeholder">
<span class="placeholder-icon">🎬</span>
<span>Upload an image and describe the motion</span>
</div>
</div>
</div>
`;
// Replace the container div (slot's parent) with our wrapper
const slot = root.querySelector("slot");
const containerDiv = slot?.parentElement as HTMLElement;
if (containerDiv) {
containerDiv.replaceWith(wrapper);
}
this.#promptInput = wrapper.querySelector(".prompt-input");
this.#durationSelect = wrapper.querySelector(".duration-select");
this.#videoArea = wrapper.querySelector(".video-area");
this.#generateBtn = wrapper.querySelector(".generate-btn");
this.#imageUpload = wrapper.querySelector(".image-upload");
this.#fileInput = wrapper.querySelector(".hidden-input");
const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
const modeTabs = wrapper.querySelectorAll(".mode-tab");
// Mode tab switching
modeTabs.forEach((tab) => {
tab.addEventListener("click", (e) => {
e.stopPropagation();
const mode = (tab as HTMLElement).dataset.mode as "i2v" | "t2v";
this.#setMode(mode);
modeTabs.forEach((t) => t.classList.remove("active"));
tab.classList.add("active");
});
});
// Image upload
this.#imageUpload?.addEventListener("click", (e) => {
e.stopPropagation();
this.#fileInput?.click();
});
this.#fileInput?.addEventListener("change", (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) this.#handleImageUpload(file);
});
// Generate button
this.#generateBtn?.addEventListener("click", (e) => {
e.stopPropagation();
this.#generate();
});
// Prevent drag on inputs
this.#promptInput?.addEventListener("pointerdown", (e) => e.stopPropagation());
// Close button
closeBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.dispatchEvent(new CustomEvent("close"));
});
return root;
}
#setMode(mode: "i2v" | "t2v") {
this.#mode = mode;
if (this.#imageUpload) {
this.#imageUpload.style.display = mode === "i2v" ? "block" : "none";
}
}
#handleImageUpload(file: File) {
const reader = new FileReader();
reader.onload = (e) => {
this.#sourceImage = e.target?.result as string;
if (this.#imageUpload) {
this.#imageUpload.classList.add("has-image");
this.#imageUpload.innerHTML = `<img class="uploaded-image" src="${this.#sourceImage}" alt="Source" />`;
}
};
reader.readAsDataURL(file);
}
async #generate() {
const prompt = this.#promptInput?.value.trim();
if (!prompt || this.#isLoading) return;
if (this.#mode === "i2v" && !this.#sourceImage) {
this.#error = "Please upload a source image first";
this.#renderError();
return;
}
const duration = Number.parseInt(this.#durationSelect?.value || "4", 10);
this.#isLoading = true;
this.#error = null;
this.#progress = 0;
if (this.#generateBtn) this.#generateBtn.disabled = true;
this.#renderLoading();
try {
const endpoint = this.#mode === "i2v" ? "/api/video-gen/i2v" : "/api/video-gen/t2v";
const body =
this.#mode === "i2v"
? { image: this.#sourceImage, prompt, duration }
: { prompt, duration };
const response = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`Generation failed: ${response.statusText}`);
}
const result = await response.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(),
};
this.#videos.unshift(video);
this.#renderVideos();
this.dispatchEvent(new CustomEvent("video-generated", { detail: { video } }));
// Clear input
if (this.#promptInput) this.#promptInput.value = "";
} catch (error) {
this.#error = error instanceof Error ? error.message : "Generation failed";
this.#renderError();
} finally {
this.#isLoading = false;
if (this.#generateBtn) this.#generateBtn.disabled = false;
}
}
#renderLoading() {
if (!this.#videoArea) return;
this.#videoArea.innerHTML = `
<div class="loading">
<div class="spinner"></div>
<span>Generating video...</span>
<div class="progress-bar">
<div class="progress-fill" style="width: ${this.#progress}%"></div>
</div>
<span style="font-size: 11px; color: #64748b;">This may take 30-60 seconds</span>
</div>
`;
}
#renderError() {
if (!this.#videoArea) return;
this.#videoArea.innerHTML = `
<div class="error">${this.#escapeHtml(this.#error || "Unknown error")}</div>
${this.#videos.length > 0 ? this.#renderVideoList() : '<div class="placeholder"><span class="placeholder-icon">\u{1F3AC}</span><span>Try again</span></div>'}
`;
}
#renderVideos() {
if (!this.#videoArea) return;
if (this.#videos.length === 0) {
this.#videoArea.innerHTML = `
<div class="placeholder">
<span class="placeholder-icon">\u{1F3AC}</span>
<span>Upload an image and describe the motion</span>
</div>
`;
return;
}
this.#videoArea.innerHTML = this.#renderVideoList();
}
#renderVideoList(): string {
return this.#videos
.map(
(vid) => `
<div class="video-item">
<video class="generated-video" src="${this.#escapeHtml(vid.url)}" controls autoplay loop muted playsinline></video>
<div class="video-prompt">${this.#escapeHtml(vid.prompt)}</div>
</div>
`
)
.join("");
}
#escapeHtml(text: string): string {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
override toJSON() {
return {
...super.toJSON(),
type: "folk-video-gen",
mode: this.#mode,
videos: this.videos.map((vid) => ({
...vid,
timestamp: vid.timestamp.toISOString(),
})),
};
}
}