MikePGxAI commited on
Commit
3893a2b
·
verified ·
1 Parent(s): 2f120b8

Upload 4 files

Browse files
Files changed (4) hide show
  1. Dockerfile +21 -0
  2. index.html +238 -0
  3. requirements.txt +8 -0
  4. server.py +312 -0
Dockerfile ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE=1
4
+ ENV PYTHONUNBUFFERED=1
5
+
6
+ # Cache locations (helpful if you later enable persistent storage)
7
+ ENV HF_HOME=/data/.huggingface
8
+ ENV TRANSFORMERS_CACHE=/data/.huggingface/transformers
9
+ ENV HF_HUB_CACHE=/data/.huggingface/hub
10
+
11
+ WORKDIR /app
12
+
13
+ COPY requirements.txt /app/requirements.txt
14
+ RUN pip install --upgrade pip && pip install -r requirements.txt
15
+
16
+ COPY server.py /app/server.py
17
+ COPY index.html /app/index.html
18
+
19
+ EXPOSE 7860
20
+
21
+ CMD ["python","-m","uvicorn","server:app","--host","0.0.0.0","--port","7860","--log-level","info"]
index.html ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="ru">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
6
+ <title>AI Artwall</title>
7
+ <style>
8
+ html, body { width:100%; height:100%; margin:0; background:#000; overflow:hidden; }
9
+ canvas { width:100vw; height:100vh; display:block; }
10
+ #hint {
11
+ position: fixed;
12
+ left: 16px; bottom: 12px;
13
+ font: 12px/1.2 system-ui, -apple-system, Segoe UI, Roboto, Arial;
14
+ color: rgba(255,255,255,0.45);
15
+ user-select: none;
16
+ pointer-events: none;
17
+ }
18
+ </style>
19
+ </head>
20
+ <body>
21
+ <canvas id="c"></canvas>
22
+ <div id="hint">Клик: fullscreen • /health: статус</div>
23
+
24
+ <script>
25
+ const API_NEXT = "/next";
26
+ const FADE_MS = 1600;
27
+ const TRANSITION = "dissolve";
28
+
29
+ // Медленное, монотонное отдаление
30
+ // Попробуйте: 20000 (20с) / 30000 (30с) / 45000 (45с)
31
+ const DRIFT_MS = 20000;
32
+
33
+ // Насколько “близко” стартуем
34
+ // 1.08–1.18 обычно выглядит естественно
35
+ const ZOOM_FROM = 1.14;
36
+ const ZOOM_TO = 1.00;
37
+
38
+ const MASK_SIZE = 256;
39
+ const DPR_MAX = 2;
40
+
41
+ const canvas = document.getElementById("c");
42
+ const ctx = canvas.getContext("2d", { alpha: false });
43
+
44
+ const buf = document.createElement("canvas");
45
+ const bctx = buf.getContext("2d", { alpha: true });
46
+
47
+ function resize() {
48
+ const dpr = Math.min(window.devicePixelRatio || 1, DPR_MAX);
49
+ canvas.width = Math.floor(innerWidth * dpr);
50
+ canvas.height = Math.floor(innerHeight * dpr);
51
+
52
+ ctx.imageSmoothingEnabled = true;
53
+ ctx.imageSmoothingQuality = "high";
54
+
55
+ buf.width = canvas.width;
56
+ buf.height = canvas.height;
57
+ }
58
+ addEventListener("resize", resize);
59
+ resize();
60
+
61
+ const maskCanvas = document.createElement("canvas");
62
+ maskCanvas.width = MASK_SIZE;
63
+ maskCanvas.height = MASK_SIZE;
64
+ const mctx = maskCanvas.getContext("2d", { willReadFrequently: true });
65
+
66
+ const maskData = mctx.createImageData(MASK_SIZE, MASK_SIZE);
67
+ const noise = new Uint8Array(MASK_SIZE * MASK_SIZE);
68
+ (crypto || window.crypto).getRandomValues(noise);
69
+
70
+ function regenNoise() {
71
+ (crypto || window.crypto).getRandomValues(noise);
72
+ }
73
+
74
+ function updateMask(progress01) {
75
+ const t = Math.max(0, Math.min(255, Math.floor(progress01 * 255)));
76
+ const d = maskData.data;
77
+ for (let i = 0, p = 0; i < noise.length; i++, p += 4) {
78
+ const a = (noise[i] < t) ? 255 : 0;
79
+ d[p] = 255; d[p+1] = 255; d[p+2] = 255; d[p+3] = a;
80
+ }
81
+ mctx.putImageData(maskData, 0, 0);
82
+ }
83
+
84
+ function blobToImage(blob) {
85
+ return new Promise((resolve, reject) => {
86
+ const url = URL.createObjectURL(blob);
87
+ const img = new Image();
88
+ img.onload = () => { URL.revokeObjectURL(url); resolve(img); };
89
+ img.onerror = (e) => { URL.revokeObjectURL(url); reject(e); };
90
+ img.src = url;
91
+ });
92
+ }
93
+
94
+ async function fetchNext() {
95
+ const r = await fetch(API_NEXT, { cache: "no-store" });
96
+ if (!r.ok) throw new Error("fetch /next failed: " + r.status);
97
+ const id = Number(r.headers.get("x-frame-id") || "0");
98
+ const blob = await r.blob();
99
+ const img = await blobToImage(blob);
100
+ return { id, img };
101
+ }
102
+
103
+ let current = null;
104
+ let incoming = null;
105
+ let swapping = false;
106
+
107
+ let fadeStart = 0;
108
+
109
+ // ВАЖНО: отдельные “старты отдаления” для текущей и входящей картинки
110
+ let driftStartCurrent = performance.now();
111
+ let driftStartIncoming = performance.now();
112
+
113
+ function clamp01(x){ return Math.max(0, Math.min(1, x)); }
114
+ function easeInOut(x){
115
+ x = clamp01(x);
116
+ return x * x * (3 - 2 * x); // smoothstep
117
+ }
118
+
119
+ function coverDraw(targetCtx, img, alpha, zoom) {
120
+ const cw = targetCtx.canvas.width;
121
+ const ch = targetCtx.canvas.height;
122
+ const iw = img.naturalWidth || img.width;
123
+ const ih = img.naturalHeight || img.height;
124
+
125
+ const s = Math.max(cw / iw, ch / ih) * zoom;
126
+ const w = iw * s, h = ih * s;
127
+ const x = (cw - w) * 0.5;
128
+ const y = (ch - h) * 0.5;
129
+
130
+ targetCtx.globalAlpha = alpha;
131
+ targetCtx.drawImage(img, x, y, w, h);
132
+ targetCtx.globalAlpha = 1;
133
+ }
134
+
135
+ function drawLoading(now) {
136
+ ctx.fillStyle = "#000";
137
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
138
+
139
+ const t = (Math.sin(now / 450) * 0.5 + 0.5);
140
+ ctx.fillStyle = `rgba(255,255,255,${0.22 + 0.22 * t})`;
141
+ ctx.font = `${Math.floor(canvas.width / 40)}px system-ui, -apple-system, Segoe UI, Roboto, Arial`;
142
+ ctx.textAlign = "center";
143
+ ctx.textBaseline = "middle";
144
+ ctx.fillText("Генерация…", canvas.width/2, canvas.height/2);
145
+ }
146
+
147
+ function zoomFor(now, driftStart) {
148
+ const k = easeInOut(clamp01((now - driftStart) / DRIFT_MS));
149
+ return ZOOM_FROM + (ZOOM_TO - ZOOM_FROM) * k;
150
+ }
151
+
152
+ function tick(now) {
153
+ if (!current) {
154
+ drawLoading(now);
155
+ requestAnimationFrame(tick);
156
+ return;
157
+ }
158
+
159
+ ctx.fillStyle = "#000";
160
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
161
+
162
+ const zoomCurrent = zoomFor(now, driftStartCurrent);
163
+ coverDraw(ctx, current.img, 1, zoomCurrent);
164
+
165
+ if (swapping && incoming) {
166
+ const t = clamp01((now - fadeStart) / FADE_MS);
167
+ const e = easeInOut(t);
168
+
169
+ // Входящая картинка отдаляется со своего старта (без скачка после перехода)
170
+ const zoomIncoming = zoomFor(now, driftStartIncoming);
171
+
172
+ if (TRANSITION === "fade") {
173
+ coverDraw(ctx, incoming.img, e, zoomIncoming);
174
+ } else {
175
+ updateMask(e);
176
+ bctx.clearRect(0, 0, buf.width, buf.height);
177
+
178
+ bctx.globalCompositeOperation = "source-over";
179
+ bctx.drawImage(maskCanvas, 0, 0, buf.width, buf.height);
180
+
181
+ bctx.globalCompositeOperation = "source-in";
182
+ coverDraw(bctx, incoming.img, 1, zoomIncoming);
183
+
184
+ ctx.drawImage(buf, 0, 0);
185
+ }
186
+
187
+ if (t >= 1) {
188
+ // commit swap — и ВАЖНО: не сбрасываем drift заново, а переносим его
189
+ current = incoming;
190
+ incoming = null;
191
+ swapping = false;
192
+
193
+ driftStartCurrent = driftStartIncoming; // никаких резких “приближений”
194
+ regenNoise();
195
+ }
196
+ }
197
+
198
+ requestAnimationFrame(tick);
199
+ }
200
+
201
+ async function run() {
202
+ while (true) {
203
+ try {
204
+ const fr = await fetchNext();
205
+
206
+ if (!current) {
207
+ current = fr;
208
+ driftStartCurrent = performance.now();
209
+ requestAnimationFrame(tick);
210
+ continue;
211
+ }
212
+
213
+ incoming = fr;
214
+
215
+ if (!swapping) {
216
+ swapping = true;
217
+ const now = performance.now();
218
+ fadeStart = now;
219
+ driftStartIncoming = now; // входящая сразу стартует “ближе” и начинает отдаляться
220
+ }
221
+
222
+ } catch (e) {
223
+ console.error(e);
224
+ await new Promise(r => setTimeout(r, 1000));
225
+ }
226
+ }
227
+ }
228
+
229
+ document.addEventListener("click", () => {
230
+ if (!document.fullscreenElement) {
231
+ document.documentElement.requestFullscreen?.().catch(() => {});
232
+ }
233
+ });
234
+
235
+ run();
236
+ </script>
237
+ </body>
238
+ </html>
requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.115.6
2
+ uvicorn[standard]==0.32.1
3
+ pillow==10.4.0
4
+ torch
5
+ diffusers==0.30.3
6
+ transformers==4.45.2
7
+ accelerate
8
+ safetensors==0.4.5
server.py ADDED
@@ -0,0 +1,312 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import io
2
+ import os
3
+ import random
4
+ import threading
5
+ import time
6
+ from queue import Queue, Empty
7
+
8
+ import torch
9
+ from fastapi import FastAPI, Response
10
+ from fastapi.responses import JSONResponse, HTMLResponse
11
+ from fastapi.middleware.cors import CORSMiddleware
12
+ from diffusers import AutoPipelineForText2Image
13
+ from PIL import Image
14
+
15
+ MODEL_ID = os.getenv("MODEL_ID", "stabilityai/sd-turbo")
16
+
17
+ W = int(os.getenv("W", "640"))
18
+ H = int(os.getenv("H", "640"))
19
+
20
+ STEPS = int(os.getenv("STEPS", "6"))
21
+ GUIDANCE = float(os.getenv("GUIDANCE", "1.5"))
22
+
23
+ USE_FP32_ON_MPS = os.getenv("USE_FP32_ON_MPS", "1") == "1"
24
+ QUEUE_MAX = int(os.getenv("QUEUE_MAX", "2"))
25
+
26
+ # Epoch distribution
27
+ # 50%: Impressionism+ → Contemporary
28
+ # 30%: Early Renaissance → Romanticism
29
+ # 20%: Pre-Gothic / Medieval / Gothic / Icon-like
30
+ P_MODERN = float(os.getenv("P_MODERN", "0.50"))
31
+ P_CLASSIC = float(os.getenv("P_CLASSIC", "0.30"))
32
+ P_EARLY = float(os.getenv("P_EARLY", "0.20"))
33
+
34
+ # Content balance
35
+ CLASSIC_PEOPLE_WEIGHT = float(os.getenv("CLASSIC_PEOPLE_WEIGHT", "0.60")) # in classic 30%, more portraits/figures than landscapes
36
+ MODERN_PEOPLE_WEIGHT = float(os.getenv("MODERN_PEOPLE_WEIGHT", "0.35")) # in modern 50%, fewer faces (helps with hands)
37
+
38
+ # Strong "no frame" enforcement
39
+ NEGATIVE = (
40
+ # Kill frames / borders / museum context
41
+ "frame, picture frame, painting frame, ornate frame, gold frame, "
42
+ "border, canvas edge, cropped canvas, mat, passepartout, "
43
+ "gallery wall, museum wall, hanging painting, framed artwork, "
44
+ "wood frame, gilded frame, edge of painting, "
45
+ # Kill text/logos
46
+ "text, watermark, logo, signature, letters, "
47
+ # Safety
48
+ "nsfw, nude, naked, porn, gore, violence, "
49
+ # Quality
50
+ "blur, blurry, out of focus, lowres, jpeg artifacts, "
51
+ # Anatomy (but do NOT force hands in positive prompt)
52
+ "bad anatomy, bad proportions, bad face, deformed face, "
53
+ "bad hands, malformed hands, deformed hands, "
54
+ "extra fingers, missing fingers, fused fingers, extra limbs, "
55
+ "hands in foreground, close-up hands, cropped hands, "
56
+ # Avoid photo/CGI look
57
+ "photorealistic, hyperrealistic, cgi, 3d render, plastic skin, anime, cartoon"
58
+ )
59
+
60
+ # ---- Prompt building blocks ----
61
+
62
+ BASE_PAINTING_QUALITY = (
63
+ "fine art painting, museum-quality artwork, painterly, expressive brushwork, "
64
+ "coherent artistic style, unified composition, natural color harmony, "
65
+ "sharp focus, high detail, canvas texture subtle, "
66
+ "no frame, no border, no canvas edge, "
67
+ "no photographic realism"
68
+ )
69
+
70
+ COMPOSITION_CALM = (
71
+ "balanced composition, calm pose, medium shot, hands not emphasized, "
72
+ "hands partially obscured by clothing or out of frame, "
73
+ "no dramatic hand gestures, hands not in foreground"
74
+ )
75
+
76
+ LIGHTING_SOFT = "soft natural light, gentle contrast, pleasing tonal range"
77
+ LIGHTING_DRAMATIC = "dramatic chiaroscuro, deep shadows, warm highlights"
78
+
79
+ # ---- 20% Early (pre-gothic / medieval / gothic / icons) ----
80
+ EARLY_POOL = [
81
+ "early medieval illuminated manuscript style, flat composition, symbolic forms, tempera, muted pigments",
82
+ "byzantine icon painting style, gold leaf tones, sacred atmosphere, stylized features, tempera on wood",
83
+ "gothic panel painting style, elongated forms, ornate patterns, flat background, tempera",
84
+ "romanese mural painting style, fresco texture, simplified figures, symbolic composition",
85
+ "medieval devotional painting style, stylized drapery, flat shapes, decorative borders implied (but no frame)",
86
+ ]
87
+
88
+ # ---- 30% Classic (early renaissance → romanticism) ----
89
+ CLASSIC_PEOPLE = [
90
+ "early renaissance oil painting portrait, sfumato, subtle realism, classical balance",
91
+ "high renaissance portrait painting, refined anatomy, calm expression, old master",
92
+ "baroque oil painting figure scene, rich pigments, theatrical lighting",
93
+ "dutch golden age interior scene painting, soft window light, oil on canvas",
94
+ "romanticism portrait painting, warm skin tones, painterly texture, emotional mood",
95
+ ]
96
+
97
+ CLASSIC_LANDSCAPES = [
98
+ "renaissance landscape painting, atmospheric perspective, classical composition",
99
+ "baroque landscape painting, dramatic sky, warm highlights, painterly",
100
+ "romantic landscape painting, luminous clouds, distant horizon, oil on canvas",
101
+ "classical pastoral landscape painting, soft light, calm mood, painterly",
102
+ ]
103
+
104
+ # ---- 50% Modern+ (impressionism → postimpressionism → modern → contemporary) ----
105
+ MODERN_PEOPLE = [
106
+ "impressionist portrait painting, visible brush strokes, light and color, soft edges",
107
+ "post-impressionist portrait painting, structured brushwork, rich color, painterly",
108
+ "fauvism portrait painting, bold color harmony, simplified shapes, expressive",
109
+ "expressionist figure painting, energetic brushwork, emotional color, painterly",
110
+ "modern figurative painting, simplified forms, contemporary palette, painterly",
111
+ ]
112
+
113
+ MODERN_LANDSCAPES = [
114
+ "impressionist landscape painting, plein air, shimmering light, visible brush strokes",
115
+ "post-impressionist landscape painting, vibrant color, structured strokes, painterly",
116
+ "fauvism landscape painting, bold color fields, simplified forms, expressive",
117
+ "modern abstract landscape-inspired painting, color fields, texture, painterly",
118
+ "contemporary painting, abstract forms, subtle glitch-like texture, mixed media feel (still painterly)",
119
+ "minimal color-field painting, soft gradients, subtle texture, contemporary art",
120
+ ]
121
+
122
+ def weighted_choice(groups):
123
+ r = random.random()
124
+ acc = 0.0
125
+ for p, name in groups:
126
+ acc += p
127
+ if r <= acc:
128
+ return name
129
+ return groups[-1][1]
130
+
131
+ def pick_epoch_group():
132
+ total = P_MODERN + P_CLASSIC + P_EARLY
133
+ if total <= 0:
134
+ return "modern"
135
+ pm = P_MODERN / total
136
+ pc = P_CLASSIC / total
137
+ pe = P_EARLY / total
138
+ return weighted_choice([(pm, "modern"), (pc, "classic"), (pe, "early")])
139
+
140
+ def pick_prompt():
141
+ epoch = pick_epoch_group()
142
+
143
+ if epoch == "early":
144
+ style = random.choice(EARLY_POOL)
145
+ lighting = LIGHTING_SOFT
146
+ comp = "balanced composition, icon-like calm, no frame, no border"
147
+ return f"{style}, {BASE_PAINTING_QUALITY}, {lighting}, {comp}"
148
+
149
+ if epoch == "classic":
150
+ if random.random() < CLASSIC_PEOPLE_WEIGHT:
151
+ style = random.choice(CLASSIC_PEOPLE)
152
+ lighting = random.choice([LIGHTING_SOFT, LIGHTING_DRAMATIC])
153
+ comp = COMPOSITION_CALM
154
+ else:
155
+ style = random.choice(CLASSIC_LANDSCAPES)
156
+ lighting = random.choice([LIGHTING_SOFT, LIGHTING_DRAMATIC])
157
+ comp = "balanced composition, no frame, no border"
158
+ return f"{style}, {BASE_PAINTING_QUALITY}, {lighting}, {comp}"
159
+
160
+ # modern (50%)
161
+ # Reduce hands/faces issues by favoring landscapes/abstract more often
162
+ if random.random() < MODERN_PEOPLE_WEIGHT:
163
+ style = random.choice(MODERN_PEOPLE)
164
+ comp = COMPOSITION_CALM
165
+ else:
166
+ style = random.choice(MODERN_LANDSCAPES)
167
+ comp = "balanced composition, no frame, no border"
168
+ lighting = random.choice([LIGHTING_SOFT, "natural daylight, atmospheric light", "soft studio light"])
169
+ return f"{style}, {BASE_PAINTING_QUALITY}, {lighting}, {comp}"
170
+
171
+ # Device selection
172
+ if torch.backends.mps.is_available():
173
+ DEVICE = "mps"
174
+ elif torch.cuda.is_available():
175
+ DEVICE = "cuda"
176
+ else:
177
+ DEVICE = "cpu"
178
+
179
+ if DEVICE == "mps":
180
+ DTYPE = torch.float32 if USE_FP32_ON_MPS else torch.float16
181
+ elif DEVICE == "cuda":
182
+ DTYPE = torch.float16
183
+ else:
184
+ DTYPE = torch.float32
185
+
186
+ app = FastAPI()
187
+ app.add_middleware(
188
+ CORSMiddleware,
189
+ allow_origins=["*"],
190
+ allow_methods=["*"],
191
+ allow_headers=["*"],
192
+ )
193
+
194
+ pipe = None
195
+ pipe_lock = threading.Lock()
196
+
197
+ q = Queue(maxsize=QUEUE_MAX)
198
+
199
+ latest_id = 0
200
+ last_error = ""
201
+ last_gen_ms = None
202
+ generated_total = 0
203
+ last_prompt = ""
204
+
205
+ def load_pipeline():
206
+ global pipe
207
+ pipe = AutoPipelineForText2Image.from_pretrained(
208
+ MODEL_ID,
209
+ torch_dtype=DTYPE,
210
+ safety_checker=None,
211
+ feature_extractor=None,
212
+ ).to(DEVICE)
213
+ try:
214
+ pipe.set_progress_bar_config(disable=True)
215
+ except Exception:
216
+ pass
217
+
218
+ def render_png():
219
+ global last_prompt
220
+ prompt = pick_prompt()
221
+ last_prompt = prompt
222
+
223
+ t0 = time.perf_counter()
224
+ with pipe_lock, torch.inference_mode():
225
+ out = pipe(
226
+ prompt=prompt,
227
+ negative_prompt=NEGATIVE,
228
+ width=W,
229
+ height=H,
230
+ num_inference_steps=STEPS,
231
+ guidance_scale=GUIDANCE,
232
+ )
233
+ img: Image.Image = out.images[0]
234
+ buf = io.BytesIO()
235
+ img.save(buf, format="PNG", optimize=True)
236
+ ms = (time.perf_counter() - t0) * 1000.0
237
+ return buf.getvalue(), ms
238
+
239
+ def generator_loop():
240
+ global latest_id, last_error, last_gen_ms, generated_total
241
+
242
+ while True:
243
+ try:
244
+ png, ms = render_png()
245
+
246
+ latest_id += 1
247
+ last_gen_ms = ms
248
+ last_error = ""
249
+ generated_total += 1
250
+
251
+ if q.full():
252
+ try:
253
+ q.get_nowait()
254
+ except Empty:
255
+ pass
256
+
257
+ q.put((latest_id, png), timeout=1)
258
+
259
+ if DEVICE == "mps":
260
+ try:
261
+ torch.mps.empty_cache()
262
+ except Exception:
263
+ pass
264
+
265
+ except Exception as e:
266
+ last_error = repr(e)
267
+ time.sleep(0.5)
268
+
269
+ @app.on_event("startup")
270
+ async def startup():
271
+ load_pipeline()
272
+ threading.Thread(target=generator_loop, daemon=True).start()
273
+
274
+ @app.get("/", response_class=HTMLResponse)
275
+ def root():
276
+ try:
277
+ with open(os.path.join(os.path.dirname(__file__), "index.html"), "r", encoding="utf-8") as f:
278
+ return f.read()
279
+ except Exception:
280
+ return "<html><body style='background:black;color:white;font-family:system-ui'>index.html not found</body></html>"
281
+
282
+ @app.get("/health")
283
+ def health():
284
+ return JSONResponse({
285
+ "device": DEVICE,
286
+ "dtype": str(DTYPE),
287
+ "model": MODEL_ID,
288
+ "w": W,
289
+ "h": H,
290
+ "steps": STEPS,
291
+ "guidance": GUIDANCE,
292
+ "queue": q.qsize(),
293
+ "latest_id": latest_id,
294
+ "last_gen_ms": last_gen_ms,
295
+ "last_error": last_error,
296
+ "generated_total": generated_total,
297
+ "p_modern": P_MODERN,
298
+ "p_classic": P_CLASSIC,
299
+ "p_early": P_EARLY,
300
+ "classic_people_weight": CLASSIC_PEOPLE_WEIGHT,
301
+ "modern_people_weight": MODERN_PEOPLE_WEIGHT,
302
+ "last_prompt": last_prompt[:400],
303
+ })
304
+
305
+ @app.get("/next")
306
+ def next_frame():
307
+ fid, png = q.get(timeout=600)
308
+ return Response(
309
+ content=png,
310
+ media_type="image/png",
311
+ headers={"X-Frame-Id": str(fid), "Cache-Control": "no-store"},
312
+ )