LINEスタンプ・絵文字制作のためのオールインワン一括変換・パッケージング支援ツール

LINE Ultimate (v1.2) – ユーザーマニュアル & 制作ガイド
LINE Creators Market 公式ガイドライン(スタンプ / 絵文字)に完全準拠した、LINEスタンプ・絵文字制作のためのオールインワン一括変換・パッケージング支援ツールです。
目次
- ① 通常スタンプ
- ② アニメスタンプ (アニメーションスタンプ)
- ③ BIGスタンプ
- ④ ポップアップスタンプ
- ⑤ エフェクトスタンプ
- ⑥ メッセージスタンプ
- ⑦ LINE絵文字 (静止画)
- ⑧ アニメーション絵文字 (動く絵文字)
1. プログラム概要
LINEスタンプや絵文字を申請する際は、種類ごとに異なるキャンバスサイズ、偶数ピクセル制限、余白推奨値、連番命名規則(スタンプは2桁、絵文字は3桁など)、必須ファイル(main.png, tab.png, main_animation.png等)の有無、容量制限(1MBまたは300KB以下)など、厳格な規格が存在します。
LINE Ultimate は、イラスト素材を用意してドラッグ&ドロップするだけで、これらすべての規格を満たす画像ファイル群と申請用ZIPパッケージを自動生成するツールです。
主な特長
- 全8種のLINE公式規格プリセット対応: 通常スタンプ、アニメ、BIG、ポップアップ、エフェクト、メッセージ、絵文字、アニメ絵文字を網羅。
- 絵文字の数字3桁連番(
001.png〜)に完全対応: 絵文字選択時は自動的に3桁命名・余白0%・main.png除外(tab.pngのみ生成)に自動連動。 - ペア画像の自動生成: ポップアップスタンプ(通常
01.png+ 全画面01_popup.png)やエフェクトスタンプ(通常01.png+ 背景01_effect.png)の複数解像度ペアを1回の変換で自動出力。 - 高品質エフェクト処理: 自動透過余白トリム、視認性を高める白フチ/黒フチ付与、立体感を出すドロップシャドウを搭載。
- 容量・規格の自動最適化: 1MB(アニメ絵文字は300KB)を超過した場合、自動的に256色パレット量子化圧縮を行い審査落ちを防止。
- 非同期スレッド処理: 大量の画像変換中も操作画面がフリーズせず、リアルタイムに進捗バーとログが更新。
2. 動作要件 & 起動方法
動作環境
- OS: Windows 10 / 11
- Python 3.10+ (WinPython / 標準Python環境)
- 必要ライブラリ:
Pillow,tkinterdnd2(任意:opencv-python)
起動方法
- バッチファイルから起動(一番簡単)
フォルダ内のrun_line_ultimate.batをダブルクリックします。 - コマンドラインから起動
D:\WinPython\python-3.10.11.amd64\python.exe D:\opencode\line_creator_resizer\line_ultimate.py
3. 基本の使い方・制作フロー
- 画像を登録する
作成したイラスト画像(PNG, JPG, WEBP, GIF, BMP)を、アプリ画面上に直接ドラッグ&ドロップします(または「📁 フォルダ追加」「➕ ファイル追加」ボタンを押します)。 - 規格タイプを選択する
右側パネルの「規格タイプ」から、制作したいLINEスタンプ・絵文字の種類(通常スタンプ、LINE絵文字など)を選びます。
※選択に応じて、命名規則や余白、特殊画像生成の有無が自動で最適化されます。 - プレビューで仕上がりを確認する
左側の画像一覧から画像をクリックすると、中央のキャンバスに変換後のプレビューが表示されます。
「背景」をLINEトーク画面色や黒・白に切り替えたり、「白フチ」のチェックを入れて視認性を確認します。 - 出力先フォルダを設定する
右側パネルの「出力先フォルダ」の「参照…」ボタンを押して保存先を指定します。 - 一括変換を実行する
ツールバーの「▶️ 一括変換実行 (Convert)」ボタンを押します。 - 変換完了と確認
変換が完了すると、生成枚数、公式規定数との合致状況、ZIPパッケージの容量などをまとめたレポート画面が表示されます。そのままワンクリックで出力先フォルダを開いて確認できます。
4. 画面各部・ボタン機能の説明
ツールバー
画面上部に配置されたクイック操作ボタンです。
| ボタン | 機能説明 |
|---|---|
| 📁 フォルダ追加 | 選択したフォルダ内の画像を一括スキャンして追加します。 |
| ➕ ファイル追加 | ファイル選択ダイアログから個別に画像ファイルを追加します。 |
| 🗑️ クリア | 登録されている画像リストをすべてクリアします。 |
| ▶️ 一括変換実行 (Convert) | 現在の設定に基づいてすべての画像を一括処理し、フォルダ出力とZIP作成を行います。 |
| 👁️ OpenCVプレビュー | OpenCVを用いて、別ウィンドウでBefore(元画像)とAfter(変換後)の原寸並列比較画面を開きます (QキーまたはESCで閉じます)。 |
| 📂 出力先を開く | 設定されている出力先フォルダをWindowsエクスプローラーで開きます。 |
| 🌐 ガイドラインボタン | クリックするとブラウザでLINE公式制作ガイドライン(スタンプ用 / 絵文字用)を即座に開きます。 |
左ペイン: 画像一覧パネル
登録されている画像の一覧を表示するテーブルです。
- ヘッダー表示 (
画像一覧: ○件): 現在登録されている枚数と、LINE公式パッケージ規定数(8/16/24/32/40個)に適合しているかの判定バッジがリアルタイムに表示されます。 ✅ 8個パック適合: 申請可能な枚数にぴったり適合しています。⚠️ 7件 (あと1件で8個): パッケージ規定数に届いていない場合のナビゲーション。- テーブル項目:
#: 連番インデックスファイル名: 元のファイル名容量: ファイルサイズ (KB)解像度: 元画像の幅 × 高さ- 項目クリック: 選択した画像が中央のプレビュー画面にリアルタイムで表示されます。
中央ペイン: プレビュー & ログビューア
上部コントロール
- 表示モード切替:
変換後 (After): 選択中の規格サイズ・余白・エフェクトが適用された完成プレビューを表示。元画像 (Before): 加工前の元画像を表示。並べて比較 (Both): 左にBefore、右にAfterを同時に並べて比較表示。- 背景切替:
透過格子 (Checker): 透過部分が市松模様で表示され、トリミングや透過漏れを確認可能。白: 白背景上での見え方を確認。黒: ダークモードや黒背景での見え方を確認。LINEトーク: LINE公式のトークルーム背景色(スカイブルー#7ac5d8)上での見え方を確認。
メインキャンバス
- 選択画像のリアルタイムレンダリング領域です。
- 青色の枠線で規格キャンバスサイズを示し、下部に解像度情報が表示されます。
下部ログビューア (📋 ログ (Log Viewer))
- 画像の読み込み、トリミング、リサイズ、フチ取り、ZIP圧縮などの進行状況がリアルタイムにテキスト出力されます。
右ペイン: 設定パネル
【規格・サイズ設定】
- 規格タイプ: 全8種類(通常、アニメ、BIG、ポップアップ、エフェクト、メッセージ、絵文字、アニメ絵文字)から選択。
- 生成ファイル構成枠: 選択した規格で生成される具体的なファイル構成(例:
main.png,tab.png,01.png〜)がプレビュー表示されます。 - 余白率 (%): 画像の周囲に設ける透明余白の比率(0〜40%)。スピンボックスまたはキー入力で調整可能。
【エフェクト・トリミング】
- 透明余白を自動トリム: イラストの周囲にある不要な透明ピクセルを自動除去してからリサイズします(推奨: ON)。
- 白フチを追加: キャラクターの周囲に滑らかな白フチを追加します。LINEのダークテーマや写真背景でも見やすくなります。
- 黒フチを追加: キャラクターの周囲に黒フチを追加します。
- シャドウ (影) を追加: 斜め下に自然なドロップシャドウを付与します。
- フチの太さ (px): 白フチ・黒フチの太さを1〜20pxの範囲で指定できます。
【出力・パッケージ】
- 出力先フォルダ: 変換後画像の保存先。未指定の場合は変換実行時に選択を促します。
- ファイル命名:
001〜 (001.png, 002.png... 絵文字公式3桁): 絵文字用の公式連番01〜 (01.png, 02.png... スタンプ公式2桁): スタンプ用の公式連番A000001〜: 旧来のLINE連番000001〜: 6桁連番original: 元ファイル名を維持- 特殊画像生成チェックボックス:
- モードに応じて「main.png / tab.png を自動生成」「tab.png のみ自動生成 (絵文字)」等に自動で切り替わります。
- LINE_PACKAGE.zip を自動作成:
- 変換完了後、そのままLINE Creators Marketに一括アップロードできるZIP圧縮ファイルを自動生成します。
- 💾 設定を保存: 現在の設定を
line_ultimate_settings.jsonに保存します。次回起動時も同じ設定で作業できます。
メニューバー
- ファイル (F): フォルダ追加、ファイル追加、出力先フォルダを開く、設定保存、終了。
- 編集 (E): 全てクリア、選択した画像を削除。
- ツール (T): OpenCV詳細プレビュー、一括変換実行。
- ヘルプ (H): LINE Creators Market 規格ガイド(仕様一覧ダイアログ)、各公式ガイドラインWebページを開く、バージョン情報。
5. 今回対応した全8モードとファイル構成一覧
LINE公式ガイドライン(https://creator.line.me/ja/guideline/)の全規格に対応しています。
| # | 規格タイプ | コンテンツ画像サイズ | ファイル名規則 | 必須特殊画像 | パッケージ規定枚数 | 容量制限 |
| :-: | :— | :— | :— | :— | :— | :— |
| 1 | 通常スタンプ | 最大 370 × 320 px (縦横偶数) | 01.png ~ 40.png | main.png + tab.png | 8 / 16 / 24 / 32 / 40 個 | 1MB以下 |
| 2 | アニメスタンプ | 最大 320 × 270 px (縦横偶数) | 01.png ~ 24.png | main.png + tab.png | 8 / 16 / 24 個 | 1MB以下 |
| 3 | BIGスタンプ | 最大 660 × 540 px (縦横偶数) | 01.png ~ 40.png | main.png + tab.png | 8 / 16 / 24 / 32 / 40 個 | 1MB以下 |
| 4 | ポップアップスタンプ | 通常: 最大 370 × 320 px
ポップアップ: 480 × 480 px | 01.png ~ 24.png01_popup.png ~ 24_popup.png | main.pngmain_animation.pngtab.png | 8 / 16 / 24 個 | 1MB以下 |
| 5 | エフェクトスタンプ | 通常: 最大 370 × 320 px
エフェクト: 480 × 480 px | 01.png ~ 24.png01_effect.png ~ 24_effect.png | main.pngmain_effect.pngtab.png | 8 / 16 / 24 個 | 1MB以下 |
| 6 | メッセージスタンプ | 最大 370 × 320 px (縦横偶数) | 01.png ~ 24.png | main.png + tab.png | 8 / 16 / 24 個 | 1MB以下 |
| 7 | LINE絵文字 (静止画) | 180 × 180 px (固定) | 001.png ~ 040.png
(数字3桁連番) | tab.png のみ
(※main.png不要) | 8 / 16 / 24 / 32 / 40 個
(デコ文字は最大250個) | 1MB以下 |
| 8 | アニメーション絵文字 | 180 × 180 px (固定) | 001.png ~ 040.png
(数字3桁連番) | tab.png のみ
(※main.png不要) | 8 / 16 / 24 / 32 / 40 個 | 300KB以下
(厳格自動圧縮) |
各モードの詳細ファイル構成
1. 通常スタンプ
output_folder/
├── main.png (240 x 240 px: メイン画像)
├── tab.png (96 x 74 px: トークルームタブ画像)
├── 01.png (最大 370 x 320 px 偶数: スタンプ画像1)
├── 02.png (最大 370 x 320 px 偶数: スタンプ画像2)
...
├── 40.png
└── LINE_PACKAGE.zip (一括アップロード用ZIP)
2. アニメスタンプ (アニメーションスタンプ)
output_folder/
├── main.png (240 x 240 px)
├── tab.png (96 x 74 px)
├── 01.png ~ 24.png (最大 320 x 270 px 偶数)
└── LINE_PACKAGE.zip
3. BIGスタンプ
output_folder/
├── main.png (240 x 240 px)
├── tab.png (96 x 74 px)
├── 01.png ~ 40.png (最大 660 x 540 px 偶数)
└── LINE_PACKAGE.zip
4. ポップアップスタンプ
画面全体に飛び出す演出用。通常表示用とポップアップ用がペアで自動出力されます。
output_folder/
├── main.png (240 x 240 px: 静止画メイン)
├── main_animation.png (240 x 240 px: ポップアップ代表画像)
├── tab.png (96 x 74 px: タブ画像)
├── 01.png ~ 24.png (最大 370 x 320 px: 通常スタンプ用)
├── 01_popup.png ~ 24_popup.png (480 x 480 px: 全画面ポップアップ用)
└── LINE_PACKAGE.zip
5. エフェクトスタンプ
トークルーム背景全体にエフェクトが広がるスタンプ。通常表示用と背景エフェクト用がペアで自動出力されます。
output_folder/
├── main.png (240 x 240 px: 静止画メイン)
├── main_effect.png (240 x 240 px: エフェクト代表画像)
├── tab.png (96 x 74 px: タブ画像)
├── 01.png ~ 24.png (最大 370 x 320 px: 通常スタンプ用)
├── 01_effect.png ~ 24_effect.png (480 x 480 px: 背景エフェクト用)
└── LINE_PACKAGE.zip
6. メッセージスタンプ
ユーザーが自由にテキストを入れて使うスタンプです。
output_folder/
├── main.png (240 x 240 px)
├── tab.png (96 x 74 px)
├── 01.png ~ 24.png (最大 370 x 320 px 偶数)
└── LINE_PACKAGE.zip
7. LINE絵文字 (静止画)
公式規定により main.png は不要です(管理画面上で登録絵文字の中から4個を選択する仕様のため)。
ファイル名は必ず数字3桁 (001.png〜)にする必要があります。
output_folder/
├── tab.png (96 x 74 px: タブ画像) ※main.pngは不要のため未生成
├── 001.png (180 x 180 px: 絵文字画像1)
├── 002.png (180 x 180 px: 絵文字画像2)
...
├── 040.png
└── LINE_PACKAGE.zip
8. アニメーション絵文字 (動く絵文字)
絵文字と同様に main.png は不要で、数字3桁命名 (001.png〜) です。
また、公式レギュレーションにより 1ファイルあたり 300KB以下 の制限が課されます(本ツールでは自動で判定・最適化します)。
output_folder/
├── tab.png (96 x 74 px: タブ画像)
├── 001.png ~ 040.png (180 x 180 px: 300KB以下)
└── LINE_PACKAGE.zip
6. LINE公式審査を通過するための重要ポイント
- 幅と高さは必ず偶数にする(スタンプ)
LINEスタンプの画像はシステムで自動縮小されるため、奇数サイズ(例: 369×319)の画像は審査でリジェクト(却下)されます。
→ 本ツールはすべて自動で偶数サイズ(切り捨て偶数)に補正して出力します。 - 絵文字の3桁連番(001.png〜)と main.png の除外
絵文字パッケージで1.pngや01.pngといった命名や、不要なmain.pngが含まれていると一括登録エラーになります。
→ 本ツールは絵文字モード選択時、自動で「001.png〜」命名に切り替わり、main.pngを除外して出力します。 - 容量制限(スタンプ1MB以下 / アニメ絵文字300KB以下)
1ファイルでも容量を超えるとアップロードできません。
→ 本ツールは保存時に容量を計測し、制限を超える場合は自動で画質劣化を抑えた256色パレット量子化を行って規格内に収めます。 - 透過漏れの防止
背景に消し残しの薄い白やゴミピクセルがあると、ダークモードのトーク画面で目立ちます。
→ 中央ペインの背景切替で「透過格子」「黒」を選び、消し残しがないか事前にプレビューで確認してください。 - 白フチの活用
黒っぽいキャラクターや細い線のイラストは、ユーザーの着せかえテーマや写真背景で見えなくなることがあります。
→ 「白フチを追加」をONにして太さ 4〜6px 程度を付与することで、どんな背景でもくっきり視認できるようになります。
プログラマ
# -*- coding: utf-8 -*-
# ===== LINE ULTIMATE PART1-A FRONT =====
# Core Foundation Layer
import os
import sys
import io
import json
import logging
import threading
import queue
import zipfile
import subprocess
import webbrowser
from pathlib import Path
from datetime import datetime
APP_NAME = 'LINE Ultimate'
APP_VERSION = '1.2'
LINE_GUIDELINE_URL = 'https://creator.line.me/ja/guideline/'
LINE_EMOJI_GUIDELINE_URL = 'https://creator.line.me/ja/guideline/emoji/'
# LINE official specs
LINE_STAMP_SIZE = (370, 320) # max WxH, both even
LINE_BIG_STAMP_SIZE = (660, 540) # max WxH, both even
LINE_POPUP_SIZE = (480, 480) # fixed WxH
LINE_ANIM_SIZE = (320, 270) # max WxH, both even
LINE_EMOJI_SIZE = (180, 180) # fixed WxH
LINE_MAIN_SIZE = (240, 240) # fixed WxH
LINE_TAB_SIZE = (96, 74) # fixed WxH
SUPPORTED_EXTS = {
'.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp'
}
class ConfigManager:
DEFAULTS = {
'mode': 'stamp',
'padding_percent': 5,
'trim_transparent': True,
'white_outline': False,
'black_outline': False,
'shadow': False,
'outline_size': 4,
'output_pattern': '01',
'keep_filename': False,
'zip_output': True,
'gen_main_tab': True,
'bg_preview': 'checker',
'output_folder': ''
}
def __init__(self, filename='line_ultimate_settings.json'):
self.filename = filename
self.data = self.DEFAULTS.copy()
self.load()
def load(self):
if os.path.exists(self.filename):
try:
with open(self.filename, 'r', encoding='utf-8') as f:
saved = json.load(f)
self.data.update(saved)
except Exception as e:
logging.warning(f'Failed to load config: {e}')
return self.data
def save(self):
try:
with open(self.filename, 'w', encoding='utf-8') as f:
json.dump(self.data, f, ensure_ascii=False, indent=2)
return True
except Exception as e:
logging.error(f'Failed to save config: {e}')
return False
def get(self, key, default=None):
return self.data.get(key, default)
def set(self, key, value):
self.data[key] = value
class LogManager:
def __init__(self, logfile='line_ultimate.log'):
self.logfile = logfile
self.listeners = []
logging.basicConfig(
filename=logfile,
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
encoding='utf-8',
force=True
)
self.info(f'=== {APP_NAME} v{APP_VERSION} Started ===')
def add_listener(self, callback):
if callback not in self.listeners:
self.listeners.append(callback)
def info(self, text):
logging.info(text)
self._notify('INFO', text)
def warning(self, text):
logging.warning(text)
self._notify('WARN', text)
def error(self, text):
logging.error(text)
self._notify('ERROR', text)
def _notify(self, level, text):
for listener in self.listeners:
try:
listener(level, text)
except Exception:
pass
class ImageCache:
def __init__(self, max_items=128):
self.cache = {}
self.max_items = max_items
def add(self, key, value):
if len(self.cache) >= self.max_items:
first_key = next(iter(self.cache))
del self.cache[first_key]
self.cache[key] = value
def get(self, key):
return self.cache.get(key)
def clear(self):
self.cache.clear()
class JobManager:
def __init__(self):
self.jobs = []
def add_job(self, filepath):
path_obj = Path(filepath)
if not path_obj.exists():
return None
for j in self.jobs:
if j['path'] == str(path_obj):
return None
size_kb = round(path_obj.stat().st_size / 1024, 1)
res_str = 'Unknown'
try:
if PIL_AVAILABLE:
with Image.open(filepath) as img:
res_str = f'{img.width}x{img.height}'
except Exception:
pass
job_info = {
'path': str(path_obj),
'name': path_obj.name,
'ext': path_obj.suffix.lower(),
'size_kb': size_kb,
'res': res_str
}
self.jobs.append(job_info)
return job_info
def remove_job(self, index):
if 0 <= index < len(self.jobs):
return self.jobs.pop(index)
return None
def clear(self):
self.jobs.clear()
def count(self):
return len(self.jobs)
def get_paths(self):
return [j['path'] for j in self.jobs]
class FileScanner:
@staticmethod
def scan(folder):
files = []
for root, dirs, names in os.walk(folder):
for n in names:
if Path(n).suffix.lower() in SUPPORTED_EXTS:
files.append(os.path.join(root, n))
return sorted(files)
@staticmethod
def is_supported(path):
return Path(path).suffix.lower() in SUPPORTED_EXTS
class RuntimeContext:
def __init__(self):
self.config = ConfigManager()
self.logger = LogManager()
self.cache = ImageCache()
self.jobs = JobManager()
self.started = datetime.now()
CTX = RuntimeContext()
# ===== END PART1-A FRONT =====
# ===== LINE ULTIMATE PART1-A BACK =====
# Runtime / Thread / Utility Layer
try:
import cv2
import numpy as np
CV2_AVAILABLE = True
except Exception:
CV2_AVAILABLE = False
try:
from PIL import Image, ImageTk, ImageFilter, ImageOps
PIL_AVAILABLE = True
except Exception:
PIL_AVAILABLE = False
try:
import tkinterdnd2
from tkinterdnd2 import DND_FILES, TkinterDnD
TKDND_AVAILABLE = True
except Exception:
TKDND_AVAILABLE = False
class ThreadManager:
def __init__(self):
self.threads = []
def start(self, target, *args, **kwargs):
th = threading.Thread(target=target, args=args, kwargs=kwargs, daemon=True)
th.start()
self.threads.append(th)
return th
class ProgressManager:
def __init__(self):
self.value = 0
self.maximum = 100
self.message = ''
def reset(self):
self.value = 0
self.message = ''
def set(self, value, message=''):
self.value = value
self.message = message
class ThumbnailCache:
def __init__(self):
self.items = {}
def put(self, path, image):
self.items[path] = image
def get(self, path):
return self.items.get(path)
def clear(self):
self.items.clear()
class EventBus:
def __init__(self):
self.q = queue.Queue()
def send(self, name, data=None):
self.q.put((name, data))
def receive(self):
try:
return self.q.get_nowait()
except Exception:
return None
class LineMode:
"""
LINE Creators Market 制作ガイドライン完全準拠モード定義
(https://creator.line.me/ja/guideline/ & https://creator.line.me/ja/guideline/emoji/)
"""
STAMP = 'stamp'
ANIM_STAMP = 'anim_stamp'
BIG_STAMP = 'big_stamp'
POPUP_STAMP = 'popup_stamp'
EFFECT_STAMP = 'effect_stamp'
MESSAGE_STAMP = 'message_stamp'
EMOJI = 'emoji'
ANIM_EMOJI = 'anim_emoji'
SPECS = {
'stamp': {
'label': '通常スタンプ',
'size': LINE_STAMP_SIZE,
'fit_exact': False,
'force_even': True,
'default_padding': 5,
'default_pattern': '01',
'need_main': True,
'need_tab': True,
'extra_mains': [],
'has_dual_content': False,
'allowed_counts': [8, 16, 24, 32, 40],
'max_filesize_mb': 1.0,
'file_structure': [
'main.png (240x240)',
'tab.png (96x74)',
'01.png ~ 40.png (最大370x320 偶数)'
],
'desc': '【通常スタンプ】main.png / tab.png / 01.png〜40.png (最大370x320 偶数)'
},
'anim_stamp': {
'label': 'アニメスタンプ',
'size': LINE_ANIM_SIZE,
'fit_exact': False,
'force_even': True,
'default_padding': 5,
'default_pattern': '01',
'need_main': True,
'need_tab': True,
'extra_mains': [],
'has_dual_content': False,
'allowed_counts': [8, 16, 24],
'max_filesize_mb': 1.0,
'file_structure': [
'main.png (240x240)',
'tab.png (96x74)',
'01.png ~ 24.png (最大320x270 偶数)'
],
'desc': '【アニメスタンプ】main.png / tab.png / 01.png〜24.png (最大320x270 偶数)'
},
'big_stamp': {
'label': 'BIGスタンプ',
'size': LINE_BIG_STAMP_SIZE,
'fit_exact': False,
'force_even': True,
'default_padding': 5,
'default_pattern': '01',
'need_main': True,
'need_tab': True,
'extra_mains': [],
'has_dual_content': False,
'allowed_counts': [8, 16, 24, 32, 40],
'max_filesize_mb': 1.0,
'file_structure': [
'main.png (240x240)',
'tab.png (96x74)',
'01.png ~ 40.png (最大660x540 偶数)'
],
'desc': '【BIGスタンプ】main.png / tab.png / 01.png〜40.png (最大660x540 偶数)'
},
'popup_stamp': {
'label': 'ポップアップスタンプ',
'size': LINE_STAMP_SIZE,
'popup_size': LINE_POPUP_SIZE,
'fit_exact': False,
'force_even': True,
'default_padding': 5,
'default_pattern': '01',
'need_main': True,
'need_tab': True,
'extra_mains': [('main_animation.png', LINE_MAIN_SIZE)],
'has_dual_content': True,
'dual_suffix': '_popup',
'allowed_counts': [8, 16, 24],
'max_filesize_mb': 1.0,
'file_structure': [
'main.png (240x240)',
'main_animation.png (240x240)',
'tab.png (96x74)',
'01.png ~ 24.png (最大370x320 偶数)',
'01_popup.png ~ 24_popup.png (480x480 固定)'
],
'desc': '【ポップアップ】main.png / main_animation.png / tab.png / 01〜24.png / 01_popup〜24_popup.png'
},
'effect_stamp': {
'label': 'エフェクトスタンプ',
'size': LINE_STAMP_SIZE,
'popup_size': LINE_POPUP_SIZE,
'fit_exact': False,
'force_even': True,
'default_padding': 5,
'default_pattern': '01',
'need_main': True,
'need_tab': True,
'extra_mains': [('main_effect.png', LINE_MAIN_SIZE)],
'has_dual_content': True,
'dual_suffix': '_effect',
'allowed_counts': [8, 16, 24],
'max_filesize_mb': 1.0,
'file_structure': [
'main.png (240x240)',
'main_effect.png (240x240)',
'tab.png (96x74)',
'01.png ~ 24.png (最大370x320 偶数)',
'01_effect.png ~ 24_effect.png (480x480 固定)'
],
'desc': '【エフェクト】main.png / main_effect.png / tab.png / 01〜24.png / 01_effect〜24_effect.png'
},
'message_stamp': {
'label': 'メッセージスタンプ',
'size': LINE_STAMP_SIZE,
'fit_exact': False,
'force_even': True,
'default_padding': 5,
'default_pattern': '01',
'need_main': True,
'need_tab': True,
'extra_mains': [],
'has_dual_content': False,
'allowed_counts': [8, 16, 24],
'max_filesize_mb': 1.0,
'file_structure': [
'main.png (240x240)',
'tab.png (96x74)',
'01.png ~ 24.png (最大370x320 偶数)'
],
'desc': '【メッセージスタンプ】main.png / tab.png / 01.png〜24.png (最大370x320 偶数・テキスト入力対応)'
},
'emoji': {
'label': 'LINE絵文字 (静止画)',
'size': LINE_EMOJI_SIZE,
'fit_exact': True,
'force_even': False,
'default_padding': 0,
'default_pattern': '001',
'need_main': False, # 絵文字はmain.png不要!
'need_tab': True,
'extra_mains': [],
'has_dual_content': False,
'allowed_counts': [8, 16, 24, 32, 40],
'max_filesize_mb': 1.0,
'file_structure': [
'tab.png (96x74) ※main不要',
'001.png ~ 040.png (180x180 固定 / 数字3桁)'
],
'desc': '【LINE絵文字】001.png〜040.png (180x180 数字3桁) / tab.png (96x74) ※main.png不要'
},
'anim_emoji': {
'label': 'アニメーション絵文字',
'size': LINE_EMOJI_SIZE,
'fit_exact': True,
'force_even': False,
'default_padding': 0,
'default_pattern': '001',
'need_main': False, # 絵文字はmain.png不要!
'need_tab': True,
'extra_mains': [],
'has_dual_content': False,
'allowed_counts': [8, 16, 24, 32, 40],
'max_filesize_mb': 0.3, # 300KB以下 (公式レギュレーション)
'file_structure': [
'tab.png (96x74) ※main不要',
'001.png ~ 040.png (180x180 固定 / 数字3桁 / 300KB以下)'
],
'desc': '【アニメ絵文字】001.png〜040.png (180x180 数字3桁 / 300KB以下) / tab.png ※main不要'
}
}
@classmethod
def get_spec(cls, mode):
return cls.SPECS.get(mode, cls.SPECS[cls.STAMP])
@classmethod
def canvas_size(cls, mode):
return cls.get_spec(mode)['size']
def format_sequence(index, prefix='01'):
if prefix == '001':
return f'{index:03d}'
elif prefix == '01':
return f'{index:02d}'
elif prefix == '000001':
return f'{index:06d}'
return f'A{index:06d}'
def ensure_folder(path):
os.makedirs(path, exist_ok=True)
THREADS = ThreadManager()
PROGRESS = ProgressManager()
THUMBS = ThumbnailCache()
EVENTS = EventBus()
# ===== END PART1-A BACK =====
# ===== LINE ULTIMATE PART2-A =====
# Image Processing Core
def make_even(n: int) -> int:
"""LINE sticker rule: width and height must both be even numbers (min 2)"""
n = max(2, int(n))
return n if n % 2 == 0 else n - 1
class ImageProcessor:
@staticmethod
def load_image(path):
img = Image.open(path)
return img.convert('RGBA')
@staticmethod
def trim_transparent(img, margin=0):
bbox = img.getbbox()
if bbox:
cropped = img.crop(bbox)
if margin > 0:
new_w = cropped.width + margin * 2
new_h = cropped.height + margin * 2
canvas = Image.new('RGBA', (new_w, new_h), (0, 0, 0, 0))
canvas.paste(cropped, (margin, margin))
return canvas
return cropped
return img
@staticmethod
def fit_canvas(img, size, padding_percent=0, fit_exact=True, force_even=True):
"""
Resize image preserving aspect ratio.
If fit_exact=True: embed in a transparent canvas of exact `size`.
If fit_exact=False: return resized image itself (ensuring even dimensions if force_even).
"""
w, h = size
scale_w = max(2, int(w * (100 - padding_percent) / 100))
scale_h = max(2, int(h * (100 - padding_percent) / 100))
ratio = min(scale_w / img.width, scale_h / img.height)
new_w = max(2, round(img.width * ratio))
new_h = max(2, round(img.height * ratio))
if force_even:
new_w = make_even(new_w)
new_h = make_even(new_h)
new_w = min(new_w, w)
new_h = min(new_h, h)
tmp = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
if fit_exact:
canvas = Image.new('RGBA', size, (0, 0, 0, 0))
x = (w - tmp.width) // 2
y = (h - tmp.height) // 2
canvas.paste(tmp, (x, y), tmp)
return canvas
else:
return tmp
@classmethod
def process_for_spec(cls, img, mode='stamp', padding=0, trim=True, use_popup_size=False):
"""Process image strictly according to LINE mode specifications"""
spec = LineMode.get_spec(mode)
if trim:
img = cls.trim_transparent(img)
target_size = spec.get('popup_size', spec['size']) if use_popup_size else spec['size']
fit_exact = True if use_popup_size else spec['fit_exact']
force_even = False if use_popup_size else spec['force_even']
return cls.fit_canvas(
img,
target_size,
padding_percent=padding,
fit_exact=fit_exact,
force_even=force_even
)
@classmethod
def create_stamp(cls, img, padding=5, trim=True, fit_exact=False):
if trim:
img = cls.trim_transparent(img)
return cls.fit_canvas(img, LINE_STAMP_SIZE, padding_percent=padding, fit_exact=fit_exact, force_even=True)
@classmethod
def create_emoji(cls, img, padding=0, trim=True):
"""LINE emoji: 180x180 px fixed transparent canvas, margin typically 0"""
if trim:
img = cls.trim_transparent(img)
return cls.fit_canvas(img, LINE_EMOJI_SIZE, padding_percent=padding, fit_exact=True, force_even=False)
@classmethod
def create_big_stamp(cls, img, padding=5, trim=True, fit_exact=False):
if trim:
img = cls.trim_transparent(img)
return cls.fit_canvas(img, LINE_BIG_STAMP_SIZE, padding_percent=padding, fit_exact=fit_exact, force_even=True)
@classmethod
def create_popup(cls, img, padding=5, trim=True):
if trim:
img = cls.trim_transparent(img)
return cls.fit_canvas(img, LINE_POPUP_SIZE, padding_percent=padding, fit_exact=True, force_even=False)
@classmethod
def create_main(cls, img, padding=5, trim=True):
"""LINE main image: 240x240 px fixed"""
if trim:
img = cls.trim_transparent(img)
return cls.fit_canvas(img, LINE_MAIN_SIZE, padding_percent=padding, fit_exact=True, force_even=False)
@classmethod
def create_tab(cls, img, padding=5, trim=True):
"""LINE tab image: 96x74 px fixed"""
if trim:
img = cls.trim_transparent(img)
return cls.fit_canvas(img, LINE_TAB_SIZE, padding_percent=padding, fit_exact=True, force_even=False)
@staticmethod
def save_png(img, output_path, limit_mb=1.0):
"""Save image as PNG ensuring under limit_mb (1MB or 300KB by LINE spec)"""
output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
buf = io.BytesIO()
img.save(buf, format='PNG', optimize=True)
data = buf.getvalue()
max_bytes = int(limit_mb * 1024 * 1024)
if len(data) <= max_bytes:
with open(output_path, 'wb') as f:
f.write(data)
return True
# Exceeds limit: quantize to 256 colors palette with alpha
try:
quantized = img.quantize(colors=256, method=Image.Quantize.FASTOCTREE)
buf2 = io.BytesIO()
quantized.save(buf2, format='PNG', optimize=True)
with open(output_path, 'wb') as f:
f.write(buf2.getvalue())
return True
except Exception:
with open(output_path, 'wb') as f:
f.write(data)
return False
# ===== END PART2-A =====
# ===== LINE ULTIMATE PART2-B =====
# Outline / Shadow Engine
class EffectProcessor:
@staticmethod
def create_outline(img, color=(255, 255, 255, 255), size=4):
"""Create high-quality smooth outline using alpha channel dilation"""
if size <= 0:
return img
img = img.convert('RGBA')
pad = size + 4
w, h = img.size
canvas = Image.new('RGBA', (w + pad * 2, h + pad * 2), (0, 0, 0, 0))
canvas.paste(img, (pad, pad))
alpha = canvas.split()[3]
filter_size = size * 2 + 1
expanded_alpha = alpha.filter(ImageFilter.MaxFilter(filter_size))
expanded_alpha = expanded_alpha.filter(ImageFilter.GaussianBlur(radius=0.7))
expanded_alpha = expanded_alpha.point(lambda p: 255 if p > 70 else 0)
color_layer = Image.new('RGBA', canvas.size, color)
color_layer.putalpha(expanded_alpha)
result = Image.alpha_composite(color_layer, canvas)
bbox = result.getbbox()
if bbox:
return result.crop(bbox)
return result
@staticmethod
def white_outline(img, size=4):
return EffectProcessor.create_outline(img, (255, 255, 255, 255), size)
@staticmethod
def black_outline(img, size=4):
return EffectProcessor.create_outline(img, (0, 0, 0, 255), size)
@staticmethod
def shadow(img, offset=(4, 4), blur=6, color=(0, 0, 0, 140)):
img = img.convert('RGBA')
pad = max(abs(offset[0]), abs(offset[1])) + blur * 2
w, h = img.size
canvas = Image.new('RGBA', (w + pad * 2, h + pad * 2), (0, 0, 0, 0))
img_pos = (pad, pad)
canvas.paste(img, img_pos)
alpha = canvas.split()[3]
shadow_layer = Image.new('RGBA', canvas.size, (0, 0, 0, 0))
shadow_fill = Image.new('RGBA', canvas.size, color)
shadow_layer.paste(shadow_fill, offset, alpha)
shadow_layer = shadow_layer.filter(ImageFilter.GaussianBlur(blur))
result = Image.alpha_composite(shadow_layer, canvas)
bbox = result.getbbox()
if bbox:
return result.crop(bbox)
return result
@staticmethod
def apply_effects(
img,
white_outline=False,
black_outline=False,
shadow=False,
outline_size=4,
trim=True
):
result = img.copy()
if trim:
result = ImageProcessor.trim_transparent(result)
if white_outline:
result = EffectProcessor.white_outline(result, outline_size)
if black_outline:
result = EffectProcessor.black_outline(result, outline_size)
if shadow:
result = EffectProcessor.shadow(result)
return result
# ===== END PART2-B =====
# ===== LINE ULTIMATE PART3-A =====
# Batch Export Engine
class BatchExporter:
def __init__(self, processor=None):
self.processor = processor or ImageProcessor()
def make_name(self, index, pattern='01', orig_name=None, suffix=''):
"""
ファイル名生成:
- '001': 絵文字公式規定 (001.png, 002.png... 数字3桁)
- '01': スタンプ公式規定 (01.png, 02.png... 数字2桁)
- 'A000001': 旧連番 (A000001.png)
- '000001': 6桁連番
- 'original': 元ファイル名保持
- suffix: '_popup' や '_effect' などの付加文字
"""
if pattern == 'original' and orig_name:
stem = Path(orig_name).stem
return f'{stem}{suffix}.png'
if pattern == '001':
return f'{index:03d}{suffix}.png'
if pattern == '01':
return f'{index:02d}{suffix}.png'
if pattern == '000001':
return f'{index:06d}{suffix}.png'
return f'A{index:06d}{suffix}.png'
def export_folder(
self,
files,
output_dir,
mode='stamp',
padding=5,
effects=None,
pattern='01',
keep_filename=False,
gen_main_tab=True,
progress_cb=None
):
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
effects = effects or {}
white_outline = effects.get('white_outline', False)
black_outline = effects.get('black_outline', False)
shadow = effects.get('shadow', False)
outline_size = effects.get('outline_size', 4)
trim = effects.get('trim_transparent', True)
spec = LineMode.get_spec(mode)
max_size_mb = spec.get('max_filesize_mb', 1.0)
has_dual = spec.get('has_dual_content', False)
dual_suffix = spec.get('dual_suffix', '')
first_image = None
total = len(files)
created_files = []
for index, file_path in enumerate(files, start=1):
try:
img = self.processor.load_image(file_path)
# Apply effects first
processed = EffectProcessor.apply_effects(
img,
white_outline=white_outline,
black_outline=black_outline,
shadow=shadow,
outline_size=outline_size,
trim=trim
)
if first_image is None:
first_image = processed.copy()
# 1. Main standard content image (e.g. 01.png)
result_std = self.processor.process_for_spec(
processed, mode=mode, padding=padding, trim=False, use_popup_size=False
)
if keep_filename:
out_name_std = Path(file_path).stem + '.png'
else:
out_name_std = self.make_name(index, pattern=pattern, orig_name=file_path)
dest_std = output_dir / out_name_std
self.processor.save_png(result_std, dest_std, limit_mb=max_size_mb)
created_files.append(str(dest_std))
# 2. Dual popup / effect content image if needed (e.g. 01_popup.png or 01_effect.png)
if has_dual:
result_dual = self.processor.process_for_spec(
processed, mode=mode, padding=padding, trim=False, use_popup_size=True
)
if keep_filename:
out_name_dual = Path(file_path).stem + dual_suffix + '.png'
else:
out_name_dual = self.make_name(index, pattern=pattern, orig_name=file_path, suffix=dual_suffix)
dest_dual = output_dir / out_name_dual
self.processor.save_png(result_dual, dest_dual, limit_mb=max_size_mb)
created_files.append(str(dest_dual))
if progress_cb:
pct = int(index / total * (80 if gen_main_tab else 95))
status_name = f'{out_name_std}' + (f' & {out_name_dual}' if has_dual else '')
progress_cb(pct, f'変換中: {status_name} ({index}/{total})')
except Exception as e:
logging.error(f'Error processing {file_path}: {e}')
# main.png / tab.png / extra_mains 生成判定 (LINE公式ガイドライン準拠)
if gen_main_tab and first_image is not None:
# 1. Standard main.png (240x240)
if spec['need_main']:
if progress_cb:
progress_cb(85, 'main.png (240x240) を生成中...')
main_img = self.processor.create_main(first_image, padding=padding, trim=False)
main_path = output_dir / 'main.png'
self.processor.save_png(main_img, main_path, limit_mb=1.0)
created_files.append(str(main_path))
# 2. Extra mains (e.g. main_animation.png, main_effect.png)
for extra_filename, extra_size in spec.get('extra_mains', []):
if progress_cb:
progress_cb(88, f'{extra_filename} を生成中...')
extra_img = self.processor.fit_canvas(first_image, extra_size, padding_percent=padding, fit_exact=True, force_even=False)
extra_path = output_dir / extra_filename
self.processor.save_png(extra_img, extra_path, limit_mb=1.0)
created_files.append(str(extra_path))
# 3. tab.png (96x74)
if spec['need_tab']:
if progress_cb:
progress_cb(92, 'tab.png (96x74) を生成中...')
tab_img = self.processor.create_tab(first_image, padding=padding, trim=False)
tab_path = output_dir / 'tab.png'
self.processor.save_png(tab_img, tab_path, limit_mb=1.0)
created_files.append(str(tab_path))
if progress_cb:
progress_cb(100, f'変換完了: 計 {len(created_files)} ファイル')
return created_files
# ===== END PART3-A =====
# ===== LINE ULTIMATE PART3-B =====
# ZIP / Naming Engine
class PackageBuilder:
@staticmethod
def file_size_mb(path):
try:
return os.path.getsize(path) / (1024 * 1024)
except Exception:
return 0.0
@staticmethod
def check_png_limit(path, limit_mb=1.0):
return PackageBuilder.file_size_mb(path) <= limit_mb
@staticmethod
def build_zip(source_folder, output_zip='LINE_PACKAGE.zip'):
source_folder = Path(source_folder)
zip_path = source_folder / output_zip
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as z:
for file in sorted(source_folder.iterdir()):
if file.is_file() and file.suffix.lower() == '.png' and file.name != output_zip:
z.write(file, file.name)
return str(zip_path)
@staticmethod
def zip_size_mb(zip_path):
try:
return os.path.getsize(zip_path) / (1024 * 1024)
except Exception:
return 0.0
@staticmethod
def validate_folder(folder, mode='stamp'):
"""
LINE公式規定に基づくパッケージ妥当性検証
"""
spec = LineMode.get_spec(mode)
results = []
files = list(Path(folder).glob('*.png'))
mains_and_tabs = {'main.png', 'tab.png'}
for extra_name, _ in spec.get('extra_mains', []):
mains_and_tabs.add(extra_name)
content_files = [f for f in files if f.name not in mains_and_tabs]
has_main = any(f.name == 'main.png' for f in files)
has_tab = any(f.name == 'tab.png' for f in files)
extra_checks = {}
for extra_name, _ in spec.get('extra_mains', []):
extra_checks[extra_name] = any(f.name == extra_name for f in files)
max_limit_mb = spec.get('max_filesize_mb', 1.0)
for file in sorted(files):
sz = PackageBuilder.file_size_mb(file)
limit = max_limit_mb if file.name not in mains_and_tabs else 1.0
results.append({
'file': file.name,
'size_mb': round(sz, 3),
'limit_mb': limit,
'ok': sz <= limit
})
# Dual content handling (count standard items)
if spec.get('has_dual_content', False):
dual_suffix = spec.get('dual_suffix', '')
std_items = [f for f in content_files if not f.stem.endswith(dual_suffix)]
dual_items = [f for f in content_files if f.stem.endswith(dual_suffix)]
count = len(std_items)
dual_count_match = (len(std_items) == len(dual_items))
else:
count = len(content_files)
dual_count_match = True
count_ok = count in spec['allowed_counts']
summary = {
'mode': mode,
'files': results,
'content_count': count,
'count_ok': count_ok,
'allowed_counts': spec['allowed_counts'],
'has_main': has_main,
'main_required': spec['need_main'],
'main_ok': (has_main == spec['need_main']),
'has_tab': has_tab,
'tab_required': spec['need_tab'],
'tab_ok': (has_tab == spec['need_tab']),
'extra_checks': extra_checks,
'extra_mains_ok': all(extra_checks.values()) if extra_checks else True,
'dual_count_match': dual_count_match,
'all_size_ok': all(r['ok'] for r in results)
}
return summary
# ===== END PART3-B =====
# ===== LINE ULTIMATE PART4-A =====
# OpenCV Preview Engine
class PreviewEngine:
def __init__(self):
self.zoom = 1.0
self.bg_mode = 'checker'
def set_zoom(self, zoom):
self.zoom = max(0.1, min(20.0, zoom))
def set_background(self, mode):
self.bg_mode = mode
def create_checkerboard(self, width, height, size=16):
if not CV2_AVAILABLE:
return None
img = np.zeros((height, width, 3), dtype=np.uint8)
for y in range(0, height, size):
for x in range(0, width, size):
c = 220 if ((x // size) + (y // size)) % 2 == 0 else 180
img[y:y + size, x:x + size] = (c, c, c)
return img
def pil_to_cv(self, pil_img):
if not CV2_AVAILABLE:
return None
arr = np.array(pil_img.convert('RGBA'))
return cv2.cvtColor(arr, cv2.COLOR_RGBA2BGRA)
def show_before_after(self, before_img, after_img, window_title='LINE Ultimate - Preview (Press Q or ESC to close)'):
if not CV2_AVAILABLE:
return False
def prepare(img):
arr = np.array(img.convert('RGBA'))
b, g, r, a = cv2.split(arr)
h, w = arr.shape[:2]
checker = self.create_checkerboard(w, h, size=12)
alpha_f = a.astype(float) / 255.0
alpha_3 = np.dstack([alpha_f, alpha_f, alpha_f])
fg = cv2.merge([b, g, r]).astype(float)
bg = checker.astype(float)
comp = (fg * alpha_3 + bg * (1.0 - alpha_3)).astype(np.uint8)
return comp
left = prepare(before_img)
right = prepare(after_img)
cv2.putText(left, 'BEFORE (Original)', (10, 25), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2)
cv2.putText(right, 'AFTER (LINE Spec)', (10, 25), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 180, 0), 2)
max_h = max(left.shape[0], right.shape[0])
if left.shape[0] < max_h:
pad_top = (max_h - left.shape[0]) // 2
pad_bottom = max_h - left.shape[0] - pad_top
left = cv2.copyMakeBorder(left, pad_top, pad_bottom, 0, 0, cv2.BORDER_CONSTANT, value=(40, 40, 40))
if right.shape[0] < max_h:
pad_top = (max_h - right.shape[0]) // 2
pad_bottom = max_h - right.shape[0] - pad_top
right = cv2.copyMakeBorder(right, pad_top, pad_bottom, 0, 0, cv2.BORDER_CONSTANT, value=(40, 40, 40))
divider = np.full((max_h, 4, 3), 100, dtype=np.uint8)
canvas = np.hstack([left, divider, right])
cv2.imshow(window_title, canvas)
cv2.waitKey(0)
cv2.destroyWindow(window_title)
return True
# ===== END PART4-A =====
# ===== LINE ULTIMATE PART4-B =====
# Advanced Preview Controls
class PreviewInteraction:
def __init__(self):
self.zoom = 1.0
self.offset_x = 0
self.offset_y = 0
self.background = 'checker'
def zoom_in(self, step=0.1):
self.zoom = min(10.0, round(self.zoom + step, 2))
def zoom_out(self, step=0.1):
self.zoom = max(0.2, round(self.zoom - step, 2))
def reset(self):
self.zoom = 1.0
self.offset_x = 0
self.offset_y = 0
def set_background(self, mode):
self.background = mode
class PixelInfo:
@staticmethod
def rgba_at(image, x, y):
try:
return image.getpixel((x, y))
except Exception:
return None
class BackgroundModes:
CHECKER = 'checker'
WHITE = 'white'
BLACK = 'black'
LINE = 'line' # LINE official chatroom sky blue (#7ac5d8)
class RealtimePreview:
def __init__(self):
self.enabled = True
def refresh(self):
return self.enabled
# ===== END PART4-B =====
# ===== LINE ULTIMATE PART1-C =====
# Drag&Drop / TreeView / Settings UI Layer
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
class DragDropSupport:
@staticmethod
def attach(widget, callback):
if not TKDND_AVAILABLE:
return False
try:
widget.drop_target_register(DND_FILES)
widget.dnd_bind('<<Drop>>', callback)
return True
except Exception as e:
logging.debug(f'DragDrop attach failed: {e}')
return False
class ThumbnailPanel(ttk.Frame):
def __init__(self, master):
super().__init__(master)
self.canvas = tk.Canvas(self, bg='#1e1e1e', highlightthickness=0)
self.scroll = ttk.Scrollbar(self, orient='vertical', command=self.canvas.yview)
self.canvas.configure(yscrollcommand=self.scroll.set)
self.canvas.pack(side='left', fill='both', expand=True)
self.scroll.pack(side='right', fill='y')
class FileTreePanel(ttk.Frame):
def __init__(self, master, on_select=None):
super().__init__(master)
self.on_select = on_select
header_frame = ttk.Frame(self)
header_frame.pack(fill='x', padx=2, pady=2)
self.count_label = ttk.Label(header_frame, text='画像一覧: 0件', font=('Segoe UI', 9, 'bold'))
self.count_label.pack(side='left')
self.status_badge = ttk.Label(header_frame, text='', font=('Segoe UI', 8))
self.status_badge.pack(side='right', padx=2)
tree_frame = ttk.Frame(self)
tree_frame.pack(fill='both', expand=True)
self.tree = ttk.Treeview(
tree_frame,
columns=('num', 'name', 'size', 'res'),
show='headings',
selectmode='browse'
)
self.tree.heading('num', text='#')
self.tree.column('num', width=35, anchor='center', stretch=False)
self.tree.heading('name', text='ファイル名')
self.tree.column('name', width=160, anchor='w')
self.tree.heading('size', text='容量')
self.tree.column('size', width=65, anchor='e', stretch=False)
self.tree.heading('res', text='解像度')
self.tree.column('res', width=80, anchor='center', stretch=False)
scroll_y = ttk.Scrollbar(tree_frame, orient='vertical', command=self.tree.yview)
self.tree.configure(yscrollcommand=scroll_y.set)
self.tree.pack(side='left', fill='both', expand=True)
scroll_y.pack(side='right', fill='y')
self.tree.bind('<<TreeviewSelect>>', self._handle_select)
def _handle_select(self, event):
sel = self.tree.selection()
if sel and self.on_select:
item = self.tree.item(sel[0])
idx = int(item['values'][0]) - 1
self.on_select(idx)
def set_items(self, jobs, mode='stamp'):
self.tree.delete(*self.tree.get_children())
for idx, job in enumerate(jobs, start=1):
self.tree.insert('', 'end', values=(
idx,
job['name'],
f"{job['size_kb']} KB",
job['res']
))
count = len(jobs)
spec = LineMode.get_spec(mode)
allowed = spec['allowed_counts']
self.count_label.config(text=f'画像一覧: {count}件')
if count == 0:
self.status_badge.config(text='(未登録)', foreground='#888888')
elif count in allowed:
self.status_badge.config(text=f'✅ {count}個パック適合', foreground='#2e7d32')
else:
next_target = next((c for c in allowed if c > count), None)
if next_target:
diff = next_target - count
self.status_badge.config(text=f'⚠️ {count}件 (あと{diff}件で{next_target}個)', foreground='#e65100')
else:
self.status_badge.config(text=f'ℹ️ {count}件 (規定: {allowed})', foreground='#1565c0')
def select_index(self, index):
children = self.tree.get_children()
if 0 <= index < len(children):
self.tree.selection_set(children[index])
self.tree.see(children[index])
class SettingsPanel(ttk.LabelFrame):
def __init__(self, master, on_change=None):
super().__init__(master, text=' ⚙ 設定 (LINE Creators Market 規格) ', padding=8)
self.on_change = on_change
# Mode Mapping
self.mode_keys = [
'stamp',
'anim_stamp',
'big_stamp',
'popup_stamp',
'effect_stamp',
'message_stamp',
'emoji',
'anim_emoji'
]
self.mode_labels = [LineMode.get_spec(k)['label'] for k in self.mode_keys]
# Mode
mode_frame = ttk.Frame(self)
mode_frame.pack(fill='x', pady=2)
ttk.Label(mode_frame, text='規格タイプ:').pack(side='left')
self.mode_var = tk.StringVar(value=self.mode_labels[0])
self.cb_mode = ttk.Combobox(
mode_frame,
textvariable=self.mode_var,
values=self.mode_labels,
state='readonly'
)
self.cb_mode.pack(side='right', fill='x', expand=True, padx=(4, 0))
self.cb_mode.bind('<<ComboboxSelected>>', self._on_mode_selected)
# Mode description banner
self.mode_desc_lbl = ttk.Label(
self,
text=LineMode.get_spec('stamp')['desc'],
font=('Segoe UI', 8),
wraplength=310,
foreground='#1976d2'
)
self.mode_desc_lbl.pack(fill='x', pady=(2, 4))
# Expected file structure box
struct_frame = ttk.LabelFrame(self, text=' 📁 生成されるファイル構成 ', padding=4)
struct_frame.pack(fill='x', pady=4)
self.struct_text_lbl = ttk.Label(
struct_frame,
text='\n'.join(LineMode.get_spec('stamp')['file_structure']),
font=('Consolas', 8),
foreground='#333333'
)
self.struct_text_lbl.pack(anchor='w')
# Padding
pad_frame = ttk.Frame(self)
pad_frame.pack(fill='x', pady=(4, 2))
ttk.Label(pad_frame, text='余白率 (%):').pack(side='left')
self.padding_var = tk.IntVar(value=5)
self.sp_pad = ttk.Spinbox(
pad_frame,
from_=0,
to=40,
textvariable=self.padding_var,
width=5,
command=self._trigger_change
)
self.sp_pad.pack(side='right')
self.sp_pad.bind('<KeyRelease>', self._trigger_change)
# Separator
ttk.Separator(self, orient='horizontal').pack(fill='x', pady=8)
# Effects Group
eff_lbl = ttk.Label(self, text='【エフェクト・トリミング】', font=('Segoe UI', 9, 'bold'))
eff_lbl.pack(anchor='w')
self.trim_var = tk.BooleanVar(value=True)
ttk.Checkbutton(
self,
text='透明余白を自動トリム',
variable=self.trim_var,
command=self._trigger_change
).pack(anchor='w', pady=2)
self.white_outline = tk.BooleanVar(value=False)
ttk.Checkbutton(
self,
text='白フチを追加 (LINE背景対策)',
variable=self.white_outline,
command=self._trigger_change
).pack(anchor='w', pady=2)
self.black_outline = tk.BooleanVar(value=False)
ttk.Checkbutton(
self,
text='黒フチを追加',
variable=self.black_outline,
command=self._trigger_change
).pack(anchor='w', pady=2)
self.shadow = tk.BooleanVar(value=False)
ttk.Checkbutton(
self,
text='シャドウ (影) を追加',
variable=self.shadow,
command=self._trigger_change
).pack(anchor='w', pady=2)
size_frame = ttk.Frame(self)
size_frame.pack(fill='x', pady=2)
ttk.Label(size_frame, text='フチの太さ (px):').pack(side='left')
self.outline_size_var = tk.IntVar(value=4)
ttk.Spinbox(
size_frame,
from_=1,
to=20,
textvariable=self.outline_size_var,
width=5,
command=self._trigger_change
).pack(side='right')
# Separator
ttk.Separator(self, orient='horizontal').pack(fill='x', pady=8)
# Output Group
out_lbl = ttk.Label(self, text='【出力・パッケージ】', font=('Segoe UI', 9, 'bold'))
out_lbl.pack(anchor='w')
ttk.Label(self, text='出力先フォルダ:').pack(anchor='w', pady=(4, 0))
out_dir_frame = ttk.Frame(self)
out_dir_frame.pack(fill='x', pady=2)
self.output_folder = tk.StringVar()
ttk.Entry(out_dir_frame, textvariable=self.output_folder).pack(side='left', fill='x', expand=True)
ttk.Button(out_dir_frame, text='参照...', width=6, command=self._browse_output).pack(side='right', padx=(2, 0))
pat_frame = ttk.Frame(self)
pat_frame.pack(fill='x', pady=4)
ttk.Label(pat_frame, text='ファイル命名:').pack(side='left')
self.pattern_var = tk.StringVar(value='01')
self.cb_pat = ttk.Combobox(
pat_frame,
textvariable=self.pattern_var,
values=[
'001〜 (001.png, 002.png... 絵文字公式3桁)',
'01〜 (01.png, 02.png... スタンプ公式2桁)',
'A000001〜 (LINE旧連番)',
'000001〜 (6桁連番)',
'original (元ファイル名保持)'
],
state='readonly'
)
self.cb_pat.current(1)
self.cb_pat.pack(side='right', fill='x', expand=True, padx=(4, 0))
self.gen_main_tab_var = tk.BooleanVar(value=True)
self.chk_main_tab = ttk.Checkbutton(
self,
text='main.png / tab.png を自動生成',
variable=self.gen_main_tab_var
)
self.chk_main_tab.pack(anchor='w', pady=2)
self.zip_output = tk.BooleanVar(value=True)
ttk.Checkbutton(
self,
text='LINE_PACKAGE.zip を自動作成',
variable=self.zip_output
).pack(anchor='w', pady=2)
# Save Button
ttk.Button(self, text='💾 設定を保存', command=self._save_settings).pack(fill='x', pady=(10, 0))
def _on_mode_selected(self, event=None):
mode = self.get_mode()
spec = LineMode.get_spec(mode)
self.mode_desc_lbl.config(text=spec['desc'])
self.struct_text_lbl.config(text='\n'.join(spec['file_structure']))
# Auto-adjust naming pattern & padding & main/tab according to LINE guidelines
if mode in (LineMode.EMOJI, LineMode.ANIM_EMOJI):
self.pattern_var.set('001〜 (001.png, 002.png... 絵文字公式3桁)')
self.padding_var.set(spec['default_padding'])
self.chk_main_tab.config(text='tab.png (96x74) のみ自動生成 (絵文字はmain不要)')
elif mode == LineMode.POPUP_STAMP:
self.pattern_var.set('01〜 (01.png, 02.png... スタンプ公式2桁)')
self.padding_var.set(spec['default_padding'])
self.chk_main_tab.config(text='main.png / main_animation.png / tab.png を生成')
elif mode == LineMode.EFFECT_STAMP:
self.pattern_var.set('01〜 (01.png, 02.png... スタンプ公式2桁)')
self.padding_var.set(spec['default_padding'])
self.chk_main_tab.config(text='main.png / main_effect.png / tab.png を生成')
else:
if '001〜' in self.pattern_var.get():
self.pattern_var.set('01〜 (01.png, 02.png... スタンプ公式2桁)')
self.padding_var.set(spec['default_padding'])
self.chk_main_tab.config(text='main.png (240x240) / tab.png (96x74) を自動生成')
self._trigger_change()
def _trigger_change(self, event=None):
if self.on_change:
self.on_change()
def _browse_output(self):
folder = filedialog.askdirectory(title='出力先フォルダを選択')
if folder:
self.output_folder.set(folder)
def _save_settings(self):
cfg = CTX.config
cfg.set('mode', self.get_mode())
cfg.set('padding_percent', self.padding_var.get())
cfg.set('trim_transparent', self.trim_var.get())
cfg.set('white_outline', self.white_outline.get())
cfg.set('black_outline', self.black_outline.get())
cfg.set('shadow', self.shadow.get())
cfg.set('outline_size', self.outline_size_var.get())
cfg.set('output_pattern', self.get_pattern())
cfg.set('gen_main_tab', self.gen_main_tab_var.get())
cfg.set('zip_output', self.zip_output.get())
cfg.set('output_folder', self.output_folder.get())
if cfg.save():
messagebox.showinfo('保存完了', '設定を保存しました: line_ultimate_settings.json')
def load_from_config(self, cfg):
mode = cfg.get('mode', 'stamp')
for idx, key in enumerate(self.mode_keys):
if key == mode:
self.cb_mode.current(idx)
break
else:
self.cb_mode.current(0)
spec = LineMode.get_spec(self.get_mode())
self.mode_desc_lbl.config(text=spec['desc'])
self.struct_text_lbl.config(text='\n'.join(spec['file_structure']))
self.padding_var.set(cfg.get('padding_percent', spec['default_padding']))
self.trim_var.set(cfg.get('trim_transparent', True))
self.white_outline.set(cfg.get('white_outline', False))
self.black_outline.set(cfg.get('black_outline', False))
self.shadow.set(cfg.get('shadow', False))
self.outline_size_var.set(cfg.get('outline_size', 4))
self.gen_main_tab_var.set(cfg.get('gen_main_tab', True))
self.zip_output.set(cfg.get('zip_output', True))
self.output_folder.set(cfg.get('output_folder', ''))
pat = cfg.get('output_pattern', spec['default_pattern'])
if pat == '001':
self.pattern_var.set('001〜 (001.png, 002.png... 絵文字公式3桁)')
elif pat == '01':
self.pattern_var.set('01〜 (01.png, 02.png... スタンプ公式2桁)')
elif pat == 'original':
self.pattern_var.set('original (元ファイル名保持)')
elif pat == '000001':
self.pattern_var.set('000001〜 (6桁連番)')
else:
self.pattern_var.set('A000001〜 (LINE旧連番)')
self._on_mode_selected()
def get_mode(self):
sel = self.mode_var.get()
for key, label in zip(self.mode_keys, self.mode_labels):
if sel == label:
return key
return 'stamp'
def get_pattern(self):
pat = self.pattern_var.get()
if '001〜' in pat:
return '001'
if '01〜' in pat:
return '01'
if 'original' in pat:
return 'original'
if '000001' in pat:
return '000001'
return 'A000001'
def get_effects(self):
return {
'white_outline': self.white_outline.get(),
'black_outline': self.black_outline.get(),
'shadow': self.shadow.get(),
'outline_size': self.outline_size_var.get(),
'trim_transparent': self.trim_var.get()
}
class LogViewer(ttk.LabelFrame):
def __init__(self, master):
super().__init__(master, text=' 📋 ログ (Log Viewer) ', padding=4)
self.text = tk.Text(self, height=6, bg='#1a1a1a', fg='#e0e0e0', font=('Consolas', 9))
scroll = ttk.Scrollbar(self, orient='vertical', command=self.text.yview)
self.text.configure(yscrollcommand=scroll.set)
self.text.pack(side='left', fill='both', expand=True)
scroll.pack(side='right', fill='y')
def write(self, msg):
self.text.insert('end', msg + '\n')
self.text.see('end')
def clear(self):
self.text.delete('1.0', 'end')
# ===== END PART1-C =====
# ===== LINE ULTIMATE PART5-A =====
# Integration & Execution Layer
class ApplicationController:
def __init__(self, exporter=None):
self.exporter = exporter or BatchExporter()
self.running = False
self.progress = 0
def run_batch(
self,
files,
output_dir,
mode='stamp',
padding=5,
effects=None,
pattern='01',
keep_filename=False,
gen_main_tab=True,
create_zip=True,
progress_cb=None,
complete_cb=None
):
self.running = True
self.progress = 0
try:
created = self.exporter.export_folder(
files=files,
output_dir=output_dir,
mode=mode,
padding=padding,
effects=effects,
pattern=pattern,
keep_filename=keep_filename,
gen_main_tab=gen_main_tab,
progress_cb=progress_cb
)
zip_path = None
if create_zip and created:
if progress_cb:
progress_cb(96, 'ZIPパッケージを作成中...')
zip_path = PackageBuilder.build_zip(output_dir, 'LINE_PACKAGE.zip')
validation = PackageBuilder.validate_folder(output_dir, mode=mode)
if complete_cb:
complete_cb(True, {
'files': created,
'zip': zip_path,
'validation': validation,
'output_dir': str(output_dir),
'mode': mode
})
except Exception as e:
logging.error(f'Batch run failed: {e}')
if complete_cb:
complete_cb(False, {'error': str(e)})
finally:
self.running = False
def run_thread(
self,
files,
output_dir,
mode='stamp',
padding=5,
effects=None,
pattern='01',
keep_filename=False,
gen_main_tab=True,
create_zip=True,
progress_cb=None,
complete_cb=None
):
th = threading.Thread(
target=self.run_batch,
kwargs={
'files': files,
'output_dir': output_dir,
'mode': mode,
'padding': padding,
'effects': effects,
'pattern': pattern,
'keep_filename': keep_filename,
'gen_main_tab': gen_main_tab,
'create_zip': create_zip,
'progress_cb': progress_cb,
'complete_cb': complete_cb
},
daemon=True
)
th.start()
return th
class SettingsBridge:
def __init__(self, config):
self.config = config
def load(self):
return self.config.load()
def save(self):
return self.config.save()
class ProgressBridge:
def __init__(self):
self.percent = 0
def update(self, value):
self.percent = value
# ===== END PART5-A =====
# ===== LINE ULTIMATE PART1-B =====
# GUI Foundation Layer (MainWindow)
class MainWindow:
def __init__(self, root, controller=None):
self.root = root
self.controller = controller or ApplicationController()
self.preview_engine = PreviewEngine()
self.root.title(f'{APP_NAME} v{APP_VERSION} - LINE Creators Market 全スタンプ・絵文字 一括変換&ZIP')
self.root.geometry('1300x840')
self.root.minsize(1000, 680)
self.current_preview_image = None
self.selected_job_index = -1
self.bg_mode = tk.StringVar(value='checker')
self.view_mode = tk.StringVar(value='after') # 'after', 'before', 'both'
self.status_text = tk.StringVar(value='準備完了: 画像ファイルまたはフォルダをドロップしてください')
self.create_layout()
self.create_menu()
self.create_toolbar()
self.create_statusbar()
# Connect logger to GUI
CTX.logger.add_listener(self._on_log_message)
# DND Support
DragDropSupport.attach(self.root, self._on_drop_files)
# Load settings
self.settings_panel.load_from_config(CTX.config)
def create_menu(self):
menubar = tk.Menu(self.root)
file_menu = tk.Menu(menubar, tearoff=0)
file_menu.add_command(label='📁 フォルダから画像を追加...', command=self.select_folder, accelerator='Ctrl+O')
file_menu.add_command(label='➕ 画像ファイルを追加...', command=self.select_files)
file_menu.add_separator()
file_menu.add_command(label='📂 出力フォルダを開く', command=self.open_output_dir)
file_menu.add_separator()
file_menu.add_command(label='💾 設定を保存', command=self.settings_panel._save_settings)
file_menu.add_separator()
file_menu.add_command(label='❌ 終了', command=self.root.quit)
menubar.add_cascade(label='ファイル (F)', menu=file_menu)
edit_menu = tk.Menu(menubar, tearoff=0)
edit_menu.add_command(label='🗑️ 全ての画像をクリア', command=self.clear_all_jobs)
edit_menu.add_command(label='選択した画像を削除', command=self.remove_selected_job)
menubar.add_cascade(label='編集 (E)', menu=edit_menu)
tool_menu = tk.Menu(menubar, tearoff=0)
tool_menu.add_command(label='👁️ OpenCV 詳細プレビュー (Before/After)', command=self.show_opencv_preview)
tool_menu.add_command(label='▶️ 一括変換を実行', command=self.start_conversion)
menubar.add_cascade(label='ツール (T)', menu=tool_menu)
help_menu = tk.Menu(menubar, tearoff=0)
help_menu.add_command(label='LINE Creators Market 規格ガイド', command=self._show_spec_guide)
help_menu.add_command(label='🌐 スタンプ制作ガイドライン (公式Web)', command=self._open_line_guideline)
help_menu.add_command(label='🌐 絵文字制作ガイドライン (公式Web)', command=self._open_emoji_guideline)
help_menu.add_separator()
help_menu.add_command(label='バージョン情報', command=self._show_about)
menubar.add_cascade(label='ヘルプ (H)', menu=help_menu)
self.root.config(menu=menubar)
self.root.bind_all('<Control-o>', lambda e: self.select_folder())
def create_toolbar(self):
self.toolbar = ttk.Frame(self.root, padding=4)
self.toolbar.pack(fill='x', side='top')
ttk.Button(self.toolbar, text='📁 フォルダ追加', command=self.select_folder).pack(side='left', padx=2)
ttk.Button(self.toolbar, text='➕ ファイル追加', command=self.select_files).pack(side='left', padx=2)
ttk.Button(self.toolbar, text='🗑️ クリア', command=self.clear_all_jobs).pack(side='left', padx=2)
ttk.Separator(self.toolbar, orient='vertical').pack(side='left', fill='y', padx=8)
self.btn_convert = ttk.Button(
self.toolbar,
text='▶️ 一括変換実行 (Convert)',
command=self.start_conversion
)
self.btn_convert.pack(side='left', padx=2)
ttk.Button(self.toolbar, text='👁️ OpenCVプレビュー', command=self.show_opencv_preview).pack(side='left', padx=4)
ttk.Button(self.toolbar, text='📂 出力先を開く', command=self.open_output_dir).pack(side='left', padx=2)
ttk.Button(self.toolbar, text='🌐 絵文字ガイドライン', command=self._open_emoji_guideline).pack(side='right', padx=2)
ttk.Button(self.toolbar, text='🌐 スタンプガイドライン', command=self._open_line_guideline).pack(side='right', padx=2)
def create_layout(self):
self.main_paned = ttk.PanedWindow(self.root, orient='horizontal')
self.main_paned.pack(fill='both', expand=True, padx=4, pady=2)
# Left: File List Tree
self.left_frame = ttk.Frame(self.main_paned, width=320)
self.file_panel = FileTreePanel(self.left_frame, on_select=self._on_tree_select)
self.file_panel.pack(fill='both', expand=True)
# Center: Preview & Log Viewer
self.center_frame = ttk.Frame(self.main_paned)
self._build_center_preview(self.center_frame)
# Right: Settings Panel
self.right_frame = ttk.Frame(self.main_paned, width=350)
self.settings_panel = SettingsPanel(self.right_frame, on_change=self.update_preview)
self.settings_panel.pack(fill='both', expand=True)
self.main_paned.add(self.left_frame, weight=2)
self.main_paned.add(self.center_frame, weight=5)
self.main_paned.add(self.right_frame, weight=3)
def _build_center_preview(self, parent):
top_ctrl = ttk.Frame(parent)
top_ctrl.pack(fill='x', pady=2)
ttk.Label(top_ctrl, text='表示モード:').pack(side='left', padx=(2, 4))
for text, val in [('変換後 (After)', 'after'), ('元画像 (Before)', 'before'), ('並べて比較 (Both)', 'both')]:
ttk.Radiobutton(top_ctrl, text=text, value=val, variable=self.view_mode, command=self.update_preview).pack(side='left', padx=2)
ttk.Separator(top_ctrl, orient='vertical').pack(side='left', fill='y', padx=8)
ttk.Label(top_ctrl, text='背景:').pack(side='left', padx=(2, 4))
for text, val in [('透過格子', 'checker'), ('白', 'white'), ('黒', 'black'), ('LINEトーク', 'line')]:
ttk.Radiobutton(top_ctrl, text=text, value=val, variable=self.bg_mode, command=self.update_preview).pack(side='left', padx=2)
# Canvas Frame
self.canvas_frame = ttk.Frame(parent)
self.canvas_frame.pack(fill='both', expand=True, pady=2)
self.canvas = tk.Canvas(self.canvas_frame, bg='#252526', highlightthickness=1, highlightbackground='#404040')
self.canvas.pack(fill='both', expand=True)
self.canvas.bind('<Configure>', lambda e: self.update_preview())
# Bottom Log Viewer
self.log_viewer = LogViewer(parent)
self.log_viewer.pack(fill='x', side='bottom', pady=(4, 0))
def create_statusbar(self):
bottom = ttk.Frame(self.root, padding=2)
bottom.pack(fill='x', side='bottom')
self.progress = ttk.Progressbar(bottom, mode='determinate', length=200)
self.progress.pack(side='right', padx=6, pady=2)
self.lbl_status = ttk.Label(bottom, textvariable=self.status_text, anchor='w')
self.lbl_status.pack(side='left', fill='x', expand=True, padx=4)
# --- File Management ---
def select_folder(self):
folder = filedialog.askdirectory(title='画像フォルダを選択')
if folder:
self.load_folder(folder)
def select_files(self):
files = filedialog.askopenfilenames(
title='画像ファイルを選択',
filetypes=[('Supported Images', '*.png;*.jpg;*.jpeg;*.webp;*.gif;*.bmp'), ('All Files', '*.*')]
)
if files:
self.add_files(files)
def load_folder(self, folder):
files = FileScanner.scan(folder)
if not files:
messagebox.showinfo('通知', '指定フォルダ内に対象となる画像が見つかりませんでした。')
return
self.add_files(files)
if not self.settings_panel.output_folder.get():
default_out = os.path.join(folder, 'line_output')
self.settings_panel.output_folder.set(default_out)
def add_files(self, file_paths):
added = 0
for f in file_paths:
if FileScanner.is_supported(f):
if CTX.jobs.add_job(f):
added += 1
mode = self.settings_panel.get_mode()
self.file_panel.set_items(CTX.jobs.jobs, mode=mode)
self.status_text.set(f'{added} 件の画像を追加しました (合計: {CTX.jobs.count()} 件)')
CTX.logger.info(f'{added} files added. Total jobs: {CTX.jobs.count()}')
if self.selected_job_index == -1 and CTX.jobs.count() > 0:
self.file_panel.select_index(0)
def clear_all_jobs(self):
CTX.jobs.clear()
mode = self.settings_panel.get_mode()
self.file_panel.set_items([], mode=mode)
self.selected_job_index = -1
self.current_preview_image = None
self.canvas.delete('all')
self.status_text.set('画像をすべてクリアしました')
def remove_selected_job(self):
if self.selected_job_index >= 0:
CTX.jobs.remove_job(self.selected_job_index)
mode = self.settings_panel.get_mode()
self.file_panel.set_items(CTX.jobs.jobs, mode=mode)
if CTX.jobs.count() > 0:
idx = min(self.selected_job_index, CTX.jobs.count() - 1)
self.file_panel.select_index(idx)
else:
self.selected_job_index = -1
self.canvas.delete('all')
def _on_drop_files(self, event):
raw_data = event.data
if not raw_data:
return
files = self.root.tk.splitlist(raw_data)
to_add = []
for p in files:
p_obj = Path(p)
if p_obj.is_dir():
to_add.extend(FileScanner.scan(str(p_obj)))
elif p_obj.is_file() and FileScanner.is_supported(p):
to_add.append(str(p_obj))
if to_add:
self.add_files(to_add)
def _on_tree_select(self, index):
self.selected_job_index = index
self.update_preview()
# --- Preview Engine & Canvas Rendering ---
def update_preview(self):
mode = self.settings_panel.get_mode()
self.file_panel.set_items(CTX.jobs.jobs, mode=mode)
if self.selected_job_index < 0 or self.selected_job_index >= CTX.jobs.count():
self.canvas.delete('all')
self.canvas.create_text(
self.canvas.winfo_width() // 2,
self.canvas.winfo_height() // 2,
text='画像を選択するとここにプレビューが表示されます',
fill='#888888',
font=('Segoe UI', 12)
)
return
job = CTX.jobs.jobs[self.selected_job_index]
file_path = job['path']
try:
raw_img = ImageProcessor.load_image(file_path)
padding = self.settings_panel.padding_var.get()
eff = self.settings_panel.get_effects()
processed = EffectProcessor.apply_effects(
raw_img,
white_outline=eff['white_outline'],
black_outline=eff['black_outline'],
shadow=eff['shadow'],
outline_size=eff['outline_size'],
trim=eff['trim_transparent']
)
after_img = ImageProcessor.process_for_spec(processed, mode=mode, padding=padding, trim=False)
self._draw_on_canvas(raw_img, after_img, mode=mode)
except Exception as e:
logging.error(f'Preview generation error: {e}')
def _create_checker_image(self, width, height, size=12):
img = Image.new('RGBA', (width, height), (255, 255, 255, 255))
pixels = img.load()
for y in range(height):
for x in range(width):
c = (230, 230, 230, 255) if ((x // size) + (y // size)) % 2 == 0 else (190, 190, 190, 255)
pixels[x, y] = c
return img
def _draw_on_canvas(self, before_img, after_img, mode='stamp'):
can_w = max(100, self.canvas.winfo_width())
can_h = max(100, self.canvas.winfo_height())
vmode = self.view_mode.get()
bgmode = self.bg_mode.get()
spec = LineMode.get_spec(mode)
if vmode == 'before':
target_img = before_img
label_text = '元画像 (Before)'
elif vmode == 'after':
target_img = after_img
label_text = f'変換後 ({spec["label"]})'
else:
self._draw_both_on_canvas(before_img, after_img, can_w, can_h, bgmode, spec['label'])
return
rendered = self._render_with_background(target_img, bgmode)
ratio = min((can_w - 60) / rendered.width, (can_h - 60) / rendered.height, 2.0)
ratio = max(0.1, ratio)
disp_w = int(rendered.width * ratio)
disp_h = int(rendered.height * ratio)
disp_img = rendered.resize((disp_w, disp_h), Image.Resampling.LANCZOS)
self._tk_img = ImageTk.PhotoImage(disp_img)
self.canvas.delete('all')
cx, cy = can_w // 2, can_h // 2
self.canvas.create_image(cx, cy, image=self._tk_img)
self.canvas.create_rectangle(
cx - disp_w // 2, cy - disp_h // 2,
cx + disp_w // 2, cy + disp_h // 2,
outline='#00bcd4', width=2
)
self.canvas.create_text(
cx, cy + disp_h // 2 + 18,
text=f'{label_text} [{target_img.width}x{target_img.height}]',
fill='#e0e0e0', font=('Segoe UI', 10, 'bold')
)
def _draw_both_on_canvas(self, before_img, after_img, can_w, can_h, bgmode, mode_label):
left = self._render_with_background(before_img, bgmode)
right = self._render_with_background(after_img, bgmode)
combined_w = left.width + right.width + 40
combined_h = max(left.height, right.height)
ratio = min((can_w - 60) / combined_w, (can_h - 80) / combined_h, 2.0)
ratio = max(0.1, ratio)
lw, lh = int(left.width * ratio), int(left.height * ratio)
rw, rh = int(right.width * ratio), int(right.height * ratio)
l_disp = left.resize((lw, lh), Image.Resampling.LANCZOS)
r_disp = right.resize((rw, rh), Image.Resampling.LANCZOS)
canvas_comp = Image.new('RGBA', (can_w, can_h), (37, 37, 38, 255))
gap = 30
total_w = lw + rw + gap
start_x = (can_w - total_w) // 2
cy = can_h // 2
canvas_comp.paste(l_disp, (start_x, cy - lh // 2))
canvas_comp.paste(r_disp, (start_x + lw + gap, cy - rh // 2))
self._tk_img = ImageTk.PhotoImage(canvas_comp)
self.canvas.delete('all')
self.canvas.create_image(can_w // 2, can_h // 2, image=self._tk_img)
self.canvas.create_text(start_x + lw // 2, cy + lh // 2 + 16, text=f'Before (元画像: {before_img.width}x{before_img.height})', fill='#e0e0e0', font=('Segoe UI', 9, 'bold'))
self.canvas.create_text(start_x + lw + gap + rw // 2, cy + rh // 2 + 16, text=f'After ({mode_label}: {after_img.width}x{after_img.height})', fill='#4caf50', font=('Segoe UI', 9, 'bold'))
def _render_with_background(self, img, bgmode):
w, h = img.size
if bgmode == 'white':
bg = Image.new('RGBA', (w, h), (255, 255, 255, 255))
elif bgmode == 'black':
bg = Image.new('RGBA', (w, h), (0, 0, 0, 255))
elif bgmode == 'line':
bg = Image.new('RGBA', (w, h), (122, 197, 216, 255))
else:
bg = self._create_checker_image(w, h, size=12)
return Image.alpha_composite(bg, img)
def show_opencv_preview(self):
if not CV2_AVAILABLE:
messagebox.showwarning('OpenCV未検出', 'OpenCV (cv2) がインストールされていないため別ウィンドウプレビューは利用できません。')
return
if self.selected_job_index < 0 or self.selected_job_index >= CTX.jobs.count():
messagebox.showinfo('通知', 'プレビューする画像をリストから選択してください。')
return
job = CTX.jobs.jobs[self.selected_job_index]
file_path = job['path']
raw_img = ImageProcessor.load_image(file_path)
mode = self.settings_panel.get_mode()
padding = self.settings_panel.padding_var.get()
eff = self.settings_panel.get_effects()
processed = EffectProcessor.apply_effects(
raw_img,
white_outline=eff['white_outline'],
black_outline=eff['black_outline'],
shadow=eff['shadow'],
outline_size=eff['outline_size'],
trim=eff['trim_transparent']
)
after_img = ImageProcessor.process_for_spec(processed, mode=mode, padding=padding, trim=False)
threading.Thread(
target=self.preview_engine.show_before_after,
args=(raw_img, after_img),
daemon=True
).start()
# --- Batch Conversion Execution ---
def start_conversion(self):
if CTX.jobs.count() == 0:
messagebox.showwarning('警告', '変換対象の画像が登録されていません。\nフォルダまたは画像ファイルを追加してください。')
return
out_dir = self.settings_panel.output_folder.get().strip()
if not out_dir:
out_dir = filedialog.askdirectory(title='保存先フォルダを選択してください')
if not out_dir:
return
self.settings_panel.output_folder.set(out_dir)
mode = self.settings_panel.get_mode()
padding = self.settings_panel.padding_var.get()
eff = self.settings_panel.get_effects()
pat = self.settings_panel.get_pattern()
gen_main_tab = self.settings_panel.gen_main_tab_var.get()
create_zip = self.settings_panel.zip_output.get()
files = CTX.jobs.get_paths()
self.btn_convert.config(state='disabled')
self.progress['value'] = 0
self.status_text.set('変換を開始します...')
def progress_cb(pct, msg):
self.root.after(0, lambda: self._update_progress_ui(pct, msg))
def complete_cb(success, data):
self.root.after(0, lambda: self._on_conversion_complete(success, data))
self.controller.run_thread(
files=files,
output_dir=out_dir,
mode=mode,
padding=padding,
effects=eff,
pattern=pat,
keep_filename=(pat == 'original'),
gen_main_tab=gen_main_tab,
create_zip=create_zip,
progress_cb=progress_cb,
complete_cb=complete_cb
)
def _update_progress_ui(self, pct, msg):
self.progress['value'] = pct
self.status_text.set(msg)
CTX.logger.info(msg)
def _on_conversion_complete(self, success, data):
self.btn_convert.config(state='normal')
self.progress['value'] = 100
if success:
file_count = len(data.get('files', []))
zip_p = data.get('zip')
out_d = data.get('output_dir')
val = data.get('validation', {})
mode = data.get('mode', 'stamp')
spec = LineMode.get_spec(mode)
msg = f'変換が正常に完了しました!\n\n'
msg += f'・規格タイプ: {spec["label"]}\n'
msg += f'・出力ファイル総数: {file_count} 枚\n'
msg += f'・出力先: {out_d}\n\n'
# ファイル構成チェック
msg += '【ファイル構成チェック】\n'
if val.get('count_ok'):
msg += f'✅ コンテンツ画像数: {val.get("content_count")} 個 (規定: {spec["allowed_counts"]}個 適合)\n'
else:
msg += f'⚠️ コンテンツ画像数: {val.get("content_count")} 個 (規定数 {spec["allowed_counts"]}個 と異なります)\n'
if spec.get('has_dual_content'):
suffix = spec.get('dual_suffix')
if val.get('dual_count_match'):
msg += f'✅ 通常画像と {suffix}.png 画像が同数ペア生成されました\n'
else:
msg += f'⚠️ 通常画像と {suffix}.png 画像のペア数不一致\n'
if spec['need_main']:
msg += '✅ main.png (240x240) 生成済\n' if val.get('has_main') else '❌ main.png 未生成\n'
else:
msg += 'ℹ️ main.png: 不要のため未生成 (絵文字公式仕様)\n'
for extra_name, is_ok in val.get('extra_checks', {}).items():
msg += f'✅ {extra_name} 生成済\n' if is_ok else f'❌ {extra_name} 未生成\n'
if spec['need_tab']:
msg += '✅ tab.png (96x74) 生成済\n' if val.get('has_tab') else '❌ tab.png 未生成\n'
if zip_p:
sz = round(PackageBuilder.zip_size_mb(zip_p), 2)
zip_status = '✅ 20MB以下' if sz <= 20.0 else '⚠️ 20MB超過(要確認)'
msg += f'\n・ZIPパッケージ: {Path(zip_p).name} ({sz} MB - {zip_status})\n'
over_limit = [f"{v['file']} ({v['size_mb']}MB > {v['limit_mb']}MB)" for v in val.get('files', []) if not v['ok']]
if over_limit:
msg += f'\n⚠️ 警告: 容量制限を超過した画像があります:\n' + '\n'.join(over_limit)
self.status_text.set(f'完了: {file_count} ファイルを出力しました')
CTX.logger.info(f'Conversion finished successfully. Output: {out_d}')
if messagebox.askyesno('変換完了', msg + '\n出力フォルダを開きますか?'):
self.open_output_dir()
else:
err = data.get('error', '不明なエラー')
self.status_text.set('エラーにより変換が中断しました')
messagebox.showerror('変換エラー', f'処理中にエラーが発生しました:\n{err}')
def open_output_dir(self):
folder = self.settings_panel.output_folder.get().strip()
if folder and os.path.exists(folder):
if sys.platform == 'win32':
os.startfile(folder)
elif sys.platform == 'darwin':
subprocess.Popen(['open', folder])
else:
subprocess.Popen(['xdg-open', folder])
else:
messagebox.showinfo('通知', '出力フォルダがまだ作成されていないか指定されていません。')
def _on_log_message(self, level, text):
self.root.after(0, lambda: self.log_viewer.write(f'[{level}] {text}'))
def _open_line_guideline(self):
webbrowser.open(LINE_GUIDELINE_URL)
def _open_emoji_guideline(self):
webbrowser.open(LINE_EMOJI_GUIDELINE_URL)
def _show_spec_guide(self):
guide = (
"【LINE Creators Market 公式画像規格・ファイル構成ガイド】\n"
"詳細: https://creator.line.me/ja/guideline/\n"
"絵文字: https://creator.line.me/ja/guideline/emoji/\n\n"
"■ 通常スタンプ\n"
" ・main.png (240x240)\n"
" ・tab.png (96x74)\n"
" ・01.png ~ 40.png (最大370x320 偶数)\n"
" ・個数: 8 / 16 / 24 / 32 / 40 個\n\n"
"■ アニメーションスタンプ\n"
" ・main.png (240x240)\n"
" ・tab.png (96x74)\n"
" ・01.png ~ 24.png (最大320x270 偶数)\n"
" ・個数: 8 / 16 / 24 個\n\n"
"■ BIGスタンプ\n"
" ・main.png (240x240)\n"
" ・tab.png (96x74)\n"
" ・01.png ~ 40.png (最大660x540 偶数)\n"
" ・個数: 8 / 16 / 24 / 32 / 40 個\n\n"
"■ ポップアップスタンプ\n"
" ・main.png (240x240)\n"
" ・main_animation.png (240x240)\n"
" ・tab.png (96x74)\n"
" ・01.png ~ 24.png (最大370x320 偶数)\n"
" ・01_popup.png ~ 24_popup.png (480x480 固定)\n"
" ・個数: 8 / 16 / 24 個\n\n"
"■ エフェクトスタンプ\n"
" ・main.png (240x240)\n"
" ・main_effect.png (240x240)\n"
" ・tab.png (96x74)\n"
" ・01.png ~ 24.png (最大370x320 偶数)\n"
" ・01_effect.png ~ 24_effect.png (480x480 固定)\n"
" ・個数: 8 / 16 / 24 個\n\n"
"■ メッセージスタンプ\n"
" ・main.png (240x240)\n"
" ・tab.png (96x74)\n"
" ・01.png ~ 24.png (最大370x320 偶数)\n"
" ・個数: 8 / 16 / 24 個\n\n"
"■ LINE絵文字 (静止画)\n"
" ・tab.png (96x74) ※main.png不要\n"
" ・001.png ~ 040.png (180x180 固定 / 数字3桁連番)\n"
" ・個数: 8 / 16 / 24 / 32 / 40 個 (最大250個)\n\n"
"■ アニメーション絵文字\n"
" ・tab.png (96x74) ※main.png不要\n"
" ・001.png ~ 040.png (180x180 固定 / 数字3桁連番)\n"
" ・容量制限: 1ファイルあたり 300KB以下\n"
" ・個数: 8 / 16 / 24 / 32 / 40 個\n\n"
"■ 共通規定\n"
" ・形式: PNG (透過RGBA)\n"
" ・ZIPパッケージ最大容量: 20MB以下"
)
messagebox.showinfo('LINE公式ガイドライン規格ガイド', guide)
def _show_about(self):
messagebox.showinfo(
f'{APP_NAME} について',
f'{APP_NAME} v{APP_VERSION}\n\n'
'LINE Creators Market ガイドライン公式準拠の制作支援ツールです。\n'
'・通常スタンプ (01〜40.png / main / tab)\n'
'・アニメスタンプ (01〜24.png / main / tab)\n'
'・BIGスタンプ (01〜40.png / main / tab)\n'
'・ポップアップスタンプ (01〜24.png + 01_popup〜24_popup.png / main / main_animation / tab)\n'
'・エフェクトスタンプ (01〜24.png + 01_effect〜24_effect.png / main / main_effect / tab)\n'
'・メッセージスタンプ (01〜24.png / main / tab)\n'
'・LINE絵文字 (001〜040.png 数字3桁 / tabのみ / main不要)\n'
'・アニメーション絵文字 (001〜040.png 数字3桁 / tabのみ / 300KB制限)\n'
'・自動透過トリミング & 白フチ/黒フチ/シャドウ\n'
'・偶数化処理 & 1MB/300KB制限自動最適化\n'
'・LINE_PACKAGE.zip パッケージング'
)
# ===== END PART1-B =====
# ===== LINE ULTIMATE PART5-B =====
# Final Entry Point / Application Bootstrap
class EntryPoint:
def __init__(self):
self.initialized = False
self.preview_engine = None
self.exporter = None
self.settings = None
def initialize(self):
self.initialized = True
return True
def connect_preview(self, preview_engine):
self.preview_engine = preview_engine
def connect_exporter(self, exporter):
self.exporter = exporter
def connect_settings(self, settings):
self.settings = settings
def run(self):
if not self.initialized:
self.initialize()
return True
class ExeSupport:
@staticmethod
def is_frozen():
return getattr(sys, 'frozen', False)
@staticmethod
def application_path():
if ExeSupport.is_frozen():
return os.path.dirname(sys.executable)
return os.path.dirname(os.path.abspath(__file__))
# ===== END PART5-B =====
# ===== LINE ULTIMATE PART6-A =====
# Refactoring / Merge Layer
class DependencyContainer:
def __init__(self):
self.services = {}
def register(self, name, obj):
self.services[name] = obj
def resolve(self, name):
return self.services.get(name)
class ModuleIntegrator:
def __init__(self):
self.modules = []
def add(self, module_name):
self.modules.append(module_name)
def list_modules(self):
return self.modules
class RuntimeValidator:
REQUIRED = [
'ConfigManager',
'ImageProcessor',
'EffectProcessor',
'BatchExporter',
'PackageBuilder',
'PreviewEngine'
]
def validate(self, namespace):
missing = []
for name in self.REQUIRED:
if name not in namespace:
missing.append(name)
return missing
class UltimateAssembler:
def __init__(self):
self.container = DependencyContainer()
self.integrator = ModuleIntegrator()
def register_core(self, name, obj):
self.container.register(name, obj)
def build(self):
return True
# ===== END PART6-A =====
# ===== LINE ULTIMATE PART6-B =====
# Final Merge Entry
class FinalApplication:
def __init__(self):
self.ready = False
self.assembler = UltimateAssembler()
self.root = None
self.main_window = None
def load_modules(self):
self.assembler.register_core('config', CTX.config)
self.assembler.register_core('logger', CTX.logger)
self.assembler.register_core('processor', ImageProcessor())
self.assembler.register_core('effects', EffectProcessor())
self.assembler.register_core('exporter', BatchExporter())
self.assembler.register_core('package_builder', PackageBuilder())
self.assembler.register_core('controller', ApplicationController())
self.ready = True
return True
def start(self):
if not self.ready:
self.load_modules()
if TKDND_AVAILABLE:
try:
self.root = TkinterDnD.Tk()
except Exception:
self.root = tk.Tk()
else:
self.root = tk.Tk()
controller = self.assembler.container.resolve('controller')
self.main_window = MainWindow(self.root, controller=controller)
print(f'{APP_NAME} v{APP_VERSION} Started')
self.root.mainloop()
def build_line_ultimate():
app = FinalApplication()
app.load_modules()
return app
if __name__ == '__main__':
build_line_ultimate().start()
# ===== END PART6-B =====
# -*- coding: utf-8 -*-
# ===== LINE ULTIMATE PART1-A FRONT =====
# Core Foundation Layer
import os
import sys
import io
import json
import logging
import threading
import queue
import zipfile
import subprocess
import webbrowser
from pathlib import Path
from datetime import datetime
APP_NAME = 'LINE Ultimate'
APP_VERSION = '1.2'
LINE_GUIDELINE_URL = 'https://creator.line.me/ja/guideline/'
LINE_EMOJI_GUIDELINE_URL = 'https://creator.line.me/ja/guideline/emoji/'
# LINE official specs
LINE_STAMP_SIZE = (370, 320) # max WxH, both even
LINE_BIG_STAMP_SIZE = (660, 540) # max WxH, both even
LINE_POPUP_SIZE = (480, 480) # fixed WxH
LINE_ANIM_SIZE = (320, 270) # max WxH, both even
LINE_EMOJI_SIZE = (180, 180) # fixed WxH
LINE_MAIN_SIZE = (240, 240) # fixed WxH
LINE_TAB_SIZE = (96, 74) # fixed WxH
SUPPORTED_EXTS = {
'.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp'
}
class ConfigManager:
DEFAULTS = {
'mode': 'stamp',
'padding_percent': 5,
'trim_transparent': True,
'white_outline': False,
'black_outline': False,
'shadow': False,
'outline_size': 4,
'output_pattern': '01',
'keep_filename': False,
'zip_output': True,
'gen_main_tab': True,
'bg_preview': 'checker',
'output_folder': ''
}
def __init__(self, filename='line_ultimate_settings.json'):
self.filename = filename
self.data = self.DEFAULTS.copy()
self.load()
def load(self):
if os.path.exists(self.filename):
try:
with open(self.filename, 'r', encoding='utf-8') as f:
saved = json.load(f)
self.data.update(saved)
except Exception as e:
logging.warning(f'Failed to load config: {e}')
return self.data
def save(self):
try:
with open(self.filename, 'w', encoding='utf-8') as f:
json.dump(self.data, f, ensure_ascii=False, indent=2)
return True
except Exception as e:
logging.error(f'Failed to save config: {e}')
return False
def get(self, key, default=None):
return self.data.get(key, default)
def set(self, key, value):
self.data[key] = value
class LogManager:
def __init__(self, logfile='line_ultimate.log'):
self.logfile = logfile
self.listeners = []
logging.basicConfig(
filename=logfile,
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
encoding='utf-8',
force=True
)
self.info(f'=== {APP_NAME} v{APP_VERSION} Started ===')
def add_listener(self, callback):
if callback not in self.listeners:
self.listeners.append(callback)
def info(self, text):
logging.info(text)
self._notify('INFO', text)
def warning(self, text):
logging.warning(text)
self._notify('WARN', text)
def error(self, text):
logging.error(text)
self._notify('ERROR', text)
def _notify(self, level, text):
for listener in self.listeners:
try:
listener(level, text)
except Exception:
pass
class ImageCache:
def __init__(self, max_items=128):
self.cache = {}
self.max_items = max_items
def add(self, key, value):
if len(self.cache) >= self.max_items:
first_key = next(iter(self.cache))
del self.cache[first_key]
self.cache[key] = value
def get(self, key):
return self.cache.get(key)
def clear(self):
self.cache.clear()
class JobManager:
def __init__(self):
self.jobs = []
def add_job(self, filepath):
path_obj = Path(filepath)
if not path_obj.exists():
return None
for j in self.jobs:
if j['path'] == str(path_obj):
return None
size_kb = round(path_obj.stat().st_size / 1024, 1)
res_str = 'Unknown'
try:
if PIL_AVAILABLE:
with Image.open(filepath) as img:
res_str = f'{img.width}x{img.height}'
except Exception:
pass
job_info = {
'path': str(path_obj),
'name': path_obj.name,
'ext': path_obj.suffix.lower(),
'size_kb': size_kb,
'res': res_str
}
self.jobs.append(job_info)
return job_info
def remove_job(self, index):
if 0 <= index < len(self.jobs):
return self.jobs.pop(index)
return None
def clear(self):
self.jobs.clear()
def count(self):
return len(self.jobs)
def get_paths(self):
return [j['path'] for j in self.jobs]
class FileScanner:
@staticmethod
def scan(folder):
files = []
for root, dirs, names in os.walk(folder):
for n in names:
if Path(n).suffix.lower() in SUPPORTED_EXTS:
files.append(os.path.join(root, n))
return sorted(files)
@staticmethod
def is_supported(path):
return Path(path).suffix.lower() in SUPPORTED_EXTS
class RuntimeContext:
def __init__(self):
self.config = ConfigManager()
self.logger = LogManager()
self.cache = ImageCache()
self.jobs = JobManager()
self.started = datetime.now()
CTX = RuntimeContext()
# ===== END PART1-A FRONT =====
# ===== LINE ULTIMATE PART1-A BACK =====
# Runtime / Thread / Utility Layer
try:
import cv2
import numpy as np
CV2_AVAILABLE = True
except Exception:
CV2_AVAILABLE = False
try:
from PIL import Image, ImageTk, ImageFilter, ImageOps
PIL_AVAILABLE = True
except Exception:
PIL_AVAILABLE = False
try:
import tkinterdnd2
from tkinterdnd2 import DND_FILES, TkinterDnD
TKDND_AVAILABLE = True
except Exception:
TKDND_AVAILABLE = False
class ThreadManager:
def __init__(self):
self.threads = []
def start(self, target, *args, **kwargs):
th = threading.Thread(target=target, args=args, kwargs=kwargs, daemon=True)
th.start()
self.threads.append(th)
return th
class ProgressManager:
def __init__(self):
self.value = 0
self.maximum = 100
self.message = ''
def reset(self):
self.value = 0
self.message = ''
def set(self, value, message=''):
self.value = value
self.message = message
class ThumbnailCache:
def __init__(self):
self.items = {}
def put(self, path, image):
self.items[path] = image
def get(self, path):
return self.items.get(path)
def clear(self):
self.items.clear()
class EventBus:
def __init__(self):
self.q = queue.Queue()
def send(self, name, data=None):
self.q.put((name, data))
def receive(self):
try:
return self.q.get_nowait()
except Exception:
return None
class LineMode:
"""
LINE Creators Market 制作ガイドライン完全準拠モード定義
(https://creator.line.me/ja/guideline/ & https://creator.line.me/ja/guideline/emoji/)
"""
STAMP = 'stamp'
ANIM_STAMP = 'anim_stamp'
BIG_STAMP = 'big_stamp'
POPUP_STAMP = 'popup_stamp'
EFFECT_STAMP = 'effect_stamp'
MESSAGE_STAMP = 'message_stamp'
EMOJI = 'emoji'
ANIM_EMOJI = 'anim_emoji'
SPECS = {
'stamp': {
'label': '通常スタンプ',
'size': LINE_STAMP_SIZE,
'fit_exact': False,
'force_even': True,
'default_padding': 5,
'default_pattern': '01',
'need_main': True,
'need_tab': True,
'extra_mains': [],
'has_dual_content': False,
'allowed_counts': [8, 16, 24, 32, 40],
'max_filesize_mb': 1.0,
'file_structure': [
'main.png (240x240)',
'tab.png (96x74)',
'01.png ~ 40.png (最大370x320 偶数)'
],
'desc': '【通常スタンプ】main.png / tab.png / 01.png〜40.png (最大370x320 偶数)'
},
'anim_stamp': {
'label': 'アニメスタンプ',
'size': LINE_ANIM_SIZE,
'fit_exact': False,
'force_even': True,
'default_padding': 5,
'default_pattern': '01',
'need_main': True,
'need_tab': True,
'extra_mains': [],
'has_dual_content': False,
'allowed_counts': [8, 16, 24],
'max_filesize_mb': 1.0,
'file_structure': [
'main.png (240x240)',
'tab.png (96x74)',
'01.png ~ 24.png (最大320x270 偶数)'
],
'desc': '【アニメスタンプ】main.png / tab.png / 01.png〜24.png (最大320x270 偶数)'
},
'big_stamp': {
'label': 'BIGスタンプ',
'size': LINE_BIG_STAMP_SIZE,
'fit_exact': False,
'force_even': True,
'default_padding': 5,
'default_pattern': '01',
'need_main': True,
'need_tab': True,
'extra_mains': [],
'has_dual_content': False,
'allowed_counts': [8, 16, 24, 32, 40],
'max_filesize_mb': 1.0,
'file_structure': [
'main.png (240x240)',
'tab.png (96x74)',
'01.png ~ 40.png (最大660x540 偶数)'
],
'desc': '【BIGスタンプ】main.png / tab.png / 01.png〜40.png (最大660x540 偶数)'
},
'popup_stamp': {
'label': 'ポップアップスタンプ',
'size': LINE_STAMP_SIZE,
'popup_size': LINE_POPUP_SIZE,
'fit_exact': False,
'force_even': True,
'default_padding': 5,
'default_pattern': '01',
'need_main': True,
'need_tab': True,
'extra_mains': [('main_animation.png', LINE_MAIN_SIZE)],
'has_dual_content': True,
'dual_suffix': '_popup',
'allowed_counts': [8, 16, 24],
'max_filesize_mb': 1.0,
'file_structure': [
'main.png (240x240)',
'main_animation.png (240x240)',
'tab.png (96x74)',
'01.png ~ 24.png (最大370x320 偶数)',
'01_popup.png ~ 24_popup.png (480x480 固定)'
],
'desc': '【ポップアップ】main.png / main_animation.png / tab.png / 01〜24.png / 01_popup〜24_popup.png'
},
'effect_stamp': {
'label': 'エフェクトスタンプ',
'size': LINE_STAMP_SIZE,
'popup_size': LINE_POPUP_SIZE,
'fit_exact': False,
'force_even': True,
'default_padding': 5,
'default_pattern': '01',
'need_main': True,
'need_tab': True,
'extra_mains': [('main_effect.png', LINE_MAIN_SIZE)],
'has_dual_content': True,
'dual_suffix': '_effect',
'allowed_counts': [8, 16, 24],
'max_filesize_mb': 1.0,
'file_structure': [
'main.png (240x240)',
'main_effect.png (240x240)',
'tab.png (96x74)',
'01.png ~ 24.png (最大370x320 偶数)',
'01_effect.png ~ 24_effect.png (480x480 固定)'
],
'desc': '【エフェクト】main.png / main_effect.png / tab.png / 01〜24.png / 01_effect〜24_effect.png'
},
'message_stamp': {
'label': 'メッセージスタンプ',
'size': LINE_STAMP_SIZE,
'fit_exact': False,
'force_even': True,
'default_padding': 5,
'default_pattern': '01',
'need_main': True,
'need_tab': True,
'extra_mains': [],
'has_dual_content': False,
'allowed_counts': [8, 16, 24],
'max_filesize_mb': 1.0,
'file_structure': [
'main.png (240x240)',
'tab.png (96x74)',
'01.png ~ 24.png (最大370x320 偶数)'
],
'desc': '【メッセージスタンプ】main.png / tab.png / 01.png〜24.png (最大370x320 偶数・テキスト入力対応)'
},
'emoji': {
'label': 'LINE絵文字 (静止画)',
'size': LINE_EMOJI_SIZE,
'fit_exact': True,
'force_even': False,
'default_padding': 0,
'default_pattern': '001',
'need_main': False, # 絵文字はmain.png不要!
'need_tab': True,
'extra_mains': [],
'has_dual_content': False,
'allowed_counts': [8, 16, 24, 32, 40],
'max_filesize_mb': 1.0,
'file_structure': [
'tab.png (96x74) ※main不要',
'001.png ~ 040.png (180x180 固定 / 数字3桁)'
],
'desc': '【LINE絵文字】001.png〜040.png (180x180 数字3桁) / tab.png (96x74) ※main.png不要'
},
'anim_emoji': {
'label': 'アニメーション絵文字',
'size': LINE_EMOJI_SIZE,
'fit_exact': True,
'force_even': False,
'default_padding': 0,
'default_pattern': '001',
'need_main': False, # 絵文字はmain.png不要!
'need_tab': True,
'extra_mains': [],
'has_dual_content': False,
'allowed_counts': [8, 16, 24, 32, 40],
'max_filesize_mb': 0.3, # 300KB以下 (公式レギュレーション)
'file_structure': [
'tab.png (96x74) ※main不要',
'001.png ~ 040.png (180x180 固定 / 数字3桁 / 300KB以下)'
],
'desc': '【アニメ絵文字】001.png〜040.png (180x180 数字3桁 / 300KB以下) / tab.png ※main不要'
}
}
@classmethod
def get_spec(cls, mode):
return cls.SPECS.get(mode, cls.SPECS[cls.STAMP])
@classmethod
def canvas_size(cls, mode):
return cls.get_spec(mode)['size']
def format_sequence(index, prefix='01'):
if prefix == '001':
return f'{index:03d}'
elif prefix == '01':
return f'{index:02d}'
elif prefix == '000001':
return f'{index:06d}'
return f'A{index:06d}'
def ensure_folder(path):
os.makedirs(path, exist_ok=True)
THREADS = ThreadManager()
PROGRESS = ProgressManager()
THUMBS = ThumbnailCache()
EVENTS = EventBus()
# ===== END PART1-A BACK =====
# ===== LINE ULTIMATE PART2-A =====
# Image Processing Core
def make_even(n: int) -> int:
"""LINE sticker rule: width and height must both be even numbers (min 2)"""
n = max(2, int(n))
return n if n % 2 == 0 else n - 1
class ImageProcessor:
@staticmethod
def load_image(path):
img = Image.open(path)
return img.convert('RGBA')
@staticmethod
def trim_transparent(img, margin=0):
bbox = img.getbbox()
if bbox:
cropped = img.crop(bbox)
if margin > 0:
new_w = cropped.width + margin * 2
new_h = cropped.height + margin * 2
canvas = Image.new('RGBA', (new_w, new_h), (0, 0, 0, 0))
canvas.paste(cropped, (margin, margin))
return canvas
return cropped
return img
@staticmethod
def fit_canvas(img, size, padding_percent=0, fit_exact=True, force_even=True):
"""
Resize image preserving aspect ratio.
If fit_exact=True: embed in a transparent canvas of exact `size`.
If fit_exact=False: return resized image itself (ensuring even dimensions if force_even).
"""
w, h = size
scale_w = max(2, int(w * (100 - padding_percent) / 100))
scale_h = max(2, int(h * (100 - padding_percent) / 100))
ratio = min(scale_w / img.width, scale_h / img.height)
new_w = max(2, round(img.width * ratio))
new_h = max(2, round(img.height * ratio))
if force_even:
new_w = make_even(new_w)
new_h = make_even(new_h)
new_w = min(new_w, w)
new_h = min(new_h, h)
tmp = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
if fit_exact:
canvas = Image.new('RGBA', size, (0, 0, 0, 0))
x = (w - tmp.width) // 2
y = (h - tmp.height) // 2
canvas.paste(tmp, (x, y), tmp)
return canvas
else:
return tmp
@classmethod
def process_for_spec(cls, img, mode='stamp', padding=0, trim=True, use_popup_size=False):
"""Process image strictly according to LINE mode specifications"""
spec = LineMode.get_spec(mode)
if trim:
img = cls.trim_transparent(img)
target_size = spec.get('popup_size', spec['size']) if use_popup_size else spec['size']
fit_exact = True if use_popup_size else spec['fit_exact']
force_even = False if use_popup_size else spec['force_even']
return cls.fit_canvas(
img,
target_size,
padding_percent=padding,
fit_exact=fit_exact,
force_even=force_even
)
@classmethod
def create_stamp(cls, img, padding=5, trim=True, fit_exact=False):
if trim:
img = cls.trim_transparent(img)
return cls.fit_canvas(img, LINE_STAMP_SIZE, padding_percent=padding, fit_exact=fit_exact, force_even=True)
@classmethod
def create_emoji(cls, img, padding=0, trim=True):
"""LINE emoji: 180x180 px fixed transparent canvas, margin typically 0"""
if trim:
img = cls.trim_transparent(img)
return cls.fit_canvas(img, LINE_EMOJI_SIZE, padding_percent=padding, fit_exact=True, force_even=False)
@classmethod
def create_big_stamp(cls, img, padding=5, trim=True, fit_exact=False):
if trim:
img = cls.trim_transparent(img)
return cls.fit_canvas(img, LINE_BIG_STAMP_SIZE, padding_percent=padding, fit_exact=fit_exact, force_even=True)
@classmethod
def create_popup(cls, img, padding=5, trim=True):
if trim:
img = cls.trim_transparent(img)
return cls.fit_canvas(img, LINE_POPUP_SIZE, padding_percent=padding, fit_exact=True, force_even=False)
@classmethod
def create_main(cls, img, padding=5, trim=True):
"""LINE main image: 240x240 px fixed"""
if trim:
img = cls.trim_transparent(img)
return cls.fit_canvas(img, LINE_MAIN_SIZE, padding_percent=padding, fit_exact=True, force_even=False)
@classmethod
def create_tab(cls, img, padding=5, trim=True):
"""LINE tab image: 96x74 px fixed"""
if trim:
img = cls.trim_transparent(img)
return cls.fit_canvas(img, LINE_TAB_SIZE, padding_percent=padding, fit_exact=True, force_even=False)
@staticmethod
def save_png(img, output_path, limit_mb=1.0):
"""Save image as PNG ensuring under limit_mb (1MB or 300KB by LINE spec)"""
output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
buf = io.BytesIO()
img.save(buf, format='PNG', optimize=True)
data = buf.getvalue()
max_bytes = int(limit_mb * 1024 * 1024)
if len(data) <= max_bytes:
with open(output_path, 'wb') as f:
f.write(data)
return True
# Exceeds limit: quantize to 256 colors palette with alpha
try:
quantized = img.quantize(colors=256, method=Image.Quantize.FASTOCTREE)
buf2 = io.BytesIO()
quantized.save(buf2, format='PNG', optimize=True)
with open(output_path, 'wb') as f:
f.write(buf2.getvalue())
return True
except Exception:
with open(output_path, 'wb') as f:
f.write(data)
return False
# ===== END PART2-A =====
# ===== LINE ULTIMATE PART2-B =====
# Outline / Shadow Engine
class EffectProcessor:
@staticmethod
def create_outline(img, color=(255, 255, 255, 255), size=4):
"""Create high-quality smooth outline using alpha channel dilation"""
if size <= 0:
return img
img = img.convert('RGBA')
pad = size + 4
w, h = img.size
canvas = Image.new('RGBA', (w + pad * 2, h + pad * 2), (0, 0, 0, 0))
canvas.paste(img, (pad, pad))
alpha = canvas.split()[3]
filter_size = size * 2 + 1
expanded_alpha = alpha.filter(ImageFilter.MaxFilter(filter_size))
expanded_alpha = expanded_alpha.filter(ImageFilter.GaussianBlur(radius=0.7))
expanded_alpha = expanded_alpha.point(lambda p: 255 if p > 70 else 0)
color_layer = Image.new('RGBA', canvas.size, color)
color_layer.putalpha(expanded_alpha)
result = Image.alpha_composite(color_layer, canvas)
bbox = result.getbbox()
if bbox:
return result.crop(bbox)
return result
@staticmethod
def white_outline(img, size=4):
return EffectProcessor.create_outline(img, (255, 255, 255, 255), size)
@staticmethod
def black_outline(img, size=4):
return EffectProcessor.create_outline(img, (0, 0, 0, 255), size)
@staticmethod
def shadow(img, offset=(4, 4), blur=6, color=(0, 0, 0, 140)):
img = img.convert('RGBA')
pad = max(abs(offset[0]), abs(offset[1])) + blur * 2
w, h = img.size
canvas = Image.new('RGBA', (w + pad * 2, h + pad * 2), (0, 0, 0, 0))
img_pos = (pad, pad)
canvas.paste(img, img_pos)
alpha = canvas.split()[3]
shadow_layer = Image.new('RGBA', canvas.size, (0, 0, 0, 0))
shadow_fill = Image.new('RGBA', canvas.size, color)
shadow_layer.paste(shadow_fill, offset, alpha)
shadow_layer = shadow_layer.filter(ImageFilter.GaussianBlur(blur))
result = Image.alpha_composite(shadow_layer, canvas)
bbox = result.getbbox()
if bbox:
return result.crop(bbox)
return result
@staticmethod
def apply_effects(
img,
white_outline=False,
black_outline=False,
shadow=False,
outline_size=4,
trim=True
):
result = img.copy()
if trim:
result = ImageProcessor.trim_transparent(result)
if white_outline:
result = EffectProcessor.white_outline(result, outline_size)
if black_outline:
result = EffectProcessor.black_outline(result, outline_size)
if shadow:
result = EffectProcessor.shadow(result)
return result
# ===== END PART2-B =====
# ===== LINE ULTIMATE PART3-A =====
# Batch Export Engine
class BatchExporter:
def __init__(self, processor=None):
self.processor = processor or ImageProcessor()
def make_name(self, index, pattern='01', orig_name=None, suffix=''):
"""
ファイル名生成:
- '001': 絵文字公式規定 (001.png, 002.png... 数字3桁)
- '01': スタンプ公式規定 (01.png, 02.png... 数字2桁)
- 'A000001': 旧連番 (A000001.png)
- '000001': 6桁連番
- 'original': 元ファイル名保持
- suffix: '_popup' や '_effect' などの付加文字
"""
if pattern == 'original' and orig_name:
stem = Path(orig_name).stem
return f'{stem}{suffix}.png'
if pattern == '001':
return f'{index:03d}{suffix}.png'
if pattern == '01':
return f'{index:02d}{suffix}.png'
if pattern == '000001':
return f'{index:06d}{suffix}.png'
return f'A{index:06d}{suffix}.png'
def export_folder(
self,
files,
output_dir,
mode='stamp',
padding=5,
effects=None,
pattern='01',
keep_filename=False,
gen_main_tab=True,
progress_cb=None
):
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
effects = effects or {}
white_outline = effects.get('white_outline', False)
black_outline = effects.get('black_outline', False)
shadow = effects.get('shadow', False)
outline_size = effects.get('outline_size', 4)
trim = effects.get('trim_transparent', True)
spec = LineMode.get_spec(mode)
max_size_mb = spec.get('max_filesize_mb', 1.0)
has_dual = spec.get('has_dual_content', False)
dual_suffix = spec.get('dual_suffix', '')
first_image = None
total = len(files)
created_files = []
for index, file_path in enumerate(files, start=1):
try:
img = self.processor.load_image(file_path)
# Apply effects first
processed = EffectProcessor.apply_effects(
img,
white_outline=white_outline,
black_outline=black_outline,
shadow=shadow,
outline_size=outline_size,
trim=trim
)
if first_image is None:
first_image = processed.copy()
# 1. Main standard content image (e.g. 01.png)
result_std = self.processor.process_for_spec(
processed, mode=mode, padding=padding, trim=False, use_popup_size=False
)
if keep_filename:
out_name_std = Path(file_path).stem + '.png'
else:
out_name_std = self.make_name(index, pattern=pattern, orig_name=file_path)
dest_std = output_dir / out_name_std
self.processor.save_png(result_std, dest_std, limit_mb=max_size_mb)
created_files.append(str(dest_std))
# 2. Dual popup / effect content image if needed (e.g. 01_popup.png or 01_effect.png)
if has_dual:
result_dual = self.processor.process_for_spec(
processed, mode=mode, padding=padding, trim=False, use_popup_size=True
)
if keep_filename:
out_name_dual = Path(file_path).stem + dual_suffix + '.png'
else:
out_name_dual = self.make_name(index, pattern=pattern, orig_name=file_path, suffix=dual_suffix)
dest_dual = output_dir / out_name_dual
self.processor.save_png(result_dual, dest_dual, limit_mb=max_size_mb)
created_files.append(str(dest_dual))
if progress_cb:
pct = int(index / total * (80 if gen_main_tab else 95))
status_name = f'{out_name_std}' + (f' & {out_name_dual}' if has_dual else '')
progress_cb(pct, f'変換中: {status_name} ({index}/{total})')
except Exception as e:
logging.error(f'Error processing {file_path}: {e}')
# main.png / tab.png / extra_mains 生成判定 (LINE公式ガイドライン準拠)
if gen_main_tab and first_image is not None:
# 1. Standard main.png (240x240)
if spec['need_main']:
if progress_cb:
progress_cb(85, 'main.png (240x240) を生成中...')
main_img = self.processor.create_main(first_image, padding=padding, trim=False)
main_path = output_dir / 'main.png'
self.processor.save_png(main_img, main_path, limit_mb=1.0)
created_files.append(str(main_path))
# 2. Extra mains (e.g. main_animation.png, main_effect.png)
for extra_filename, extra_size in spec.get('extra_mains', []):
if progress_cb:
progress_cb(88, f'{extra_filename} を生成中...')
extra_img = self.processor.fit_canvas(first_image, extra_size, padding_percent=padding, fit_exact=True, force_even=False)
extra_path = output_dir / extra_filename
self.processor.save_png(extra_img, extra_path, limit_mb=1.0)
created_files.append(str(extra_path))
# 3. tab.png (96x74)
if spec['need_tab']:
if progress_cb:
progress_cb(92, 'tab.png (96x74) を生成中...')
tab_img = self.processor.create_tab(first_image, padding=padding, trim=False)
tab_path = output_dir / 'tab.png'
self.processor.save_png(tab_img, tab_path, limit_mb=1.0)
created_files.append(str(tab_path))
if progress_cb:
progress_cb(100, f'変換完了: 計 {len(created_files)} ファイル')
return created_files
# ===== END PART3-A =====
# ===== LINE ULTIMATE PART3-B =====
# ZIP / Naming Engine
class PackageBuilder:
@staticmethod
def file_size_mb(path):
try:
return os.path.getsize(path) / (1024 * 1024)
except Exception:
return 0.0
@staticmethod
def check_png_limit(path, limit_mb=1.0):
return PackageBuilder.file_size_mb(path) <= limit_mb
@staticmethod
def build_zip(source_folder, output_zip='LINE_PACKAGE.zip'):
source_folder = Path(source_folder)
zip_path = source_folder / output_zip
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as z:
for file in sorted(source_folder.iterdir()):
if file.is_file() and file.suffix.lower() == '.png' and file.name != output_zip:
z.write(file, file.name)
return str(zip_path)
@staticmethod
def zip_size_mb(zip_path):
try:
return os.path.getsize(zip_path) / (1024 * 1024)
except Exception:
return 0.0
@staticmethod
def validate_folder(folder, mode='stamp'):
"""
LINE公式規定に基づくパッケージ妥当性検証
"""
spec = LineMode.get_spec(mode)
results = []
files = list(Path(folder).glob('*.png'))
mains_and_tabs = {'main.png', 'tab.png'}
for extra_name, _ in spec.get('extra_mains', []):
mains_and_tabs.add(extra_name)
content_files = [f for f in files if f.name not in mains_and_tabs]
has_main = any(f.name == 'main.png' for f in files)
has_tab = any(f.name == 'tab.png' for f in files)
extra_checks = {}
for extra_name, _ in spec.get('extra_mains', []):
extra_checks[extra_name] = any(f.name == extra_name for f in files)
max_limit_mb = spec.get('max_filesize_mb', 1.0)
for file in sorted(files):
sz = PackageBuilder.file_size_mb(file)
limit = max_limit_mb if file.name not in mains_and_tabs else 1.0
results.append({
'file': file.name,
'size_mb': round(sz, 3),
'limit_mb': limit,
'ok': sz <= limit
})
# Dual content handling (count standard items)
if spec.get('has_dual_content', False):
dual_suffix = spec.get('dual_suffix', '')
std_items = [f for f in content_files if not f.stem.endswith(dual_suffix)]
dual_items = [f for f in content_files if f.stem.endswith(dual_suffix)]
count = len(std_items)
dual_count_match = (len(std_items) == len(dual_items))
else:
count = len(content_files)
dual_count_match = True
count_ok = count in spec['allowed_counts']
summary = {
'mode': mode,
'files': results,
'content_count': count,
'count_ok': count_ok,
'allowed_counts': spec['allowed_counts'],
'has_main': has_main,
'main_required': spec['need_main'],
'main_ok': (has_main == spec['need_main']),
'has_tab': has_tab,
'tab_required': spec['need_tab'],
'tab_ok': (has_tab == spec['need_tab']),
'extra_checks': extra_checks,
'extra_mains_ok': all(extra_checks.values()) if extra_checks else True,
'dual_count_match': dual_count_match,
'all_size_ok': all(r['ok'] for r in results)
}
return summary
# ===== END PART3-B =====
# ===== LINE ULTIMATE PART4-A =====
# OpenCV Preview Engine
class PreviewEngine:
def __init__(self):
self.zoom = 1.0
self.bg_mode = 'checker'
def set_zoom(self, zoom):
self.zoom = max(0.1, min(20.0, zoom))
def set_background(self, mode):
self.bg_mode = mode
def create_checkerboard(self, width, height, size=16):
if not CV2_AVAILABLE:
return None
img = np.zeros((height, width, 3), dtype=np.uint8)
for y in range(0, height, size):
for x in range(0, width, size):
c = 220 if ((x // size) + (y // size)) % 2 == 0 else 180
img[y:y + size, x:x + size] = (c, c, c)
return img
def pil_to_cv(self, pil_img):
if not CV2_AVAILABLE:
return None
arr = np.array(pil_img.convert('RGBA'))
return cv2.cvtColor(arr, cv2.COLOR_RGBA2BGRA)
def show_before_after(self, before_img, after_img, window_title='LINE Ultimate - Preview (Press Q or ESC to close)'):
if not CV2_AVAILABLE:
return False
def prepare(img):
arr = np.array(img.convert('RGBA'))
b, g, r, a = cv2.split(arr)
h, w = arr.shape[:2]
checker = self.create_checkerboard(w, h, size=12)
alpha_f = a.astype(float) / 255.0
alpha_3 = np.dstack([alpha_f, alpha_f, alpha_f])
fg = cv2.merge([b, g, r]).astype(float)
bg = checker.astype(float)
comp = (fg * alpha_3 + bg * (1.0 - alpha_3)).astype(np.uint8)
return comp
left = prepare(before_img)
right = prepare(after_img)
cv2.putText(left, 'BEFORE (Original)', (10, 25), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2)
cv2.putText(right, 'AFTER (LINE Spec)', (10, 25), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 180, 0), 2)
max_h = max(left.shape[0], right.shape[0])
if left.shape[0] < max_h:
pad_top = (max_h - left.shape[0]) // 2
pad_bottom = max_h - left.shape[0] - pad_top
left = cv2.copyMakeBorder(left, pad_top, pad_bottom, 0, 0, cv2.BORDER_CONSTANT, value=(40, 40, 40))
if right.shape[0] < max_h:
pad_top = (max_h - right.shape[0]) // 2
pad_bottom = max_h - right.shape[0] - pad_top
right = cv2.copyMakeBorder(right, pad_top, pad_bottom, 0, 0, cv2.BORDER_CONSTANT, value=(40, 40, 40))
divider = np.full((max_h, 4, 3), 100, dtype=np.uint8)
canvas = np.hstack([left, divider, right])
cv2.imshow(window_title, canvas)
cv2.waitKey(0)
cv2.destroyWindow(window_title)
return True
# ===== END PART4-A =====
# ===== LINE ULTIMATE PART4-B =====
# Advanced Preview Controls
class PreviewInteraction:
def __init__(self):
self.zoom = 1.0
self.offset_x = 0
self.offset_y = 0
self.background = 'checker'
def zoom_in(self, step=0.1):
self.zoom = min(10.0, round(self.zoom + step, 2))
def zoom_out(self, step=0.1):
self.zoom = max(0.2, round(self.zoom - step, 2))
def reset(self):
self.zoom = 1.0
self.offset_x = 0
self.offset_y = 0
def set_background(self, mode):
self.background = mode
class PixelInfo:
@staticmethod
def rgba_at(image, x, y):
try:
return image.getpixel((x, y))
except Exception:
return None
class BackgroundModes:
CHECKER = 'checker'
WHITE = 'white'
BLACK = 'black'
LINE = 'line' # LINE official chatroom sky blue (#7ac5d8)
class RealtimePreview:
def __init__(self):
self.enabled = True
def refresh(self):
return self.enabled
# ===== END PART4-B =====
# ===== LINE ULTIMATE PART1-C =====
# Drag&Drop / TreeView / Settings UI Layer
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
class DragDropSupport:
@staticmethod
def attach(widget, callback):
if not TKDND_AVAILABLE:
return False
try:
widget.drop_target_register(DND_FILES)
widget.dnd_bind('<<Drop>>', callback)
return True
except Exception as e:
logging.debug(f'DragDrop attach failed: {e}')
return False
class ThumbnailPanel(ttk.Frame):
def __init__(self, master):
super().__init__(master)
self.canvas = tk.Canvas(self, bg='#1e1e1e', highlightthickness=0)
self.scroll = ttk.Scrollbar(self, orient='vertical', command=self.canvas.yview)
self.canvas.configure(yscrollcommand=self.scroll.set)
self.canvas.pack(side='left', fill='both', expand=True)
self.scroll.pack(side='right', fill='y')
class FileTreePanel(ttk.Frame):
def __init__(self, master, on_select=None):
super().__init__(master)
self.on_select = on_select
header_frame = ttk.Frame(self)
header_frame.pack(fill='x', padx=2, pady=2)
self.count_label = ttk.Label(header_frame, text='画像一覧: 0件', font=('Segoe UI', 9, 'bold'))
self.count_label.pack(side='left')
self.status_badge = ttk.Label(header_frame, text='', font=('Segoe UI', 8))
self.status_badge.pack(side='right', padx=2)
tree_frame = ttk.Frame(self)
tree_frame.pack(fill='both', expand=True)
self.tree = ttk.Treeview(
tree_frame,
columns=('num', 'name', 'size', 'res'),
show='headings',
selectmode='browse'
)
self.tree.heading('num', text='#')
self.tree.column('num', width=35, anchor='center', stretch=False)
self.tree.heading('name', text='ファイル名')
self.tree.column('name', width=160, anchor='w')
self.tree.heading('size', text='容量')
self.tree.column('size', width=65, anchor='e', stretch=False)
self.tree.heading('res', text='解像度')
self.tree.column('res', width=80, anchor='center', stretch=False)
scroll_y = ttk.Scrollbar(tree_frame, orient='vertical', command=self.tree.yview)
self.tree.configure(yscrollcommand=scroll_y.set)
self.tree.pack(side='left', fill='both', expand=True)
scroll_y.pack(side='right', fill='y')
self.tree.bind('<<TreeviewSelect>>', self._handle_select)
def _handle_select(self, event):
sel = self.tree.selection()
if sel and self.on_select:
item = self.tree.item(sel[0])
idx = int(item['values'][0]) - 1
self.on_select(idx)
def set_items(self, jobs, mode='stamp'):
self.tree.delete(*self.tree.get_children())
for idx, job in enumerate(jobs, start=1):
self.tree.insert('', 'end', values=(
idx,
job['name'],
f"{job['size_kb']} KB",
job['res']
))
count = len(jobs)
spec = LineMode.get_spec(mode)
allowed = spec['allowed_counts']
self.count_label.config(text=f'画像一覧: {count}件')
if count == 0:
self.status_badge.config(text='(未登録)', foreground='#888888')
elif count in allowed:
self.status_badge.config(text=f'✅ {count}個パック適合', foreground='#2e7d32')
else:
next_target = next((c for c in allowed if c > count), None)
if next_target:
diff = next_target - count
self.status_badge.config(text=f'⚠️ {count}件 (あと{diff}件で{next_target}個)', foreground='#e65100')
else:
self.status_badge.config(text=f'ℹ️ {count}件 (規定: {allowed})', foreground='#1565c0')
def select_index(self, index):
children = self.tree.get_children()
if 0 <= index < len(children):
self.tree.selection_set(children[index])
self.tree.see(children[index])
class SettingsPanel(ttk.LabelFrame):
def __init__(self, master, on_change=None):
super().__init__(master, text=' ⚙ 設定 (LINE Creators Market 規格) ', padding=8)
self.on_change = on_change
# Mode Mapping
self.mode_keys = [
'stamp',
'anim_stamp',
'big_stamp',
'popup_stamp',
'effect_stamp',
'message_stamp',
'emoji',
'anim_emoji'
]
self.mode_labels = [LineMode.get_spec(k)['label'] for k in self.mode_keys]
# Mode
mode_frame = ttk.Frame(self)
mode_frame.pack(fill='x', pady=2)
ttk.Label(mode_frame, text='規格タイプ:').pack(side='left')
self.mode_var = tk.StringVar(value=self.mode_labels[0])
self.cb_mode = ttk.Combobox(
mode_frame,
textvariable=self.mode_var,
values=self.mode_labels,
state='readonly'
)
self.cb_mode.pack(side='right', fill='x', expand=True, padx=(4, 0))
self.cb_mode.bind('<<ComboboxSelected>>', self._on_mode_selected)
# Mode description banner
self.mode_desc_lbl = ttk.Label(
self,
text=LineMode.get_spec('stamp')['desc'],
font=('Segoe UI', 8),
wraplength=310,
foreground='#1976d2'
)
self.mode_desc_lbl.pack(fill='x', pady=(2, 4))
# Expected file structure box
struct_frame = ttk.LabelFrame(self, text=' 📁 生成されるファイル構成 ', padding=4)
struct_frame.pack(fill='x', pady=4)
self.struct_text_lbl = ttk.Label(
struct_frame,
text='\n'.join(LineMode.get_spec('stamp')['file_structure']),
font=('Consolas', 8),
foreground='#333333'
)
self.struct_text_lbl.pack(anchor='w')
# Padding
pad_frame = ttk.Frame(self)
pad_frame.pack(fill='x', pady=(4, 2))
ttk.Label(pad_frame, text='余白率 (%):').pack(side='left')
self.padding_var = tk.IntVar(value=5)
self.sp_pad = ttk.Spinbox(
pad_frame,
from_=0,
to=40,
textvariable=self.padding_var,
width=5,
command=self._trigger_change
)
self.sp_pad.pack(side='right')
self.sp_pad.bind('<KeyRelease>', self._trigger_change)
# Separator
ttk.Separator(self, orient='horizontal').pack(fill='x', pady=8)
# Effects Group
eff_lbl = ttk.Label(self, text='【エフェクト・トリミング】', font=('Segoe UI', 9, 'bold'))
eff_lbl.pack(anchor='w')
self.trim_var = tk.BooleanVar(value=True)
ttk.Checkbutton(
self,
text='透明余白を自動トリム',
variable=self.trim_var,
command=self._trigger_change
).pack(anchor='w', pady=2)
self.white_outline = tk.BooleanVar(value=False)
ttk.Checkbutton(
self,
text='白フチを追加 (LINE背景対策)',
variable=self.white_outline,
command=self._trigger_change
).pack(anchor='w', pady=2)
self.black_outline = tk.BooleanVar(value=False)
ttk.Checkbutton(
self,
text='黒フチを追加',
variable=self.black_outline,
command=self._trigger_change
).pack(anchor='w', pady=2)
self.shadow = tk.BooleanVar(value=False)
ttk.Checkbutton(
self,
text='シャドウ (影) を追加',
variable=self.shadow,
command=self._trigger_change
).pack(anchor='w', pady=2)
size_frame = ttk.Frame(self)
size_frame.pack(fill='x', pady=2)
ttk.Label(size_frame, text='フチの太さ (px):').pack(side='left')
self.outline_size_var = tk.IntVar(value=4)
ttk.Spinbox(
size_frame,
from_=1,
to=20,
textvariable=self.outline_size_var,
width=5,
command=self._trigger_change
).pack(side='right')
# Separator
ttk.Separator(self, orient='horizontal').pack(fill='x', pady=8)
# Output Group
out_lbl = ttk.Label(self, text='【出力・パッケージ】', font=('Segoe UI', 9, 'bold'))
out_lbl.pack(anchor='w')
ttk.Label(self, text='出力先フォルダ:').pack(anchor='w', pady=(4, 0))
out_dir_frame = ttk.Frame(self)
out_dir_frame.pack(fill='x', pady=2)
self.output_folder = tk.StringVar()
ttk.Entry(out_dir_frame, textvariable=self.output_folder).pack(side='left', fill='x', expand=True)
ttk.Button(out_dir_frame, text='参照...', width=6, command=self._browse_output).pack(side='right', padx=(2, 0))
pat_frame = ttk.Frame(self)
pat_frame.pack(fill='x', pady=4)
ttk.Label(pat_frame, text='ファイル命名:').pack(side='left')
self.pattern_var = tk.StringVar(value='01')
self.cb_pat = ttk.Combobox(
pat_frame,
textvariable=self.pattern_var,
values=[
'001〜 (001.png, 002.png... 絵文字公式3桁)',
'01〜 (01.png, 02.png... スタンプ公式2桁)',
'A000001〜 (LINE旧連番)',
'000001〜 (6桁連番)',
'original (元ファイル名保持)'
],
state='readonly'
)
self.cb_pat.current(1)
self.cb_pat.pack(side='right', fill='x', expand=True, padx=(4, 0))
self.gen_main_tab_var = tk.BooleanVar(value=True)
self.chk_main_tab = ttk.Checkbutton(
self,
text='main.png / tab.png を自動生成',
variable=self.gen_main_tab_var
)
self.chk_main_tab.pack(anchor='w', pady=2)
self.zip_output = tk.BooleanVar(value=True)
ttk.Checkbutton(
self,
text='LINE_PACKAGE.zip を自動作成',
variable=self.zip_output
).pack(anchor='w', pady=2)
# Save Button
ttk.Button(self, text='💾 設定を保存', command=self._save_settings).pack(fill='x', pady=(10, 0))
def _on_mode_selected(self, event=None):
mode = self.get_mode()
spec = LineMode.get_spec(mode)
self.mode_desc_lbl.config(text=spec['desc'])
self.struct_text_lbl.config(text='\n'.join(spec['file_structure']))
# Auto-adjust naming pattern & padding & main/tab according to LINE guidelines
if mode in (LineMode.EMOJI, LineMode.ANIM_EMOJI):
self.pattern_var.set('001〜 (001.png, 002.png... 絵文字公式3桁)')
self.padding_var.set(spec['default_padding'])
self.chk_main_tab.config(text='tab.png (96x74) のみ自動生成 (絵文字はmain不要)')
elif mode == LineMode.POPUP_STAMP:
self.pattern_var.set('01〜 (01.png, 02.png... スタンプ公式2桁)')
self.padding_var.set(spec['default_padding'])
self.chk_main_tab.config(text='main.png / main_animation.png / tab.png を生成')
elif mode == LineMode.EFFECT_STAMP:
self.pattern_var.set('01〜 (01.png, 02.png... スタンプ公式2桁)')
self.padding_var.set(spec['default_padding'])
self.chk_main_tab.config(text='main.png / main_effect.png / tab.png を生成')
else:
if '001〜' in self.pattern_var.get():
self.pattern_var.set('01〜 (01.png, 02.png... スタンプ公式2桁)')
self.padding_var.set(spec['default_padding'])
self.chk_main_tab.config(text='main.png (240x240) / tab.png (96x74) を自動生成')
self._trigger_change()
def _trigger_change(self, event=None):
if self.on_change:
self.on_change()
def _browse_output(self):
folder = filedialog.askdirectory(title='出力先フォルダを選択')
if folder:
self.output_folder.set(folder)
def _save_settings(self):
cfg = CTX.config
cfg.set('mode', self.get_mode())
cfg.set('padding_percent', self.padding_var.get())
cfg.set('trim_transparent', self.trim_var.get())
cfg.set('white_outline', self.white_outline.get())
cfg.set('black_outline', self.black_outline.get())
cfg.set('shadow', self.shadow.get())
cfg.set('outline_size', self.outline_size_var.get())
cfg.set('output_pattern', self.get_pattern())
cfg.set('gen_main_tab', self.gen_main_tab_var.get())
cfg.set('zip_output', self.zip_output.get())
cfg.set('output_folder', self.output_folder.get())
if cfg.save():
messagebox.showinfo('保存完了', '設定を保存しました: line_ultimate_settings.json')
def load_from_config(self, cfg):
mode = cfg.get('mode', 'stamp')
for idx, key in enumerate(self.mode_keys):
if key == mode:
self.cb_mode.current(idx)
break
else:
self.cb_mode.current(0)
spec = LineMode.get_spec(self.get_mode())
self.mode_desc_lbl.config(text=spec['desc'])
self.struct_text_lbl.config(text='\n'.join(spec['file_structure']))
self.padding_var.set(cfg.get('padding_percent', spec['default_padding']))
self.trim_var.set(cfg.get('trim_transparent', True))
self.white_outline.set(cfg.get('white_outline', False))
self.black_outline.set(cfg.get('black_outline', False))
self.shadow.set(cfg.get('shadow', False))
self.outline_size_var.set(cfg.get('outline_size', 4))
self.gen_main_tab_var.set(cfg.get('gen_main_tab', True))
self.zip_output.set(cfg.get('zip_output', True))
self.output_folder.set(cfg.get('output_folder', ''))
pat = cfg.get('output_pattern', spec['default_pattern'])
if pat == '001':
self.pattern_var.set('001〜 (001.png, 002.png... 絵文字公式3桁)')
elif pat == '01':
self.pattern_var.set('01〜 (01.png, 02.png... スタンプ公式2桁)')
elif pat == 'original':
self.pattern_var.set('original (元ファイル名保持)')
elif pat == '000001':
self.pattern_var.set('000001〜 (6桁連番)')
else:
self.pattern_var.set('A000001〜 (LINE旧連番)')
self._on_mode_selected()
def get_mode(self):
sel = self.mode_var.get()
for key, label in zip(self.mode_keys, self.mode_labels):
if sel == label:
return key
return 'stamp'
def get_pattern(self):
pat = self.pattern_var.get()
if '001〜' in pat:
return '001'
if '01〜' in pat:
return '01'
if 'original' in pat:
return 'original'
if '000001' in pat:
return '000001'
return 'A000001'
def get_effects(self):
return {
'white_outline': self.white_outline.get(),
'black_outline': self.black_outline.get(),
'shadow': self.shadow.get(),
'outline_size': self.outline_size_var.get(),
'trim_transparent': self.trim_var.get()
}
class LogViewer(ttk.LabelFrame):
def __init__(self, master):
super().__init__(master, text=' 📋 ログ (Log Viewer) ', padding=4)
self.text = tk.Text(self, height=6, bg='#1a1a1a', fg='#e0e0e0', font=('Consolas', 9))
scroll = ttk.Scrollbar(self, orient='vertical', command=self.text.yview)
self.text.configure(yscrollcommand=scroll.set)
self.text.pack(side='left', fill='both', expand=True)
scroll.pack(side='right', fill='y')
def write(self, msg):
self.text.insert('end', msg + '\n')
self.text.see('end')
def clear(self):
self.text.delete('1.0', 'end')
# ===== END PART1-C =====
# ===== LINE ULTIMATE PART5-A =====
# Integration & Execution Layer
class ApplicationController:
def __init__(self, exporter=None):
self.exporter = exporter or BatchExporter()
self.running = False
self.progress = 0
def run_batch(
self,
files,
output_dir,
mode='stamp',
padding=5,
effects=None,
pattern='01',
keep_filename=False,
gen_main_tab=True,
create_zip=True,
progress_cb=None,
complete_cb=None
):
self.running = True
self.progress = 0
try:
created = self.exporter.export_folder(
files=files,
output_dir=output_dir,
mode=mode,
padding=padding,
effects=effects,
pattern=pattern,
keep_filename=keep_filename,
gen_main_tab=gen_main_tab,
progress_cb=progress_cb
)
zip_path = None
if create_zip and created:
if progress_cb:
progress_cb(96, 'ZIPパッケージを作成中...')
zip_path = PackageBuilder.build_zip(output_dir, 'LINE_PACKAGE.zip')
validation = PackageBuilder.validate_folder(output_dir, mode=mode)
if complete_cb:
complete_cb(True, {
'files': created,
'zip': zip_path,
'validation': validation,
'output_dir': str(output_dir),
'mode': mode
})
except Exception as e:
logging.error(f'Batch run failed: {e}')
if complete_cb:
complete_cb(False, {'error': str(e)})
finally:
self.running = False
def run_thread(
self,
files,
output_dir,
mode='stamp',
padding=5,
effects=None,
pattern='01',
keep_filename=False,
gen_main_tab=True,
create_zip=True,
progress_cb=None,
complete_cb=None
):
th = threading.Thread(
target=self.run_batch,
kwargs={
'files': files,
'output_dir': output_dir,
'mode': mode,
'padding': padding,
'effects': effects,
'pattern': pattern,
'keep_filename': keep_filename,
'gen_main_tab': gen_main_tab,
'create_zip': create_zip,
'progress_cb': progress_cb,
'complete_cb': complete_cb
},
daemon=True
)
th.start()
return th
class SettingsBridge:
def __init__(self, config):
self.config = config
def load(self):
return self.config.load()
def save(self):
return self.config.save()
class ProgressBridge:
def __init__(self):
self.percent = 0
def update(self, value):
self.percent = value
# ===== END PART5-A =====
# ===== LINE ULTIMATE PART1-B =====
# GUI Foundation Layer (MainWindow)
class MainWindow:
def __init__(self, root, controller=None):
self.root = root
self.controller = controller or ApplicationController()
self.preview_engine = PreviewEngine()
self.root.title(f'{APP_NAME} v{APP_VERSION} - LINE Creators Market 全スタンプ・絵文字 一括変換&ZIP')
self.root.geometry('1300x840')
self.root.minsize(1000, 680)
self.current_preview_image = None
self.selected_job_index = -1
self.bg_mode = tk.StringVar(value='checker')
self.view_mode = tk.StringVar(value='after') # 'after', 'before', 'both'
self.status_text = tk.StringVar(value='準備完了: 画像ファイルまたはフォルダをドロップしてください')
self.create_layout()
self.create_menu()
self.create_toolbar()
self.create_statusbar()
# Connect logger to GUI
CTX.logger.add_listener(self._on_log_message)
# DND Support
DragDropSupport.attach(self.root, self._on_drop_files)
# Load settings
self.settings_panel.load_from_config(CTX.config)
def create_menu(self):
menubar = tk.Menu(self.root)
file_menu = tk.Menu(menubar, tearoff=0)
file_menu.add_command(label='📁 フォルダから画像を追加...', command=self.select_folder, accelerator='Ctrl+O')
file_menu.add_command(label='➕ 画像ファイルを追加...', command=self.select_files)
file_menu.add_separator()
file_menu.add_command(label='📂 出力フォルダを開く', command=self.open_output_dir)
file_menu.add_separator()
file_menu.add_command(label='💾 設定を保存', command=self.settings_panel._save_settings)
file_menu.add_separator()
file_menu.add_command(label='❌ 終了', command=self.root.quit)
menubar.add_cascade(label='ファイル (F)', menu=file_menu)
edit_menu = tk.Menu(menubar, tearoff=0)
edit_menu.add_command(label='🗑️ 全ての画像をクリア', command=self.clear_all_jobs)
edit_menu.add_command(label='選択した画像を削除', command=self.remove_selected_job)
menubar.add_cascade(label='編集 (E)', menu=edit_menu)
tool_menu = tk.Menu(menubar, tearoff=0)
tool_menu.add_command(label='👁️ OpenCV 詳細プレビュー (Before/After)', command=self.show_opencv_preview)
tool_menu.add_command(label='▶️ 一括変換を実行', command=self.start_conversion)
menubar.add_cascade(label='ツール (T)', menu=tool_menu)
help_menu = tk.Menu(menubar, tearoff=0)
help_menu.add_command(label='LINE Creators Market 規格ガイド', command=self._show_spec_guide)
help_menu.add_command(label='🌐 スタンプ制作ガイドライン (公式Web)', command=self._open_line_guideline)
help_menu.add_command(label='🌐 絵文字制作ガイドライン (公式Web)', command=self._open_emoji_guideline)
help_menu.add_separator()
help_menu.add_command(label='バージョン情報', command=self._show_about)
menubar.add_cascade(label='ヘルプ (H)', menu=help_menu)
self.root.config(menu=menubar)
self.root.bind_all('<Control-o>', lambda e: self.select_folder())
def create_toolbar(self):
self.toolbar = ttk.Frame(self.root, padding=4)
self.toolbar.pack(fill='x', side='top')
ttk.Button(self.toolbar, text='📁 フォルダ追加', command=self.select_folder).pack(side='left', padx=2)
ttk.Button(self.toolbar, text='➕ ファイル追加', command=self.select_files).pack(side='left', padx=2)
ttk.Button(self.toolbar, text='🗑️ クリア', command=self.clear_all_jobs).pack(side='left', padx=2)
ttk.Separator(self.toolbar, orient='vertical').pack(side='left', fill='y', padx=8)
self.btn_convert = ttk.Button(
self.toolbar,
text='▶️ 一括変換実行 (Convert)',
command=self.start_conversion
)
self.btn_convert.pack(side='left', padx=2)
ttk.Button(self.toolbar, text='👁️ OpenCVプレビュー', command=self.show_opencv_preview).pack(side='left', padx=4)
ttk.Button(self.toolbar, text='📂 出力先を開く', command=self.open_output_dir).pack(side='left', padx=2)
ttk.Button(self.toolbar, text='🌐 絵文字ガイドライン', command=self._open_emoji_guideline).pack(side='right', padx=2)
ttk.Button(self.toolbar, text='🌐 スタンプガイドライン', command=self._open_line_guideline).pack(side='right', padx=2)
def create_layout(self):
self.main_paned = ttk.PanedWindow(self.root, orient='horizontal')
self.main_paned.pack(fill='both', expand=True, padx=4, pady=2)
# Left: File List Tree
self.left_frame = ttk.Frame(self.main_paned, width=320)
self.file_panel = FileTreePanel(self.left_frame, on_select=self._on_tree_select)
self.file_panel.pack(fill='both', expand=True)
# Center: Preview & Log Viewer
self.center_frame = ttk.Frame(self.main_paned)
self._build_center_preview(self.center_frame)
# Right: Settings Panel
self.right_frame = ttk.Frame(self.main_paned, width=350)
self.settings_panel = SettingsPanel(self.right_frame, on_change=self.update_preview)
self.settings_panel.pack(fill='both', expand=True)
self.main_paned.add(self.left_frame, weight=2)
self.main_paned.add(self.center_frame, weight=5)
self.main_paned.add(self.right_frame, weight=3)
def _build_center_preview(self, parent):
top_ctrl = ttk.Frame(parent)
top_ctrl.pack(fill='x', pady=2)
ttk.Label(top_ctrl, text='表示モード:').pack(side='left', padx=(2, 4))
for text, val in [('変換後 (After)', 'after'), ('元画像 (Before)', 'before'), ('並べて比較 (Both)', 'both')]:
ttk.Radiobutton(top_ctrl, text=text, value=val, variable=self.view_mode, command=self.update_preview).pack(side='left', padx=2)
ttk.Separator(top_ctrl, orient='vertical').pack(side='left', fill='y', padx=8)
ttk.Label(top_ctrl, text='背景:').pack(side='left', padx=(2, 4))
for text, val in [('透過格子', 'checker'), ('白', 'white'), ('黒', 'black'), ('LINEトーク', 'line')]:
ttk.Radiobutton(top_ctrl, text=text, value=val, variable=self.bg_mode, command=self.update_preview).pack(side='left', padx=2)
# Canvas Frame
self.canvas_frame = ttk.Frame(parent)
self.canvas_frame.pack(fill='both', expand=True, pady=2)
self.canvas = tk.Canvas(self.canvas_frame, bg='#252526', highlightthickness=1, highlightbackground='#404040')
self.canvas.pack(fill='both', expand=True)
self.canvas.bind('<Configure>', lambda e: self.update_preview())
# Bottom Log Viewer
self.log_viewer = LogViewer(parent)
self.log_viewer.pack(fill='x', side='bottom', pady=(4, 0))
def create_statusbar(self):
bottom = ttk.Frame(self.root, padding=2)
bottom.pack(fill='x', side='bottom')
self.progress = ttk.Progressbar(bottom, mode='determinate', length=200)
self.progress.pack(side='right', padx=6, pady=2)
self.lbl_status = ttk.Label(bottom, textvariable=self.status_text, anchor='w')
self.lbl_status.pack(side='left', fill='x', expand=True, padx=4)
# --- File Management ---
def select_folder(self):
folder = filedialog.askdirectory(title='画像フォルダを選択')
if folder:
self.load_folder(folder)
def select_files(self):
files = filedialog.askopenfilenames(
title='画像ファイルを選択',
filetypes=[('Supported Images', '*.png;*.jpg;*.jpeg;*.webp;*.gif;*.bmp'), ('All Files', '*.*')]
)
if files:
self.add_files(files)
def load_folder(self, folder):
files = FileScanner.scan(folder)
if not files:
messagebox.showinfo('通知', '指定フォルダ内に対象となる画像が見つかりませんでした。')
return
self.add_files(files)
if not self.settings_panel.output_folder.get():
default_out = os.path.join(folder, 'line_output')
self.settings_panel.output_folder.set(default_out)
def add_files(self, file_paths):
added = 0
for f in file_paths:
if FileScanner.is_supported(f):
if CTX.jobs.add_job(f):
added += 1
mode = self.settings_panel.get_mode()
self.file_panel.set_items(CTX.jobs.jobs, mode=mode)
self.status_text.set(f'{added} 件の画像を追加しました (合計: {CTX.jobs.count()} 件)')
CTX.logger.info(f'{added} files added. Total jobs: {CTX.jobs.count()}')
if self.selected_job_index == -1 and CTX.jobs.count() > 0:
self.file_panel.select_index(0)
def clear_all_jobs(self):
CTX.jobs.clear()
mode = self.settings_panel.get_mode()
self.file_panel.set_items([], mode=mode)
self.selected_job_index = -1
self.current_preview_image = None
self.canvas.delete('all')
self.status_text.set('画像をすべてクリアしました')
def remove_selected_job(self):
if self.selected_job_index >= 0:
CTX.jobs.remove_job(self.selected_job_index)
mode = self.settings_panel.get_mode()
self.file_panel.set_items(CTX.jobs.jobs, mode=mode)
if CTX.jobs.count() > 0:
idx = min(self.selected_job_index, CTX.jobs.count() - 1)
self.file_panel.select_index(idx)
else:
self.selected_job_index = -1
self.canvas.delete('all')
def _on_drop_files(self, event):
raw_data = event.data
if not raw_data:
return
files = self.root.tk.splitlist(raw_data)
to_add = []
for p in files:
p_obj = Path(p)
if p_obj.is_dir():
to_add.extend(FileScanner.scan(str(p_obj)))
elif p_obj.is_file() and FileScanner.is_supported(p):
to_add.append(str(p_obj))
if to_add:
self.add_files(to_add)
def _on_tree_select(self, index):
self.selected_job_index = index
self.update_preview()
# --- Preview Engine & Canvas Rendering ---
def update_preview(self):
mode = self.settings_panel.get_mode()
self.file_panel.set_items(CTX.jobs.jobs, mode=mode)
if self.selected_job_index < 0 or self.selected_job_index >= CTX.jobs.count():
self.canvas.delete('all')
self.canvas.create_text(
self.canvas.winfo_width() // 2,
self.canvas.winfo_height() // 2,
text='画像を選択するとここにプレビューが表示されます',
fill='#888888',
font=('Segoe UI', 12)
)
return
job = CTX.jobs.jobs[self.selected_job_index]
file_path = job['path']
try:
raw_img = ImageProcessor.load_image(file_path)
padding = self.settings_panel.padding_var.get()
eff = self.settings_panel.get_effects()
processed = EffectProcessor.apply_effects(
raw_img,
white_outline=eff['white_outline'],
black_outline=eff['black_outline'],
shadow=eff['shadow'],
outline_size=eff['outline_size'],
trim=eff['trim_transparent']
)
after_img = ImageProcessor.process_for_spec(processed, mode=mode, padding=padding, trim=False)
self._draw_on_canvas(raw_img, after_img, mode=mode)
except Exception as e:
logging.error(f'Preview generation error: {e}')
def _create_checker_image(self, width, height, size=12):
img = Image.new('RGBA', (width, height), (255, 255, 255, 255))
pixels = img.load()
for y in range(height):
for x in range(width):
c = (230, 230, 230, 255) if ((x // size) + (y // size)) % 2 == 0 else (190, 190, 190, 255)
pixels[x, y] = c
return img
def _draw_on_canvas(self, before_img, after_img, mode='stamp'):
can_w = max(100, self.canvas.winfo_width())
can_h = max(100, self.canvas.winfo_height())
vmode = self.view_mode.get()
bgmode = self.bg_mode.get()
spec = LineMode.get_spec(mode)
if vmode == 'before':
target_img = before_img
label_text = '元画像 (Before)'
elif vmode == 'after':
target_img = after_img
label_text = f'変換後 ({spec["label"]})'
else:
self._draw_both_on_canvas(before_img, after_img, can_w, can_h, bgmode, spec['label'])
return
rendered = self._render_with_background(target_img, bgmode)
ratio = min((can_w - 60) / rendered.width, (can_h - 60) / rendered.height, 2.0)
ratio = max(0.1, ratio)
disp_w = int(rendered.width * ratio)
disp_h = int(rendered.height * ratio)
disp_img = rendered.resize((disp_w, disp_h), Image.Resampling.LANCZOS)
self._tk_img = ImageTk.PhotoImage(disp_img)
self.canvas.delete('all')
cx, cy = can_w // 2, can_h // 2
self.canvas.create_image(cx, cy, image=self._tk_img)
self.canvas.create_rectangle(
cx - disp_w // 2, cy - disp_h // 2,
cx + disp_w // 2, cy + disp_h // 2,
outline='#00bcd4', width=2
)
self.canvas.create_text(
cx, cy + disp_h // 2 + 18,
text=f'{label_text} [{target_img.width}x{target_img.height}]',
fill='#e0e0e0', font=('Segoe UI', 10, 'bold')
)
def _draw_both_on_canvas(self, before_img, after_img, can_w, can_h, bgmode, mode_label):
left = self._render_with_background(before_img, bgmode)
right = self._render_with_background(after_img, bgmode)
combined_w = left.width + right.width + 40
combined_h = max(left.height, right.height)
ratio = min((can_w - 60) / combined_w, (can_h - 80) / combined_h, 2.0)
ratio = max(0.1, ratio)
lw, lh = int(left.width * ratio), int(left.height * ratio)
rw, rh = int(right.width * ratio), int(right.height * ratio)
l_disp = left.resize((lw, lh), Image.Resampling.LANCZOS)
r_disp = right.resize((rw, rh), Image.Resampling.LANCZOS)
canvas_comp = Image.new('RGBA', (can_w, can_h), (37, 37, 38, 255))
gap = 30
total_w = lw + rw + gap
start_x = (can_w - total_w) // 2
cy = can_h // 2
canvas_comp.paste(l_disp, (start_x, cy - lh // 2))
canvas_comp.paste(r_disp, (start_x + lw + gap, cy - rh // 2))
self._tk_img = ImageTk.PhotoImage(canvas_comp)
self.canvas.delete('all')
self.canvas.create_image(can_w // 2, can_h // 2, image=self._tk_img)
self.canvas.create_text(start_x + lw // 2, cy + lh // 2 + 16, text=f'Before (元画像: {before_img.width}x{before_img.height})', fill='#e0e0e0', font=('Segoe UI', 9, 'bold'))
self.canvas.create_text(start_x + lw + gap + rw // 2, cy + rh // 2 + 16, text=f'After ({mode_label}: {after_img.width}x{after_img.height})', fill='#4caf50', font=('Segoe UI', 9, 'bold'))
def _render_with_background(self, img, bgmode):
w, h = img.size
if bgmode == 'white':
bg = Image.new('RGBA', (w, h), (255, 255, 255, 255))
elif bgmode == 'black':
bg = Image.new('RGBA', (w, h), (0, 0, 0, 255))
elif bgmode == 'line':
bg = Image.new('RGBA', (w, h), (122, 197, 216, 255))
else:
bg = self._create_checker_image(w, h, size=12)
return Image.alpha_composite(bg, img)
def show_opencv_preview(self):
if not CV2_AVAILABLE:
messagebox.showwarning('OpenCV未検出', 'OpenCV (cv2) がインストールされていないため別ウィンドウプレビューは利用できません。')
return
if self.selected_job_index < 0 or self.selected_job_index >= CTX.jobs.count():
messagebox.showinfo('通知', 'プレビューする画像をリストから選択してください。')
return
job = CTX.jobs.jobs[self.selected_job_index]
file_path = job['path']
raw_img = ImageProcessor.load_image(file_path)
mode = self.settings_panel.get_mode()
padding = self.settings_panel.padding_var.get()
eff = self.settings_panel.get_effects()
processed = EffectProcessor.apply_effects(
raw_img,
white_outline=eff['white_outline'],
black_outline=eff['black_outline'],
shadow=eff['shadow'],
outline_size=eff['outline_size'],
trim=eff['trim_transparent']
)
after_img = ImageProcessor.process_for_spec(processed, mode=mode, padding=padding, trim=False)
threading.Thread(
target=self.preview_engine.show_before_after,
args=(raw_img, after_img),
daemon=True
).start()
# --- Batch Conversion Execution ---
def start_conversion(self):
if CTX.jobs.count() == 0:
messagebox.showwarning('警告', '変換対象の画像が登録されていません。\nフォルダまたは画像ファイルを追加してください。')
return
out_dir = self.settings_panel.output_folder.get().strip()
if not out_dir:
out_dir = filedialog.askdirectory(title='保存先フォルダを選択してください')
if not out_dir:
return
self.settings_panel.output_folder.set(out_dir)
mode = self.settings_panel.get_mode()
padding = self.settings_panel.padding_var.get()
eff = self.settings_panel.get_effects()
pat = self.settings_panel.get_pattern()
gen_main_tab = self.settings_panel.gen_main_tab_var.get()
create_zip = self.settings_panel.zip_output.get()
files = CTX.jobs.get_paths()
self.btn_convert.config(state='disabled')
self.progress['value'] = 0
self.status_text.set('変換を開始します...')
def progress_cb(pct, msg):
self.root.after(0, lambda: self._update_progress_ui(pct, msg))
def complete_cb(success, data):
self.root.after(0, lambda: self._on_conversion_complete(success, data))
self.controller.run_thread(
files=files,
output_dir=out_dir,
mode=mode,
padding=padding,
effects=eff,
pattern=pat,
keep_filename=(pat == 'original'),
gen_main_tab=gen_main_tab,
create_zip=create_zip,
progress_cb=progress_cb,
complete_cb=complete_cb
)
def _update_progress_ui(self, pct, msg):
self.progress['value'] = pct
self.status_text.set(msg)
CTX.logger.info(msg)
def _on_conversion_complete(self, success, data):
self.btn_convert.config(state='normal')
self.progress['value'] = 100
if success:
file_count = len(data.get('files', []))
zip_p = data.get('zip')
out_d = data.get('output_dir')
val = data.get('validation', {})
mode = data.get('mode', 'stamp')
spec = LineMode.get_spec(mode)
msg = f'変換が正常に完了しました!\n\n'
msg += f'・規格タイプ: {spec["label"]}\n'
msg += f'・出力ファイル総数: {file_count} 枚\n'
msg += f'・出力先: {out_d}\n\n'
# ファイル構成チェック
msg += '【ファイル構成チェック】\n'
if val.get('count_ok'):
msg += f'✅ コンテンツ画像数: {val.get("content_count")} 個 (規定: {spec["allowed_counts"]}個 適合)\n'
else:
msg += f'⚠️ コンテンツ画像数: {val.get("content_count")} 個 (規定数 {spec["allowed_counts"]}個 と異なります)\n'
if spec.get('has_dual_content'):
suffix = spec.get('dual_suffix')
if val.get('dual_count_match'):
msg += f'✅ 通常画像と {suffix}.png 画像が同数ペア生成されました\n'
else:
msg += f'⚠️ 通常画像と {suffix}.png 画像のペア数不一致\n'
if spec['need_main']:
msg += '✅ main.png (240x240) 生成済\n' if val.get('has_main') else '❌ main.png 未生成\n'
else:
msg += 'ℹ️ main.png: 不要のため未生成 (絵文字公式仕様)\n'
for extra_name, is_ok in val.get('extra_checks', {}).items():
msg += f'✅ {extra_name} 生成済\n' if is_ok else f'❌ {extra_name} 未生成\n'
if spec['need_tab']:
msg += '✅ tab.png (96x74) 生成済\n' if val.get('has_tab') else '❌ tab.png 未生成\n'
if zip_p:
sz = round(PackageBuilder.zip_size_mb(zip_p), 2)
zip_status = '✅ 20MB以下' if sz <= 20.0 else '⚠️ 20MB超過(要確認)'
msg += f'\n・ZIPパッケージ: {Path(zip_p).name} ({sz} MB - {zip_status})\n'
over_limit = [f"{v['file']} ({v['size_mb']}MB > {v['limit_mb']}MB)" for v in val.get('files', []) if not v['ok']]
if over_limit:
msg += f'\n⚠️ 警告: 容量制限を超過した画像があります:\n' + '\n'.join(over_limit)
self.status_text.set(f'完了: {file_count} ファイルを出力しました')
CTX.logger.info(f'Conversion finished successfully. Output: {out_d}')
if messagebox.askyesno('変換完了', msg + '\n出力フォルダを開きますか?'):
self.open_output_dir()
else:
err = data.get('error', '不明なエラー')
self.status_text.set('エラーにより変換が中断しました')
messagebox.showerror('変換エラー', f'処理中にエラーが発生しました:\n{err}')
def open_output_dir(self):
folder = self.settings_panel.output_folder.get().strip()
if folder and os.path.exists(folder):
if sys.platform == 'win32':
os.startfile(folder)
elif sys.platform == 'darwin':
subprocess.Popen(['open', folder])
else:
subprocess.Popen(['xdg-open', folder])
else:
messagebox.showinfo('通知', '出力フォルダがまだ作成されていないか指定されていません。')
def _on_log_message(self, level, text):
self.root.after(0, lambda: self.log_viewer.write(f'[{level}] {text}'))
def _open_line_guideline(self):
webbrowser.open(LINE_GUIDELINE_URL)
def _open_emoji_guideline(self):
webbrowser.open(LINE_EMOJI_GUIDELINE_URL)
def _show_spec_guide(self):
guide = (
"【LINE Creators Market 公式画像規格・ファイル構成ガイド】\n"
"詳細: https://creator.line.me/ja/guideline/\n"
"絵文字: https://creator.line.me/ja/guideline/emoji/\n\n"
"■ 通常スタンプ\n"
" ・main.png (240x240)\n"
" ・tab.png (96x74)\n"
" ・01.png ~ 40.png (最大370x320 偶数)\n"
" ・個数: 8 / 16 / 24 / 32 / 40 個\n\n"
"■ アニメーションスタンプ\n"
" ・main.png (240x240)\n"
" ・tab.png (96x74)\n"
" ・01.png ~ 24.png (最大320x270 偶数)\n"
" ・個数: 8 / 16 / 24 個\n\n"
"■ BIGスタンプ\n"
" ・main.png (240x240)\n"
" ・tab.png (96x74)\n"
" ・01.png ~ 40.png (最大660x540 偶数)\n"
" ・個数: 8 / 16 / 24 / 32 / 40 個\n\n"
"■ ポップアップスタンプ\n"
" ・main.png (240x240)\n"
" ・main_animation.png (240x240)\n"
" ・tab.png (96x74)\n"
" ・01.png ~ 24.png (最大370x320 偶数)\n"
" ・01_popup.png ~ 24_popup.png (480x480 固定)\n"
" ・個数: 8 / 16 / 24 個\n\n"
"■ エフェクトスタンプ\n"
" ・main.png (240x240)\n"
" ・main_effect.png (240x240)\n"
" ・tab.png (96x74)\n"
" ・01.png ~ 24.png (最大370x320 偶数)\n"
" ・01_effect.png ~ 24_effect.png (480x480 固定)\n"
" ・個数: 8 / 16 / 24 個\n\n"
"■ メッセージスタンプ\n"
" ・main.png (240x240)\n"
" ・tab.png (96x74)\n"
" ・01.png ~ 24.png (最大370x320 偶数)\n"
" ・個数: 8 / 16 / 24 個\n\n"
"■ LINE絵文字 (静止画)\n"
" ・tab.png (96x74) ※main.png不要\n"
" ・001.png ~ 040.png (180x180 固定 / 数字3桁連番)\n"
" ・個数: 8 / 16 / 24 / 32 / 40 個 (最大250個)\n\n"
"■ アニメーション絵文字\n"
" ・tab.png (96x74) ※main.png不要\n"
" ・001.png ~ 040.png (180x180 固定 / 数字3桁連番)\n"
" ・容量制限: 1ファイルあたり 300KB以下\n"
" ・個数: 8 / 16 / 24 / 32 / 40 個\n\n"
"■ 共通規定\n"
" ・形式: PNG (透過RGBA)\n"
" ・ZIPパッケージ最大容量: 20MB以下"
)
messagebox.showinfo('LINE公式ガイドライン規格ガイド', guide)
def _show_about(self):
messagebox.showinfo(
f'{APP_NAME} について',
f'{APP_NAME} v{APP_VERSION}\n\n'
'LINE Creators Market ガイドライン公式準拠の制作支援ツールです。\n'
'・通常スタンプ (01〜40.png / main / tab)\n'
'・アニメスタンプ (01〜24.png / main / tab)\n'
'・BIGスタンプ (01〜40.png / main / tab)\n'
'・ポップアップスタンプ (01〜24.png + 01_popup〜24_popup.png / main / main_animation / tab)\n'
'・エフェクトスタンプ (01〜24.png + 01_effect〜24_effect.png / main / main_effect / tab)\n'
'・メッセージスタンプ (01〜24.png / main / tab)\n'
'・LINE絵文字 (001〜040.png 数字3桁 / tabのみ / main不要)\n'
'・アニメーション絵文字 (001〜040.png 数字3桁 / tabのみ / 300KB制限)\n'
'・自動透過トリミング & 白フチ/黒フチ/シャドウ\n'
'・偶数化処理 & 1MB/300KB制限自動最適化\n'
'・LINE_PACKAGE.zip パッケージング'
)
# ===== END PART1-B =====
# ===== LINE ULTIMATE PART5-B =====
# Final Entry Point / Application Bootstrap
class EntryPoint:
def __init__(self):
self.initialized = False
self.preview_engine = None
self.exporter = None
self.settings = None
def initialize(self):
self.initialized = True
return True
def connect_preview(self, preview_engine):
self.preview_engine = preview_engine
def connect_exporter(self, exporter):
self.exporter = exporter
def connect_settings(self, settings):
self.settings = settings
def run(self):
if not self.initialized:
self.initialize()
return True
class ExeSupport:
@staticmethod
def is_frozen():
return getattr(sys, 'frozen', False)
@staticmethod
def application_path():
if ExeSupport.is_frozen():
return os.path.dirname(sys.executable)
return os.path.dirname(os.path.abspath(__file__))
# ===== END PART5-B =====
# ===== LINE ULTIMATE PART6-A =====
# Refactoring / Merge Layer
class DependencyContainer:
def __init__(self):
self.services = {}
def register(self, name, obj):
self.services[name] = obj
def resolve(self, name):
return self.services.get(name)
class ModuleIntegrator:
def __init__(self):
self.modules = []
def add(self, module_name):
self.modules.append(module_name)
def list_modules(self):
return self.modules
class RuntimeValidator:
REQUIRED = [
'ConfigManager',
'ImageProcessor',
'EffectProcessor',
'BatchExporter',
'PackageBuilder',
'PreviewEngine'
]
def validate(self, namespace):
missing = []
for name in self.REQUIRED:
if name not in namespace:
missing.append(name)
return missing
class UltimateAssembler:
def __init__(self):
self.container = DependencyContainer()
self.integrator = ModuleIntegrator()
def register_core(self, name, obj):
self.container.register(name, obj)
def build(self):
return True
# ===== END PART6-A =====
# ===== LINE ULTIMATE PART6-B =====
# Final Merge Entry
class FinalApplication:
def __init__(self):
self.ready = False
self.assembler = UltimateAssembler()
self.root = None
self.main_window = None
def load_modules(self):
self.assembler.register_core('config', CTX.config)
self.assembler.register_core('logger', CTX.logger)
self.assembler.register_core('processor', ImageProcessor())
self.assembler.register_core('effects', EffectProcessor())
self.assembler.register_core('exporter', BatchExporter())
self.assembler.register_core('package_builder', PackageBuilder())
self.assembler.register_core('controller', ApplicationController())
self.ready = True
return True
def start(self):
if not self.ready:
self.load_modules()
if TKDND_AVAILABLE:
try:
self.root = TkinterDnD.Tk()
except Exception:
self.root = tk.Tk()
else:
self.root = tk.Tk()
controller = self.assembler.container.resolve('controller')
self.main_window = MainWindow(self.root, controller=controller)
print(f'{APP_NAME} v{APP_VERSION} Started')
self.root.mainloop()
def build_line_ultimate():
app = FinalApplication()
app.load_modules()
return app
if __name__ == '__main__':
build_line_ultimate().start()
# ===== END PART6-B =====
シンプル
import os
import io
import zipfile
import threading
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
from pathlib import Path
from PIL import Image, ImageFilter, ImageOps
# ==========================================
# LINE Creators Market 画像処理ロジック
# ==========================================
def make_even(n: int) -> int:
"""LINEスタンプ規定: 縦横とも偶数にする(切り捨て偶数、最小2)"""
n = max(2, int(n))
return n if n % 2 == 0 else n - 1
def remove_background_floodfill(img: Image.Image, tolerance: int = 30) -> Image.Image:
"""
画像の四隅の色を基準にフラッドフィルで背景を透過する。
(外部AIライブラリ不要で軽量・高速に動作)
"""
img = img.convert("RGBA")
width, height = img.size
pixels = img.load()
# 四隅のピクセルを取得して背景色候補とする
corners = [(0, 0), (width - 1, 0), (0, height - 1), (width - 1, height - 1)]
visited = set()
stack = []
for cx, cy in corners:
r, g, b, a = pixels[cx, cy]
if a > 0:
stack.append((cx, cy, (r, g, b)))
def color_match(c1, c2, tol):
return (
abs(c1[0] - c2[0]) <= tol
and abs(c1[1] - c2[1]) <= tol
and abs(c1[2] - c2[2]) <= tol
)
while stack:
x, y, ref_color = stack.pop()
if (x, y) in visited:
continue
visited.add((x, y))
r, g, b, a = pixels[x, y]
if a == 0:
continue
if color_match((r, g, b), ref_color, tolerance):
pixels[x, y] = (r, g, b, 0)
for dx, dy in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
nx, ny = x + dx, y + dy
if 0 <= nx < width and 0 <= ny < height and (nx, ny) not in visited:
stack.append((nx, ny, ref_color))
return img
def trim_transparent(img: Image.Image, margin: int = 4) -> Image.Image:
"""透明余白をトリミングし、指定マージンを付与する"""
img = img.convert("RGBA")
bbox = img.getbbox()
if bbox is None:
return img
cropped = img.crop(bbox)
if margin > 0:
new_w = cropped.width + margin * 2
new_h = cropped.height + margin * 2
canvas = Image.new("RGBA", (new_w, new_h), (0, 0, 0, 0))
canvas.paste(cropped, (margin, margin))
return canvas
return cropped
def add_white_outline(img: Image.Image, border_width: int = 6) -> Image.Image:
"""不透明部分の周囲に滑らかな白フチを追加する"""
if border_width <= 0:
return img
img = img.convert("RGBA")
pad = border_width + 4
w, h = img.size
canvas = Image.new("RGBA", (w + pad * 2, h + pad * 2), (0, 0, 0, 0))
canvas.paste(img, (pad, pad))
alpha = canvas.split()[3]
# MaxFilterでアルファチャンネルを膨張させる(奇数サイズ指定)
filter_size = border_width * 2 + 1
expanded_alpha = alpha.filter(ImageFilter.MaxFilter(filter_size))
# エッジを少し滑らかにする
expanded_alpha = expanded_alpha.filter(ImageFilter.GaussianBlur(radius=0.8))
expanded_alpha = expanded_alpha.point(lambda p: 255 if p > 80 else 0)
white_layer = Image.new("RGBA", canvas.size, (255, 255, 255, 255))
white_layer.putalpha(expanded_alpha)
result = Image.alpha_composite(white_layer, canvas)
return result
def resize_for_line(
img: Image.Image,
mode: str,
fit_exact: bool = False,
) -> Image.Image:
"""
LINE Creators Market 規格にリサイズ
- sticker: 最大 370x320 (縦横とも偶数)
- emoji: 180x180 固定キャンバス中央配置
- main: 240x240 固定キャンバス中央配置
- tab: 96x74 固定キャンバス中央配置
"""
img = img.convert("RGBA")
orig_w, orig_h = img.size
if mode == "sticker":
max_w, max_h = 370, 320
ratio = min(max_w / orig_w, max_h / orig_h)
new_w = make_even(round(orig_w * ratio))
new_h = make_even(round(orig_h * ratio))
new_w = min(new_w, max_w)
new_h = min(new_h, max_h)
resized = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
if fit_exact:
canvas = Image.new("RGBA", (max_w, max_h), (0, 0, 0, 0))
paste_x = (max_w - new_w) // 2
paste_y = (max_h - new_h) // 2
canvas.paste(resized, (paste_x, paste_y))
return canvas
return resized
elif mode == "emoji":
target_w, target_h = 180, 180
elif mode == "main":
target_w, target_h = 240, 240
elif mode == "tab":
target_w, target_h = 96, 74
else:
raise ValueError(f"Unknown mode: {mode}")
# キャンバス内に収まる最大サイズへアスペクト比維持リサイズして中央配置
ratio = min(target_w / orig_w, target_h / orig_h)
new_w = max(1, round(orig_w * ratio))
new_h = max(1, round(orig_h * ratio))
resized = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
canvas = Image.new("RGBA", (target_w, target_h), (0, 0, 0, 0))
paste_x = (target_w - new_w) // 2
paste_y = (target_h - new_h) // 2
canvas.paste(resized, (paste_x, paste_y))
return canvas
def compress_png_under_limit(img: Image.Image, max_bytes: int = 1024 * 1024) -> bytes:
"""1MB (1,048,576 bytes) 以下に収まるようPNGエンコード(超過時は減色最適化)"""
buf = io.BytesIO()
img.save(buf, format="PNG", optimize=True)
data = buf.getvalue()
if len(data) <= max_bytes:
return data
# 1MBを超える場合は256色パレットへ量子化(アルファ維持)
quantized = img.quantize(colors=256, method=Image.Quantize.FASTOCTREE)
buf2 = io.BytesIO()
quantized.save(buf2, format="PNG", optimize=True)
return buf2.getvalue()
# ==========================================
# GUI アプリケーション (Tkinter)
# ==========================================
class LineCreatorResizerApp:
SUPPORTED_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".bmp"}
def __init__(self, root: tk.Tk):
self.root = root
self.root.title("LINEスタンプ・絵文字 一括リサイズ&ZIP作成ツール (2026年10月公式規格準拠)")
self.root.geometry("760x720")
self.root.resizable(True, True)
self.input_dir = tk.StringVar()
self.output_dir = tk.StringVar()
self.mode_var = tk.StringVar(value="sticker")
self.bg_remove_var = tk.BooleanVar(value=False)
self.bg_tol_var = tk.IntVar(value=30)
self.trim_var = tk.BooleanVar(value=True)
self.outline_var = tk.BooleanVar(value=False)
self.outline_width_var = tk.IntVar(value=6)
self.sticker_fixed_canvas_var = tk.BooleanVar(value=False)
self.gen_main_tab_var = tk.BooleanVar(value=True)
self.main_index_var = tk.IntVar(value=1)
self.tab_index_var = tk.IntVar(value=1)
self.create_zip_var = tk.BooleanVar(value=True)
self.zip_name_var = tk.StringVar(value="line_creators_pack.zip")
self._build_ui()
def _build_ui(self):
style = ttk.Style()
try:
style.theme_use("clam")
except Exception:
pass
main_frame = ttk.Frame(self.root, padding=14)
main_frame.pack(fill=tk.BOTH, expand=True)
# 1. フォルダ選択
folder_frame = ttk.LabelFrame(main_frame, text="1. 入出力フォルダ指定", padding=10)
folder_frame.pack(fill=tk.X, pady=(0, 8))
ttk.Label(folder_frame, text="画像フォルダ:").grid(row=0, column=0, sticky=tk.W, pady=3)
ttk.Entry(folder_frame, textvariable=self.input_dir, width=58).grid(row=0, column=1, padx=6, pady=3, sticky=tk.EW)
ttk.Button(folder_frame, text="参照...", command=self._select_input_dir).grid(row=0, column=2, pady=3)
ttk.Label(folder_frame, text="出力先フォルダ:").grid(row=1, column=0, sticky=tk.W, pady=3)
ttk.Entry(folder_frame, textvariable=self.output_dir, width=58).grid(row=1, column=1, padx=6, pady=3, sticky=tk.EW)
ttk.Button(folder_frame, text="参照...", command=self._select_output_dir).grid(row=1, column=2, pady=3)
folder_frame.columnconfigure(1, weight=1)
# 2. 作成モード選択
mode_frame = ttk.LabelFrame(main_frame, text="2. 作成タイプ(2026年10月 LINE Creators Market 規格)", padding=10)
mode_frame.pack(fill=tk.X, pady=(0, 8))
ttk.Radiobutton(
mode_frame,
text="LINEスタンプ(最大 370×320px / 縦横偶数 / 01.png〜 / 推奨 8,16,24,32,40個 / ZIP 60MB以下)",
variable=self.mode_var,
value="sticker",
command=self._on_mode_change,
).pack(anchor=tk.W, pady=2)
ttk.Radiobutton(
mode_frame,
text="LINE絵文字(180×180px 固定 / 001.png〜 / 推奨 8〜40個 / ZIP 20MB以下)",
variable=self.mode_var,
value="emoji",
command=self._on_mode_change,
).pack(anchor=tk.W, pady=2)
ttk.Radiobutton(
mode_frame,
text="メイン画像のみ一括変換(240×240px / main.png または連番)",
variable=self.mode_var,
value="main",
command=self._on_mode_change,
).pack(anchor=tk.W, pady=2)
ttk.Radiobutton(
mode_frame,
text="トークルームタブ画像のみ一括変換(96×74px / tab.png または連番)",
variable=self.mode_var,
value="tab",
command=self._on_mode_change,
).pack(anchor=tk.W, pady=2)
# 3. 画像加工オプション
opt_frame = ttk.LabelFrame(main_frame, text="3. 画像加工・規格調整オプション", padding=10)
opt_frame.pack(fill=tk.X, pady=(0, 8))
# 背景透過
bg_row = ttk.Frame(opt_frame)
bg_row.pack(fill=tk.X, pady=2)
ttk.Checkbutton(
bg_row,
text="背景を自動透過する(四隅の色を基準に透過)",
variable=self.bg_remove_var,
).pack(side=tk.LEFT)
ttk.Label(bg_row, text=" 許容値(0-100):").pack(side=tk.LEFT)
ttk.Spinbox(bg_row, from_=0, to=100, textvariable=self.bg_tol_var, width=5).pack(side=tk.LEFT, padx=4)
# 余白トリミング
ttk.Checkbutton(
opt_frame,
text="透明余白を自動トリミングしてイラストを最大化(余白4px確保)",
variable=self.trim_var,
).pack(anchor=tk.W, pady=2)
# 白フチ
outline_row = ttk.Frame(opt_frame)
outline_row.pack(fill=tk.X, pady=2)
ttk.Checkbutton(
outline_row,
text="白フチ(ステッカー風縁取り)を追加する",
variable=self.outline_var,
).pack(side=tk.LEFT)
ttk.Label(outline_row, text=" フチの太さ(px):").pack(side=tk.LEFT)
ttk.Spinbox(outline_row, from_=1, to=30, textvariable=self.outline_width_var, width=5).pack(side=tk.LEFT, padx=4)
# スタンプ370x320固定オプション
self.chk_fixed_canvas = ttk.Checkbutton(
opt_frame,
text="スタンプを常に 370×320px の透明キャンバス中央に配置する(オフ時はアスペクト比維持の偶数サイズ)",
variable=self.sticker_fixed_canvas_var,
)
self.chk_fixed_canvas.pack(anchor=tk.W, pady=2)
# 4. メイン画像・タブ画像・ZIP出力設定
pack_frame = ttk.LabelFrame(main_frame, text="4. パッケージ・ZIP出力設定", padding=10)
pack_frame.pack(fill=tk.X, pady=(0, 8))
self.chk_gen_main_tab = ttk.Checkbutton(
pack_frame,
text="メイン画像 (main.png: 240×240) と タブ画像 (tab.png: 96×74) を自動生成する",
variable=self.gen_main_tab_var,
)
self.chk_gen_main_tab.pack(anchor=tk.W, pady=2)
idx_row = ttk.Frame(pack_frame)
idx_row.pack(fill=tk.X, pady=2)
ttk.Label(idx_row, text=" main.png に使う画像番号:").pack(side=tk.LEFT)
ttk.Spinbox(idx_row, from_=1, to=9999, textvariable=self.main_index_var, width=6).pack(side=tk.LEFT, padx=(4, 16))
ttk.Label(idx_row, text="tab.png に使う画像番号:").pack(side=tk.LEFT)
ttk.Spinbox(idx_row, from_=1, to=9999, textvariable=self.tab_index_var, width=6).pack(side=tk.LEFT, padx=4)
zip_row = ttk.Frame(pack_frame)
zip_row.pack(fill=tk.X, pady=2)
ttk.Checkbutton(
zip_row,
text="提出用ZIPファイルを自動作成する",
variable=self.create_zip_var,
).pack(side=tk.LEFT)
ttk.Label(zip_row, text=" ZIPファイル名:").pack(side=tk.LEFT)
ttk.Entry(zip_row, textvariable=self.zip_name_var, width=28).pack(side=tk.LEFT, padx=4)
# 5. 実行ボタン & プログレスバー
action_frame = ttk.Frame(main_frame)
action_frame.pack(fill=tk.X, pady=(4, 6))
self.run_btn = ttk.Button(
action_frame,
text="一括変換&ZIP作成を実行",
command=self._start_processing,
)
self.run_btn.pack(side=tk.LEFT, ipadx=16, ipady=6)
self.status_label = ttk.Label(action_frame, text="待機中(画像枚数に上限はありません)")
self.status_label.pack(side=tk.LEFT, padx=12)
self.progress = ttk.Progressbar(main_frame, mode="determinate")
self.progress.pack(fill=tk.X, pady=(0, 8))
# 6. ログ表示
log_frame = ttk.LabelFrame(main_frame, text="処理ログ・規格チェック結果", padding=6)
log_frame.pack(fill=tk.BOTH, expand=True)
self.log_text = tk.Text(log_frame, height=11, state=tk.DISABLED, font=("Consolas", 9))
scrollbar = ttk.Scrollbar(log_frame, command=self.log_text.yview)
self.log_text.configure(yscrollcommand=scrollbar.set)
self.log_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
def _on_mode_change(self):
mode = self.mode_var.get()
if mode == "sticker":
self.chk_fixed_canvas.configure(state=tk.NORMAL)
self.chk_gen_main_tab.configure(state=tk.NORMAL)
self.zip_name_var.set("line_stickers_pack.zip")
elif mode == "emoji":
self.chk_fixed_canvas.configure(state=tk.DISABLED)
self.chk_gen_main_tab.configure(state=tk.NORMAL)
self.zip_name_var.set("line_emoji_pack.zip")
else:
self.chk_fixed_canvas.configure(state=tk.DISABLED)
self.chk_gen_main_tab.configure(state=tk.DISABLED)
self.zip_name_var.set(f"line_{mode}_images.zip")
def _select_input_dir(self):
path = filedialog.askdirectory(title="元画像が入っているフォルダを選択")
if path:
self.input_dir.set(path)
if not self.output_dir.get():
self.output_dir.set(os.path.join(path, "line_output"))
def _select_output_dir(self):
path = filedialog.askdirectory(title="出力先フォルダを選択")
if path:
self.output_dir.set(path)
def _log(self, msg: str):
def append():
self.log_text.configure(state=tk.NORMAL)
self.log_text.insert(tk.END, msg + "\n")
self.log_text.see(tk.END)
self.log_text.configure(state=tk.DISABLED)
self.root.after(0, append)
def _start_processing(self):
in_dir = self.input_dir.get().strip()
out_dir = self.output_dir.get().strip()
if not in_dir or not os.path.isdir(in_dir):
messagebox.showerror("エラー", "有効な入力画像フォルダを選択してください。")
return
if not out_dir:
out_dir = os.path.join(in_dir, "line_output")
self.output_dir.set(out_dir)
self.run_btn.configure(state=tk.DISABLED)
self.log_text.configure(state=tk.NORMAL)
self.log_text.delete("1.0", tk.END)
self.log_text.configure(state=tk.DISABLED)
thread = threading.Thread(target=self._process_images, args=(in_dir, out_dir), daemon=True)
thread.start()
def _prepare_base_image(self, img_path: Path) -> Image.Image:
img = Image.open(img_path).convert("RGBA")
if self.bg_remove_var.get():
img = remove_background_floodfill(img, tolerance=self.bg_tol_var.get())
if self.trim_var.get():
img = trim_transparent(img, margin=4)
if self.outline_var.get():
img = add_white_outline(img, border_width=self.outline_width_var.get())
return img
def _process_images(self, in_dir: str, out_dir: str):
try:
os.makedirs(out_dir, exist_ok=True)
files = sorted(
[
p for p in Path(in_dir).iterdir()
if p.is_file() and p.suffix.lower() in self.SUPPORTED_EXTS
],
key=lambda x: x.name.lower(),
)
total = len(files)
if total == 0:
self._log("エラー: 指定フォルダ内に対象画像 (PNG/JPG/WEBP/BMP) が見つかりませんでした。")
self.root.after(0, lambda: self.run_btn.configure(state=tk.NORMAL))
return
mode = self.mode_var.get()
self._log(f"検出画像数: {total} 枚 | モード: {mode}")
# 規格個数チェック(参考警告)
if mode == "sticker" and total not in {8, 16, 24, 32, 40}:
self._log(f"[注意] LINEスタンプの販売個数規定は 8・16・24・32・40個 です(現在 {total} 枚)。全枚数を連番出力します。")
elif mode == "emoji" and not (8 <= total <= 40):
self._log(f"[注意] LINE絵文字の販売個数規定は 8〜40個 です(現在 {total} 枚)。全枚数を連番出力します。")
self.root.after(0, lambda: self.progress.configure(maximum=total, value=0))
generated_files = []
# 連番桁数(通常2桁または3桁、100枚以上も自動対応)
pad_digits = 3 if (mode == "emoji" or total >= 100) else 2
base_images_cache = {}
main_idx = self.main_index_var.get()
tab_idx = self.tab_index_var.get()
for idx, file_path in enumerate(files, start=1):
self.root.after(0, lambda i=idx: self.status_label.configure(text=f"処理中: {i} / {total} 枚 ({file_path.name})"))
base_img = self._prepare_base_image(file_path)
# main / tab 用に必要なら保持
if self.gen_main_tab_var.get() and mode in ("sticker", "emoji"):
if idx == main_idx:
base_images_cache["main"] = base_img.copy()
if idx == tab_idx:
base_images_cache["tab"] = base_img.copy()
# リサイズ実行
out_img = resize_for_line(
base_img,
mode=mode,
fit_exact=self.sticker_fixed_canvas_var.get(),
)
# ファイル名決定
if mode == "main" and total == 1:
out_name = "main.png"
elif mode == "tab" and total == 1:
out_name = "tab.png"
else:
out_name = f"{idx:0{pad_digits}d}.png"
png_bytes = compress_png_under_limit(out_img, max_bytes=1024 * 1024)
out_path = os.path.join(out_dir, out_name)
with open(out_path, "wb") as f:
f.write(png_bytes)
generated_files.append(out_path)
w, h = out_img.size
kb = len(png_bytes) / 1024
self._log(f"OK: {file_path.name} -> {out_name} ({w}x{h}px, {kb:.1f}KB)")
self.root.after(0, lambda i=idx: self.progress.configure(value=i))
# main.png / tab.png 自動生成
if self.gen_main_tab_var.get() and mode in ("sticker", "emoji"):
# 指定番号が範囲外なら1枚目を使用
if "main" not in base_images_cache:
base_images_cache["main"] = self._prepare_base_image(files[0])
if "tab" not in base_images_cache:
base_images_cache["tab"] = self._prepare_base_image(files[0])
main_img = resize_for_line(base_images_cache["main"], mode="main")
main_bytes = compress_png_under_limit(main_img, max_bytes=1024 * 1024)
main_path = os.path.join(out_dir, "main.png")
with open(main_path, "wb") as f:
f.write(main_bytes)
generated_files.append(main_path)
self._log(f"OK: メイン画像 -> main.png (240x240px, {len(main_bytes)/1024:.1f}KB)")
tab_img = resize_for_line(base_images_cache["tab"], mode="tab")
tab_bytes = compress_png_under_limit(tab_img, max_bytes=1024 * 1024)
tab_path = os.path.join(out_dir, "tab.png")
with open(tab_path, "wb") as f:
f.write(tab_bytes)
generated_files.append(tab_path)
self._log(f"OK: トークルームタブ画像 -> tab.png (96x74px, {len(tab_bytes)/1024:.1f}KB)")
# ZIP圧縮
if self.create_zip_var.get():
zip_filename = self.zip_name_var.get().strip() or "line_creators_pack.zip"
if not zip_filename.lower().endswith(".zip"):
zip_filename += ".zip"
zip_path = os.path.join(out_dir, zip_filename)
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for fp in generated_files:
zf.write(fp, arcname=os.path.basename(fp))
zip_mb = os.path.getsize(zip_path) / (1024 * 1024)
limit_mb = 20.0 if mode == "emoji" else 60.0
self._log(f"\nZIP作成完了: {zip_path} ({zip_mb:.2f} MB / 上限 {limit_mb:.0f} MB)")
if zip_mb > limit_mb:
self._log(f"[警告] ZIP容量がLINE規定上限 ({limit_mb:.0f}MB) を超えています!")
self.root.after(0, lambda: self.status_label.configure(text=f"完了!全 {total} 枚を処理しました"))
self.root.after(0, lambda: messagebox.showinfo("完了", f"全 {total} 枚の変換が完了しました!\n出力先: {out_dir}"))
except Exception as e:
self._log(f"[エラー発生] {e}")
self.root.after(0, lambda: messagebox.showerror("エラー", f"処理中にエラーが発生しました:\n{e}"))
finally:
self.root.after(0, lambda: self.run_btn.configure(state=tk.NORMAL))
def main():
root = tk.Tk()
app = LineCreatorResizerApp(root)
root.mainloop()
if __name__ == "__main__":
main()









ディスカッション
コメント一覧
まだ、コメントがありません