Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/preppipe/frontend/vnmodel/passes.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from ...irbase import *
from .vncodegen import VNCodeGen
from .vnparser import VNParser
from .vnprune import prune_unused_asset_declarations
from ...vnmodel import VNModel

@TransformArgumentGroup('vnparse', 'Options for VNModel source parsing')
Expand Down Expand Up @@ -55,3 +56,13 @@ def run(self) -> Operation | list[Operation] | None:
ast = self.inputs[0]
model = VNCodeGen.run(ast)
return model


@MiddleEndDecl('vn-prune-unused-decls', input_decl=VNModel, output_decl=VNModel)
class VNPruneUnusedAssetsTransform(TransformBase):
def run(self) -> Operation | list[Operation] | None:
assert len(self.inputs) == 1
model = self.inputs[0]
if isinstance(model, VNModel):
prune_unused_asset_declarations(model)
return model
33 changes: 33 additions & 0 deletions src/preppipe/frontend/vnmodel/vnprune.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# SPDX-FileCopyrightText: 2023 PrepPipe's Contributors
# SPDX-License-Identifier: Apache-2.0

from ...vnmodel import (
VNModel,
VNNamespace,
VNCharacterSymbol,
VNSceneSymbol,
VNAssetValueSymbol,
)


def _prune_region(symbols):
to_remove = [s for s in symbols if isinstance(s, VNAssetValueSymbol) and s.use_empty()]
for s in to_remove:
s.erase_from_parent()


def prune_unused_asset_declarations(model: VNModel) -> None:
"""原地移除 model 中未被使用的资源声明(依赖 Value 的 uselist)。"""
for ns in model.namespace:
if not isinstance(ns, VNNamespace):
continue
for c in ns.characters:
if not isinstance(c, VNCharacterSymbol):
continue
_prune_region(c.sprites)
_prune_region(c.sideimages)
for scene in ns.scenes:
if not isinstance(scene, VNSceneSymbol):
continue
_prune_region(scene.backgrounds)
_prune_region(ns.assets)
102 changes: 95 additions & 7 deletions src/preppipe/util/imagepack.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,26 @@ def clear(self):
self.layers.clear()
self.min_cached_layer = 0


class LayerBlendMode(enum.Enum):
"""图层混合模式。NORMAL 为默认 alpha 合成,MULTIPLY 为正片叠底。
序列化时使用 name.lower()(如 "normal", "multiply"),反序列化用 LayerBlendMode.from_serialized(str)。"""
NORMAL = enum.auto()
MULTIPLY = enum.auto()

def to_serialized(self) -> str:
return self.name.lower()

@classmethod
def from_serialized(cls, s : str | None) -> "LayerBlendMode":
if s is None or s.strip() == "":
return cls.NORMAL
try:
return cls[s.upper()]
except KeyError:
raise ValueError("Unsupported layer mode: " + s + " (only normal and multiply are supported)")


@AssetClassDecl("imagepack")
@ToolClassDecl("imagepack")
class ImagePack(NamedAssetClassBase):
Expand Down Expand Up @@ -175,11 +195,13 @@ class LayerInfo:
base : bool
# 如果是 True 的话,该层可以不受限制地单独被选取
toggle : bool
# 图层混合模式
mode : LayerBlendMode

def __init__(self, patch : ImageWrapper,
offset_x : int = 0, offset_y : int = 0,
width : int = 0, height : int = 0,
base : bool = False, toggle : bool = False, basename : str = '') -> None:
base : bool = False, toggle : bool = False, basename : str = '', mode : LayerBlendMode | None = None) -> None:
self.patch = patch
self.basename = basename
self.offset_x = offset_x
Expand All @@ -188,6 +210,7 @@ def __init__(self, patch : ImageWrapper,
self.height = height
self.base = base
self.toggle = toggle
self.mode = mode if mode is not None else LayerBlendMode.NORMAL
if width == 0 or height == 0:
raise RuntimeError("Zero-sized layer?")

Expand All @@ -199,7 +222,7 @@ def get_shrinked(self, ratio : decimal.Decimal):
offset_x=int(self.offset_x * ratio),
offset_y=int(self.offset_y * ratio),
width=newwidth, height=newheight,
base=self.base, toggle=self.toggle, basename=self.basename)
base=self.base, toggle=self.toggle, basename=self.basename, mode=self.mode)

