rspace-online/lib/folk-image-studio.ts

794 lines
20 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: 420px;
min-height: 520px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: linear-gradient(135deg, #f59e0b, #ec4899);
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-y: auto;
}
.section {
padding: 12px;
border-bottom: 1px solid #e2e8f0;
}
.section-label {
font-size: 11px;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
}
/* Brand references */
.upload-zone {
border: 2px dashed #cbd5e1;
border-radius: 8px;
padding: 16px;
text-align: center;
cursor: pointer;
font-size: 12px;
color: #94a3b8;
transition: border-color 0.2s;
}
.upload-zone:hover, .upload-zone.drag-over {
border-color: #f59e0b;
color: #f59e0b;
}
.thumb-grid {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.thumb {
position: relative;
width: 60px;
height: 60px;
border-radius: 6px;
overflow: hidden;
}
.thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.thumb .remove-btn {
position: absolute;
top: 2px;
right: 2px;
width: 16px;
height: 16px;
background: rgba(0,0,0,0.6);
color: white;
border: none;
border-radius: 50%;
font-size: 10px;
line-height: 16px;
text-align: center;
cursor: pointer;
padding: 0;
}
.analyze-btn, .generate-btn {
padding: 8px 16px;
background: linear-gradient(135deg, #f59e0b, #ec4899);
color: white;
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
.analyze-btn:hover, .generate-btn:hover {
opacity: 0.9;
}
.analyze-btn:disabled, .generate-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Style analysis display */
.style-display {
margin-top: 8px;
padding: 10px;
background: #fefce8;
border-radius: 6px;
font-size: 12px;
}
.palette {
display: flex;
gap: 4px;
margin: 6px 0;
}
.swatch {
width: 24px;
height: 24px;
border-radius: 4px;
border: 1px solid #e2e8f0;
}
.keywords {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin: 6px 0;
}
.keyword-tag {
padding: 2px 8px;
background: #fef3c7;
border-radius: 12px;
font-size: 11px;
color: #92400e;
}
.brand-prefix {
width: 100%;
padding: 6px 8px;
border: 1px solid #e2e8f0;
border-radius: 4px;
font-size: 11px;
resize: vertical;
font-family: inherit;
min-height: 40px;
}
/* Source + Controls */
.source-preview {
margin-top: 8px;
}
.source-preview img {
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: #ec4899;
}
.controls {
display: flex;
gap: 8px;
margin-top: 8px;
align-items: center;
flex-wrap: wrap;
}
.provider-select {
padding: 6px 10px;
border: 2px solid #e2e8f0;
border-radius: 6px;
font-size: 12px;
background: white;
cursor: pointer;
}
.strength-group {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: #64748b;
}
.strength-slider {
width: 80px;
}
.brand-toggle {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: #64748b;
}
/* Results */
.image-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;
padding: 24px;
}
.placeholder-icon {
font-size: 48px;
opacity: 0.5;
}
.image-item {
position: relative;
}
.generated-image {
width: 100%;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.image-meta {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: #64748b;
margin-top: 4px;
padding: 4px 8px;
background: #f1f5f9;
border-radius: 4px;
}
.provider-badge {
padding: 1px 6px;
border-radius: 8px;
font-size: 10px;
font-weight: 600;
color: white;
}
.provider-badge.fal { background: #8b5cf6; }
.provider-badge.gemini { background: #0ea5e9; }
.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: #ec4899;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error {
color: #ef4444;
padding: 12px;
background: #fef2f2;
border-radius: 6px;
font-size: 13px;
}
`;
interface StyleAnalysis {
style_description: string;
color_palette: string[];
style_keywords: string[];
brand_prompt_prefix: string;
}
interface StudioResult {
id: string;
prompt: string;
url: string;
provider: string;
timestamp: string;
}
declare global {
interface HTMLElementTagNameMap {
"folk-image-studio": FolkImageStudio;
}
}
export class FolkImageStudio extends FolkShape {
static override tagName = "folk-image-studio";
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;
}
#brandReferences: string[] = []; // server paths or data URLs
#styleAnalysis: StyleAnalysis | null = null;
#sourceImage: string | null = null;
#results: StudioResult[] = [];
#provider: string = "fal";
#strength = 0.85;
#applyBrandStyle = true;
#isAnalyzing = false;
#isGenerating = false;
#error: string | null = null;
// DOM refs
#wrapper: HTMLElement | null = null;
#refUploadInput: HTMLInputElement | null = null;
#sourceUploadInput: HTMLInputElement | null = null;
#promptInput: HTMLTextAreaElement | null = null;
#brandPrefixInput: HTMLTextAreaElement | null = null;
override createRenderRoot() {
const root = super.createRenderRoot();
this.#wrapper = document.createElement("div");
this.#wrapper.innerHTML = this.#renderAll();
const slot = root.querySelector("slot");
const containerDiv = slot?.parentElement as HTMLElement;
if (containerDiv) containerDiv.replaceWith(this.#wrapper);
this.#bindEvents();
return root;
}
#renderAll(): string {
return html`
<div class="header">
<span class="header-title">
<span>🖌️</span>
<span>Image Studio</span>
</span>
<div class="header-actions">
<button class="close-btn" title="Close">×</button>
</div>
</div>
<div class="content">
<!-- Brand References -->
<div class="section">
<div class="section-label">Brand References</div>
<div class="upload-zone ref-upload-zone">
Click or drag images to add brand references
</div>
<input type="file" class="ref-upload-input" accept="image/*" multiple hidden />
${this.#renderThumbs()}
${this.#brandReferences.length >= 1
? `<div style="margin-top:8px"><button class="analyze-btn" ${this.#isAnalyzing ? "disabled" : ""}>${this.#isAnalyzing ? "Analyzing..." : "Analyze Style"}</button></div>`
: ""}
${this.#renderStyleDisplay()}
</div>
<!-- Source + Controls -->
<div class="section">
<div class="section-label">Source Image + Prompt</div>
<div class="upload-zone source-upload-zone" style="padding:10px;">
${this.#sourceImage
? `<div class="source-preview"><img src="${this.#escapeHtml(this.#sourceImage)}" /></div>`
: "Click or drag a source image"}
</div>
<input type="file" class="source-upload-input" accept="image/*" hidden />
<textarea class="prompt-input" placeholder="Describe how to transform the image..." rows="2"></textarea>
<div class="controls">
<select class="provider-select">
<option value="fal" ${this.#provider === "fal" ? "selected" : ""}>fal.ai FLUX</option>
<option value="gemini" ${this.#provider === "gemini" ? "selected" : ""}>Nano Banana</option>
<option value="gemini-pro" ${this.#provider === "gemini-pro" ? "selected" : ""}>Nano Banana Pro</option>
<option value="gemini-flash-2" ${this.#provider === "gemini-flash-2" ? "selected" : ""}>Nano Banana 2</option>
</select>
<div class="strength-group" style="${this.#provider === "fal" ? "" : "display:none"}">
<label>Strength</label>
<input type="range" class="strength-slider" min="0" max="1" step="0.05" value="${this.#strength}" />
<span class="strength-val">${this.#strength}</span>
</div>
<label class="brand-toggle">
<input type="checkbox" class="brand-check" ${this.#applyBrandStyle ? "checked" : ""} />
Apply Brand Style
</label>
<button class="generate-btn" ${this.#isGenerating ? "disabled" : ""}>${this.#isGenerating ? "Generating..." : "Generate"}</button>
</div>
</div>
<!-- Results -->
<div class="image-area">
${this.#error ? `<div class="error">${this.#escapeHtml(this.#error)}</div>` : ""}
${this.#isGenerating ? '<div class="loading"><div class="spinner"></div><span>Generating image...</span></div>' : ""}
${this.#results.length > 0 ? this.#renderImageList() : (!this.#isGenerating ? '<div class="placeholder"><span class="placeholder-icon">🖌️</span><span>Upload a source image and generate</span></div>' : "")}
</div>
</div>
`;
}
#renderThumbs(): string {
if (!this.#brandReferences.length) return "";
return `<div class="thumb-grid">${this.#brandReferences
.map(
(ref, i) => `<div class="thumb">
<img src="${this.#escapeHtml(ref)}" />
<button class="remove-btn" data-ref-index="${i}">×</button>
</div>`
)
.join("")}</div>`;
}
#renderStyleDisplay(): string {
if (!this.#styleAnalysis) return "";
const s = this.#styleAnalysis;
return `
<div class="style-display">
<div style="margin-bottom:4px;font-weight:600;">Style Analysis</div>
<div style="margin-bottom:6px;">${this.#escapeHtml(s.style_description)}</div>
<div class="palette">${s.color_palette.map((c) => `<div class="swatch" style="background:${c}" title="${c}"></div>`).join("")}</div>
<div class="keywords">${s.style_keywords.map((k) => `<span class="keyword-tag">${this.#escapeHtml(k)}</span>`).join("")}</div>
<div class="section-label" style="margin-top:6px;">Brand Prompt Prefix</div>
<textarea class="brand-prefix">${this.#escapeHtml(s.brand_prompt_prefix)}</textarea>
</div>
`;
}
#renderImageList(): string {
return this.#results
.map(
(r) => `
<div class="image-item">
<img class="generated-image" src="${this.#escapeHtml(r.url)}" loading="lazy" />
<div class="image-meta">
<span class="provider-badge ${r.provider === "fal" ? "fal" : "gemini"}">${this.#escapeHtml(r.provider)}</span>
<span>${this.#escapeHtml(r.prompt)}</span>
</div>
</div>
`
)
.join("");
}
#rerender() {
if (!this.#wrapper) return;
this.#wrapper.innerHTML = this.#renderAll();
this.#bindEvents();
}
#bindEvents() {
if (!this.#wrapper) return;
const w = this.#wrapper;
this.#refUploadInput = w.querySelector(".ref-upload-input");
this.#sourceUploadInput = w.querySelector(".source-upload-input");
this.#promptInput = w.querySelector(".prompt-input");
this.#brandPrefixInput = w.querySelector(".brand-prefix");
// Close
w.querySelector(".close-btn")?.addEventListener("click", (e) => {
e.stopPropagation();
this.dispatchEvent(new CustomEvent("close"));
});
// Ref upload zone
const refZone = w.querySelector(".ref-upload-zone") as HTMLElement | null;
refZone?.addEventListener("click", (e) => {
e.stopPropagation();
this.#refUploadInput?.click();
});
refZone?.addEventListener("dragover", (e) => { e.preventDefault(); refZone.classList.add("drag-over"); });
refZone?.addEventListener("dragleave", () => refZone.classList.remove("drag-over"));
refZone?.addEventListener("drop", (e) => {
e.preventDefault();
refZone.classList.remove("drag-over");
if (e.dataTransfer?.files) this.#handleRefFiles(e.dataTransfer.files);
});
this.#refUploadInput?.addEventListener("change", () => {
if (this.#refUploadInput?.files) this.#handleRefFiles(this.#refUploadInput.files);
});
// Source upload zone
const srcZone = w.querySelector(".source-upload-zone") as HTMLElement | null;
srcZone?.addEventListener("click", (e) => {
e.stopPropagation();
this.#sourceUploadInput?.click();
});
srcZone?.addEventListener("dragover", (e) => { e.preventDefault(); srcZone.classList.add("drag-over"); });
srcZone?.addEventListener("dragleave", () => srcZone.classList.remove("drag-over"));
srcZone?.addEventListener("drop", (e) => {
e.preventDefault();
srcZone.classList.remove("drag-over");
if (e.dataTransfer?.files?.[0]) this.#handleSourceFile(e.dataTransfer.files[0]);
});
this.#sourceUploadInput?.addEventListener("change", () => {
if (this.#sourceUploadInput?.files?.[0]) this.#handleSourceFile(this.#sourceUploadInput.files[0]);
});
// Remove ref buttons
w.querySelectorAll(".remove-btn[data-ref-index]").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const idx = parseInt((btn as HTMLElement).dataset.refIndex || "0", 10);
this.#brandReferences.splice(idx, 1);
this.#rerender();
});
});
// Analyze button
w.querySelector(".analyze-btn")?.addEventListener("click", (e) => {
e.stopPropagation();
this.#analyzeStyle();
});
// Provider select
const provSelect = w.querySelector(".provider-select") as HTMLSelectElement | null;
provSelect?.addEventListener("change", () => {
this.#provider = provSelect.value;
this.#rerender();
});
// Strength slider
const slider = w.querySelector(".strength-slider") as HTMLInputElement | null;
slider?.addEventListener("input", () => {
this.#strength = parseFloat(slider.value);
const valEl = w.querySelector(".strength-val");
if (valEl) valEl.textContent = String(this.#strength);
});
// Brand toggle
const brandCheck = w.querySelector(".brand-check") as HTMLInputElement | null;
brandCheck?.addEventListener("change", () => {
this.#applyBrandStyle = brandCheck.checked;
});
// Brand prefix textarea
this.#brandPrefixInput?.addEventListener("input", () => {
if (this.#styleAnalysis && this.#brandPrefixInput) {
this.#styleAnalysis.brand_prompt_prefix = this.#brandPrefixInput.value;
}
});
// Generate button
w.querySelector(".generate-btn")?.addEventListener("click", (e) => {
e.stopPropagation();
this.#generate();
});
// Prevent drag on interactive elements
this.#promptInput?.addEventListener("pointerdown", (e) => e.stopPropagation());
this.#brandPrefixInput?.addEventListener("pointerdown", (e) => e.stopPropagation());
slider?.addEventListener("pointerdown", (e) => e.stopPropagation());
provSelect?.addEventListener("pointerdown", (e) => e.stopPropagation());
brandCheck?.addEventListener("pointerdown", (e) => e.stopPropagation());
}
#fileToDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
async #handleRefFiles(files: FileList) {
for (const file of Array.from(files)) {
if (!file.type.startsWith("image/")) continue;
try {
// Upload to server for persistence
const dataUrl = await this.#fileToDataUrl(file);
const res = await fetch("/api/image-upload", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ image: dataUrl }),
});
if (res.ok) {
const { url } = await res.json();
this.#brandReferences.push(url);
} else {
this.#brandReferences.push(dataUrl);
}
} catch {
const dataUrl = await this.#fileToDataUrl(file);
this.#brandReferences.push(dataUrl);
}
}
this.#rerender();
}
async #handleSourceFile(file: File) {
if (!file.type.startsWith("image/")) return;
try {
const dataUrl = await this.#fileToDataUrl(file);
const res = await fetch("/api/image-upload", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ image: dataUrl }),
});
if (res.ok) {
const { url } = await res.json();
this.#sourceImage = url;
} else {
this.#sourceImage = dataUrl;
}
} catch {
this.#sourceImage = await this.#fileToDataUrl(file);
}
this.#rerender();
}
async #analyzeStyle() {
if (this.#isAnalyzing || !this.#brandReferences.length) return;
this.#isAnalyzing = true;
this.#error = null;
this.#rerender();
try {
const res = await fetch("/api/image-gen/analyze-style", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ images: this.#brandReferences }),
});
if (!res.ok) throw new Error("Style analysis failed");
this.#styleAnalysis = await res.json();
} catch (e: any) {
this.#error = e.message || "Analysis failed";
} finally {
this.#isAnalyzing = false;
this.#rerender();
}
}
async #generate() {
const prompt = this.#promptInput?.value.trim();
if (!prompt || !this.#sourceImage || this.#isGenerating) return;
this.#isGenerating = true;
this.#error = null;
this.#rerender();
try {
let fullPrompt = prompt;
if (this.#applyBrandStyle && this.#styleAnalysis?.brand_prompt_prefix) {
fullPrompt = this.#styleAnalysis.brand_prompt_prefix + " " + prompt;
}
const isGemini = this.#provider !== "fal";
const body: any = {
prompt: fullPrompt,
source_image: this.#sourceImage,
provider: isGemini ? "gemini" : "fal",
strength: this.#strength,
};
if (isGemini && this.#provider !== "gemini") {
body.model = this.#provider; // "gemini-pro" or "gemini-flash-2"
}
if (this.#brandReferences.length) {
body.reference_images = this.#brandReferences;
}
const res = await fetch("/api/image-gen/img2img", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error("Generation failed");
const data = await res.json();
const url = data.url || data.image_url;
if (!url) throw new Error("No image returned");
this.#results.unshift({
id: crypto.randomUUID(),
prompt,
url,
provider: this.#provider,
timestamp: new Date().toISOString(),
});
if (this.#promptInput) this.#promptInput.value = "";
} catch (e: any) {
this.#error = e.message || "Generation failed";
} finally {
this.#isGenerating = false;
this.#rerender();
}
}
#escapeHtml(text: string): string {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
override toJSON() {
return {
...super.toJSON(),
type: "folk-image-studio",
provider: this.#provider,
strength: this.#strength,
styleAnalysis: this.#styleAnalysis,
brandReferences: this.#brandReferences,
results: this.#results,
};
}
}