diff --git a/src/preppipe/frontend/vnmodel/passes.py b/src/preppipe/frontend/vnmodel/passes.py index 342e832..542e943 100644 --- a/src/preppipe/frontend/vnmodel/passes.py +++ b/src/preppipe/frontend/vnmodel/passes.py @@ -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') @@ -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 diff --git a/src/preppipe/frontend/vnmodel/vnprune.py b/src/preppipe/frontend/vnmodel/vnprune.py new file mode 100644 index 0000000..65d7ed8 --- /dev/null +++ b/src/preppipe/frontend/vnmodel/vnprune.py @@ -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) diff --git a/src/preppipe/util/imagepack.py b/src/preppipe/util/imagepack.py index b0a6e1b..9f41711 100644 --- a/src/preppipe/util/imagepack.py +++ b/src/preppipe/util/imagepack.py @@ -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): @@ -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 @@ -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?") @@ -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: # 保存时每个差分的信息 @@ -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 @@ -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") @@ -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)) @@ -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) @@ -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 所影响的部分替换掉 @@ -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 @@ -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) @@ -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): @@ -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: @@ -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] = {} # 除了装饰部件以外,每种部件类型最多只能声明一次 @@ -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() # 检查一下输入有没有问题,所有标签是否都有定义,所有部件是否都有定义 @@ -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]] = {} diff --git a/src/preppipe/util/imagepackexportop.py b/src/preppipe/util/imagepackexportop.py index 1fc7393..3294c2f 100644 --- a/src/preppipe/util/imagepackexportop.py +++ b/src/preppipe/util/imagepackexportop.py @@ -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("\\", "/") # 以下是提供给子类使用者的接口 # --------------------------------------------------------------------- @@ -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: diff --git a/src/preppipe/util/psddump.py b/src/preppipe/util/psddump.py index 4940056..773244f 100644 --- a/src/preppipe/util/psddump.py +++ b/src/preppipe/util/psddump.py @@ -3,13 +3,51 @@ # 该文件用于分析 psd 文件的图层结构、显示相关信息以便后续编写图片包(ImagePack)的配置文件 # 可使用以下方式进行调用: -# python3 -m preppipe.util.psddump [ ...] +# python3 -m preppipe.util.psddump [ ...] +# python3 -m preppipe.util.psddump --layer-modes-only [--include-hidden] # 仅输出非常规混合模式的图层 +import argparse import sys import psd_tools import psd_tools.api.layers +from typing import Any -def _dump_psd_info(psdpath : str): + +def _export_layer_modes_only(psdpath : str, skip_hidden : bool = True) -> None: + """只输出非常规混合模式(非 normal/pass_through)的图层列表,使用 L1-、L2- 风格命名。""" + psd = psd_tools.PSDImage.open(psdpath) + layer_data : list[dict[str, Any]] = [] + layer_index = 0 + + def visit(stack : list[str], layer : psd_tools.api.layers.Layer) -> None: + nonlocal layer_index + if skip_hidden and not layer.visible: + return + layer_name = layer.name + if not isinstance(layer, psd_tools.api.layers.Group): + layer_index += 1 + layer_name = f"L{layer_index}-{layer.name}" + layer_data.append({ + "name": layer_name, + "blend_mode": layer.blend_mode.name.lower(), + }) + if isinstance(layer, psd_tools.api.layers.Group) and len(layer) > 0: + stack.append(layer.name) + for child in layer: + visit(stack, child) + stack.pop() + + layer_stack : list[str] = [] + for layer in psd: + visit(layer_stack, layer) + + print(f"图层模式:") + for layer in layer_data: + if layer["blend_mode"] not in ("normal", "pass_through"): + print(f" {layer['name']}:{layer['blend_mode']}") + + +def _dump_psd_info(psdpath : str) -> None: psd = psd_tools.PSDImage.open(psdpath) print(f"{psdpath}: {psd.width}x{psd.height}") def visit(stack : list[str], layer : psd_tools.api.layers.Layer) -> None: @@ -42,12 +80,31 @@ def visit(stack : list[str], layer : psd_tools.api.layers.Layer) -> None: visit(layer_stack, layer) def main(args : list[str]) -> int: - if len(args) < 2: - print("Usage: psddump.py [ ...]") - return 1 - for psdpath in args[1:]: - _dump_psd_info(psdpath) + parser = argparse.ArgumentParser( + description="分析 PSD 图层结构,或仅输出非常规混合模式的图层列表。", + epilog="示例: python -m preppipe.util.psddump --layer-modes-only a.psd", + ) + parser.add_argument("psdfiles", nargs="+", metavar="psdfile", help="PSD 文件路径") + parser.add_argument( + "--layer-modes-only", + action="store_true", + help="仅输出非常规混合模式(如 multiply)的图层,使用 L1-、L2- 命名", + ) + parser.add_argument( + "--include-hidden", + action="store_true", + help="包含隐藏图层(仅与 --layer-modes-only 配合时生效)", + ) + parsed = parser.parse_args(args[1:]) + + if parsed.layer_modes_only: + for psdpath in parsed.psdfiles: + _export_layer_modes_only(psdpath, skip_hidden=not parsed.include_hidden) + else: + for psdpath in parsed.psdfiles: + _dump_psd_info(psdpath) return 0 + if __name__ == "__main__": sys.exit(main(sys.argv)) diff --git a/src/preppipe_gui_pyside6/execution.py b/src/preppipe_gui_pyside6/execution.py index cbb0df3..d74472a 100644 --- a/src/preppipe_gui_pyside6/execution.py +++ b/src/preppipe_gui_pyside6/execution.py @@ -108,6 +108,7 @@ def init_main_pipeline(inputs : list[str]): result.add_debug_dump() result.args.extend([ "--vncodegen", + "--vn-prune-unused-decls", "--vn-blocksorting", "--vn-entryinference", ])