Skip to content

Instantly share code, notes, and snippets.

@kuluna
Created April 4, 2026 08:06
Show Gist options
  • Select an option

  • Save kuluna/70ad753b61bb35b60b3fb22d1783f787 to your computer and use it in GitHub Desktop.

Select an option

Save kuluna/70ad753b61bb35b60b3fb22d1783f787 to your computer and use it in GitHub Desktop.
クリスタのファイル内のSQLiteを操作して擬似的にレイヤーカンプができる
#!/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