fix(characters): remove the gutters between the concept sheet panels

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-10-02 08:11:33 +02:00
parent 1a527c3b41
commit d4099e45c6
3 changed files with 29 additions and 14 deletions

View File

@ -10,6 +10,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
- **Filter the page grid by status.** Each legend entry is now a toggle button with the number of pages in that status; choose one or several to show only pages that have any of them. It combines with *Show only pages that need me*, the summary says how many pages are shown, *Show all pages* clears everything, a status no page has is greyed out, and the buttons announce their on/off state and work from the keyboard.
### Fixed
- **No gaps on the character concept sheet.** After the divider lines were removed, light cream gutters remained between the three poses and above the lower pictures, and the left pose's cape and the right pose's sword were clipped at them. The composer cut the generated triptych into thirds and pasted each into an inset panel (8 px per side), and the lower pictures had 12-18 px padding. The poses are now pasted as one whole image with no cut and no inset, and the lower pictures sit flush under it. The generation prompt never asked for gaps, so it is unchanged. Sheets already stored have the gaps baked in and need to be regenerated.
- **No divider lines on the character concept sheet.** The Konzeptbild composer drew light lines between the three poses and between the top and bottom rows, which cut through capes and swords. They are gone. Sheets already stored have the lines baked into the image file and need to be regenerated to lose them.
- **The two lower pictures of the concept sheet now show the character in the book's world.** For Marcian (a knight in plate and a red cloak, *Das Jahr des Greifen*) the story picture showed a modern man in a polo shirt with headphones and a camera in a garden, covered in pseudo-text captions, and the item tile a stock crossbow on a white backdrop. The story prompt was about 5,400 characters with the period rules at the very end, which the fast 4-step model never reaches; it opened with "ears clearly visible" and asked for "studio photography by a production photographer with a real actor", and words such as "unlettered", "overlays" and "Output 768x512" invited writing. The negative prompt that lists text and modern clothing had no effect, because these samplers run at cfg 1.0, where ComfyUI ignores it. The story prompt is now short and starts with the world (medieval high fantasy, The Dark Eye, 900-1300 AD, plus the book profile's setting, without the book title, which was read as lettering), then the same character as the turnaround (identity, appearance, costume, what the portrait shows), the moment from the book, a period background, and a hand-made, unmarked world; the item is the character's own, made in the colours and craftsmanship of their costume, on an oak table in a medieval room. Both are worded positively only; text, captions, headphones, cameras and modern clothing and buildings are added to the scene and item negative prompts for when the cfg is raised. Checked with two renders of Marcian on the local ComfyUI: a knight in plate and red cloak with a crossbow in a medieval village, and his crossbow on a table, with no text and nothing modern. Existing sheets need to be regenerated.
- **Review opens at the top.** Opening a page in review no longer keeps the scroll position of the page grid, which could leave the sticky page bar covering the top of the page picture.

View File

@ -3681,19 +3681,11 @@ def _contain_image(raw: bytes, width: int, height: int, padding: int = 12) -> Im
def _compose_character_reference(images: dict[str, bytes], prop_count: int) -> str:
"""Assemble panels without cropping or asking the model to draw a layout."""
"""Assemble panels flush (no gutters, margins or dividers) without cropping or asking the model to draw a layout."""
canvas = Image.new("RGB", (1536, 1536), (246, 242, 234))
with Image.open(io.BytesIO(images["turnaround"])) as source:
turnaround = ImageOps.fit(source.convert("RGB"), (1536, 1024), Image.Resampling.LANCZOS)
# The model receives an explicit equal-thirds triptych. Crop only those
# fixed panel boundaries; every generated person must already fit inside
# their third with safe head/foot margins.
for index in range(3):
raw_panel = turnaround.crop((index * 512, 0, (index + 1) * 512, 1024))
buffer = io.BytesIO()
raw_panel.save(buffer, format="PNG")
panel = _contain_image(buffer.getvalue(), 512, 1024, 8)
canvas.paste(panel, (index * 512, 0))
# The three poses are one seamless 3:2 image: pasted whole (aspect kept, no per-third crop and
# no inset), so a cape or sword reaching across a third boundary is not clipped or interrupted.
canvas.paste(_contain_image(images["turnaround"], 1536, 1024, 0), (0, 0))
if prop_count == 0:
scene_width = 1536
@ -3701,14 +3693,14 @@ def _compose_character_reference(images: dict[str, bytes], prop_count: int) -> s
scene_width = 1024
else:
scene_width = 768
scene = _contain_image(images["scene"], scene_width, 512, 12)
scene = _contain_image(images["scene"], scene_width, 512, 0)
canvas.paste(scene, (0, 1024))
remaining = 1536 - scene_width
if prop_count:
tile_width = remaining // prop_count
for index in range(prop_count):
width = remaining - tile_width * index if index == prop_count - 1 else tile_width
panel = _contain_image(images[f"prop{index + 1}"], width, 512, 18)
panel = _contain_image(images[f"prop{index + 1}"], width, 512, 0)
x = scene_width + tile_width * index
canvas.paste(panel, (x, 1024))
# No divider lines between panels: they cut through capes and swords at the panel edges.

View File

@ -33,5 +33,27 @@ class ComposeReferenceNoDividers(unittest.TestCase):
self.assertEqual(img.getcolors(maxcolors=16), [(1536 * 1536, BG)], f"divider drawn with {props} props")
class ComposeReferenceNoGutters(unittest.TestCase):
def test_panels_are_flush(self):
def solid(color, size):
out = io.BytesIO()
Image.new("RGB", size, color).save(out, format="PNG")
return out.getvalue()
red, green, blue = (200, 0, 0), (0, 200, 0), (0, 0, 200)
for props in (0, 1, 2):
scene_w = {0: 1536, 1: 1024, 2: 768}[props]
tile_w = max(1, (1536 - scene_w) // 2 if props == 2 else 1536 - scene_w)
images = {"turnaround": solid(red, (1536, 1024)), "scene": solid(green, (scene_w, 512)),
"prop1": solid(blue, (tile_w, 512)), "prop2": solid(blue, (tile_w, 512))}
uri = conversation._compose_character_reference(images, props)
img = Image.open(io.BytesIO(base64.b64decode(uri.split(",", 1)[1]))).convert("RGB")
colours = {c for _, c in img.getcolors(maxcolors=16)}
self.assertNotIn(BG, colours, f"background gutter with {props} props")
self.assertTrue(all(img.getpixel((x, 100)) == red for x in range(1536)))
self.assertTrue(all(img.getpixel((x, 1023)) == red for x in range(1536)))
self.assertEqual(img.getpixel((0, 1024)), green)
if __name__ == "__main__":
unittest.main()