前回は、FlameでのACES環境とCDLの扱いについて整理しました。

その中で、.cccファイルを使ったショット単位のCDL適用について触れましたが、
Flame標準機能だけでは自動適用が難しい場面があります。

今回は、.cccファイルからCDL情報を取得し、Lookノードへ適用するPython Hookについて整理します。

なぜPythonが必要か

Flameでは、EDLを使った場合、ショット単位でCDLが適用されますが、.cccファイルのみ渡されるケースでは、
自動適用の仕組みがありません。

そのため、

  • ショットごとに手動で設定する必要がある
  • ミスが発生しやすい

といった問題が出てきます。

実装と処理

.cccファイルには複数のCDL情報が含まれており、それぞれにCCCIDが割り当てられています。

このCCCIDとFlameのsource_nameを照合することで、ショットとCDLを紐付けることができます。

処理の流れは以下の通りです。

  1. .cccファイルを読み込み
  2. CCCIDとCDL値を辞書として保持
  3. タイムラインセグメントを取得
  4. source_nameとCCCIDを照合
  5. Lookノードを取得または新規作成
  6. CDL値(Slope / Offset / Power / Saturation)を適用

既存のLookノードがある場合は再利用し、無い場合のみ新規作成するようにしています。

この処理をベースにスクリプトを組んでいます。

まず、.cccファイルからCCCIDごとのCDL値を取得、CCCIDとショットの紐付きを確認しましたが、
最初は一致せず、マッチしない状態でした。

ポイントは2つありました。

一つは、.cccのCCCIDをFlameのショットと一致させる方法です。

最初は、source_name / file_path / tape_nameのいずれを使うか検証しましたが、
今回の素材では、source_nameをそのまま使うことで、CCCIDと一致することを確認しました。

実際の確認ログは以下の通りです。

もう一つは、複数あるタイムラインセグメントへの適用方法です。

current_segment.parentからトラックを取得し、track.segments を使うことで、
トラック内の全セグメントに対して処理を行えることを確認しました。

実際の確認ログは以下の通りです。

つまり今回は、

  • CCCIDとsource_nameの一致(キーの特定)
  • track.segmentsによる複数ショット処理(適用範囲)

両方が成立したことで、EDLなしでも実用的な運用が可能になります。

■ 補足:Lookノード Working Spaceについて

今回の実装では、LookノードにCDL値を適用することはできますが、
Working Space(カラースペース)をPythonから直接設定することができませんでした。

そのため、LookノードのWorking Spaceについては、あらかじめACEScctを設定した状態で
ノードセットアップとして保存し、新規作成時にそのノードセットアップを読み込む形にしています。

# 新規 Look 作成時に読み込むテンプレート
LOOK_SETUP_ACESCCT = "/Volumes/StorageMedia/Apple_Project/Import_CDL/setups/look/ACEScct.look_node"

この方法により、

  • CDL値はショット単位で適用
  • Working Spaceはプロジェクト単位で統一

という役割分担で運用することができます。

スクリプト構成

今回のスクリプトは、以下のような構成で整理しています。

処理としては、

という流れになります。

ccc_apply_timeline_hook.py
import xml.etree.ElementTree as ET
import flame
import os
import re

# .ccc を置いておくフォルダ
CCC_DIR = "/Volumes/StorageMedia/Apple_Project/Import_CDL/setups/colour_mgmt/Resolve_CDL"

# 新規 Look 作成時に読み込むテンプレート
LOOK_SETUP_ACESCCT = "/Volumes/StorageMedia/Apple_Project/Import_CDL/setups/look/ACEScct.look_node"


def normalize(name):
    if name is None:
        return ""

    name = os.path.basename(str(name))
    name = re.sub(r"\.[^.]+$", "", name)        # 拡張子削除
    name = re.sub(r"_\d+-\d+_?$", "", name)     # Resolve由来の末尾範囲を削除
    return name


def load_ccc(path):
    tree = ET.parse(path)
    root = tree.getroot()

    data = {}

    for cc in root.iter():
        if cc.tag.split("}")[-1] != "ColorCorrection":
            continue

        cid = normalize(cc.get("id"))

        slope = None
        offset = None
        power = None
        sat = None

        for elem in cc.iter():
            tag = elem.tag.split("}")[-1]
            txt = elem.text.strip() if elem.text else None

            if tag == "Slope":
                slope = txt
            elif tag == "Offset":
                offset = txt
            elif tag == "Power":
                power = txt
            elif tag == "Saturation":
                sat = txt

        if cid and slope and offset and power and sat:
            data[cid] = {
                "slope": [float(x) for x in slope.split()],
                "offset": [float(x) for x in offset.split()],
                "power": [float(x) for x in power.split()],
                "sat": float(sat),
            }

    return data


