Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,19 @@ curl "$SERVICE_URL/jobs/claim?worker_id=gpu-1" \
-H "Authorization: Bearer $WORKER_TOKEN"
```

The claim response contains exactly `id`, `workflow`, and `inputs`. Upload one or more generated images with a JSON-string `meta` field; `image_0` is required:
The claim response contains exactly `id`, `workflow`, and `inputs`. Preview inputs
omit `prompt` entirely and contain the submitted `title` and `keywords` unchanged
(including Korean), plus `style`, `seed`, `prefix`, dimensions, `format`, `quality`,
and `steps`. The GPU worker is responsible for rendering an English prompt.

Upload one or more generated images with a JSON-string `meta` field; `image_0` is
required. For previews, `meta.prompt` must be a non-empty string containing the
actual English prompt used by the GPU:

```bash
curl -X POST "$SERVICE_URL/jobs/JOB_ID/result" \
-H "Authorization: Bearer $WORKER_TOKEN" \
-F 'meta={"renderer":"comfyui"}' \
-F 'meta={"renderer":"comfyui","prompt":"Exact prompt used by the GPU"}' \
-F image_0=@preview.webp \
-F image_1=@alternate.webp
```
Expand All @@ -81,6 +88,13 @@ curl -X POST "$SERVICE_URL/api/covers/REQUEST_ID/select" \
-d '{"style_id":"watercolour"}'
```

Selection returns HTTP 409 until that style's preview is done and its rendered
prompt has been stored. Final inputs contain that prompt byte-for-byte, omit
`title` and `keywords`, and reuse the selected preview's exact seed.

At startup, existing SQLite databases are migrated in place by adding the nullable
`jobs.rendered_prompt` column when absent; existing rows and files are preserved.

Previews request 512×768 WebP at quality 82. Final jobs request 1795×2657 WebP at quality 92. Both use generation dimensions 1024×1536 and four steps.

## Verification
Expand Down
77 changes: 51 additions & 26 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ class Failure(StrictModel):
style_id TEXT NOT NULL, kind TEXT NOT NULL, workflow TEXT NOT NULL,
inputs TEXT NOT NULL, status TEXT NOT NULL, worker_id TEXT, file TEXT,
error TEXT, claimed_at TEXT, attempts INTEGER NOT NULL DEFAULT 0,
rendered_prompt TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS jobs_queue ON jobs(status, created_at);
Expand All @@ -99,24 +100,18 @@ def _connect(app: FastAPI) -> sqlite3.Connection:
return connection


def _prompt(title: str, keywords: list[str]) -> str:
lowered_title = title.strip().lower()
clean = [
item.strip()
for item in keywords
if item.strip()
and item.isascii()
and lowered_title not in item.strip().lower()
and item.strip().lower() not in lowered_title
]
scene = ", ".join(clean) if clean and len(clean) == len(keywords) else "a quiet landscape with balanced natural forms and soft atmospheric light"
return f"Wordless book cover artwork, no typography. Scene: {scene}. No text, letters, logo, watermark, or signature."


