Spaces:
Running
Running
| <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 & 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> | |