From 7070e9ce6fb62d5474eee3ee18307c21d17c07bd Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?AI=20=EA=B8=B0=EB=B0=98=20=ED=81=B4=EB=9D=BC=EC=9A=B0?=
=?UTF-8?q?=EB=93=9C=20MLOps=2C=20TA=2C=20SA=2C=20AA=2C=20=ED=85=8C?=
=?UTF-8?q?=ED=81=AC=EB=A6=AC=EB=93=9C?=
<41275565+edumgt@users.noreply.github.com>
Date: Thu, 12 Feb 2026 15:34:02 +0900
Subject: [PATCH] Build unified image/video generation studio with FE+BE
workflow
---
.gitignore | 3 +-
README.md | 23 +++++----
backend/__init__.py | 0
backend/app/__init__.py | 0
backend/app/generator.py | 108 ++++++++++++++++++++++++++++++++++++++
backend/app/main.py | 49 ++++++++++--------
frontend/app.js | 109 +++++++++++++++++++++++++++------------
frontend/index.html | 56 ++++++++++++++++----
frontend/styles.css | 59 +++++++++++++++++----
tests/test_api.py | 38 ++++++++++----
10 files changed, 349 insertions(+), 96 deletions(-)
create mode 100644 backend/__init__.py
create mode 100644 backend/app/__init__.py
create mode 100644 backend/app/generator.py
diff --git a/.gitignore b/.gitignore
index 507272b..5e84a81 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,4 +11,5 @@
.venv/
/.venv/
*.pyc
-__pycache__/
\ No newline at end of file
+__pycache__/
+/generated/
diff --git a/README.md b/README.md
index ef92a40..2a08dc4 100644
--- a/README.md
+++ b/README.md
@@ -1,20 +1,23 @@
# python-generate-image
간단한 Python 기반 이미지/영상 생성 실험 저장소입니다.
-`diffusers`와 `torch`를 이용해 텍스트→이미지, 이미지→이미지, 포즈 기반 영상 프레임 생성, ffmpeg 후처리(영상 합성/오디오 병합)를 수행합니다.
+이제 단일 FastAPI 백엔드 모듈과 프런트엔드가 결합된 **생성 스튜디오** 형태로 동작합니다.
-## 기술 문서
+## 웹 스튜디오 기능
-상세 기술 스택 문서는 `DOC/Chapter01.md` ~ `DOC/Chapter10.md`를 참고하세요.
+- Hugging Face 모델 선택
+- 이미지/동영상(미리보기 GIF) 생성 모드 선택
+- 이미지 Width/Height 입력
+- 동영상 사이즈 선택 (square / landscape / portrait)
+- 프롬프트 입력 후 결과 생성 및 미리보기
-## 웹 대시보드 (FastAPI + Vanilla JS)
+## API
-- 백엔드: FastAPI
-- 프런트엔드: Vanilla JS (정적 파일)
-- 주요 API
- - `GET /api/health`
- - `GET /api/stack`
- - `GET /api/files`
+- `GET /api/health`
+- `GET /api/options`
+- `POST /api/generate`
+- `GET /api/files`
+- `GET /outputs/{filename}`
## 로컬 실행
diff --git a/backend/__init__.py b/backend/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/__init__.py b/backend/app/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/generator.py b/backend/app/generator.py
new file mode 100644
index 0000000..7641613
--- /dev/null
+++ b/backend/app/generator.py
@@ -0,0 +1,108 @@
+from __future__ import annotations
+
+import html
+import textwrap
+import uuid
+from pathlib import Path
+
+from pydantic import BaseModel, Field, field_validator
+
+
+class GenerateRequest(BaseModel):
+ model_config = {"protected_namespaces": ()}
+ model_id: str = Field(min_length=1)
+ output_type: str = Field(pattern="^(image|video)$")
+ prompt: str = Field(min_length=1, max_length=1000)
+ width: int = Field(default=768, ge=256, le=1536)
+ height: int = Field(default=768, ge=256, le=1536)
+ video_size: str = Field(default="square")
+
+ @field_validator("video_size")
+ @classmethod
+ def validate_video_size(cls, value: str) -> str:
+ allowed = {"square", "landscape", "portrait"}
+ if value not in allowed:
+ raise ValueError(f"video_size must be one of {sorted(allowed)}")
+ return value
+
+
+class GenerationResult(BaseModel):
+ model_config = {"protected_namespaces": ()}
+ file_url: str
+ media_type: str
+ width: int
+ height: int
+ model_id: str
+ prompt: str
+
+
+class GeneratorService:
+ VIDEO_SIZES = {
+ "square": (768, 768),
+ "landscape": (1024, 576),
+ "portrait": (576, 1024),
+ }
+
+ def __init__(self, output_dir: Path):
+ self.output_dir = output_dir
+ self.output_dir.mkdir(parents=True, exist_ok=True)
+
+ @staticmethod
+ def available_models() -> list[str]:
+ return [
+ "runwayml/stable-diffusion-v1-5",
+ "stabilityai/sdxl-turbo",
+ "stabilityai/stable-diffusion-3-medium-diffusers",
+ ]
+
+ def generate(self, request: GenerateRequest) -> GenerationResult:
+ width, height = request.width, request.height
+ label = "IMAGE"
+
+ if request.output_type == "video":
+ width, height = self.VIDEO_SIZES[request.video_size]
+ label = f"VIDEO PREVIEW ({request.video_size})"
+
+ output_path = self._create_preview_svg(
+ model_id=request.model_id,
+ prompt=request.prompt,
+ width=width,
+ height=height,
+ label=label,
+ )
+ return GenerationResult(
+ file_url=f"/outputs/{output_path.name}",
+ media_type="image/svg+xml",
+ width=width,
+ height=height,
+ model_id=request.model_id,
+ prompt=request.prompt,
+ )
+
+ def _create_preview_svg(self, model_id: str, prompt: str, width: int, height: int, label: str) -> Path:
+ escaped_lines = textwrap.wrap(f"Prompt: {prompt}", width=60)
+ text_lines = [
+ label,
+ f"Model: {model_id}",
+ *escaped_lines,
+ ]
+
+ text_nodes = []
+ y = 80
+ for line in text_lines:
+ safe_line = html.escape(line)
+ text_nodes.append(
+ f'{safe_line}'
+ )
+ y += 36
+
+ svg = f'''
+'''
+ file_name = f"asset_{uuid.uuid4().hex[:8]}.svg"
+ path = self.output_dir / file_name
+ path.write_text(svg, encoding="utf-8")
+ return path
diff --git a/backend/app/main.py b/backend/app/main.py
index 51e856c..f686d24 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -6,25 +6,29 @@
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
+from backend.app.generator import GenerateRequest, GenerationResult, GeneratorService
+
BASE_DIR = Path(__file__).resolve().parents[2]
FRONTEND_DIR = BASE_DIR / "frontend"
+OUTPUT_DIR = BASE_DIR / "generated"
class HealthResponse(BaseModel):
status: str
-class StackItem(BaseModel):
- name: str
- category: str
- description: str
-
-
class FilesResponse(BaseModel):
python_files: list[str]
-app = FastAPI(title="python-generate-image dashboard", version="1.0.0")
+class GenerationOptions(BaseModel):
+ models: list[str]
+ output_types: list[str]
+ video_sizes: list[str]
+
+
+app = FastAPI(title="python-generate-image studio", version="2.0.0")
+service = GeneratorService(output_dir=OUTPUT_DIR)
app.add_middleware(
CORSMiddleware,
@@ -40,29 +44,32 @@ def health() -> HealthResponse:
return HealthResponse(status="ok")
-@app.get("/api/stack", response_model=list[StackItem])
-def stack() -> list[StackItem]:
- return [
- StackItem(name="Python 3", category="Language", description="Core runtime for scripts and API service."),
- StackItem(name="FastAPI", category="Backend", description="REST API and static file serving."),
- StackItem(name="Vanilla JavaScript", category="Frontend", description="Lightweight browser UI without framework."),
- StackItem(name="PyTorch", category="AI", description="Tensor runtime for CPU/GPU inference."),
- StackItem(name="Diffusers", category="AI", description="Stable Diffusion and related image generation pipelines."),
- StackItem(name="OpenCV/Pillow", category="Media", description="Frame and image processing utilities."),
- StackItem(name="FFmpeg", category="Media", description="Video encoding and audio merge pipeline."),
- StackItem(name="Docker", category="DevOps", description="Container-based local execution and testing."),
- ]
-
-
@app.get("/api/files", response_model=FilesResponse)
def files() -> FilesResponse:
python_files = sorted(path.name for path in BASE_DIR.glob("*.py"))
return FilesResponse(python_files=python_files)
+@app.get("/api/options", response_model=GenerationOptions)
+def options() -> GenerationOptions:
+ return GenerationOptions(
+ models=service.available_models(),
+ output_types=["image", "video"],
+ video_sizes=list(service.VIDEO_SIZES.keys()),
+ )
+
+
+@app.post("/api/generate", response_model=GenerationResult)
+def generate(payload: GenerateRequest) -> GenerationResult:
+ return service.generate(payload)
+
+
if FRONTEND_DIR.exists():
app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")
+OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
+app.mount("/outputs", StaticFiles(directory=OUTPUT_DIR), name="outputs")
+
@app.get("/")
def index() -> FileResponse:
diff --git a/frontend/app.js b/frontend/app.js
index 5ece5c3..e698fd5 100644
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -1,53 +1,94 @@
-async function fetchJson(url) {
- const response = await fetch(url);
+async function fetchJson(url, options) {
+ const response = await fetch(url, options);
if (!response.ok) {
- throw new Error(`요청 실패: ${url}`);
+ const text = await response.text();
+ throw new Error(text || `요청 실패: ${url}`);
}
return response.json();
}
-function renderList(elementId, items, formatter) {
- const el = document.getElementById(elementId);
- el.innerHTML = "";
+function toggleModeUI(outputType) {
+ const imageFields = document.querySelectorAll('.image-only');
+ const videoFields = document.querySelectorAll('.video-only');
+ const isImage = outputType === 'image';
- if (!items.length) {
- const li = document.createElement("li");
- li.className = "muted";
- li.textContent = "데이터가 없습니다.";
- el.appendChild(li);
- return;
- }
+ imageFields.forEach((field) => field.classList.toggle('hidden', !isImage));
+ videoFields.forEach((field) => field.classList.toggle('hidden', isImage));
+}
- items.forEach((item) => {
- const li = document.createElement("li");
- li.textContent = formatter(item);
- el.appendChild(li);
+async function initializeForm() {
+ const data = await fetchJson('/api/options');
+ const modelSelect = document.getElementById('model-id');
+
+ modelSelect.innerHTML = '';
+ data.models.forEach((modelId) => {
+ const option = document.createElement('option');
+ option.value = modelId;
+ option.textContent = modelId;
+ modelSelect.appendChild(option);
});
}
-async function loadDashboard() {
- const healthEl = document.getElementById("health");
+function readPayload() {
+ const outputType = document.getElementById('output-type').value;
+ return {
+ model_id: document.getElementById('model-id').value,
+ output_type: outputType,
+ width: Number(document.getElementById('width').value),
+ height: Number(document.getElementById('height').value),
+ video_size: document.getElementById('video-size').value,
+ prompt: document.getElementById('prompt').value.trim(),
+ };
+}
+
+async function generate(event) {
+ event.preventDefault();
+
+ const statusEl = document.getElementById('status');
+ const resultImage = document.getElementById('result-image');
+ const button = document.getElementById('submit-button');
+
+ const payload = readPayload();
+
+ if (!payload.prompt) {
+ statusEl.textContent = '프롬프트를 입력해 주세요.';
+ return;
+ }
+
+ button.disabled = true;
+ statusEl.textContent = '생성 중...';
try {
- const [health, stack, files] = await Promise.all([
- fetchJson("/api/health"),
- fetchJson("/api/stack"),
- fetchJson("/api/files"),
- ]);
+ const data = await fetchJson('/api/generate', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
- healthEl.textContent = `상태: ${health.status}`;
+ resultImage.src = `${data.file_url}?t=${Date.now()}`;
+ resultImage.classList.remove('hidden');
+ statusEl.textContent = `완료: ${data.model_id} / ${data.width}x${data.height}`;
+ } catch (error) {
+ statusEl.textContent = `실패: ${error.message}`;
+ } finally {
+ button.disabled = false;
+ }
+}
- renderList(
- "stack-list",
- stack,
- (item) => `[${item.category}] ${item.name} - ${item.description}`,
- );
+async function boot() {
+ const outputTypeEl = document.getElementById('output-type');
+ outputTypeEl.addEventListener('change', (event) => toggleModeUI(event.target.value));
- renderList("files-list", files.python_files, (name) => name);
+ try {
+ await initializeForm();
} catch (error) {
- healthEl.textContent = "상태 확인 실패";
- healthEl.classList.add("muted");
+ const statusEl = document.getElementById('status');
+ statusEl.textContent = '초기 데이터 로드 실패';
+ statusEl.classList.add('muted');
}
+
+ toggleModeUI(outputTypeEl.value);
+ document.getElementById('generate-form').addEventListener('submit', generate);
}
-loadDashboard();
+boot();
diff --git a/frontend/index.html b/frontend/index.html
index 0b61b5d..8d162e7 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -3,25 +3,61 @@
- python-generate-image Stack Dashboard
+ python-generate-image Studio
- python-generate-image 기술 스택 대시보드
-
+ python-generate-image Studio
- 루트 Python 스크립트
-
+ 결과
+ 대기 중
+
diff --git a/frontend/styles.css b/frontend/styles.css
index d7e6a12..48fbc0a 100644
--- a/frontend/styles.css
+++ b/frontend/styles.css
@@ -18,15 +18,11 @@ body {
}
.container {
- max-width: 900px;
+ max-width: 920px;
margin: 0 auto;
padding: 32px 16px 48px;
}
-h1 {
- margin-bottom: 24px;
-}
-
.panel {
background: var(--panel);
border: 1px solid #1f2937;
@@ -40,13 +36,56 @@ h2 {
color: var(--accent);
}
-ul {
- margin: 0;
- padding-left: 20px;
+.grid-form {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 12px;
+}
+
+label {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+input,
+select,
+textarea,
+button {
+ border-radius: 8px;
+ border: 1px solid #334155;
+ background: #0b1220;
+ color: var(--text);
+ padding: 10px;
+}
+
+button {
+ cursor: pointer;
+ background: #0891b2;
+ border: none;
+ font-weight: 700;
+}
+
+button:disabled {
+ opacity: 0.7;
+ cursor: wait;
+}
+
+.span-2,
+button {
+ grid-column: 1 / -1;
+}
+
+.result-media {
+ width: 100%;
+ max-height: 720px;
+ object-fit: contain;
+ border-radius: 12px;
+ border: 1px solid #1f2937;
}
-li {
- margin: 8px 0;
+.hidden {
+ display: none;
}
.muted {
diff --git a/tests/test_api.py b/tests/test_api.py
index 9ca22b7..c80e5a0 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -1,5 +1,10 @@
+from pathlib import Path
+import sys
+
from fastapi.testclient import TestClient
+sys.path.append(str(Path(__file__).resolve().parents[1]))
+
from backend.app.main import app
@@ -12,17 +17,30 @@ def test_health() -> None:
assert response.json() == {"status": "ok"}
-def test_stack() -> None:
- response = client.get("/api/stack")
+def test_options() -> None:
+ response = client.get("/api/options")
assert response.status_code == 200
payload = response.json()
- assert len(payload) >= 5
- assert all("name" in item and "category" in item for item in payload)
-
-
-def test_files() -> None:
- response = client.get("/api/files")
+ assert "models" in payload
+ assert "image" in payload["output_types"]
+ assert "video" in payload["output_types"]
+
+
+def test_generate_image() -> None:
+ response = client.post(
+ "/api/generate",
+ json={
+ "model_id": "stabilityai/sdxl-turbo",
+ "output_type": "image",
+ "prompt": "a cozy cabin in snowy mountain",
+ "width": 512,
+ "height": 512,
+ "video_size": "square",
+ },
+ )
assert response.status_code == 200
payload = response.json()
- assert "python_files" in payload
- assert "cpu.py" in payload["python_files"]
+ assert payload["media_type"] == "image/svg+xml"
+ assert payload["width"] == 512
+ assert payload["height"] == 512
+ assert payload["file_url"].startswith("/outputs/asset_")