refactor: refactor assemblicon context
This commit is contained in:
@@ -1,19 +1,25 @@
|
|||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import PIL.Image
|
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)
|
logging.info("Loading asset: %s", p)
|
||||||
return PIL.Image.open(p, "r", ["PNG", "JPEG", "BMP"])
|
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)
|
logging.info("Loading artifact: %s", p)
|
||||||
return PIL.Image.open(p, "r", ["PNG"])
|
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)
|
logging.info("Saving artifact: %s", p)
|
||||||
art.save(p, format="PNG")
|
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 tempfile
|
||||||
|
import os
|
||||||
|
import enum
|
||||||
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
from types import TracebackType
|
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:
|
class Context:
|
||||||
|
__prerequisite: Prerequisite
|
||||||
__input_dir: Path
|
|
||||||
__output_dir: Path
|
|
||||||
__temp_dir: tempfile.TemporaryDirectory[str]
|
__temp_dir: tempfile.TemporaryDirectory[str]
|
||||||
|
|
||||||
def __init__(self, input_dir: Path, output_dir: Path) -> None:
|
def __init__(self, prerequisite: Prerequisite) -> None:
|
||||||
self.__input_dir = input_dir
|
self.__prerequisite = prerequisite
|
||||||
self.__output_dir = output_dir
|
|
||||||
self.__temp_dir = tempfile.TemporaryDirectory()
|
self.__temp_dir = tempfile.TemporaryDirectory()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def prerequisite(self) -> Prerequisite:
|
||||||
|
return self.__prerequisite
|
||||||
|
|
||||||
def input_artifact(self, *args: str) -> Path:
|
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:
|
def output_artifact(self, *args: str) -> Path:
|
||||||
p = self.__output_dir / Path(*args)
|
return self.__prerequisite.output_directory / Path(*args)
|
||||||
p.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
return p
|
|
||||||
|
|
||||||
def temporary_artifact(self, *args: str) -> Path:
|
def temporary_artifact(self, *args: str) -> Path:
|
||||||
p = Path(self.__temp_dir.name) / "user" / Path(*args)
|
return Path(self.__temp_dir.name) / 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
|
|
||||||
|
|
||||||
def __enter__(self) -> "Context":
|
def __enter__(self) -> "Context":
|
||||||
return self
|
return self
|
||||||
@@ -42,3 +59,214 @@ class Context:
|
|||||||
traceback: TracebackType | None,
|
traceback: TracebackType | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.__temp_dir.cleanup()
|
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
|
import logging
|
||||||
from PIL.Image import Image
|
from PIL.Image import Image
|
||||||
|
from PIL.ImageFile import ImageFile
|
||||||
|
|
||||||
type Artifact = Image
|
type Artifact = Image
|
||||||
|
type ArtifactFile = ImageFile
|
||||||
|
|
||||||
|
|
||||||
def setup_logging() -> None:
|
def setup_logging() -> None:
|
||||||
logging.basicConfig(format="[%(levelname)s] %(message)s", level=logging.INFO)
|
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.Image
|
||||||
import PIL.ImageEnhance
|
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.context import Context
|
||||||
from ..assemblicon.utils import Artifact
|
from ..assemblicon.utils import Artifact
|
||||||
from ..assemblicon.geometry import MinMaxRect, PosSizeRect
|
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 (
|
from ..assemblicon.artproc import (
|
||||||
aspect_ratio_scale_and_crop,
|
aspect_ratio_scale_and_crop,
|
||||||
VerticalAnchor,
|
VerticalAnchor,
|
||||||
@@ -17,10 +17,10 @@ IMAGE_INNER_RECT_POSSIZE = IMAGE_INNER_RECT_MINMAX.to_pos_size_rect()
|
|||||||
|
|
||||||
def build_image_icons(ctx: Context) -> None:
|
def build_image_icons(ctx: Context) -> None:
|
||||||
# build image base
|
# build image base
|
||||||
image_base = inkscape_render(
|
image_base = render_svg(
|
||||||
ctx.input_artifact("component", "image-base.svg"),
|
ctx.input_artifact("component", "image-base.svg"),
|
||||||
ctx.temporary_artifact("component", "image-base.png"),
|
ctx.temporary_artifact("component", "image-base.png"),
|
||||||
InkscapeConfig(1024, 1024),
|
SvgConfig(1024, 1024),
|
||||||
)
|
)
|
||||||
# load image base
|
# load image base
|
||||||
with 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:
|
def _build_jpg_icon(ctx: Context, base: Artifact) -> None:
|
||||||
jpg_image = base.copy()
|
jpg_image = base.copy()
|
||||||
|
|
||||||
jpg_image_inner = load_asset(
|
jpg_image_inner = load_input_artifact(
|
||||||
ctx.input_artifact("ext", "bitmap", "Niinsaare_järv.jpg")
|
ctx.input_artifact("ext", "bitmap", "Niinsaare_järv.jpg")
|
||||||
)
|
)
|
||||||
with jpg_image_inner:
|
with jpg_image_inner:
|
||||||
@@ -60,7 +60,7 @@ def _build_jpg_icon(ctx: Context, base: Artifact) -> None:
|
|||||||
)
|
)
|
||||||
# build artifacts
|
# build artifacts
|
||||||
save_artifact(jpg_image, ctx.temporary_artifact("ext", "jpg-image.png"))
|
save_artifact(jpg_image, ctx.temporary_artifact("ext", "jpg-image.png"))
|
||||||
ico_render(
|
render_ico(
|
||||||
ctx,
|
ctx,
|
||||||
jpg_image,
|
jpg_image,
|
||||||
ctx.output_artifact("ext", "jpg-image.ico"),
|
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
|
from pathlib import Path
|
||||||
import PIL.Image
|
import PIL.Image
|
||||||
from ..assemblicon.render import inkscape_render, InkscapeConfig
|
from ..assemblicon.artrender import render_svg, SvgConfig
|
||||||
from ..assemblicon.context import Context
|
from ..assemblicon.context import Context
|
||||||
from ..assemblicon.utils import Artifact
|
from ..assemblicon.utils import Artifact
|
||||||
from ..assemblicon.artio import save_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:
|
def _build_go_icons(ctx: Context) -> None:
|
||||||
# build go-* base
|
# build go-* base
|
||||||
go_base = inkscape_render(
|
go_base = render_svg(
|
||||||
ctx.input_artifact("component", "go-base.svg"),
|
ctx.input_artifact("component", "go-base.svg"),
|
||||||
ctx.temporary_artifact("component", "go-base.png"),
|
ctx.temporary_artifact("component", "go-base.png"),
|
||||||
InkscapeConfig(1024, 1024),
|
SvgConfig(1024, 1024),
|
||||||
)
|
)
|
||||||
# build go-* shining
|
# build go-* shining
|
||||||
go_shining = inkscape_render(
|
go_inner_shining = render_svg(
|
||||||
ctx.input_artifact("component", "go-inner-shining.svg"),
|
ctx.input_artifact("component", "go-inner-shining.svg"),
|
||||||
ctx.temporary_artifact("component", "go-inner-shining.png"),
|
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:
|
with go_base, go_inner_shining, go_inner:
|
||||||
_build_go_icon(
|
go_mask = go_inner
|
||||||
ctx,
|
for part in ("previous", "down", "next", "up"):
|
||||||
go_base,
|
go = go_base.copy()
|
||||||
go_shining,
|
# render shining with inner mask
|
||||||
ctx.input_artifact("go-previous.svg"),
|
go.paste(go_inner_shining, (0, 0), go_mask)
|
||||||
ctx.temporary_artifact("go-previous.png"),
|
# save as image
|
||||||
ctx.output_artifact("go-previous.png")
|
save_artifact(go, ctx.output_artifact(f"go-{part}.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)
|
|
||||||
# save as image
|
|
||||||
save_artifact(go, dst)
|
|
||||||
|
|
||||||
|
# rotate it for next calling
|
||||||
|
go_mask = go_mask.transpose(PIL.Image.Transpose.ROTATE_90)
|
||||||
|
|||||||
Reference in New Issue
Block a user