class CompositeInfo:
# 保存时每个差分的信息
Expand Down Expand Up @@ -328,6 +351,8 @@ def collect_layer_group(prefix : str, layers : list[ImagePack.LayerInfo]):
flags.append("toggle")
if len(flags) > 0:
jsonobj["flags"] = flags
if l.mode != LayerBlendMode.NORMAL:
jsonobj["mode"] = l.mode.to_serialized()
result.append(jsonobj)
self._write_image_to_path(l.patch, filename, path)
return result
Expand Down Expand Up @@ -430,11 +455,16 @@ def read_layer_group(prefix, group_name):
height = layer_info["h"]
base = "base" in layer_info.get("flags", [])
toggle = "toggle" in layer_info.get("flags", [])
mode_str = layer_info.get("mode", None)
try:
mode = LayerBlendMode.from_serialized(mode_str)
except ValueError as e:
raise PPInternalError(str(e))
layer_img = ImageWrapper(path=os.path.join(path, layer_filename))
layers.append(ImagePack.LayerInfo(patch=layer_img,
offset_x=offset_x, offset_y=offset_y,
width=width, height=height,
base=base, toggle=toggle, basename=layer_info["p"]))
base=base, toggle=toggle, basename=layer_info["p"], mode=mode))
return layers

self.layers = read_layer_group("l", "layers")
Expand Down Expand Up @@ -503,7 +533,11 @@ def get_composed_image_lower(self, layer_indices : list[int], composition_cache:
for li in layer_indices:
layer = self.layers[li]
cur = result.crop((layer.offset_x, layer.offset_y, layer.offset_x + layer.width, layer.offset_y + layer.height))
cur = PIL.Image.alpha_composite(cur, layer.patch.get())
layer_patch = layer.patch.get()
if layer.mode == LayerBlendMode.MULTIPLY:
cur = ImagePack.apply_multiply_blend_mode(cur, layer_patch)
else:
cur = PIL.Image.alpha_composite(cur, layer_patch)
if composition_cache is not None and li >= composition_cache.min_cached_layer:
result = result.copy()
result.paste(cur, (layer.offset_x, layer.offset_y))
Expand All @@ -513,6 +547,21 @@ def get_composed_image_lower(self, layer_indices : list[int], composition_cache:
# print(f"get_composed_image_lower: {end-start} s")
return ImageWrapper(image=result)

@staticmethod
def apply_multiply_blend_mode(base : PIL.Image.Image, overlay : PIL.Image.Image) -> PIL.Image.Image:
base_array = np.array(base, dtype=np.float32)
overlay_array = np.array(overlay, dtype=np.float32)
overlay_alpha = overlay_array[:, :, 3:4] / 255.0
base_alpha = base_array[:, :, 3:4] / 255.0
result_rgb = base_array[:, :, :3] * overlay_array[:, :, :3] / 255.0
result_alpha = overlay_alpha + base_alpha * (1.0 - overlay_alpha)
base_contribution = 1.0 - overlay_alpha
final_rgb = result_rgb * overlay_alpha + base_array[:, :, :3] * base_contribution
final_rgb = np.clip(final_rgb, 0, 255).astype(np.uint8)
final_alpha = np.clip(result_alpha * 255, 0, 255).astype(np.uint8)
result_array = np.dstack((final_rgb, final_alpha))
return PIL.Image.fromarray(result_array, mode='RGBA')

@staticmethod
def ndarray_hsv_to_rgb(hsv : np.ndarray) -> np.ndarray:
#return np.apply_along_axis(ImagePack.hsv_to_rgb, 1, hsv)
Expand Down Expand Up @@ -726,7 +775,7 @@ def create_forked_layer(imgwidth : int, imgheight : int, l : LayerInfo, layerind
return ImagePack.LayerInfo(ImageWrapper(image=newbase.crop(bbox)),
offset_x=offset_x, offset_y=offset_y,
width=xmax-offset_x, height=ymax-offset_y,
base=True, toggle=l.toggle, basename=l.basename)
base=True, toggle=l.toggle, basename=l.basename, mode=l.mode)

def fork_applying_mask(self, args : list[Color | PIL.Image.Image | str | tuple[str, Color] | None], enable_parallelization : bool = False):
# 创建一个新的 imagepack, 将 mask 所影响的部分替换掉
Expand Down Expand Up @@ -1336,6 +1385,7 @@ def lookup_file(filename : str) -> str:
layer_dict[imgpathbase] = layerindex
flag_base = False
flag_toggle = False
flag_mode : LayerBlendMode = LayerBlendMode.NORMAL
def add_flag(flag : str):
nonlocal flag_base
nonlocal flag_toggle
Expand All @@ -1357,6 +1407,13 @@ def add_flag(flag : str):
elif isinstance(value, list):
for flag in value:
add_flag(flag)
elif key in ImagePack.TR_imagepack_yamlparse_mode.get_all_candidates():
if not isinstance(value, str):
raise PPInternalError("Invalid mode in " + yamlpath + ": expecting a str but got " + str(value))
if value in ImagePack.TR_imagepack_yamlparse_multiply.get_all_candidates():
flag_mode = LayerBlendMode.MULTIPLY
else:
raise PPInternalError("Unsupported layer mode: " + value + " (only normal and multiply are supported)")
imgpath = lookup_file(imgpathbase + ".png")
if not os.path.exists(os.path.join(basepath, imgpath)):
raise PPInternalError("Image file not found: " + imgpath)
Expand All @@ -1379,7 +1436,7 @@ def add_flag(flag : str):
newlayer = ImagePack.LayerInfo(patch,
offset_x=offset_x, offset_y=offset_y,
width=img.width, height=img.height,
base=flag_base, toggle=flag_toggle, basename=imgpathbase)
base=flag_base, toggle=flag_toggle, basename=imgpathbase, mode=flag_mode)
result.layers.append(newlayer)
if composites is None:
for i, layer in enumerate(result.layers):
Expand Down Expand Up @@ -1639,6 +1696,21 @@ class CharacterSpritePartsBased_PartKind(enum.Enum):
zh_cn="基底简写",
zh_hk="基底簡寫",
)
TR_imagepack_yamlgen_layer_modes = TR_imagepack.tr("layer_modes",
en="layer_modes",
zh_cn="图层模式",
zh_hk="圖層模式",
)
TR_imagepack_yamlparse_mode = TR_imagepack.tr("mode",
en="mode",
zh_cn="模式",
zh_hk="模式",
)
TR_imagepack_yamlparse_multiply = TR_imagepack.tr("multiply",
en="multiply",
zh_cn="正片叠底",
zh_hk="正片疊底",
)

