Upload 4 files
Browse files- Dockerfile +21 -0
- index.html +238 -0
- requirements.txt +8 -0
- 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 |
+
)
|