Fix dynamic memoir image limits
This commit is contained in:
@@ -23,7 +23,7 @@ def parse_image_placeholders(content: str, max_images: int) -> list[dict[str, An
|
||||
"start_offset": match.start(),
|
||||
}
|
||||
)
|
||||
if len(items) >= max_images:
|
||||
if max_images is not None and len(items) >= max_images:
|
||||
break
|
||||
return items
|
||||
|
||||
|
||||
@@ -2,32 +2,52 @@ import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
DEFAULT_LIBLIB_TEMPLATE_UUID = "5d7e67009b344550bc1aa6ccbfa1d7f4"
|
||||
DEFAULT_MAX_IMAGES_PER_CHAPTER = 2
|
||||
DEFAULT_CHARS_PER_EXTRA_IMAGE = 1500
|
||||
DEFAULT_MAX_IMAGES_CAP = 8
|
||||
DEFAULT_IMAGE_PROVIDER = "liblib"
|
||||
DEFAULT_IMAGE_STYLE = "watercolor"
|
||||
DEFAULT_IMAGE_SIZE = "1280x720"
|
||||
DEFAULT_POLL_INTERVAL_SECONDS = 3
|
||||
DEFAULT_MAX_ATTEMPTS = 60
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MemoirImageSettings:
|
||||
enabled: bool
|
||||
max_per_chapter: int
|
||||
provider: str
|
||||
default_style: str
|
||||
default_size: str
|
||||
poll_interval_seconds: int
|
||||
max_attempts: int
|
||||
liblib_template_uuid: str
|
||||
enabled: bool = False
|
||||
max_per_chapter: int = DEFAULT_MAX_IMAGES_PER_CHAPTER
|
||||
chars_per_extra_image: int = DEFAULT_CHARS_PER_EXTRA_IMAGE
|
||||
max_images_cap: int = DEFAULT_MAX_IMAGES_CAP
|
||||
provider: str = DEFAULT_IMAGE_PROVIDER
|
||||
default_style: str = DEFAULT_IMAGE_STYLE
|
||||
default_size: str = DEFAULT_IMAGE_SIZE
|
||||
poll_interval_seconds: int = DEFAULT_POLL_INTERVAL_SECONDS
|
||||
max_attempts: int = DEFAULT_MAX_ATTEMPTS
|
||||
liblib_template_uuid: str = DEFAULT_LIBLIB_TEMPLATE_UUID
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "MemoirImageSettings":
|
||||
return cls(
|
||||
enabled=os.getenv("MEMOIR_IMAGE_ENABLED", "").lower() in {"1", "true", "yes"},
|
||||
max_per_chapter=_get_int_env("MEMOIR_IMAGE_MAX_PER_CHAPTER", 2),
|
||||
provider=os.getenv("MEMOIR_IMAGE_PROVIDER", "liblib"),
|
||||
default_style=os.getenv("MEMOIR_IMAGE_STYLE_DEFAULT", "watercolor"),
|
||||
default_size=os.getenv("MEMOIR_IMAGE_SIZE_DEFAULT", "1280x720"),
|
||||
poll_interval_seconds=_get_int_env("MEMOIR_IMAGE_POLL_INTERVAL", 3),
|
||||
max_attempts=_get_int_env("MEMOIR_IMAGE_MAX_ATTEMPTS", 60),
|
||||
max_per_chapter=_get_int_env("MEMOIR_IMAGE_MAX_PER_CHAPTER", DEFAULT_MAX_IMAGES_PER_CHAPTER),
|
||||
chars_per_extra_image=_get_int_env("MEMOIR_IMAGE_CHARS_PER_EXTRA", DEFAULT_CHARS_PER_EXTRA_IMAGE),
|
||||
max_images_cap=_get_int_env("MEMOIR_IMAGE_MAX_CAP", DEFAULT_MAX_IMAGES_CAP),
|
||||
provider=os.getenv("MEMOIR_IMAGE_PROVIDER", DEFAULT_IMAGE_PROVIDER),
|
||||
default_style=os.getenv("MEMOIR_IMAGE_STYLE_DEFAULT", DEFAULT_IMAGE_STYLE),
|
||||
default_size=os.getenv("MEMOIR_IMAGE_SIZE_DEFAULT", DEFAULT_IMAGE_SIZE),
|
||||
poll_interval_seconds=_get_int_env("MEMOIR_IMAGE_POLL_INTERVAL", DEFAULT_POLL_INTERVAL_SECONDS),
|
||||
max_attempts=_get_int_env("MEMOIR_IMAGE_MAX_ATTEMPTS", DEFAULT_MAX_ATTEMPTS),
|
||||
liblib_template_uuid=os.getenv("LIBLIB_TEMPLATE_UUID") or DEFAULT_LIBLIB_TEMPLATE_UUID,
|
||||
)
|
||||
|
||||
def effective_max_images(self, content_length: int) -> int:
|
||||
"""根据正文字数动态计算单章允许的最大图片数。"""
|
||||
base_max = max(self.max_per_chapter, 0)
|
||||
effective_cap = max(self.max_images_cap, base_max)
|
||||
safe_length = max(content_length, 0)
|
||||
extra = safe_length // self.chars_per_extra_image if self.chars_per_extra_image > 0 else 0
|
||||
return min(base_max + extra, effective_cap)
|
||||
|
||||
|
||||
def _get_int_env(name: str, default: int) -> int:
|
||||
value = os.getenv(name, str(default))
|
||||
|
||||
@@ -168,6 +168,34 @@ def chapter_has_images_to_generate(images: list[dict] | None) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _select_placeholders_for_effective_max(
|
||||
placeholders: list[dict],
|
||||
existing_images: list[dict] | None,
|
||||
effective_max: int,
|
||||
) -> list[dict]:
|
||||
existing_placeholders = {
|
||||
item.get("placeholder")
|
||||
for item in normalize_image_assets(existing_images)
|
||||
if item.get("placeholder")
|
||||
}
|
||||
existing_count_in_content = sum(
|
||||
1 for item in placeholders if item.get("placeholder") in existing_placeholders
|
||||
)
|
||||
remaining_new_slots = max(0, effective_max - existing_count_in_content)
|
||||
|
||||
selected: list[dict] = []
|
||||
for item in placeholders:
|
||||
if item.get("placeholder") in existing_placeholders:
|
||||
selected.append(item)
|
||||
continue
|
||||
if remaining_new_slots <= 0:
|
||||
continue
|
||||
selected.append(item)
|
||||
remaining_new_slots -= 1
|
||||
|
||||
return [{**item, "index": index} for index, item in enumerate(selected)]
|
||||
|
||||
|
||||
def initialize_chapter_images(chapter) -> list[dict]:
|
||||
"""Parse IMAGE placeholders from chapter content and build pending image assets."""
|
||||
settings = MemoirImageSettings.from_env()
|
||||
@@ -177,7 +205,13 @@ def initialize_chapter_images(chapter) -> list[dict]:
|
||||
return chapter.images
|
||||
|
||||
prompt_service = MemoirImagePromptService(llm=None, settings=settings)
|
||||
placeholders = parse_image_placeholders(chapter.content, settings.max_per_chapter)
|
||||
effective_max = settings.effective_max_images(len(chapter.content or ""))
|
||||
all_placeholders = parse_image_placeholders(chapter.content, max_images=None)
|
||||
placeholders = _select_placeholders_for_effective_max(
|
||||
placeholders=all_placeholders,
|
||||
existing_images=chapter.images,
|
||||
effective_max=effective_max,
|
||||
)
|
||||
style = prompt_service.CATEGORY_STYLE_MAP.get(chapter.category, settings.default_style)
|
||||
chapter.images = _merge_chapter_image_assets(
|
||||
existing_images=chapter.images,
|
||||
@@ -188,8 +222,10 @@ def initialize_chapter_images(chapter) -> list[dict]:
|
||||
now_iso=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
logger.info(
|
||||
"章节图片初始化完成: chapter=%s, placeholders=%d, images=%d, statuses=%s",
|
||||
"章节图片初始化完成: chapter=%s, effective_max=%d, total_placeholders=%d, selected_placeholders=%d, images=%d, statuses=%s",
|
||||
chapter.id,
|
||||
effective_max,
|
||||
len(all_placeholders),
|
||||
len(placeholders),
|
||||
len(chapter.images or []),
|
||||
[item.get("status") for item in (chapter.images or [])],
|
||||
|
||||
@@ -149,3 +149,135 @@ class MemoirImageBootstrapTest(unittest.TestCase):
|
||||
self.assertEqual(len(assets), 1)
|
||||
self.assertEqual(assets[0]["status"], "failed")
|
||||
self.assertEqual(assets[0]["error"], "invalid image status: mystery")
|
||||
|
||||
def test_initialize_chapter_images_preserves_existing_completed_assets_beyond_effective_max(self):
|
||||
chapter = type(
|
||||
"ChapterStub",
|
||||
(),
|
||||
{
|
||||
"id": "chapter-1",
|
||||
"title": "童年的夏天",
|
||||
"category": "childhood",
|
||||
"content": (
|
||||
"{{IMAGE:南方小镇的青石板路}}\n"
|
||||
"{{IMAGE:奶奶坐在院子里的藤椅上}}\n"
|
||||
"{{IMAGE:门前的老槐树}}"
|
||||
),
|
||||
"images": [
|
||||
{
|
||||
"index": 0,
|
||||
"placeholder": "{{IMAGE:南方小镇的青石板路}}",
|
||||
"description": "南方小镇的青石板路",
|
||||
"status": "completed",
|
||||
"url": "https://cos.example.com/1.png",
|
||||
},
|
||||
{
|
||||
"index": 1,
|
||||
"placeholder": "{{IMAGE:奶奶坐在院子里的藤椅上}}",
|
||||
"description": "奶奶坐在院子里的藤椅上",
|
||||
"status": "completed",
|
||||
"url": "https://cos.example.com/2.png",
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"placeholder": "{{IMAGE:门前的老槐树}}",
|
||||
"description": "门前的老槐树",
|
||||
"status": "completed",
|
||||
"url": "https://cos.example.com/3.png",
|
||||
},
|
||||
],
|
||||
},
|
||||
)()
|
||||
|
||||
with unittest.mock.patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"MEMOIR_IMAGE_ENABLED": "true",
|
||||
"MEMOIR_IMAGE_MAX_PER_CHAPTER": "2",
|
||||
"MEMOIR_IMAGE_CHARS_PER_EXTRA": "99999",
|
||||
"MEMOIR_IMAGE_MAX_CAP": "8",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
assets = initialize_chapter_images(chapter)
|
||||
|
||||
self.assertEqual(len(assets), 3)
|
||||
self.assertEqual(
|
||||
[asset["placeholder"] for asset in assets],
|
||||
[
|
||||
"{{IMAGE:南方小镇的青石板路}}",
|
||||
"{{IMAGE:奶奶坐在院子里的藤椅上}}",
|
||||
"{{IMAGE:门前的老槐树}}",
|
||||
],
|
||||
)
|
||||
self.assertTrue(all(asset["status"] == "completed" for asset in assets))
|
||||
|
||||
def test_initialize_chapter_images_increases_limit_for_long_content(self):
|
||||
chapter = type(
|
||||
"ChapterStub",
|
||||
(),
|
||||
{
|
||||
"id": "chapter-1",
|
||||
"title": "童年的夏天",
|
||||
"category": "childhood",
|
||||
"content": (
|
||||
("很长的正文" * 800)
|
||||
+ "\n{{IMAGE:南方小镇的青石板路}}"
|
||||
+ "\n{{IMAGE:奶奶坐在院子里的藤椅上}}"
|
||||
+ "\n{{IMAGE:门前的老槐树}}"
|
||||
+ "\n{{IMAGE:夏夜的晒谷场}}"
|
||||
),
|
||||
"images": [],
|
||||
},
|
||||
)()
|
||||
|
||||
with unittest.mock.patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"MEMOIR_IMAGE_ENABLED": "true",
|
||||
"MEMOIR_IMAGE_MAX_PER_CHAPTER": "2",
|
||||
"MEMOIR_IMAGE_CHARS_PER_EXTRA": "1000",
|
||||
"MEMOIR_IMAGE_MAX_CAP": "8",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
assets = initialize_chapter_images(chapter)
|
||||
|
||||
self.assertEqual(len(assets), 4)
|
||||
self.assertTrue(all(asset["status"] == "pending" for asset in assets))
|
||||
|
||||
def test_initialize_chapter_images_caps_dynamic_limit_at_max_images_cap(self):
|
||||
chapter = type(
|
||||
"ChapterStub",
|
||||
(),
|
||||
{
|
||||
"id": "chapter-1",
|
||||
"title": "童年的夏天",
|
||||
"category": "childhood",
|
||||
"content": (
|
||||
("很长的正文" * 1600)
|
||||
+ "\n{{IMAGE:图1}}"
|
||||
+ "\n{{IMAGE:图2}}"
|
||||
+ "\n{{IMAGE:图3}}"
|
||||
+ "\n{{IMAGE:图4}}"
|
||||
+ "\n{{IMAGE:图5}}"
|
||||
+ "\n{{IMAGE:图6}}"
|
||||
),
|
||||
"images": [],
|
||||
},
|
||||
)()
|
||||
|
||||
with unittest.mock.patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"MEMOIR_IMAGE_ENABLED": "true",
|
||||
"MEMOIR_IMAGE_MAX_PER_CHAPTER": "2",
|
||||
"MEMOIR_IMAGE_CHARS_PER_EXTRA": "1000",
|
||||
"MEMOIR_IMAGE_MAX_CAP": "4",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
assets = initialize_chapter_images(chapter)
|
||||
|
||||
self.assertEqual(len(assets), 4)
|
||||
self.assertEqual([asset["description"] for asset in assets], ["图1", "图2", "图3", "图4"])
|
||||
|
||||
@@ -13,6 +13,8 @@ class MemoirImageSettingsTest(unittest.TestCase):
|
||||
os.environ,
|
||||
{
|
||||
"MEMOIR_IMAGE_MAX_PER_CHAPTER": "not-an-int",
|
||||
"MEMOIR_IMAGE_CHARS_PER_EXTRA": "bad-extra",
|
||||
"MEMOIR_IMAGE_MAX_CAP": "bad-cap",
|
||||
"MEMOIR_IMAGE_POLL_INTERVAL": "bad",
|
||||
"MEMOIR_IMAGE_MAX_ATTEMPTS": "oops",
|
||||
},
|
||||
@@ -22,6 +24,8 @@ class MemoirImageSettingsTest(unittest.TestCase):
|
||||
settings = MemoirImageSettings.from_env()
|
||||
|
||||
self.assertEqual(settings.max_per_chapter, 2)
|
||||
self.assertEqual(settings.chars_per_extra_image, 1500)
|
||||
self.assertEqual(settings.max_images_cap, 8)
|
||||
self.assertEqual(settings.poll_interval_seconds, 3)
|
||||
self.assertEqual(settings.max_attempts, 60)
|
||||
|
||||
@@ -31,3 +35,8 @@ class MemoirImageSettingsTest(unittest.TestCase):
|
||||
settings = MemoirImageSettings.from_env()
|
||||
|
||||
self.assertEqual(settings.liblib_template_uuid, DEFAULT_LIBLIB_TEMPLATE_UUID)
|
||||
|
||||
def test_effective_max_images_never_drops_below_base_max_per_chapter(self):
|
||||
settings = MemoirImageSettings(enabled=True, max_per_chapter=2, max_images_cap=1)
|
||||
|
||||
self.assertEqual(settings.effective_max_images(0), 2)
|
||||
|
||||
Reference in New Issue
Block a user