diff --git a/deno.jsonc b/deno.jsonc index eb9fdca..08250cf 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -38,6 +38,7 @@ "@std/path": "jsr:@std/path@^1.0.8", "@std/streams": "jsr:@std/streams@^1.0.9", "@std/testing": "jsr:@std/testing@^1.0.5", + "ueblueprint":"npm:ueblueprint@2.0.0", "zod": "npm:zod@3.23.8", "zod-to-json-schema": "npm:zod-to-json-schema@3.23.5", "ndjson": "https://deno.land/x/ndjson@1.1.0/mod.ts", diff --git a/deno.lock b/deno.lock index 1c036e7..19e2a56 100644 --- a/deno.lock +++ b/deno.lock @@ -10,6 +10,7 @@ "jsr:@david/dax@0.42.0": "0.42.0", "jsr:@david/path@0.2": "0.2.0", "jsr:@david/which@~0.4.1": "0.4.1", + "jsr:@luca/esbuild-deno-loader@0.11.1": "0.11.1", "jsr:@rebeccastevens/deepmerge@^7.1.5": "7.1.5", "jsr:@std/assert@0.221": "0.221.0", "jsr:@std/assert@^1.0.12": "1.0.12", @@ -18,13 +19,16 @@ "jsr:@std/assert@~1.0.6": "1.0.12", "jsr:@std/async@^1.0.12": "1.0.12", "jsr:@std/bytes@0.221": "0.221.0", + "jsr:@std/bytes@^1.0.2": "1.0.5", "jsr:@std/bytes@^1.0.5": "1.0.5", "jsr:@std/data-structures@^1.0.6": "1.0.6", "jsr:@std/dotenv@~0.225.3": "0.225.3", + "jsr:@std/encoding@^1.0.5": "1.0.9", "jsr:@std/encoding@~1.0.5": "1.0.9", "jsr:@std/fmt@1": "1.0.6", "jsr:@std/fmt@^1.0.6": "1.0.6", "jsr:@std/fmt@~1.0.2": "1.0.6", + "jsr:@std/fs@*": "1.0.16", "jsr:@std/fs@1": "1.0.16", "jsr:@std/fs@^1.0.1": "1.0.16", "jsr:@std/fs@^1.0.16": "1.0.16", @@ -33,14 +37,18 @@ "jsr:@std/io@0.221": "0.221.0", "jsr:@std/json@1": "1.0.1", "jsr:@std/jsonc@^1.0.1": "1.0.1", + "jsr:@std/path@*": "1.0.8", "jsr:@std/path@1": "1.0.8", "jsr:@std/path@^1.0.2": "1.0.8", + "jsr:@std/path@^1.0.6": "1.0.8", "jsr:@std/path@^1.0.8": "1.0.8", "jsr:@std/streams@0.221": "0.221.0", "jsr:@std/streams@^1.0.9": "1.0.9", "jsr:@std/testing@1.0.0": "1.0.0", "jsr:@std/testing@^1.0.5": "1.0.11", "jsr:@std/text@~1.0.7": "1.0.12", + "npm:@types/node@*": "22.12.0", + "npm:ueblueprint@2.0.0": "2.0.0", "npm:zod-to-json-schema@3.23.5": "3.23.5_zod@3.23.8", "npm:zod@3.23.8": "3.23.8" }, @@ -49,7 +57,7 @@ "integrity": "f71c921cce224c13d322e5cedba4f38e8f7354c7d855c9cb22729362a53f25aa", "dependencies": [ "jsr:@cliffy/internal", - "jsr:@std/encoding" + "jsr:@std/encoding@~1.0.5" ] }, "@cliffy/command@1.0.0-rc.7": { @@ -112,6 +120,14 @@ "@david/which@0.4.1": { "integrity": "896a682b111f92ab866cc70c5b4afab2f5899d2f9bde31ed00203b9c250f225e" }, + "@luca/esbuild-deno-loader@0.11.1": { + "integrity": "dc020d16d75b591f679f6b9288b10f38bdb4f24345edb2f5732affa1d9885267", + "dependencies": [ + "jsr:@std/bytes@^1.0.2", + "jsr:@std/encoding@^1.0.5", + "jsr:@std/path@^1.0.6" + ] + }, "@rebeccastevens/deepmerge@7.1.5": { "integrity": "edefc338e7ee643606002c0c532e3c652e86348ab577b9188b4c974afee7b421" }, @@ -210,6 +226,59 @@ } }, "npm": { + "@lit-labs/ssr-dom-shim@1.3.0": { + "integrity": "sha512-nQIWonJ6eFAvUUrSlwyHDm/aE8PBDu5kRpL0vHMg6K8fK3Diq1xdPjTnsJSwxABhaZ+5eBi1btQB5ShUTKo4nQ==" + }, + "@lit/reactive-element@1.6.3": { + "integrity": "sha512-QuTgnG52Poic7uM1AN5yJ09QMe0O28e10XzSvWDz02TJiiKee4stsiownEIadWm8nYzyDAyT+gKzUoZmiWQtsQ==", + "dependencies": [ + "@lit-labs/ssr-dom-shim" + ] + }, + "@types/node@22.12.0": { + "integrity": "sha512-Fll2FZ1riMjNmlmJOdAyY5pUbkftXslB5DgEzlIuNaiWhXd00FhWxVC/r4yV/4wBb9JfImTu+jiSvXTkJ7F/gA==", + "dependencies": [ + "undici-types" + ] + }, + "@types/trusted-types@2.0.7": { + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==" + }, + "lit-element@3.3.3": { + "integrity": "sha512-XbeRxmTHubXENkV4h8RIPyr8lXc+Ff28rkcQzw3G6up2xg5E8Zu1IgOWIwBLEQsu3cOVFqdYwiVi0hv0SlpqUA==", + "dependencies": [ + "@lit-labs/ssr-dom-shim", + "@lit/reactive-element", + "lit-html" + ] + }, + "lit-html@2.8.0": { + "integrity": "sha512-o9t+MQM3P4y7M7yNzqAyjp7z+mQGa4NS4CxiyLqFPyFWyc4O+nodLrkrxSaCTrla6M5YOLaT3RpbbqjszB5g3Q==", + "dependencies": [ + "@types/trusted-types" + ] + }, + "lit@2.8.0": { + "integrity": "sha512-4Sc3OFX9QHOJaHbmTMk28SYgVxLN3ePDjg7hofEft2zWlehFL3LiAuapWc4U/kYwMYJSh2hTCPZ6/LIC7ii0MA==", + "dependencies": [ + "@lit/reactive-element", + "lit-element", + "lit-html" + ] + }, + "parsernostrum@1.2.6": { + "integrity": "sha512-Ho+y3yoqVCHRtqsKVqltsv17MgjP8Np+VIC8nd2cyEAsko5hNiZZpA6mi0krvfv8XmrS+tOpra83d4UctJtmQg==" + }, + "ueblueprint@2.0.0": { + "integrity": "sha512-M9xIAAl7H6pqkkwJ5pyDLiOUkMq7ti75RiD6W20Sp3e+VW1KhQ3M9H9BXyw61qF8hKjD2Y+CYTd260hGFWjJpg==", + "dependencies": [ + "lit", + "parsernostrum" + ] + }, + "undici-types@6.20.0": { + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==" + }, "zod-to-json-schema@3.23.5_zod@3.23.8": { "integrity": "sha512-5wlSS0bXfF/BrL4jPAbz9da5hDlDptdEppYfe+x4eIJ7jioqKG9uUxOwPzqof09u/XeVdrgFu29lZi+8XNDJtA==", "dependencies": [ @@ -237,6 +306,10 @@ "https://deno.land/std@0.150.0/path/posix.ts": "c1f7afe274290ea0b51da07ee205653b2964bd74909a82deb07b69a6cc383aaa", "https://deno.land/std@0.150.0/path/separator.ts": "fe1816cb765a8068afb3e8f13ad272351c85cbc739af56dacfc7d93d710fe0f9", "https://deno.land/std@0.150.0/path/win32.ts": "bd7549042e37879c68ff2f8576a25950abbfca1d696d41d82c7bca0b7e6f452c", + "https://deno.land/x/denoflate@1.2.1/mod.ts": "f5628e44b80b3d80ed525afa2ba0f12408e3849db817d47a883b801f9ce69dd6", + "https://deno.land/x/denoflate@1.2.1/pkg/denoflate.js": "b9f9ad9457d3f12f28b1fb35c555f57443427f74decb403113d67364e4f2caf4", + "https://deno.land/x/denoflate@1.2.1/pkg/denoflate_bg.wasm.js": "d581956245407a2115a3d7e8d85a9641c032940a8e810acbd59ca86afd34d44d", + "https://deno.land/x/esbuild@v0.24.0/mod.js": "15b51f08198c373555700a695b6c6630a86f2c254938e81be7711eb6d4edc74e", "https://deno.land/x/globber@0.1.0/mod.ts": "971e58757909b2ef722e3dda1125aea8f5694601203ad835bdfc020f202bd5b8", "https://deno.land/x/globber@0.1.0/src/create_matcher.ts": "85be3a6d67376905521aed9da51db756d1ee747ebd0d52b88fc7b78a6831a393", "https://deno.land/x/globber@0.1.0/src/deps.ts": "179ba170213f7a35b7b794c409e7ca523da58644139c053721f93575dcbe616e", @@ -276,6 +349,7 @@ "jsr:@std/path@^1.0.8", "jsr:@std/streams@^1.0.9", "jsr:@std/testing@^1.0.5", + "npm:ueblueprint@2.0.0", "npm:zod-to-json-schema@3.23.5", "npm:zod@3.23.8" ] diff --git a/src/commands/pkg.ts b/src/commands/pkg.ts index 7568d2e..e329e91 100644 --- a/src/commands/pkg.ts +++ b/src/commands/pkg.ts @@ -58,7 +58,7 @@ export const pkg = new Command() .option('--profile ', 'Build profile', { default: 'client', required: true }) .action(async (options) => { const { platform, configuration, dryRun, profile, archiveDirectory, zip } = options as PkgOptions - const cfg = await Config.getInstance() + const cfg = Config.getInstance() const { engine: { path: enginePath }, project: { path: projectPath } } = cfg.mergeConfigCLIConfig({ cliOptions: options, }) diff --git a/src/commands/runpython.ts b/src/commands/runpython.ts new file mode 100644 index 0000000..1c2fae8 --- /dev/null +++ b/src/commands/runpython.ts @@ -0,0 +1,75 @@ +import { Command } from '@cliffy/command' +import * as path from '@std/path' +import { findProjectFile } from '../lib/utils.ts' +import { Config } from '../lib/config.ts' +import { logger } from '../lib/logger.ts' +import type { GlobalOptions } from '../lib/types.ts' + +import { exec } from '../lib/utils.ts' +import { Engine, getEditorPath } from '../lib/engine.ts' + +export type RunPythonOptions = typeof runpython extends + Command ? Options + : never + +export const runpython = new Command() + .description('Run Python script in Unreal Engine headless mode') + .option('-s, --script ', 'Path to Python script', { required: true }) + .option('--stdout', 'Redirect output to stdout', { default: true }) + .option('--nosplash', 'Skip splash screen', { default: true }) + .option('--nopause', "Don't pause after execution", { default: true }) + .option('--nosound', 'Disable sound', { default: true }) + .option('--args ', 'Additional arguments to pass to the script') + .action(async (options) => { + const config = Config.getInstance() + const { engine: { path: enginePath }, project: { path: projectPath } } = config.mergeConfigCLIConfig({ + cliOptions: options, + }) + + const projectFile = await findProjectFile(projectPath).catch(() => { + logger.error(`Could not find project file in ${projectPath}`) + Deno.exit(1) + }) + + if (!projectFile) { + logger.error(`Could not find project file in ${projectPath}`) + Deno.exit(1) + } + + // Resolve absolute path to script + const scriptPath = path.resolve(options.script) + + // Get the editor executable path based on platform + const currentPlatform = Engine.getCurrentPlatform() + const editorExePath = getEditorPath(enginePath, currentPlatform) + + // Build command arguments + const args = [ + projectFile, + '-run=pythonscript', + `-script="${scriptPath}"`, + '-unattended', + ] + + // Add optional flags + if (options.stdout) args.push('-stdout') + if (options.nosplash) args.push('-nosplash') + if (options.nopause) args.push('-nopause') + if (options.nosound) args.push('-nosound') + + // Add any additional arguments + if (options.args) { + args.push(options.args) + } + + logger.info(`Running Python script: ${scriptPath}`) + logger.debug(`Full command: ${editorExePath} ${args.join(' ')}`) + + try { + const result = await exec(editorExePath, args) + return result + } catch (error: unknown) { + logger.error(`Error running Python script: ${error instanceof Error ? error.message : String(error)}`) + Deno.exit(1) + } + }) diff --git a/src/commands/script.ts b/src/commands/script.ts index 79b5c9e..a2f3ceb 100644 --- a/src/commands/script.ts +++ b/src/commands/script.ts @@ -6,7 +6,7 @@ import { logger } from '../lib/logger.ts' import * as esbuild from 'https://deno.land/x/esbuild@v0.24.0/mod.js' -import { denoPlugins } from 'jsr:@luca/esbuild-deno-loader@0.11.0' +import { denoPlugins } from 'jsr:@luca/esbuild-deno-loader@0.11.1' import { Config } from '../lib/config.ts' export const script = new Command() @@ -44,7 +44,7 @@ export const script = new Command() const script = (await import(builtOutput)) as Script await script.main(context) - } catch (e) { + } catch (e: any) { logger.error(e) Deno.exit(1) } diff --git a/src/commands/uasset/extract-eventgraph.ts b/src/commands/uasset/extract-eventgraph.ts new file mode 100644 index 0000000..d147baa --- /dev/null +++ b/src/commands/uasset/extract-eventgraph.ts @@ -0,0 +1,124 @@ +import { Command } from '@cliffy/command' +import type { GlobalOptions } from '../../lib/types.ts' +import { generateBlueprintHtml } from '../../lib/utils.ts' +import { logger } from '../../lib/logger.ts' +import * as path from 'jsr:@std/path' +import * as fs from 'jsr:@std/fs' + +export type ExtractEventGraphOptions = typeof extractEventGraph extends + Command, [], GlobalOptions> ? Options + : never + +/** + * Find the EventGraph section in the exported uasset file as is + * @param fileContent The content of the .copy file as a string + * @returns The EventGraph section as a string, or null if not found + */ +function findEventGraph(fileContent: string): string | null { + // Find the "Begin Object Name="EventGraph"" section + const lines = fileContent.split('\n') + const eventGraphNodes: string[] = [] + + let insideEventGraph = false + let insideNodeSection = false + let nodeCount = 0 + let currentObject = '' + let insideBeginObjectBlock = false + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim() + + if (!line) continue + + // Check if we're starting a Begin Object Name="EventGraph" section + if (line.startsWith('Begin Object Name="EventGraph"')) { + insideEventGraph = true + nodeCount = 0 + insideNodeSection = false + continue + } + + // Skip the end of the EventGraph parent object + if (insideEventGraph && line === 'End Object' && !insideBeginObjectBlock && !insideNodeSection) { + insideEventGraph = false + continue + } + + // Check if we're inside an EventGraph and starting a node section with Begin Object Name="..." + if (insideEventGraph && line.startsWith('Begin Object Name="K2Node_')) { + nodeCount++ + currentObject = line + eventGraphNodes.push(currentObject) + insideBeginObjectBlock = true + continue + } + + // Process EventGraph child blocks that define nodes + if (insideEventGraph && line.startsWith('Begin Object Class="/Script/BlueprintGraph.K2Node_')) { + nodeCount++ + currentObject = line + eventGraphNodes.push(currentObject) + insideBeginObjectBlock = true + continue + } + + // If we're inside a node definition in the EventGraph, keep collecting its content + if ( + insideEventGraph && insideBeginObjectBlock && !line.startsWith('Begin Object') && !line.startsWith('End Object') + ) { + eventGraphNodes.push(' ' + line) + continue + } + + // Handle the end of a node's Begin/End Object block + if (insideEventGraph && insideBeginObjectBlock && line === 'End Object') { + eventGraphNodes.push(line) + insideBeginObjectBlock = false + currentObject = '' + continue + } + } + + // Return the extracted EventGraph nodes, or null if none found + return nodeCount > 0 ? eventGraphNodes.join('\n') : null +} + +async function extractEventGraphFromFile(filePath: string, render: boolean) { + const data = await Deno.readTextFile(filePath) + const eventGraph = findEventGraph(data) + if (eventGraph) { + if (render) { + const html = generateBlueprintHtml(eventGraph) + const basename = path.basename(filePath, path.extname(filePath)) + const basepath = path.dirname(filePath) + await Deno.writeTextFile(`${basepath}/${basename}.html`, html) + } else { + logger.info(eventGraph) + } + } +} + +export const extractEventGraph = new Command() + .description('extract event graph from exported uasset') + .option('-i, --input ', 'Path to the exported uasset file or directory containing exported uassets', { + required: true, + }) + .option('-r, --render', 'Save the output as rendered html', { default: false }) + .action(async (options) => { + try { + const isDirectory = await fs.exists(options.input, { isDirectory: true }) + if (isDirectory) { + const files = await Deno.readDir(options.input) + for await (const file of files) { + if (file.isFile) { + await extractEventGraphFromFile(path.join(options.input, file.name), options.render) + } + } + } else { + await extractEventGraphFromFile(options.input, options.render) + } + } catch (error: unknown) { + logger.error(`Error parsing blueprint: ${error instanceof Error ? error.message : String(error)}`) + Deno.exit(1) + } + }) diff --git a/src/commands/uasset/index.ts b/src/commands/uasset/index.ts new file mode 100644 index 0000000..5c5f0cb --- /dev/null +++ b/src/commands/uasset/index.ts @@ -0,0 +1,15 @@ +import { Command } from '@cliffy/command' +import type { GlobalOptions } from '../../lib/types.ts' + +import { parse } from './parse.ts' +import { renderBlueprint } from './render-blueprint.ts' +import { extractEventGraph } from './extract-eventgraph.ts' + +export const uasset = new Command() + .description('uasset') + .action(function () { + this.showHelp() + }) + .command('parse', parse) + .command('render-blueprint', renderBlueprint) + .command('extract-eventgraph', extractEventGraph) diff --git a/src/commands/uasset/parse.ts b/src/commands/uasset/parse.ts new file mode 100644 index 0000000..485c73d --- /dev/null +++ b/src/commands/uasset/parse.ts @@ -0,0 +1,242 @@ +import { Command } from '@cliffy/command' +import type { GlobalOptions } from '../../lib/types.ts' +import { logger } from '../../lib/logger.ts' + +export type ParseOptions = typeof parse extends + Command, [], GlobalOptions> ? Options + : never + +/** + * Normalizes path strings in Unreal Engine format by handling escaped slashes + * @param path The path string to normalize + * @returns Normalized path + */ +function normalizePath(path: string): string { + if (!path) return path + + // If the path is enclosed in quotes, remove them + if (path.startsWith('"') && path.endsWith('"')) { + path = path.substring(1, path.length - 1) + } + + // Replace escaped backslashes with forward slashes + return path.replace(/\\\\/g, '/') +} + +/** + * Handles string values by removing quotes and normalizing paths + * @param value String value to process + * @returns Processed string + */ +function processStringValue(value: string): string { + if (!value) return value + + // If it's a quoted string, unquote it + if (value.startsWith('"') && value.endsWith('"')) { + value = value.substring(1, value.length - 1) + } + + // If it looks like a path (contains /Script/ or has forward slashes) + if (value.includes('/Script/') || value.includes('/') || value.includes('\\')) { + return normalizePath(value) + } + + return value +} + +/** + * Parses an Unreal Engine Blueprint .copy file into a JavaScript object + * @param fileContent The content of the .copy file as a string + * @returns The parsed blueprint object + */ +function parseBlueprint(fileContent: string) { + const lines = fileContent.split('\n') + const stack: any[] = [] + let currentObject: any = null + const rootObjects: any[] = [] + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim() + if (!line) continue + + if (line.startsWith('Begin Object')) { + // Extract Class and Name using regex + const classMatch = line.match(/Class=([^\s"]+)/) + const nameMatch = line.match(/Name="([^"]+)"/) + const exportPathMatch = line.match(/ExportPath="([^"]+)"/) + + const newObject: any = { + type: 'object', + properties: {}, + children: [], + } + + if (classMatch) newObject.class = normalizePath(classMatch[1]) + if (nameMatch) newObject.name = nameMatch[1] + if (exportPathMatch) newObject.exportPath = normalizePath(exportPathMatch[1]) + + if (currentObject) { + stack.push(currentObject) + currentObject.children.push(newObject) + } else { + rootObjects.push(newObject) + } + currentObject = newObject + } else if (line.startsWith('End Object')) { + if (stack.length > 0) { + currentObject = stack.pop() + } else { + currentObject = null + } + } else if (currentObject && line.includes('=')) { + // Handle property lines + const equalPos = line.indexOf('=') + const key = line.substring(0, equalPos).trim() + const value = line.substring(equalPos + 1).trim() + + // Special handling for complex properties like arrays, parenthesized values, etc. + if (value.startsWith('(') && !value.endsWith(')')) { + // This is a multi-line property + let complexValue = value + while (!complexValue.endsWith(')') && i < lines.length - 1) { + i++ + complexValue += ' ' + lines[i].trim() + } + currentObject.properties[key] = parseComplexProperty(complexValue) + } else if (value.startsWith('(')) { + // Parse complex property with parentheses into an object + const parsedValue = parseComplexProperty(value) + currentObject.properties[key] = parsedValue + } else if (key.startsWith('CustomProperties')) { + // Handle CustomProperties as a special case + if (!currentObject.customProperties) { + currentObject.customProperties = [] + } + currentObject.customProperties.push(processStringValue(value)) + } else if (key.includes('(')) { + // Handle array properties like Nodes(0) + const arrayMatch = key.match(/([^\(]+)\((\d+)\)/) + if (arrayMatch) { + const arrayName = arrayMatch[1] + const arrayIndex = parseInt(arrayMatch[2]) + + if (!currentObject.properties[arrayName]) { + currentObject.properties[arrayName] = [] + } + + // Ensure array has enough elements + while (currentObject.properties[arrayName].length <= arrayIndex) { + currentObject.properties[arrayName].push(null) + } + + // Process string values in array elements + const processedValue = value.startsWith('"') || + value.includes('/') || + value.includes('\\') + ? processStringValue(value) + : value + + currentObject.properties[arrayName][arrayIndex] = processedValue + } else { + currentObject.properties[key] = processStringValue(value) + } + } else { + currentObject.properties[key] = processStringValue(value) + } + } + } + + return rootObjects.length === 1 ? rootObjects[0] : rootObjects +} + +/** + * Parse complex properties with parentheses, typically representing structs + * @param value The complex property value string + * @returns Parsed object representation + */ +function parseComplexProperty(value: string): any { + if (!value.startsWith('(') || !value.endsWith(')')) { + return processStringValue(value) + } + + // Remove outer parentheses + const content = value.substring(1, value.length - 1) + + // Split by commas, handling nested parentheses + const parts: string[] = [] + let currentPart = '' + let depth = 0 + let inQuotes = false + + for (let i = 0; i < content.length; i++) { + const char = content[i] + + // Handle quotes to avoid splitting inside quoted strings + if (char === '"' && (i === 0 || content[i - 1] !== '\\')) { + inQuotes = !inQuotes + } + + if (!inQuotes) { + if (char === '(') depth++ + else if (char === ')') depth-- + else if (char === ',' && depth === 0) { + parts.push(currentPart.trim()) + currentPart = '' + continue + } + } + + currentPart += char + } + + if (currentPart) { + parts.push(currentPart.trim()) + } + + // Convert parts to key-value pairs + const result: Record = {} + + for (const part of parts) { + const equalPos = part.indexOf('=') + if (equalPos !== -1) { + const key = part.substring(0, equalPos).trim() + const val = part.substring(equalPos + 1).trim() + + // Recursively parse nested complex properties + if (val.startsWith('(') && val.endsWith(')')) { + result[key] = parseComplexProperty(val) + } else { + result[key] = processStringValue(val) + } + } else { + // Handle valueless properties or booleans + result[part.trim()] = true + } + } + + return result +} + +export const parse = new Command() + .description('parse exported uasset') + .option('-i, --input ', 'Path to the exported uasset file', { required: true }) + .option('-o, --output ', 'Output file path for the JSON result', { required: false }) + .option('--pretty', 'Pretty print the JSON output', { default: true }) + .action((options) => { + try { + const data = Deno.readTextFileSync(options.input) + const parsedObject = parseBlueprint(data) + + const jsonOutput = options.pretty ? JSON.stringify(parsedObject, null, 2) : JSON.stringify(parsedObject) + + if (options.output) { + Deno.writeTextFileSync(options.output, jsonOutput) + logger.info(`Parsed blueprint written to ${options.output}`) + } else { + console.log(jsonOutput) + } + } catch (error: unknown) { + logger.error(`Error parsing blueprint: ${error instanceof Error ? error.message : String(error)}`) + Deno.exit(1) + } + }) diff --git a/src/commands/uasset/render-blueprint.ts b/src/commands/uasset/render-blueprint.ts new file mode 100644 index 0000000..6e2d78e --- /dev/null +++ b/src/commands/uasset/render-blueprint.ts @@ -0,0 +1,41 @@ +import { Command } from '@cliffy/command' +import type { GlobalOptions } from '../../lib/types.ts' +import { generateBlueprintHtml } from '../../lib/utils.ts' + +const url = Deno.env.get('RENDER_URL') || 'http://localhost:8787' + +export const renderBlueprint = new Command() + .description('Render your blueprint') + .option( + '-b, --blueprint ', + 'Path to the input file', + { required: true }, + ) + .option('-o, --output ', 'Path to the output in local', { + default: 'index.html', + }) + .option('-l, --local', 'Local export', { default: true }) + .action( + async (options) => { + const data = Deno.readFileSync(options.blueprint) + const decoder = new TextDecoder('utf-8') + const blueprint = decoder.decode(data) + if (!options.local) { + // Send blueprint to the server + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ blueprint }), + }) + const blob = await res.blob() + + Deno.writeFileSync('result.png', await blob.bytes()) + } else { + const encoder = new TextEncoder() + const html = generateBlueprintHtml(blueprint) + Deno.writeFileSync(options.output, encoder.encode(html)) + } + }, + ) diff --git a/src/index.ts b/src/index.ts index 12efa62..dccffd8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,8 @@ import { workflow } from './commands/workflow/index.ts' import { clean } from './commands/clean.ts' import { cmd } from './cmd.ts' import { script } from './commands/script.ts' +import { runpython } from './commands/runpython.ts' +import { uasset } from './commands/uasset/index.ts' await cmd .name('runreal') @@ -31,4 +33,6 @@ await cmd .command('buildgraph', buildgraph) .command('workflow', workflow) .command('script', script) + .command('runpython', runpython) + .command('uasset', uasset) .parse(Deno.args) diff --git a/src/lib/engine.ts b/src/lib/engine.ts index d93f100..5ccb532 100644 --- a/src/lib/engine.ts +++ b/src/lib/engine.ts @@ -416,3 +416,37 @@ export function createEngine(enginePath: string): Engine { throw new Error(`Unsupported platform: ${Deno.build.os}`) } } + +/** + * Get the platform-specific path to the Unreal Editor executable + */ +export function getEditorPath(enginePath: string, platform: EnginePlatform): string { + switch (platform) { + case EnginePlatform.Windows: + return path.join( + enginePath, + 'Engine', + 'Binaries', + 'Win64', + 'UnrealEditor.exe', + ) + case EnginePlatform.Mac: + return path.join( + enginePath, + 'Engine', + 'Binaries', + 'Mac', + 'UnrealEditor', + ) + case EnginePlatform.Linux: + return path.join( + enginePath, + 'Engine', + 'Binaries', + 'Linux', + 'UnrealEditor', + ) + default: + throw new Error(`Unsupported platform: ${platform}`) + } +} diff --git a/src/lib/logger.ts b/src/lib/logger.ts index 0a0bb18..9aeb5ed 100644 --- a/src/lib/logger.ts +++ b/src/lib/logger.ts @@ -49,10 +49,10 @@ class Logger { this.logLevel = level } - private formatMessage(level: LogLevel, message: any) { + private formatMessage(level: LogLevel, message: string) { const timestamp = new Date().toISOString() const messageStr = typeof message === 'object' ? `\n${Deno.inspect(message, { colors: true })}` : message - let levelStr + let levelStr: string switch (level) { case LogLevel.INFO: levelStr = fmt.bold(fmt.green(level)) @@ -93,21 +93,21 @@ class Logger { return level >= this.logLevel } - info(message: any) { + info(message: string) { if (!this.shouldLog(LogLevel.INFO)) return const formatted = this.formatMessage(LogLevel.INFO, message) console.log(formatted) this.writeToFile(formatted) } - error(message: any) { + error(message: string) { if (!this.shouldLog(LogLevel.ERROR)) return const formatted = this.formatMessage(LogLevel.ERROR, message) console.error(formatted) this.writeToFile(formatted) } - debug(message: any) { + debug(message: string) { if (!this.shouldLog(LogLevel.DEBUG)) return const formatted = this.formatMessage(LogLevel.DEBUG, message) console.log(formatted) diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 0b589c0..e2acaff 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -374,3 +374,37 @@ export function formatIsoTimestamp( ): string { return new Intl.DateTimeFormat(locale, options).format(new Date(ts)) } + +export const generateBlueprintHtml = (blueprint: string) => { + const html = ` + + + + + UE Blueprint + + + + + + + + + + + + + +` + return html +}