Created
April 4, 2026 08:06
-
-
Save kuluna/70ad753b61bb35b60b3fb22d1783f787 to your computer and use it in GitHub Desktop.
クリスタのファイル内のSQLiteを操作して擬似的にレイヤーカンプができる
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """CLIP STUDIO PAINT (.clip) ファイルのレイヤーカンプ(可視性の保存・復元)ツール""" | |
| import argparse | |
| import json | |
| import os | |
| import sqlite3 | |
| import struct | |
| import sys | |
| import tempfile | |
| CSFCHUNK_HEADER = struct.Struct(">8sQQ") # magic, filesize, offset | |
| CHUNK_HEADER = struct.Struct(">8sQ") # type, length | |
| CHUNK_SQLITE = b"CHNKSQLi" | |
| # --- .clip バイナリ操作(cliprename.py と共通) --- | |
| def find_sqlite_chunk(data: bytes) -> tuple[int, int]: | |
| """CHNKSQLiチャンクのデータ開始位置とサイズを返す。""" | |
| _, filesize, _ = CSFCHUNK_HEADER.unpack_from(data, 0) | |
| pos = CSFCHUNK_HEADER.size | |
| while pos < filesize: | |
| chunk_type, length = CHUNK_HEADER.unpack_from(data, pos) | |
| if chunk_type == CHUNK_SQLITE: | |
| return (pos + CHUNK_HEADER.size, length) | |
| pos += CHUNK_HEADER.size + length | |
| raise ValueError("CHNKSQLi チャンクが見つかりません") | |
| def rebuild_clip( | |
| original: bytes, sqlite_offset: int, old_size: int, new_sqlite: bytes | |
| ) -> bytes: | |
| """元の.clipデータにSQLiteを差し替えて再構築する。""" | |
| size_diff = len(new_sqlite) - old_size | |
| _, old_filesize, offset = CSFCHUNK_HEADER.unpack_from(original, 0) | |
| new_filesize = old_filesize + size_diff | |
| new_header = CSFCHUNK_HEADER.pack(b"CSFCHUNK", new_filesize, offset) | |
| chunk_header_offset = sqlite_offset - CHUNK_HEADER.size | |
| new_chunk_header = CHUNK_HEADER.pack(CHUNK_SQLITE, len(new_sqlite)) | |
| return ( | |
| new_header | |
| + original[CSFCHUNK_HEADER.size : chunk_header_offset] | |
| + new_chunk_header | |
| + new_sqlite | |
| + original[sqlite_offset + old_size :] | |
| ) | |
| def extract_sqlite(data: bytes) -> tuple[bytes, int, int]: | |
| """clipデータからSQLiteバイト列、オフセット、サイズを返す。""" | |
| offset, size = find_sqlite_chunk(data) | |
| return data[offset : offset + size], offset, size | |
| def query_layers(sqlite_data: bytes): | |
| """SQLiteデータからレイヤー情報のリストを返す。""" | |
| with tempfile.NamedTemporaryFile(suffix=".sqlite3", delete=False) as tmp: | |
| tmp.write(sqlite_data) | |
| tmp_path = tmp.name | |
| try: | |
| conn = sqlite3.connect(tmp_path) | |
| cur = conn.cursor() | |
| cur.execute( | |
| "SELECT MainId, LayerName, LayerVisibility, LayerOpacity, " | |
| "LayerFolder, LayerNextIndex, LayerFirstChildIndex FROM Layer" | |
| ) | |
| rows = cur.fetchall() | |
| conn.close() | |
| return rows | |
| finally: | |
| os.unlink(tmp_path) | |
| # --- サブコマンド --- | |
| def cmd_show(args): | |
| """現在のレイヤー状態をツリー表示する。""" | |
| with open(args.input, "rb") as f: | |
| data = f.read() | |
| sqlite_data, _, _ = extract_sqlite(data) | |
| rows = query_layers(sqlite_data) | |
| # MainId -> row のマップを作成 | |
| layer_map = {} | |
| for row in rows: | |
| main_id, name, visible, opacity, folder, next_idx, first_child = row | |
| layer_map[main_id] = { | |
| "name": name or "(root)", | |
| "visible": visible, | |
| "opacity": opacity, | |
| "folder": folder, | |
| "next": next_idx, | |
| "first_child": first_child, | |
| } | |
| # ツリー構造を再帰的に表示 | |
| def print_tree(node_id, indent=""): | |
| if node_id == 0 or node_id not in layer_map: | |
| return | |
| node = layer_map[node_id] | |
| vis = "👁" if node["visible"] else " " | |
| kind = "📁" if node["folder"] else " " | |
| print(f" {vis} {kind} {node['name']}") | |
| if node["first_child"]: | |
| print_tree(node["first_child"], indent + " ") | |
| if node["next"]: | |
| print_tree(node["next"], indent) | |
| print(f"レイヤー構成: {args.input}") | |
| # ルートフォルダ(LayerType=256, 空名)を探す | |
| root_id = None | |
| for mid, info in layer_map.items(): | |
| if info["name"] == "(root)" and info["folder"]: | |
| root_id = mid | |
| break | |
| if root_id and layer_map[root_id]["first_child"]: | |
| print_tree(layer_map[root_id]["first_child"]) | |
| else: | |
| # フォールバック: フラットに全レイヤー表示 | |
| for mid, info in layer_map.items(): | |
| if info["name"] == "(root)": | |
| continue | |
| vis = "👁" if info["visible"] else " " | |
| kind = "📁" if info["folder"] else " " | |
| print(f" {vis} {kind} {info['name']}") | |
| def cmd_save(args): | |
| """現在のレイヤー可視性をカンプとして保存する。""" | |
| with open(args.input, "rb") as f: | |
| data = f.read() | |
| sqlite_data, _, _ = extract_sqlite(data) | |
| rows = query_layers(sqlite_data) | |
| # 既存のカンプファイルを読み込み(なければ新規作成) | |
| comps = {"comps": {}} | |
| if os.path.exists(args.comps): | |
| with open(args.comps, "r", encoding="utf-8") as f: | |
| comps = json.load(f) | |
| if args.name in comps["comps"]: | |
| print(f"カンプ '{args.name}' を上書きします") | |
| layers = {} | |
| for row in rows: | |
| main_id, name, visible, opacity, folder, _, _ = row | |
| layers[str(main_id)] = { | |
| "name": name or "", | |
| "visible": visible, | |
| } | |
| comps["comps"][args.name] = { | |
| "description": "", | |
| "layers": layers, | |
| } | |
| with open(args.comps, "w", encoding="utf-8") as f: | |
| json.dump(comps, f, ensure_ascii=False, indent=2) | |
| print(f"カンプ '{args.name}' を保存しました -> {args.comps}") | |
| for mid, info in layers.items(): | |
| vis = "ON " if info["visible"] else "OFF" | |
| print(f" [{vis}] {info['name'] or '(root)'}") | |
| def cmd_list(args): | |
| """カンプ一覧を表示する。""" | |
| if not os.path.exists(args.comps): | |
| print(f"カンプファイルが見つかりません: {args.comps}") | |
| return | |
| with open(args.comps, "r", encoding="utf-8") as f: | |
| comps = json.load(f) | |
| if not comps["comps"]: | |
| print("カンプが登録されていません") | |
| return | |
| print(f"カンプ一覧 ({args.comps}):") | |
| for name, comp in comps["comps"].items(): | |
| layer_count = len(comp["layers"]) | |
| visible_count = sum(1 for l in comp["layers"].values() if l["visible"]) | |
| desc = f" - {comp['description']}" if comp.get("description") else "" | |
| print(f" {name} ({visible_count}/{layer_count} 表示){desc}") | |
| def cmd_apply(args): | |
| """カンプを.clipファイルに適用する。""" | |
| if not os.path.exists(args.comps): | |
| print(f"カンプファイルが見つかりません: {args.comps}") | |
| sys.exit(1) | |
| with open(args.comps, "r", encoding="utf-8") as f: | |
| comps = json.load(f) | |
| if args.name not in comps["comps"]: | |
| print(f"カンプ '{args.name}' が見つかりません") | |
| print(f"利用可能: {', '.join(comps['comps'].keys())}") | |
| sys.exit(1) | |
| comp = comps["comps"][args.name] | |
| with open(args.input, "rb") as f: | |
| data = f.read() | |
| sqlite_data, sqlite_offset, sqlite_size = extract_sqlite(data) | |
| with tempfile.NamedTemporaryFile(suffix=".sqlite3", delete=False) as tmp: | |
| tmp.write(sqlite_data) | |
| tmp_path = tmp.name | |
| try: | |
| conn = sqlite3.connect(tmp_path) | |
| cur = conn.cursor() | |
| changed = 0 | |
| for main_id_str, layer_info in comp["layers"].items(): | |
| main_id = int(main_id_str) | |
| cur.execute( | |
| "SELECT LayerVisibility, LayerName FROM Layer WHERE MainId = ?", | |
| (main_id,), | |
| ) | |
| row = cur.fetchone() | |
| if row is None: | |
| print(f" 警告: MainId={main_id} ({layer_info['name']}) が見つかりません(スキップ)") | |
| continue | |
| old_vis, name = row | |
| new_vis = layer_info["visible"] | |
| if old_vis != new_vis: | |
| cur.execute( | |
| "UPDATE Layer SET LayerVisibility = ? WHERE MainId = ?", | |
| (new_vis, main_id), | |
| ) | |
| old_state = "ON" if old_vis else "OFF" | |
| new_state = "ON" if new_vis else "OFF" | |
| print(f" {name or '(root)'}: {old_state} -> {new_state}") | |
| changed += 1 | |
| conn.commit() | |
| conn.close() | |
| if changed == 0: | |
| print(" (変更なし)") | |
| with open(tmp_path, "rb") as f: | |
| new_sqlite = f.read() | |
| finally: | |
| os.unlink(tmp_path) | |
| result = rebuild_clip(data, sqlite_offset, sqlite_size, new_sqlite) | |
| with open(args.output, "wb") as f: | |
| f.write(result) | |
| print(f"カンプ '{args.name}' を適用 -> {args.output}") | |
| def main(): | |
| parser = argparse.ArgumentParser( | |
| description=".clip ファイルのレイヤーカンプ管理ツール" | |
| ) | |
| subparsers = parser.add_subparsers(dest="command", required=True) | |
| # show | |
| p_show = subparsers.add_parser("show", help="レイヤー状態を表示") | |
| p_show.add_argument("-i", "--input", required=True, help="入力 .clip ファイル") | |
| # save | |
| p_save = subparsers.add_parser("save", help="現在の可視性をカンプとして保存") | |
| p_save.add_argument("-i", "--input", required=True, help="入力 .clip ファイル") | |
| p_save.add_argument("-c", "--comps", required=True, help="カンプ設定ファイル (JSON)") | |
| p_save.add_argument("--name", required=True, help="カンプ名") | |
| # list | |
| p_list = subparsers.add_parser("list", help="カンプ一覧を表示") | |
| p_list.add_argument("-c", "--comps", required=True, help="カンプ設定ファイル (JSON)") | |
| # apply | |
| p_apply = subparsers.add_parser("apply", help="カンプを .clip に適用") | |
| p_apply.add_argument("-i", "--input", required=True, help="入力 .clip ファイル") | |
| p_apply.add_argument("-o", "--output", required=True, help="出力 .clip ファイル") | |
| p_apply.add_argument("-c", "--comps", required=True, help="カンプ設定ファイル (JSON)") | |
| p_apply.add_argument("--name", required=True, help="適用するカンプ名") | |
| args = parser.parse_args() | |
| if args.command == "show": | |
| cmd_show(args) | |
| elif args.command == "save": | |
| cmd_save(args) | |
| elif args.command == "list": | |
| cmd_list(args) | |
| elif args.command == "apply": | |
| cmd_apply(args) | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment