- Adicionado estrutura completa do projeto - Configurado MCP server para Premiere Pro - Adicionado documentação e skills - Configurado Gitignore para o projeto
292 lines
10 KiB
TypeScript
Executable File
292 lines
10 KiB
TypeScript
Executable File
import {
|
|
copyFileSync,
|
|
constants,
|
|
existsSync,
|
|
readFileSync,
|
|
readdirSync,
|
|
statSync,
|
|
} from "node:fs";
|
|
import { createHash } from "node:crypto";
|
|
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
import { buildToolScript } from "../bridge/script-builder.js";
|
|
import {
|
|
getTempDir,
|
|
getBridgeLiveness,
|
|
sendCommand,
|
|
type BridgeOptions,
|
|
type CommandResult,
|
|
} from "../bridge/file-bridge.js";
|
|
|
|
const MAX_CANDIDATES = 50;
|
|
const AUTOSAVE_DIR_NAMES = [
|
|
"Adobe Premiere Pro Auto-Save",
|
|
"Premiere Pro Auto-Save",
|
|
];
|
|
|
|
interface ProjectSnapshot {
|
|
name: string;
|
|
path: string;
|
|
}
|
|
|
|
export interface RecoveryCandidate {
|
|
path: string;
|
|
fileName: string;
|
|
modifiedAt: string;
|
|
modifiedMs: number;
|
|
sizeBytes: number;
|
|
newerThanProjectFile: boolean | null;
|
|
}
|
|
|
|
export interface ProjectBackupReceipt {
|
|
sourcePath: string;
|
|
backupPath: string;
|
|
sizeBytes: number;
|
|
checksumSha256: string;
|
|
sourceUnchanged: true;
|
|
byteIdentical: true;
|
|
}
|
|
|
|
function sha256File(path: string): string {
|
|
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
}
|
|
|
|
export function createProjectBackup(projectPath: string, now = new Date()): ProjectBackupReceipt {
|
|
const sourcePath = resolve(projectPath);
|
|
if (extname(sourcePath).toLowerCase() !== ".prproj") {
|
|
throw new Error("project_path must point to an Adobe Premiere .prproj file");
|
|
}
|
|
if (!existsSync(sourcePath)) throw new Error(`Project file does not exist: ${sourcePath}`);
|
|
const before = statSync(sourcePath);
|
|
if (!before.isFile()) throw new Error(`Project path is not a regular file: ${sourcePath}`);
|
|
|
|
const stamp = now.toISOString().replace(/[:.]/g, "-");
|
|
const backupPath = `${sourcePath}.backup-${stamp}`;
|
|
copyFileSync(sourcePath, backupPath, constants.COPYFILE_EXCL);
|
|
|
|
const after = statSync(sourcePath);
|
|
const backup = statSync(backupPath);
|
|
if (before.size !== after.size || before.mtimeMs !== after.mtimeMs) {
|
|
throw new Error("Project file changed while its backup was being created; do not rely on this copy");
|
|
}
|
|
const sourceChecksum = sha256File(sourcePath);
|
|
const backupChecksum = sha256File(backupPath);
|
|
if (backup.size !== before.size || backupChecksum !== sourceChecksum) {
|
|
throw new Error("Project backup verification failed: copied bytes do not match the source");
|
|
}
|
|
return {
|
|
sourcePath,
|
|
backupPath,
|
|
sizeBytes: backup.size,
|
|
checksumSha256: backupChecksum,
|
|
sourceUnchanged: true,
|
|
byteIdentical: true,
|
|
};
|
|
}
|
|
|
|
export function discoverAdjacentRecoveryCandidates(
|
|
projectPath: string,
|
|
): RecoveryCandidate[] {
|
|
if (!projectPath || extname(projectPath).toLowerCase() !== ".prproj") return [];
|
|
const absoluteProjectPath = resolve(projectPath);
|
|
const projectDirectory = dirname(absoluteProjectPath);
|
|
const projectStem = basename(projectPath, extname(projectPath)).toLowerCase();
|
|
let projectModifiedMs: number | null = null;
|
|
try {
|
|
projectModifiedMs = statSync(absoluteProjectPath).mtimeMs;
|
|
} catch {
|
|
// An unsaved/new or inaccessible project may not exist on disk yet.
|
|
}
|
|
|
|
const roots = [
|
|
...AUTOSAVE_DIR_NAMES.map((name) => join(projectDirectory, name)),
|
|
projectDirectory,
|
|
];
|
|
const seen = new Set<string>();
|
|
const candidates: RecoveryCandidate[] = [];
|
|
for (const root of roots) {
|
|
if (!existsSync(root)) continue;
|
|
let names: string[] = [];
|
|
try {
|
|
names = readdirSync(root);
|
|
} catch {
|
|
continue;
|
|
}
|
|
for (const name of names) {
|
|
if (candidates.length >= MAX_CANDIDATES) break;
|
|
if (extname(name).toLowerCase() !== ".prproj") continue;
|
|
if (!name.toLowerCase().includes(projectStem)) continue;
|
|
const candidatePath = resolve(root, name);
|
|
if (candidatePath === absoluteProjectPath || seen.has(candidatePath)) continue;
|
|
seen.add(candidatePath);
|
|
try {
|
|
const stat = statSync(candidatePath);
|
|
if (!stat.isFile()) continue;
|
|
candidates.push({
|
|
path: candidatePath,
|
|
fileName: basename(candidatePath),
|
|
modifiedAt: stat.mtime.toISOString(),
|
|
modifiedMs: stat.mtimeMs,
|
|
sizeBytes: stat.size,
|
|
newerThanProjectFile:
|
|
projectModifiedMs === null ? null : stat.mtimeMs > projectModifiedMs,
|
|
});
|
|
} catch {
|
|
// A candidate can disappear during an autosave; omit unstable entries.
|
|
}
|
|
}
|
|
}
|
|
return candidates.sort((left, right) => right.modifiedMs - left.modifiedMs);
|
|
}
|
|
|
|
export function collectBridgeTelemetry(
|
|
bridgeOptions: BridgeOptions,
|
|
nowMs = Date.now(),
|
|
) {
|
|
const directory = getTempDir(bridgeOptions);
|
|
const heartbeat = getBridgeLiveness(bridgeOptions, nowMs);
|
|
const counts = { pendingCommands: 0, pendingResponses: 0, busyOperations: 0 };
|
|
let oldestPendingAgeMs: number | null = null;
|
|
let directoryAccessible = false;
|
|
try {
|
|
if (existsSync(directory)) {
|
|
directoryAccessible = true;
|
|
for (const name of readdirSync(directory)) {
|
|
let bucket: keyof typeof counts | null = null;
|
|
if (/^cmd_.+\.jsx$/.test(name)) bucket = "pendingCommands";
|
|
else if (/^res_.+\.json$/.test(name)) bucket = "pendingResponses";
|
|
else if (/^busy_.+\.json$/.test(name)) bucket = "busyOperations";
|
|
if (!bucket) continue;
|
|
counts[bucket]++;
|
|
try {
|
|
const age = Math.max(0, nowMs - statSync(join(directory, name)).mtimeMs);
|
|
oldestPendingAgeMs =
|
|
oldestPendingAgeMs === null ? age : Math.max(oldestPendingAgeMs, age);
|
|
} catch {
|
|
// Aggregate telemetry remains useful if one transient file disappears.
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
directoryAccessible = false;
|
|
}
|
|
return {
|
|
schemaVersion: 1,
|
|
directoryAccessible,
|
|
...counts,
|
|
oldestPendingAgeMs,
|
|
heartbeat,
|
|
healthy:
|
|
directoryAccessible &&
|
|
heartbeat.state === "running" &&
|
|
counts.busyOperations === 0 &&
|
|
(oldestPendingAgeMs === null || oldestPendingAgeMs < 30_000),
|
|
privacy:
|
|
"Aggregate local bridge state only; no paths, filenames, script contents, project names, or media names are returned.",
|
|
};
|
|
}
|
|
|
|
function projectSnapshot(result: CommandResult): ProjectSnapshot | null {
|
|
if (!result.success || !result.data || typeof result.data !== "object") return null;
|
|
const value = result.data as Record<string, unknown>;
|
|
if (typeof value.name !== "string" || typeof value.path !== "string") return null;
|
|
return { name: value.name, path: value.path };
|
|
}
|
|
|
|
export function getRecoveryTools(bridgeOptions: BridgeOptions) {
|
|
return {
|
|
create_project_backup: {
|
|
description:
|
|
"Create a collision-safe, byte-verified backup beside an existing .prproj file without opening or modifying the source project.",
|
|
parameters: {
|
|
type: "object" as const,
|
|
properties: {
|
|
project_path: {
|
|
type: "string",
|
|
description: "Absolute or working-directory-relative path to an existing .prproj file",
|
|
},
|
|
},
|
|
required: ["project_path"],
|
|
},
|
|
handler: async (args: { project_path: string }) => {
|
|
try {
|
|
return { success: true, data: createProjectBackup(args.project_path) };
|
|
} catch (error) {
|
|
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
|
}
|
|
},
|
|
},
|
|
|
|
inspect_project_recovery: {
|
|
description:
|
|
"Read-only recovery inspection: diagnose the active project path and list adjacent Premiere Auto-Save project candidates without opening, copying, or restoring anything.",
|
|
parameters: {},
|
|
handler: async () => {
|
|
const result = await sendCommand(
|
|
buildToolScript(`
|
|
if (!app.project) return __error("No project is open");
|
|
return __result({
|
|
name: app.project.name || "",
|
|
path: app.project.path || ""
|
|
});
|
|
`),
|
|
bridgeOptions,
|
|
);
|
|
if (!result.success) return result;
|
|
const project = projectSnapshot(result);
|
|
if (!project) {
|
|
return {
|
|
success: false,
|
|
error: "Premiere returned an invalid project snapshot",
|
|
};
|
|
}
|
|
const hasSavedPath =
|
|
project.path.length > 0 &&
|
|
extname(project.path).toLowerCase() === ".prproj";
|
|
const candidates = hasSavedPath
|
|
? discoverAdjacentRecoveryCandidates(project.path)
|
|
: [];
|
|
return {
|
|
success: true,
|
|
data: {
|
|
project: {
|
|
name: project.name,
|
|
path: project.path || null,
|
|
hasSavedPath,
|
|
fileExists: hasSavedPath ? existsSync(project.path) : false,
|
|
},
|
|
unsavedChanges: {
|
|
status: "not_exposed",
|
|
dirty: null,
|
|
reason:
|
|
"Premiere's documented scripting APIs do not expose the current dirty/unsaved flag.",
|
|
},
|
|
recovery: {
|
|
mode: "read_only_discovery",
|
|
candidateCount: candidates.length,
|
|
truncated: candidates.length >= MAX_CANDIDATES,
|
|
candidates,
|
|
automaticRestoreSupported: false,
|
|
guidance:
|
|
candidates.length > 0
|
|
? "Review modification times and file sizes, then use Premiere Pro's File > Open to inspect a chosen copy. Keep the current project open until you have confirmed the recovery."
|
|
: hasSavedPath
|
|
? "No adjacent .prproj recovery candidates were found. Check Premiere Pro's configured Auto Save location and Creative Cloud recovery UI manually."
|
|
: "Save the project to establish a project directory; then inspect Premiere's Auto Save location manually.",
|
|
},
|
|
},
|
|
};
|
|
},
|
|
},
|
|
|
|
get_bridge_telemetry: {
|
|
description:
|
|
"Inspect privacy-preserving aggregate bridge health: pending command/response counts, busy operations, queue age, and CEP heartbeat state without returning project or personal data.",
|
|
parameters: {},
|
|
handler: async () => ({
|
|
success: true,
|
|
data: collectBridgeTelemetry(bridgeOptions),
|
|
}),
|
|
},
|
|
};
|
|
}
|