flint-image / index.html
erenkycn's picture
Enable flint-image 4k tab (remove coming soon gate)
9a236cb verified
Raw
History Blame Contribute Delete
38.7 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Flint Image — each::labs</title>
<style>
:root {
--bg: #000000;
--panel: #0a0a0a;
--panel2: #101010;
--border: #1f1f1f;
--text: #f5f0e6;
--muted: #a8a39a;
--faint: #6e6a63;
--accent: #ff3c15;
--accent-dim: #d63310;
--glow: #fb9000;
--err: #ef4444;
--radius: 12px;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background: var(--bg);
color: var(--text);
min-height: 100vh;
background-image:
radial-gradient(
ellipse 70% 50% at 12% -8%,
rgba(255, 60, 21, 0.07),
transparent 60%
),
radial-gradient(
ellipse 55% 35% at 88% 30%,
rgba(255, 60, 21, 0.04),
transparent 65%
);
background-attachment: fixed;
font-family:
ui-sans-serif,
-apple-system,
"Segoe UI",
Roboto,
Helvetica,
Arial,
sans-serif;
}
.wrap {
max-width: 1160px;
margin: 0 auto;
padding: 30px 20px 60px;
}
header {
margin-bottom: 6px;
}
h1 {
font-size: 27px;
letter-spacing: -0.5px;
display: flex;
align-items: center;
gap: 12px;
}
h1 .mark {
width: 26px;
height: 26px;
flex-shrink: 0;
}
h1 .spark {
color: var(--accent);
}
.sub {
color: var(--muted);
font-size: 14px;
margin: 8px 0 22px;
line-height: 1.55;
}
.sub a {
color: var(--accent-dim);
}
.sub b {
color: var(--text);
}
.keyrow {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 24px;
background: rgba(10, 10, 10, 0.8);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 13px 15px;
}
.keyrow label {
font-size: 13px;
color: var(--muted);
white-space: nowrap;
}
.keyrow input {
flex: 1;
min-width: 220px;
}
.keyrow .hint {
font-size: 12px;
color: var(--faint);
width: 100%;
}
.keyrow .hint a {
color: var(--accent-dim);
}
.tabs {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.tab {
background: transparent;
border: 1px solid var(--border);
border-bottom: none;
color: var(--muted);
padding: 10px 18px;
border-radius: 10px 10px 0 0;
cursor: pointer;
font-size: 13.5px;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
letter-spacing: -0.2px;
transition:
color 0.15s,
background 0.15s;
}
.tab:hover {
color: var(--text);
}
.tab.active {
background: var(--panel2);
color: var(--accent);
border-color: #2a2a2a;
box-shadow: inset 0 2px 0 var(--accent);
}
.panel {
display: none;
position: relative;
background: var(--panel2);
border: 1px solid #2a2a2a;
border-radius: 0 var(--radius) var(--radius) var(--radius);
padding: 22px;
overflow: hidden;
}
.panel.active {
display: block;
}
.panel-content {
display: grid;
grid-template-columns: 360px 1fr;
gap: 22px;
}
.panel.coming-soon {
min-height: 520px;
}
.panel.coming-soon .panel-content {
filter: blur(7px);
opacity: 0.38;
pointer-events: none;
user-select: none;
}
.coming-soon-overlay {
display: none;
position: absolute;
inset: 0;
z-index: 10;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.25);
backdrop-filter: blur(1px);
-webkit-backdrop-filter: blur(1px);
}
.panel.coming-soon.active .coming-soon-overlay {
display: flex;
}
.coming-soon-message {
padding: 16px 30px;
border: 1px solid rgba(255, 60, 21, 0.5);
border-radius: 999px;
background: rgba(10, 10, 10, 0.94);
color: #fff;
font-size: 19px;
font-weight: 700;
letter-spacing: -0.3px;
box-shadow:
0 0 45px rgba(255, 60, 21, 0.17),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
@media (max-width: 840px) {
.panel-content {
grid-template-columns: 1fr;
}
}
.desc {
grid-column: 1 / -1;
color: var(--muted);
font-size: 13px;
line-height: 1.55;
border-bottom: 1px solid var(--border);
padding-bottom: 14px;
}
.controls {
display: flex;
flex-direction: column;
gap: 13px;
}
label.fld {
font-size: 12px;
color: var(--faint);
display: block;
margin-bottom: 5px;
text-transform: uppercase;
letter-spacing: 0.4px;
}
textarea,
input,
select {
width: 100%;
background: var(--panel);
color: var(--text);
border: 1px solid var(--border);
border-radius: 9px;
padding: 10px 12px;
font-size: 14px;
font-family: inherit;
transition: border-color 0.15s;
}
textarea {
resize: vertical;
min-height: 96px;
line-height: 1.5;
}
textarea:focus,
input:focus,
select:focus {
outline: none;
border-color: var(--accent-dim);
}
.row2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.seg {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.seg button {
background: var(--panel);
border: 1px solid var(--border);
color: var(--muted);
padding: 8px 13px;
border-radius: 999px;
font-size: 13px;
cursor: pointer;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
letter-spacing: -0.2px;
transition:
color 0.12s,
border-color 0.12s,
background 0.12s;
}
.seg button:hover {
color: var(--text);
border-color: #3a3a3a;
}
.seg button.sel {
border-color: var(--accent);
color: var(--text);
background: rgba(255, 60, 21, 0.12);
}
button.run {
background: var(--accent);
color: #fff;
font-weight: 700;
font-size: 15px;
border: none;
border-radius: 9px;
padding: 13px;
cursor: pointer;
margin-top: 2px;
transition:
background 0.15s,
transform 0.1s;
}
button.run:hover:not(:disabled) {
background: var(--accent-dim);
}
button.run:active:not(:disabled) {
transform: scale(0.99);
}
button.run:disabled {
background: var(--accent-dim);
opacity: 0.55;
cursor: wait;
}
button.small {
background: var(--panel);
color: var(--muted);
border: 1px dashed var(--border);
border-radius: 9px;
padding: 8px 12px;
cursor: pointer;
font-size: 13px;
transition:
color 0.15s,
border-color 0.15s;
}
button.small:hover {
color: var(--text);
border-color: var(--accent-dim);
}
.drop {
border: 2px dashed #2e2e2e;
border-radius: 10px;
padding: 22px 12px;
text-align: center;
color: var(--muted);
font-size: 13px;
cursor: pointer;
line-height: 1.6;
transition:
border-color 0.15s,
background 0.15s;
}
.drop:hover {
border-color: var(--accent-dim);
}
.drop.over {
border-color: var(--accent);
background: rgba(255, 60, 21, 0.07);
color: var(--text);
}
.drop .dicon {
font-size: 20px;
display: block;
margin-bottom: 2px;
color: var(--faint);
}
.thumbs {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(72px, 1fr));
gap: 8px;
margin-top: 10px;
}
.thumb {
position: relative;
border: 1px solid var(--border);
border-radius: 9px;
overflow: hidden;
aspect-ratio: 1;
}
.thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.thumb.uploading img {
opacity: 0.3;
}
.thumb .spin {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--glow);
font-size: 16px;
animation: pulse 1.2s infinite;
}
.thumb .x {
position: absolute;
top: 3px;
right: 3px;
background: rgba(0, 0, 0, 0.75);
color: #fff;
border: none;
border-radius: 50%;
width: 18px;
height: 18px;
font-size: 11px;
cursor: pointer;
line-height: 1;
}
.status {
font-size: 13px;
color: var(--muted);
min-height: 18px;
}
.status.err {
color: var(--err);
white-space: pre-wrap;
}
.status .dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
margin-right: 6px;
animation: pulse 1.2s infinite;
}
@keyframes pulse {
50% {
opacity: 0.25;
}
}
.out {
display: flex;
flex-direction: column;
gap: 10px;
align-content: start;
}
.gal-note {
font-size: 12px;
color: var(--faint);
display: flex;
align-items: center;
gap: 8px;
}
.gal-note .pill {
background: rgba(255, 60, 21, 0.12);
color: var(--accent);
border: 1px solid rgba(255, 60, 21, 0.25);
padding: 2px 9px;
border-radius: 999px;
font-size: 11px;
letter-spacing: 0.3px;
}
.gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 12px;
}
.gallery.single {
grid-template-columns: 1fr;
}
.gallery a {
display: block;
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
transition:
border-color 0.15s,
transform 0.15s;
}
.gallery a:hover {
border-color: var(--accent-dim);
transform: translateY(-2px);
}
.gallery img {
width: 100%;
display: block;
}
.loader {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
min-height: 320px;
color: var(--muted);
font-size: 13px;
}
.loader svg {
width: 52px;
height: 52px;
animation: spinlogo 1.5s cubic-bezier(0.6, 0.15, 0.4, 0.85) infinite;
}
@keyframes spinlogo {
to {
transform: rotate(360deg);
}
}
footer {
margin-top: 28px;
font-size: 12px;
color: var(--faint);
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
footer a {
color: var(--accent-dim);
}
footer .wordmark {
height: 13px;
width: auto;
display: inline-block;
vertical-align: -2px;
margin-left: 2px;
}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>
<svg
class="mark"
viewBox="0 0 9 9"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-label="each::labs"
>
<path
d="M0 8.39934V5.67973H2.74088V8.39934H0ZM0.0186447 2.71961V0H2.75953V2.71961H0.0186447Z"
fill="#FF3C15"
/>
<path
d="M5.30566 8.39934V5.67973H8.04654V8.39934H5.30566ZM5.32638 2.71961V0H8.06726V2.71961H5.32638Z"
fill="#FF3C15"
/>
</svg>
Flint Image
</h1>
</header>
<div class="sub">
<a
href="https://www.eachlabs.ai/eachlabs/flint-image/flint-image"
target="_blank"
rel="noreferrer"
>
<b>Flint Image</b>
</a>
is an
<a href="https://www.eachlabs.ai/" target="_blank" rel="noreferrer">
each::labs
</a>
BlackBox model, powered by Gemini, for text-to-image, image editing, and
4K generation and editing from a single prompt. It is cost-optimized to
run cheaper than standard generation, returning a full spread of results
in one call.
</div>
<div class="keyrow">
<label for="apikey">eachlabs API key</label>
<input
id="apikey"
type="password"
placeholder="each-…"
autocomplete="off"
/>
<div class="hint">
Stored only in this browser (localStorage), sent only to
<code>api.eachlabs.ai</code>. Get one at
<a
href="https://www.eachlabs.ai/api-keys"
target="_blank"
rel="noreferrer"
>
eachlabs.ai/api-keys </a
>.
</div>
</div>
<div class="tabs" id="tabs"></div>
<div id="panels"></div>
<footer>
Models:
<a
href="https://www.eachlabs.ai/eachlabs/flint-image/flint-image"
target="_blank"
rel="noreferrer"
>
flint-image
</a>
· flint-image-edit · flint-image-4k · flint-image-4k-edit
<span style="margin-left: auto">
powered by
<a href="https://www.eachlabs.ai" target="_blank" rel="noreferrer">
each::labs
</a>
</span>
</footer>
</div>
<script>
const API = "https://api.eachlabs.ai";
const VERSION = "0.0.1";
const RATIOS = ["1:1", "3:4", "4:3", "9:16", "16:9"];
const POLL_MS = 3000;
const TIMEOUT_MS = 600000;
const LOGO_SVG = `
<svg
viewBox="0 0 9 9"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-label="each::labs"
>
<path
d="M0 8.39934V5.67973H2.74088V8.39934H0ZM0.0186447 2.71961V0H2.75953V2.71961H0.0186447Z"
fill="#FF3C15"
/>
<path
d="M5.30566 8.39934V5.67973H8.04654V8.39934H5.30566ZM5.32638 2.71961V0H8.06726V2.71961H5.32638Z"
fill="#FF3C15"
/>
</svg>
`;
const EXAMPLES = {
gen: {
input: {
prompt:
"Low-angle fashion editorial portrait of a confident female model standing against a rich lavender background. She is wearing a tailored cobalt blue suit with oversized flowing sleeves and bold abstract floral patterns in burnt orange. Underneath, a soft ivory silk blouse. She has sleek braided hair, oversized translucent orange sunglasses, and statement metallic earrings. Dramatic studio lighting, ultra-sharp fabric texture, vibrant color contrast, high-fashion campaign aesthetic.",
aspect_ratio: "4:3",
output_format: "png",
},
output: [
"https://cdn-us.eachlabs.ai/uploads/21001205-cfaf-4c1a-97ac-6048a950ccae.png",
"https://cdn-us.eachlabs.ai/uploads/8e87d902-1ff3-48d3-946e-d4334bdccc27.png",
"https://cdn-us.eachlabs.ai/uploads/55e34fd5-e307-4129-b400-3f08761ee131.png",
"https://cdn-us.eachlabs.ai/uploads/1b4485a1-f2ca-4267-b51e-97e56b08c4fd.png",
],
},
edit: {
input: {
prompt:
"Keep all the details the same; just change the woman's outfit.",
image_urls: [
"https://cdn-us.eachlabs.ai/defaults/39233386ae87466f997a53e4e61d95ad.png",
"https://cdn-us.eachlabs.ai/defaults/0bfd9f83d41d4ccbb4b6b8cd7dd83d55.png",
],
aspect_ratio: "4:3",
output_format: "jpeg",
},
output: [
"https://cdn-us.eachlabs.ai/uploads/fa0fb6da-e40b-464d-a703-c5ce8fcee25d.jpg",
"https://cdn-us.eachlabs.ai/uploads/f5813756-c2d9-4e70-acca-f188190f260a.jpg",
"https://cdn-us.eachlabs.ai/uploads/ebd68217-5309-4abe-92cf-184bae3e5e91.jpg",
"https://cdn-us.eachlabs.ai/uploads/26a09053-2a16-4d7b-a5e9-8e64838157aa.jpg",
],
},
gen4k: {
input: {
prompt:
"Ultra-realistic interior of a bright contemporary living room dominated by warm white tones and soft natural materials. White plaster walls, elegant light oak flooring, and a spacious open layout filled with abundant daylight streaming through large floor-to-ceiling windows. Flowing white linen curtains featuring subtle embroidered botanical patterns gently frame the windows. A plush ivory sectional sofa with layered textured cushions sits atop a soft cream woven rug. Sculptural white coffee table, minimalist built-in shelving, ceramic vases, dried pampas grass, linen throws, and carefully curated neutral decor create a refined yet cozy atmosphere. Soft boucle armchair, warm wood accents, matte white finishes, delicate wall moldings, and tasteful architectural details. Bright, airy, luxurious Scandinavian-meets-modern aesthetic with premium interior design, clean composition, realistic shadows, highly detailed textures, natural sunlight, editorial interior photography, 24mm wide-angle lens, ultra-photorealistic",
aspect_ratio: "16:9",
output_format: "png",
},
output: [
"https://cdn-us.eachlabs.ai/defaults/605df7de53034ebca5b501cdd6be78b5.png",
],
},
edit4k: {
input: {
prompt:
"All the details in the room should remain the same; the pattern in the second uploaded image should be the pattern of the armchair",
image_urls: [
"https://cdn-us.eachlabs.ai/defaults/bae727b8e72644a08d6fc38caf2b957c.png",
"https://cdn-us.eachlabs.ai/defaults/93f9c9633d494074b6e4704d5be9c82a.png",
],
aspect_ratio: "16:9",
output_format: "png",
},
output: [
"https://cdn-us.eachlabs.ai/defaults/12ec02a11cfd4f109209b8f43c77140c.png",
],
},
};
const TABS = [
{
id: "gen",
label: "flint-image",
model: "flint-image",
edit: false,
multi: true,
comingSoon: false,
formats: ["webp", "png", "jpeg"],
defaults: {
output_format: "webp",
aspect_ratio: "1:1",
},
desc: "Flint Image generates four images from a text prompt, with selectable output format and aspect ratio controls.",
btn: "Generate 4 images",
},
{
id: "edit",
label: "flint-image-edit",
model: "flint-image-edit",
edit: true,
multi: true,
comingSoon: false,
formats: ["webp", "png", "jpeg"],
defaults: {
output_format: "webp",
aspect_ratio: "1:1",
},
desc: "Flint Image Edit transforms one to ten reference images into four edited variants, guided by a text prompt with format and aspect ratio controls.",
btn: "Edit — 4 variants",
},
{
id: "gen4k",
label: "flint-image-4k",
model: "spark-image-4k",
edit: false,
multi: false,
comingSoon: false,
formats: ["webp", "png", "jpg"],
defaults: {
output_format: "webp",
aspect_ratio: "1:1",
},
desc: "Flint Image 4K generates a single high-resolution image from a text prompt, with format and aspect ratio controls.",
btn: "Generate 4K image",
},
{
id: "edit4k",
label: "flint-image-4k-edit",
model: "spark-image-edit-4k",
edit: true,
multi: false,
comingSoon: false,
formats: ["webp", "png", "jpg"],
defaults: {
output_format: "webp",
aspect_ratio: "1:1",
},
desc: "Flint Image 4K Edit transforms one to ten source images into a single high-resolution result, guided by a text prompt with format and aspect ratio controls.",
btn: "Edit in 4K",
},
];
const $ = (selector, root = document) => root.querySelector(selector);
const keyInput = $("#apikey");
keyInput.value = localStorage.getItem("eachlabs_api_key") || "";
keyInput.addEventListener("change", () => {
localStorage.setItem("eachlabs_api_key", keyInput.value.trim());
});
const tabsEl = $("#tabs");
const panelsEl = $("#panels");
TABS.forEach((tabConfig, index) => {
const example = EXAMPLES[tabConfig.id] || {
input: {},
output: [],
};
const tabButton = document.createElement("button");
tabButton.className = "tab" + (index === 0 ? " active" : "");
tabButton.textContent = tabConfig.label;
tabButton.onclick = () => {
document.querySelectorAll(".tab").forEach((element) => {
element.classList.remove("active");
});
document.querySelectorAll(".panel").forEach((element) => {
element.classList.remove("active");
});
tabButton.classList.add("active");
$("#panel-" + tabConfig.id).classList.add("active");
};
tabsEl.appendChild(tabButton);
const exampleFormat = tabConfig.formats.includes(
example.input.output_format,
)
? example.input.output_format
: tabConfig.defaults.output_format;
const exampleRatio = RATIOS.includes(example.input.aspect_ratio)
? example.input.aspect_ratio
: tabConfig.defaults.aspect_ratio;
const panel = document.createElement("div");
panel.className =
"panel" +
(tabConfig.comingSoon ? " coming-soon" : "") +
(index === 0 ? " active" : "");
panel.id = "panel-" + tabConfig.id;
panel.innerHTML = `
<div class="panel-content">
<div class="desc">
${tabConfig.desc}
</div>
<div class="controls">
${
tabConfig.edit
? `
<div>
<label class="fld">
Images to edit (1–10)
</label>
<div
class="drop"
id="${tabConfig.id}-drop"
>
<span class="dicon">⌲</span>
Drag &amp; drop images here<br>
or click to browse
</div>
<input
type="file"
id="${tabConfig.id}-file"
accept="image/*"
multiple
hidden
>
<div
class="thumbs"
id="${tabConfig.id}-thumbs"
></div>
<div
style="
display: flex;
gap: 6px;
margin-top: 8px;
"
>
<input
type="url"
id="${tabConfig.id}-urlin"
placeholder="…or paste an image URL"
>
<button
class="small"
id="${tabConfig.id}-addurl"
>
Add
</button>
</div>
</div>
`
: ""
}
<div>
<label class="fld">
${tabConfig.edit ? "Edit prompt" : "Prompt"}
</label>
<textarea
id="${tabConfig.id}-prompt"
></textarea>
</div>
<div>
<label class="fld">
Aspect ratio
</label>
<div
class="seg"
id="${tabConfig.id}-ratio"
>
${RATIOS.map(
(ratio) => `
<button
type="button"
data-v="${ratio}"
${ratio === exampleRatio ? 'class="sel"' : ""}
>
${ratio}
</button>
`,
).join("")}
</div>
</div>
<div>
<label class="fld">
Format
</label>
<div
class="seg"
id="${tabConfig.id}-fmt"
>
${tabConfig.formats
.map(
(format) => `
<button
type="button"
data-v="${format}"
${format === exampleFormat ? 'class="sel"' : ""}
>
${format}
</button>
`,
)
.join("")}
</div>
</div>
<button
class="run"
id="${tabConfig.id}-run"
>
${tabConfig.btn}
</button>
<div
class="status"
id="${tabConfig.id}-status"
></div>
</div>
<div class="out">
<div
class="gal-note"
id="${tabConfig.id}-note"
>
<span class="pill">
example output
</span>
run the model to generate your own
</div>
<div
class="gallery${tabConfig.multi ? "" : " single"}"
id="${tabConfig.id}-out"
></div>
</div>
</div>
${
tabConfig.comingSoon
? `
<div
class="coming-soon-overlay"
aria-label="Coming soon"
>
<div class="coming-soon-message">
<span>Coming</span> soon
</div>
</div>
`
: ""
}
`;
panelsEl.appendChild(panel);
$("#" + tabConfig.id + "-prompt").value = example.input.prompt || "";
$("#" + tabConfig.id + "-out").innerHTML = (example.output || [])
.map(
(url) => `
<a
href="${url}"
target="_blank"
rel="noreferrer"
>
<img
src="${url}"
alt="example output"
loading="lazy"
>
</a>
`,
)
.join("");
["ratio", "fmt"].forEach((groupName) => {
const group = $("#" + tabConfig.id + "-" + groupName);
group.querySelectorAll("button").forEach((button) => {
button.onclick = () => {
group.querySelectorAll("button").forEach((element) => {
element.classList.remove("sel");
});
button.classList.add("sel");
};
});
});
if (!tabConfig.comingSoon) {
if (tabConfig.edit) {
initDrop(tabConfig, example.input.image_urls || []);
}
$("#" + tabConfig.id + "-run").onclick = () => run(tabConfig);
}
});
async function uploadFile(file) {
const key = keyInput.value.trim();
if (!key) {
throw new Error("enter your eachlabs API key above first");
}
const presignResponse = await fetch(API + "/v1/upload/presign", {
method: "POST",
headers: {
"X-API-Key": key,
"Content-Type": "application/json",
},
body: JSON.stringify({
filename: file.name,
content_type: file.type,
file_type: "image",
}),
});
if (!presignResponse.ok) {
throw new Error(`presign failed (HTTP ${presignResponse.status})`);
}
const presignData = await presignResponse.json();
const uploadResponse = await fetch(presignData.presigned_url, {
method: "PUT",
headers: {
"Content-Type": file.type,
...(presignData.required_headers ?? {
"x-amz-meta-file-id": presignData.id,
}),
},
body: file,
});
if (!uploadResponse.ok) {
throw new Error(`upload failed (HTTP ${uploadResponse.status})`);
}
return presignData.public_url;
}
function initDrop(tabConfig, presetImages) {
const zone = $("#" + tabConfig.id + "-drop");
const fileInput = $("#" + tabConfig.id + "-file");
const thumbs = $("#" + tabConfig.id + "-thumbs");
const urlInput = $("#" + tabConfig.id + "-urlin");
tabConfig.images = (presetImages || []).map((url) => ({
preview: url,
publicUrl: url,
}));
const render = () => {
thumbs.innerHTML = tabConfig.images
.map(
(image, index) => `
<div
class="thumb${image.publicUrl ? "" : " uploading"}"
>
<img
src="${image.preview}"
alt=""
>
${image.publicUrl ? "" : '<div class="spin">⇡</div>'}
<button
class="x"
data-i="${index}"
title="remove"
>
×
</button>
</div>
`,
)
.join("");
thumbs.querySelectorAll(".x").forEach((button) => {
button.onclick = (event) => {
event.stopPropagation();
tabConfig.images.splice(Number(button.dataset.i), 1);
render();
};
});
};
render();
const addFiles = (files) => {
for (const file of files) {
if (!file.type.startsWith("image/")) {
continue;
}
if (tabConfig.images.length >= 10) {
setStatus(tabConfig, "Maximum 10 images.", true);
break;
}
const image = {
preview: URL.createObjectURL(file),
publicUrl: null,
};
tabConfig.images.push(image);
render();
uploadFile(file)
.then((url) => {
image.publicUrl = url;
})
.catch((error) => {
const imageIndex = tabConfig.images.indexOf(image);
if (imageIndex >= 0) {
tabConfig.images.splice(imageIndex, 1);
}
setStatus(
tabConfig,
"Image upload failed: " + error.message,
true,
);
})
.finally(render);
}
};
zone.onclick = () => {
fileInput.click();
};
fileInput.onchange = () => {
addFiles(fileInput.files);
fileInput.value = "";
};
zone.ondragover = (event) => {
event.preventDefault();
zone.classList.add("over");
};
zone.ondragleave = () => {
zone.classList.remove("over");
};
zone.ondrop = (event) => {
event.preventDefault();
zone.classList.remove("over");
addFiles(event.dataTransfer.files);
};
$("#" + tabConfig.id + "-addurl").onclick = () => {
const value = urlInput.value.trim();
if (!value) {
return;
}
if (tabConfig.images.length >= 10) {
setStatus(tabConfig, "Maximum 10 images.", true);
return;
}
tabConfig.images.push({
preview: value,
publicUrl: value,
});
urlInput.value = "";
render();
};
}
function setStatus(tabConfig, message, isError = false, busy = false) {
const element = $("#" + tabConfig.id + "-status");
element.className = "status" + (isError ? " err" : "");
element.innerHTML = (busy ? '<span class="dot"></span>' : "") + message;
}
async function run(tabConfig) {
if (tabConfig.comingSoon) {
return;
}
const key = keyInput.value.trim();
if (!key) {
setStatus(
tabConfig,
"Enter your eachlabs API key above first.",
true,
);
return;
}
const prompt = $("#" + tabConfig.id + "-prompt").value.trim();
if (!prompt) {
setStatus(tabConfig, "Please enter a prompt.", true);
return;
}
const input = {
...tabConfig.defaults,
prompt,
aspect_ratio: $("#" + tabConfig.id + "-ratio").querySelector(".sel")
.dataset.v,
output_format: $("#" + tabConfig.id + "-fmt").querySelector(".sel")
.dataset.v,
};
if (tabConfig.edit) {
if (!tabConfig.images.length) {
setStatus(
tabConfig,
"Add at least one image (drag & drop above).",
true,
);
return;
}
if (tabConfig.images.some((image) => !image.publicUrl)) {
setStatus(
tabConfig,
"Images are still uploading — wait a moment.",
true,
);
return;
}
input.image_urls = tabConfig.images.map((image) => image.publicUrl);
}
const runButton = $("#" + tabConfig.id + "-run");
const output = $("#" + tabConfig.id + "-out");
const note = $("#" + tabConfig.id + "-note");
runButton.disabled = true;
note.style.display = "none";
output.innerHTML = `
<div
class="loader"
style="grid-column: 1 / -1"
>
${LOGO_SVG}
<span id="${tabConfig.id}-loadtxt">
Starting…
</span>
</div>
`;
const loadText = () => $("#" + tabConfig.id + "-loadtxt");
const startedAt = Date.now();
const headers = {
"X-API-Key": key,
"Content-Type": "application/json",
};
try {
setStatus(tabConfig, "Creating prediction…", false, true);
const createResponse = await fetch(API + "/v1/prediction", {
method: "POST",
headers,
body: JSON.stringify({
model: tabConfig.model,
version: VERSION,
input,
}),
});
const createData = await createResponse.json().catch(() => ({}));
if (!createResponse.ok || !createData.predictionID) {
throw new Error(
`Could not create prediction (HTTP ${createResponse.status}): ${
createData.error ||
createData.message ||
JSON.stringify(createData).slice(0, 200)
}`,
);
}
while (Date.now() - startedAt < TIMEOUT_MS) {
await new Promise((resolve) => {
setTimeout(resolve, POLL_MS);
});
const elapsedSeconds = Math.round((Date.now() - startedAt) / 1000);
setStatus(
tabConfig,
`Running ${tabConfig.label}${elapsedSeconds}s`,
false,
true,
);
if (loadText()) {
loadText().textContent = `Running ${tabConfig.label}${elapsedSeconds}s`;
}
const predictionResponse = await fetch(
API + "/v1/prediction/" + createData.predictionID,
{
headers: {
"X-API-Key": key,
},
},
);
if (!predictionResponse.ok) {
continue;
}
const predictionData = await predictionResponse.json();
if (predictionData.status === "success") {
const urls = (
Array.isArray(predictionData.output)
? predictionData.output
: [predictionData.output]
).filter(Boolean);
if (!urls.length) {
throw new Error("Prediction succeeded but returned no output.");
}
output.innerHTML = urls
.map(
(url) => `
<a
href="${url}"
target="_blank"
rel="noreferrer"
>
<img
src="${url}"
alt="result"
>
</a>
`,
)
.join("");
setStatus(
tabConfig,
`Done in ${Math.round(
(Date.now() - startedAt) / 1000,
)}s — ${urls.length} image${
urls.length > 1 ? "s" : ""
}. Click to open full size.`,
);
return;
}
if (
predictionData.status === "failed" ||
predictionData.status === "cancelled"
) {
throw new Error(
`Prediction ${predictionData.status}: ${
predictionData.error || "no error detail"
}`,
);
}
}
throw new Error(
`Timed out after ${
TIMEOUT_MS / 1000
}s (id: ${createData.predictionID}).`,
);
} catch (error) {
output.innerHTML = "";
setStatus(tabConfig, error.message, true);
} finally {
runButton.disabled = false;
}
}
</script>
</body>
</html>