diff --git a/tour-comfy/car.html b/tour-comfy/car.html index 4d34ae7..a4e0a64 100644 --- a/tour-comfy/car.html +++ b/tour-comfy/car.html @@ -2556,34 +2556,66 @@ // --- HYDRATION_START --- const PRELOADED_IMAGES = [ - "20260701_064213_sc_pad_view_000_000deg.png", + "20260701_113041_sc_3_20260628_205049_image.png", + "20260701_112945_sc_3_20260628_205049_image.png", + "20260701_112713_sc_3_20260628_205049_image.png", + "20260701_112118_sc_image.nobg.png", + "20260701_111927_sc_image.nobg.png", + "20260701_111515_sc_image.nobg.png", + "20260701_111415_sc_image.png", + "20260701_111333_image.png", + "20260701_111102_sc_image.png", + "20260701_111027_image.png", + "20260701_110958_test_upload_image.png", + "20260701_110930_test_upload_image.png", + "20260701_110842_test_upload_image.png", + "20260701_110730_image.png", + "20260701_110604_sc_image.png", + "20260701_110400_sc_image.png", + "20260701_105852_sc_image.nobg.png", + "20260701_105307_sc_image.nobg.nobg.png", + "20260701_105019_sc_image.nobg.nobg.png", + "20260701_105003_sc_image.nobg.png", + "20260701_104901_sc_image.nobg.png", + "20260701_064734_sc_image.nobg.nobg.png", + "20260701_104623_sc_image.nobg.png", + "20260701_104606_sc_image.nobg.png", + "20260701_104550_sc_image.nobg.png", + "_turntable/cg_9aadaf6d/views/view_023_345deg.png", + "_turntable/cg_9aadaf6d/views/view_022_330deg.png", + "_turntable/cg_9aadaf6d/views/view_021_315deg.png", + "_turntable/cg_9aadaf6d/views/view_020_300deg.png", + "_turntable/cg_9aadaf6d/views/view_019_285deg.png", + "_turntable/cg_9aadaf6d/views/view_018_270deg.png", + "_turntable/cg_9aadaf6d/views/view_017_255deg.png", + "_turntable/cg_9aadaf6d/views/view_016_240deg.png", + "_turntable/cg_9aadaf6d/views/view_015_225deg.png", + "_turntable/cg_9aadaf6d/views/view_014_210deg.png", + "_turntable/cg_9aadaf6d/views/view_013_195deg.png", + "_turntable/cg_9aadaf6d/views/view_012_180deg.png", + "_turntable/cg_9aadaf6d/views/view_011_165deg.png", + "_turntable/cg_9aadaf6d/views/view_010_150deg.png", + "_turntable/cg_9aadaf6d/views/view_009_135deg.png", + "_turntable/cg_9aadaf6d/views/view_008_120deg.png", + "_turntable/cg_9aadaf6d/views/view_007_105deg.png", + "_turntable/cg_9aadaf6d/views/view_006_090deg.png", + "_turntable/cg_9aadaf6d/views/view_005_075deg.png", + "_turntable/cg_9aadaf6d/views/view_004_060deg.png", + "_turntable/cg_9aadaf6d/views/view_003_045deg.png", + "_turntable/cg_9aadaf6d/views/view_002_030deg.png", + "20260701_065556_sc_3_20260628_205049_image.nobg.png", + "_turntable/cg_9aadaf6d/views/view_001_015deg.png", + "_turntable/cg_9aadaf6d/views/view_000_000deg.png", + "20260701_064913_sc_1_20260618_053458_image.png", + "20260701_064840_mr_1_20260618_053458_image.png", + "20260701_064828_sc_image.png", + "20260701_064812_test_upload_image.png", + "20260701_064734_test_upload_image.png", + "20260701_064603_sc_image.png", + "20260701_064521_sc_image.png", + "20260701_064420_sc_image.png", "20260701_063422_sc_2_20260618_052526_image.nobg.nobg.png", - "20260701_062850_sc_pad_view_000_000deg.png", - "20260701_062218_pad_view_000_000deg.png", - "20260701_062201_pad_view_000_000deg.png", - "20260701_062144_pad_view_000_000deg.png", - "20260701_062128_pad_view_000_000deg.png", - "20260701_062111_pad_view_000_000deg.png", - "20260701_062054_pad_view_000_000deg.png", - "20260701_062037_pad_view_000_000deg.png", - "20260701_061913_pad_view_000_000deg.png", - "20260701_061854_pad_view_000_000deg.png", - "20260701_061837_pad_view_000_000deg.png", - "20260701_061814_pad_view_000_000deg.png", - "20260701_060854_pad_view_000_000deg.png", - "20260701_060215_pad_view_000_000deg.png", - "20260701_060110_pad_view_000_000deg.png", - "20260701_060054_pad_view_000_000deg.png", - "20260701_055944_pad_view_000_000deg.png", - "20260701_055727_pad_view_000_000deg.png", - "20260701_055710_pad_view_000_000deg.png", - "20260701_055654_pad_view_000_000deg.png", - "20260701_055637_pad_view_000_000deg.png", - "20260701_055621_pad_view_000_000deg.png", - "20260701_055605_pad_view_000_000deg.png", - "20260701_055542_pad_view_000_000deg.png", "up_f475c600_face.png", - "20260701_055451_pad_view_000_000deg.png", "20260701_051754_image.png", "20260701_051737_image.png", "20260701_051721_image.png", @@ -3265,7 +3297,6 @@ "20260628_205348_9_20260628_205049_image.png", "20260628_205336_8_20260628_205049_image.png", "20260628_205325_7_20260628_205049_image.png", - "20260628_205302_5_20260628_205049_image.png", "20260628_205240_3_20260628_205049_image.png", "20260628_205229_2_20260628_205049_image.png", "20260628_205217_1_20260628_205049_image.png", @@ -16035,6 +16066,30 @@ if (!bg) { showToast('Set a background (Image 1) — capture, upload, or Shift+Click', 'error'); return; } if (!person || person.kind !== 'file') { showToast('Set a person image (Image 2) from the filmstrip', 'error'); return; } if (extra && extra.kind !== 'file') { showToast('Image 3 must be a gallery image', 'error'); return; } + + // Validate distinct files + if (bg.kind === 'file' && person.kind === 'file' && bg.name === person.name) { + showToast('Image 1 (scenery) and Image 2 (person) cannot be the same image.', 'error'); + return; + } + if (bg.kind === 'wireframe' && person.kind === 'file' && bg.name === person.name) { + showToast('Image 1 (scenery) and Image 2 (person) cannot be the same image.', 'error'); + return; + } + if (extra && extra.kind === 'file') { + if (person.kind === 'file' && extra.name === person.name) { + showToast('Image 3 (extra reference) and Image 2 (person) cannot be the same image.', 'error'); + return; + } + if (bg.kind === 'file' && extra.name === bg.name) { + showToast('Image 3 (extra reference) and Image 1 (scenery) cannot be the same image.', 'error'); + return; + } + if (bg.kind === 'wireframe' && extra.name === bg.name) { + showToast('Image 3 (extra reference) and Image 1 (scenery) cannot be the same image.', 'error'); + return; + } + } const prompt = (document.getElementById('scenePromptInput')?.value || '').trim() || null; const btn = document.getElementById('sceneGenBtn'); const statusEl = document.getElementById('sceneJobStatus'); @@ -16051,11 +16106,23 @@ let sceneImage = null; if (bg.kind === 'bytes') { sceneBytes = bg.data; + if (bg.filename) { + sceneImage = bg.filename; + } } else if (bg.kind === 'wireframe') { sceneImage = bg.name; + } else if (bg.kind === 'file') { + sceneImage = bg.name; + try { + sceneBytes = await _fetchAsBase64(_sceneUrlForName(bg.name)); + } catch (e) { + showToast('Could not read background image: ' + e, 'error'); + if (btn) btn.disabled = false; if (statusEl) statusEl.style.display = 'none'; return; + } } else { try { sceneBytes = await _fetchAsBase64(_sceneUrlForName(bg.name)); + sceneImage = bg.name; } catch (e) { showToast('Could not read background image: ' + e, 'error'); if (btn) btn.disabled = false; if (statusEl) statusEl.style.display = 'none'; return; diff --git a/tour-comfy/edit_api.py b/tour-comfy/edit_api.py index 769f612..3e3555e 100644 --- a/tour-comfy/edit_api.py +++ b/tour-comfy/edit_api.py @@ -4512,7 +4512,24 @@ def _scenery_worker(job_id: str, model_filename: str, scene_pil: Image.Image, scene_video: str | None = None, scene_image: str | None = None, extra_filename: str | None = None): output_dir = _load_output_dir() + out_path = None try: + # Construct and validate source references early + refs = [model_filename] + if scene_video: + refs.append(f"video:{scene_video}") + if scene_image: + wireframe_dir = _load_wireframe_dir() + if os.path.exists(os.path.join(wireframe_dir, scene_image)) or not os.path.exists(os.path.join(output_dir, scene_image)): + refs.append(f"wireframe:{scene_image}") + else: + refs.append(scene_image) + if extra_filename: + refs.append(extra_filename) + + if len(refs) < 2: + raise ValueError(f"Scenery generation failed: expected at least 2 source references (model and scene), but got {len(refs)}: {refs}") + model_path = os.path.join(output_dir, model_filename) model_pil = Image.open(model_path).convert("RGB") @@ -4540,17 +4557,10 @@ def _scenery_worker(job_id: str, model_filename: str, scene_pil: Image.Image, f.write(png_bytes) person = database.get_person(model_filename) group_id = person[1] if person and person[1] else naming.get_base_name(os.path.basename(model_filename)) + try: embedding = embeddings.generate_embedding(out_path) next_order = database.get_next_sort_order(group_id) - # Store all source references: person image, background video (if any), extra ref (if any) - refs = [model_filename] - if scene_video: - refs.append(f"video:{scene_video}") - if scene_image: - refs.append(f"wireframe:{scene_image}") - if extra_filename: - refs.append(extra_filename) database.upsert_person(out_name, filepath=out_path, embedding=embedding, group_id=group_id, prompt=prompt, sort_order=next_order, @@ -4559,6 +4569,8 @@ def _scenery_worker(job_id: str, model_filename: str, scene_pil: Image.Image, _metadata_executor.submit(_process_image_for_metadata, out_name) except Exception as db_err: print(f"[scenery] DB error: {db_err}") + raise + jobs[job_id]["latest_output"] = out_name jobs[job_id]["status"] = "done" jobs[job_id]["output"] = out_name @@ -4570,6 +4582,12 @@ def _scenery_worker(job_id: str, model_filename: str, scene_pil: Image.Image, print(f"[scenery] error: {e}") jobs[job_id]["status"] = "error" jobs[job_id]["error"] = str(e) + if out_path and os.path.exists(out_path): + try: + os.remove(out_path) + print(f"[scenery] Cleaned up unregistered file due to error: {out_path}") + except Exception as clean_err: + print(f"[scenery] Clean up error: {clean_err}") @app.post("/generate-scenery") @@ -4577,24 +4595,71 @@ def generate_scenery(req: SceneryRequest): output_dir = _load_output_dir() wireframe_dir = _load_wireframe_dir() - model_path = os.path.join(output_dir, req.model_filename) + if not req.model_filename: + raise HTTPException(400, "Model image (Image 2) is required and cannot be empty.") + + # Strip query parameters (e.g. ?t=...) + clean_model_filename = req.model_filename.split('?')[0].strip() + if not clean_model_filename: + raise HTTPException(400, "Model image filename is empty after cleaning.") + + model_path = os.path.join(output_dir, clean_model_filename) if not os.path.exists(model_path): - raise HTTPException(404, f"Model image not found: {req.model_filename}") + raise HTTPException(404, f"Model image not found: {clean_model_filename}") + if not os.path.isfile(model_path): + raise HTTPException(400, f"Model path is not a file: {clean_model_filename}") + + # Validate that it is a valid, openable image file + try: + with Image.open(model_path) as img: + img.verify() + except Exception as img_err: + raise HTTPException(400, f"Invalid or corrupt model image: {img_err}") # Resolve scene image + clean_scene_image = req.scene_image.split('?')[0].strip() if req.scene_image else None + + # Enforce that we have at least one background source reference (either scene_image or scene_video) + if not clean_scene_image and not req.scene_video: + raise HTTPException(400, "Scenery generation requires a background scene reference (either scene_image or scene_video).") + + # Check if model image and scene image are identical files + if clean_scene_image and clean_scene_image == clean_model_filename: + raise HTTPException(400, "Image 1 (scenery) and Image 2 (person) cannot be the same image.") + + # Verify background reference existence on disk early if clean_scene_image is provided + if clean_scene_image: + image_path = os.path.join(wireframe_dir, clean_scene_image) + if not os.path.exists(image_path): + image_path = os.path.join(output_dir, clean_scene_image) # Check output_dir as fallback! + if not os.path.exists(image_path): + raise HTTPException(404, f"Scene image not found: {clean_scene_image}") + if req.scene_bytes: import base64 - raw = base64.b64decode(req.scene_bytes) - scene_pil = Image.open(io.BytesIO(raw)).convert("RGB") - elif req.scene_image: - image_path = os.path.join(wireframe_dir, req.scene_image) + try: + raw = base64.b64decode(req.scene_bytes) + # Verify image + with Image.open(io.BytesIO(raw)) as img: + img.verify() + scene_pil = Image.open(io.BytesIO(raw)).convert("RGB") + except Exception as img_err: + raise HTTPException(400, f"Invalid or corrupt scene image: {img_err}") + elif clean_scene_image: + image_path = os.path.join(wireframe_dir, clean_scene_image) if not os.path.exists(image_path): - raise HTTPException(404, f"Scene image not found: {req.scene_image}") - scene_pil = Image.open(image_path).convert("RGB") + image_path = os.path.join(output_dir, clean_scene_image) + try: + with Image.open(image_path) as img: + img.verify() + scene_pil = Image.open(image_path).convert("RGB") + except Exception as img_err: + raise HTTPException(400, f"Invalid or corrupt scene image: {img_err}") elif req.scene_video: - video_path = os.path.join(wireframe_dir, req.scene_video) + clean_scene_video = req.scene_video.split('?')[0].strip() + video_path = os.path.join(wireframe_dir, clean_scene_video) if not os.path.exists(video_path): - raise HTTPException(404, f"Scene video not found: {req.scene_video}") + raise HTTPException(404, f"Scene video not found: {clean_scene_video}") try: scene_pil = _extract_frame_at(video_path, req.scene_time) except Exception as e: @@ -4604,27 +4669,47 @@ def generate_scenery(req: SceneryRequest): # Optional image3 (Picture 3) — extra reference from output_dir extra_pils = [] - if req.extra_filename: - extra_path = os.path.join(output_dir, req.extra_filename) + clean_extra_filename = req.extra_filename.split('?')[0].strip() if req.extra_filename else None + if clean_extra_filename: + if clean_extra_filename == clean_model_filename: + raise HTTPException(400, "Image 3 (extra reference) and Image 2 (person) cannot be the same image.") + if clean_scene_image and clean_extra_filename == clean_scene_image: + raise HTTPException(400, "Image 3 (extra reference) and Image 1 (scenery) cannot be the same image.") + + extra_path = os.path.join(output_dir, clean_extra_filename) if not os.path.exists(extra_path): - raise HTTPException(404, f"Extra reference not found: {req.extra_filename}") - extra_pils.append(Image.open(extra_path).convert("RGB")) + raise HTTPException(404, f"Extra reference not found: {clean_extra_filename}") + if not os.path.isfile(extra_path): + raise HTTPException(400, f"Extra reference path is not a file: {clean_extra_filename}") + try: + with Image.open(extra_path) as img: + img.verify() + extra_pils.append(Image.open(extra_path).convert("RGB")) + except Exception as img_err: + raise HTTPException(400, f"Invalid or corrupt extra reference image: {img_err}") - prompt = req.prompt or ( - "Place the person from Picture 2 naturally inside the environment shown in Picture 1. " - "Keep the person's face, body proportions, clothing and pose exactly as in Picture 2. " - "Use the location, lighting and atmosphere from Picture 1 as the background. " - "Match the color temperature and shadows so it looks like one photograph taken on location. " - + ("Incorporate the reference from Picture 3 as well. " if extra_pils else "") - + "Output a single photorealistic image. High quality, detailed." - ) + # Process and refine prompt to map Image 1/2/3 to Picture 1/2/3 + prompt = req.prompt + if prompt: + prompt = re.sub(r"(?i)\bimage\s*1\b", "Picture 1", prompt) + prompt = re.sub(r"(?i)\bimage\s*2\b", "Picture 2", prompt) + prompt = re.sub(r"(?i)\bimage\s*3\b", "Picture 3", prompt) + else: + prompt = ( + "Place the person from Picture 2 naturally inside the environment shown in Picture 1. " + "Keep the person's face, body proportions, clothing and pose exactly as in Picture 2. " + "Use the location, lighting and atmosphere from Picture 1 as the background. " + "Match the color temperature and shadows so it looks like one photograph taken on location. " + + ("Incorporate the reference from Picture 3 as well. " if extra_pils else "") + + "Output a single photorealistic image. High quality, detailed." + ) try: database.save_db_prompt("scene", prompt, { - "model_filename": req.model_filename, + "model_filename": clean_model_filename, "scene_video": req.scene_video, - "scene_image": req.scene_image, - "extra_filename": req.extra_filename, + "scene_image": clean_scene_image, + "extra_filename": clean_extra_filename, "seed": req.seed }) except Exception as db_err: @@ -4634,13 +4719,13 @@ def generate_scenery(req: SceneryRequest): jobs[job_id] = {"status": "running", "type": "scenery", "total": 1, "done": 0, "failed": 0} threading.Thread( target=_scenery_worker, - args=(job_id, req.model_filename, scene_pil, prompt, req.seed, extra_pils), + args=(job_id, clean_model_filename, scene_pil, prompt, req.seed, extra_pils), kwargs={"scene_video": req.scene_video if req.scene_video else None, - "scene_image": req.scene_image if req.scene_image else None, - "extra_filename": req.extra_filename}, + "scene_image": clean_scene_image if clean_scene_image else None, + "extra_filename": clean_extra_filename}, daemon=True, ).start() - return {"job_id": job_id, "model": req.model_filename} + return {"job_id": job_id, "model": clean_model_filename} @app.get("/scenery/library") diff --git a/tour-comfy/test/test_scenery_case.py b/tour-comfy/test/test_scenery_case.py new file mode 100644 index 0000000..7348560 --- /dev/null +++ b/tour-comfy/test/test_scenery_case.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 +import argparse +import contextlib +import os +import re +import shutil +import sys +import tempfile +import traceback +from pathlib import Path +from unittest.mock import patch + +IMAGE_EXTS = [".png", ".jpg", ".jpeg", ".webp"] + + +def slug(value: str) -> str: + return re.sub(r"[^a-zA-Z0-9_.-]+", "_", value).strip("_") or "case" + + +def find_image(folder: Path, stem: str) -> Path | None: + for file in sorted(folder.iterdir()): + if file.is_file() and file.stem.lower() == stem.lower() and file.suffix.lower() in IMAGE_EXTS: + return file + return None + + +def find_cases(root: Path) -> list[dict]: + cases = [] + + for folder, _, _ in os.walk(root): + folder = Path(folder) + + prompt_file = folder / "prompt.md" + image1 = find_image(folder, "image1") + image2 = find_image(folder, "image2") + + if prompt_file.exists() and image1 and image2: + cases.append({ + "folder": folder, + "image1": image1, + "image2": image2, + "prompt": prompt_file, + }) + + return cases + + +def expected_prompt_refinement(prompt: str) -> str: + # Match de intent uit je bestaande test: + # "image 1" / "Image 2" moet "Picture 1" / "Picture 2" worden. + prompt = re.sub(r"\bimage\s+1\b", "Picture 1", prompt, flags=re.IGNORECASE) + prompt = re.sub(r"\bimage\s+2\b", "Picture 2", prompt, flags=re.IGNORECASE) + return prompt + + +def run_case(edit_api, HTTPException, case: dict, output_dir: Path, check_refinement: bool) -> tuple[bool, str]: + folder = case["folder"] + case_name = slug(folder.relative_to(folder.parents[0]).as_posix()) + + model_filename = f"{case_name}__image1{case['image1'].suffix.lower()}" + scene_filename = f"{case_name}__image2{case['image2'].suffix.lower()}" + + model_path = output_dir / model_filename + scene_path = output_dir / scene_filename + + shutil.copy2(case["image1"], model_path) + shutil.copy2(case["image2"], scene_path) + + prompt = case["prompt"].read_text(encoding="utf-8").strip() + + saved_prompts = [] + + def mock_save_db_prompt(ptype, prompt_text, meta): + saved_prompts.append(prompt_text) + + # Test 1: missing model image -> 404 + req_missing = edit_api.SceneryRequest( + model_filename=f"missing_{case_name}.png", + scene_image=scene_filename, + prompt=prompt, + ) + + try: + edit_api.generate_scenery(req_missing) + return False, "Missing-model check faalde: er kwam geen HTTPException." + except HTTPException as exc: + if exc.status_code != 404: + return False, f"Missing-model check gaf status {exc.status_code}, verwacht 404." + if "Model image not found" not in str(exc.detail): + return False, f"Missing-model check detail onverwacht: {exc.detail}" + + # Test 2: model en scene zijn hetzelfde bestand -> 400 + req_identical = edit_api.SceneryRequest( + model_filename=model_filename, + scene_image=model_filename, + prompt=prompt, + ) + + try: + edit_api.generate_scenery(req_identical) + return False, "Identical-image check faalde: er kwam geen HTTPException." + except HTTPException as exc: + if exc.status_code != 400: + return False, f"Identical-image check gaf status {exc.status_code}, verwacht 400." + if "cannot be the same image" not in str(exc.detail): + return False, f"Identical-image check detail onverwacht: {exc.detail}" + + # Test 3: geldige case, prompt refinement controleren, geen echte thread starten + req_valid = edit_api.SceneryRequest( + model_filename=model_filename, + scene_image=scene_filename, + prompt=prompt, + ) + + with patch("edit_api._load_wireframe_dir", return_value=str(output_dir), create=True), \ + patch("edit_api.database.save_db_prompt", side_effect=mock_save_db_prompt), \ + patch("threading.Thread.start"): + + edit_api.generate_scenery(req_valid) + + if len(saved_prompts) != 1: + return False, f"save_db_prompt werd {len(saved_prompts)} keer aangeroepen, verwacht 1." + + if check_refinement: + expected = expected_prompt_refinement(prompt) + actual = saved_prompts[0] + + if actual != expected: + return False, ( + "Prompt refinement klopt niet.\n" + f"Expected: {expected!r}\n" + f"Actual: {actual!r}" + ) + + return True, "OK" + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "--cases-dir", + default="scenery_cases", + help="Root-folder met subfolders die image1.*, image2.* en prompt.md bevatten.", + ) + parser.add_argument( + "--output-dir", + default=None, + help="Optioneel vaste output-dir. Default gebruikt een tijdelijke map.", + ) + parser.add_argument( + "--no-refinement-check", + action="store_true", + help="Alleen controleren dat generate_scenery werkt, niet de exacte prompt-output.", + ) + + args = parser.parse_args() + + project_root = Path.cwd() + sys.path.insert(0, str(project_root)) + + import edit_api + from fastapi import HTTPException + + cases_root = Path(args.cases_dir).resolve() + + if not cases_root.exists(): + print(f"ERROR: cases-dir bestaat niet: {cases_root}") + return 2 + + cases = find_cases(cases_root) + + if not cases: + print(f"Geen testcases gevonden onder: {cases_root}") + print("Verwachte structuur per testcase:") + print(" some_case/") + print(" image1.png") + print(" image2.png") + print(" prompt.md") + return 2 + + if args.output_dir: + output_dir = Path(args.output_dir).resolve() + output_dir.mkdir(parents=True, exist_ok=True) + cleanup = contextlib.nullcontext(output_dir) + else: + cleanup = tempfile.TemporaryDirectory(prefix="scenery_validation_") + + passed = 0 + failed = 0 + + with cleanup as tmp: + output_dir = Path(tmp) if not isinstance(tmp, Path) else tmp + + print(f"Cases root : {cases_root}") + print(f"Output dir : {output_dir}") + print(f"Cases : {len(cases)}") + print() + + for case in cases: + label = case["folder"].relative_to(cases_root) + + try: + ok, message = run_case( + edit_api=edit_api, + HTTPException=HTTPException, + case=case, + output_dir=output_dir, + check_refinement=not args.no_refinement_check, + ) + + if ok: + passed += 1 + print(f"[PASS] {label}") + else: + failed += 1 + print(f"[FAIL] {label}") + print(message) + print() + + except Exception: + failed += 1 + print(f"[ERROR] {label}") + traceback.print_exc() + print() + + print() + print(f"Resultaat: {passed} passed, {failed} failed") + + return 0 if failed == 0 else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tour-comfy/test_regression_api.py b/tour-comfy/test_regression_api.py index 4178e6c..f263ef8 100644 --- a/tour-comfy/test_regression_api.py +++ b/tour-comfy/test_regression_api.py @@ -799,6 +799,116 @@ class TestAPIRegression(unittest.TestCase): except Exception: pass + def test_13b_scenery_validation_checks(self): + from unittest.mock import patch + import edit_api + from fastapi import HTTPException + + # Test 1: model image is completely missing -> raise 404 + req_missing = edit_api.SceneryRequest( + model_filename="non_existent_model_file_999.png", + scene_image="some_scene.png", + prompt="replace person from image 1 with the person from Image 2" + ) + with self.assertRaises(HTTPException) as ctx: + edit_api.generate_scenery(req_missing) + self.assertEqual(ctx.exception.status_code, 404) + self.assertIn("Model image not found", ctx.exception.detail) + + # Write dummy model image on disk for subsequent tests + model_filename = "test_model_validation_123.png" + model_path = os.path.join(self.output_dir, model_filename) + Image.new("RGB", (10, 10), color="red").save(model_path) + + try: + # Test 2: model and scene images are the same file -> raise 400 + req_identical = edit_api.SceneryRequest( + model_filename=model_filename, + scene_image=model_filename, + prompt="replace person from image 1 with the person from Image 2" + ) + with self.assertRaises(HTTPException) as ctx: + edit_api.generate_scenery(req_identical) + self.assertEqual(ctx.exception.status_code, 400) + self.assertIn("cannot be the same image", ctx.exception.detail) + + # Test 3: prompt refinement "image 1/2" -> "Picture 1/2" + # We mock the thread start and save_db_prompt to verify the processed prompt + saved_prompt = [] + def mock_save_db_prompt(ptype, prompt_text, meta): + saved_prompt.append(prompt_text) + + dummy_scene_filename = "dummy_scene_validation_123.png" + dummy_scene_path = os.path.join(self.output_dir, dummy_scene_filename) + Image.new("RGB", (10, 10), color="blue").save(dummy_scene_path) + + try: + req_refine = edit_api.SceneryRequest( + model_filename=model_filename, + scene_image=dummy_scene_filename, + prompt="replace person from image 1 with the person from Image 2" + ) + + with patch("edit_api._load_wireframe_dir", return_value=self.output_dir), \ + patch("edit_api.database.save_db_prompt", side_effect=mock_save_db_prompt), \ + patch("threading.Thread.start") as mock_thread_start: + + edit_api.generate_scenery(req_refine) + + self.assertEqual(len(saved_prompt), 1) + self.assertEqual(saved_prompt[0], "replace person from Picture 1 with the person from Picture 2") + + finally: + if os.path.exists(dummy_scene_path): + os.remove(dummy_scene_path) + + finally: + if os.path.exists(model_path): + os.remove(model_path) + + def test_13c_scenery_generation_insufficient_references_checks(self): + import edit_api + from fastapi import HTTPException + from PIL import Image + + # Write dummy model image on disk + model_filename = "test_scenery_insufficient_model.png" + model_path = os.path.join(self.output_dir, model_filename) + Image.new("RGB", (10, 10), color="red").save(model_path) + + try: + # Test 1: both scene_image and scene_video are missing -> raise HTTPException 400 + req_no_bg = edit_api.SceneryRequest( + model_filename=model_filename, + prompt="some prompt" + ) + with self.assertRaises(HTTPException) as ctx: + edit_api.generate_scenery(req_no_bg) + self.assertEqual(ctx.exception.status_code, 400) + self.assertIn("requires a background scene reference", ctx.exception.detail) + + # Test 2: inside _scenery_worker, if len(refs) < 2, it raises ValueError and sets status to error + edit_api.jobs["test_scenery_insufficient_job"] = {"status": "running"} + dummy_scene = Image.new("RGB", (10, 10), color="blue") + edit_api._scenery_worker( + job_id="test_scenery_insufficient_job", + model_filename=model_filename, + scene_pil=dummy_scene, + prompt="test scenery prompt", + seed=42, + extra_pils=[], + scene_video=None, # explicitly None to trigger the < 2 refs check + scene_image=None, # explicitly None to trigger the < 2 refs check + extra_filename=None + ) + job = edit_api.jobs["test_scenery_insufficient_job"] + self.assertEqual(job["status"], "error") + self.assertIn("expected at least 2 source references", job["error"]) + + finally: + if os.path.exists(model_path): + os.remove(model_path) + def test_14_static_serialization_of_deep_metadata(self): import edit_api import database