def find_look_fx(seg):
    for fx in seg.effects:
        try:
            if str(fx.type) == "Look":
                return fx
        except Exception:
            pass
    return None


def create_look_fx(seg):
    fx = seg.create_effect("Look")

    if os.path.isfile(LOOK_SETUP_ACESCCT):
        try:
            fx.load_setup(LOOK_SETUP_ACESCCT)
            print("LOADED SETUP:", LOOK_SETUP_ACESCCT)
        except Exception as e:
            print("WARNING: failed to load setup:", e)
    else:
        print("WARNING: setup file not found:", LOOK_SETUP_ACESCCT)

    return fx


def get_or_create_look_fx(seg):
    fx = find_look_fx(seg)

    if fx is not None:
        return fx, False  # reused

    fx = create_look_fx(seg)
    return fx, True       # created


def apply_cdl(seg, ccc):
    shot_name = normalize(seg.source_name)

    if shot_name not in ccc:
        print("NO MATCH:", shot_name)
        return False, shot_name

    values = ccc[shot_name]
    fx, created = get_or_create_look_fx(seg)

    fx.slope_red = values["slope"][0]
    fx.slope_green = values["slope"][1]
    fx.slope_blue = values["slope"][2]

    fx.offset_red = values["offset"][0]
    fx.offset_green = values["offset"][1]
    fx.offset_blue = values["offset"][2]

    fx.power_red = values["power"][0]
    fx.power_green = values["power"][1]
    fx.power_blue = values["power"][2]

    fx.saturation = values["sat"]

    if created:
        print("CREATED + APPLIED:", shot_name)
    else:
        print("REUSED + APPLIED:", shot_name)

    return True, shot_name


def selection_has_segments(selection):
    for item in selection:
        try:
            if str(item.type) == "Video Segment":
                return True
        except Exception:
            pass
    return False


def execute_apply_ccc_from_file(selection, ccc_file):
    if not os.path.isfile(ccc_file):
        print("CCC file not found:", ccc_file)
        return

    print("CCC FILE:", ccc_file)
    ccc = load_ccc(ccc_file)

    applied = []
    no_match = []

    print("\n=== APPLY START ===")

    for item in selection:
        try:
            if str(item.type) != "Video Segment":
                continue
        except Exception:
            continue

        ok, shot = apply_cdl(item, ccc)
        if ok:
            applied.append(shot)
        else:
            no_match.append(shot)

    print("\n=== SUMMARY ===")
    print("Applied:", len(applied))
    print("No Match:", len(no_match))

    if no_match:
        print("Unmatched shots:")
        for shot in no_match:
            print("  -", shot)

    print("\n=== DONE ===")


def make_execute_callback(ccc_file):
    def _execute(selection):
        execute_apply_ccc_from_file(selection, ccc_file)
    return _execute


def list_ccc_files():
    if not os.path.isdir(CCC_DIR):
        return []

    files = []
    for name in sorted(os.listdir(CCC_DIR)):
        if name.lower().endswith(".ccc"):
            files.append(os.path.join(CCC_DIR, name))
    return files


def get_timeline_custom_ui_actions():
    actions = []
    ccc_files = list_ccc_files()

    if not ccc_files:
        return [
            {
                "name": "ccc_files",
                "actions": [
                    {
                        "name": "No .ccc files found",
                        "isVisible": selection_has_segments,
                        "execute": lambda selection: print("No .ccc files found in:", CCC_DIR),
                        "minimumVersion": "2023"
                    }
                ]
            }
        ]

    for ccc_file in ccc_files:
        display_name = os.path.basename(ccc_file)
        actions.append(
            {
                "name": f"{display_name}",
                "isVisible": selection_has_segments,
                "execute": make_execute_callback(ccc_file),
                "minimumVersion": "2023"
            }
        )

    return [
        {
            "name": "ccc_Tools",
            "actions": actions
        }
    ]

まとめ

今回の方法により、.cccファイルのみでもショット単位でのCDL適用を自動化することができました。

Flame標準機能では難しい部分も、Python Hookを使うことで実務レベルで対応可能になります。

次は、View TransformとContext Variableを使った、
表示側でのCDL制御について整理します。