refactor: refactor assemblicon context
This commit is contained in:
@@ -1,19 +1,25 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import PIL.Image
|
||||
import PIL.ImageFile
|
||||
from .utils import Artifact, ArtifactFile
|
||||
|
||||
|
||||
def load_asset(p: Path) -> PIL.ImageFile.ImageFile:
|
||||
def load_input_artifact(p: Path) -> ArtifactFile:
|
||||
"""
|
||||
Load input artifact used for building temporary and output artifact.
|
||||
"""
|
||||
logging.info("Loading asset: %s", p)
|
||||
return PIL.Image.open(p, "r", ["PNG", "JPEG", "BMP"])
|
||||
|
||||
|
||||
def load_artifact(p: Path) -> PIL.ImageFile.ImageFile:
|
||||
def load_artifact(p: Path) -> ArtifactFile:
|
||||
"""
|
||||
Load artifact saved previously as the intermediary.
|
||||
"""
|
||||
logging.info("Loading artifact: %s", p)
|
||||
return PIL.Image.open(p, "r", ["PNG"])
|
||||
|
||||
|
||||
def save_artifact(art: PIL.Image.Image, p: Path) -> None:
|
||||
def save_artifact(art: Artifact, p: Path) -> None:
|
||||
logging.info("Saving artifact: %s", p)
|
||||
art.save(p, format="PNG")
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import logging
|
||||
import os
|
||||
import enum
|
||||
import subprocess
|
||||
from typing import Iterable, Iterator
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass
|
||||
import PIL.Image
|
||||
from .utils import Artifact, check_vanilla_artifact
|
||||
|
||||
|
||||
class SvgRenderKind(enum.IntEnum):
|
||||
Inkscape = enum.auto()
|
||||
ReSvg = enum.auto()
|
||||
|
||||
|
||||
@dataclass
|
||||
class SvgConfig:
|
||||
width: int
|
||||
height: int
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.width <= 0:
|
||||
raise ValueError(
|
||||
f"The image width in Inkscape config must be a positive integer"
|
||||
)
|
||||
if self.height <= 0:
|
||||
raise ValueError(
|
||||
f"The image height in Inkscape config must be a positive integer"
|
||||
)
|
||||
|
||||
|
||||
def render_svg(src: Path, dst: Path, kind: SvgRenderKind, cfg: SvgConfig) -> None:
|
||||
"""
|
||||
Render given SVG file to PNG file.
|
||||
|
||||
:param src: The path to SVG file to be rendered
|
||||
:param dst: The path to rendered PNG file.
|
||||
:param kind: The selected SVG render kind.
|
||||
:param cfg: The configuration when rendering.
|
||||
"""
|
||||
logging.info("Rendering SVG artifact with render {kind.name}: %s -> %s", src, dst)
|
||||
|
||||
match kind:
|
||||
case SvgRenderKind.Inkscape:
|
||||
inkscape_bin = os.getenv("ASSEMBLICON_INKSCAPE", "inkscape")
|
||||
cmd = [
|
||||
inkscape_bin,
|
||||
"--export-area-page",
|
||||
"--export-width",
|
||||
str(cfg.width),
|
||||
"--export-height",
|
||||
str(cfg.height),
|
||||
"--export-type",
|
||||
"png",
|
||||
"--export-png-color-mode",
|
||||
"RGBA_8",
|
||||
"--export-background-opacity",
|
||||
"0.0",
|
||||
"--export-png-use-dithering",
|
||||
"false",
|
||||
"--export-filename",
|
||||
str(dst),
|
||||
str(src),
|
||||
]
|
||||
case SvgRenderKind.ReSvg:
|
||||
resvg_bin = os.getenv("ASSEMBLICON_RESVG", "resvg")
|
||||
cmd = [
|
||||
resvg_bin,
|
||||
"--width",
|
||||
str(cfg.width),
|
||||
"--height",
|
||||
str(cfg.height),
|
||||
str(src),
|
||||
str(dst),
|
||||
]
|
||||
|
||||
proc = subprocess.run(cmd, capture_output=False)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"Fail to execute SVG render {kind.name}. "
|
||||
f"Return code is {proc.returncode}"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BlenderConfig:
|
||||
width: int
|
||||
height: int
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.width <= 0:
|
||||
raise ValueError(
|
||||
f"The image width in Blender config must be a positive integer"
|
||||
)
|
||||
if self.height <= 0:
|
||||
raise ValueError(
|
||||
f"The image height in Blender config must be a positive integer"
|
||||
)
|
||||
|
||||
|
||||
_BLENDER_RENDER_SCRIPT = """\
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
try:
|
||||
import bpy
|
||||
|
||||
render = bpy.context.scene.render
|
||||
render.resolution_x = {width}
|
||||
render.resolution_y = {height}
|
||||
render.resolution_percentage = 100
|
||||
render.image_settings.file_format = "PNG"
|
||||
render.image_settings.color_mode = "RGBA"
|
||||
render.image_settings.color_depth = "8"
|
||||
render.film_transparent = True
|
||||
render.use_file_extension = False
|
||||
render.filepath = {dst}
|
||||
bpy.ops.render.render(write_still=True)
|
||||
except BaseException:
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
"""
|
||||
|
||||
|
||||
def render_blender(src: Path, dst: Path, temp_script: Path, cfg: BlenderConfig) -> None:
|
||||
"""
|
||||
Render given Blender composition into PNG file.
|
||||
|
||||
:param src: The path to Blender composition file to be rendered
|
||||
:param dst: The path to rendered PNG file.
|
||||
:param temp_script: The path to temporary Python script instructing Blender rendering.
|
||||
The caller must make sure that this file is immutable during calling this function.
|
||||
:param cfg: The configuration when rendering.
|
||||
"""
|
||||
logging.info("Rendering Blender artifact: %s -> %s", src, dst)
|
||||
|
||||
script = _BLENDER_RENDER_SCRIPT.format(
|
||||
width=cfg.width, height=cfg.height, dst=repr(str(dst))
|
||||
)
|
||||
temp_script.write_text(script, encoding="utf-8")
|
||||
|
||||
blender_bin = os.getenv("ASSEMBLICON_BLENDER", "blender")
|
||||
cmd = [
|
||||
blender_bin,
|
||||
"--background",
|
||||
str(src),
|
||||
"--python",
|
||||
str(temp_script),
|
||||
]
|
||||
proc = subprocess.run(cmd, capture_output=False)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"Fail to execute Blender. Return code is {proc.returncode}")
|
||||
if not dst.exists():
|
||||
raise RuntimeError(
|
||||
f"Blender exited successfully but output file {dst} is missing"
|
||||
)
|
||||
|
||||
|
||||
def render_thumbnail(art: Artifact, hws: Iterable[int]) -> Iterator[Artifact]:
|
||||
"""
|
||||
Render given artifact as multiple resolution thumbnails.
|
||||
|
||||
:param art: The artifact to be rendered.
|
||||
:param hws: The int iterable holding all resolution of thumbnails (square thumbnail only)
|
||||
:return: A generator outputing thumbnail one by one with the order of given resolution.
|
||||
"""
|
||||
check_vanilla_artifact(art)
|
||||
for hw in hws:
|
||||
logging.info("Rendering thumbnail %dx%d.", hw, hw)
|
||||
thumbnail = art.copy()
|
||||
thumbnail.thumbnail((hw, hw), PIL.Image.Resampling.LANCZOS)
|
||||
yield thumbnail
|
||||
|
||||
|
||||
def render_ico(art: Artifact, dst: Path) -> None:
|
||||
"""
|
||||
Render given artifact as Windows ICO in given path.
|
||||
|
||||
:param art: The artifact to be rendered.
|
||||
:param dst: The path to rendered result.
|
||||
"""
|
||||
logging.info("Rendering .ICO artifact: -> %s", dst)
|
||||
|
||||
check_vanilla_artifact(art)
|
||||
sizes = [16, 32, 48, 64, 128, 256]
|
||||
# provide every frame explicitly so that we never depend on
|
||||
# undocumented plugin-side resizing behavior
|
||||
frames = list(render_thumbnail(art, sizes))
|
||||
art.save(
|
||||
dst,
|
||||
format="ICO",
|
||||
sizes=[(size, size) for size in sizes],
|
||||
append_images=frames,
|
||||
)
|
||||
|
||||
|
||||
def render_icns(art: Artifact, dst: Path) -> None:
|
||||
"""
|
||||
Render given artifact as macOS ICNS in given path.
|
||||
|
||||
:param art: The artifact to be rendered.
|
||||
:param dst: The path to rendered result.
|
||||
"""
|
||||
logging.info("Rendering .ICNS artifact: -> %s", dst)
|
||||
|
||||
check_vanilla_artifact(art)
|
||||
# provide every smaller frame explicitly; the 1024x1024 entry is
|
||||
# covered by the art itself
|
||||
frames = list(render_thumbnail(art, (512, 256, 128, 64, 32)))
|
||||
art.save(dst, format="ICNS", append_images=frames)
|
||||
@@ -1,36 +1,53 @@
|
||||
import logging
|
||||
import tempfile
|
||||
import os
|
||||
import enum
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from types import TracebackType
|
||||
from .utils import Artifact, ArtifactFile, VANILLA_ARTIFACT_HW
|
||||
from .artrender import SvgRenderKind, SvgConfig, BlenderConfig
|
||||
from . import artio, artrender
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Prerequisite:
|
||||
input_directory: Path
|
||||
output_directory: Path
|
||||
svg_render: SvgRenderKind
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.input_directory.is_dir():
|
||||
raise ValueError(
|
||||
f"Given input path {self.input_directory} is not existing directory."
|
||||
)
|
||||
if not self.output_directory.is_dir():
|
||||
raise ValueError(
|
||||
f"Given output path {self.output_directory} is not existing directory."
|
||||
)
|
||||
|
||||
|
||||
class Context:
|
||||
|
||||
__input_dir: Path
|
||||
__output_dir: Path
|
||||
__prerequisite: Prerequisite
|
||||
__temp_dir: tempfile.TemporaryDirectory[str]
|
||||
|
||||
def __init__(self, input_dir: Path, output_dir: Path) -> None:
|
||||
self.__input_dir = input_dir
|
||||
self.__output_dir = output_dir
|
||||
def __init__(self, prerequisite: Prerequisite) -> None:
|
||||
self.__prerequisite = prerequisite
|
||||
self.__temp_dir = tempfile.TemporaryDirectory()
|
||||
|
||||
@property
|
||||
def prerequisite(self) -> Prerequisite:
|
||||
return self.__prerequisite
|
||||
|
||||
def input_artifact(self, *args: str) -> Path:
|
||||
return self.__input_dir / Path(*args)
|
||||
return self.__prerequisite.input_directory / Path(*args)
|
||||
|
||||
def output_artifact(self, *args: str) -> Path:
|
||||
p = self.__output_dir / Path(*args)
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
return p
|
||||
return self.__prerequisite.output_directory / Path(*args)
|
||||
|
||||
def temporary_artifact(self, *args: str) -> Path:
|
||||
p = Path(self.__temp_dir.name) / "user" / Path(*args)
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
return p
|
||||
|
||||
def _intern_temporary_artifact(self, *args: str) -> Path:
|
||||
p = Path(self.__temp_dir.name) / "assemblicon" / Path(*args)
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
return p
|
||||
return Path(self.__temp_dir.name) / Path(*args)
|
||||
|
||||
def __enter__(self) -> "Context":
|
||||
return self
|
||||
@@ -42,3 +59,214 @@ class Context:
|
||||
traceback: TracebackType | None,
|
||||
) -> None:
|
||||
self.__temp_dir.cleanup()
|
||||
|
||||
|
||||
class FdContext(enum.StrEnum):
|
||||
Actions = "actions"
|
||||
Animations = "animations"
|
||||
Applications = "apps"
|
||||
Categories = "categories"
|
||||
Devices = "devices"
|
||||
Emblems = "emblems"
|
||||
Emotes = "emotes"
|
||||
International = "intl"
|
||||
MimeTypes = "mimetypes"
|
||||
Places = "places"
|
||||
Status = "status"
|
||||
|
||||
|
||||
class WinCategory(enum.StrEnum):
|
||||
Application = "apps"
|
||||
"""Icons for applications"""
|
||||
Extension = "exts"
|
||||
"""Icons for file extensions"""
|
||||
|
||||
|
||||
class MacCategory(enum.StrEnum):
|
||||
Application = "apps"
|
||||
"""Icons for applications"""
|
||||
Extension = "exts"
|
||||
"""Icons for file extensions"""
|
||||
|
||||
|
||||
_TEMP_USER_DIR: str = "user"
|
||||
_TEMP_LIB_DIR: str = "assemblicon"
|
||||
_TEMP_LIB_ALLOC_DIR: str = "alloc"
|
||||
_TEMP_LIB_STABLE_DIR: str = "stable"
|
||||
|
||||
_ART_WIN: str = "windows"
|
||||
_ART_MAC: str = "macos"
|
||||
|
||||
_DEFAULT_SVG_CONFIG = artrender.SvgConfig(VANILLA_ARTIFACT_HW, VANILLA_ARTIFACT_HW)
|
||||
_DEFAULT_BLD_CONFIG = artrender.BlenderConfig(VANILLA_ARTIFACT_HW, VANILLA_ARTIFACT_HW)
|
||||
|
||||
# TODO: For easy testing, we only build 48x48.
|
||||
# Enable full generation after testing.
|
||||
_FD_THUMBNAIL_HWS: tuple[int, ...] = (48,)
|
||||
# _FD_THUMBNAIL_HWS: tuple[int, ...] = (16, 32, 48, 64, 128, 256)
|
||||
|
||||
|
||||
class AdvancedContext:
|
||||
__context: Context
|
||||
|
||||
def __init__(self, ctx: Context) -> None:
|
||||
self.__context = ctx
|
||||
|
||||
# construct basic layout of temporary directory and output directory.
|
||||
|
||||
def __enter__(self) -> "AdvancedContext":
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc_value: BaseException | None,
|
||||
traceback: TracebackType | None,
|
||||
) -> None:
|
||||
self.__context.__exit__(exc_type, exc_value, traceback)
|
||||
|
||||
# region: Input Artifact
|
||||
|
||||
def input_artifact(self, *args: str) -> Path:
|
||||
"""
|
||||
Get the path to input artifact.
|
||||
"""
|
||||
return self.__context.input_artifact(*args)
|
||||
|
||||
def load_input_bitmap_artifact(self, *args: str) -> ArtifactFile:
|
||||
"""
|
||||
Load given input bitmap asset.
|
||||
"""
|
||||
return artio.load_input_artifact(self.input_artifact(*args))
|
||||
|
||||
# endregion
|
||||
|
||||
# region: Temporary Artifact
|
||||
|
||||
def __user_temporary_artifact(self, *args: str) -> Path:
|
||||
return self.__context.temporary_artifact(_TEMP_USER_DIR, *args)
|
||||
|
||||
def __lib_temporary_artifact(self, *args: str) -> Path:
|
||||
return self.__context.temporary_artifact(_TEMP_LIB_DIR, *args)
|
||||
|
||||
def temporary_artifact(self, *args: str) -> Path:
|
||||
"""
|
||||
Get the path to temporary artifact.
|
||||
|
||||
All existence of sub-directory should be ensured on your own.
|
||||
"""
|
||||
return self.__user_temporary_artifact(*args)
|
||||
|
||||
def load_temporary_artifact(self, *args: str) -> ArtifactFile:
|
||||
return artio.load_artifact(self.temporary_artifact(*args))
|
||||
|
||||
def save_temporary_artifact(self, art: Artifact, *args: str) -> None:
|
||||
"""
|
||||
Save given artifact as temporary artifact.
|
||||
|
||||
All existence of sub-directory should be ensured on your own.
|
||||
"""
|
||||
artio.save_artifact(art, self.temporary_artifact(*args))
|
||||
|
||||
def __allocate_lib_temporary_artifact(self, suffix: Optional[str] = None) -> Path:
|
||||
p = self.__lib_temporary_artifact(_TEMP_LIB_ALLOC_DIR)
|
||||
(fd, pf) = tempfile.mkstemp(suffix=suffix, prefix=None, dir=str(p), text=False)
|
||||
os.close(fd)
|
||||
return Path(pf)
|
||||
|
||||
def __fetch_const_lib_temporary_artifact(self, name: str) -> Path:
|
||||
return self.__lib_temporary_artifact(_TEMP_LIB_STABLE_DIR, name)
|
||||
|
||||
# endregion
|
||||
|
||||
# region: Render Wrapper
|
||||
|
||||
def __intern_render_svg(
|
||||
self, src: Path, dst: Optional[Path], cfg: SvgConfig
|
||||
) -> Path:
|
||||
if dst is None:
|
||||
dst = self.__allocate_lib_temporary_artifact(".png")
|
||||
artrender.render_svg(src, dst, self.__context.prerequisite.svg_render, cfg)
|
||||
return dst
|
||||
|
||||
def render_svg(
|
||||
self, src: Path, dst: Optional[Path], cfg: SvgConfig = _DEFAULT_SVG_CONFIG
|
||||
) -> None:
|
||||
self.__intern_render_svg(src, dst, cfg)
|
||||
|
||||
def render_svg_then_load(
|
||||
self, src: Path, dst: Optional[Path], cfg: SvgConfig = _DEFAULT_SVG_CONFIG
|
||||
) -> Artifact:
|
||||
dst = self.__intern_render_svg(src, dst, cfg)
|
||||
return artio.load_artifact(dst)
|
||||
|
||||
def __intern_render_blender(
|
||||
self, src: Path, dst: Optional[Path], cfg: BlenderConfig
|
||||
) -> Path:
|
||||
temp_script = self.__fetch_const_lib_temporary_artifact("blender.py")
|
||||
if dst is None:
|
||||
dst = self.__allocate_lib_temporary_artifact(".png")
|
||||
artrender.render_blender(src, dst, temp_script, cfg)
|
||||
return dst
|
||||
|
||||
def render_blender(
|
||||
self, src: Path, dst: Optional[Path], cfg: BlenderConfig = _DEFAULT_BLD_CONFIG
|
||||
) -> None:
|
||||
self.__intern_render_blender(src, dst, cfg)
|
||||
|
||||
def render_blender_then_load(
|
||||
self, src: Path, dst: Optional[Path], cfg: BlenderConfig = _DEFAULT_BLD_CONFIG
|
||||
) -> Artifact:
|
||||
dst = self.__intern_render_blender(src, dst, cfg)
|
||||
return artio.load_artifact(dst)
|
||||
|
||||
# endregion
|
||||
|
||||
# region: Advanced Saver
|
||||
|
||||
def __fd_artifact(self, hw: int, kind: FdContext, name: str) -> Path:
|
||||
return self.__context.output_artifact(f"{hw}x{hw}", str(kind), f"{name}.png")
|
||||
|
||||
def save_fd_artifact(self, art: Artifact, kind: FdContext, name: str) -> None:
|
||||
"""
|
||||
Save icon following FreeDesktop icon theme specification.
|
||||
|
||||
:param name: The name of saved artifact. The file extension ".png" is not required.
|
||||
"""
|
||||
logging.info(
|
||||
"Saving FreeDesktop artifact with kind %s and name %s.", kind, name
|
||||
)
|
||||
|
||||
for hw, thumbnail in zip(
|
||||
_FD_THUMBNAIL_HWS, artrender.render_thumbnail(art, _FD_THUMBNAIL_HWS)
|
||||
):
|
||||
dst = self.__fd_artifact(hw, kind, name)
|
||||
artio.save_artifact(thumbnail, dst)
|
||||
|
||||
def __win_artifact(self, kind: WinCategory, name: str) -> Path:
|
||||
return self.__context.output_artifact(_ART_WIN, str(kind), f"{name}.ico")
|
||||
|
||||
def save_win_artifact(self, art: Artifact, kind: WinCategory, name: str) -> None:
|
||||
"""
|
||||
Save icon for Windows-only
|
||||
|
||||
:param name: The name of saved artifact. The file extension ".ico" is not required.
|
||||
"""
|
||||
logging.info(
|
||||
"Saving Windows-only artifact with kind %s and name %s.", kind, name
|
||||
)
|
||||
artrender.render_ico(art, self.__win_artifact(kind, name))
|
||||
|
||||
def __mac_artifact(self, kind: MacCategory, name: str) -> Path:
|
||||
return self.__context.output_artifact(_ART_MAC, str(kind), f"{name}.icns")
|
||||
|
||||
def save_mac_artifact(self, art: Artifact, kind: MacCategory, name: str) -> None:
|
||||
"""
|
||||
Save icon for macOS-only
|
||||
|
||||
:param name: The name of saved artifact. The file extension ".icns" is not required.
|
||||
"""
|
||||
logging.info("Saving macOS-only artifact with kind %s and name %s.", kind, name)
|
||||
artrender.render_icns(art, self.__mac_artifact(kind, name))
|
||||
|
||||
# endregion
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass
|
||||
import PIL.Image
|
||||
import PIL.ImageFile
|
||||
from .context import Context
|
||||
from .utils import Artifact
|
||||
from .artio import load_artifact
|
||||
|
||||
|
||||
@dataclass
|
||||
class InkscapeConfig:
|
||||
width: int
|
||||
height: int
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.width <= 0:
|
||||
raise ValueError(
|
||||
f"The image width in Inkscape config must be a positive integer"
|
||||
)
|
||||
if self.height <= 0:
|
||||
raise ValueError(
|
||||
f"The image height in Inkscape config must be a positive integer"
|
||||
)
|
||||
|
||||
# TODO: Add "resvg" alternative for Inkscape
|
||||
# because Inkscape is too slow.
|
||||
|
||||
def inkscape_render(
|
||||
src: Path, dst: Path, cfg: InkscapeConfig
|
||||
) -> PIL.ImageFile.ImageFile:
|
||||
logging.info("Rendering Inkscape artifact: %s -> %s", src, dst)
|
||||
|
||||
inkscape_bin = os.getenv("ASSEMBLICON_INKSCAPE", "inkscape")
|
||||
cmd = [
|
||||
inkscape_bin,
|
||||
"--export-area-page",
|
||||
"--export-width",
|
||||
str(cfg.width),
|
||||
"--export-height",
|
||||
str(cfg.height),
|
||||
"--export-type",
|
||||
"png",
|
||||
"--export-png-color-mode",
|
||||
"RGBA_8",
|
||||
"--export-background-opacity",
|
||||
"0.0",
|
||||
"--export-png-use-dithering",
|
||||
"false",
|
||||
"--export-filename",
|
||||
str(dst),
|
||||
str(src),
|
||||
]
|
||||
proc = subprocess.run(cmd, capture_output=False)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"Fail to execute Inkscape. Return code is {proc.returncode}"
|
||||
)
|
||||
|
||||
return load_artifact(dst)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BlenderConfig:
|
||||
width: int
|
||||
height: int
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.width <= 0:
|
||||
raise ValueError(
|
||||
f"The image width in Blender config must be a positive integer"
|
||||
)
|
||||
if self.height <= 0:
|
||||
raise ValueError(
|
||||
f"The image height in Blender config must be a positive integer"
|
||||
)
|
||||
|
||||
|
||||
_BLENDER_RENDER_SCRIPT = """\
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
try:
|
||||
import bpy
|
||||
|
||||
render = bpy.context.scene.render
|
||||
render.resolution_x = {width}
|
||||
render.resolution_y = {height}
|
||||
render.resolution_percentage = 100
|
||||
render.image_settings.file_format = "PNG"
|
||||
render.image_settings.color_mode = "RGBA"
|
||||
render.image_settings.color_depth = "8"
|
||||
render.film_transparent = True
|
||||
render.use_file_extension = False
|
||||
render.filepath = {dst}
|
||||
bpy.ops.render.render(write_still=True)
|
||||
except BaseException:
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
"""
|
||||
|
||||
|
||||
def blender_render(
|
||||
ctx: Context, src: Path, dst: Path, cfg: BlenderConfig
|
||||
) -> PIL.ImageFile.ImageFile:
|
||||
logging.info("Rendering Blender artifact: %s -> %s", src, dst)
|
||||
|
||||
script = _BLENDER_RENDER_SCRIPT.format(
|
||||
width=cfg.width, height=cfg.height, dst=repr(str(dst))
|
||||
)
|
||||
script_path = ctx._intern_temporary_artifact("blender_render.py")
|
||||
script_path.write_text(script, encoding="utf-8")
|
||||
|
||||
blender_bin = os.getenv("ASSEMBLICON_BLENDER", "blender")
|
||||
cmd = [
|
||||
blender_bin,
|
||||
"--background",
|
||||
str(src),
|
||||
"--python",
|
||||
str(script_path),
|
||||
]
|
||||
proc = subprocess.run(cmd, capture_output=False)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"Fail to execute Blender. Return code is {proc.returncode}")
|
||||
if not dst.exists():
|
||||
raise RuntimeError(
|
||||
f"Blender exited successfully but output file {dst} is missing"
|
||||
)
|
||||
|
||||
return load_artifact(dst)
|
||||
|
||||
|
||||
_ICON_ART_SIZE = 1024
|
||||
|
||||
|
||||
def _ensure_1024_art(art: Artifact) -> None:
|
||||
if art.size != (_ICON_ART_SIZE, _ICON_ART_SIZE):
|
||||
raise ValueError(
|
||||
f"Icon art must be {_ICON_ART_SIZE}x{_ICON_ART_SIZE}, "
|
||||
f"got {art.width}x{art.height} instead"
|
||||
)
|
||||
|
||||
|
||||
def _scaled_frame(art: Artifact, size: int) -> Artifact:
|
||||
_ensure_1024_art(art)
|
||||
frame = art.copy()
|
||||
frame.thumbnail((size, size), PIL.Image.Resampling.LANCZOS)
|
||||
return frame
|
||||
|
||||
|
||||
def ico_render(ctx: Context, art: Artifact, dst: Path) -> None:
|
||||
logging.info("Rendering .ICO artifact via Pillow: -> %s", dst)
|
||||
|
||||
_ensure_1024_art(art)
|
||||
sizes = [16, 32, 48, 64, 128, 256]
|
||||
# provide every frame explicitly so that we never depend on
|
||||
# undocumented plugin-side resizing behavior
|
||||
frames = [_scaled_frame(art, size) for size in sizes]
|
||||
art.save(
|
||||
dst,
|
||||
format="ICO",
|
||||
sizes=[(size, size) for size in sizes],
|
||||
append_images=frames,
|
||||
)
|
||||
|
||||
|
||||
def icns_render(ctx: Context, art: Artifact, dst: Path) -> None:
|
||||
logging.info("Rendering .ICNS artifact via Pillow: -> %s", dst)
|
||||
|
||||
_ensure_1024_art(art)
|
||||
# provide every smaller frame explicitly; the 1024x1024 entry is
|
||||
# covered by the art itself
|
||||
frames = [_scaled_frame(art, size) for size in (512, 256, 128, 64, 32)]
|
||||
art.save(dst, format="ICNS", append_images=frames)
|
||||
@@ -1,7 +1,20 @@
|
||||
import logging
|
||||
from PIL.Image import Image
|
||||
from PIL.ImageFile import ImageFile
|
||||
|
||||
type Artifact = Image
|
||||
type ArtifactFile = ImageFile
|
||||
|
||||
|
||||
def setup_logging() -> None:
|
||||
logging.basicConfig(format="[%(levelname)s] %(message)s", level=logging.INFO)
|
||||
|
||||
|
||||
VANILLA_ARTIFACT_HW: int = 1024
|
||||
|
||||
|
||||
def check_vanilla_artifact(art: Artifact) -> None:
|
||||
if art.size != (VANILLA_ARTIFACT_HW, VANILLA_ARTIFACT_HW):
|
||||
raise ValueError(
|
||||
f"Icon art must be {VANILLA_ARTIFACT_HW}x{VANILLA_ARTIFACT_HW}, got {art.width}x{art.height} instead"
|
||||
)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import PIL.Image
|
||||
import PIL.ImageEnhance
|
||||
from ..assemblicon.render import inkscape_render, ico_render, InkscapeConfig
|
||||
from ..assemblicon.artrender import render_svg, render_ico, SvgConfig
|
||||
from ..assemblicon.context import Context
|
||||
from ..assemblicon.utils import Artifact
|
||||
from ..assemblicon.geometry import MinMaxRect, PosSizeRect
|
||||
from ..assemblicon.artio import load_asset, save_artifact
|
||||
from ..assemblicon.artio import load_input_artifact, save_artifact
|
||||
from ..assemblicon.artproc import (
|
||||
aspect_ratio_scale_and_crop,
|
||||
VerticalAnchor,
|
||||
@@ -17,10 +17,10 @@ IMAGE_INNER_RECT_POSSIZE = IMAGE_INNER_RECT_MINMAX.to_pos_size_rect()
|
||||
|
||||
def build_image_icons(ctx: Context) -> None:
|
||||
# build image base
|
||||
image_base = inkscape_render(
|
||||
image_base = render_svg(
|
||||
ctx.input_artifact("component", "image-base.svg"),
|
||||
ctx.temporary_artifact("component", "image-base.png"),
|
||||
InkscapeConfig(1024, 1024),
|
||||
SvgConfig(1024, 1024),
|
||||
)
|
||||
# load image base
|
||||
with image_base:
|
||||
@@ -30,7 +30,7 @@ def build_image_icons(ctx: Context) -> None:
|
||||
def _build_jpg_icon(ctx: Context, base: Artifact) -> None:
|
||||
jpg_image = base.copy()
|
||||
|
||||
jpg_image_inner = load_asset(
|
||||
jpg_image_inner = load_input_artifact(
|
||||
ctx.input_artifact("ext", "bitmap", "Niinsaare_järv.jpg")
|
||||
)
|
||||
with jpg_image_inner:
|
||||
@@ -60,7 +60,7 @@ def _build_jpg_icon(ctx: Context, base: Artifact) -> None:
|
||||
)
|
||||
# build artifacts
|
||||
save_artifact(jpg_image, ctx.temporary_artifact("ext", "jpg-image.png"))
|
||||
ico_render(
|
||||
render_ico(
|
||||
ctx,
|
||||
jpg_image,
|
||||
ctx.output_artifact("ext", "jpg-image.ico"),
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
from ..assemblicon.context import Context
|
||||
|
||||
|
||||
def build_non_standard_icons(ctx: Context) -> None:
|
||||
pass
|
||||
@@ -1,6 +1,6 @@
|
||||
from pathlib import Path
|
||||
import PIL.Image
|
||||
from ..assemblicon.render import inkscape_render, InkscapeConfig
|
||||
from ..assemblicon.artrender import render_svg, SvgConfig
|
||||
from ..assemblicon.context import Context
|
||||
from ..assemblicon.utils import Artifact
|
||||
from ..assemblicon.artio import save_artifact
|
||||
@@ -12,44 +12,32 @@ def build_action_icons(ctx: Context) -> None:
|
||||
|
||||
def _build_go_icons(ctx: Context) -> None:
|
||||
# build go-* base
|
||||
go_base = inkscape_render(
|
||||
go_base = render_svg(
|
||||
ctx.input_artifact("component", "go-base.svg"),
|
||||
ctx.temporary_artifact("component", "go-base.png"),
|
||||
InkscapeConfig(1024, 1024),
|
||||
SvgConfig(1024, 1024),
|
||||
)
|
||||
# build go-* shining
|
||||
go_shining = inkscape_render(
|
||||
go_inner_shining = render_svg(
|
||||
ctx.input_artifact("component", "go-inner-shining.svg"),
|
||||
ctx.temporary_artifact("component", "go-inner-shining.png"),
|
||||
InkscapeConfig(1024, 1024),
|
||||
SvgConfig(1024, 1024),
|
||||
)
|
||||
# build go-* inner
|
||||
go_inner = render_svg(
|
||||
ctx.input_artifact("component", "go-inner.svg"),
|
||||
ctx.temporary_artifact("component", "go-inner.png"),
|
||||
SvgConfig(1024, 1024),
|
||||
)
|
||||
|
||||
with go_base, go_shining:
|
||||
_build_go_icon(
|
||||
ctx,
|
||||
go_base,
|
||||
go_shining,
|
||||
ctx.input_artifact("go-previous.svg"),
|
||||
ctx.temporary_artifact("go-previous.png"),
|
||||
ctx.output_artifact("go-previous.png")
|
||||
)
|
||||
_build_go_icon(
|
||||
ctx,
|
||||
go_base,
|
||||
go_shining,
|
||||
ctx.input_artifact("go-next.svg"),
|
||||
ctx.temporary_artifact("go-next.png"),
|
||||
ctx.output_artifact("go-next.png")
|
||||
)
|
||||
|
||||
|
||||
def _build_go_icon(
|
||||
ctx: Context, base: Artifact, shining: Artifact, mask: Path, mask_dst: Path, dst: Path
|
||||
) -> None:
|
||||
go = base.copy()
|
||||
# render shining with mask
|
||||
go_mask = inkscape_render(mask, mask_dst, InkscapeConfig(1024, 1024))
|
||||
go.paste(shining, (0, 0), go_mask)
|
||||
with go_base, go_inner_shining, go_inner:
|
||||
go_mask = go_inner
|
||||
for part in ("previous", "down", "next", "up"):
|
||||
go = go_base.copy()
|
||||
# render shining with inner mask
|
||||
go.paste(go_inner_shining, (0, 0), go_mask)
|
||||
# save as image
|
||||
save_artifact(go, dst)
|
||||
save_artifact(go, ctx.output_artifact(f"go-{part}.png"))
|
||||
|
||||
# rotate it for next calling
|
||||
go_mask = go_mask.transpose(PIL.Image.Transpose.ROTATE_90)
|
||||
|
||||
Reference in New Issue
Block a user