def _inputs(prompt: str, style_id: str, seed: int, kind: str, prefix: str) -> dict[str, Any]:
def _inputs(
style_id: str,
seed: int,
kind: str,
prefix: str,
*,
title: str | None = None,
keywords: list[str] | None = None,
prompt: str | None = None,
) -> dict[str, Any]:
final = kind == "final"
return {
"prompt": prompt,
inputs = {
"style": STYLES[style_id]["prompt"],
"seed": seed,
"prefix": prefix,
Expand All @@ -128,6 +123,12 @@ def _inputs(prompt: str, style_id: str, seed: int, kind: str, prefix: str) -> di
"width": WIDTH,
"height": HEIGHT,
}
if final:
inputs["prompt"] = prompt
else:
inputs["title"] = title
inputs["keywords"] = keywords
return inputs


def worker_auth(authorization: Annotated[str | None, Header()] = None) -> None:
Expand All @@ -152,6 +153,9 @@ async def lifespan(app: FastAPI):
with _connect(app) as connection:
connection.execute("PRAGMA journal_mode = WAL")
connection.executescript(SCHEMA)
columns = {row[1] for row in connection.execute("PRAGMA table_info(jobs)")}
if "rendered_prompt" not in columns:
connection.execute("ALTER TABLE jobs ADD COLUMN rendered_prompt TEXT")
yield

app = FastAPI(title="GPU cover worker service", lifespan=lifespan)
Expand All @@ -170,17 +174,19 @@ def styles() -> dict:
@app.post("/api/covers")
def create_cover(body: CoverCreate) -> dict:
request_id, created_at = str(uuid.uuid4()), now()
prompt = _prompt(body.title, body.keywords)
seed = secrets.randbelow(2_000_000_000)
with _connect(app) as connection:
connection.execute("BEGIN IMMEDIATE")
connection.execute(
"INSERT INTO requests(id,title,keywords,prompt,seed,created_at) VALUES(?,?,?,?,?,?)",
(request_id, body.title, json.dumps(body.keywords, ensure_ascii=False), prompt, seed, created_at),
(request_id, body.title, json.dumps(body.keywords, ensure_ascii=False), "", seed, created_at),
)
for index, style_id in enumerate(STYLES):
job_id = str(uuid.uuid4())
inputs = _inputs(prompt, style_id, seed + index, "preview", f"{request_id}_{style_id}_preview")
inputs = _inputs(
style_id, seed + index, "preview", f"{request_id}_{style_id}_preview",
title=body.title, keywords=body.keywords,
)
connection.execute(
"""INSERT INTO jobs(id,request_id,style_id,kind,workflow,inputs,status,created_at)
VALUES(?,?,?,'preview','cover',?,'queued',?)""",
Expand Down Expand Up @@ -225,12 +231,22 @@ def get_cover(request_id: str) -> dict:
def select_cover(request_id: str, body: Selection) -> dict:
with _connect(app) as connection:
connection.execute("BEGIN IMMEDIATE")
stored = connection.execute("SELECT prompt,seed FROM requests WHERE id=?", (request_id,)).fetchone()
stored = connection.execute("SELECT seed FROM requests WHERE id=?", (request_id,)).fetchone()
if stored is None:
raise HTTPException(404, "cover request not found")
index = list(STYLES).index(body.style_id)
preview = connection.execute(
"""SELECT status,rendered_prompt,inputs FROM jobs
WHERE request_id=? AND kind='preview' AND style_id=?""",
(request_id, body.style_id),
).fetchone()
if preview is None or preview["status"] != "done" or not preview["rendered_prompt"]:
raise HTTPException(409, "selected preview is not done with a rendered prompt")
preview_seed = json.loads(preview["inputs"])["seed"]
job_id, created_at = str(uuid.uuid4()), now()
inputs = _inputs(stored["prompt"], body.style_id, stored["seed"] + index, "final", f"{request_id}_{body.style_id}_final")
inputs = _inputs(
body.style_id, preview_seed, "final", f"{request_id}_{body.style_id}_final",
prompt=preview["rendered_prompt"],
)
connection.execute(
"""INSERT INTO jobs(id,request_id,style_id,kind,workflow,inputs,status,created_at)
VALUES(?,?,?,'final','cover',?,'queued',?)""",
Expand Down Expand Up @@ -285,7 +301,7 @@ async def job_result(job_id: str, request: Request) -> dict:
if not isinstance(meta_value, str):
raise HTTPException(422, "meta must be a JSON string")
try:
json.loads(meta_value)
meta = json.loads(meta_value)
except json.JSONDecodeError as exc:
raise HTTPException(422, "meta must be valid JSON") from exc
uploads: list[tuple[str, Any]] = []
Expand All @@ -295,11 +311,16 @@ async def job_result(job_id: str, request: Request) -> dict:
if not any(key == "image_0" for key, _ in uploads):
raise HTTPException(422, "image_0 is required")
with _connect(app) as connection:
row = connection.execute("SELECT status FROM jobs WHERE id=?", (job_id,)).fetchone()
row = connection.execute("SELECT status,kind FROM jobs WHERE id=?", (job_id,)).fetchone()
if row is None:
raise HTTPException(404, "job not found")
if row["status"] != "running":
raise HTTPException(409, "job is not running")
rendered_prompt = meta.get("prompt") if isinstance(meta, dict) else None
if row["kind"] == "preview" and (
not isinstance(rendered_prompt, str) or not rendered_prompt.strip()
):
raise HTTPException(422, "preview meta.prompt must be a non-empty string")
first_url = None
for key, upload in uploads:
content = upload.read()
Expand All @@ -315,7 +336,11 @@ async def job_result(job_id: str, request: Request) -> dict:
first_url = f"{public_base_path}/files/{filename}"
with _connect(app) as connection:
connection.execute("BEGIN IMMEDIATE")
connection.execute("UPDATE jobs SET status='done',file=?,error=NULL WHERE id=? AND status='running'", (first_url, job_id))
connection.execute(
"""UPDATE jobs SET status='done',file=?,error=NULL,rendered_prompt=?
WHERE id=? AND status='running'""",
(first_url, rendered_prompt, job_id),
)
connection.commit()
return {"id": job_id, "status": "done", "file": first_url}

Expand Down
89 changes: 72 additions & 17 deletions tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,19 +76,17 @@ def test_create_accepts_exact_schema(client, bad):
assert client.post("/api/covers", json=bad).status_code == 422


def test_prompt_is_ascii_english_and_does_not_leak_non_ascii_input(client):
response = create_cover(client, title="달빛 고양이", keywords=["숲", "달빛"])
def test_korean_input_is_stored_exactly_and_preview_claim_omits_prompt(client):
response = create_cover(client, title=" 달빛 고양이 ", keywords=["숲", "달빛", "고요한 밤"])
assert response.status_code == 200
db = sqlite3.connect(os.environ["DATABASE_PATH"])
prompt = db.execute("SELECT prompt FROM requests").fetchone()[0]
assert prompt.isascii()
assert "달빛 고양이" not in prompt and "숲" not in prompt and "달빛" not in prompt
assert "moon" not in prompt.lower() and "cat" not in prompt.lower()
assert "generic" not in prompt.lower()

create_cover(client, title="Secret Forest", keywords=["Secret Forest"])
prompts = [row[0] for row in db.execute("SELECT prompt FROM requests ORDER BY rowid")]
assert "secret forest" not in prompts[-1].lower()
stored = db.execute("SELECT title,keywords FROM requests").fetchone()
assert stored[0] == " 달빛 고양이 "
assert json.loads(stored[1]) == ["숲", "달빛", "고요한 밤"]
inputs = claim(client).json()["inputs"]
assert "prompt" not in inputs
assert inputs["title"] == " 달빛 고양이 "
assert inputs["keywords"] == ["숲", "달빛", "고요한 밤"]


def test_claim_exact_payload_atomic_and_records_worker(client):
Expand All @@ -105,7 +103,7 @@ def get_claim(index):
for job in jobs:
assert set(job) == {"id", "workflow", "inputs"}
assert set(job["inputs"]) == {
"prompt", "style", "seed", "prefix", "out_width", "out_height", "format",
"title", "keywords", "style", "seed", "prefix", "out_width", "out_height", "format",
"quality", "steps", "width", "height",
}
db = sqlite3.connect(os.environ["DATABASE_PATH"])
Expand Down Expand Up @@ -153,7 +151,8 @@ def test_get_progress_result_upload_and_final_selection(client):
assert all(set(item) == {"style_id", "label", "status", "url"} for item in initial["candidates"])

first = claim(client).json()
meta = {"renderer": "test", "images": 1}
rendered_prompt = "Moonlit forest — exact GPU prompt, punctuation preserved."
meta = {"renderer": "test", "images": 1, "prompt": rendered_prompt}
done = client.post(
f"/jobs/{first['id']}/result",
data={"meta": json.dumps(meta)},
Expand All @@ -173,7 +172,8 @@ def test_get_progress_result_upload_and_final_selection(client):
)
assert selected.status_code == 200
final_job = claim(client, "final-worker").json()
assert final_job["inputs"]["prompt"] == first["inputs"]["prompt"]
assert final_job["inputs"]["prompt"] == rendered_prompt
assert "title" not in final_job["inputs"] and "keywords" not in final_job["inputs"]
assert final_job["inputs"]["seed"] == first["inputs"]["seed"]
assert (final_job["inputs"]["out_width"], final_job["inputs"]["out_height"]) == (1795, 2657)
assert (final_job["inputs"]["width"], final_job["inputs"]["height"]) == (1024, 1536)
Expand Down Expand Up @@ -208,7 +208,8 @@ def test_final_status_controls_request_status_when_final_exists(client):
request_id = create_cover(client).json()["request_id"]
with sqlite3.connect(os.environ["DATABASE_PATH"]) as db:
db.execute(
"UPDATE jobs SET status='done' WHERE request_id=? AND kind='preview'",
"""UPDATE jobs SET status='done',rendered_prompt='completed prompt'
WHERE request_id=? AND kind='preview' AND style_id='watercolour'""",
(request_id,),
)
client.post(f"/api/covers/{request_id}/select", json={"style_id": "watercolour"})
Expand All @@ -232,7 +233,7 @@ def test_public_base_path_prefixes_urls_but_static_route_stays_internal(tmp_path
request_id = create_cover(prefixed).json()["request_id"]
job = claim(prefixed).json()
result = prefixed.post(
f"/jobs/{job['id']}/result", data={"meta": "{}"},
f"/jobs/{job['id']}/result", data={"meta": json.dumps({"prompt": "rendered"})},
files={"image_0": ("preview.webp", io.BytesIO(b"prefixed"), "image/webp")},
).json()
expected = f"/image/files/{job['id']}_image_0.webp"
Expand All @@ -255,7 +256,7 @@ def test_result_requires_meta_and_image_zero_but_accepts_multiple_images(client)
files={"image_0": ("x.webp", io.BytesIO(b"x"), "image/webp")},
).status_code == 422
response = client.post(
f"/jobs/{job['id']}/result", data={"meta": "{}"},
f"/jobs/{job['id']}/result", data={"meta": json.dumps({"prompt": "rendered"})},
files=[
("image_0", ("a.webp", io.BytesIO(b"a"), "image/webp")),
("image_1", ("b.webp", io.BytesIO(b"b"), "image/webp")),
Expand All @@ -264,6 +265,60 @@ def test_result_requires_meta_and_image_zero_but_accepts_multiple_images(client)
assert response.status_code == 200


def test_preview_result_requires_non_empty_prompt_and_persists_it(client):
create_cover(client)
job = claim(client).json()
for meta in ({}, {"prompt": ""}, {"prompt": " "}, {"prompt": 12}):
response = client.post(
f"/jobs/{job['id']}/result", data={"meta": json.dumps(meta)},
files={"image_0": ("x.webp", io.BytesIO(b"x"), "image/webp")},
)
assert response.status_code == 422
prompt = "Exact rendered prompt 한 글자도 바꾸지 않음"
response = client.post(
f"/jobs/{job['id']}/result", data={"meta": json.dumps({"prompt": prompt})},
files={"image_0": ("x.webp", io.BytesIO(b"x"), "image/webp")},
)
assert response.status_code == 200
with sqlite3.connect(os.environ["DATABASE_PATH"]) as db:
assert db.execute("SELECT rendered_prompt FROM jobs WHERE id=?", (job["id"],)).fetchone()[0] == prompt


def test_selection_requires_done_preview_with_rendered_prompt(client):
request_id = create_cover(client).json()["request_id"]
endpoint = f"/api/covers/{request_id}/select"
assert client.post(endpoint, json={"style_id": "watercolour"}).status_code == 409
with sqlite3.connect(os.environ["DATABASE_PATH"]) as db:
db.execute(
"UPDATE jobs SET status='done' WHERE request_id=? AND style_id='watercolour' AND kind='preview'",
(request_id,),
)
assert client.post(endpoint, json={"style_id": "watercolour"}).status_code == 409


def test_lifespan_migrates_existing_database_without_data_loss(tmp_path, monkeypatch):
database = tmp_path / "legacy.db"
with sqlite3.connect(database) as db:
db.executescript("""
CREATE TABLE requests (id TEXT PRIMARY KEY, title TEXT NOT NULL, keywords TEXT NOT NULL,
prompt TEXT NOT NULL, seed INTEGER NOT NULL, created_at TEXT NOT NULL);
CREATE TABLE jobs (id TEXT PRIMARY KEY, request_id TEXT NOT NULL REFERENCES requests(id),
style_id TEXT NOT NULL, kind TEXT NOT NULL, workflow TEXT NOT NULL, inputs TEXT NOT NULL,
status TEXT NOT NULL, worker_id TEXT, file TEXT, error TEXT, claimed_at TEXT,
attempts INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL);
INSERT INTO requests VALUES ('r','기존 제목','["키워드"]','legacy',7,'now');
INSERT INTO jobs VALUES ('j','r','watercolour','preview','cover','{}','done',NULL,
'/files/old.webp',NULL,NULL,1,'now');
""")
monkeypatch.setenv("DATABASE_PATH", str(database))
monkeypatch.setenv("FILES_DIR", str(tmp_path / "files"))
with TestClient(create_app()):
pass
with sqlite3.connect(database) as db:
assert "rendered_prompt" in {row[1] for row in db.execute("PRAGMA table_info(jobs)")}
assert db.execute("SELECT title,file FROM requests JOIN jobs ON jobs.request_id=requests.id").fetchone() == ("기존 제목", "/files/old.webp")


def test_failure_retryable_policy_and_exact_json(client):
create_cover(client)
job = claim(client).json()
Expand Down
Loading