From 145fa686e486dc1a37ef7908ee32caa0affc465a Mon Sep 17 00:00:00 2001 From: mike Date: Wed, 1 Jul 2026 02:25:51 +0200 Subject: [PATCH] updates UI --- .../install_junie_ollama_a6000.sh | 0 {tour-comfy => .trash}/watcher.lock | 0 {tour-comfy => .trash}/watcher.py | 11 +- {tour-comfy => MI50}/deploy.sh | 0 {tour-comfy => MI50}/env.sh | 0 .../run_comfyui_mi50.sh | 0 {tour-comfy => MI50}/start_api.sh | 0 {tour-comfy => MI50}/start_watcher.sh | 0 {tour-comfy => MI50}/stop.sh | 0 a6000-comfy/deploy.sh | 6 +- .../install_facefusion.sh | 0 {tour-comfy => a6000-comfy}/install_gfpgan.sh | 0 a6000-comfy/start_api.sh | 9 +- a6000-comfy/systemd/comfyui-watcher.service | 17 - a6000-comfy/systemd/comfyui.target | 6 +- backlog.md | 19 +- studio.sh | 22 + tour-comfy/car.html | 821 +++++++++++++++--- tour-comfy/database.py | 446 ++++++++++ tour-comfy/edit_api.py | 243 +++++- tour-comfy/poses.md | 4 +- tour-comfy/test_regression_qwen.py | 114 +++ 22 files changed, 1559 insertions(+), 159 deletions(-) rename {a6000-comfy => .junie}/install_junie_ollama_a6000.sh (100%) rename {tour-comfy => .trash}/watcher.lock (100%) rename {tour-comfy => .trash}/watcher.py (97%) rename {tour-comfy => MI50}/deploy.sh (100%) rename {tour-comfy => MI50}/env.sh (100%) rename tour-comfy/run_comfyui.sh => MI50/run_comfyui_mi50.sh (100%) rename {tour-comfy => MI50}/start_api.sh (100%) rename {tour-comfy => MI50}/start_watcher.sh (100%) rename {tour-comfy => MI50}/stop.sh (100%) rename {tour-comfy => a6000-comfy}/install_facefusion.sh (100%) rename {tour-comfy => a6000-comfy}/install_gfpgan.sh (100%) delete mode 100644 a6000-comfy/systemd/comfyui-watcher.service create mode 100755 studio.sh create mode 100644 tour-comfy/test_regression_qwen.py diff --git a/a6000-comfy/install_junie_ollama_a6000.sh b/.junie/install_junie_ollama_a6000.sh similarity index 100% rename from a6000-comfy/install_junie_ollama_a6000.sh rename to .junie/install_junie_ollama_a6000.sh diff --git a/tour-comfy/watcher.lock b/.trash/watcher.lock similarity index 100% rename from tour-comfy/watcher.lock rename to .trash/watcher.lock diff --git a/tour-comfy/watcher.py b/.trash/watcher.py similarity index 97% rename from tour-comfy/watcher.py rename to .trash/watcher.py index 5455e50..81009ec 100644 --- a/tour-comfy/watcher.py +++ b/.trash/watcher.py @@ -60,8 +60,11 @@ def get_processed_files(): def save_processed_files(processed): try: - with open(CONF["processed_file"], 'w') as f: + p = CONF["processed_file"] + tmp = p + ".tmp" + with open(tmp, 'w') as f: json.dump(processed, f, indent=2) + os.replace(tmp, p) except Exception as e: logging.error(f"Error saving processed file: {e}") @@ -253,9 +256,11 @@ def update_car_html(): new_content = re.sub(pattern, replacement, content, flags=re.DOTALL) - with open(car_html_path, 'w') as f: + tmp_path = car_html_path + ".tmp" + with open(tmp_path, 'w') as f: f.write(new_content) - logging.info(f"Updated {car_html_path} with {len(images)} images") + os.replace(tmp_path, car_html_path) + logging.info(f"Updated {car_html_path} atomically with {len(images)} images") except Exception as e: logging.error(f"Failed to update car.html: {e}") diff --git a/tour-comfy/deploy.sh b/MI50/deploy.sh similarity index 100% rename from tour-comfy/deploy.sh rename to MI50/deploy.sh diff --git a/tour-comfy/env.sh b/MI50/env.sh similarity index 100% rename from tour-comfy/env.sh rename to MI50/env.sh diff --git a/tour-comfy/run_comfyui.sh b/MI50/run_comfyui_mi50.sh similarity index 100% rename from tour-comfy/run_comfyui.sh rename to MI50/run_comfyui_mi50.sh diff --git a/tour-comfy/start_api.sh b/MI50/start_api.sh similarity index 100% rename from tour-comfy/start_api.sh rename to MI50/start_api.sh diff --git a/tour-comfy/start_watcher.sh b/MI50/start_watcher.sh similarity index 100% rename from tour-comfy/start_watcher.sh rename to MI50/start_watcher.sh diff --git a/tour-comfy/stop.sh b/MI50/stop.sh similarity index 100% rename from tour-comfy/stop.sh rename to MI50/stop.sh diff --git a/a6000-comfy/deploy.sh b/a6000-comfy/deploy.sh index 6f60f94..d1c3fdf 100755 --- a/a6000-comfy/deploy.sh +++ b/a6000-comfy/deploy.sh @@ -20,7 +20,7 @@ echo "Installing services: user=$SVC_USER group=$SVC_GROUP" echo " a6k=$A6K" echo " tour=$TOUR" -for unit in comfyui-backend comfyui-api comfyui-watcher; do +for unit in comfyui-backend comfyui-api; do sed -e "s|__USER__|$SVC_USER|g" \ -e "s|__GROUP__|$SVC_GROUP|g" \ -e "s|__A6K__|$A6K|g" \ @@ -36,10 +36,10 @@ echo "Reloading systemd daemon..." systemctl daemon-reload echo "Enabling services + target..." -systemctl enable comfyui-backend.service comfyui-api.service comfyui-watcher.service comfyui.target +systemctl enable comfyui-backend.service comfyui-api.service comfyui.target echo "Starting system..." systemctl start comfyui.target echo "Deployment complete." -echo "Check status with: systemctl status comfyui.target comfyui-backend comfyui-api comfyui-watcher" +echo "Check status with: systemctl status comfyui.target comfyui-backend comfyui-api" diff --git a/tour-comfy/install_facefusion.sh b/a6000-comfy/install_facefusion.sh similarity index 100% rename from tour-comfy/install_facefusion.sh rename to a6000-comfy/install_facefusion.sh diff --git a/tour-comfy/install_gfpgan.sh b/a6000-comfy/install_gfpgan.sh similarity index 100% rename from tour-comfy/install_gfpgan.sh rename to a6000-comfy/install_gfpgan.sh diff --git a/a6000-comfy/start_api.sh b/a6000-comfy/start_api.sh index a8c523e..7b7aac0 100755 --- a/a6000-comfy/start_api.sh +++ b/a6000-comfy/start_api.sh @@ -15,8 +15,15 @@ NV_LIBS=$(find "$VENV"/lib/python*/site-packages/nvidia -maxdepth 2 -name lib -t export COMFY_URL="http://127.0.0.1:8188" export HOST="0.0.0.0" export PORT="8500" + +# A6000 48GB is not VRAM-bound here, so default to a ~2MP output budget. +# This comfortably allows full-HD-ish outputs like 1920x1080. +# Override via MAX_AREA when needed. +export MAX_AREA="${MAX_AREA:-2097152}" + +# @LEGACY PREVIOUS VERSION # The A6000 is fast and not VRAM-bound on this model, so default to a full ~1MP # output budget (tour caps at 0.65MP to survive the MI50). Override via MAX_AREA. -export MAX_AREA="${MAX_AREA:-1048576}" +# export MAX_AREA="${MAX_AREA:-1048576}" exec python edit_api.py diff --git a/a6000-comfy/systemd/comfyui-watcher.service b/a6000-comfy/systemd/comfyui-watcher.service deleted file mode 100644 index c9ebf8a..0000000 --- a/a6000-comfy/systemd/comfyui-watcher.service +++ /dev/null @@ -1,17 +0,0 @@ -[Unit] -Description=Qwen-Image-Edit Folder Watcher (A6000) -After=comfyui-api.service -Requires=comfyui-api.service - -[Service] -Type=simple -User=__USER__ -Group=__GROUP__ -ExecStart=/bin/bash __TOUR__/start_watcher.sh -Restart=on-failure -RestartSec=5 -StandardOutput=journal -StandardError=journal - -[Install] -WantedBy=comfyui.target diff --git a/a6000-comfy/systemd/comfyui.target b/a6000-comfy/systemd/comfyui.target index 127cb9a..c066145 100644 --- a/a6000-comfy/systemd/comfyui.target +++ b/a6000-comfy/systemd/comfyui.target @@ -1,7 +1,7 @@ [Unit] -Description=Qwen-Image-Edit Complete System (A6000) -Wants=comfyui-backend.service comfyui-api.service comfyui-watcher.service -After=comfyui-watcher.service +Description=Afterimage (A6000) +Wants=comfyui-backend.service comfyui-api.service +After=comfyui-backend.service comfyui-api.service [Install] WantedBy=multi-user.target diff --git a/backlog.md b/backlog.md index 3c2957d..680b1c5 100644 --- a/backlog.md +++ b/backlog.md @@ -82,7 +82,21 @@ Scenery: - tab "SAM" checkerboard icon to studio-view overlay - in the landing page hide SOURCE images - als het input bestand real is, dan gaat echt elke opvolging perfect. supe plaatjes.. (3 kwart maakt de opvolgende plaatjes ook altijd engaging.. top down (vanwege ruimte)) -- +- When open the app via the launcher, make it privacy enabled by default, so the privacy screen will appear. +- When open a group by default hide orbit images, videos +- Do not show videos in the landingpage image-loop +- Make - html per group and html for the landing page. Decouple the landingpage from the studio and studio is hydrated available per group +- update svg +- Extract the heavy model API, so we can unit-test it. ( and we can stand-alone disable it ) and doesnt require the entire 41gb model to load into memory for a simple backend update +- Save pose-prompt history + refine + reverse engineer + Scenery prompt in database. create a new db table called prompt, add the type of prompt for exmaple refine or scene to the record and all relevant information used to recontruct the prompt input. so also include a jsonb for metadata/tags etc. +- In order to decouple the model-engine (by design it can be turned off) and the backend (by design it can be turned off) + - in the UI right top make two dots indicating the model-engine, backend is online and if its (actively processing a task), use the healthchecks of the services to check realtime status. + - in case the Model-engine is offline, we want to disable the UI-actions that require the model-engine, such as pose generation, + - in case the backend is offline, we want to disable the UI-actions that require the backend, such as duplicate, pad, crop, etc.. + Start by implmenting the mechanics of the status leds in the UI. and disable one or two features that require the model-engine or the backend to be online. We complete the task after review +- in order to decouple the landingpage/dashboard from the studio view (editor) we are going to generate group "shoot" specific html pages. Start by just generate the studio portion of car.html into the group specifc html pages. It does not need to be synchronous, but when data of the group is changing we should update the group specifc data json file too, we copy the goup files to the output folder next to car.html +- in /mnt/zim/tour-comfy/output/_turntable we have data regarding orbit generations. We want to store the orbit data in the database, start by adding the corresponding tags and meta info to the images (currently stored in the person table (but actually are images with metadata)) +- in the ui - studio view we have an orbit filter. Currently the filter is filtering too many files, also references of orbit files are being marked orbit. When an orbit file is used, the metadata should not include the orbit tag. ## refine - when refresh page, we lose track of current jobs running. @@ -129,7 +143,8 @@ TRACKING 4) add a new video filter, and hide video by default in the Group Active -- feel free to furhter optimize the studio view "info" tab -1) for every image we have an entry in the database, keep for every image an entry with the same filename in the same folder but ending with json. in the json we store the functional representation of the database record and keep it up-to-date when data changes. . +so we have a postgres-db, the frontend car.html and the backend edit_api.py +1) for every image we have an entry in the database table person, keep for every image an entry with the same filename in the same folder but ending with json. in the json we store the functional representation of the database record and keep it up-to-date when data changes. . In the app we looking at groups of images, so also keep track of the group, this include the additional functional features we show in the UI for that group with the images it includes. Keep the data up-to-date in the backend when data changes, also update the referring data files. 2) introduce a rating feature. to thumps up or thumbs down an image. When an image gets athamps up or thumbs down add a tag in the db and show it in the UI. Also the default filter in "Group Active" should filter out the images with a negative rating, but also include a filter that would show only images with a positive rating. The rating should also cascade (calculated? perhaps) into the poses. So images with pose that have good rating will define the rating of a pose, same for the image group (use a clever normalized formula ) over the positive and negative thumbs to compare it with another group 3) introduce (?cosine) similarity variation indexes in a group. Also for the faces in a group. To keep track of authenticity of a person in a group diff --git a/studio.sh b/studio.sh new file mode 100755 index 0000000..5e5205a --- /dev/null +++ b/studio.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# Launch the studio UI in a clean Chrome profile (no stale cache) +TMPDIR=$(mktemp -d /tmp/chrome-studio-XXXXXX) +trap "rm -rf '$TMPDIR'" EXIT + +DRI_PRIME=pci-0000_02_00_0 \ +google-chrome \ + --disable-web-security \ + --allow-file-access-from-files \ + --user-data-dir="$TMPDIR" \ + --disable-dev-shm-usage \ + --no-first-run \ + --no-default-browser-check \ + --disable-infobars \ + --test-type \ + --ozone-platform=x11 \ + --disable-vulkan \ + --use-gl=desktop \ + --log-level=3 \ + --silent-debugger-extension-api \ + --app="file:///mnt/zim/tour-comfy/output/car.html" \ + 2>/dev/null diff --git a/tour-comfy/car.html b/tour-comfy/car.html index bc20356..fa64f87 100644 --- a/tour-comfy/car.html +++ b/tour-comfy/car.html @@ -1817,7 +1817,7 @@ /* ===================== SCENERY TAB ===================== */ .scene-frame-preview { width: 100%; aspect-ratio: 16/9; background: #111; - border-radius: 6px; overflow: hidden; display: none; + border-radius: 6px; overflow: hidden; display: block; margin-bottom: 8px; position: relative; } .scene-frame-preview img { width:100%; height:100%; object-fit:contain; display:block; } @@ -2055,6 +2055,7 @@
+ @@ -2106,7 +2107,10 @@
+
Rating
+
+ + +
+
Order & visibility
@@ -2373,7 +2383,134 @@ // --- HYDRATION_START --- const PRELOADED_IMAGES = [ - "20260630_031441_dup_20260621_032709_image.nobg.png", + "image.png_face.png", + "20260628_235923_3_20260628_205049_image.nobg.png", + "20260701_005145_sc_image.png", + "20260701_004903_sc_3_20260628_205049_image.png", + "20260701_004803_sc_image.nobg.png", + "20260701_004523_sc_image.nobg.png", + "20260701_004433_sc_image.nobg.png", + "20260701_004316_sc_image.nobg.png", + "20260701_004107_sc_11_20260628_205049_image.png", + "20260701_003559_sc_2_20260618_052526_image.nobg.nobg.png", + "20260701_002737_sc_2_20260618_052526_image.nobg.nobg.png", + "20260701_002644_sc_2_20260618_052526_image.nobg.nobg.png", + "20260701_002540_sc_3_20260618_052526_image.png", + "_turntable/cg_0606b4bb/views/view_023_345deg.png", + "_turntable/cg_0606b4bb/views/view_022_330deg.png", + "_turntable/cg_0606b4bb/views/view_021_315deg.png", + "_turntable/cg_0606b4bb/views/view_020_300deg.png", + "20260701_001709_fs_badly_1039s-1109s_0s-17s_3_20260618_052526_image.png.mp4", + "_turntable/cg_0606b4bb/views/view_019_285deg.png", + "_turntable/cg_0606b4bb/views/view_018_270deg.png", + "_turntable/cg_0606b4bb/views/view_017_255deg.png", + "_turntable/cg_0606b4bb/views/view_016_240deg.png", + "_turntable/cg_0606b4bb/views/view_015_225deg.png", + "_turntable/cg_0606b4bb/views/view_014_210deg.png", + "_turntable/cg_0606b4bb/views/view_013_195deg.png", + "20260701_000750_fs_bitch insemination_3s-43s_cg_7ec17537_face.png.mp4", + "_turntable/cg_0606b4bb/views/view_012_180deg.png", + "_turntable/cg_0606b4bb/views/view_011_165deg.png", + "_turntable/cg_0606b4bb/views/view_010_150deg.png", + "_turntable/cg_0606b4bb/views/view_009_135deg.png", + "_turntable/cg_0606b4bb/views/view_008_120deg.png", + "_turntable/cg_0606b4bb/views/view_007_105deg.png", + "_turntable/cg_0606b4bb/views/view_006_090deg.png", + "_turntable/cg_0606b4bb/views/view_005_075deg.png", + "_turntable/cg_0606b4bb/views/view_004_060deg.png", + "_turntable/cg_0606b4bb/views/view_003_045deg.png", + "_turntable/cg_0606b4bb/views/view_002_030deg.png", + "_turntable/cg_0606b4bb/views/view_001_015deg.png", + "_turntable/cg_0606b4bb/views/view_000_000deg.png", + "_turntable/cg_5bf03835/views/view_023_345deg.png", + "_turntable/cg_5bf03835/views/view_022_330deg.png", + "20260630_235941_fs_bitch insemination_4s-43s_2_20260618_052526_image.nobg.nobg.png.mp4", + "_turntable/cg_5bf03835/views/view_021_315deg.png", + "_turntable/cg_5bf03835/views/view_020_300deg.png", + "_turntable/cg_5bf03835/views/view_019_285deg.png", + "20260630_235750_sc_2_20260618_052526_image.nobg.nobg.png", + "20260630_235710_image.png", + "20260630_235619_sc_2_20260618_052526_image.nobg.nobg.png", + "_turntable/cg_5bf03835/views/view_018_270deg.png", + "_turntable/cg_5bf03835/views/view_017_255deg.png", + "_turntable/cg_5bf03835/views/view_016_240deg.png", + "_turntable/cg_5bf03835/views/view_015_225deg.png", + "20260630_235018_sc_2_20260618_052526_image.nobg.nobg.png", + "20260630_234910_sc_2_20260618_052526_image.nobg.nobg.png", + "20260630_234810_image.png", + "_turntable/cg_5bf03835/views/view_014_210deg.png", + "_turntable/cg_5bf03835/views/view_013_195deg.png", + "_turntable/cg_5bf03835/views/view_012_180deg.png", + "_turntable/cg_5bf03835/views/view_011_165deg.png", + "_turntable/cg_5bf03835/views/view_010_150deg.png", + "_turntable/cg_5bf03835/views/view_009_135deg.png", + "20260630_234141_sc_2_20260618_052526_image.nobg.nobg.png", + "_turntable/cg_5bf03835/views/view_008_120deg.png", + "_turntable/cg_5bf03835/views/view_007_105deg.png", + "_turntable/cg_5bf03835/views/view_006_090deg.png", + "_turntable/cg_5bf03835/views/view_005_075deg.png", + "_turntable/cg_5bf03835/views/view_004_060deg.png", + "20260630_233732_sc_2_20260618_052526_image.nobg.nobg.png", + "_turntable/cg_5bf03835/views/view_003_045deg.png", + "_turntable/cg_5bf03835/views/view_002_030deg.png", + "_turntable/cg_5bf03835/views/view_001_015deg.png", + "20260630_233521_sc_1_20260618_053458_image.png", + "_turntable/cg_5bf03835/views/view_000_000deg.png", + "20260630_233418_mr_1_20260618_053458_image.png", + "20260630_233334_sc_5_20260618_052526_image.png", + "20260630_233309_mr_1_20260618_053458_image.png", + "20260630_233128_2_20260618_052526_image.nobg.nobg.png", + "20260630_233128_2_20260618_052526_image.nobg.png", + "20260630_232932_2_20260618_052526_image.nobg.png", + "20260630_232800_2_20260618_052526_image.nobg.png", + "20260630_232800_2_20260618_052526_image.png", + "20260618_052559_2_20260618_052526_image.nobg.png", + "20260630_231721_sc_5_20260618_052526_image.png", + "20260630_163400_image.png", + "20260630_163247_3_20260628_205049_image.png", + "20260630_155337_5_20260619_124038_image.png", + "20260630_153428_sc_2_20260622_101128_image.nobg.png", + "20260630_153343_sc_2_20260622_101128_image.nobg.png", + "20260630_151554_sc_0_20260622_101128_image.png", + "20260630_151502_sc_0_20260622_101128_image.png", + "20260630_132351_sc_2_20260622_101128_image.nobg.png", + "20260630_132133_sc_2_20260622_101128_image.nobg.png", + "20260630_131910_sc_2_20260622_101128_image.nobg.png", + "20260630_131638_sc_2_20260622_101128_image.nobg.png", + "20260630_131402_sc_2_20260622_101128_image.nobg.png", + "20260630_130738_sc_2_20260622_101128_image.nobg.png", + "20260630_130601_sc_4_20260622_101128_image.png", + "20260630_130454_sc_0_20260622_101128_image.png", + "20260630_130350_sc_0_20260622_101128_image.png", + "20260630_130220_sc_0_20260622_101128_image.png", + "20260630_125808_sc_0_20260622_101128_image.png", + "20260630_061223_sc_7_20260622_101128_image.png", + "20260630_061020_sc_0_20260622_101128_image.png", + "20260630_060724_sc_2_20260622_101128_image.nobg.png", + "20260630_060611_sc_4_20260622_101128_image.png", + "20260630_060231_sc_2_20260622_101128_image.nobg.png", + "20260630_055626_sc_0_20260622_101128_image.png", + "20260630_055451_sc_2_20260622_101128_image.nobg.png", + "20260630_054928_sc_0_20260622_101128_image.png", + "20260630_053544_sc_image.png", + "cg_36a33bae_face.png", + "20260630_053010_sc_dup_20260621_032709_image.nobg.png", + "20260630_052808_sc_image.png", + "20260630_052535_sc_image.png", + "20260630_052314_sc_image.png", + "20260630_051519_sc_0_20260629_163755_image.png", + "20260630_051421_sc_0_20260629_163755_image.png", + "20260630_051322_sc_2_20260629_163755_image.png", + "20260630_045948_sc_view_022_330deg.png", + "20260630_045753_sc_view_022_330deg.png", + "20260630_044618_image.png", + "20260630_044435_sc_image.png", + "20260630_043928_sc_dup_view_003_045deg.png", + "20260630_043834_sc_view_022_330deg.png", + "20260630_043724_sc_view_022_330deg.png", + "20260630_043634_sc_view_022_330deg.png", + "20260630_043508_sc_view_022_330deg.png", + "20260630_052829_dup_20260630_031430_dup_20260621_032709_image.nobg.png", "20260630_031430_dup_20260621_032709_image.nobg.png", "20260630_031419_dup_20260621_032709_image.nobg.png", "20260630_031408_dup_20260621_032709_image.nobg.png", @@ -2481,7 +2618,6 @@ "_turntable/up_e7e12f38/views/view_018_270deg.png", "_turntable/up_e7e12f38/views/view_017_255deg.png", "_turntable/up_e7e12f38/views/view_016_240deg.png", - "cg_36a33bae_face.png", "20260629_193733_image.png", "20260629_193704_image.png", "20260629_193611_image.png", @@ -2783,8 +2919,6 @@ "20260629_032057_mr_sc_dup_20260624_055327_image.nobg.png", "20260629_031800_mr_sc_dup_20260624_055327_image.nobg.png", "20260629_031650_mr_sc_dup_20260624_055327_image.nobg.png", - "20260629_031559_mr_sc_dup_20260624_055327_image.nobg.png", - "20260629_031435_image.png", "20260629_031259_image.png", "20260629_030904_sc_dup_20260624_055327_image.nobg.png", "20260629_030742_sc_dup_20260624_055327_image.nobg.png", @@ -2846,7 +2980,6 @@ "20260629_001408_image.nobg.png", "20260629_001357_image.nobg.png", "20260629_001225_image.nobg.png", - "image.png_face.png", "20260629_000834_image.nobg.png", "20260629_000906_image.png", "20260629_000855_image.png", @@ -5753,8 +5886,8 @@ "20260625_021910_image.png", "20260625_021702_sc_image.png", "20260625_021615_sc_image.png", - "20260625_020212_dup_20260625_015711_sc_image.png", "20260625_015711_sc_image.png", + "20260625_020212_dup_20260625_015711_sc_image.png", "20260625_015711_sc_image.nobg.png", "20260625_015900_sc_image.png", "20260625_015413_sc_image.png", @@ -5941,8 +6074,8 @@ "20260624_183010_0_20260624_182958_image.png", "20260624_182958_image.png", "20260624_174900_image.png", - "20260624_173115_image.png", "20260624_174943_dup_20260624_173115_image.png", + "20260624_173115_image.png", "20260624_175711_dup_20260624_174943_dup_20260624_173115_image.png", "20260624_174757_image.png", "20260624_174442_image.png", @@ -5966,8 +6099,8 @@ "20260624_164353_dup_20260624_163951_dup_20260624_163352_dup_20260624_162601_mr_0_20260622_093058_image.png", "20260624_162601_mr_0_20260622_093058_image.nobg.png", "20260624_164059_dup_20260624_163951_dup_20260624_163352_dup_20260624_162601_mr_0_20260622_093058_image.png", - "20260624_164116_dup_20260624_163951_dup_20260624_163352_dup_20260624_162601_mr_0_20260622_093058_image.png", "20260624_163951_dup_20260624_163352_dup_20260624_162601_mr_0_20260622_093058_image.png", + "20260624_164116_dup_20260624_163951_dup_20260624_163352_dup_20260624_162601_mr_0_20260622_093058_image.png", "20260624_163634_dup_20260624_163352_dup_20260624_162601_mr_0_20260622_093058_image.png", "20260624_162510_image.png", "20260624_162408_image.png", @@ -6143,7 +6276,6 @@ "20260624_110310_image.png", "20260624_110253_image.png", "20260624_110237_image.png", - "20260624_110158_mr_image.png", "20260624_110028_image.png", "20260624_110008_image.png", "20260624_105859_5_20260624_105736_image.png", @@ -6177,7 +6309,6 @@ "20260624_103353_image.png", "20260624_103310_Screenshot_From_2026-06-24_10-30-32.png", "20260624_103246_image.png", - "20260624_075439_0_20260622_101128_image.png", "20260624_075405_0_20260622_101128_image.png", "20260624_075343_0_20260622_101128_image.png", "20260624_075331_0_20260622_101128_image.png", @@ -6433,7 +6564,6 @@ "20260622_215820_0_20260619_124038_image.png", "20260622_215703_dup_20260622_181910_0_20260619_124038_image.png", "20260622_205918_0_20260619_124038_image.png", - "20260622_205853_0_20260619_124038_image.png", "20260622_205722_0_20260619_124038_image.png", "20260622_205705_0_20260619_124038_image.png", "20260622_205648_0_20260619_124038_image.png", @@ -7719,8 +7849,11 @@ let fileHasClothing = {}; // filename โ†’ boolean|null (null = unknown) let fileContentType = {}; // filename โ†’ 'image'|'video' let fileFaceswapSrcVideo = {}; // filename โ†’ source video name - let privacyMode = localStorage.getItem('privacyMode') === 'true'; + let privacyMode = localStorage.getItem('privacyMode') !== 'false'; let _lastSystemLockState = false; + let groupSimilarityData = {}; + let groupRatingsData = {}; + let poseRatingsData = {}; // ---- faceswap state ---- let _fsModelFilename = null; // model image selected for faceswap @@ -7880,20 +8013,35 @@ function startGroupCycle(base) { cycleIdx.set(base, 0); // index into visibleIdx array + let orbitFrameIdx = 0; const id = setInterval(() => { // Don't update gallery cards while studio is open โ€” saves GPU and avoids distraction if (document.getElementById('studio').classList.contains('open')) return; const data = groupData.get(base); - if (!data || !data.visibleIdx || data.visibleIdx.length < 2) { clearInterval(id); cycleTimers.delete(base); return; } - const card = [...document.querySelectorAll('.image-card')].find(c => c.dataset.group === base); - if (!card) { clearInterval(id); cycleTimers.delete(base); return; } - const nextSlot = (cycleIdx.get(base) + 1) % data.visibleIdx.length; - cycleIdx.set(base, nextSlot); - const realIdx = data.visibleIdx[nextSlot]; - const img = card.querySelector('img'); - if (img) img.src = data.urls[realIdx]; - card.querySelectorAll('.dot').forEach((d, i) => d.classList.toggle('active', i === nextSlot)); - }, 1800); + if (!data) return; + + // 1. Main image cycle (for standard non-orbit, non-video images) + if (data.visibleIdx && data.visibleIdx.length >= 2) { + const card = [...document.querySelectorAll('.image-card')].find(c => c.dataset.group === base); + if (card) { + const nextSlot = (cycleIdx.get(base) + 1) % data.visibleIdx.length; + cycleIdx.set(base, nextSlot); + const realIdx = data.visibleIdx[nextSlot]; + const img = card.querySelector('.image-wrapper > img'); + if (img) img.src = data.urls[realIdx]; + card.querySelectorAll('.dot').forEach((d, i) => d.classList.toggle('active', i === nextSlot)); + } + } + + // 2. Orbit corner cycle (rapid yaw rotation semi-video-view!) + if (data.orbitIdx && data.orbitIdx.length > 0) { + const orbitImg = document.getElementById(`orbitImg_${base}`); + if (orbitImg) { + orbitFrameIdx = (orbitFrameIdx + 1) % data.orbitIdx.length; + orbitImg.src = data.urls[data.orbitIdx[orbitFrameIdx]]; + } + } + }, 800); cycleTimers.set(base, id); } @@ -7908,6 +8056,16 @@ return fname.includes('_turntable/') || /deg\.png|deg_|\d+deg/i.test(fname); } + function computeVisibleIdx(names) { + return names.map((n, i) => i).filter(i => { + const fname = names[i]; + const isVid = isVideo(fname) || fileContentType[fname] === 'video'; + const isFace = fname.endsWith('_face.png') || fname.includes('_face.'); + const isOrb = isOrbitFile(fname); + return !fileHidden[fname] && !isOrb && !isVid && !isFace; + }); + } + function getRepresentativeLabel(n) { // Priority 1: Pose Name if (filePoses[n]) { @@ -7947,7 +8105,7 @@ _activeFilmstripTab = tab; // Highlight tab - ['Active', 'Group', 'Hidden', 'Source', 'Archived', 'Video', 'OrbitFilter'].forEach(t => { + ['Active', 'Liked', 'Group', 'Hidden', 'Source', 'Archived', 'Video', 'OrbitFilter'].forEach(t => { const el = document.getElementById(`fsTab${t}`); if (el) el.classList.toggle('active', t.toLowerCase().replace('filter', '') === tab); }); @@ -7958,19 +8116,28 @@ const currentImg = lbNames[lbIdx]; - if (tab === 'active' || tab === 'group') { + if (tab === 'active' || tab === 'liked' || tab === 'group') { const data = groupData.get(lbCurrentGid); if (data) { - if (tab === 'active') { + if (tab === 'active' || tab === 'liked') { // Filter out hidden, source, archived, videos, and orbits const activeNames = []; const activeUrls = []; data.names.forEach((name, i) => { const isVid = isVideo(name) || fileContentType[name] === 'video'; const isOrb = isOrbitFile(name); + const tags = fileTags[name] || []; + const isLiked = tags.includes('LIKE'); + const isDisliked = tags.includes('DISLIKE'); + if (!fileHidden[name] && !fileIsSource[name] && !fileArchived[name] && !isVid && !isOrb) { - activeNames.push(name); - activeUrls.push(data.urls[i]); + if (tab === 'active' && !isDisliked) { + activeNames.push(name); + activeUrls.push(data.urls[i]); + } else if (tab === 'liked' && isLiked) { + activeNames.push(name); + activeUrls.push(data.urls[i]); + } } }); lbNames = activeNames; @@ -8313,7 +8480,14 @@ localStorage.setItem('lastSelectedGid', gid); // Default to 'active' tab, but fallback to 'group' if empty - const activeNames = data.names.filter(name => !fileHidden[name] && !fileIsSource[name] && !fileArchived[name]); + const activeNames = data.names.filter(name => { + const isVid = isVideo(name) || fileContentType[name] === 'video'; + const isOrb = isOrbitFile(name); + const tags = fileTags[name] || []; + const isDisliked = tags.includes('DISLIKE'); + const isFace = name.endsWith('_face.png') || name.includes('_face.'); + return !fileHidden[name] && !fileIsSource[name] && !fileArchived[name] && !isVid && !isOrb && !isDisliked && !isFace; + }); if (activeNames.length > 0) { _activeFilmstripTab = 'active'; lbNames = activeNames; @@ -8324,7 +8498,7 @@ lbNames = data.names; } - ['Active', 'Group', 'Hidden', 'Source', 'Archived'].forEach(t => { + ['Active', 'Liked', 'Group', 'Hidden', 'Source', 'Archived'].forEach(t => { const el = document.getElementById(`fsTab${t}`); if (el) el.classList.toggle('active', t.toLowerCase() === _activeFilmstripTab); }); @@ -8984,6 +9158,22 @@ estimateBtn.textContent = is21 ? '๐Ÿ”ž 21+ (Yes)' : '๐Ÿ”ž 21+'; } + const likeBtn = document.getElementById('lbLikeBtn'); + const dislikeBtn = document.getElementById('lbDislikeBtn'); + const itemTags = fileTags[fname] || []; + if (likeBtn) { + const isLiked = itemTags.includes('LIKE'); + likeBtn.style.color = isLiked ? '#10b981' : ''; + likeBtn.style.borderColor = isLiked ? '#10b981' : ''; + likeBtn.textContent = isLiked ? '๐Ÿ‘ Liked โœ“' : '๐Ÿ‘ Like'; + } + if (dislikeBtn) { + const isDisliked = itemTags.includes('DISLIKE'); + dislikeBtn.style.color = isDisliked ? '#ef4444' : ''; + dislikeBtn.style.borderColor = isDisliked ? '#ef4444' : ''; + dislikeBtn.textContent = isDisliked ? '๐Ÿ‘Ž Disliked โœ“' : '๐Ÿ‘Ž Dislike'; + } + const noBgBtn = document.getElementById('lbNoBgBtn'); const undressBtn = document.getElementById('lbUndressBtn'); const faceswapBtn = document.getElementById('lbFaceswapBtn'); @@ -9466,7 +9656,7 @@ // Update visibleIdx for this group const data = groupData.get(lbCurrentGid); if (data) { - data.visibleIdx = data.names.map((n, i) => i).filter(i => !fileHidden[data.names[i]]); + data.visibleIdx = computeVisibleIdx(data.names); // Restart or stop cycle accordingly const timer = cycleTimers.get(lbCurrentGid); if (timer) { clearInterval(timer); cycleTimers.delete(lbCurrentGid); } @@ -9698,7 +9888,7 @@ // Floating toolbar (top-left so it doesn't fight the crop bar on the right). const bar = document.createElement('div'); bar.id = 'poseToolbar'; - bar.style.cssText = 'position:absolute;top:8px;left:8px;display:flex;gap:6px;z-index:102;'; + bar.style.cssText = 'position:absolute;top:8px;left:8px;display:flex;gap:6px;z-index:102;align-items:center;'; bar.innerHTML = '' + '' @@ -9707,7 +9897,8 @@ + ' ' + '
' + '' - + ''; + + '' + + ''; viewer.appendChild(bar); _buildGestureMenu(); @@ -9805,6 +9996,9 @@ ctx.lineWidth = 2; ctx.strokeStyle = color; ctx.stroke(); }); }); + try { + updatePoseRatioSuggestion(); + } catch (e) {} } const POSE_SKELETON = [[5,7],[7,9],[6,8],[8,10],[11,13],[13,15],[12,14],[14,16],[5,6],[11,12],[5,11],[6,12],[0,1],[0,2],[1,3],[2,4],[0,5],[0,6]]; @@ -10397,7 +10591,7 @@ if (gIdx !== -1) { group.urls.splice(gIdx + 1, 0, lbUrls[lbIdx]); group.names.splice(gIdx + 1, 0, lbNames[lbIdx]); - group.visibleIdx = group.names.map((_, i) => i).filter(i => !fileHidden[group.names[i]]); + group.visibleIdx = computeVisibleIdx(group.names); } } @@ -10523,7 +10717,7 @@ if (gIdx !== -1) { group.urls.splice(gIdx + 1, 0, lbUrls[lbIdx]); group.names.splice(gIdx + 1, 0, lbNames[lbIdx]); - group.visibleIdx = group.names.map((_, i) => i).filter(i => !fileHidden[group.names[i]]); + group.visibleIdx = computeVisibleIdx(group.names); } } @@ -10578,7 +10772,7 @@ if (group) { group.urls.push(lbUrls[lbIdx]); group.names.push(lbNames[lbIdx]); - group.visibleIdx = group.names.map((_, i) => i).filter(i => !fileHidden[group.names[i]]); + group.visibleIdx = computeVisibleIdx(group.names); } updateStudio(); @@ -11429,14 +11623,19 @@ } async function loadImages(bypassStatic = false) { - if (loadImages._inFlight) return; + if (loadImages._inFlight) { + if (bypassStatic) loadImages._pendingBypass = true; + loadImages._needsRerun = true; + return; + } loadImages._inFlight = true; const gallery = document.getElementById('gallery'); const statusDot = document.getElementById('statusDot'); if (statusDot) statusDot.classList.add('updating'); - let files = null; - let _staticOk = false; + try { + let files = null; + let _staticOk = false; // Primary: pre-generated static JSON (fast, no DB round-trip per request) try { @@ -11491,51 +11690,88 @@ loadTemplateVideos(); } - if (fileObjects.length === 0) { - // Only show "no images" if gallery is currently empty (avoid flash on slow API) - if (!gallery.querySelector('.image-card')) { - gallery.innerHTML = ` -
- - - - - -

No images found

-

Place images in the output folder and refresh.

-
`; + if (fileObjects.length === 0) { + // Only show "no images" if gallery is currently empty (avoid flash on slow API) + if (!gallery.querySelector('.image-card')) { + gallery.innerHTML = ` +
+ + + + + +

No images found

+

Place images in the output folder and refresh.

+
`; + } + return; } - if (statusDot) statusDot.classList.remove('updating'); - loadImages._inFlight = false; - return; - } - // Refresh names/groups/group-names โ€” skip if already hydrated from static JSON - if (!_staticOk) { - try { - const [nr, gr, gnr] = await Promise.all([ - fetch(`${API}/names`, { signal: AbortSignal.timeout(2000) }), - fetch(`${API}/groups`, { signal: AbortSignal.timeout(2000) }), - fetch(`${API}/group-names`, { signal: AbortSignal.timeout(2000) }), - ]); - if (nr.ok) imageNames = await nr.json(); - if (gr.ok) customGroups = await gr.json(); - if (gnr.ok) groupNames = await gnr.json(); - } catch (e) { /* non-fatal */ } + // Refresh names/groups/group-names โ€” skip if already hydrated from static JSON + if (!_staticOk) { + try { + const nrUrl = `${API}/names${bypassStatic ? '?bypass_static=true' : ''}`; + const grUrl = `${API}/groups${bypassStatic ? '?bypass_static=true' : ''}`; + const gnrUrl = `${API}/group-names${bypassStatic ? '?bypass_static=true' : ''}`; + const [nr, gr, gnr] = await Promise.all([ + fetch(nrUrl, { signal: AbortSignal.timeout(2000) }), + fetch(grUrl, { signal: AbortSignal.timeout(2000) }), + fetch(gnrUrl, { signal: AbortSignal.timeout(2000) }) + ]); + if (nr && nr.ok) imageNames = await nr.json(); + if (gr && gr.ok) customGroups = await gr.json(); + if (gnr && gnr.ok) groupNames = await gnr.json(); + } catch (e) { /* non-fatal */ } + + // Fetch similarity and ratings asynchronously in the background so it doesn't block UI rendering! + Promise.all([ + fetch(`${API}/output/_data/group_similarity.json?t=${Date.now()}`).catch(() => null), + fetch(`${API}/output/_data/group_ratings.json?t=${Date.now()}`).catch(() => null), + fetch(`${API}/output/_data/pose_ratings.json?t=${Date.now()}`).catch(() => null) + ]).then(async ([gs, grat, pr]) => { + try { + if (gs && gs.ok) groupSimilarityData = await gs.json().catch(() => ({})); + if (grat && grat.ok) groupRatingsData = await grat.json().catch(() => ({})); + if (pr && pr.ok) poseRatingsData = await pr.json().catch(() => ({})); + updateGroupSimilarityBadges(); + } catch (e) {} + }).catch(() => {}); + } else { + // Fetch similarity and ratings asynchronously in the background so it doesn't block UI rendering! + Promise.all([ + fetch(`${API}/output/_data/group_similarity.json?t=${Date.now()}`).catch(() => null), + fetch(`${API}/output/_data/group_ratings.json?t=${Date.now()}`).catch(() => null), + fetch(`${API}/output/_data/pose_ratings.json?t=${Date.now()}`).catch(() => null) + ]).then(async ([gs, grat, pr]) => { + try { + if (gs && gs.ok) groupSimilarityData = await gs.json().catch(() => ({})); + if (grat && grat.ok) groupRatingsData = await grat.json().catch(() => ({})); + if (pr && pr.ok) poseRatingsData = await pr.json().catch(() => ({})); + updateGroupSimilarityBadges(); + } catch (e) {} + }).catch(() => {}); } clearCycleTimers(); groupData.clear(); currentGroups = groupFiles(fileObjects); - gallery.innerHTML = currentGroups.map((group, gIdx) => { + // Pre-populate groupData for all groups immediately so that metadata is hydrated for other UI components + currentGroups.forEach(group => { const { gid, files: gf } = group; const urls = gf.map(f => f.url); const names = gf.map(f => f.cleanName); - // Track which indices are visible (not hidden) - const visibleIdx = names.map((n, i) => i).filter(i => !fileHidden[names[i]]); - groupData.set(gid, { urls, names, visibleIdx }); + const visibleIdx = computeVisibleIdx(names); + const orbitIdx = names.map((n, i) => i).filter(i => isOrbitFile(names[i])); + groupData.set(gid, { urls, names, visibleIdx, orbitIdx }); + }); + function renderGroupCard(group, gIdx) { + const { gid, files: gf } = group; + const urls = gf.map(f => f.url); + const names = gf.map(f => f.cleanName); + const visibleIdx = computeVisibleIdx(names); + const orbitIdx = names.map((n, i) => i).filter(i => isOrbitFile(names[i])); const isNew = gf.some(f => newFileNames.has(f.cleanName)); const isRecent = gIdx < 3; @@ -11549,6 +11785,45 @@ const isVideoCard = isVideo(firstVisName) || fileContentType[firstVisName] === 'video'; const isFaceswap = !!fileFaceswapSrcVideo[firstVisName]; + const faceFile = names.find(n => n.endsWith('_face.png') || n.includes('_face.')); + const faceUrl = faceFile ? (IMAGE_FOLDER + faceFile) : null; + + const faceEl = faceUrl + ? `
+ +
` + : ''; + + const orbitEl = orbitIdx.length > 0 + ? `
+ +
` + : ''; + + let likes = 0; + let dislikes = 0; + names.forEach(n => { + if (fileTags[n]) { + if (fileTags[n].includes('LIKE')) likes++; + if (fileTags[n].includes('DISLIKE')) dislikes++; + } + }); + const ratingBadge = (likes > 0 || dislikes > 0) + ? `๐Ÿ‘ ${likes} ๐Ÿ‘Ž ${dislikes}` + : ''; + + const faceStats = groupSimilarityData?.[gid]?.face; + let authenticityBadge = ''; + if (faceStats && faceStats.pairs_count > 0) { + const avg = faceStats.average; + let label = 'Consistent'; + let color = '#10b981'; + if (avg < 0.75) { label = 'Outliers'; color = '#ef4444'; } + else if (avg < 0.88) { label = 'Consistent'; color = '#3b82f6'; } + else { label = 'Identical'; color = '#059669'; } + authenticityBadge = `${label}`; + } + const mediaEl = isVideoCard ? ` @@ -11581,10 +11856,13 @@
+ ${faceEl} + ${orbitEl} ${vCount > 1 ? `
${vCount}ร—${count !== vCount ? ` /${count}` : ''}
` : ''} ${mediaEl} ${vCount > 1 && !isVideoCard ? `
${visibleIdx.map((_, i) => ``).join('')}
` : ''}
+
${label}
@@ -11592,17 +11870,62 @@ ${isVideoCard ? `${isFaceswap ? 'Faceswap' : 'Video'}` : ''} ${!isVideoCard && poses.length ? `${escHtml(poses[0])}` : ''} ${isRecent && !isVideoCard ? 'Latest' : ''} + ${ratingBadge} + ${authenticityBadge}
`; - }).join(''); + } - currentGroups.forEach(g => { + const CHUNK_SIZE = 40; + const firstChunk = currentGroups.slice(0, CHUNK_SIZE); + const remainingGroups = currentGroups.slice(CHUNK_SIZE); + + gallery.innerHTML = firstChunk.map((group, gIdx) => renderGroupCard(group, gIdx)).join(''); + + // Start cycle timers for the first chunk immediately + firstChunk.forEach(g => { const data = groupData.get(g.gid); if (data && data.visibleIdx && data.visibleIdx.length > 1) startGroupCycle(g.gid); }); - updateCardSelection(); + // Asynchronously render subsequent groups in chunks of CHUNK_SIZE + let chunkIdx = 0; + function renderNextChunk() { + if (chunkIdx >= remainingGroups.length) { + updateCardSelection(); + updateGalleryHighlight(); + return; + } + const nextBatch = remainingGroups.slice(chunkIdx, chunkIdx + CHUNK_SIZE); + const html = nextBatch.map((group, i) => { + const gIdx = CHUNK_SIZE + chunkIdx + i; + return renderGroupCard(group, gIdx); + }).join(''); + + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = html; + while (tempDiv.firstChild) { + gallery.appendChild(tempDiv.firstChild); + } + + // Start cycles for this batch + nextBatch.forEach(g => { + const data = groupData.get(g.gid); + if (data && data.visibleIdx && data.visibleIdx.length > 1) startGroupCycle(g.gid); + }); + + chunkIdx += CHUNK_SIZE; + requestAnimationFrame(renderNextChunk); + } + + if (remainingGroups.length > 0) { + requestAnimationFrame(renderNextChunk); + } else { + updateCardSelection(); + updateGalleryHighlight(); + } + document.getElementById('count').textContent = `${currentGroups.length} group${currentGroups.length !== 1 ? 's' : ''} ยท ${fileObjects.length} images`; if (newFileNames.size > 0) showToast(`${newFileNames.size} new image${newFileNames.size !== 1 ? 's' : ''} detected`, 'success'); @@ -11642,7 +11965,7 @@ // Update groupData as well so that other components have correct metadata fresh.names = mergedNames; fresh.urls = mergedUrls; - fresh.visibleIdx = fresh.names.map((_, idx) => idx).filter(idx => !fileHidden[fresh.names[idx]]); + fresh.visibleIdx = computeVisibleIdx(fresh.names); // Now, handle auto-follow logic inside the open studio and current group context! let targetFname = null; @@ -11728,9 +12051,51 @@ } } - if (statusDot) statusDot.classList.remove('updating'); - updateGalleryHighlight(); - loadImages._inFlight = false; + } finally { + if (statusDot) statusDot.classList.remove('updating'); + updateGalleryHighlight(); + loadImages._inFlight = false; + if (loadImages._needsRerun) { + loadImages._needsRerun = false; + const bp = !!loadImages._pendingBypass; + loadImages._pendingBypass = false; + setTimeout(() => loadImages(bp), 100); + } + } + } + + function updateGroupSimilarityBadges() { + if (!groupSimilarityData) return; + currentGroups.forEach(group => { + const gid = group.gid; + const faceStats = groupSimilarityData[gid]?.face; + if (faceStats && faceStats.pairs_count > 0) { + const avg = faceStats.average; + let label = 'Consistent'; + let color = '#10b981'; + if (avg < 0.75) { label = 'Outliers'; color = '#ef4444'; } + else if (avg < 0.88) { label = 'Consistent'; color = '#3b82f6'; } + else { label = 'Identical'; color = '#059669'; } + + const card = document.querySelector(`.image-card[data-group="${CSS.escape(gid)}"]`); + if (card) { + const meta = card.querySelector('.image-meta'); + if (meta) { + let badge = meta.querySelector('.badge-authenticity'); + if (!badge) { + badge = document.createElement('span'); + badge.className = 'badge badge-authenticity'; + badge.style.fontSize = '9px'; + badge.style.marginLeft = '4px'; + meta.appendChild(badge); + } + badge.style.background = color; + badge.title = `Face Cosine Consistency: ${(avg * 100).toFixed(1)}%`; + badge.textContent = label; + } + } + } + }); } // Discover images by trying common patterns (fallback for file:// protocol) @@ -12121,7 +12486,44 @@ }, 2000); } - // --- prompt history (localStorage) --- + // --- prompt history (localStorage + database) --- + async function fetchAndPopulatePromptHistory() { + try { + const r1 = await fetch(`${API}/prompts?type=pose-prompt&limit=100`); + if (r1.ok) { + const prompts = await r1.json(); + const list = prompts.map(p => p.prompt_text); + if (list.length > 0) { + localStorage.setItem('batchPromptHistory', JSON.stringify(list)); + } + } + } catch (e) { console.error('Error fetching pose-prompt history:', e); } + + try { + const r2 = await fetch(`${API}/prompts?type=scene&limit=100`); + if (r2.ok) { + const prompts = await r2.json(); + const list = prompts.map(p => p.prompt_text); + if (list.length > 0) { + localStorage.setItem('scenePromptHistory', JSON.stringify(list)); + } + } + } catch (e) { console.error('Error fetching scene history:', e); } + + try { + const r3 = await fetch(`${API}/prompts?type=designer&limit=100`); + if (r3.ok) { + const prompts = await r3.json(); + const list = prompts.map(p => p.prompt_text); + if (list.length > 0) { + localStorage.setItem('designerPromptHistory', JSON.stringify(list)); + } + } + } catch (e) { console.error('Error fetching designer history:', e); } + + loadPromptHistory(); + } + function loadPromptHistory() { const hist = JSON.parse(localStorage.getItem('batchPromptHistory') || '[]'); const dl = document.getElementById('promptHistory'); @@ -12135,6 +12537,16 @@ if (key === 'batchPromptHistory') { loadPromptHistory(); } + // Background save to database prompt table + let type = 'pose-prompt'; + if (key === 'scenePromptHistory') type = 'scene'; + else if (key === 'designerPromptHistory') type = 'designer'; + + fetch(`${API}/prompts`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: type, prompt_text: prompt, metadata: {} }) + }).catch(e => console.error('Error saving prompt to DB:', e)); } // --- tagging --- @@ -12241,15 +12653,163 @@ }); } }); - loadPromptHistory(); + fetchAndPopulatePromptHistory(); loadImages(); autoRefreshTimer = setInterval(_timedRefresh, REFRESH_INTERVAL * 1000); // Privacy mode init โ€” apply stored state on load - (function applyPrivacyInit() { - const btn = document.getElementById('privacyBtn'); - if (privacyMode) { btn.textContent = '๐Ÿ”’'; document.title = PRIV_TITLE; } - })(); + const pBtn = document.getElementById('privacyBtn'); + if (privacyMode && pBtn) { + pBtn.textContent = '๐Ÿ”’'; + document.title = PRIV_TITLE; + showPrivacyOverlay(); + } + }); + + async function toggleTagAction(tagName) { + const fname = lbNames[lbIdx]; + if (!fname) return; + try { + const r = await fetch(`${API}/images/${encodeURIComponent(fname)}/tag-action`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'toggle', tag: tagName }) + }); + if (r.ok) { + const data = await r.json(); + fileTags[fname] = data.tags; + showToast(`Updated rating: ${tagName}`, 'success'); + updateStudio(); + refreshNow(); + } else { + showToast('Failed to update rating: ' + await r.text(), 'error'); + } + } catch (err) { + console.error(err); + showToast('Error updating rating: ' + err, 'error'); + } + } + + async function reverseEngineerActiveImage() { + const activeFn = (lbIdx >= 0 && lbNames[lbIdx]) ? lbNames[lbIdx] : null; + if (!activeFn) { + showToast('No active image selected to reverse engineer', 'error'); + return; + } + const textarea = document.getElementById('designerContextInput'); + if (!textarea) return; + const oldPlaceholder = textarea.placeholder; + textarea.disabled = true; + textarea.placeholder = "Reverse engineering image... Please wait..."; + try { + const r = await fetch(`${API}/images/${encodeURIComponent(activeFn)}/reverse-engineer`, { + method: 'POST' + }); + if (r.ok) { + const data = await r.json(); + _designerContext = data.prompt; + textarea.value = _designerContext; + showToast('Successfully reverse-engineered the active image! Prompt set in Guidelines.', 'success'); + } else { + showToast('Reverse engineering failed: ' + await r.text(), 'error'); + } + } catch (err) { + console.error(err); + showToast('Error reverse engineering active image: ' + err, 'error'); + } finally { + textarea.disabled = false; + textarea.placeholder = oldPlaceholder; + renderSidebarDesigner(); + } + } + + async function suggestUpdatedDescription() { + const fname = lbNames[lbIdx]; + if (!fname) return; + showToast('AI is analyzing the image and suggesting a description...', 'info'); + try { + const r = await fetch(`${API}/images/${encodeURIComponent(fname)}/reverse-engineer`, { + method: 'POST' + }); + if (r.ok) { + const data = await r.json(); + const newPrompt = data.prompt; + if (confirm(`Suggested Reconstructed Prompt:\n\n"${newPrompt}"\n\nWould you like to set this as the image generation prompt?`)) { + const updateResp = await fetch(`${API}/images/${encodeURIComponent(fname)}/update-prompt`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: newPrompt }) + }); + if (updateResp.ok) { + filePrompts[fname] = newPrompt; + showToast('Successfully updated image prompt!', 'success'); + updateStudio(); + refreshNow(); + } else { + showToast('Failed to update image prompt', 'error'); + } + } + } else { + showToast('Failed to generate suggest description: ' + await r.text(), 'error'); + } + } catch (err) { + console.error(err); + showToast('Error suggesting description: ' + err, 'error'); + } + } + + function updatePoseRatioSuggestion() { + const st = window._poseState; + const container = document.getElementById('poseRatioSuggestion'); + if (!st || !container) return; + + const MIN_CONF = 0.25; + let xs = []; + let ys = []; + st.people.forEach(kpts => { + kpts.forEach(kp => { + if (kp && kp[2] >= MIN_CONF) { + xs.push(kp[0]); + ys.push(kp[1]); + } + }); + }); + + if (xs.length === 0 || ys.length === 0) { + container.style.display = 'none'; + return; + } + + const xmin = Math.min(...xs); + const xmax = Math.max(...xs); + const ymin = Math.min(...ys); + const ymax = Math.max(...ys); + + const width = xmax - xmin; + const height = ymax - ymin; + + if (height === 0) { + container.style.display = 'none'; + return; + } + + const ar = width / height; + container.style.display = 'flex'; + + if (ar > 1.2) { + container.innerHTML = `โš ๏ธ Wide pose (${ar.toFixed(2)}:1). Suggest: Add horizontal padding.`; + container.style.color = '#f59e0b'; + container.style.borderColor = 'rgba(245,158,11,0.5)'; + } else if (ar < 0.45) { + container.innerHTML = `โš ๏ธ Narrow pose (${ar.toFixed(2)}:1). Suggest: Add vertical padding.`; + container.style.color = '#f59e0b'; + container.style.borderColor = 'rgba(245,158,11,0.5)'; + } else { + container.innerHTML = `โœ“ Balanced pose (${ar.toFixed(2)}:1). Standard fit.`; + container.style.color = '#10b981'; + container.style.borderColor = 'rgba(16,185,129,0.3)'; + } + } // Studio + variant picker + select-mode keyboard nav document.addEventListener('keydown', e => { @@ -12347,21 +12907,33 @@ } } // Guard async actions against concurrent executions via a simple inflight flag. - if ((e.key === 'h' || e.key === 'H') && tag !== 'INPUT') { + if ((e.key === 'h' || e.key === 'H') && tag !== 'INPUT' && !e.ctrlKey && !e.altKey && !e.metaKey) { if (!document._lbHideInFlight) { document._lbHideInFlight = true; lbToggleHidden().finally(() => { document._lbHideInFlight = false; }); } } - if (e.key === 'F' || e.key === 'f') { + if ((e.key === 'F' || e.key === 'f') && !e.ctrlKey && !e.altKey && !e.metaKey) { lbSetPreferred(); e.preventDefault(); } - if ((e.key === 's' || e.key === 'S') && tag !== 'INPUT' && tag !== 'TEXTAREA') { + if ((e.key === 's' || e.key === 'S') && tag !== 'INPUT' && tag !== 'TEXTAREA' && !e.ctrlKey && !e.altKey && !e.metaKey) { toggleSourceKeybind(); e.preventDefault(); } - if ((e.key === 'e' || e.key === 'E') && tag !== 'INPUT' && tag !== 'TEXTAREA') { + if ((e.key === 'e' || e.key === 'E') && tag !== 'INPUT' && tag !== 'TEXTAREA' && !e.ctrlKey && !e.altKey && !e.metaKey) { estimate21PlusKeybind(); e.preventDefault(); } + if ((e.key === 'l' || e.key === 'L') && tag !== 'INPUT' && tag !== 'TEXTAREA' && !e.ctrlKey && !e.altKey && !e.metaKey) { + toggleTagAction('LIKE'); + e.preventDefault(); + } + if ((e.key === 'd' || e.key === 'D') && tag !== 'INPUT' && tag !== 'TEXTAREA' && !e.ctrlKey && !e.altKey && !e.metaKey) { + toggleTagAction('DISLIKE'); + e.preventDefault(); + } + if ((e.key === 'r' || e.key === 'R') && tag !== 'INPUT' && tag !== 'TEXTAREA' && !e.ctrlKey && !e.altKey && !e.metaKey) { + suggestUpdatedDescription(); + e.preventDefault(); + } if (e.key === 'Delete') { lbArchive(); e.preventDefault(); @@ -12408,7 +12980,6 @@ } } }); - }); // Privacy overlay โ€” disguise the page as a study-chat on any focus loss. const PRIV_TITLE = 'AI Assist'; @@ -12508,7 +13079,7 @@ }); fresh.names = paired.map(p => p.name); fresh.urls = paired.map(p => p.url); - fresh.visibleIdx = fresh.names.map((_, i) => i).filter(i => !fileHidden[fresh.names[i]]); + fresh.visibleIdx = computeVisibleIdx(fresh.names); } // Re-open picker to reflect new order @@ -13526,6 +14097,7 @@ onmouseenter="_tplPlay(this,'${repVideoSafe}')" onmouseleave="_tplStop(this)" style="width:100%;height:100%;margin:0;border:none"> + ${g.video ? `` : ''}
@@ -13539,7 +14111,6 @@
- ${g.video ? `` : ''} ${hasClips ? ` + style="width:100%;margin-bottom:8px">๐Ÿ“ท Capture this frame`; + } else if (!gridOpen && _sceneFrameBytes) { + html += ` +
+ +
โœ“ frame captured
+
`; + style="width:100%;margin-bottom:8px">โœ– Pick a different frame`; } html += ` `; + if (!_sceneVideo || gridOpen) { + html += ` + `; + } // --- Person / Extra: filled via filmstrip Shift+Click (see slots above) --- const person = _sceneRefs[1]; @@ -14239,7 +14815,7 @@ const url = `${API}/output/` + encodeURIComponent(it.filename); return `
+ onclick="sceneLibUseAsBackground('${it.filename.replace(/'/g, "\\'")}')">
`; @@ -14292,14 +14868,18 @@ if (_sceneVideo) { const vid = document.getElementById('sceneVideoEl'); if (vid) { - vid.src = `${API}/wireframe/${encodeURIComponent(_sceneVideo)}`; - // Once metadata is known, size the slider so 1000 steps span the whole clip. - vid.addEventListener('loadedmetadata', () => { + const onMetadata = () => { _sceneDuration = vid.duration || 0; const lbl = document.getElementById('sceneTimeLabel'); if (lbl) lbl.textContent = formatSecs(vid.currentTime || 0); - }, { once: true }); - // Keep the time label in sync if the frame is seeked any other way. + // Force a tiny seek to render the first frame in all browsers + vid.currentTime = 0.01; + }; + if (vid.readyState >= 1) { + onMetadata(); + } else { + vid.addEventListener('loadedmetadata', onMetadata, { once: true }); + } vid.addEventListener('seeked', () => { const lbl = document.getElementById('sceneTimeLabel'); if (lbl) lbl.textContent = formatSecs(vid.currentTime || 0); @@ -14316,8 +14896,10 @@ // Scrub the preview video live as the slider moves (no server round-trip). function sceneScrub(val) { const vid = document.getElementById('sceneVideoEl'); - if (!vid || !vid.duration) return; - const t = (parseFloat(val) / 1000) * vid.duration; + if (!vid) return; + const duration = vid.duration || _sceneDuration; + if (!duration) return; + const t = (parseFloat(val) / 1000) * duration; vid.pause(); vid.currentTime = t; const lbl = document.getElementById('sceneTimeLabel'); @@ -14408,7 +14990,7 @@ function sceneClearSlot(i) { const ref = _sceneRefs[i]; _sceneRefs[i] = null; - if (i === 0 && ref && ref.kind === 'bytes') _sceneFrameBytes = null; + if (i === 0) _sceneFrameBytes = null; if (i === 1) _scenePersonInit = true; // don't auto-refill after explicit clear _sceneRefresh(); } @@ -14453,7 +15035,7 @@ async function sceneSelectVideo(v) { _sceneVideo = v; _sceneFrameBytes = null; - if (_sceneRefs[0] && (_sceneRefs[0].kind === 'bytes' || _sceneRefs[0].kind === 'wireframe')) _sceneRefs[0] = null; + _sceneRefs[0] = null; _sceneGridOpen = false; // collapse the picker so the player is front-and-centre renderSidebarScenery(); } @@ -14469,7 +15051,7 @@ function sceneReleaseFrame() { _sceneFrameBytes = null; - if (_sceneRefs[0] && (_sceneRefs[0].kind === 'bytes' || _sceneRefs[0].kind === 'wireframe')) _sceneRefs[0] = null; + _sceneRefs[0] = null; renderSidebarScenery(); // re-render restores the live video + slider + capture button } @@ -14763,7 +15345,7 @@ if (gIdx !== -1) { group.urls.splice(gIdx + 1, 0, nobgUrl); group.names.splice(gIdx + 1, 0, newFn); - group.visibleIdx = group.names.map((_, i) => i).filter(i => !fileHidden[group.names[i]]); + group.visibleIdx = computeVisibleIdx(group.names); } } @@ -14886,7 +15468,7 @@ }); fresh.names = paired.map(p => p.name); fresh.urls = paired.map(p => p.url); - fresh.visibleIdx = fresh.names.map((_, i) => i).filter(i => !fileHidden[fresh.names[i]]); + fresh.visibleIdx = computeVisibleIdx(fresh.names); lbUrls = fresh.urls; lbNames = fresh.names; @@ -15029,11 +15611,12 @@ // Checkbox for active image context if (activeFn) { const checkedAttr = _designerUseActiveImg ? ' checked' : ''; - html += ` + `; } else { html += `
No active image selected to extract context.
`; } diff --git a/tour-comfy/database.py b/tour-comfy/database.py index 82d9d59..fe998c2 100644 --- a/tour-comfy/database.py +++ b/tour-comfy/database.py @@ -101,6 +101,17 @@ def migrate_schema(): conn = get_db_connection() cur = conn.cursor() try: + # Create prompt table first + cur.execute(""" + CREATE TABLE IF NOT EXISTS prompt ( + id SERIAL PRIMARY KEY, + type TEXT NOT NULL, + prompt_text TEXT NOT NULL, + metadata JSONB DEFAULT '{}'::jsonb, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT unique_prompt_type_text UNIQUE (type, prompt_text) + ) + """) for sql in [ "ALTER TABLE person ADD COLUMN IF NOT EXISTS prompt TEXT", "ALTER TABLE person ADD COLUMN IF NOT EXISTS pose TEXT", @@ -262,6 +273,11 @@ def upsert_person(filename, filepath=None, name=None, group_id=None, tags=None, pose_description, pose_skeleton, people_count, anatomical_completeness, facial_direction, json.dumps(objects) if objects else None)) conn.commit() + # Sync after commit + try: + sync_by_filename_async(filename) + except Exception: + pass finally: cur.close() _put_db_connection(conn) @@ -272,6 +288,10 @@ def set_archived(filename, archived: bool): try: cur.execute("UPDATE person SET archived = %s WHERE filename = %s", (archived, filename)) conn.commit() + try: + sync_by_filename_async(filename) + except Exception: + pass finally: cur.close() _put_db_connection(conn) @@ -286,6 +306,11 @@ def set_filenames_archived(filenames, archived: bool): rows = cur.fetchall() updated = [r[0] for r in rows] conn.commit() + for fn in updated: + try: + sync_by_filename_async(fn) + except Exception: + pass return updated finally: cur.close() @@ -297,6 +322,10 @@ def set_hidden(filename, hidden: bool): try: cur.execute("UPDATE person SET hidden = %s WHERE filename = %s", (hidden, filename)) conn.commit() + try: + sync_by_filename_async(filename) + except Exception: + pass finally: cur.close() _put_db_connection(conn) @@ -307,6 +336,10 @@ def set_person_tags(filename, tags): try: cur.execute("UPDATE person SET tags = %s WHERE filename = %s", (json.dumps(tags) if tags is not None else None, filename)) conn.commit() + try: + sync_by_filename_async(filename) + except Exception: + pass finally: cur.close() _put_db_connection(conn) @@ -363,21 +396,56 @@ def search_similar(embedding, limit=10): _put_db_connection(conn) def delete_person(filename): + person = get_person(filename) + gid = person[1] if person else None + conn = get_db_connection() cur = conn.cursor() try: cur.execute("DELETE FROM person WHERE filename = %s", (filename,)) conn.commit() + # Delete JSON file on disk + try: + out_dir = _get_output_dir() + base, _ = os.path.splitext(filename) + json_path = os.path.join(out_dir, base + ".json") + if os.path.exists(json_path): + os.remove(json_path) + except Exception: + pass + if gid: + try: + sync_group_to_json_async(gid) + except Exception: + pass finally: cur.close() _put_db_connection(conn) def delete_group(group_id): + files = get_group_files(group_id) + conn = get_db_connection() cur = conn.cursor() try: cur.execute("DELETE FROM person WHERE group_id = %s", (group_id,)) conn.commit() + # Delete JSON files on disk + out_dir = _get_output_dir() + for f in files: + try: + base, _ = os.path.splitext(f[0]) + json_path = os.path.join(out_dir, base + ".json") + if os.path.exists(json_path): + os.remove(json_path) + except Exception: + pass + try: + group_json_path = os.path.join(out_dir, "_data", f"group_{group_id}.json") + if os.path.exists(group_json_path): + os.remove(group_json_path) + except Exception: + pass finally: cur.close() _put_db_connection(conn) @@ -430,6 +498,13 @@ def set_group_order(group_id, ordered_filenames): finally: cur.close() _put_db_connection(conn) + + try: + sync_group_to_json_async(group_id) + for fname in ordered_filenames: + sync_person_to_json_async(fname) + except Exception: + pass def get_group_order(group_id): """Return [(filename, sort_order), ...] sorted by sort_order NULLS LAST.""" @@ -454,6 +529,13 @@ def set_group_name(group_id, name): try: cur.execute("UPDATE person SET group_name = %s WHERE group_id = %s", (name, group_id)) conn.commit() + try: + sync_group_to_json_async(group_id) + files = get_group_files(group_id) + for f in files: + sync_person_to_json_async(f[0]) + except Exception: + pass finally: cur.close() _put_db_connection(conn) @@ -560,3 +642,367 @@ def invalidate_all_metadata(): finally: cur.close() _put_db_connection(conn) + +import os +import math + +def _get_output_dir(): + # Load config.json to find output_dir + config_paths = ["config.json", "tour-comfy/config.json", "../config.json"] + for p in config_paths: + if os.path.exists(p): + try: + with open(p, "r") as f: + cfg = json.load(f) + if "output_dir" in cfg: + return cfg["output_dir"] + except Exception: + pass + return "../output" # Default fallback + +def sync_person_to_json(filename): + """Write/sync a detailed .json file with the DB fields of the specified filename.""" + conn = get_db_connection() + cur = conn.cursor() + try: + cur.execute(""" + SELECT filename, filepath, name, group_id, tags, embedding, + clip_description, prompt, pose, sort_order, group_name, hidden, + has_background, source_refs, has_clothing, content_type, + faceswap_source_video, archived, face_embedding, is_source, + pose_description, pose_skeleton, people_count, anatomical_completeness, + facial_direction, objects + FROM person + WHERE filename = %s + """, (filename,)) + row = cur.fetchone() + if not row: + return + + # Parse tags + tags_val = row[4] + tags_list = [] + if tags_val: + if isinstance(tags_val, str): + try: tags_list = json.loads(tags_val) + except Exception: tags_list = [] + elif isinstance(tags_val, list): + tags_list = tags_val + + # Parse objects + obj_val = row[25] + obj_list = [] + if obj_val: + if isinstance(obj_val, str): + try: obj_list = json.loads(obj_val) + except Exception: obj_list = [] + elif isinstance(obj_val, list): + obj_list = obj_val + + # Parse source_refs + ref_val = row[13] + ref_list = [] + if ref_val: + if isinstance(ref_val, str): + try: ref_list = json.loads(ref_val) + except Exception: ref_list = [] + elif isinstance(ref_val, list): + ref_list = ref_val + + # Represent embeddings as lists of floats (for json compatibility) + embedding_list = None + if row[5] is not None: + if isinstance(row[5], str): + embedding_list = [float(x) for x in row[5].strip("[]").split(",") if x.strip()] + else: + embedding_list = list(row[5]) + + face_embedding_list = None + if row[18] is not None: + if isinstance(row[18], str): + face_embedding_list = [float(x) for x in row[18].strip("[]").split(",") if x.strip()] + else: + face_embedding_list = list(row[18]) + + # Parse pose_skeleton + pose_skel = None + if row[21]: + try: + pose_skel = json.loads(row[21]) + except Exception: + pose_skel = row[21] + + data = { + "filename": row[0], + "filepath": row[1], + "name": row[2], + "group_id": row[3], + "tags": tags_list, + "embedding": embedding_list, + "clip_description": row[6], + "prompt": row[7], + "pose": row[8], + "sort_order": row[9], + "group_name": row[10], + "hidden": bool(row[11]) if row[11] is not None else False, + "has_background": bool(row[12]) if row[12] is not None else True, + "source_refs": ref_list, + "has_clothing": row[14], + "content_type": row[15] or "image", + "faceswap_source_video": row[16], + "archived": bool(row[17]) if row[17] is not None else False, + "face_embedding": face_embedding_list, + "is_source": bool(row[19]) if row[19] is not None else False, + "pose_description": row[20], + "pose_skeleton": pose_skel, + "people_count": row[22], + "anatomical_completeness": row[23], + "facial_direction": row[24], + "objects": obj_list + } + + # Determine json path + out_dir = _get_output_dir() + if row[1] and os.path.isabs(row[1]): + # Use same directory as absolute filepath + base, _ = os.path.splitext(row[1]) + json_path = base + ".json" + else: + # Fallback to output_dir + base, _ = os.path.splitext(row[0]) + json_path = os.path.join(out_dir, base + ".json") + + os.makedirs(os.path.dirname(json_path), exist_ok=True) + tmp_json_path = json_path + ".tmp" + with open(tmp_json_path, "w") as f: + json.dump(data, f, indent=2) + os.replace(tmp_json_path, json_path) + + except Exception as e: + print(f"[db] Error syncing person {filename} to JSON: {e}") + finally: + cur.close() + _put_db_connection(conn) + +def sync_group_to_json(group_id): + """Sync all members of a group, calculate group rating and similarity variation, + and save to _data/group_.json. + """ + if not group_id: + return + + conn = get_db_connection() + cur = conn.cursor() + try: + # Fetch all members + cur.execute(""" + SELECT filename, filepath, name, tags, embedding, + clip_description, prompt, pose, sort_order, group_name, hidden, + has_background, source_refs, content_type, faceswap_source_video, + archived, face_embedding, is_source, pose_description, pose_skeleton + FROM person + WHERE group_id = %s + ORDER BY sort_order ASC, filename ASC + """, (group_id,)) + rows = cur.fetchall() + + members = [] + likes = 0 + dislikes = 0 + clip_embs = [] + face_embs = [] + + for r in rows: + tags_val = r[3] + tags_list = [] + if tags_val: + if isinstance(tags_val, str): + try: tags_list = json.loads(tags_val) + except Exception: tags_list = [] + elif isinstance(tags_val, list): + tags_list = tags_val + + if "LIKE" in tags_list: + likes += 1 + elif "DISLIKE" in tags_list: + dislikes += 1 + + # Parse embeddings for similarity + if r[4] is not None: + if isinstance(r[4], str): + clip_list = [float(x) for x in r[4].strip("[]").split(",") if x.strip()] + else: + clip_list = list(r[4]) + if clip_list: + clip_embs.append(clip_list) + + if r[16] is not None: + if isinstance(r[16], str): + face_list = [float(x) for x in r[16].strip("[]").split(",") if x.strip()] + else: + face_list = list(r[16]) + if face_list: + face_embs.append(face_list) + + members.append({ + "filename": r[0], + "filepath": r[1], + "name": r[2], + "tags": tags_list, + "clip_description": r[5], + "prompt": r[6], + "pose": r[7], + "sort_order": r[8], + "hidden": bool(r[10]), + "archived": bool(r[15]), + "is_source": bool(r[17]) + }) + + def cosine_similarity(v1, v2): + if not v1 or not v2: return 0.0 + dot = sum(a * b for a, b in zip(v1, v2)) + norm1 = math.sqrt(sum(a * a for a in v1)) + norm2 = math.sqrt(sum(b * b for b in v2)) + if norm1 == 0 or norm2 == 0: return 0.0 + return dot / (norm1 * norm2) + + def calc_stats(embs): + if len(embs) < 2: + return {"average": 1.0, "min": 1.0, "max": 1.0, "variation": 0.0, "pairs_count": 0} + similarities = [] + for i in range(len(embs)): + for j in range(i + 1, len(embs)): + similarities.append(cosine_similarity(embs[i], embs[j])) + if not similarities: + return {"average": 1.0, "min": 1.0, "max": 1.0, "variation": 0.0, "pairs_count": 0} + avg_sim = sum(similarities) / len(similarities) + min_sim = min(similarities) + max_sim = max(similarities) + variance = sum((s - avg_sim) ** 2 for s in similarities) / len(similarities) + return { + "average": float(avg_sim), + "min": float(min_sim), + "max": float(max_sim), + "variation": float(math.sqrt(variance)), + "pairs_count": len(similarities) + } + + group_name = rows[0][9] if rows else group_id + + group_data = { + "group_id": group_id, + "group_name": group_name, + "rating": { + "likes": likes, + "dislikes": dislikes, + "score": likes - dislikes, + "normalized_score": (likes - dislikes) / (likes + dislikes + 2.0) if (likes + dislikes) > 0 else 0.0, + "ratio": likes / (likes + dislikes) if (likes + dislikes) > 0 else 0.0 + }, + "clip_similarity_stats": calc_stats(clip_embs), + "face_similarity_stats": calc_stats(face_embs), + "members": members + } + + out_dir = _get_output_dir() + data_dir = os.path.join(out_dir, "_data") + os.makedirs(data_dir, exist_ok=True) + + json_path = os.path.join(data_dir, f"group_{group_id}.json") + tmp_json_path = json_path + ".tmp" + with open(tmp_json_path, "w") as f: + json.dump(group_data, f, indent=2) + os.replace(tmp_json_path, json_path) + + except Exception as e: + print(f"[db] Error syncing group {group_id} to JSON: {e}") + finally: + cur.close() + _put_db_connection(conn) + +def sync_by_filename(filename): + sync_person_to_json(filename) + person = get_person(filename) + if person and person[1]: + sync_group_to_json(person[1]) + +from concurrent.futures import ThreadPoolExecutor +_sync_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="db_sync_worker") + +def sync_by_filename_async(filename): + _sync_executor.submit(sync_by_filename, filename) + +def sync_group_to_json_async(group_id): + _sync_executor.submit(sync_group_to_json, group_id) + +def sync_person_to_json_async(filename): + _sync_executor.submit(sync_person_to_json, filename) + + +def save_db_prompt(prompt_type: str, prompt_text: str, metadata: dict = None): + conn = get_db_connection() + cur = conn.cursor() + try: + cur.execute(""" + INSERT INTO prompt (type, prompt_text, metadata, created_at) + VALUES (%s, %s, %s, NOW()) + ON CONFLICT (type, prompt_text) DO UPDATE + SET metadata = EXCLUDED.metadata, + created_at = NOW() + """, (prompt_type, prompt_text, json.dumps(metadata) if metadata is not None else None)) + conn.commit() + except Exception as e: + print(f"[db] error saving prompt {prompt_type}: {e}") + try: + conn.rollback() + except Exception: + pass + finally: + cur.close() + _put_db_connection(conn) + +def list_db_prompts(prompt_type: str = None, limit: int = 100): + conn = get_db_connection() + cur = conn.cursor() + try: + if prompt_type: + cur.execute(""" + SELECT id, type, prompt_text, metadata, created_at + FROM prompt + WHERE type = %s + ORDER BY created_at DESC + LIMIT %s + """, (prompt_type, limit)) + else: + cur.execute(""" + SELECT id, type, prompt_text, metadata, created_at + FROM prompt + ORDER BY created_at DESC + LIMIT %s + """, (limit,)) + + rows = cur.fetchall() + result = [] + for r in rows: + meta = r[3] + if isinstance(meta, str): + try: + meta = json.loads(meta) + except Exception: + meta = {} + elif meta is None: + meta = {} + result.append({ + "id": r[0], + "type": r[1], + "prompt_text": r[2], + "metadata": meta, + "created_at": r[4].isoformat() if r[4] else None + }) + return result + except Exception as e: + print(f"[db] error listing prompts: {e}") + return [] + finally: + cur.close() + _put_db_connection(conn) diff --git a/tour-comfy/edit_api.py b/tour-comfy/edit_api.py index f93f59a..ab6e1c4 100644 --- a/tour-comfy/edit_api.py +++ b/tour-comfy/edit_api.py @@ -50,9 +50,13 @@ WORKFLOW_PATH = os.environ.get( "WORKFLOW_PATH", os.path.join(os.path.dirname(os.path.abspath(__file__)), "workflow_qwen_edit.json"), ) -# Default target pixel area for the output latent. The MI50 is not fast, so we -# cap at ~1MP by default; raise via MAX_AREA env if you want bigger output. -MAX_AREA = int(os.environ.get("MAX_AREA", str(1024 * 1024))) +# Default target pixel area for the output latent. +# We currently cap at ~1MP by default; raise via MAX_AREA env if you want bigger output. +# A6000 48GB is not VRAM-bound here, so default to a ~2MP output budget. +# This comfortably allows full-HD-ish outputs like 1920x1080. +# Override via MAX_AREA when needed. +#export MAX_AREA="${MAX_AREA:-2097152}" +MAX_AREA = int(os.environ.get("MAX_AREA", str(2097152))) GEN_TIMEOUT = int(os.environ.get("GEN_TIMEOUT", "600")) # seconds per request # Node ids in workflow_qwen_edit.json (kept stable on purpose). @@ -1758,6 +1762,23 @@ def _batch_worker(job_id: str, filenames: list, prompts: list[str], poses: list, if jobs[job_id].get("cancelled"): return try: + try: + database.save_db_prompt("pose-prompt", prompt, { + "pose": pose, + "seed": seed, + "max_area": max_area, + "wireframe_ref": wireframe_ref, + "wireframe_time": wireframe_time, + "pad_top": pad_top, + "pad_right": pad_right, + "pad_bottom": pad_bottom, + "pad_left": pad_left, + "pad_fill": pad_fill, + "pad_outpaint": pad_outpaint + }) + except Exception as db_err: + print(f"[batch] failed to save to prompt table: {db_err}") + pil = base_pil actual_prompt = prompt if pad_outpaint: @@ -1865,6 +1886,21 @@ def _multi_ref_worker(job_id: str, filenames: list[str], prompts: list[str], pos for prompt, pose in zip(prompts, poses): try: + try: + database.save_db_prompt("pose-prompt", prompt, { + "pose": pose, + "seed": seed, + "max_area": max_area, + "filenames": filenames, + "pad_top": pad_top, + "pad_right": pad_right, + "pad_bottom": pad_bottom, + "pad_left": pad_left, + "pad_fill": pad_fill, + "pad_outpaint": pad_outpaint + }) + except Exception as db_err: + print(f"[multi-ref] failed to save to prompt table: {db_err}") work_pil = primary_pil actual_prompt = prompt if pad_outpaint: @@ -1947,6 +1983,23 @@ def update_config(update: ConfigUpdate): return {"seed": conf["seed"]} +class SavePromptRequest(BaseModel): + type: str + prompt_text: str + metadata: dict | None = None + + +@app.post("/prompts") +def api_save_prompt(req: SavePromptRequest): + database.save_db_prompt(req.type, req.prompt_text, req.metadata) + return {"status": "success"} + + +@app.get("/prompts") +def api_list_prompts(type: str | None = None, limit: int = 100): + return database.list_db_prompts(type, limit) + + class GroupArchiveRequest(BaseModel): filenames: list[str] @@ -2123,12 +2176,173 @@ def refine_prompt(req: RefineRequest): r.raise_for_status() data = r.json() refined = data["choices"][0]["message"]["content"].strip() + try: + database.save_db_prompt("refine", refined, { + "original": req.prompt, + "filename": req.filename + }) + except Exception as db_err: + print(f"[refine-prompt] failed to save to prompt table: {db_err}") return {"refined": refined} except Exception as e: print(f"Refinement error: {e}") raise HTTPException(500, f"LLM refinement failed: {str(e)}") +class UpdatePromptRequest(BaseModel): + prompt: str + + +@app.post("/images/{filename:path}/reverse-engineer") +def reverse_engineer(filename: str): + person = database.get_person(filename) + if not person: + raise HTTPException(404, "Image not found in database") + + # Extract metadata on the fly if pose_description is not present + if person[15] is None: + try: + metadata = _process_image_for_metadata(filename) + if metadata: + # Reload person row + person = database.get_person(filename) + except Exception as e: + print(f"Failed to process image for metadata during reverse-engineer: {e}") + + # Build context string + tags_val = person[2] + clip_desc_val = person[4] + original_prompt = person[6] + pose_desc = person[15] + people_count = person[17] + anatomical_completeness = person[18] + facial_direction = person[19] + objects_val = person[20] + + context_parts = [] + + # 1. Base prompt or tags + if original_prompt: + context_parts.append(f"Original Prompt/Tags: {original_prompt}") + elif clip_desc_val: + context_parts.append(f"Scene Tags Description: {clip_desc_val}") + + # 2. WD Tagger tags + if tags_val: + try: + if isinstance(tags_val, str): + tags_list = json.loads(tags_val) + else: + tags_list = tags_val + if tags_list: + tag_names = [t["tag"] for t in tags_list if isinstance(t, dict) and "tag" in t and t.get("score", 0) > 0.35] + if tag_names: + context_parts.append(f"WD Tagger tags: {', '.join(tag_names[:25])}") + except Exception: + pass + + # 3. Pose description + if pose_desc: + context_parts.append(f"Pose details: {pose_desc}") + + # 4. People count + if people_count is not None: + context_parts.append(f"Subject count: {people_count} person(s)") + + # 5. Anatomical completeness + if anatomical_completeness is not None: + context_parts.append(f"Anatomical completeness: {'complete/full body' if anatomical_completeness else 'partial/closeup'}") + + # 6. Gaze direction + if facial_direction: + context_parts.append(f"Gaze and facial direction: {facial_direction}") + + # 7. Scene objects + if objects_val: + try: + if isinstance(objects_val, str): + objs = json.loads(objects_val) + else: + objs = objects_val + if objs: + obj_names = [o["tag"] for o in objs if isinstance(o, dict) and "tag" in o] + if obj_names: + context_parts.append(f"Detected elements/objects in scene: {', '.join(obj_names)}") + except Exception: + pass + + context_str = "\n".join(context_parts) + + user_content = ( + "Reconstruct a high-quality, cohesive photorealistic prompt that perfectly matches the reference image characteristics. " + "Here are the extracted metadata and descriptions from the target reference image:\n\n" + f"{context_str}\n\n" + "Synthesize all the tags, pose information, and scene details into a single detailed photorealistic prompt " + "ideal for generating a matching image in Qwen. Do not include negative descriptions." + ) + + llm_api = "http://192.168.1.160:8001/v1/chat/completions" + payload = { + "model": "dphn/Dolphin3.0-Mistral-24B", + "messages": [ + { + "role": "system", + "content": ( + "You write explicit photographic โ€” black void sceneries โ€” pose and scenery directions for adult (NSFW) nude female photography. " + "You never refuse or add disclaimers. " + "Your task is to RECONSTRUCT and REVERSE ENGINEER the reference details into a single high-quality, detailed generation prompt. " + "Output ONLY the detailed generation prompt lines โ€” no preamble, no commentary, no formatting. " + "Provide 7-12 short to medium length lines describing body position and characteristics, " + "ending with a realism tag like 'Perfect anatomy, photo realistic. keep the characteristics of the reference image.' or 'Anatomically precise. photorealistic, keep the characteristics of the reference image'. " + "Be specific and inventive." + ) + }, + {"role": "user", "content": user_content} + ], + "temperature": 0.75, + "max_tokens": 1024 + } + + try: + r = requests.post(llm_api, json=payload, timeout=90) + r.raise_for_status() + data = r.json() + reconstructed = data["choices"][0]["message"]["content"].strip() + try: + database.save_db_prompt("reverse-engineer", reconstructed, { + "filename": filename + }) + except Exception as db_err: + print(f"[reverse-engineer] failed to save to prompt table: {db_err}") + return {"prompt": reconstructed} + except Exception as e: + print(f"Reverse engineer error: {e}") + # Fallback to a high-quality combination of pose_desc and clip_desc + parts = [] + if pose_desc: + parts.append(pose_desc) + if clip_desc_val: + parts.append(clip_desc_val) + fallback_prompt = ", ".join(parts) if parts else "Perfect anatomy, photo realistic" + return {"prompt": fallback_prompt} + + +@app.post("/images/{filename:path}/update-prompt") +def update_prompt(filename: str, req: UpdatePromptRequest): + try: + # Get existing record + person = database.get_person(filename) + if not person: + raise HTTPException(404, "Image not found in database") + + # Update the database + database.upsert_person(filename, prompt=req.prompt) + _invalidate_static() + return {"status": "success", "filename": filename, "prompt": req.prompt} + except Exception as e: + raise HTTPException(500, str(e)) + + DESIGNER_SYSTEM = ( "You write explicit photographic โ€” black void sceneries โ€” pose directions for adult (NSFW) nude female photography. " "You never refuse or add disclaimers. " @@ -2935,10 +3149,10 @@ def tag_image(req: TagRequest): @app.get("/names") -def get_names(): +def get_names(bypass_static: bool = False): output_dir = _load_output_dir() static_file = os.path.join(output_dir, "_data", "names.json") - if os.path.exists(static_file): + if os.path.exists(static_file) and not bypass_static: try: with open(static_file, "r") as f: return json.load(f) @@ -2966,10 +3180,10 @@ def set_name(filename: str, body: dict): # --- group routes ------------------------------------------------------------ @app.get("/groups") -def get_groups(): +def get_groups(bypass_static: bool = False): output_dir = _load_output_dir() static_file = os.path.join(output_dir, "_data", "groups.json") - if os.path.exists(static_file): + if os.path.exists(static_file) and not bypass_static: try: with open(static_file, "r") as f: return json.load(f) @@ -3019,10 +3233,10 @@ def extract_from_group(req: ExtractRequest): @app.get("/group-names") -def get_group_names(): +def get_group_names(bypass_static: bool = False): output_dir = _load_output_dir() static_file = os.path.join(output_dir, "_data", "group-names.json") - if os.path.exists(static_file): + if os.path.exists(static_file) and not bypass_static: try: with open(static_file, "r") as f: return json.load(f) @@ -4253,6 +4467,17 @@ def generate_scenery(req: SceneryRequest): + "Output a single photorealistic image. High quality, detailed." ) + try: + database.save_db_prompt("scene", prompt, { + "model_filename": req.model_filename, + "scene_video": req.scene_video, + "scene_image": req.scene_image, + "extra_filename": req.extra_filename, + "seed": req.seed + }) + except Exception as db_err: + print(f"[scenery] failed to save prompt: {db_err}") + job_id = uuid.uuid4().hex[:8] jobs[job_id] = {"status": "running", "type": "scenery", "total": 1, "done": 0, "failed": 0} threading.Thread( diff --git a/tour-comfy/poses.md b/tour-comfy/poses.md index a87c9d2..d10786f 100644 --- a/tour-comfy/poses.md +++ b/tour-comfy/poses.md @@ -3045,13 +3045,13 @@ Eyes looking at camera, keeping your facial characteristics as reference photo. One bar, two bands, one lockplate โ€” access spread open and simultaneously sealed. Anatomically precise, hyperrealistic, high detail, keep the characteristics of the reference image. -# Tampon โ€” Internal Plug, External Shield, Integral Collar +# Tampon โ€” Internal Plug, Integral Collar You are standing in black void. A single solid steel device, polished and seamless. An internal plug, contoured and smooth, connected by a short rigid bar to a small external shield covering your vulva. From the shield, a thin rigid steel bar rises up your abdomen and sternum, terminating in a collar locked around your neck. -One continuous piece โ€” internal plug, external shield, connecting bar, collar. +One continuous piece โ€” internal plug, connecting bar, collar. Your wrists cuffed to the vertical bar at your hips. Your ankles cuffed together. The device occupies and seals simultaneously โ€” internal and external denial in one form. diff --git a/tour-comfy/test_regression_qwen.py b/tour-comfy/test_regression_qwen.py new file mode 100644 index 0000000..a9eca84 --- /dev/null +++ b/tour-comfy/test_regression_qwen.py @@ -0,0 +1,114 @@ +import os +import sys +import time +import unittest +from fastapi.testclient import TestClient + +# Ensure tour-comfy is in the import path +_HERE = os.path.dirname(os.path.abspath(__file__)) +if _HERE not in sys.path: + sys.path.append(_HERE) + +from edit_api import app, _load_output_dir + +class TestQwenBackendRegression(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.client = TestClient(app) + cls.output_dir = _load_output_dir() + + # Real-data filenames specified in the issue description + cls.real_img_1 = "20260618_053519_1_20260618_053458_image.png" + cls.real_img_2 = "20260618_052537_0_20260618_052526_image.png" + cls.wireframe_img = "up_20260628_104641_image.png" + + # Validate that they exist on disk to run real end-to-end tests + cls.run_real_tests = True + img1_path = os.path.join(cls.output_dir, cls.real_img_1) + img2_path = os.path.join(cls.output_dir, cls.real_img_2) + + if not os.path.exists(img1_path) or not os.path.exists(img2_path): + cls.run_real_tests = False + print(f"[test] Real images not found at {img1_path} or {img2_path}. Running tests with fallback/mock assertion modes.") + + def test_multi_ref_qwen_endpoint(self): + """Test multi-reference generation endpoint with specified real-data images.""" + if not self.run_real_tests: + self.skipTest("Skipping real backend test due to missing real image artifacts on this node.") + + payload = { + "filenames": [self.real_img_1, self.real_img_2], + "prompt": "standing girl looking at camera, high quality, highly detailed", + "seed": 42, + "max_area": 512 * 512, + "pad_outpaint": False + } + + response = self.client.post("/multi-ref", json=payload) + self.assertEqual(response.status_code, 200) + + data = response.json() + self.assertIn("job_id", data) + job_id = data["job_id"] + + # Poll status until done (limit to 300 seconds to prevent hanging if backend is offline) + print(f"[test] Polling multi-ref job {job_id} until completion...") + start_time = time.time() + completed = False + + while time.time() - start_time < 300: + status_resp = self.client.get(f"/batch/{job_id}") + if status_resp.status_code == 200: + job_data = status_resp.json() + status = job_data.get("status") + print(f"[test] Job status: {status} (done: {job_data.get('done')}/{job_data.get('total')})") + if status == "done": + completed = True + break + elif status in ["error", "cancelled"]: + break + time.sleep(3) + + self.assertTrue(completed, "Multi-ref generation job did not complete successfully in time.") + + def test_scenery_qwen_endpoint(self): + """Test scenery generation endpoint with specified wireframe scene image and prompt.""" + if not self.run_real_tests: + self.skipTest("Skipping real backend test due to missing real image artifacts on this node.") + + payload = { + "model_filename": self.real_img_1, + "scene_image": self.wireframe_img, + "prompt": "replace person from image 1 naturally with the person from Image 2. Keep the exact position of Image 1, and the exact person of Image 2", + "seed": 42 + } + + response = self.client.post("/generate-scenery", json=payload) + self.assertEqual(response.status_code, 200) + + data = response.json() + self.assertIn("job_id", data) + job_id = data["job_id"] + + # Poll status until done + print(f"[test] Polling scenery job {job_id} until completion...") + start_time = time.time() + completed = False + + while time.time() - start_time < 300: + status_resp = self.client.get(f"/batch/{job_id}") + if status_resp.status_code == 200: + job_data = status_resp.json() + status = job_data.get("status") + print(f"[test] Job status: {status} (done: {job_data.get('done')}/{job_data.get('total')})") + if status == "done": + completed = True + break + elif status in ["error", "cancelled"]: + break + time.sleep(3) + + self.assertTrue(completed, "Scenery generation job did not complete successfully in time.") + +if __name__ == "__main__": + unittest.main()