$ cat node-template.py

V

Video Color Match

// Matches the color grading of a video to a reference image. Extracts the color profile from the reference and applies it to the video. Strength parameter controls transfer intensity.

Process
Video
template.py
1import os2import sys3import json4import subprocess5import traceback67import numpy as np89INPUT_DIR = "/data/input"10OUTPUT_DIR = "/data/output"11FFMPEG_IMAGE = f"emblema/ffmpeg:{os.getenv('EMBLEMA_VERSION', 'dev')}"121314# ---------------------------------------------------------------------------15# Image loading helpers (ffmpeg-based, no PIL needed)16# ---------------------------------------------------------------------------1718def _get_image_dimensions(probe_cmd, host_input, host_output, input_path):19    """Run ffprobe in a sibling container and return (width, height)."""20    shell_script = (21        f"ffprobe -v error -select_streams v:0 "22        f"-show_entries stream=width,height -of csv=s=x:p=0 '{input_path}'"23    )24    cmd = [25        "docker", "run", "--rm",26        "--network", "none",27        "--memory", "512m",28        "--cpus", "0.5",29        "-v", f"{host_input}:/data/input:ro",30        "-v", f"{host_output}:/data/output:rw",31        "--entrypoint", "sh",32        FFMPEG_IMAGE,33        "-c", shell_script,34    ]35    result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)36    if result.returncode != 0:37        raise RuntimeError(f"ffprobe failed: {result.stderr[-1000:]}")38    dims = result.stdout.strip().split("x")39    return int(dims[0]), int(dims[1])404142def load_image_as_numpy(filename, host_input, host_output):43    """Load an image from INPUT_DIR as a (H, W, 3) uint8 numpy array via ffmpeg."""44    input_path = f"/data/input/{filename}"45    raw_file = "_ref_raw.rgb"46    raw_local = os.path.join(OUTPUT_DIR, raw_file)4748    # Get dimensions49    w, h = _get_image_dimensions("ffprobe", host_input, host_output, input_path)5051    # Decode to raw RGB2452    shell_script = (53        f"ffmpeg -i '{input_path}' -vframes 1 -f rawvideo -pix_fmt rgb24 "54        f"-y '/data/output/{raw_file}'"55    )56    cmd = [57        "docker", "run", "--rm",58        "--network", "none",59        "--memory", "512m",60        "--cpus", "0.5",61        "-v", f"{host_input}:/data/input:ro",62        "-v", f"{host_output}:/data/output:rw",63        "--entrypoint", "sh",64        FFMPEG_IMAGE,65        "-c", shell_script,66    ]67    result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)68    if result.returncode != 0:69        raise RuntimeError(f"ffmpeg raw decode failed: {result.stderr[-1000:]}")7071    expected_size = w * h * 372    actual_size = os.path.getsize(raw_local)73    if actual_size != expected_size:74        raise RuntimeError(75            f"Raw file size mismatch: expected {expected_size} bytes ({w}x{h}x3), got {actual_size}"76        )7778    arr = np.fromfile(raw_local, dtype=np.uint8).reshape(h, w, 3)79    os.remove(raw_local)80    return arr818283def extract_first_frame_as_numpy(video_filename, host_input, host_output):84    """Extract the first frame from a video as a (H, W, 3) uint8 numpy array."""85    input_path = f"/data/input/{video_filename}"86    raw_file = "_target_raw.rgb"87    raw_local = os.path.join(OUTPUT_DIR, raw_file)8889    # Get dimensions90    w, h = _get_image_dimensions("ffprobe", host_input, host_output, input_path)9192    # Extract first frame as raw RGB2493    shell_script = (94        f"ffmpeg -i '{input_path}' -vframes 1 -f rawvideo -pix_fmt rgb24 "95        f"-y '/data/output/{raw_file}'"96    )97    cmd = [98        "docker", "run", "--rm",99        "--network", "none",100        "--memory", "1g",101        "--cpus", "1.0",102        "-v", f"{host_input}:/data/input:ro",103        "-v", f"{host_output}:/data/output:rw",104        "--entrypoint", "sh",105        FFMPEG_IMAGE,106        "-c", shell_script,107    ]108    result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)109    if result.returncode != 0:110        raise RuntimeError(f"Frame extraction failed: {result.stderr[-2000:]}")111112    expected_size = w * h * 3113    actual_size = os.path.getsize(raw_local)114    if actual_size != expected_size:115        raise RuntimeError(116            f"Raw file size mismatch: expected {expected_size} bytes ({w}x{h}x3), got {actual_size}"117        )118119    arr = np.fromfile(raw_local, dtype=np.uint8).reshape(h, w, 3)120    os.remove(raw_local)121    return arr122123124# ---------------------------------------------------------------------------125# sRGB <-> CIE LAB conversion (no scipy needed)126# ---------------------------------------------------------------------------127128def srgb_to_linear(rgb):129    """Convert sRGB [0,1] to linear RGB."""130    rgb = np.clip(rgb, 0.0, 1.0)131    mask = rgb <= 0.04045132    out = np.where(mask, rgb / 12.92, ((rgb + 0.055) / 1.055) ** 2.4)133    return out134135136def linear_to_srgb(rgb):137    """Convert linear RGB to sRGB [0,1]."""138    rgb = np.clip(rgb, 0.0, 1.0)139    mask = rgb <= 0.0031308140    out = np.where(mask, rgb * 12.92, 1.055 * (rgb ** (1.0 / 2.4)) - 0.055)141    return np.clip(out, 0.0, 1.0)142143144def rgb_to_xyz(rgb_linear):145    """Linear RGB to XYZ D65."""146    # sRGB -> XYZ matrix (D65)147    M = np.array([148        [0.4124564, 0.3575761, 0.1804375],149        [0.2126729, 0.7151522, 0.0721750],150        [0.0193339, 0.1191920, 0.9503041],151    ])152    return rgb_linear @ M.T153154155def xyz_to_rgb(xyz):156    """XYZ D65 to linear RGB."""157    M_inv = np.array([158        [ 3.2404542, -1.5371385, -0.4985314],159        [-0.9692660,  1.8760108,  0.0415560],160        [ 0.0556434, -0.2040259,  1.0572252],161    ])162    return xyz @ M_inv.T163164165def xyz_to_lab(xyz):166    """XYZ to CIE LAB (D65 illuminant)."""167    # D65 reference white168    ref = np.array([0.95047, 1.00000, 1.08883])169    xyz_n = xyz / ref170171    delta = 6.0 / 29.0172    delta_sq = delta ** 2173    delta_cb = delta ** 3174175    mask = xyz_n > delta_cb176    f = np.where(mask, xyz_n ** (1.0 / 3.0), xyz_n / (3.0 * delta_sq) + 4.0 / 29.0)177178    L = 116.0 * f[..., 1] - 16.0179    a = 500.0 * (f[..., 0] - f[..., 1])180    b = 200.0 * (f[..., 1] - f[..., 2])181182    return np.stack([L, a, b], axis=-1)183184185def lab_to_xyz(lab):186    """CIE LAB to XYZ (D65 illuminant)."""187    ref = np.array([0.95047, 1.00000, 1.08883])188    delta = 6.0 / 29.0189190    L, a, b = lab[..., 0], lab[..., 1], lab[..., 2]191    fy = (L + 16.0) / 116.0192    fx = a / 500.0 + fy193    fz = fy - b / 200.0194195    f_vals = np.stack([fx, fy, fz], axis=-1)196    mask = f_vals > delta197    xyz_n = np.where(mask, f_vals ** 3, 3.0 * (delta ** 2) * (f_vals - 4.0 / 29.0))198199    return xyz_n * ref200201202def srgb_to_lab(srgb):203    """sRGB [0,1] -> LAB."""204    return xyz_to_lab(rgb_to_xyz(srgb_to_linear(srgb)))205206207def lab_to_srgb(lab):208    """LAB -> sRGB [0,1]."""209    return linear_to_srgb(xyz_to_rgb(lab_to_xyz(lab)))210211212# ---------------------------------------------------------------------------213# Reinhard color transfer214# ---------------------------------------------------------------------------215216def compute_lab_stats(image_array):217    """Compute mean and std per LAB channel for an image (H,W,3 uint8)."""218    pixels = image_array.reshape(-1, 3).astype(np.float64) / 255.0219    lab = srgb_to_lab(pixels)220    return lab.mean(axis=0), lab.std(axis=0)221222223def generate_lut_cube(ref_mean, ref_std, tgt_mean, tgt_std, strength, lut_size):224    """Generate a .cube 3D LUT encoding the Reinhard transfer."""225    n = lut_size226    steps = np.linspace(0.0, 1.0, n)227    # Build NxNxN grid of RGB values228    b, g, r = np.meshgrid(steps, steps, steps, indexing='ij')229    grid = np.stack([r, g, b], axis=-1).reshape(-1, 3)230231    # Convert grid to LAB232    lab = srgb_to_lab(grid)233234    # Reinhard transfer per channel with strength blending235    for ch in range(3):236        src_std = tgt_std[ch]237        if src_std < 1e-6:238            # Near-zero std: skip scaling to avoid division by zero239            shifted = lab[:, ch] - tgt_mean[ch] + ref_mean[ch]240        else:241            shifted = (lab[:, ch] - tgt_mean[ch]) * (ref_std[ch] / src_std) + ref_mean[ch]242        lab[:, ch] = lab[:, ch] * (1.0 - strength) + shifted * strength243244    # Convert back to sRGB245    rgb_out = lab_to_srgb(lab)246    rgb_out = np.clip(rgb_out, 0.0, 1.0)247248    # Write .cube file249    lines = [f"LUT_3D_SIZE {n}", ""]250    for i in range(rgb_out.shape[0]):251        lines.append(f"{rgb_out[i, 0]:.6f} {rgb_out[i, 1]:.6f} {rgb_out[i, 2]:.6f}")252253    return "\n".join(lines)254255256# ---------------------------------------------------------------------------257# Main258# ---------------------------------------------------------------------------259260def main():261    execution_input = json.loads(sys.stdin.read())262    inputs = execution_input.get("inputs", {})263264    video = inputs.get("video", "")265    reference = inputs.get("reference", "")266    if not video:267        raise ValueError("Video input is required")268    if not reference:269        raise ValueError("Reference image input is required")270271    strength = float(inputs.get("strength", 1.0))272    lut_size = int(inputs.get("lut_size", 33))273274    # Validate lut_size275    if lut_size not in (17, 33, 65):276        lut_size = 33277278    os.makedirs(OUTPUT_DIR, exist_ok=True)279280    host_input = os.environ.get("HOST_STAGING_INPUT", INPUT_DIR)281    host_output = os.environ.get("HOST_STAGING_OUTPUT", OUTPUT_DIR)282283    # Stage 1: Load reference image and extract first frame as numpy arrays284    print("Loading reference image...", file=sys.stderr)285    ref_array = load_image_as_numpy(reference, host_input, host_output)286287    print("Extracting first frame from target video...", file=sys.stderr)288    tgt_array = extract_first_frame_as_numpy(video, host_input, host_output)289290    # Compute LAB statistics291    print("Computing color statistics...", file=sys.stderr)292    ref_mean, ref_std = compute_lab_stats(ref_array)293    tgt_mean, tgt_std = compute_lab_stats(tgt_array)294295    # Generate 3D LUT296    print(f"Generating {lut_size}x{lut_size}x{lut_size} 3D LUT (strength={strength})...", file=sys.stderr)297    cube_content = generate_lut_cube(ref_mean, ref_std, tgt_mean, tgt_std, strength, lut_size)298299    lut_path = os.path.join(OUTPUT_DIR, "_transfer.cube")300    with open(lut_path, "w") as f:301        f.write(cube_content)302303    # Stage 2: Apply LUT to video via ffmpeg304    print("Applying LUT to video...", file=sys.stderr)305    src = f"/data/input/{video}"306    lut = "/data/output/_transfer.cube"307    out = "/data/output/color_matched.mp4"308309    shell_script = "\n".join([310        "set -e",311        "",312        f"HAS_AUDIO=$(ffprobe -v quiet -select_streams a -show_entries stream=codec_type -of csv=p=0 '{src}' | head -1)",313        'if [ -n "$HAS_AUDIO" ]; then',314        f"  ffmpeg -i '{src}' -vf 'lut3d={lut}' "315        f"-c:v libx264 -preset medium -crf 23 -pix_fmt yuv420p "316        f"-c:a aac -ar 44100 -ac 2 -b:a 192k "317        f"-movflags +faststart -y '{out}'",318        "else",319        f"  ffmpeg -i '{src}' -f lavfi -i anullsrc=r=44100:cl=stereo "320        f"-vf 'lut3d={lut}' "321        f"-c:v libx264 -preset medium -crf 23 -pix_fmt yuv420p "322        f"-c:a aac -ar 44100 -ac 2 -b:a 192k "323        f"-map 0:v:0 -map 1:a:0 -shortest "324        f"-movflags +faststart -y '{out}'",325        "fi",326    ])327328    cmd = [329        "docker", "run", "--rm",330        "--network", "none",331        "--memory", "2g",332        "--cpus", "2.0",333        "-v", f"{host_input}:/data/input:ro",334        "-v", f"{host_output}:/data/output:rw",335        "--entrypoint", "sh",336        FFMPEG_IMAGE,337        "-c", shell_script,338    ]339340    result = subprocess.run(cmd, capture_output=True, text=True, timeout=1800)341    if result.returncode != 0:342        raise RuntimeError(f"ffmpeg LUT application failed (exit {result.returncode}): {result.stderr[-2000:]}")343344    # Clean up temp files345    for tmp in ["_transfer.cube"]:346        tmp_path = os.path.join(OUTPUT_DIR, tmp)347        if os.path.exists(tmp_path):348            os.remove(tmp_path)349350    print(json.dumps({"video": "color_matched.mp4"}, indent=2))351352353if __name__ == "__main__":354    try:355        main()356    except Exception as e:357        print(json.dumps({358            "error": str(e),359            "errorType": type(e).__name__,360            "traceback": traceback.format_exc(),361        }), file=sys.stderr)362        sys.exit(1)

$ git log --oneline

v1.1.2
HEAD
2026-05-07
v1.1.12026-04-23
v1.1.02026-04-22
v1.0.02026-04-09