@dataclasses.dataclass
class CharacterSpritePartsBased_PartsDecl:
Expand All @@ -1661,6 +1733,9 @@ def yaml_generation_charactersprite_parts_based(data : dict, layers : dict | Non
# 为了避免基底所用的名称太长(比如每个选区都有独立的图层、立绘有部分需要拆成独立的置顶的图层),我们支持定义基底组合的简称,如果有简称的话所有基底组合都必须有简称
base_abbreviation : dict[str, tuple[str,...]] | None = None

# 存储图层模式映射:图层名 -> 模式
layer_modes : dict[str, LayerBlendMode] = {}

kinds_enum_map : dict[str, ImagePack.CharacterSpritePartsBased_PartKind] = {}

# 除了装饰部件以外,每种部件类型最多只能声明一次
Expand Down Expand Up @@ -1785,11 +1860,22 @@ def add_parsed_info_to_metadata():
if not isinstance(abbr_list, str):
raise PPInternalError("Invalid abbreviation list in generation: expecting a str but got " + str(abbr_list) + " (type: " + str(type(abbr_list)) + ")")
base_abbreviation[abbr_name] = parse_dep_liststr(abbr_list)
elif k in ImagePack.TR_imagepack_yamlgen_layer_modes.get_all_candidates():
if not isinstance(v, dict):
raise PPInternalError("Invalid layer_modes in generation: expecting a dict but got " + str(v) + " (type: " + str(type(v)) + ")")
for layer_name, mode in v.items():
if not isinstance(mode, str):
raise PPInternalError("Invalid layer mode for " + layer_name + ": expecting a str but got " + str(mode) + " (type: " + str(type(mode)) + ")")
if mode in ImagePack.TR_imagepack_yamlparse_multiply.get_all_candidates():
layer_modes[layer_name] = LayerBlendMode.MULTIPLY
else:
raise PPInternalError("Unsupported layer mode for " + layer_name + ": " + mode + " (only normal and multiply are supported)")
else:
raise PPInternalError("Unknown key in generation: " + k + "(supported keys: "
+ str(ImagePack.TR_imagepack_yamlgen_parts.get_all_candidates()) + ", "
+ str(ImagePack.TR_imagepack_yamlgen_parts_kind.get_all_candidates()) + ", "
+ str(ImagePack.TR_imagepack_yamlgen_tags.get_all_candidates()) + ")")
+ str(ImagePack.TR_imagepack_yamlgen_tags.get_all_candidates()) + ", "
+ str(ImagePack.TR_imagepack_yamlgen_layer_modes.get_all_candidates()) + ")")
# 初步解析完毕,我们将解析结果写入 metadata
add_parsed_info_to_metadata()
# 检查一下输入有没有问题,所有标签是否都有定义,所有部件是否都有定义
Expand Down Expand Up @@ -1937,6 +2023,8 @@ def try_add_combinations(parts_list : tuple[str, ...]):
part_dict = {}
if kinds_enum_map[part.kind] == ImagePack.CharacterSpritePartsBased_PartKind.BASE:
part_dict[ImagePack.TR_imagepack_yamlparse_flags.get()] = ImagePack.TR_imagepack_yamlparse_base.get()
if part.name in layer_modes and layer_modes[part.name] == LayerBlendMode.MULTIPLY:
part_dict[ImagePack.TR_imagepack_yamlparse_mode.get()] = ImagePack.TR_imagepack_yamlparse_multiply.get()
layer_orders[part.name] = len(result_layers)
result_layers[part.name] = part_dict
result_composites : dict[str, list[str]] = {}
Expand Down
20 changes: 18 additions & 2 deletions src/preppipe/util/imagepackexportop.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,9 @@ def get_imagepack_layer_filename(self, pack_id : str, layer_index : int) -> str:

