* feat(saved-views): keep a collection in a saved view Images saved views gain a collection key (#331 D140): empty or a collection id of 1 to 9 digits. Writes refuse favorites together with a collection; older rows read an empty collection, and a stored row with both opens the collection, as the listing does for a URL with both. Part of #331 * feat(collections): store collections in the library database * feat(collections): add, remove and rename collections and keep members through duplicate cleanup * feat(collections): add collection routes and the Images collection filter * feat(images): store images' own categories in the library database (#332) One new table, image_categories, and its category index, created empty by a forward-only migration that reads and writes only its own objects. Startup checks the structure; restore also checks the values. The store sets, clears and reads own categories by file key (the deepest output source holding the file and the path below it), and copies a removed duplicate's category to a kept copy that has none. * feat(images): duplicate cleanup keeps images' own categories (#332) The kept copy's own category wins; a kept copy without one receives the removed copy's, keyed by the deepest output source holding each file. The removed copy's row is never deleted, so a give-back or a failed delete loses nothing. The job log reports how many were kept. * feat(images): set, remove and list images' own categories (#332) PUT /prompt_manager/images/category sets the own category of up to 500 listed files, DELETE removes it (listed or not), and GET /prompt_manager/images/categories lists the values whose file is listed. Targets are the Images listing identity of an output source, checked by media_target (shared with Collections). The routes never touch prompts, recipes or links; POST /prompt_manager/bulk/category is unchanged. * fix(images): key symlinked output folders and keep key work off the event loop (#332) Review fixes: a source matches by its configured spelling too, so a detected output folder that is a symlink keys its files (routes and duplicate cleanup). File keys are computed in the executor. A path no source holds is a plain 403, a lone surrogate a 400. The install repairs a table that lacks its category index instead of refusing to open. * fix(collections): address review findings * feat(images): set or remove a category on all N matching images (#332) The category routes also accept the Images listing filter of "Select all N matching" (#392) instead of explicit items, resolved now and capped at 500 images like an explicit selection. * feat(collections): browse a collection from the sidebar The sidebar gains the concept's COLLECTIONS group (D136): one row per collection with its folder icon, name and listed-member count, without the PROPOSED badge; the label shows once a collection exists. A row opens #/collection/<id>, the Images page filtered to that collection (D137): the collection's name as h1, Collection as breadcrumb and title, Edit to rename or delete it through the saved views' Edit dialog with collection nouns, and a note counting members the sources do not list now. An unknown or deleted collection shows a page that says so with a link to Images. A saved view on a deleted collection lists nothing and says so (MC6, D140). The list is read again after a restore and the counts 300 ms after a change. Part of #331 * test(collections): compare the COLLECTIONS group with the concept The concept hides COLLECTIONS at 950 px and below, which would leave a phone no way to open a collection; production shows the rows there in the wrapped layout of every other sidebar nav (D136). The shell comparison now compares the group instead of filtering it out: the concept fixture seeds Landscapes (12) and Experiments (8), the rows compare by name order, and at phone width the nav compares against the concept's own sidebar-nav layout. The collection rows are checked at 1600x1100 and 390x844 in both themes. Part of #331 * test(vendor): count the #332 host case in the execution matrix image-attribution.spec.mjs gains the image category route case, so the execution config lists 92 tests (3 attribution cases per project). * test(browser): install the slideshow clock before wrapping setTimeout The slideshow fixture wrapped window.setTimeout before page.clock.install(), so the viewer's timer ran on real time and clock.runFor() did not drive it; a late WebKit timer failed 'slideshow exposes exact state labels' on CI. * fix(collections): address review findings - A list read superseded by a newer one (the 300 ms count read, a restore) no longer shows "could not be loaded": the collection and saved-view pages wait for the list to settle. - A listing that finds the open collection gone shows the deleted page once the list no longer holds it; when the list read fails or still holds it, the page keeps the listing's error and Retry instead of staying blank, and each failed read is reported again. - The missing-members note follows every list read and goes away at zero. - A rename that settles after its Edit dialog closed leaves any dialog opened since alone. - The deleted-collection, list-failed and saved-view failure pages focus their title after a route change, as every page does. Part of #331 * test(coverage): keep main's floors for files this PR does not touch coverage:update raised nine floors of untouched files from one run; metadata-autocomplete.js branches then measured 84.84 against 85. * test(legacy): record the image category routes in the legacy contract snapshot (#332) #332 adds PUT/DELETE /prompt_manager/images/category and GET /prompt_manager/images/categories; the legacy pages' routes are unchanged. * test(duplicates): a removed copy with a collection and a category moves both (#331, #332)
176 lines
6.0 KiB
Python
176 lines
6.0 KiB
Python
"""Deterministic original artwork and saved prompts for workspace visual checks."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING
|
|
|
|
from PIL import Image, ImageDraw
|
|
from PIL.PngImagePlugin import PngInfo
|
|
|
|
from utils.hashing import generate_prompt_hash
|
|
from tests.browser.folder_trash import replace_folder
|
|
|
|
if TYPE_CHECKING:
|
|
from py.api import PromptManagerAPI
|
|
|
|
|
|
_POSITIVES = (
|
|
(
|
|
"Amber geometry\nLayered sandstone arches, warm afternoon light, "
|
|
"precise silhouettes."
|
|
),
|
|
(
|
|
"Tidal observatory\nCobalt circles over a quiet sea, "
|
|
"a pale horizon and measured reflections."
|
|
),
|
|
(
|
|
"Forest atlas\nOverlapping emerald triangles, paper textures "
|
|
"and a soft misty background."
|
|
),
|
|
(
|
|
"Rose pavilion\nCoral blocks and floating windows, "
|
|
"balanced shadows on a terracotta plaza."
|
|
),
|
|
"Violet orbit\nConcentric rings, lavender haze and a tiny silver moon.",
|
|
"A study in very long creative context\n"
|
|
+ "Preserve every geometric detail, layered color relationship and quiet edge. "
|
|
* 18
|
|
+ "unbroken-"
|
|
+ "context" * 40,
|
|
)
|
|
_NEGATIVES = (
|
|
("blurred edges, watermark", "flat lighting, clipped highlights", ""),
|
|
("low contrast, noise", "distorted horizon"),
|
|
("oversaturated foliage", "soft geometry"),
|
|
("unbalanced composition",),
|
|
("banding, harsh gradients",),
|
|
("cropped details, illegible marks",),
|
|
)
|
|
|
|
|
|
def _write_art(path: Path, index: int, positive: str, negative: str) -> None:
|
|
sizes = ((720, 480), (480, 720), (560, 560), (900, 400))
|
|
palettes = (
|
|
("#252c46", "#e0a75e", "#b76445"),
|
|
("#153b57", "#64bfc6", "#dfdba8"),
|
|
("#1d4947", "#71ac83", "#d5d8aa"),
|
|
("#523547", "#ef9c87", "#bc6764"),
|
|
("#342b59", "#a79bd6", "#e5c0bf"),
|
|
("#303d4d", "#b1c4c5", "#d6a05f"),
|
|
)
|
|
width, height = sizes[index % len(sizes)]
|
|
background, accent, highlight = palettes[index // 4]
|
|
graph = {
|
|
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": positive}},
|
|
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": negative}},
|
|
"3": {
|
|
"class_type": "KSampler",
|
|
"inputs": {"positive": ["1", 0], "negative": ["2", 0]},
|
|
},
|
|
"4": {"class_type": "SaveImage", "inputs": {"images": ["3", 0]}},
|
|
}
|
|
info = PngInfo()
|
|
info.add_text("prompt", json.dumps(graph))
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
with Image.new("RGB", (width, height), background) as image:
|
|
draw = ImageDraw.Draw(image)
|
|
draw.rectangle((0, height * 3 // 4, width, height), fill=highlight)
|
|
radius = min(width, height) // 3
|
|
center_x, center_y = width // 2 + (index % 3 - 1) * 25, height // 2
|
|
draw.ellipse(
|
|
(
|
|
center_x - radius,
|
|
center_y - radius,
|
|
center_x + radius,
|
|
center_y + radius,
|
|
),
|
|
fill=accent,
|
|
)
|
|
draw.polygon(
|
|
(
|
|
(width // 8, height * 7 // 8),
|
|
(width // 2, height // 4),
|
|
(width * 7 // 8, height * 7 // 8),
|
|
),
|
|
outline=highlight,
|
|
width=8,
|
|
)
|
|
draw.line(
|
|
(width // 6, height // 3, width * 5 // 6, height // 3),
|
|
fill=highlight,
|
|
width=4,
|
|
)
|
|
image.save(path, pnginfo=info)
|
|
stamp = 1_760_000_000 - index * 60
|
|
os.utime(path, (stamp, stamp))
|
|
|
|
|
|
def seed_workspace_visual(api: PromptManagerAPI, root: Path) -> None:
|
|
"""Replace only the owned browser harness records and media roots."""
|
|
with api.db.model.get_connection() as conn:
|
|
for table in (
|
|
"image_collection_items",
|
|
"image_collections",
|
|
"saved_views",
|
|
"image_favorites",
|
|
"media_review_decisions",
|
|
"media_review_items",
|
|
"media_image_tags",
|
|
"media_annotation_bytes",
|
|
"media_annotation_files",
|
|
"image_categories",
|
|
"image_prompt_pairs",
|
|
"prompt_pair_tags",
|
|
"prompt_pairs",
|
|
"generated_images",
|
|
"prompt_tags",
|
|
"tags",
|
|
"prompts",
|
|
):
|
|
conn.execute(f"DELETE FROM {table}")
|
|
for name in ("output", "input"):
|
|
replace_folder(root / name)
|
|
for parent_index, (positive, negatives) in enumerate(zip(_POSITIVES, _NEGATIVES)):
|
|
category = ("Architecture", "Landscape", "Abstract")[parent_index % 3]
|
|
tags = ["geometric", ("warm", "cool")[parent_index % 2]]
|
|
if parent_index == 5:
|
|
tags.append("long-context-" + "detail" * 28)
|
|
prompt_id = api.db.save_prompt(
|
|
positive,
|
|
category=category,
|
|
tags=tags,
|
|
prompt_hash=generate_prompt_hash(positive),
|
|
rating=parent_index if parent_index else None,
|
|
)
|
|
for variant_index, negative in enumerate(negatives):
|
|
api.db.save_prompt_pair(
|
|
positive,
|
|
negative,
|
|
title=f"Composition {variant_index + 1}",
|
|
category=category,
|
|
tags=tags,
|
|
)
|
|
for offset in range(4):
|
|
index = parent_index * 4 + offset
|
|
directory = ("output", "input")[offset % 2]
|
|
filename = f"study-{index + 1:02}.png"
|
|
if index == 23:
|
|
filename = (
|
|
"a-very-long-geometric-composition-"
|
|
+ "layered-context-" * 9
|
|
+ ".png"
|
|
)
|
|
path = root / directory / category.lower() / filename
|
|
_write_art(path, index, positive, negatives[offset % len(negatives)])
|
|
api.db.link_image_to_prompt(prompt_id, str(path))
|
|
from py.api.thumbnail_settings import ThumbnailSettings
|
|
|
|
# 150 px previews in the default format (#317 thumbnail settings).
|
|
api._generate_gallery_thumbnails_sync(
|
|
api._get_all_output_dirs(), ThumbnailSettings("webp", 80, 150)
|
|
)
|
|
api.invalidate_gallery_cache()
|