Files
2026-07-13 13:08:41 +08:00

355 lines
12 KiB
Python

import asyncio
from typing import List, Optional, Sequence
from models.image_prompt import ImagePrompt
from models.json_path_guide import JsonPathGuide
from models.sql.image_asset import ImageAsset
from models.sql.slide import SlideModel
from services.icon_finder_service import ICON_FINDER_SERVICE
from services.image_generation_service import ImageGenerationService
from utils.asset_directory_utils import (
filesystem_image_path_to_app_data_url,
normalize_slide_asset_url,
)
from utils.dict_utils import get_dict_at_path, get_dict_paths_with_key, set_dict_at_path
from utils.icon_weights import DEFAULT_ICON_WEIGHT, normalize_icon_weight
from utils.image_generation_error import image_generation_warning
IMAGE_PROMPT_KEYS = ("__image_prompt__", "image_prompt")
ICON_QUERY_KEYS = ("__icon_query__", "icon_query")
def _uses_template_v2_asset_fields(slide: SlideModel) -> bool:
return slide.layout_group.startswith("template-v2")
def _asset_url_key(asset_type: str, template_v2: bool) -> str:
if asset_type == "image":
return "image_url" if template_v2 else "__image_url__"
return "icon_url" if template_v2 else "__icon_url__"
def _set_asset_url(
asset: dict,
asset_type: str,
url: str,
*,
template_v2: bool,
) -> None:
key = _asset_url_key(asset_type, template_v2)
asset[key] = url
if template_v2:
asset.pop(f"__{asset_type}_url__", None)
def _get_asset_url(asset: dict, asset_type: str, *, template_v2: bool) -> str | None:
keys = (
(_asset_url_key(asset_type, template_v2), f"__{asset_type}_url__")
if template_v2
else (_asset_url_key(asset_type, template_v2),)
)
for key in keys:
value = asset.get(key)
if isinstance(value, str):
return value
return None
def _dict_paths_with_any_key(
content: dict, keys: Sequence[str]
) -> List[JsonPathGuide]:
paths: List[JsonPathGuide] = []
for key in keys:
for path in get_dict_paths_with_key(content, key):
if path not in paths:
paths.append(path)
return paths
def _prompt_value(parent: dict, keys: Sequence[str]) -> Optional[str]:
for key in keys:
value = parent.get(key)
if isinstance(value, str) and value.strip():
return value
return None
def _asset_dicts_with_prompt(
content: dict, keys: Sequence[str]
) -> List[tuple[JsonPathGuide, dict, str]]:
assets = []
for path in _dict_paths_with_any_key(content, keys):
parent = get_dict_at_path(content, path)
prompt = _prompt_value(parent, keys)
if prompt is not None:
assets.append((path, parent, prompt))
return assets
async def process_slide_and_fetch_assets(
image_generation_service: ImageGenerationService,
slide: SlideModel,
outline_image_urls: Optional[List[str]] = None,
icon_weight: str = DEFAULT_ICON_WEIGHT,
allow_image_fallback: bool = False,
image_warnings: Optional[List[dict]] = None,
) -> List[ImageAsset]:
async_tasks = []
async_task_meta = []
resolved_icon_weight = normalize_icon_weight(icon_weight)
template_v2 = _uses_template_v2_asset_fields(slide)
image_assets = _asset_dicts_with_prompt(slide.content, IMAGE_PROMPT_KEYS)
icon_assets = _asset_dicts_with_prompt(slide.content, ICON_QUERY_KEYS)
for image_index, (image_path, image_parent, image_prompt) in enumerate(
image_assets
):
if (
outline_image_urls
and image_index < len(outline_image_urls)
and outline_image_urls[image_index]
):
_set_asset_url(
image_parent,
"image",
normalize_slide_asset_url(outline_image_urls[image_index]),
template_v2=template_v2,
)
set_dict_at_path(slide.content, image_path, image_parent)
continue
async_tasks.append(
image_generation_service.generate_image(
ImagePrompt(prompt=image_prompt)
)
)
async_task_meta.append(("image", image_path))
for icon_path, _icon_parent, icon_query in icon_assets:
async_tasks.append(
ICON_FINDER_SERVICE.search_icons(
icon_query,
weight=resolved_icon_weight,
)
)
async_task_meta.append(("icon", icon_path))
results = (
await asyncio.gather(*async_tasks, return_exceptions=allow_image_fallback)
if async_tasks
else []
)
return_assets = []
for (task_type, asset_path), result in zip(async_task_meta, results):
if task_type == "image":
image_dict = get_dict_at_path(slide.content, asset_path)
if isinstance(result, BaseException):
if not allow_image_fallback:
raise result
_set_asset_url(
image_dict,
"image",
normalize_slide_asset_url("/static/images/placeholder.jpg"),
template_v2=template_v2,
)
if image_warnings is not None and isinstance(result, Exception):
image_warnings.append(image_generation_warning(result))
set_dict_at_path(slide.content, asset_path, image_dict)
continue
if isinstance(result, ImageAsset):
return_assets.append(result)
_set_asset_url(
image_dict,
"image",
filesystem_image_path_to_app_data_url(result.path),
template_v2=template_v2,
)
else:
_set_asset_url(
image_dict,
"image",
normalize_slide_asset_url(result),
template_v2=template_v2,
)
set_dict_at_path(slide.content, asset_path, image_dict)
continue
if isinstance(result, BaseException):
raise result
icon_dict = get_dict_at_path(slide.content, asset_path)
# ICON_FINDER_SERVICE.search_icons returns a list of URLs
if isinstance(result, list) and result:
icon_url = normalize_slide_asset_url(result[0])
else:
# Fallback to FastAPI static placeholder if no icon found
icon_url = normalize_slide_asset_url("/static/icons/placeholder.svg")
_set_asset_url(
icon_dict,
"icon",
icon_url,
template_v2=template_v2,
)
set_dict_at_path(slide.content, asset_path, icon_dict)
return return_assets
async def process_old_and_new_slides_and_fetch_assets(
image_generation_service: ImageGenerationService,
old_slide_content: dict,
new_slide_content: dict,
icon_weight: str = DEFAULT_ICON_WEIGHT,
use_template_v2_asset_fields: bool = False,
allow_image_fallback: bool = False,
image_warnings: Optional[List[dict]] = None,
) -> List[ImageAsset]:
resolved_icon_weight = normalize_icon_weight(icon_weight)
old_image_assets = _asset_dicts_with_prompt(
old_slide_content, IMAGE_PROMPT_KEYS
)
old_icon_assets = _asset_dicts_with_prompt(old_slide_content, ICON_QUERY_KEYS)
new_image_assets = _asset_dicts_with_prompt(
new_slide_content, IMAGE_PROMPT_KEYS
)
new_icon_assets = _asset_dicts_with_prompt(new_slide_content, ICON_QUERY_KEYS)
old_image_urls = {
prompt: image_url
for _path, asset, prompt in old_image_assets
if (
image_url := _get_asset_url(
asset,
"image",
template_v2=use_template_v2_asset_fields,
)
)
}
old_icon_urls = {
query: icon_url
for _path, asset, query in old_icon_assets
if (
icon_url := _get_asset_url(
asset,
"icon",
template_v2=use_template_v2_asset_fields,
)
)
}
async_image_fetch_tasks = []
fetched_image_targets = []
for _path, new_image, image_prompt in new_image_assets:
if image_prompt in old_image_urls:
_set_asset_url(
new_image,
"image",
old_image_urls[image_prompt],
template_v2=use_template_v2_asset_fields,
)
continue
async_image_fetch_tasks.append(
image_generation_service.generate_image(ImagePrompt(prompt=image_prompt))
)
fetched_image_targets.append(new_image)
async_icon_fetch_tasks = []
fetched_icon_targets = []
for _path, new_icon, icon_query in new_icon_assets:
if icon_query in old_icon_urls:
_set_asset_url(
new_icon,
"icon",
old_icon_urls[icon_query],
template_v2=use_template_v2_asset_fields,
)
continue
async_icon_fetch_tasks.append(
ICON_FINDER_SERVICE.search_icons(
icon_query,
weight=resolved_icon_weight,
)
)
fetched_icon_targets.append(new_icon)
new_images = await asyncio.gather(
*async_image_fetch_tasks,
return_exceptions=allow_image_fallback,
)
new_icons = await asyncio.gather(*async_icon_fetch_tasks)
# list of new assets
new_assets = []
# Sets new image and icon urls for assets that were fetched
for target, fetched_image in zip(fetched_image_targets, new_images):
if isinstance(fetched_image, BaseException):
if not allow_image_fallback:
raise fetched_image
image_url = normalize_slide_asset_url("/static/images/placeholder.jpg")
if image_warnings is not None and isinstance(fetched_image, Exception):
image_warnings.append(image_generation_warning(fetched_image))
elif isinstance(fetched_image, ImageAsset):
new_assets.append(fetched_image)
image_url = filesystem_image_path_to_app_data_url(fetched_image.path)
else:
image_url = normalize_slide_asset_url(fetched_image)
_set_asset_url(
target,
"image",
image_url,
template_v2=use_template_v2_asset_fields,
)
for target, icon_result in zip(fetched_icon_targets, new_icons):
if icon_result:
icon_url = normalize_slide_asset_url(icon_result[0])
else:
icon_url = normalize_slide_asset_url("/static/icons/placeholder.svg")
_set_asset_url(
target,
"icon",
icon_url,
template_v2=use_template_v2_asset_fields,
)
for path, asset, _prompt in new_image_assets:
set_dict_at_path(new_slide_content, path, asset)
for path, asset, _query in new_icon_assets:
set_dict_at_path(new_slide_content, path, asset)
return new_assets
def process_slide_add_placeholder_assets(slide: SlideModel):
template_v2 = _uses_template_v2_asset_fields(slide)
image_paths = _dict_paths_with_any_key(slide.content, IMAGE_PROMPT_KEYS)
icon_paths = _dict_paths_with_any_key(slide.content, ICON_QUERY_KEYS)
for image_path in image_paths:
image_dict = get_dict_at_path(slide.content, image_path)
# Use FastAPI static path for placeholder image
_set_asset_url(
image_dict,
"image",
normalize_slide_asset_url("/static/images/placeholder.jpg"),
template_v2=template_v2,
)
set_dict_at_path(slide.content, image_path, image_dict)
for icon_path in icon_paths:
icon_dict = get_dict_at_path(slide.content, icon_path)
# Use FastAPI static path for placeholder icon
_set_asset_url(
icon_dict,
"icon",
normalize_slide_asset_url("/static/icons/placeholder.svg"),
template_v2=template_v2,
)
set_dict_at_path(slide.content, icon_path, icon_dict)