def place_imagepack_composite(self, instance_id : str, pack_id : str, composite_code : str) -> str:
# 组合的图片应该放在哪
return os.path.join("images", pack_id, 'E' + instance_id + '_' + composite_code + ".png")
# 使用正斜杠以确保跨平台兼容性(RenPy 等引擎需要正斜杠)
path = os.path.join("images", pack_id, 'E' + instance_id + '_' + composite_code + ".png")
return path.replace("\\", "/")

# 以下是提供给子类使用者的接口
# ---------------------------------------------------------------------
Expand Down Expand Up @@ -334,7 +336,21 @@ def _add_value_impl(self, value : ImagePackElementLiteralExpr, is_require_merged
target_size_tuple = (value.size.value[0], value.size.value[1])
composite_code = value.composite_name.value
composite_index = instance_info.descriptor.get_composite_index_from_code(composite_code)
if is_require_merged_image or target_size_tuple != instance_info.descriptor.size:
# 检查该组合中是否有图层使用了非默认的混合模式
# 如果有,我们需要使用预合成图片,因为 RenPy 的 layeredimage 不支持自定义混合模式
has_non_default_blend_mode = False
try:
imagepack = AssetManager.get_instance().get_asset(pack_id)
if isinstance(imagepack, ImagePack) and imagepack.is_imagedata_loaded():
for layer_index in instance_info.descriptor.get_layers_from_composite_index(composite_index):
layer = imagepack.layers[layer_index]
if layer.mode != LayerBlendMode.NORMAL:
has_non_default_blend_mode = True
break
except:
# 如果无法访问 ImagePack 或图层信息不可用,保守地假设没有混合模式
pass
if is_require_merged_image or target_size_tuple != instance_info.descriptor.size or has_non_default_blend_mode:
# 我们需要生成一个单独的图片
key_tuple = (composite_index, target_size_tuple)
if key_tuple in instance_info.composite_export_dict:
Expand Down
Loading