﻿/*
    VFXReview Connector
    Adobe After Effects 2024+ dockable ScriptUI panel
*/

// Default config — overridden by stored settings at runtime
var CONFIG = {
    baseUrl:        "http://localhost:3000",
    token:          "",
    exportRoot:     "",
    footageRoot:    "",
    bannerImagePath:"",
    artistEmail:    "",
    handleFrames:   8,
    lastProjectCode:""
};

var SETTINGS_GROUP = "VFXReviewConnector";
var SETTINGS_KEY   = "cfg";

(function VFXReviewConnector(thisObj) {
    var uiState = {
        projectDropdown: null,
        episodeDropdown: null,
        shotDropdown: null,
        episodeLabel: null,
        selectionText: null,
        lastActionText: null,
        pipelineStatusText: null,
        urgentCheckbox: null,
        submissionNoteField: null
    };

    var CURRENT_PROJECT = null; // { code, name, showId, projectType }

    var pipelineState = {
        pollShotCode: null,
        pollTaskId: 0,
        prefilledShotCode: null
    };

    var buildState = {
        lastStartSource: "",
        lastStartFrames: 0
    };

    function parseJSON(text) {
        if (!text) {
            return null;
        }

        try {
            if (typeof JSON !== "undefined" && JSON.parse) {
                return JSON.parse(text);
            }
        } catch (jsonError) {
        }

        try {
            return eval("(" + text + ")");
        } catch (evalError) {
        }

        return null;
    }

    // ExtendScript is ES3 — String.prototype.trim does not exist.
    function trimText(value) {
        if (value === null || value === undefined) {
            return "";
        }
        return String(value).replace(/^\s+/, "").replace(/\s+$/, "");
    }

    function getSelectedComps() {
        var comps = [];
        var selection;
        var i;

        if (!app.project) {
            return comps;
        }

        selection = app.project.selection;
        for (i = 0; i < selection.length; i += 1) {
            if (selection[i] instanceof CompItem) {
                comps.push(selection[i]);
            }
        }

        return comps;
    }

    function getShotData(shotCode) {
        var url;
        var command;
        var response;
        var parsed;

        try {
            url = CONFIG.baseUrl + "/api/ext/shots/lookup" +
                "?shotCode=" + encodeURIComponent(shotCode) +
                "&projectCode=" + (CURRENT_PROJECT ? CURRENT_PROJECT.code : "");

            command = "curl -s " +
                "-H \"Authorization: Bearer " + CONFIG.token + "\" " +
                "-H \"Accept: application/json\" " +
                "\"" + url + "\"";

            response = system.callSystem(command);
        } catch (callError) {
            return null;
        }

        parsed = parseJSON(response);
        if (!parsed) {
            return null;
        }

        return parsed;
    }

    function getAPIData(url) {
        var command;
        var response;
        var parsed;

        try {
            command = "curl -s " +
                "-H \"Authorization: Bearer " + CONFIG.token + "\" " +
                "-H \"Accept: application/json\" " +
                "\"" + url + "\"";

            response = system.callSystem(command);
        } catch (callError) {
            return null;
        }

        parsed = parseJSON(response);
        if (!parsed) {
            return null;
        }

        return parsed;
    }

    function getListFromResponse(data, keyName) {
        if (!data) {
            return [];
        }

        if (data.length !== undefined) {
            return data;
        }

        if (keyName && data[keyName] && data[keyName].length !== undefined) {
            return data[keyName];
        }

        if (data.data && data.data.length !== undefined) {
            return data.data;
        }

        if (data.results && data.results.length !== undefined) {
            return data.results;
        }

        if (data.items && data.items.length !== undefined) {
            return data.items;
        }

        return [];
    }

    function padEpisodeNumber(value) {
        var text = String(value);

        while (text.length < 3) {
            text = "0" + text;
        }

        return text;
    }

    function deriveEpisodeCode(value) {
        var text = String(value);

        if (/^[A-Z]{3}_\d{3}$/.test(text)) {
            return text;
        }

        if (/^\d+$/.test(text)) {
            return "UNG_" + padEpisodeNumber(text);
        }

        return text;
    }

    function deriveEpisodeAPIValue(value) {
        var text = String(value);
        var match = text.match(/[A-Z]{3}_(\d{3})/);

        if (match && match.length > 1) {
            return String(parseInt(match[1], 10));
        }

        return text;
    }

    function getEpisodeValue(item) {
        if (item === null || item === undefined) {
            return "";
        }

        if (typeof item !== "object") {
            return deriveEpisodeAPIValue(item);
        }

        return deriveEpisodeAPIValue(item.episode || item.episodeNumber || item.number || item.id || item.code || item.episodeCode || item.name || "");
    }

    function getEpisodeLabel(item) {
        if (item === null || item === undefined) {
            return "";
        }

        if (typeof item !== "object") {
            return deriveEpisodeCode(item);
        }

        return String(item.episodeCode || item.code || item.name || deriveEpisodeCode(getEpisodeValue(item)));
    }

    function getShotCodeValue(item) {
        if (item === null || item === undefined) {
            return "";
        }

        if (typeof item !== "object") {
            return String(item);
        }

        return String(item.shotCode || item.code || item.name || "");
    }

    function getEpisodesFromAPI() {
        var url = CONFIG.baseUrl + "/api/ext/projects/" + (CURRENT_PROJECT ? CURRENT_PROJECT.code : "") + "/episodes";
        var data = getAPIData(url);
        return getListFromResponse(data, "episodes");
    }

    function getShotsFromAPI(value) {
        var isEpisodic = CURRENT_PROJECT && CURRENT_PROJECT.projectType === "EPISODIC";
        var param = isEpisodic ? "episode" : "scene";
        var url = CONFIG.baseUrl + "/api/ext/projects/" + (CURRENT_PROJECT ? CURRENT_PROJECT.code : "") + "/shots?" + param + "=" + encodeURIComponent(value);
        var data = getAPIData(url);
        return getListFromResponse(data, "shots");
    }

    function getScenesFromAPI() {
        var url = CONFIG.baseUrl + "/api/ext/projects/" + (CURRENT_PROJECT ? CURRENT_PROJECT.code : "") + "/shots?limit=500";
        var data = getAPIData(url);
        var shots = getListFromResponse(data, "shots");
        var seen = {};
        var scenes = [];
        for (var i = 0; i < shots.length; i += 1) {
            var sc = shots[i].scene || "";
            if (sc && !seen[sc]) { seen[sc] = true; scenes.push(sc); }
        }
        scenes.sort();
        return scenes;
    }

    function getProjectsFromAPI() {
        var url = CONFIG.baseUrl + "/api/ext/projects";
        var data = getAPIData(url);
        return getListFromResponse(data, "projects");
    }

    function normalizeShotData(data) {
        if (!data) {
            return null;
        }

        if (data.shotCode) {
            return data;
        }

        if (data.shot && data.shot.shotCode) {
            return data.shot;
        }

        if (data.data && data.data.shotCode) {
            return data.data;
        }

        if (data.result && data.result.shotCode) {
            return data.result;
        }

        if (data.results && data.results.length && data.results[0].shotCode) {
            return data.results[0];
        }

        return null;
    }

    function getShowExportRoot() {
        var showId = CURRENT_PROJECT ? (CURRENT_PROJECT.showId || CURRENT_PROJECT.code) : "";
        return CONFIG.exportRoot + (showId ? "/" + showId : "");
    }

    function getShowFootageRoot() {
        var showId = CURRENT_PROJECT ? (CURRENT_PROJECT.showId || CURRENT_PROJECT.code) : "";
        return CONFIG.footageRoot + (showId ? "/" + showId : "");
    }

    function ensureFolder(path) {
        var folder = new Folder(path);
        var parent;

        if (folder.exists) {
            return true;
        }

        parent = folder.parent;
        if (parent && !parent.exists) {
            if (!ensureFolder(parent.fsName)) {
                return false;
            }
        }

        try {
            return folder.create();
        } catch (folderError) {
            return false;
        }
    }

    function setCompStartFrame(comp, frameNumber) {
        try {
            comp.displayStartFrame = frameNumber;
            return;
        } catch (frameError) {
        }

        try {
            comp.displayStartTime = frameNumber / comp.frameRate;
        } catch (timeError) {
        }
    }

    function deselectAllProjectItems() {
        var i;

        try {
            for (i = 1; i <= app.project.numItems; i += 1) {
                app.project.item(i).selected = false;
            }
        } catch (deselectError) {
        }
    }

    // AE scripting exposes no direct read of a footage item's embedded start
    // timecode. Recreate the UI behaviour instead: "New Comp from Selection"
    // inherits the footage's start timecode from the file metadata, so build a
    // throwaway comp, read the start time AE gave it, then delete it.
    //
    // Returns null when the probe could not run (menu command unavailable or
    // disabled), so callers can fall back to the API timecode.
    function probeFootageStartFrames(footageItem, fps) {
        var commandId;
        var projectPanelId;
        var existingIds = {};
        var probeComp = null;
        var frames = null;
        var i;
        var item;

        try {
            commandId = app.findMenuCommandId("New Comp from Selection");
            if (!commandId) {
                return null;
            }

            for (i = 1; i <= app.project.numItems; i += 1) {
                existingIds[app.project.item(i).id] = true;
            }

            // executeCommand mirrors the menu bar, so the Project panel has to
            // be frontmost or "New Comp from Selection" is greyed out and the
            // call silently does nothing.
            try {
                projectPanelId = app.findMenuCommandId("Project");
                if (projectPanelId) {
                    app.executeCommand(projectPanelId);
                }
            } catch (focusError) {
            }

            deselectAllProjectItems();
            footageItem.selected = true;
            app.executeCommand(commandId);

            // Only ever touch a comp that did not exist before the probe —
            // never app.project.activeItem, which is an existing open comp
            // when the command did not run.
            for (i = app.project.numItems; i >= 1; i -= 1) {
                item = app.project.item(i);
                if (item instanceof CompItem && !existingIds[item.id]) {
                    probeComp = item;
                    break;
                }
            }

            if (probeComp) {
                frames = Math.round((probeComp.displayStartTime || 0) * (fps || probeComp.frameRate || 24));
            }
        } catch (probeError) {
            frames = null;
        }

        try {
            if (probeComp) {
                probeComp.remove();
            }
        } catch (removeError) {
        }

        return frames;
    }

    // The plate's own start timecode: AE metadata when we can read it,
    // otherwise the API's source clip timecode less the handle frames — the
    // same convention the Set Start Timecode button uses.
    function getFootageStartFrames(footageItem, apiShot, fps) {
        var probed = probeFootageStartFrames(footageItem, fps);
        var frames;

        if (probed !== null && probed > 0) {
            return { frames: probed, source: "footage metadata" };
        }

        if (apiShot && apiShot.timecodeStart) {
            frames = parseTimecodeToFrames(apiShot.timecodeStart, fps) - CONFIG.handleFrames;
            if (frames > 0) {
                return { frames: frames, source: "API timecode " + apiShot.timecodeStart + " -" + CONFIG.handleFrames + "f" };
            }
        }

        return { frames: 0, source: probed === 0 ? "footage starts at 00:00:00:00" : "no timecode found" };
    }

    function findComp(name) {
        var i;
        var item;

        if (!app.project) {
            return null;
        }

        for (i = 1; i <= app.project.numItems; i += 1) {
            item = app.project.item(i);
            if (item instanceof CompItem && item.name === name) {
                return item;
            }
        }

        return null;
    }

    function findFolder(name) {
        var i;
        var item;

        if (!app.project) {
            return null;
        }

        for (i = 1; i <= app.project.numItems; i += 1) {
            item = app.project.item(i);
            if (item instanceof FolderItem && item.name === name) {
                return item;
            }
        }

        return null;
    }

    function findEpisodeProjectFolder(episodeCode) {
        var shotsRoot = findFolder("__SHOTS");
        var i;
        var item;

        if (!shotsRoot) {
            return findFolder(episodeCode);
        }

        for (i = 1; i <= app.project.numItems; i += 1) {
            item = app.project.item(i);
            if (item instanceof FolderItem && item.name === episodeCode && item.parentFolder === shotsRoot) {
                return item;
            }
        }

        return findFolder(episodeCode);
    }

    function getEpisodeRootFolder(episodeCode) {
        return new Folder(getShowFootageRoot() + "/" + episodeCode);
    }

    function getEpisodeCodesFromDisk() {
        var root = new Folder(getShowFootageRoot());
        var folders;
        var codes = [];
        var i;

        if (!root.exists) {
            return codes;
        }

        folders = root.getFiles();
        for (i = 0; i < folders.length; i += 1) {
            if (folders[i] instanceof Folder) {
                codes.push(folders[i].name);
            }
        }

        codes.sort();
        return codes;
    }

    function addUniqueText(values, value) {
        var i;

        for (i = 0; i < values.length; i += 1) {
            if (values[i] === value) {
                return;
            }
        }

        values.push(value);
    }

    function getShotCodesForEpisode(episodeCode) {
        var root = getEpisodeRootFolder(episodeCode);
        var folders;
        var shotCodes = [];
        var i;
        var shotCode;

        if (!root.exists) {
            return shotCodes;
        }

        folders = root.getFiles();
        for (i = 0; i < folders.length; i += 1) {
            if (folders[i] instanceof Folder) {
                shotCode = extractShotCode(folders[i].name);
                if (shotCode && folders[i].name.indexOf(shotCode) === 0) {
                    addUniqueText(shotCodes, shotCode);
                }
            }
        }

        shotCodes.sort();
        return shotCodes;
    }

    function findSequenceFolders(shotCode, episodeCode) {
        var root = getEpisodeRootFolder(episodeCode);
        var folders;
        var matches = [];
        var i;

        if (!root.exists) {
            return matches;
        }

        folders = root.getFiles();
        for (i = 0; i < folders.length; i += 1) {
            if (folders[i] instanceof Folder && folders[i].name.indexOf(shotCode + "_") === 0) {
                matches.push(folders[i]);
            }
        }

        matches.sort();
        return matches;
    }

    function importEXRSequence(folder) {
        var files = folder.getFiles("*.exr");
        var importOptions;
        var footage;

        if (files.length < 1) {
            return null;
        }

        files.sort();
        importOptions = new ImportOptions(files[0]);
        importOptions.sequence = true;
        footage = app.project.importFile(importOptions);
        footage.mainSource.conformFrameRate = 24;
        return footage;
    }

    function addOCIOToLayer(layer) {
        var ocio;
        var outputProp;

        try {
            ocio = layer.property("Effects").addProperty("OCIO Color Space Transform");
            ocio.enabled = false;
        } catch (ocioError) {
            return;
        }

        try {
            outputProp = ocio.property("Output Color Space");
        } catch (propNameError) {
            outputProp = null;
        }
        if (!outputProp) {
            try {
                outputProp = ocio.property(2);
            } catch (propIdxError) {
                outputProp = null;
            }
        }
        if (outputProp) {
            try {
                outputProp.setValue(93);
            } catch (setValueError) {
            }
        }
    }

    function getDateString() {
        var today = new Date();

        return today.getFullYear() +
            "/" +
            ("0" + (today.getMonth() + 1)).slice(-2) +
            "/" +
            ("0" + today.getDate()).slice(-2);
    }

    function parseTimecodeToSeconds(tc, fps) {
        var parts = String(tc).split(/[:;]/);
        var hh;
        var mm;
        var ss;
        var ff;

        if (parts.length < 4) {
            return 0;
        }

        hh = parseInt(parts[0], 10);
        mm = parseInt(parts[1], 10);
        ss = parseInt(parts[2], 10);
        ff = parseInt(parts[3], 10);

        return hh * 3600 + mm * 60 + ss + ff / (fps || 24);
    }

    function parseTimecodeToFrames(tc, fps) {
        var parts = String(tc).split(/[:;]/);
        var hh;
        var mm;
        var ss;
        var ff;

        if (parts.length < 4) {
            return 0;
        }

        hh = parseInt(parts[0], 10);
        mm = parseInt(parts[1], 10);
        ss = parseInt(parts[2], 10);
        ff = parseInt(parts[3], 10);

        return (hh * 3600 + mm * 60 + ss) * Math.round(fps || 24) + ff;
    }

    function findOverlayLayer(comp) {
        var i;
        var layer;

        for (i = 1; i <= comp.numLayers; i += 1) {
            layer = comp.layer(i);

            try {
                if (layer.source && layer.source instanceof CompItem && layer.source.name === "UNG_VFX_OVERLAY") {
                    return layer;
                }
            } catch (layerError) {
            }
        }

        return null;
    }

    function setEssentialProperty(layer, propName, value) {
        try {
            layer.property("Essential Properties").property(propName).setValue(String(value));
            return true;
        } catch (propertyError) {
            return false;
        }
    }

    function applyOutputModuleTemplate(outputModule, templateName) {
        try {
            outputModule.applyTemplate(templateName);
            return true;
        } catch (templateError) {
            return false;
        }
    }

    function queueCompWithOutput(comp, templateName, outputPath) {
        var renderQueueItem;
        var outputModule;

        try {
            renderQueueItem = app.project.renderQueue.items.add(comp);
            outputModule = renderQueueItem.outputModule(1);
            if (!applyOutputModuleTemplate(outputModule, templateName)) {
                renderQueueItem.remove();
                return false;
            }
            outputModule.file = new File(outputPath);
            return true;
        } catch (queueError) {
            try {
                if (renderQueueItem) {
                    renderQueueItem.remove();
                }
            } catch (removeError) {
            }
            return false;
        }
    }

    function updateStatus(selectedCount, message) {
        if (uiState.selectionText) {
            uiState.selectionText.text = selectedCount + " comps selected";
        }
        if (uiState.lastActionText) {
            uiState.lastActionText.text = message || "";
        }
    }

    function refreshSelection() {
        var comps = getSelectedComps();
        var message = comps.length + " comps selected";
        updateStatus(comps.length, message);
        return comps;
    }

    function extractShotCode(name) {
        var match = String(name).match(/([A-Z]{3}_\d{3}_\d{3}_\d{3})/);
        if (match && match.length > 1) {
            return match[1];
        }
        return null;
    }

    function getShotCodeFromComp(comp) {
        return extractShotCode(comp.name) || comp.name;
    }

    function isPreviewComp(comp) {
        return /_PREVIEW$/.test(comp.name);
    }

    function getMainCompForSelection(comp) {
        var shotCode = getShotCodeFromComp(comp);

        if (isPreviewComp(comp)) {
            return findComp(shotCode);
        }

        return comp;
    }

    function getPreviewCompName(shotCode) {
        return shotCode + "_PREVIEW";
    }

    function getPreviewCompForShot(shotCode) {
        return findComp(getPreviewCompName(shotCode));
    }

    function getTargetComps() {
        var comps = getSelectedComps();

        if (comps.length < 1 && app.project && app.project.activeItem instanceof CompItem) {
            comps.push(app.project.activeItem);
        }

        return comps;
    }

    function disableLayerByName(comp, layerName) {
        var i;
        var layer;

        for (i = 1; i <= comp.numLayers; i += 1) {
            try {
                layer = comp.layer(i);
                if (layer.name === layerName || (layer.source && layer.source instanceof CompItem && layer.source.name === layerName)) {
                    layer.enabled = false;
                }
            } catch (disableError) {
            }
        }
    }

    function updateOverlayForComp(comp, shotCode, apiData) {
        var overlayComp = findComp("UNG_VFX_OVERLAY");
        var overlayLayer;
        var essentialProperties;
        var overlayShotCode;

        if (!overlayComp || !apiData || !apiData.shot) {
            return false;
        }

        try {
            overlayShotCode = apiData.shot.shotCode || shotCode;
            overlayLayer = findOverlayLayer(comp);
            if (!overlayLayer) {
                overlayLayer = comp.layers.add(overlayComp);
                overlayLayer.moveToBeginning();
            }

            overlayLayer.enabled = true;
            essentialProperties = overlayLayer.property("Essential Properties");
            essentialProperties.property("DATE").setValue(getDateString());
            essentialProperties.property("SHOT NAME").setValue(overlayShotCode + "_cmp_TT_" + (apiData.shot.shotVersion || "v001"));
            return true;
        } catch (overlayError) {
            return false;
        }
    }

    function makeBatchResult(total) {
        return {
            total: total,
            queued: 0,
            renamed: 0,
            skipped: 0,
            failures: []
        };
    }

    function addFailure(result, compName, reason) {
        result.skipped += 1;
        result.failures.push(compName + ": " + reason);
    }

    function fixCompNames() {
        var comps = getSelectedComps();
        var renamed = 0;
        var i;
        var shotCode;

        app.beginUndoGroup("VFXReview Connector - Fix Comp Names");
        for (i = 0; i < comps.length; i += 1) {
            shotCode = extractShotCode(comps[i].name);
            if (shotCode && comps[i].name !== shotCode) {
                comps[i].name = shotCode;
                renamed += 1;
            }
        }
        app.endUndoGroup();

        updateStatus(comps.length, renamed + " comp(s) renamed.");
    }

    function queueEXR() {
        var comps = getSelectedComps();
        var queued = 0;
        var i;
        var comp;
        var lookupCode;
        var shot;
        var result = makeBatchResult(comps.length);
        var shotCode;
        var exrOutput;
        var folderPath;
        var outputPath;

        app.beginUndoGroup("VFXReview Connector - Queue EXR");
        for (i = 0; i < comps.length; i += 1) {
            comp = comps[i];
            lookupCode = getShotCodeFromComp(comp);
            updateStatus(comps.length, "Looking up " + lookupCode + " (" + (i + 1) + " of " + comps.length + ")");
            shot = normalizeShotData(getShotData(lookupCode));

            if (!shot || !shot.shotCode || !shot.exrOutput) {
                addFailure(result, comp.name, "API lookup failed or missing EXR output");
                continue;
            }

            shotCode = shot.shotCode;
            exrOutput = shot.exrOutput;
            disableLayerByName(comp, "UNG_VFX_OVERLAY");
            disableLayerByName(comp, "_SHOW LUT");

            folderPath = getShowExportRoot() + "/" + shotCode;
            if (!ensureFolder(folderPath)) {
                addFailure(result, comp.name, "Could not create output folder");
                continue;
            }

            outputPath = folderPath + "/" + exrOutput + ".[#####].exr";
            if (queueCompWithOutput(comp, "EXR Sequence", outputPath)) {
                queued += 1;
                result.queued += 1;
            } else {
                addFailure(result, comp.name, "Could not queue EXR render");
            }
        }
        app.endUndoGroup();

        updateStatus(comps.length, queued + " EXR renders queued. " + result.skipped + " skipped.");
    }

    function addOverlay() {
        var comps = getSelectedComps();
        var overlayComp = findComp("UNG_VFX_OVERLAY");
        var overlayLayer;
        var added = 0;
        var updated = 0;
        var skipped = 0;
        var i;
        var shotCode;
        var data;
        var dateString;
        var essentialProperties;

        if (comps.length < 1 && app.project && app.project.activeItem instanceof CompItem) {
            comps.push(app.project.activeItem);
        }

        if (comps.length < 1) {
            updateStatus(0, "Please select a comp first.");
            return;
        }

        if (!overlayComp) {
            updateStatus(comps.length, "UNG_VFX_OVERLAY not found.");
            return;
        }

        dateString = getDateString();

        app.beginUndoGroup("VFXReview Connector - Add Overlay");
        for (i = 0; i < comps.length; i += 1) {
            shotCode = getShotCodeFromComp(comps[i]);
            updateStatus(comps.length, "Updating overlay for " + shotCode + " (" + (i + 1) + " of " + comps.length + ")");
            data = getShotData(shotCode);

            if (!data || !data.shot) {
                skipped += 1;
                continue;
            }

            try {
                overlayLayer = findOverlayLayer(comps[i]);
                if (!overlayLayer) {
                    overlayLayer = comps[i].layers.add(overlayComp);
                    overlayLayer.moveToBeginning();
                    added += 1;
                }

                essentialProperties = overlayLayer.property("Essential Properties");
                essentialProperties.property("DATE").setValue(dateString);
                essentialProperties.property("SHOT NAME").setValue(shotCode + "_cmp_TT_" + (data.shot.shotVersion || "v001"));
                updated += 1;
            } catch (overlayError) {
                skipped += 1;
            }
        }
        app.endUndoGroup();

        updateStatus(comps.length, updated + " overlay(s) updated. " + added + " added. " + skipped + " skipped.");
    }

    function buildPreviewForShot(shotComp, shotCode, apiData) {
        var templateComp = findComp("UNG_EXPORT_TEMPLATE");
        var previewsFolder = findFolder("_PREVIEWS");
        var shot;
        var previewComp;
        var slateLayer;
        var frameCount;
        var essentialProperties;
        var shotLayer;

        if (!templateComp || !apiData || !apiData.shot) {
            return null;
        }

        shot = apiData.shot;
        previewComp = null;

        try {
            previewComp = templateComp.duplicate();
            previewComp.name = getPreviewCompName(shotCode);

            if (previewsFolder) {
                previewComp.parentFolder = previewsFolder;
            }

            previewComp.layer("SHOT").replaceSource(shotComp, false);
            previewComp.layer("THUMBNAIL").replaceSource(shotComp, false);

            slateLayer = previewComp.layer("NETFLIX_SLATE");
            frameCount = Math.round(shotComp.duration * shotComp.frameRate);
            essentialProperties = slateLayer.property("Essential Properties");

            essentialProperties.property(3).setValue(shotCode + "_cmp_TT_" + (shot.shotVersion || "v001"));
            setEssentialProperty(slateLayer, "Date", getDateString());
            setEssentialProperty(slateLayer, "Desc", shot.description);
            setEssentialProperty(slateLayer, "Notes", shot.notes);
            essentialProperties.property(10).setValue(shotCode);
            setEssentialProperty(slateLayer, "Episode", shot.episode);
            setEssentialProperty(slateLayer, "Scene", shot.scene);
            setEssentialProperty(slateLayer, "Frames", frameCount);

            shotLayer = previewComp.layer("SHOT");
            var targetOutPoint = shotLayer.inPoint + shotComp.duration;
            if (targetOutPoint > previewComp.duration) {
                previewComp.duration = targetOutPoint;
            }
            shotLayer.outPoint = targetOutPoint;
            previewComp.duration = targetOutPoint;

            return previewComp;
        } catch (previewError) {
            try {
                if (previewComp) {
                    previewComp.remove();
                }
            } catch (removePreviewError) {
            }
            return null;
        }
    }

    function buildShotFromCode(episodeCode, shotCode) {
        var existingShotComp = findComp(shotCode);
        var sequenceFolders;
        var footageItems = [];
        var footageFolder = findFolder("_FOOTAGE_4K");
        var precompFolder = findFolder("_PRECOMPS");
        var episodeProjectFolder = findEpisodeProjectFolder(episodeCode);
        var apiData;
        var footage;
        var footageComp;
        var shotComp;
        var footageLayer;
        var showLut;
        var lutLayer;
        var maxDuration = 0;
        var width = 1920;
        var height = 1080;
        var primaryFootage = null;
        var startInfo;
        var i;

        apiData = getShotData(shotCode);
        if (!apiData || !apiData.shot) {
            return null;
        }

        if (existingShotComp) {
            updateOverlayForComp(existingShotComp, shotCode, apiData);
            return existingShotComp;
        }

        sequenceFolders = findSequenceFolders(shotCode, episodeCode);
        if (sequenceFolders.length < 1) {
            return null;
        }

        for (i = 0; i < sequenceFolders.length; i += 1) {
            footage = importEXRSequence(sequenceFolders[i]);
            if (footage) {
                footage.name = sequenceFolders[i].name;
                if (footageFolder) {
                    footage.parentFolder = footageFolder;
                }
                footageItems.push(footage);

                if (footage.duration > maxDuration) {
                    maxDuration = footage.duration;
                    width = footage.width;
                    height = footage.height;
                    primaryFootage = footage;
                }
            }
        }

        if (footageItems.length < 1) {
            return null;
        }
        if (!primaryFootage) {
            primaryFootage = footageItems[0];
        }

        // Both comps start at the plate's native start timecode (as AE would
        // when creating a comp from the footage), not at frame 0.
        startInfo = getFootageStartFrames(primaryFootage, apiData.shot, 24);
        buildState.lastStartSource = startInfo.source;
        buildState.lastStartFrames = startInfo.frames;

        footageComp = app.project.items.addComp(shotCode + "_FOOTAGE", width, height, 1, maxDuration, 24);
        setCompStartFrame(footageComp, startInfo.frames);
        if (precompFolder) {
            footageComp.parentFolder = precompFolder;
        }

        for (i = footageItems.length - 1; i >= 0; i -= 1) {
            footageLayer = footageComp.layers.add(footageItems[i]);
            addOCIOToLayer(footageLayer);
        }

        shotComp = app.project.items.addComp(shotCode, width, height, 1, maxDuration, 24);
        setCompStartFrame(shotComp, startInfo.frames);
        if (episodeProjectFolder) {
            shotComp.parentFolder = episodeProjectFolder;
        }

        shotComp.layers.add(footageComp);

        showLut = findComp("_SHOW LUT");
        if (showLut) {
            lutLayer = shotComp.layers.add(showLut);
            lutLayer.collapseTransformation = true;
        }

        updateOverlayForComp(shotComp, shotCode, apiData);
        return shotComp;
    }

    function buildShotFromDropdown() {
        var episodeCode;
        var shotCode;
        var shotComp;
        var isEpisodic = CURRENT_PROJECT && CURRENT_PROJECT.projectType === "EPISODIC";

        if (!uiState.episodeDropdown || !uiState.episodeDropdown.selection || !uiState.shotDropdown || !uiState.shotDropdown.selection) {
            updateStatus(getSelectedComps().length, "Choose a " + (isEpisodic ? "episode" : "scene") + " and shot first.");
            return;
        }

        // For episodic, derive full episode code; for standard pass the selected scene value
        if (isEpisodic) {
            episodeCode = uiState.episodeDropdown.selection.episodeCode || deriveEpisodeCode(uiState.episodeDropdown.selection.text);
        } else {
            episodeCode = uiState.episodeDropdown.selection.filterValue || uiState.episodeDropdown.selection.text;
        }
        shotCode = uiState.shotDropdown.selection.shotCode || uiState.shotDropdown.selection.text;

        app.beginUndoGroup("VFXReview Connector - Build Shot");
        updateStatus(getSelectedComps().length, "Building shot " + shotCode);
        shotComp = buildShotFromCode(episodeCode, shotCode);
        app.endUndoGroup();

        if (shotComp) {
            updateStatus(getSelectedComps().length, "Shot built: " + shotCode +
                " (start frame " + buildState.lastStartFrames +
                (buildState.lastStartSource ? " from " + buildState.lastStartSource : "") + ")");
        } else {
            updateStatus(getSelectedComps().length, "Shot build failed: " + shotCode);
        }
    }

    function buildPreviews() {
        var comps = getTargetComps();
        var templateComp = findComp("UNG_EXPORT_TEMPLATE");
        var created = 0;
        var skipped = 0;
        var i;
        var shotComp;
        var shotCode;
        var apiData;
        var previewComp;

        if (comps.length < 1) {
            updateStatus(0, "Please select/open a shot comp.");
            return;
        }

        if (!templateComp) {
            updateStatus(comps.length, "UNG_EXPORT_TEMPLATE not found.");
            return;
        }

        app.beginUndoGroup("VFXReview Connector - Build Preview");
        for (i = 0; i < comps.length; i += 1) {
            shotCode = getShotCodeFromComp(comps[i]);
            shotComp = getMainCompForSelection(comps[i]);
            updateStatus(comps.length, "Building preview for " + shotCode + " (" + (i + 1) + " of " + comps.length + ")");

            if (!shotComp) {
                skipped += 1;
                continue;
            }

            apiData = getShotData(shotCode);
            if (!apiData || !apiData.shot) {
                skipped += 1;
                continue;
            }

            previewComp = buildPreviewForShot(shotComp, shotCode, apiData);
            if (previewComp) {
                created += 1;
            } else {
                skipped += 1;
            }
        }
        app.endUndoGroup();

        updateStatus(comps.length, created + " preview(s) built. " + skipped + " skipped.");
    }

    function queueReview(templateName, extension, label) {
        var comps = getTargetComps();
        var queued = 0;
        var i;
        var result = makeBatchResult(comps.length);
        var shotCode;
        var apiData;
        var mainComp;
        var previewComp;
        var outputPath;

        app.beginUndoGroup("VFXReview Connector - Queue " + label);
        if (!ensureFolder(getShowExportRoot())) {
            app.endUndoGroup();
            updateStatus(comps.length, "Could not create export folder.");
            return;
        }

        for (i = 0; i < comps.length; i += 1) {
            shotCode = getShotCodeFromComp(comps[i]);
            updateStatus(comps.length, "Preparing " + label + " preview for " + shotCode + " (" + (i + 1) + " of " + comps.length + ")");
            apiData = getShotData(shotCode);
            if (!apiData || !apiData.shot) {
                addFailure(result, comps[i].name, "Shot lookup failed");
                continue;
            }

            mainComp = getMainCompForSelection(comps[i]);
            if (!mainComp) {
                addFailure(result, comps[i].name, "Main shot comp not found");
                continue;
            }

            updateOverlayForComp(mainComp, shotCode, apiData);

            if (isPreviewComp(comps[i])) {
                previewComp = comps[i];
            } else {
                previewComp = getPreviewCompForShot(shotCode);
                if (!previewComp) {
                    previewComp = buildPreviewForShot(mainComp, shotCode, apiData);
                }
            }

            if (!previewComp) {
                addFailure(result, comps[i].name, "Preview comp not found or could not be created");
                continue;
            }

            updateStatus(comps.length, "Queueing " + label + " for " + shotCode + " (" + (i + 1) + " of " + comps.length + ")");
            outputPath = getShowExportRoot() + "/" + shotCode + "_cmp_TT_" + (apiData.shot.shotVersion || "v001") + "." + extension;
            if (queueCompWithOutput(previewComp, templateName, outputPath)) {
                queued += 1;
                result.queued += 1;
            } else {
                addFailure(result, comps[i].name, "Could not queue " + label + " render");
            }
        }
        app.endUndoGroup();

        updateStatus(comps.length, queued + " " + label + " renders queued. " + result.skipped + " skipped.");
    }

    function queueEXRPreview() {
        var comps = getSelectedComps();
        var queued = 0;
        var i;
        var comp;
        var lookupCode;
        var shot;
        var result = makeBatchResult(comps.length);
        var shotCode;
        var folderPath;
        var outputPath;

        app.beginUndoGroup("VFXReview Connector - Queue EXR Preview");
        for (i = 0; i < comps.length; i += 1) {
            comp = comps[i];
            lookupCode = getShotCodeFromComp(comp);
            updateStatus(comps.length, "Looking up " + lookupCode + " (" + (i + 1) + " of " + comps.length + ")");
            shot = normalizeShotData(getShotData(lookupCode));

            if (!shot || !shot.shotCode) {
                addFailure(result, comp.name, "API lookup failed");
                continue;
            }

            shotCode = shot.shotCode;
            disableLayerByName(comp, "UNG_VFX_OVERLAY");
            disableLayerByName(comp, "_SHOW LUT");

            folderPath = getShowExportRoot() + "/" + shotCode;
            if (!ensureFolder(folderPath)) {
                addFailure(result, comp.name, "Could not create output folder");
                continue;
            }

            outputPath = folderPath + "/" + shotCode + "_cmp_TT_" + (shot.shotVersion || "v001") + ".[#####].exr";
            if (queueCompWithOutput(comp, "EXR Sequence", outputPath)) {
                queued += 1;
                result.queued += 1;
            } else {
                addFailure(result, comp.name, "Could not queue EXR render");
            }
        }
        app.endUndoGroup();

        updateStatus(comps.length, queued + " EXR renders queued. " + result.skipped + " skipped.");
    }

    function queueMP4() {
        queueReview("REVIEW_PREVIEW", "mp4", "MP4");
    }

    function queueMOV() {
        queueReview("4444 Tri", "mov", "MOV");
    }

    function importExportedEXR() {
        var comps = getTargetComps();
        var exportsProjectFolder;
        var comp;
        var shotCode;
        var shot;
        var exportFolder;
        var files;
        var importOptions;
        var footage;
        var layer;
        var i;
        var imported = 0;
        var skipped = 0;

        if (comps.length < 1) {
            updateStatus(0, "Please select or open a comp first.");
            return;
        }

        app.beginUndoGroup("VFXReview Connector - Import Exported EXR");

        for (i = 0; i < comps.length; i += 1) {
            comp = comps[i];
            shotCode = getShotCodeFromComp(comp);
            updateStatus(comps.length, "Looking up " + shotCode + " (" + (i + 1) + " of " + comps.length + ")");

            shot = normalizeShotData(getShotData(shotCode));
            if (!shot || !shot.shotCode || !shot.exrOutput) {
                updateStatus(comps.length, "API lookup failed or missing EXR output for " + shotCode);
                skipped += 1;
                continue;
            }

            exportFolder = new Folder(getShowExportRoot() + "/" + shot.shotCode);
            if (!exportFolder.exists) {
                updateStatus(comps.length, "Export folder not found: " + exportFolder.fsName);
                skipped += 1;
                continue;
            }

            // Prefer the current review-render version naming convention:
            // shotCode_cmp_TT_vXXX.####.exr
            var currentVersion = shot.shotVersion || "v001";
            var reviewPrefix = shot.shotCode + "_cmp_TT_" + currentVersion + ".";
            files = exportFolder.getFiles(function (f) {
                return f instanceof File &&
                    /\.exr$/i.test(f.name) &&
                    f.name.indexOf(reviewPrefix) === 0;
            });

            // Fallback to the broader queue EXR naming pattern if needed
            if (!files || files.length < 1) {
                var cmpPrefix = shot.shotCode + "_cmp_TT_";
                files = exportFolder.getFiles(function (f) {
                    return f instanceof File &&
                        /\.exr$/i.test(f.name) &&
                        f.name.indexOf(cmpPrefix) === 0;
                });
            }

            if (!files || files.length < 1) {
                // Last resort: any EXR in the folder
                files = exportFolder.getFiles("*.exr");
            }

            if (!files || files.length < 1) {
                updateStatus(comps.length, "No EXR files found in " + exportFolder.fsName);
                skipped += 1;
                continue;
            }

            files.sort();
            try {
                importOptions = new ImportOptions(files[0]);
                importOptions.sequence = true;
                footage = app.project.importFile(importOptions);
                footage.mainSource.conformFrameRate = 24;
            } catch (importErr) {
                updateStatus(comps.length, "Import failed for " + shot.shotCode + ": " + importErr.message);
                skipped += 1;
                continue;
            }

            exportsProjectFolder = findOrCreateFolder("_EXPORTS", null);
            if (exportsProjectFolder) {
                footage.parentFolder = exportsProjectFolder;
            }

            layer = comp.layers.add(footage);
            layer.moveToBeginning();
            imported += 1;
        }

        app.endUndoGroup();

        updateStatus(comps.length, imported + " EXR export(s) imported. " + skipped + " skipped.");
    }

    function importRenders() {
        var comps = getTargetComps();
        var comp;
        var shotCode;
        var rendersRoot;
        var rendersProjectFolder;
        var allFiles;
        var subFolders;
        var importedFootage = [];
        var addedLayers = [];
        var layerIndices = [];
        var footage;
        var layer;
        var i;

        if (comps.length < 1) {
            updateStatus(0, "Please select or open a comp first.");
            return;
        }

        comp = comps[0];
        shotCode = getShotCodeFromComp(comp);
        rendersRoot = new Folder("X:/shared_projects_2026/UNGO_VFX/production/renders/" + shotCode);

        if (!rendersRoot.exists) {
            updateStatus(getSelectedComps().length, "Renders folder not found: " + rendersRoot.fsName);
            return;
        }

        rendersProjectFolder = findOrCreateFolder("_RENDERS", null);

        app.beginUndoGroup("VFXReview Connector - Import Renders");

        // Try subfolders first (each subfolder may be a separate pass/layer)
        subFolders = rendersRoot.getFiles();
        for (i = 0; i < subFolders.length; i += 1) {
            if (subFolders[i] instanceof Folder) {
                footage = importEXRSequence(subFolders[i]);
                if (footage) {
                    footage.name = subFolders[i].name;
                    footage.mainSource.conformFrameRate = 24;
                    if (rendersProjectFolder) {
                        footage.parentFolder = rendersProjectFolder;
                    }
                    importedFootage.push(footage);
                }
            }
        }

        // Fall back to sequences directly in the root renders folder
        if (importedFootage.length < 1) {
            footage = importEXRSequence(rendersRoot);
            if (footage) {
                footage.name = shotCode + "_render";
                footage.mainSource.conformFrameRate = 24;
                if (rendersProjectFolder) {
                    footage.parentFolder = rendersProjectFolder;
                }
                importedFootage.push(footage);
            }
        }

        if (importedFootage.length < 1) {
            app.endUndoGroup();
            updateStatus(getSelectedComps().length, "No EXR sequences found in renders folder.");
            return;
        }

        // Add footage layers to comp and apply Extractor effect
        for (i = 0; i < importedFootage.length; i += 1) {
            layer = comp.layers.add(importedFootage[i]);
            try {
                var extractor = layer.property("Effects").addProperty("Extractor");
                try {
                    extractor.property("Layers").setValue(1);
                } catch (layersPropError) {
                    // Property name may differ — try by index (property 1 is typically "Layers")
                    try {
                        extractor.property(1).setValue(1);
                    } catch (layersIdxError) {
                    }
                }
            } catch (fxError) {
            }
            addedLayers.push(layer);
        }

        // Collect current layer indices for pre-compose
        for (i = 0; i < addedLayers.length; i += 1) {
            layerIndices.push(addedLayers[i].index);
        }

        // Pre-compose all render layers into {shotCode}_RENDER
        try {
            comp.layers.precompose(layerIndices, shotCode + "_RENDER", true);
            updateStatus(getSelectedComps().length, importedFootage.length + " render(s) imported into " + shotCode + "_RENDER.");
        } catch (precompError) {
            updateStatus(getSelectedComps().length, importedFootage.length + " render(s) imported. Pre-compose failed: " + precompError.message);
        }

        app.endUndoGroup();
    }

    function prepDelivery() {
        var comps = getTargetComps();
        var i;
        var j;
        var comp;
        var shotCode;
        var shot;
        var exrOutputBase;
        var sourceFolder;
        var deliveryFolderPath;
        var exrFiles;
        var expectedBaseName;
        var result = makeBatchResult(comps.length);
        var srcFile;
        var srcBaseName;
        var frameMatch;
        var frameNum;
        var destFileName;
        var destPath;
        var psLines;
        var scriptFile;
        var vbsFile;
        var launched = 0;

        if (comps.length < 1) {
            updateStatus(0, "Please select or open a comp first.");
            return;
        }

        for (i = 0; i < comps.length; i += 1) {
            comp = comps[i];
            shotCode = getShotCodeFromComp(comp);
            updateStatus(comps.length, "Preparing delivery for " + shotCode + " (" + (i + 1) + " of " + comps.length + ")");

            shot = normalizeShotData(getShotData(shotCode));
            if (!shot || !shot.shotCode) {
                addFailure(result, comp.name, "API lookup failed");
                continue;
            }

            shotCode = shot.shotCode;
            expectedBaseName = shotCode + "_cmp_TT_" + (shot.shotVersion || "v001");
            exrOutputBase = shot.exrOutput || "";

            sourceFolder = new Folder(getShowExportRoot() + "/" + shotCode);
            if (!sourceFolder.exists) {
                addFailure(result, comp.name, "Source export folder not found: " + sourceFolder.fsName);
                continue;
            }

            // Prefer the current review-render version naming convention:
            // shotCode_cmp_TT_vXXX.####.exr
            exrFiles = null;
            var currentVersion = shot.shotVersion || "v001";
            var reviewPrefix = shotCode + "_cmp_TT_" + currentVersion + ".";
            exrFiles = sourceFolder.getFiles(function (f) {
                return f instanceof File &&
                    /\.exr$/i.test(f.name) &&
                    f.name.indexOf(reviewPrefix) === 0;
            });

            // Fallback to the broader queue EXR naming pattern if needed
            if (!exrFiles || exrFiles.length < 1) {
                var cmpPfx = shotCode + "_cmp_TT_";
                exrFiles = sourceFolder.getFiles(function (f) {
                    return f instanceof File &&
                        /\.exr$/i.test(f.name) &&
                        f.name.indexOf(cmpPfx) === 0;
                });
            }

            if (!exrFiles || exrFiles.length < 1) {
                // Last resort: any EXR in the folder
                exrFiles = sourceFolder.getFiles("*.exr");
            }

            if (!exrFiles || exrFiles.length < 1) {
                addFailure(result, comp.name, "No EXR files found in " + sourceFolder.fsName);
                continue;
            }

            exrFiles.sort();

            var episodeCode = shotCode.substring(0, 7);
            var deliveryDate = getDateString().replace(/\//g, "");
            deliveryFolderPath = getShowExportRoot() + "/DELIVERY/" + episodeCode + "/" + deliveryDate + "_Delivery/" + shotCode;
            if (!ensureFolder(deliveryFolderPath)) {
                addFailure(result, comp.name, "Could not create delivery folder");
                continue;
            }

            // Build a list of PowerShell Copy-Item commands
            psLines = [];
            for (j = 0; j < exrFiles.length; j += 1) {
                srcFile = exrFiles[j];
                srcBaseName = srcFile.name.replace(/\.exr$/i, "");

                if (srcBaseName.indexOf(expectedBaseName + ".") === 0) {
                    destFileName = srcFile.name;
                } else {
                    // Extract frame number and rename to delivery convention
                    frameMatch = srcFile.name.match(/\.(\d+)\.exr$/i);
                    if (!frameMatch) {
                        continue;
                    }
                    frameNum = frameMatch[1];
                    destFileName = expectedBaseName + "." + frameNum + ".exr";
                }

                destPath = (new File(deliveryFolderPath + "/" + destFileName)).fsName;
                // Single-quoted PS strings are fully literal — safe for Windows paths
                psLines.push("Copy-Item -LiteralPath '" + srcFile.fsName + "' -Destination '" + destPath + "'");
            }

            if (psLines.length < 1) {
                addFailure(result, comp.name, "No valid EXR files to copy");
                continue;
            }

            // Write PS1 script to temp folder
            scriptFile = new File(Folder.temp.fsName + "/vfxdelivery_" + shotCode + ".ps1");
            try {
                scriptFile.open("w");
                scriptFile.write(psLines.join("\r\n"));
                // Self-delete once all transfers finish
                scriptFile.write("\r\nRemove-Item -LiteralPath '" + scriptFile.fsName + "' -Force -ErrorAction SilentlyContinue");
                scriptFile.close();
            } catch (writeError) {
                addFailure(result, comp.name, "Could not write delivery script: " + writeError.message);
                continue;
            }

            // VBScript launcher: WScript.Shell.Run with bWaitOnReturn=False creates a fully
            // independent process with no inherited pipe handles, so system.callSystem returns
            // immediately and AE is never blocked regardless of how long the copy takes.
            vbsFile = new File(Folder.temp.fsName + "/vfxdelivery_launch_" + shotCode + ".vbs");
            try {
                vbsFile.open("w");
                vbsFile.write("Set sh = CreateObject(\"WScript.Shell\")\r\n");
                vbsFile.write("sh.Run \"powershell -WindowStyle Hidden -NoProfile -ExecutionPolicy Bypass -File \" & Chr(34) & \"" + scriptFile.fsName + "\" & Chr(34), 0, False\r\n");
                vbsFile.write("Set fso = CreateObject(\"Scripting.FileSystemObject\")\r\n");
                vbsFile.write("fso.DeleteFile WScript.ScriptFullName\r\n");
                vbsFile.close();
            } catch (vbsError) {
                addFailure(result, comp.name, "Could not write launcher script: " + vbsError.message);
                continue;
            }

            system.callSystem("wscript.exe /nologo \"" + vbsFile.fsName + "\"");
            launched += 1;
        }

        var statusMsg;
        if (launched > 0) {
            statusMsg = "Delivery transfer running in background for " + launched + " shot(s).";
        } else {
            statusMsg = "No shots queued for delivery.";
        }
        if (result.skipped > 0) {
            statusMsg += " " + result.skipped + " skipped.";
        }
        updateStatus(comps.length, statusMsg);
    }

    function pullPictureLock() {
        var comps = getTargetComps();
        var comp;
        var shotCode;
        var episodePrefix;
        var piclocksProjectFolder;
        var piclockFootage;
        var piclocksDir;
        var files;
        var foundFile;
        var importOptions;
        var apiData;
        var shot;
        var fps;
        var handles;
        var piclockStartTC;
        var tcIn;
        var tcOut;
        var inOffset;
        var outOffset;
        var layer;
        var i;
        var item;

        if (comps.length < 1) {
            updateStatus(0, "Please select or open a comp first.");
            return;
        }

        comp = comps[0];
        shotCode = getShotCodeFromComp(comp);
        episodePrefix = shotCode.substring(0, 7); // e.g. "UNG_106"

        // Fetch API data for seq timecodes
        updateStatus(getSelectedComps().length, "Looking up " + shotCode + "...");
        apiData = getShotData(shotCode);
        if (!apiData || !apiData.shot) {
            updateStatus(getSelectedComps().length, "API lookup failed for " + shotCode);
            return;
        }

        shot = apiData.shot;
        if (!shot.seqTimecodeStart || !shot.seqTimecodeEnd) {
            updateStatus(getSelectedComps().length, "No sequence timecodes for " + shotCode + ". Import picture tracker CSV first.");
            return;
        }

        // Find or create _PICLOCKS project folder
        piclocksProjectFolder = findOrCreateFolder("_PICLOCKS", null);
        piclockFootage = null;

        // Search only inside the _PICLOCKS project folder for matching footage
        if (piclocksProjectFolder) {
            for (i = 1; i <= app.project.numItems; i += 1) {
                item = app.project.item(i);
                if (item instanceof FootageItem &&
                    item.parentFolder === piclocksProjectFolder &&
                    item.name.substring(0, 7) === episodePrefix) {
                    piclockFootage = item;
                    break;
                }
            }
        }

        // Not in project — import from disk
        if (!piclockFootage) {
            piclocksDir = new Folder(getShowFootageRoot() + "/_PICLOCKS");
            if (!piclocksDir.exists) {
                updateStatus(getSelectedComps().length, "PICLOCKS folder not found: " + piclocksDir.fsName);
                return;
            }

            files = piclocksDir.getFiles(episodePrefix + "_*");
            foundFile = null;
            if (files && files.length > 0) {
                for (i = 0; i < files.length; i += 1) {
                    if (files[i] instanceof File) {
                        foundFile = files[i];
                        break;
                    }
                }
            }

            if (!foundFile) {
                updateStatus(getSelectedComps().length, "No piclock file matching " + episodePrefix + "_* found in " + piclocksDir.fsName);
                return;
            }

            try {
                importOptions = new ImportOptions(foundFile);
                piclockFootage = app.project.importFile(importOptions);
                if (piclocksProjectFolder) {
                    piclockFootage.parentFolder = piclocksProjectFolder;
                }
            } catch (importError) {
                updateStatus(getSelectedComps().length, "Import failed: " + importError.message);
                return;
            }
        }

        // Calculate layer in/out offsets from seqTimecodes + handles
        fps = comp.frameRate || 24;
        handles = 8;

        piclockStartTC = 3590;
        try {
            if (piclockFootage.mainSource && typeof piclockFootage.mainSource.startTimecode === "number") {
                piclockStartTC = piclockFootage.mainSource.startTimecode;
            }
        } catch (tcReadError) {
        }

        tcIn     = parseTimecodeToSeconds(shot.seqTimecodeStart, fps);
        tcOut    = parseTimecodeToSeconds(shot.seqTimecodeEnd,   fps);
        inOffset  = tcIn  - piclockStartTC - (handles / fps);
        outOffset = tcOut - piclockStartTC + (handles / fps);

        if (inOffset < 0) {
            inOffset = 0;
        }
        if (outOffset > piclockFootage.duration) {
            outOffset = piclockFootage.duration;
        }

        app.beginUndoGroup("VFXReview Connector - Pull Picture Lock");

        layer = comp.layers.add(piclockFootage);
        var scale =
            Math.max(
                comp.width / piclockFootage.width,
                comp.height / piclockFootage.height
            ) * 100;

        layer.property("Scale")
            .setValue([
                scale,
                scale
            ]);
        layer.moveToBeginning();

        // Offset startTime so the correct footage frame plays at comp time 0
        layer.startTime = -inOffset;
        layer.inPoint   = 0;
        layer.outPoint  = outOffset - inOffset;

        app.endUndoGroup();

        updateStatus(
            getSelectedComps().length,
            "Piclock added: " + shot.seqTimecodeStart + " \u2192 " + shot.seqTimecodeEnd + " (+" + handles + "fr handles)."
        );
    }

    function applyOCIOColorSpace() {
        var comps = getTargetComps();
        var applied = 0;
        var skipped = 0;
        var i;
        var j;
        var comp;
        var layer;

        if (comps.length < 1) {
            updateStatus(0, "Please select or open a comp first.");
            return;
        }

        app.beginUndoGroup("VFXReview Connector - Apply OCIO");
        for (i = 0; i < comps.length; i += 1) {
            comp = comps[i];
            for (j = 1; j <= comp.numLayers; j += 1) {
                try {
                    layer = comp.layer(j);
                    // Only apply to footage layers (not comps, solids, etc.)
                    if (layer.source && layer.source instanceof FootageItem && layer.source.mainSource instanceof FileSource) {
                        addOCIOToLayer(layer);
                        applied += 1;
                    }
                } catch (layerError) {
                    skipped += 1;
                }
            }
        }
        app.endUndoGroup();

        updateStatus(comps.length, "OCIO applied to " + applied + " layer(s). " + skipped + " skipped.");
    }

    function findOrCreateFolder(name, parentFolder) {
        var i;
        var item;

        if (!app.project) {
            return null;
        }

        for (i = 1; i <= app.project.numItems; i += 1) {
            item = app.project.item(i);
            if (item instanceof FolderItem && item.name === name) {
                if (!parentFolder || item.parentFolder === parentFolder) {
                    return item;
                }
            }
        }

        var newFolder = app.project.items.addFolder(name);
        if (parentFolder) {
            newFolder.parentFolder = parentFolder;
        }
        return newFolder;
    }

    function initialiseWorkspace() {
        var topFolderNames = ["_PREVIEWS", "_PRECOMPS", "_PICLOCKS", "_FOOTAGE_4K", "_STOCK", "_RENDERS", "_UTILITIES"];
        var templatePath = "X:/shared_projects_2026/UNGO_VFX/production/working_files/UNG_VFXOVERLAY_SLATE_TEMPLATE.aep";
        var shotsFolder;
        var epNum;
        var epCode;
        var templateFile;
        var importOptions;
        var importStatus;
        var i;

        if (!app.project) {
            updateStatus(0, "No project open.");
            return;
        }

        app.beginUndoGroup("VFXReview Connector - Initialise Workspace");

        // Create __SHOTS with UNG_101 – UNG_117 subfolders
        shotsFolder = findOrCreateFolder("__SHOTS", null);
        for (epNum = 101; epNum <= 117; epNum += 1) {
            epCode = "UNG_" + epNum;
            findOrCreateFolder(epCode, shotsFolder);
        }

        // Create remaining top-level folders
        for (i = 0; i < topFolderNames.length; i += 1) {
            findOrCreateFolder(topFolderNames[i], null);
        }

        // Import AEP template
        importStatus = "";
        templateFile = new File(templatePath);
        if (templateFile.exists) {
            try {
                importOptions = new ImportOptions(templateFile);
                importOptions.importAs = ImportAsType.PROJECT;
                app.project.importFile(importOptions);
                importStatus = " Template imported.";
            } catch (importError) {
                importStatus = " Template import failed: " + importError.message;
            }
        } else {
            importStatus = " Template file not found.";
        }

        app.endUndoGroup();

        // ── Color settings (outside undo group — project-level settings) ──────
        var colorStatus = "";

        // 32-bit depth
        try {
            app.project.bitsPerChannel = 32;
            colorStatus += " 32-bit depth set.";
        } catch (bpcError) {
            colorStatus += " Could not set 32-bit depth.";
        }

        // OCIO color management — AE 2022+ scripting API
        try {
            app.project.colorSettings.enabled = true;
            colorStatus += " Color management enabled.";
        } catch (csError) {
            colorStatus += " Color management API unavailable (set manually).";
        }

        try {
            // workingSpace index for OCIO ACES 1.2 varies by installation;
            // attempt to match by name first, fall back to direct assignment.
            var cs = app.project.colorSettings;
            var found = false;
            var wi;
            if (cs.getWorkingSpaceList) {
                var spaces = cs.getWorkingSpaceList();
                for (wi = 0; wi < spaces.length; wi += 1) {
                    if (String(spaces[wi]).indexOf("ACES") !== -1 && String(spaces[wi]).indexOf("1.2") !== -1) {
                        cs.workingSpace = spaces[wi];
                        found = true;
                        break;
                    }
                }
            }
            if (!found && cs.workingSpace !== undefined) {
                cs.workingSpace = "ACES - ACES2065-1";
            }
            colorStatus += " OCIO ACES 1.2 applied.";
        } catch (ocioError) {
            colorStatus += " OCIO config must be set manually in Project Settings.";
        }

        updateStatus(getSelectedComps().length, "Workspace initialised." + importStatus + colorStatus);
    }

    function clearDropdown(dropdown) {
        while (dropdown.items.length > 0) {
            dropdown.remove(dropdown.items[0]);
        }
    }

    function populateShotDropdown() {
        var filterValue;
        var shots;
        var i;
        var shotCode;
        var item;

        if (!uiState.episodeDropdown || !uiState.episodeDropdown.selection || !uiState.shotDropdown) {
            return;
        }

        filterValue = uiState.episodeDropdown.selection.filterValue || uiState.episodeDropdown.selection.text;
        shots = getShotsFromAPI(filterValue);

        clearDropdown(uiState.shotDropdown);
        for (i = 0; i < shots.length; i += 1) {
            shotCode = getShotCodeValue(shots[i]);
            if (shotCode) {
                item = uiState.shotDropdown.add("item", shotCode);
                item.shotCode = shotCode;
            }
        }

        if (uiState.shotDropdown.items.length > 0) {
            uiState.shotDropdown.selection = 0;
            updateStatus(getSelectedComps().length, uiState.shotDropdown.items.length + " shot(s) found for " + uiState.episodeDropdown.selection.text);
        } else {
            updateStatus(getSelectedComps().length, "No shots found for " + uiState.episodeDropdown.selection.text);
        }
    }

    function populateEpisodeOrSceneDropdown() {
        var isEpisodic = CURRENT_PROJECT && CURRENT_PROJECT.projectType === "EPISODIC";
        var i;
        var item;

        if (!uiState.episodeDropdown || !uiState.shotDropdown) { return; }

        clearDropdown(uiState.episodeDropdown);
        clearDropdown(uiState.shotDropdown);

        if (uiState.episodeLabel) {
            uiState.episodeLabel.text = isEpisodic ? "Ep:" : "Scene:";
        }

        if (isEpisodic) {
            var episodes = getEpisodesFromAPI();
            for (i = 0; i < episodes.length; i += 1) {
                var label = getEpisodeLabel(episodes[i]);
                var episodeValue = getEpisodeValue(episodes[i]);
                if (label && episodeValue) {
                    item = uiState.episodeDropdown.add("item", label);
                    item.filterValue = episodeValue;
                    item.episodeCode = deriveEpisodeCode(label);
                    if (!/^[A-Z]{3}_\d{3}$/.test(item.episodeCode)) {
                        item.episodeCode = deriveEpisodeCode(episodeValue);
                    }
                }
            }
        } else {
            var scenes = getScenesFromAPI();
            for (i = 0; i < scenes.length; i += 1) {
                item = uiState.episodeDropdown.add("item", scenes[i]);
                item.filterValue = scenes[i];
                item.episodeCode = null;
            }
        }

        if (uiState.episodeDropdown.items.length > 0) {
            uiState.episodeDropdown.selection = 0;
            populateShotDropdown();
        } else {
            updateStatus(getSelectedComps().length, isEpisodic ? "No episodes found." : "No scenes found.");
        }
    }

    function populateEpisodeDropdown() {
        populateEpisodeOrSceneDropdown();
    }

    function populateProjectDropdown() {
        var projects;
        var i;
        var p;
        var item;
        var selectIdx = 0;

        if (!uiState.projectDropdown) { return; }

        projects = getProjectsFromAPI();
        clearDropdown(uiState.projectDropdown);
        clearDropdown(uiState.episodeDropdown);
        clearDropdown(uiState.shotDropdown);

        for (i = 0; i < projects.length; i += 1) {
            p = projects[i];
            item = uiState.projectDropdown.add("item", p.code + " — " + p.name);
            item.projectData = p;
            if (p.code === CONFIG.lastProjectCode) { selectIdx = i; }
        }

        if (uiState.projectDropdown.items.length > 0) {
            uiState.projectDropdown.selection = selectIdx;
            CURRENT_PROJECT = uiState.projectDropdown.items[selectIdx].projectData;
            populateEpisodeOrSceneDropdown();
        } else {
            updateStatus(0, "No projects found — check server URL and token in Settings.");
        }
    }

    function openSettingsDialog() {
        var dlg = new Window("dialog", "VFXReview Connector — Settings", undefined);
        dlg.orientation = "column";
        dlg.alignChildren = ["fill", "top"];
        dlg.spacing = 6;
        dlg.margins = 16;

        function addField(parent, lbl, val) {
            var grp = parent.add("group");
            grp.orientation = "row";
            grp.alignChildren = ["fill", "center"];
            var label = grp.add("statictext", undefined, lbl);
            label.preferredSize = [110, 20];
            var field = grp.add("edittext", undefined, val || "");
            field.preferredSize = [280, 22];
            return field;
        }

        var fUrl    = addField(dlg, "Server URL:",    CONFIG.baseUrl);
        var fToken  = addField(dlg, "API Token:",     CONFIG.token);
        var fExport = addField(dlg, "Export Root:",   CONFIG.exportRoot);
        var fFootage= addField(dlg, "Footage Root:",  CONFIG.footageRoot);
        var fBanner = addField(dlg, "Banner Path:",   CONFIG.bannerImagePath);
        var fEmail  = addField(dlg, "Artist Email:",  CONFIG.artistEmail);
        var fHandles= addField(dlg, "Handle Frames:", String(CONFIG.handleFrames));

        var btnRow = dlg.add("group");
        btnRow.orientation = "row";
        btnRow.alignChildren = ["fill", "center"];
        btnRow.spacing = 8;
        // Using named "ok"/"cancel" — the only reliable way to capture dialog result in ScriptUI
        btnRow.add("button", undefined, "Save",   { name: "ok" });
        btnRow.add("button", undefined, "Cancel", { name: "cancel" });

        dlg.layout.layout(true);
        dlg.center();

        if (dlg.show() === 1) {
            CONFIG.baseUrl         = trimText(fUrl.text) || CONFIG.baseUrl;
            CONFIG.token           = trimText(fToken.text);
            CONFIG.exportRoot      = trimText(fExport.text);
            CONFIG.footageRoot     = trimText(fFootage.text);
            CONFIG.bannerImagePath = trimText(fBanner.text);
            CONFIG.artistEmail     = trimText(fEmail.text);
            CONFIG.handleFrames    = parseInt(fHandles.text, 10) || 8;
            saveConfig();
            populateProjectDropdown();
        }
    }

    // ── Shot Version Increment ────────────────────────────────────────────────

    function incrementVersionString(ver) {
        var match = String(ver).match(/^v(\d+)$/);
        var num;
        var str;

        if (!match) {
            return "v002";
        }

        num = parseInt(match[1], 10) + 1;
        str = String(num);
        while (str.length < 3) {
            str = "0" + str;
        }
        return "v" + str;
    }

    function patchShotVersion(shotId, newVersion) {
        var url = CONFIG.baseUrl + "/api/ext/shots/" + shotId;
        // Inner double-quotes escaped for Windows cmd.exe
        var body = "{\\\"shotVersion\\\":\\\"" + newVersion + "\\\"}";
        var command;
        var response;

        try {
            command = "curl -s -X PATCH " +
                "-H \"Authorization: Bearer " + CONFIG.token + "\" " +
                "-H \"Content-Type: application/json\" " +
                "-H \"Accept: application/json\" " +
                "-d \"" + body + "\" " +
                "\"" + url + "\"";

            response = system.callSystem(command);
        } catch (patchError) {
            return null;
        }

        return parseJSON(response);
    }

    function incrementShotVersions() {
        var comps = getTargetComps();
        var i;
        var shotCode;
        var data;
        var shot;
        var newVersion;
        var patchResult;
        var versionName;
        var previewComp;
        var slateLayer;
        var essentialProperties;
        var updated = 0;
        var skipped = 0;
        var lastVersion = "";

        if (comps.length < 1) {
            updateStatus(0, "Please select or open a comp first.");
            return;
        }

        app.beginUndoGroup("VFXReview Connector - Increment Shot Version");
        for (i = 0; i < comps.length; i += 1) {
            shotCode = getShotCodeFromComp(comps[i]);
            updateStatus(comps.length, "Incrementing " + shotCode + " (" + (i + 1) + " of " + comps.length + ")");

            data = getShotData(shotCode);
            if (!data || !data.shot) {
                skipped += 1;
                continue;
            }

            shot = data.shot;
            newVersion = incrementVersionString(shot.shotVersion || "v001");

            patchResult = patchShotVersion(shot.id, newVersion);
            if (!patchResult || !patchResult.success) {
                skipped += 1;
                continue;
            }

            data.shot.shotVersion = newVersion;
            versionName = shotCode + "_cmp_TT_" + newVersion;

            // 1. Update SHOT NAME on UNG_VFX_OVERLAY inside the selected comp
            updateOverlayForComp(comps[i], shotCode, data);

            // 2. Update {shotCode}_PREVIEW slate VersionName if the comp exists
            previewComp = findComp(getPreviewCompName(shotCode));
            if (previewComp) {
                try {
                    slateLayer = previewComp.layer("NETFLIX_SLATE");
                    essentialProperties = slateLayer.property("Essential Properties");
                    try {
                        essentialProperties.property("VersionName").setValue(versionName);
                    } catch (byNameError) {
                        essentialProperties.property(3).setValue(versionName);
                    }
                } catch (slateError) {
                }
            }

            lastVersion = newVersion;
            updated += 1;
        }
        app.endUndoGroup();

        if (updated > 0) {
            updateStatus(comps.length, updated + " shot(s) → " + lastVersion + ". " + skipped + " skipped.");
        } else {
            updateStatus(comps.length, "No shots incremented. " + skipped + " failed.");
        }
    }

    function setStartFrameFromTimecode() {
        var comps = getTargetComps();
        var i;
        var shotCode;
        var data;
        var shot;
        var fps;
        var frameNumber;
        var updated = 0;
        var skipped = 0;

        if (comps.length < 1) {
            updateStatus(0, "Please select or open a comp first.");
            return;
        }

        app.beginUndoGroup("VFXReview Connector - Set Start Timecode");
        for (i = 0; i < comps.length; i += 1) {
            shotCode = getShotCodeFromComp(comps[i]);
            updateStatus(comps.length, "Looking up " + shotCode + " (" + (i + 1) + " of " + comps.length + ")");
            data = getShotData(shotCode);

            if (!data || !data.shot || !data.shot.timecodeStart) {
                skipped += 1;
                continue;
            }

            shot = data.shot;
            fps = shot.fps || comps[i].frameRate || 24;
            frameNumber = parseTimecodeToFrames(shot.timecodeStart, fps) - CONFIG.handleFrames;
            setCompStartFrame(comps[i], frameNumber);
            updated += 1;
        }
        app.endUndoGroup();

        updateStatus(comps.length, updated + " comp(s) start frame set. " + skipped + " skipped.");
    }

    // ── Render Pipeline (Queue Export → server render queue) ─────────────────
    // Additive: the manual Queue EXR / MP4 / MOV / Prep Delivery buttons above
    // stay as escape hatches. Queue Export submits a Render Manifest to
    // POST /api/ext/exports; the SERVER increments the shot version and
    // decides output paths, so no local render or Increment Version click is
    // needed on this path.

    function jsonEscapeString(value) {
        var text = String(value);
        var out = "";
        var i;
        var ch;
        var code;

        for (i = 0; i < text.length; i += 1) {
            ch = text.charAt(i);
            code = text.charCodeAt(i);
            if (ch === "\"") {
                out += "\\\"";
            } else if (ch === "\\") {
                out += "\\\\";
            } else if (ch === "\n") {
                out += "\\n";
            } else if (ch === "\r") {
                out += "\\r";
            } else if (ch === "\t") {
                out += "\\t";
            } else if (code < 32) {
                out += "\\u" + ("000" + code.toString(16)).slice(-4);
            } else {
                out += ch;
            }
        }
        return out;
    }

    function jsonStringify(value) {
        var parts;
        var key;
        var i;

        if (value === null || value === undefined) {
            return "null";
        }
        if (typeof value === "number" || typeof value === "boolean") {
            return String(value);
        }
        if (typeof value === "string") {
            return "\"" + jsonEscapeString(value) + "\"";
        }
        if (value instanceof Array) {
            parts = [];
            for (i = 0; i < value.length; i += 1) {
                parts.push(jsonStringify(value[i]));
            }
            return "[" + parts.join(",") + "]";
        }
        parts = [];
        for (key in value) {
            if (value.hasOwnProperty(key) && value[key] !== undefined) {
                parts.push("\"" + jsonEscapeString(key) + "\":" + jsonStringify(value[key]));
            }
        }
        return "{" + parts.join(",") + "}";
    }

    function getConfigPath() {
        return Folder.userData.fsName + "/VFXReviewConnector/config.json";
    }

    function loadConfig() {
        try {
            var f = new File(getConfigPath());
            if (f.exists) {
                f.encoding = "UTF-8";
                f.open("r");
                var raw = f.read();
                f.close();
                var parsed = parseJSON(raw);
                if (parsed) {
                    for (var k in parsed) {
                        if (parsed.hasOwnProperty(k)) CONFIG[k] = parsed[k];
                    }
                }
            }
        } catch (e) {}
    }

    function saveConfig() {
        try {
            var dir = new Folder(Folder.userData.fsName + "/VFXReviewConnector");
            if (!dir.exists) { dir.create(); }
            var f = new File(getConfigPath());
            f.encoding = "UTF-8";
            f.open("w");
            f.write(jsonStringify(CONFIG));
            f.close();
        } catch (e) {}
    }

    // POST JSON via a temp file (-d @file) — avoids cmd.exe quote-escaping
    // problems for large bodies. Returns { status: httpCode, data: parsed }.
    function postJSON(url, bodyObject) {
        var bodyFile;
        var command;
        var response = null;
        var markerIndex;
        var statusCode = 0;
        var jsonText;

        bodyFile = new File(Folder.temp.fsName + "/vfxr_post_body.json");
        try {
            bodyFile.encoding = "UTF-8";
            if (!bodyFile.open("w")) {
                return { status: 0, data: null };
            }
            bodyFile.write(jsonStringify(bodyObject));
            bodyFile.close();

            command = "curl -s -X POST " +
                "-H \"Authorization: Bearer " + CONFIG.token + "\" " +
                "-H \"Content-Type: application/json\" " +
                "-H \"Accept: application/json\" " +
                "-d \"@" + bodyFile.fsName + "\" " +
                "-w \"__HTTP__:%{http_code}\" " +
                "\"" + url + "\"";

            response = system.callSystem(command);
        } catch (postError) {
            response = null;
        }
        try {
            bodyFile.remove();
        } catch (removeError) {
        }

        if (!response) {
            return { status: 0, data: null };
        }

        markerIndex = response.lastIndexOf("__HTTP__:");
        if (markerIndex >= 0) {
            statusCode = parseInt(response.substring(markerIndex + 9), 10) || 0;
            jsonText = response.substring(0, markerIndex);
        } else {
            jsonText = response;
        }

        return { status: statusCode, data: parseJSON(jsonText) };
    }

    function getLatestExportData(shotCode) {
        var url = CONFIG.baseUrl + "/api/ext/exports/latest" +
            "?shotCode=" + encodeURIComponent(shotCode) +
            "&projectCode=" + (CURRENT_PROJECT ? CURRENT_PROJECT.code : "");
        return getAPIData(url);
    }

    function isActiveExportStatus(status) {
        return status === "QUEUED" || status === "RENDERING" ||
            status === "VALIDATING" || status === "GENERATING_PREVIEW";
    }

    function formatEtaShort(seconds) {
        var mins;
        var secs;

        if (!seconds || seconds <= 0) {
            return "";
        }
        mins = Math.floor(seconds / 60);
        secs = Math.round(seconds % 60);
        if (secs < 10) {
            secs = "0" + secs;
        }
        return mins + ":" + secs;
    }

    function formatExportStatus(shotCode, exportInfo) {
        var text;
        var job;
        var pct;
        var eta;

        if (!exportInfo) {
            return shotCode + " — no exports yet";
        }

        text = shotCode + " " + (exportInfo.versionString || "") + " — " +
            String(exportInfo.status || "").replace(/_/g, " ");
        job = exportInfo.renderJob;

        if (exportInfo.status === "RENDERING" && job) {
            pct = Math.round((job.progress || 0) * 100);
            text += " " + pct + "%";
            eta = formatEtaShort(job.etaSeconds);
            if (eta) {
                text += " (ETA " + eta;
                if (job.machineName) {
                    text += " on " + job.machineName;
                }
                text += ")";
            } else if (job.machineName) {
                text += " (" + job.machineName + ")";
            }
        } else if (job && job.errorMessage && /FAILED/.test(String(exportInfo.status))) {
            text += ": " + String(job.errorMessage).substring(0, 60);
        }
        return text;
    }

    function setPipelineStatus(message) {
        if (uiState.pipelineStatusText) {
            uiState.pipelineStatusText.text = message || "";
        }
    }

    function stopExportPolling() {
        if (pipelineState.pollTaskId) {
            try {
                app.cancelTask(pipelineState.pollTaskId);
            } catch (cancelError) {
            }
            pipelineState.pollTaskId = 0;
        }
        pipelineState.pollShotCode = null;
    }

    function pollExportTick() {
        var shotCode = pipelineState.pollShotCode;
        var data;
        var exportInfo;

        if (!shotCode) {
            stopExportPolling();
            return;
        }

        try {
            data = getLatestExportData(shotCode);
            if (data && data.export !== undefined) {
                exportInfo = data.export;
                setPipelineStatus(formatExportStatus(shotCode, exportInfo));
                if (!exportInfo || !isActiveExportStatus(exportInfo.status)) {
                    stopExportPolling();
                }
            }
        } catch (pollError) {
        }
    }

    // While an export is active the panel polls every 15 s (spec §12.1); the
    // task cancels itself once the export reaches a non-active state.
    function startExportPolling(shotCode) {
        stopExportPolling();
        pipelineState.pollShotCode = shotCode;
        try {
            pipelineState.pollTaskId = app.scheduleTask("__vfxrExportPollTick()", 15000, true);
        } catch (scheduleError) {
            pipelineState.pollTaskId = 0;
        }
    }

    function getStatusShotCode() {
        var comps = getTargetComps();
        var mainComp;
        var code;

        if (comps.length > 0) {
            mainComp = getMainCompForSelection(comps[0]) || comps[0];
            code = extractShotCode(mainComp.name);
            if (code) {
                return code;
            }
        }
        if (uiState.shotDropdown && uiState.shotDropdown.selection) {
            return String(uiState.shotDropdown.selection.text || "") || null;
        }
        return null;
    }

    // Slate fields carry forward: pull the previous submission's values when the
    // selected shot changes, but never clobber edits the artist has just typed
    // for the same shot.
    function prefillSlateFields(shotCode, exportInfo) {
        if (pipelineState.prefilledShotCode === shotCode) {
            return;
        }
        if (uiState.submissionNoteField) {
            uiState.submissionNoteField.text = (exportInfo && exportInfo.submissionNote) ? exportInfo.submissionNote : "";
        }
        pipelineState.prefilledShotCode = shotCode;
    }

    function refreshExportStatus() {
        var shotCode = getStatusShotCode();
        var data;

        if (!shotCode) {
            setPipelineStatus("Select a comp or shot first");
            return;
        }
        setPipelineStatus("Checking " + shotCode + "...");
        data = getLatestExportData(shotCode);
        if (!data || data.export === undefined) {
            setPipelineStatus(shotCode + " — status check failed (server unreachable?)");
            return;
        }
        prefillSlateFields(shotCode, data.export);
        setPipelineStatus(formatExportStatus(shotCode, data.export));
        if (data.export && isActiveExportStatus(data.export.status)) {
            startExportPolling(shotCode);
        }
    }

    function buildRenderManifest(comp, shot) {
        var frameStart;
        var frameEnd;
        var frames;
        var aepPath = app.project.file.fsName.replace(/\\/g, "/");

        // Prefer the shot record's authoritative range (the server cross-checks
        // it) — but only when it looks real: shots imported without ranges
        // carry 0/0, which must never reach aerender. Fall back to the comp.
        if (shot.frameStart !== null && shot.frameStart !== undefined &&
            shot.frameEnd !== null && shot.frameEnd !== undefined &&
            Number(shot.frameEnd) >= Number(shot.frameStart) &&
            !(Number(shot.frameStart) === 0 && Number(shot.frameEnd) === 0)) {
            frameStart = shot.frameStart;
            frameEnd = shot.frameEnd;
        } else {
            frameStart = Math.round(comp.displayStartTime * comp.frameRate);
            frames = Math.round(comp.duration * comp.frameRate);
            frameEnd = frameStart + frames - 1;
        }

        return {
            manifestVersion: 1,
            projectCode: CURRENT_PROJECT ? CURRENT_PROJECT.code : "",
            shotCode: shot.shotCode,
            shotId: shot.id,
            aepPath: aepPath,
            compName: comp.name,
            rendererType: "aerender",
            outputDir: "auto",
            outputPattern: "auto",
            outputModuleTemplate: "EXR Sequence",
            frameStart: frameStart,
            frameEnd: frameEnd,
            fps: Math.round(comp.frameRate * 1000) / 1000,
            width: comp.width,
            height: comp.height
        };
    }

    function submitExport(manifest, urgent, force) {
        var body = {
            manifest: manifest,
            force: force ? true : false
        };

        if (CONFIG.artistEmail) {
            body.submittedByEmail = CONFIG.artistEmail;
        }
        if (urgent) {
            body.priority = 20;
        }
        // submissionNote is a per-export override; if blank the server falls back to the shot's slate default.
        if (uiState.submissionNoteField) {
            var note = trimText(uiState.submissionNoteField.text);
            if (note) {
                body.submissionNote = note;
            }
        }
        return postJSON(CONFIG.baseUrl + "/api/ext/exports", body);
    }

    function queueExport() {
        var comps = getTargetComps();
        var result = makeBatchResult(comps.length);
        var urgent = uiState.urgentCheckbox ? uiState.urgentCheckbox.value : false;
        var i;
        var comp;
        var lookupCode;
        var data;
        var manifest;
        var response;
        var errorText;
        var lastQueued = null;
        var lastShotCode = null;

        if (comps.length < 1) {
            updateStatus(0, "Select or open a comp first.");
            return;
        }
        if (!app.project || !app.project.file) {
            updateStatus(comps.length, "Save the project first — the render farm opens the AEP from disk.");
            return;
        }

        // The worker renders the saved file; unsaved changes would silently
        // render stale comps.
        try {
            app.project.save();
        } catch (saveError) {
            updateStatus(comps.length, "Could not save the project: " + saveError);
            return;
        }

        for (i = 0; i < comps.length; i += 1) {
            comp = getMainCompForSelection(comps[i]);
            if (!comp) {
                addFailure(result, comps[i].name, "Main comp not found for preview comp");
                continue;
            }
            lookupCode = getShotCodeFromComp(comp);
            updateStatus(comps.length, "Queuing " + lookupCode + " (" + (i + 1) + " of " + comps.length + ")...");

            data = getShotData(lookupCode);
            if (!data || !data.shot || !data.shot.id) {
                addFailure(result, comp.name, "API lookup failed");
                continue;
            }

            manifest = buildRenderManifest(comp, data.shot);
            response = submitExport(manifest, urgent, false);

            if (response.status === 409) {
                errorText = (response.data && response.data.error) ?
                    response.data.error : "An active export already exists for this shot.";
                if (confirm(lookupCode + ":\n" + errorText +
                        "\n\nForce supersede the active export and queue anyway?")) {
                    response = submitExport(manifest, urgent, true);
                } else {
                    addFailure(result, comp.name, "Skipped (active export kept)");
                    continue;
                }
            }

            if (response.status === 201 && response.data && response.data.export) {
                result.queued += 1;
                lastQueued = response.data.export;
                lastShotCode = response.data.export.shotCode || lookupCode;
            } else {
                errorText = (response.data && response.data.error) ?
                    response.data.error : ("HTTP " + response.status);
                addFailure(result, comp.name, errorText);
            }
        }

        if (lastQueued) {
            updateStatus(comps.length, "Queued " + lastQueued.versionString +
                (urgent ? " (urgent)" : "") + " on render farm. " +
                result.queued + " queued, " + result.skipped + " skipped.");
            setPipelineStatus(formatExportStatus(lastShotCode, lastQueued));
            startExportPolling(lastShotCode);
        } else {
            updateStatus(comps.length, "No exports queued. " +
                (result.failures.length > 0 ? result.failures[0] : ""));
        }
    }

    function retryLatestExport() {
        var shotCode = getStatusShotCode();
        var data;
        var exportInfo;
        var response;

        if (!shotCode) {
            setPipelineStatus("Select a comp or shot first");
            return;
        }
        data = getLatestExportData(shotCode);
        exportInfo = data ? data.export : null;
        if (!exportInfo) {
            setPipelineStatus(shotCode + " — no export to retry");
            return;
        }
        if (!/FAILED$/.test(String(exportInfo.status))) {
            setPipelineStatus(formatExportStatus(shotCode, exportInfo) + " — nothing to retry");
            return;
        }

        response = postJSON(CONFIG.baseUrl + "/api/ext/exports/" + exportInfo.id + "/retry",
            { note: "Retry from AE panel" });
        if (response.status === 200) {
            setPipelineStatus(shotCode + " " + exportInfo.versionString + " — requeued");
            startExportPolling(shotCode);
        } else {
            setPipelineStatus("Retry failed: " +
                ((response.data && response.data.error) ? response.data.error : "HTTP " + response.status));
        }
    }

    function buildUI(thisObjRef) {
        var win = (thisObjRef instanceof Panel) ? thisObjRef : new Window("palette", "VFXReview Connector", undefined, { resizeable: true });
        var bannerFile;
        var bannerImage;
        var title;
        var shotBuilderPanel;
        var shotBuilderGroup;
        var shotBuilderButton;
        var pipelinePanel;
        var pipelineRow1;
        var pipelineRow2;
        var queueExportButton;
        var refreshExportButton;
        var retryExportButton;
        var buttonsPanel;
        var btnRow1;
        var btnRow2;
        var btnRow3;
        var btnRow4;
        var btnRow4b;
        var btnRow4c;
        var prepDeliveryButton;
        var refreshButton;
        var fixNamesButton;
        var overlayButton;
        var previewButton;
        var exrButton;
        var exrPreviewButton;
        var importExrButton;
        var mp4Button;
        var movButton;
        var btnRow5;
        var importRendersButton;
        var btnRow6;
        var pictureLockButton;
        var btnRow7;
        var incrementVersionButton;
        var btnRow6b;
        var setStartTCButton;
        var colorSpacePanel;
        var ocioButton;
        var workspacePanel;
        var initWorkspaceButton;
        var statusPanel;

        win.orientation = "column";
        win.alignChildren = ["fill", "fill"];
        win.spacing = 0;
        win.margins = 0;

        var scrollRow = win.add("group");
        scrollRow.orientation = "row";
        scrollRow.alignChildren = ["fill", "fill"];
        scrollRow.spacing = 0;
        scrollRow.margins = 0;

        var scrollContent = scrollRow.add("group");
        scrollContent.orientation = "column";
        scrollContent.alignChildren = ["fill", "top"];
        scrollContent.spacing = 8;
        scrollContent.margins = 12;

        var vScrollbar = scrollRow.add("scrollbar");
        vScrollbar.orientation = "vertical";
        vScrollbar.preferredSize = [14, -1];
        vScrollbar.minvalue = 0;
        vScrollbar.value = 0;

        bannerFile = new File(CONFIG.bannerImagePath);
        if (bannerFile.exists) {
            try {
                bannerImage = scrollContent.add("image", undefined, bannerFile);
                bannerImage.alignment = ["fit", "top"];
                bannerImage.size = [360, 80];
            } catch (bannerError) {
            }
        }

        title = scrollContent.add("statictext", undefined, "VFXReview Connector");
        title.alignment = ["fill", "top"];

        shotBuilderPanel = scrollContent.add("panel", undefined, "Shot Builder");
        shotBuilderPanel.orientation = "column";
        shotBuilderPanel.alignChildren = ["fill", "top"];
        shotBuilderPanel.margins = 10;

        shotBuilderGroup = shotBuilderPanel.add("group");
        shotBuilderGroup.orientation = "row";
        shotBuilderGroup.alignChildren = ["fill", "center"];
        shotBuilderGroup.spacing = 6;

        // Project row
        var projectRow = shotBuilderPanel.add("group");
        projectRow.orientation = "row";
        projectRow.alignChildren = ["fill", "center"];
        projectRow.spacing = 6;
        var projectLabel = projectRow.add("statictext", undefined, "Project:");
        projectLabel.preferredSize = [42, 20];
        uiState.projectDropdown = projectRow.add("dropdownlist", undefined, []);
        uiState.projectDropdown.preferredSize = [220, 24];
        var settingsBtn = projectRow.add("button", undefined, "\u2699");
        settingsBtn.preferredSize = [26, 24];
        settingsBtn.helpTip = "Settings";

        // Episode / Scene row
        var episodeRow = shotBuilderPanel.add("group");
        episodeRow.orientation = "row";
        episodeRow.alignChildren = ["fill", "center"];
        episodeRow.spacing = 6;
        uiState.episodeLabel = episodeRow.add("statictext", undefined, "Ep:");
        uiState.episodeLabel.preferredSize = [42, 20];
        uiState.episodeDropdown = episodeRow.add("dropdownlist", undefined, []);
        uiState.episodeDropdown.preferredSize = [110, 24];
        uiState.shotDropdown = episodeRow.add("dropdownlist", undefined, []);
        uiState.shotDropdown.preferredSize = [130, 24];
        shotBuilderButton = episodeRow.add("button", undefined, "Build Shot");

        pipelinePanel = scrollContent.add("panel", undefined, "Render Pipeline");
        pipelinePanel.orientation = "column";
        pipelinePanel.alignChildren = ["fill", "top"];
        pipelinePanel.margins = 8;
        pipelinePanel.spacing = 4;

        uiState.pipelineStatusText = pipelinePanel.add("statictext", undefined, "Export status: not checked");

        pipelineRow1 = pipelinePanel.add("group");
        pipelineRow1.orientation = "row";
        pipelineRow1.alignChildren = ["fill", "center"];
        pipelineRow1.spacing = 6;
        queueExportButton = pipelineRow1.add("button", undefined, "Queue Export");
        uiState.urgentCheckbox = pipelineRow1.add("checkbox", undefined, "Urgent");

        var slateNoteGroup = pipelinePanel.add("group");
        slateNoteGroup.orientation = "row";
        slateNoteGroup.alignChildren = ["fill", "top"];
        slateNoteGroup.spacing = 6;
        var slateNoteLabel = slateNoteGroup.add("statictext", undefined, "Sub. Note:");
        slateNoteLabel.preferredSize = [70, 20];
        uiState.submissionNoteField = slateNoteGroup.add("edittext", undefined, "", { multiline: false });
        uiState.submissionNoteField.preferredSize = [240, 22];

        pipelineRow2 = pipelinePanel.add("group");
        pipelineRow2.orientation = "row";
        pipelineRow2.alignChildren = ["fill", "center"];
        pipelineRow2.spacing = 6;
        refreshExportButton = pipelineRow2.add("button", undefined, "Refresh Status");
        retryExportButton = pipelineRow2.add("button", undefined, "Retry Export");

        buttonsPanel = scrollContent.add("panel", undefined, "Actions");
        buttonsPanel.orientation = "column";
        buttonsPanel.alignChildren = ["fill", "top"];
        buttonsPanel.margins = 8;
        buttonsPanel.spacing = 4;

        btnRow1 = buttonsPanel.add("group");
        btnRow1.orientation = "row";
        btnRow1.alignChildren = ["fill", "center"];
        btnRow1.spacing = 6;
        refreshButton = btnRow1.add("button", undefined, "Refresh");
        fixNamesButton = btnRow1.add("button", undefined, "Fix Comp Names");

        btnRow2 = buttonsPanel.add("group");
        btnRow2.orientation = "row";
        btnRow2.alignChildren = ["fill", "center"];
        btnRow2.spacing = 6;
        overlayButton = btnRow2.add("button", undefined, "Add Overlay");
        previewButton = btnRow2.add("button", undefined, "Build Preview");

        btnRow3 = buttonsPanel.add("group");
        btnRow3.orientation = "row";
        btnRow3.alignChildren = ["fill", "center"];
        btnRow3.spacing = 6;
        mp4Button = btnRow3.add("button", undefined, "Queue MP4");
        movButton = btnRow3.add("button", undefined, "Queue MOV");

        btnRow4 = buttonsPanel.add("group");
        btnRow4.orientation = "row";
        btnRow4.alignChildren = ["fill", "center"];
        btnRow4.spacing = 6;
        exrButton = btnRow4.add("button", undefined, "Queue EXR");
        exrPreviewButton = btnRow4.add("button", undefined, "Queue EXR (Review)");

        btnRow4b = buttonsPanel.add("group");
        btnRow4b.orientation = "row";
        btnRow4b.alignChildren = ["fill", "center"];
        btnRow4b.spacing = 6;
        importExrButton = btnRow4b.add("button", undefined, "Import EXR");

        btnRow4c = buttonsPanel.add("group");
        btnRow4c.orientation = "row";
        btnRow4c.alignChildren = ["fill", "center"];
        btnRow4c.spacing = 6;
        prepDeliveryButton = btnRow4c.add("button", undefined, "Prep Delivery");

        btnRow5 = buttonsPanel.add("group");
        btnRow5.orientation = "row";
        btnRow5.alignChildren = ["fill", "center"];
        btnRow5.spacing = 6;
        importRendersButton = btnRow5.add("button", undefined, "Import Renders");

        btnRow6 = buttonsPanel.add("group");
        btnRow6.orientation = "row";
        btnRow6.alignChildren = ["fill", "center"];
        btnRow6.spacing = 6;
        pictureLockButton = btnRow6.add("button", undefined, "Pull Picture Lock");

        btnRow6b = buttonsPanel.add("group");
        btnRow6b.orientation = "row";
        btnRow6b.alignChildren = ["fill", "center"];
        btnRow6b.spacing = 6;
        setStartTCButton = btnRow6b.add("button", undefined, "Set Start Timecode");

        btnRow7 = buttonsPanel.add("group");
        btnRow7.orientation = "row";
        btnRow7.alignChildren = ["fill", "center"];
        btnRow7.spacing = 6;
        incrementVersionButton = btnRow7.add("button", undefined, "Increment Shot Version");

        workspacePanel = scrollContent.add("panel", undefined, "Workspace");
        workspacePanel.orientation = "column";
        workspacePanel.alignChildren = ["fill", "top"];
        workspacePanel.margins = 8;
        workspacePanel.spacing = 4;
        initWorkspaceButton = workspacePanel.add("button", undefined, "Initialise Workspace");

        colorSpacePanel = scrollContent.add("panel", undefined, "Color Space");
        colorSpacePanel.orientation = "column";
        colorSpacePanel.alignChildren = ["fill", "top"];
        colorSpacePanel.margins = 8;
        colorSpacePanel.spacing = 4;
        ocioButton = colorSpacePanel.add("button", undefined, "Add OCIO to Footage Layers");

        statusPanel = scrollContent.add("panel", undefined, "Status");
        statusPanel.orientation = "column";
        statusPanel.alignChildren = ["fill", "top"];
        statusPanel.margins = 10;

        uiState.selectionText = statusPanel.add("statictext", undefined, "0 comps selected");
        uiState.lastActionText = statusPanel.add("statictext", undefined, "Ready");

        uiState.projectDropdown.onChange = function () {
            if (uiState.projectDropdown.selection) {
                CURRENT_PROJECT = uiState.projectDropdown.selection.projectData;
                if (CURRENT_PROJECT) {
                    CONFIG.lastProjectCode = CURRENT_PROJECT.code;
                    saveConfig();
                }
                populateEpisodeOrSceneDropdown();
            }
        };
        uiState.episodeDropdown.onChange = populateShotDropdown;
        // ScriptUI silently swallows exceptions thrown inside onClick handlers.
        settingsBtn.onClick = function () {
            try {
                openSettingsDialog();
            } catch (settingsError) {
                alert("VFXReview Connector \u2014 Settings error:\n" + settingsError.toString() +
                    (settingsError.line ? "\nLine " + settingsError.line : ""));
            }
        };
        shotBuilderButton.onClick = buildShotFromDropdown;
        queueExportButton.onClick = queueExport;
        refreshExportButton.onClick = refreshExportStatus;
        retryExportButton.onClick = retryLatestExport;
        refreshButton.onClick = refreshSelection;
        fixNamesButton.onClick = fixCompNames;
        overlayButton.onClick = addOverlay;
        previewButton.onClick = buildPreviews;
        exrButton.onClick = queueEXR;
        exrPreviewButton.onClick = queueEXRPreview;
        importExrButton.onClick = importExportedEXR;
        prepDeliveryButton.onClick = prepDelivery;
        mp4Button.onClick = queueMP4;
        movButton.onClick = queueMOV;
        ocioButton.onClick = applyOCIOColorSpace;
        initWorkspaceButton.onClick = initialiseWorkspace;
        importRendersButton.onClick = importRenders;
        pictureLockButton.onClick = pullPictureLock;
        setStartTCButton.onClick = setStartFrameFromTimecode;
        incrementVersionButton.onClick = incrementShotVersions;

        win.layout.layout(true);
        win.layout.resize();

        var _contentH = scrollContent.size[1];
        vScrollbar.maxvalue = Math.max(0, _contentH - scrollRow.size[1]);
        vScrollbar.stepdelta = 20;
        vScrollbar.jumpdelta = 80;

        vScrollbar.onChanging = function () {
            scrollContent.location.y = -Math.round(vScrollbar.value);
        };

        win.onResizing = win.onResize = function () {
            this.layout.resize();
            var visible = scrollRow.size[1];
            vScrollbar.maxvalue = Math.max(0, _contentH - visible);
            if (vScrollbar.value > vScrollbar.maxvalue) {
                vScrollbar.value = vScrollbar.maxvalue;
            }
            scrollContent.location.y = -Math.round(vScrollbar.value);
        };

        return win;
    }

    // app.scheduleTask evaluates in global scope — expose the poll tick there
    $.global.__vfxrExportPollTick = pollExportTick;

    loadConfig();
    var panel = buildUI(thisObj);
    refreshSelection();
    populateProjectDropdown();

    if (panel instanceof Window) {
        panel.center();
        panel.show();
    }
}(this));
