"""ShotCraft — AI Shot Director for e-commerce.
HF x Gradio Build Small Hackathon 2026.
Stage 1: MiniCPM-V-2_6 (8B) | Stage 2: FLUX.1-schnell (12B)
"""
from __future__ import annotations
import io, json, os, tempfile, zipfile
import gradio as gr
from PIL import Image
from schemas import CATEGORIES, STYLE_PRESETS
from director import generate_concepts
from frames import generate_frame, generate_frames, seed_for
from model_runtime import API_URL, BackendError, health
EXAMPLES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "examples")
# Force Gradio's light palette so stock widgets match the light design system.
# Plain
"""
# Native theme base: lets Gradio's own variables style internals custom CSS
# can't reach (sliders, checkboxes, focus rings) and loads Inter without a
# CSS @import (no flash of unstyled text). cyan-600 #0891b2 is the single
# accent — closest Tailwind hue to the original brand teal #0fa3b1.
THEME = gr.themes.Base(
primary_hue="cyan",
secondary_hue="cyan",
neutral_hue="slate",
radius_size="md",
font=gr.themes.GoogleFont("Inter"),
font_mono=gr.themes.GoogleFont("JetBrains Mono"),
).set(
button_primary_background_fill="*primary_600",
button_primary_background_fill_hover="*primary_500",
button_primary_text_color="white",
slider_color="*primary_600",
)
# ShotCraft design system — premium light theme, single cyan accent.
# 1px borders for separation, layered soft shadows, Inter with tight heading
# tracking, 200ms ease-out micro-interactions, skeleton shimmer on pending
# outputs, visible dual-ring focus. Lines marked KEEP are load-bearing
# Gradio fixes — do not remove.
SHOTCRAFT_CSS = """
:root {
--sc-bg: #f8fafc;
--sc-card: #ffffff;
--sc-ink: #0f172a;
--sc-body: #475569;
--sc-muted: #94a3b8;
--sc-border: rgba(15, 23, 42, 0.08);
--sc-border-strong: rgba(15, 23, 42, 0.16);
--sc-accent: #0891b2;
--sc-accent-strong: #0e7490;
--sc-accent-soft: rgba(8, 145, 178, 0.10);
--sc-accent-ring: rgba(8, 145, 178, 0.22);
--sc-danger: #dc2626;
--sc-shadow-sm: 0 1px 2px rgba(2, 8, 23, 0.05);
--sc-shadow-md: 0 1px 2px rgba(2, 8, 23, 0.05), 0 10px 30px rgba(2, 8, 23, 0.07);
--sc-shadow-lg: 0 2px 4px rgba(2, 8, 23, 0.06), 0 18px 50px rgba(2, 8, 23, 0.10);
--sc-ease: cubic-bezier(0.22, 1, 0.36, 1);
}
/* KEEP: prevent horizontal scroll on small screens */
/* clip, not hidden: overflow-x:hidden computes overflow-y to auto, turning
body into a non-scrolling scroll container that captures position:sticky. */
html, body {
overflow-x: clip !important;
}
body,
.gradio-container {
background:
radial-gradient(1100px 480px at 85% -8%, rgba(8, 145, 178, 0.07), transparent 60%),
linear-gradient(180deg, #fbfcfe 0%, var(--sc-bg) 100%) !important;
color: var(--sc-body) !important;
font-family: 'Inter', ui-sans-serif, system-ui, sans-serif !important;
}
.gradio-container {
max-width: 1180px !important;
margin: 0 auto !important;
padding: 28px 22px 48px !important;
}
/* KEEP: let the flex/grid chain shrink below content min-width on small screens */
.gradio-container, .gradio-container .main, .gradio-container .app,
.gradio-container .contain, .gradio-container .column, .gradio-container .row,
.gradio-container .block, .gradio-container .form,
.gradio-container .tab-container, .gradio-container [role="tablist"] {
min-width: 0 !important;
max-width: 100% !important;
}
.prose h1, .prose h2, .prose h3 {
color: var(--sc-ink) !important;
letter-spacing: -0.02em;
font-weight: 700;
}
/* ---------- Interaction primitives ---------- */
button, [role="tab"], .thumbnail-item, .label-wrap, tbody tr {
cursor: pointer !important;
}
button {
transition: transform 150ms var(--sc-ease), box-shadow 200ms var(--sc-ease),
background 200ms var(--sc-ease), border-color 200ms var(--sc-ease),
color 200ms var(--sc-ease) !important;
}
button:active {
transform: scale(0.98);
}
button:focus-visible, [role="tab"]:focus-visible {
outline: none !important;
box-shadow: 0 0 0 2px #ffffff, 0 0 0 4px var(--sc-accent) !important;
}
input[type="checkbox"], input[type="radio"] {
accent-color: var(--sc-accent);
}
/* ---------- Hero ---------- */
.shotcraft-hero {
position: relative;
overflow: hidden;
padding: 36px 36px 30px;
border: 1px solid var(--sc-border);
border-radius: 18px;
background: rgba(255, 255, 255, 0.82);
backdrop-filter: blur(12px); /* safe here: hero contains no dropdowns */
box-shadow: var(--sc-shadow-md);
}
.shotcraft-hero::before {
content: "";
position: absolute;
top: 0; left: 0; right: 0;
height: 3px;
background: linear-gradient(90deg, var(--sc-accent), rgba(8, 145, 178, 0.25));
}
.shotcraft-kicker {
color: var(--sc-accent);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
margin-bottom: 14px;
}
.shotcraft-hero h1 {
max-width: 720px;
margin: 0;
color: var(--sc-ink);
font-weight: 700;
font-size: clamp(30px, 4.4vw, 46px);
line-height: 1.08;
letter-spacing: -0.03em;
}
.shotcraft-hero p {
max-width: 640px;
margin: 14px 0 22px;
color: var(--sc-body);
font-size: 15px;
line-height: 1.65;
}
.hero-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.hero-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 13px;
border: 1px solid var(--sc-border);
border-radius: 999px;
background: #ffffff;
color: var(--sc-body);
font-size: 12px;
font-weight: 500;
letter-spacing: 0.01em;
transition: border-color 200ms var(--sc-ease), box-shadow 200ms var(--sc-ease);
}
.hero-chip:hover {
border-color: var(--sc-accent-ring);
box-shadow: var(--sc-shadow-sm);
}
.hero-chip b {
color: var(--sc-accent);
font-weight: 700;
}
/* ---------- Status bar ---------- */
.shotcraft-status {
display: flex;
align-items: center;
gap: 10px;
margin: 14px 0 16px;
padding: 10px 14px;
border-radius: 10px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.02em;
}
.shotcraft-status.ok {
border: 1px solid var(--sc-accent-ring);
background: var(--sc-accent-soft);
color: var(--sc-accent-strong);
}
.shotcraft-status.fail {
border: 1px solid rgba(220, 38, 38, 0.30);
background: rgba(220, 38, 38, 0.07);
color: var(--sc-danger);
}
.shotcraft-status.checking {
border: 1px dashed var(--sc-border-strong);
background: #ffffff;
color: var(--sc-muted);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
animation: dotPulse 1.8s var(--sc-ease) infinite;
}
@keyframes dotPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.45; }
}
/* ---------- Stage rail ---------- */
.stage-rail {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin: 16px 0 20px;
}
.stage-card {
position: relative;
overflow: hidden;
padding: 16px 18px 14px;
border: 1px solid var(--sc-border);
border-radius: 14px;
background: var(--sc-card);
box-shadow: var(--sc-shadow-sm);
transition: transform 200ms var(--sc-ease), box-shadow 200ms var(--sc-ease),
border-color 200ms var(--sc-ease), opacity 200ms var(--sc-ease);
}
.stage-card::before {
content: "";
position: absolute;
top: 0; left: 0; right: 0;
height: 3px;
background: transparent;
transition: background 200ms var(--sc-ease);
}
.stage-card.pending { opacity: 0.55; }
.stage-card.done::before { background: var(--sc-accent-ring); }
.stage-card.done .stage-label { color: var(--sc-accent-strong); }
.stage-card.active {
transform: translateY(-2px);
border-color: var(--sc-accent-ring);
box-shadow: var(--sc-shadow-md), 0 8px 24px rgba(8, 145, 178, 0.12);
opacity: 1;
}
.stage-card.active::before { background: var(--sc-accent); }
.stage-card.active .stage-label { color: var(--sc-accent); }
.stage-label {
display: block;
color: var(--sc-muted);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.stage-card strong {
display: block;
margin: 7px 0 3px;
color: var(--sc-ink);
font-size: 16px;
font-weight: 700;
letter-spacing: -0.01em;
}
.stage-card span:last-child {
color: var(--sc-muted);
font-size: 13px;
line-height: 1.5;
}
/* ---------- Tabs: segmented control ---------- */
#shotcraft-tabs [role="tablist"] {
gap: 4px !important;
border: 1px solid var(--sc-border) !important;
border-radius: 12px !important;
background: #eef2f6 !important;
padding: 4px !important;
box-shadow: none !important;
}
#shotcraft-tabs button[role="tab"] {
border-radius: 9px !important;
color: var(--sc-muted) !important;
font-weight: 600 !important;
font-size: 13px !important;
letter-spacing: 0 !important;
border: 1px solid transparent !important;
transition: all 200ms var(--sc-ease) !important;
}
#shotcraft-tabs button[role="tab"]:hover {
color: var(--sc-ink) !important;
background: rgba(255, 255, 255, 0.6) !important;
}
#shotcraft-tabs button[role="tab"][aria-selected="true"] {
background: #ffffff !important;
color: var(--sc-ink) !important;
border-color: var(--sc-border) !important;
box-shadow: var(--sc-shadow-sm) !important;
}
.stage-tab {
/* KEEP opacity-only: transform/filter (even identity, kept by fill-mode)
make the tab a containing block for fixed elements -> dropdown menus
misplaced. */
animation: tabReveal 320ms var(--sc-ease);
}
@keyframes tabReveal {
from { opacity: 0; }
to { opacity: 1; }
}
/* ---------- Panels & cards ---------- */
.glass-panel,
.concept-card,
.render-panel {
border: 1px solid var(--sc-border) !important;
border-radius: 16px !important;
background: var(--sc-card) !important;
box-shadow: var(--sc-shadow-md) !important;
/* KEEP: NO backdrop-filter here — it turns the panel into a containing
block for position:fixed children, which teleports Gradio dropdown
menus. */
}
.glass-panel, .render-panel {
padding: 20px !important;
}
.concept-card {
margin-bottom: 10px !important;
box-shadow: var(--sc-shadow-sm) !important;
transition: transform 200ms var(--sc-ease), border-color 200ms var(--sc-ease),
box-shadow 200ms var(--sc-ease);
}
.concept-card:hover {
transform: translateY(-1px);
border-color: var(--sc-border-strong) !important;
box-shadow: var(--sc-shadow-md) !important;
}
.concept-card > div:first-child {
border-radius: 14px !important;
color: var(--sc-ink) !important;
font-weight: 600 !important;
}
.analysis-card {
border: 1px solid var(--sc-accent-ring) !important;
border-left: 3px solid var(--sc-accent) !important;
border-radius: 12px !important;
background: var(--sc-accent-soft) !important;
padding: 14px 16px !important;
color: var(--sc-body) !important;
}
/* KEEP: Gradio group internals — no gray block backgrounds inside cards */
.glass-panel .block, .glass-panel .form, .glass-panel .styler,
.render-panel .block, .render-panel .form, .render-panel .styler,
.concept-card .block, .concept-card .form, .concept-card .styler {
background: transparent !important;
}
/* ---------- Forms ---------- */
label,
.block label,
.form label {
color: var(--sc-body) !important;
font-size: 12px !important;
font-weight: 600 !important;
letter-spacing: 0 !important;
text-transform: none !important;
}
textarea,
input,
select {
border-color: rgba(15, 23, 42, 0.12) !important;
border-radius: 8px !important;
background: #ffffff !important;
color: var(--sc-ink) !important;
transition: border-color 150ms var(--sc-ease), box-shadow 150ms var(--sc-ease) !important;
}
textarea:focus,
input:focus {
border-color: var(--sc-accent) !important;
box-shadow: 0 0 0 3px var(--sc-accent-ring) !important;
}
/* KEEP scoped to ul.options only — [role="listbox"] also matches the dropdown
INPUT wrap and painted a stray pill border inside the field. */
ul.options {
z-index: 9999 !important;
border: 1px solid var(--sc-border) !important;
border-radius: 10px !important;
background: #ffffff !important;
box-shadow: var(--sc-shadow-lg) !important;
}
ul.options [role="option"], ul.options li {
background: transparent !important;
color: var(--sc-ink) !important;
}
ul.options [role="option"]:hover, ul.options li:hover,
ul.options [role="option"][aria-selected="true"], ul.options li.selected {
background: var(--sc-accent-soft) !important;
color: var(--sc-accent-strong) !important;
}
/* Examples table */
.table-wrap, table, thead, tbody, tr, th, td {
background: #ffffff !important;
color: var(--sc-body) !important;
border-color: var(--sc-border) !important;
}
tbody tr { transition: background 150ms var(--sc-ease) !important; }
tbody tr:hover, tbody tr:hover td { background: var(--sc-accent-soft) !important; }
/* ---------- Buttons ---------- */
button.primary,
.shotcraft-primary button,
button.shotcraft-primary {
border: 0 !important;
border-radius: 10px !important;
background: linear-gradient(180deg, #0aa3c4, var(--sc-accent)) !important;
color: #ffffff !important;
font-weight: 600 !important;
letter-spacing: 0 !important;
box-shadow: var(--sc-shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.18) !important;
}
button.primary:hover,
.shotcraft-primary button:hover,
button.shotcraft-primary:hover {
transform: translateY(-1px);
background: linear-gradient(180deg, #0db0d2, #0a9cc0) !important;
box-shadow: 0 8px 22px rgba(8, 145, 178, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.18) !important;
}
button.primary:active,
.shotcraft-primary button:active,
button.shotcraft-primary:active {
transform: translateY(0) scale(0.98);
}
.shotcraft-secondary button,
button.shotcraft-secondary {
border: 1px solid rgba(15, 23, 42, 0.14) !important;
border-radius: 10px !important;
background: #ffffff !important;
color: var(--sc-ink) !important;
font-weight: 600 !important;
box-shadow: var(--sc-shadow-sm) !important;
}
.shotcraft-secondary button:hover,
button.shotcraft-secondary:hover {
transform: translateY(-1px);
border-color: var(--sc-accent-ring) !important;
background: #fbfdfe !important;
color: var(--sc-accent-strong) !important;
}
.continue-cta {
margin-top: 16px !important;
}
.continue-cta button,
button.continue-cta {
width: 100% !important;
font-size: 15px !important;
padding: 13px 22px !important;
animation: ctaGlow 2.4s var(--sc-ease) infinite;
}
@keyframes ctaGlow {
0%, 100% { box-shadow: 0 4px 14px rgba(8, 145, 178, 0.18); }
50% { box-shadow: 0 6px 26px rgba(8, 145, 178, 0.38); }
}
/* ---------- Gallery & media ---------- */
.shotcraft-gallery, .source-preview {
border-radius: 14px !important;
background: #ffffff !important;
}
.shotcraft-gallery .thumbnail-item {
border-radius: 10px !important;
transition: transform 200ms var(--sc-ease), box-shadow 200ms var(--sc-ease) !important;
}
.shotcraft-gallery .thumbnail-item:hover {
transform: translateY(-2px);
box-shadow: var(--sc-shadow-md) !important;
}
.shotcraft-gallery .thumbnail-item.selected {
outline: 2px solid var(--sc-accent) !important;
outline-offset: 2px;
}
/* Empty states: dashed, quiet — never a bare gray void */
.gradio-container .empty {
border: 1.5px dashed rgba(15, 23, 42, 0.14) !important;
border-radius: 12px !important;
background: rgba(248, 250, 252, 0.7) !important;
color: var(--sc-muted) !important;
}
/* ---------- Pending state: skeleton shimmer + status pill ----------
Hooks Gradio's built-in .generating pending class — pure CSS, no JS.
position:relative is safe for the fixed-position pill (only transform/
filter/backdrop-filter create containing blocks for fixed children). */
.generating {
position: relative !important;
overflow: hidden !important;
border-color: var(--sc-accent-ring) !important;
}
.generating::after {
content: "";
position: absolute;
inset: 0;
z-index: 5;
pointer-events: none;
background: linear-gradient(100deg, transparent 30%, rgba(255, 255, 255, 0.55) 50%, transparent 70%);
background-size: 200% 100%;
animation: shimmer 1.6s linear infinite;
}
@keyframes shimmer {
from { background-position: 150% 0; }
to { background-position: -50% 0; }
}
.generating::before {
content: "\\25CF Generating";
position: fixed;
top: 16px;
right: 20px;
z-index: 1000;
padding: 6px 14px;
border: 1px solid var(--sc-accent-ring);
border-radius: 999px;
background: rgba(255, 255, 255, 0.92);
color: var(--sc-accent-strong);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
box-shadow: var(--sc-shadow-lg);
animation: dotPulse 1.4s var(--sc-ease) infinite;
pointer-events: none;
}
/* ---------- Toasts ---------- */
.toast-body {
border: 1px solid var(--sc-border) !important;
border-radius: 12px !important;
background: rgba(255, 255, 255, 0.94) !important;
backdrop-filter: blur(10px);
box-shadow: var(--sc-shadow-lg) !important;
}
.visually-hidden {
position: absolute !important;
left: -9999px !important;
width: 1px !important;
height: 1px !important;
overflow: hidden !important;
}
/* ---------- Stage rail = navigation (tab pills hidden) ---------- */
#shotcraft-tabs [role="tablist"] {
position: absolute !important;
left: -9999px !important;
top: 0 !important;
width: 600px !important; /* real width: zero-width tablists render no tab buttons */
pointer-events: none !important;
}
.stage-card {
cursor: pointer;
user-select: none;
}
.stage-card:hover {
transform: translateY(-2px);
border-color: var(--sc-accent-ring);
box-shadow: var(--sc-shadow-md);
}
.stage-card:focus-visible {
outline: 2px solid var(--sc-accent);
outline-offset: 2px;
}
/* ---------- Sticky continue CTA ---------- */
/* Gradio sets overflow:hidden on the container, which captures position:sticky
(a non-scrolling scroll container). overflow:clip clips identically but
does NOT create a scroll container, so the CTA can stick to the real
scrollport (Gradio's inner overflow-y:auto wrapper). */
.gradio-container {
overflow: clip !important;
}
.continue-cta,
button.continue-cta {
position: sticky !important;
bottom: 14px;
z-index: 60;
}
/* ---------- Locked product look ---------- */
.locked-box {
border-left: 4px solid var(--sc-accent) !important;
border-radius: 12px !important;
background: rgba(8, 145, 178, 0.05) !important;
padding: 4px 10px !important;
}
.locked-box textarea {
font-weight: 600 !important;
}
/* ---------- Palette chips ---------- */
.palette-chips {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 0 2px;
min-height: 24px;
}
.palette-chip {
width: 22px;
height: 22px;
border-radius: 7px;
border: 1px solid rgba(15, 23, 42, 0.18);
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.35);
display: inline-block;
}
/* Hide the analysis strip until Stage 1 fills it (it's an empty pill before) */
.analysis-card.hide-container,
.analysis-card:not(:has(.prose)) {
display: none !important;
}
.boards-toolbar button {
padding: 6px 14px !important;
font-size: 12.5px !important;
white-space: nowrap !important;
}
/* ---------- Mobile ---------- */
@media (max-width: 760px) {
.gradio-container {
padding: 14px 10px 30px !important;
}
.stage-rail {
grid-template-columns: 1fr;
}
.shotcraft-hero {
padding: 22px 18px;
}
.shotcraft-hero h1 {
font-size: clamp(24px, 8.5vw, 34px);
overflow-wrap: break-word;
}
.shotcraft-hero p {
font-size: 14px;
}
.shotcraft-status {
white-space: normal;
word-break: break-word;
}
.shotcraft-gallery .grid-container {
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
}
.table-wrap {
overflow-x: auto !important;
max-width: 100% !important;
}
.prose h3, .prose h2 {
white-space: normal !important;
overflow-wrap: break-word;
}
}
"""
def _hero_html() -> str:
return """
Upload one product photo, let MiniCPM draft five grounded art-direction
boards, then move into FLUX rendering with editable prompts and
deterministic hero-frame control.
Direct the shot. Render the campaign.