370 lines
8.7 KiB
HTML
370 lines
8.7 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>AI Image Playground</title>
|
|
<style>
|
|
:root {
|
|
color-scheme: light dark;
|
|
--bg: #0b0d10;
|
|
--panel: #14171c;
|
|
--border: #262a31;
|
|
--text: #e6e6e6;
|
|
--muted: #9aa3af;
|
|
--accent: #6366f1;
|
|
--accent-hover: #4f46e5;
|
|
--danger: #ef4444;
|
|
}
|
|
* {
|
|
box-sizing: border-box;
|
|
}
|
|
body {
|
|
margin: 0;
|
|
font-family:
|
|
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
min-height: 100vh;
|
|
}
|
|
.container {
|
|
max-width: 960px;
|
|
margin: 0 auto;
|
|
padding: 32px 20px 64px;
|
|
}
|
|
h1 {
|
|
margin: 0 0 8px;
|
|
font-size: 24px;
|
|
}
|
|
p.sub {
|
|
margin: 0 0 24px;
|
|
color: var(--muted);
|
|
font-size: 14px;
|
|
}
|
|
.panel {
|
|
background: var(--panel);
|
|
border: 1px solid var(--border);
|
|
border-radius: 12px;
|
|
padding: 20px;
|
|
}
|
|
.grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 12px;
|
|
}
|
|
label {
|
|
display: block;
|
|
font-size: 12px;
|
|
color: var(--muted);
|
|
margin-bottom: 6px;
|
|
}
|
|
input,
|
|
textarea,
|
|
select {
|
|
width: 100%;
|
|
background: #0b0d10;
|
|
color: var(--text);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 10px 12px;
|
|
font-size: 14px;
|
|
font-family: inherit;
|
|
}
|
|
input:focus,
|
|
textarea:focus,
|
|
select:focus {
|
|
outline: 2px solid var(--accent);
|
|
outline-offset: -1px;
|
|
}
|
|
textarea {
|
|
min-height: 96px;
|
|
resize: vertical;
|
|
}
|
|
.row {
|
|
margin-bottom: 12px;
|
|
}
|
|
.actions {
|
|
display: flex;
|
|
gap: 12px;
|
|
align-items: center;
|
|
margin-top: 16px;
|
|
}
|
|
button {
|
|
background: var(--accent);
|
|
color: white;
|
|
border: 0;
|
|
padding: 10px 16px;
|
|
border-radius: 8px;
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
}
|
|
button:hover {
|
|
background: var(--accent-hover);
|
|
}
|
|
button:disabled {
|
|
opacity: 0.6;
|
|
cursor: not-allowed;
|
|
}
|
|
.status {
|
|
font-size: 13px;
|
|
color: var(--muted);
|
|
}
|
|
.status.error {
|
|
color: var(--danger);
|
|
}
|
|
.ref-preview {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
margin-top: 8px;
|
|
}
|
|
.ref-preview:empty {
|
|
display: none;
|
|
}
|
|
.ref-preview .thumb {
|
|
position: relative;
|
|
width: 64px;
|
|
height: 64px;
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
border: 1px solid var(--border);
|
|
}
|
|
.ref-preview .thumb img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
display: block;
|
|
}
|
|
.ref-preview .thumb .remove {
|
|
position: absolute;
|
|
top: 2px;
|
|
right: 2px;
|
|
width: 18px;
|
|
height: 18px;
|
|
padding: 0;
|
|
border: 0;
|
|
border-radius: 50%;
|
|
background: rgba(0, 0, 0, 0.7);
|
|
color: white;
|
|
font-size: 12px;
|
|
line-height: 18px;
|
|
cursor: pointer;
|
|
font-weight: bold;
|
|
}
|
|
.hint {
|
|
display: block;
|
|
margin-top: 6px;
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
}
|
|
.hint code {
|
|
background: rgba(255, 255, 255, 0.06);
|
|
padding: 1px 4px;
|
|
border-radius: 4px;
|
|
}
|
|
input[type="file"] {
|
|
padding: 8px;
|
|
}
|
|
.result {
|
|
margin-top: 24px;
|
|
display: grid;
|
|
gap: 16px;
|
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
|
}
|
|
.result img {
|
|
width: 100%;
|
|
border-radius: 12px;
|
|
border: 1px solid var(--border);
|
|
display: block;
|
|
}
|
|
details {
|
|
margin-top: 12px;
|
|
font-size: 13px;
|
|
color: var(--muted);
|
|
}
|
|
summary {
|
|
cursor: pointer;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<h1>AI Image Playground</h1>
|
|
<p class="sub">
|
|
Generate images via any OpenAI-compatible endpoint using the Vercel AI SDK.
|
|
</p>
|
|
|
|
<div class="panel">
|
|
<div class="grid">
|
|
<div class="row">
|
|
<label for="baseURL">Base URL</label>
|
|
<input
|
|
id="baseURL"
|
|
type="text"
|
|
placeholder="https://api.openai.com/v1"
|
|
/>
|
|
</div>
|
|
<div class="row">
|
|
<label for="apiKey">API Key</label>
|
|
<input id="apiKey" type="password" placeholder="sk-..." />
|
|
</div>
|
|
<div class="row">
|
|
<label for="model">Model</label>
|
|
<input id="model" type="text" placeholder="gpt-image-2" />
|
|
</div>
|
|
<div class="row">
|
|
<label for="size">Size</label>
|
|
<select id="size">
|
|
<option value="1024x1024">1024x1024 (square)</option>
|
|
<option value="1536x1024">1536x1024 (landscape)</option>
|
|
<option value="1024x1536">1024x1536 (portrait)</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="row">
|
|
<label for="prompt">Prompt</label>
|
|
<textarea
|
|
id="prompt"
|
|
placeholder="A futuristic cityscape at sunset, cinematic lighting"
|
|
></textarea>
|
|
</div>
|
|
<div class="row">
|
|
<label for="refImages">Reference images (optional)</label>
|
|
<input id="refImages" type="file" accept="image/*" multiple />
|
|
<div id="refPreview" class="ref-preview"></div>
|
|
<small class="hint">
|
|
Provide one or more references to keep style consistent. When set,
|
|
the request is sent to <code>/v1/images/edits</code> (gpt-image series only).
|
|
</small>
|
|
</div>
|
|
<div class="actions">
|
|
<button id="generate">Generate</button>
|
|
<span id="status" class="status"></span>
|
|
</div>
|
|
<details>
|
|
<summary>Settings are stored in your browser's localStorage</summary>
|
|
Base URL, API Key, model and size are saved locally. They are sent to
|
|
the local Bun server only when you click Generate.
|
|
</details>
|
|
</div>
|
|
|
|
<div id="result" class="result"></div>
|
|
</div>
|
|
|
|
<script>
|
|
const $ = (id) => document.getElementById(id);
|
|
const fields = ["baseURL", "apiKey", "model", "size", "prompt"];
|
|
|
|
for (const f of fields) {
|
|
const saved = localStorage.getItem("aip:" + f);
|
|
if (saved) $(f).value = saved;
|
|
$(f).addEventListener("input", () => {
|
|
localStorage.setItem("aip:" + f, $(f).value);
|
|
});
|
|
$(f).addEventListener("change", () => {
|
|
localStorage.setItem("aip:" + f, $(f).value);
|
|
});
|
|
}
|
|
|
|
const refImages = [];
|
|
|
|
function renderRefPreview() {
|
|
const c = $("refPreview");
|
|
c.innerHTML = "";
|
|
refImages.forEach((src, i) => {
|
|
const thumb = document.createElement("div");
|
|
thumb.className = "thumb";
|
|
const img = document.createElement("img");
|
|
img.src = src;
|
|
img.alt = "reference " + (i + 1);
|
|
const btn = document.createElement("button");
|
|
btn.type = "button";
|
|
btn.className = "remove";
|
|
btn.textContent = "\u00d7";
|
|
btn.title = "Remove";
|
|
btn.onclick = () => {
|
|
refImages.splice(i, 1);
|
|
renderRefPreview();
|
|
};
|
|
thumb.appendChild(img);
|
|
thumb.appendChild(btn);
|
|
c.appendChild(thumb);
|
|
});
|
|
}
|
|
|
|
$("refImages").addEventListener("change", async (e) => {
|
|
const input = e.target;
|
|
const files = Array.from(input.files || []);
|
|
for (const file of files) {
|
|
if (!file.type.startsWith("image/")) continue;
|
|
const dataUrl = await new Promise((resolve, reject) => {
|
|
const r = new FileReader();
|
|
r.onload = () => resolve(r.result);
|
|
r.onerror = () => reject(r.error);
|
|
r.readAsDataURL(file);
|
|
});
|
|
refImages.push(dataUrl);
|
|
}
|
|
renderRefPreview();
|
|
input.value = "";
|
|
});
|
|
|
|
$("generate").addEventListener("click", async () => {
|
|
const btn = $("generate");
|
|
const status = $("status");
|
|
const result = $("result");
|
|
|
|
const body = {
|
|
baseURL: $("baseURL").value.trim(),
|
|
apiKey: $("apiKey").value.trim(),
|
|
model: $("model").value.trim(),
|
|
size: $("size").value,
|
|
prompt: $("prompt").value.trim(),
|
|
referenceImages: refImages,
|
|
};
|
|
|
|
if (!body.baseURL || !body.apiKey || !body.model || !body.prompt) {
|
|
status.textContent = "Please fill in Base URL, API Key, Model and Prompt.";
|
|
status.className = "status error";
|
|
return;
|
|
}
|
|
|
|
btn.disabled = true;
|
|
status.className = "status";
|
|
status.textContent = "Generating...";
|
|
result.innerHTML = "";
|
|
|
|
try {
|
|
const res = await fetch("/api/generate", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || ("HTTP " + res.status));
|
|
|
|
if (!data.images?.length) {
|
|
status.textContent = "No images returned.";
|
|
status.className = "status error";
|
|
return;
|
|
}
|
|
|
|
for (const src of data.images) {
|
|
const img = document.createElement("img");
|
|
img.src = src;
|
|
img.alt = body.prompt;
|
|
result.appendChild(img);
|
|
}
|
|
status.textContent = "Done.";
|
|
} catch (err) {
|
|
status.textContent = "Error: " + (err.message || String(err));
|
|
status.className = "status error";
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|