|
| 1 | +import path from 'path' |
| 2 | +import { IEngine } from '@dcl/ecs/dist-cjs' |
| 3 | +import { validateBytes } from '@dcl/gltf-validator-ts' |
| 4 | + |
| 5 | +import { CliComponents } from '../../components' |
| 6 | +import { SceneProject } from '../../logic/project-validations' |
| 7 | + |
| 8 | +/** |
| 9 | + * Asset Migrator for code-to-composite command |
| 10 | + * |
| 11 | + * This module handles migrating asset files to the Creator Hub directory structure: |
| 12 | + * - Models: assets/scene/Models/{modelName}/*.{gltf,glb} (with dependencies) |
| 13 | + * - Images: assets/scene/Images/*.{png,jpg,jpeg,bmp} |
| 14 | + * - Audio: assets/scene/Audio/*.{mp3,wav,ogg} |
| 15 | + * - Video: assets/scene/Video/*.{mp4} |
| 16 | + * |
| 17 | + * Models are treated specially: each model gets its own folder with all dependencies |
| 18 | + * (textures, .bin files) copied into it, even if originally shared between models. |
| 19 | + */ |
| 20 | + |
| 21 | +export type AssetType = 'Models' | 'Images' | 'Audio' | 'Video' | 'Other' |
| 22 | + |
| 23 | +interface ModelDependencies { |
| 24 | + textures: string[] |
| 25 | + binaries: string[] |
| 26 | +} |
| 27 | + |
| 28 | +/** |
| 29 | + * Determines the asset type based on file extension |
| 30 | + */ |
| 31 | +function getAssetType(extension: string): AssetType { |
| 32 | + const ext = extension.toLowerCase().replace(/^\./, '') |
| 33 | + |
| 34 | + switch (ext) { |
| 35 | + case 'gltf': |
| 36 | + case 'glb': |
| 37 | + return 'Models' |
| 38 | + case 'png': |
| 39 | + case 'jpg': |
| 40 | + case 'jpeg': |
| 41 | + case 'bmp': |
| 42 | + return 'Images' |
| 43 | + case 'mp3': |
| 44 | + case 'wav': |
| 45 | + case 'ogg': |
| 46 | + return 'Audio' |
| 47 | + case 'mp4': |
| 48 | + return 'Video' |
| 49 | + default: |
| 50 | + return 'Other' |
| 51 | + } |
| 52 | +} |
| 53 | + |
| 54 | +/** |
| 55 | + * Checks if a string value looks like an asset path |
| 56 | + */ |
| 57 | +function looksLikeAssetPath(value: string): boolean { |
| 58 | + if (typeof value !== 'string' || value.length === 0) { |
| 59 | + return false |
| 60 | + } |
| 61 | + |
| 62 | + const ext = path.extname(value) |
| 63 | + if (!ext) { |
| 64 | + return false |
| 65 | + } |
| 66 | + |
| 67 | + return getAssetType(ext) !== 'Other' |
| 68 | +} |
| 69 | + |
| 70 | +/** |
| 71 | + * Extracts dependencies from a GLTF file using @dcl/gltf-validator-ts |
| 72 | + */ |
| 73 | +async function extractGltfDependencies( |
| 74 | + components: Pick<CliComponents, 'fs' | 'logger'>, |
| 75 | + gltfPath: string |
| 76 | +): Promise<ModelDependencies> { |
| 77 | + const { fs, logger } = components |
| 78 | + const dependencies: ModelDependencies = { |
| 79 | + textures: [], |
| 80 | + binaries: [] |
| 81 | + } |
| 82 | + |
| 83 | + try { |
| 84 | + const gltfBuffer = await fs.readFile(gltfPath) |
| 85 | + |
| 86 | + const result = await validateBytes(new Uint8Array(gltfBuffer), { |
| 87 | + externalResourceFunction: () => Promise.resolve(new Uint8Array()) |
| 88 | + }) |
| 89 | + |
| 90 | + if (result.info && result.info.resources) { |
| 91 | + for (const resource of result.info.resources) { |
| 92 | + if (resource.storage === 'external' && resource.uri) { |
| 93 | + const uri = resource.uri |
| 94 | + |
| 95 | + if (resource.pointer && resource.pointer.includes('image')) { |
| 96 | + dependencies.textures.push(uri) |
| 97 | + } else if (resource.pointer && resource.pointer.includes('buffer')) { |
| 98 | + dependencies.binaries.push(uri) |
| 99 | + } |
| 100 | + } |
| 101 | + } |
| 102 | + } |
| 103 | + } catch (error) { |
| 104 | + logger.error(` ⚠ Failed to extract dependencies from GLTF "${gltfPath}": ${error}`) |
| 105 | + } |
| 106 | + |
| 107 | + return dependencies |
| 108 | +} |
| 109 | + |
| 110 | +/** |
| 111 | + * Builds the destination path following Creator Hub conventions |
| 112 | + */ |
| 113 | +function getDestinationPath(sceneRoot: string, originalPath: string): string { |
| 114 | + const fileName = path.basename(originalPath) |
| 115 | + const extension = path.extname(originalPath) |
| 116 | + const assetType = getAssetType(extension) |
| 117 | + |
| 118 | + if (assetType === 'Models') { |
| 119 | + // Models get their own folder: assets/scene/Models/{modelName}/file.glb |
| 120 | + const modelName = path.basename(originalPath, extension) |
| 121 | + return path.join(sceneRoot, 'assets', 'scene', 'Models', modelName, fileName) |
| 122 | + } |
| 123 | + |
| 124 | + // Other assets go directly in type folder: assets/scene/Images/file.png |
| 125 | + return path.join(sceneRoot, 'assets', 'scene', assetType, fileName) |
| 126 | +} |
| 127 | + |
| 128 | +/** |
| 129 | + * Recursively finds all asset paths in component data |
| 130 | + */ |
| 131 | +function collectAssetPaths(data: any, assetPaths: Set<string>): void { |
| 132 | + if (data === null || data === undefined) { |
| 133 | + return |
| 134 | + } |
| 135 | + |
| 136 | + if (typeof data === 'string') { |
| 137 | + if (looksLikeAssetPath(data)) { |
| 138 | + assetPaths.add(data) |
| 139 | + } |
| 140 | + return |
| 141 | + } |
| 142 | + |
| 143 | + if (Array.isArray(data)) { |
| 144 | + for (const item of data) { |
| 145 | + collectAssetPaths(item, assetPaths) |
| 146 | + } |
| 147 | + return |
| 148 | + } |
| 149 | + |
| 150 | + if (typeof data === 'object') { |
| 151 | + for (const value of Object.values(data)) { |
| 152 | + collectAssetPaths(value, assetPaths) |
| 153 | + } |
| 154 | + } |
| 155 | +} |
| 156 | + |
| 157 | +/** |
| 158 | + * Recursively replaces asset paths in component data |
| 159 | + * Returns an object with the updated data and a flag indicating if changes were made |
| 160 | + */ |
| 161 | +function replaceAssetPaths( |
| 162 | + data: any, |
| 163 | + pathMapping: Map<string, string> |
| 164 | +): { data: any; hasChanges: boolean } { |
| 165 | + if (data === null || data === undefined) { |
| 166 | + return { data, hasChanges: false } |
| 167 | + } |
| 168 | + |
| 169 | + if (typeof data === 'string') { |
| 170 | + const newPath = pathMapping.get(data) |
| 171 | + if (newPath !== undefined) { |
| 172 | + return { data: newPath, hasChanges: true } |
| 173 | + } |
| 174 | + return { data, hasChanges: false } |
| 175 | + } |
| 176 | + |
| 177 | + if (Array.isArray(data)) { |
| 178 | + let hasChanges = false |
| 179 | + const result = data.map((item) => { |
| 180 | + const replaced = replaceAssetPaths(item, pathMapping) |
| 181 | + if (replaced.hasChanges) hasChanges = true |
| 182 | + return replaced.data |
| 183 | + }) |
| 184 | + return { data: result, hasChanges } |
| 185 | + } |
| 186 | + |
| 187 | + if (typeof data === 'object') { |
| 188 | + let hasChanges = false |
| 189 | + const result: any = {} |
| 190 | + for (const [key, value] of Object.entries(data)) { |
| 191 | + const replaced = replaceAssetPaths(value, pathMapping) |
| 192 | + if (replaced.hasChanges) hasChanges = true |
| 193 | + result[key] = replaced.data |
| 194 | + } |
| 195 | + return { data: result, hasChanges } |
| 196 | + } |
| 197 | + |
| 198 | + return { data, hasChanges: false } |
| 199 | +} |
| 200 | + |
| 201 | +/** |
| 202 | + * Main asset migration function |
| 203 | + * |
| 204 | + * Process: |
| 205 | + * 1. Scan all components to find asset references |
| 206 | + * 2. Copy asset files to new Creator Hub structure |
| 207 | + * 3. Update all components with new asset paths |
| 208 | + */ |
| 209 | +export async function migrateAssets( |
| 210 | + components: Pick<CliComponents, 'fs' | 'logger'>, |
| 211 | + project: SceneProject, |
| 212 | + engine: IEngine |
| 213 | +): Promise<number> { |
| 214 | + const { fs, logger } = components |
| 215 | + const sceneRoot = project.workingDirectory |
| 216 | + |
| 217 | + // Step 1: find all asset paths in the engine |
| 218 | + const assetPaths = new Set<string>() |
| 219 | + for (const component of engine.componentsIter()) { |
| 220 | + for (const [entity] of engine.getEntitiesWith(component)) { |
| 221 | + const componentData = component.get(entity) |
| 222 | + if (componentData) { |
| 223 | + collectAssetPaths(componentData, assetPaths) |
| 224 | + } |
| 225 | + } |
| 226 | + } |
| 227 | + |
| 228 | + if (assetPaths.size === 0) { |
| 229 | + logger.log('No assets found to migrate') |
| 230 | + return 0 |
| 231 | + } |
| 232 | + |
| 233 | + logger.log(`Found ${assetPaths.size} asset reference(s)`) |
| 234 | + |
| 235 | + // Step 2: copy files to new locations |
| 236 | + const pathMapping = new Map<string, string>() |
| 237 | + |
| 238 | + for (const originalPath of assetPaths) { |
| 239 | + const absoluteOriginalPath = path.isAbsolute(originalPath) ? originalPath : path.join(sceneRoot, originalPath) |
| 240 | + |
| 241 | + if (!(await fs.fileExists(absoluteOriginalPath))) { |
| 242 | + logger.warn(` ⚠ Asset not found: ${originalPath}`) |
| 243 | + continue |
| 244 | + } |
| 245 | + |
| 246 | + const assetType = getAssetType(path.extname(originalPath)) |
| 247 | + const newAbsolutePath = getDestinationPath(sceneRoot, originalPath) |
| 248 | + const newRelativePath = path.relative(sceneRoot, newAbsolutePath) |
| 249 | + |
| 250 | + await fs.mkdir(path.dirname(newAbsolutePath), { recursive: true }) |
| 251 | + await fs.copyFile(absoluteOriginalPath, newAbsolutePath) |
| 252 | + |
| 253 | + pathMapping.set(originalPath, newRelativePath) |
| 254 | + |
| 255 | + logger.log(` ✓ ${originalPath} → ${newRelativePath}`) |
| 256 | + |
| 257 | + // special handling for models: copy dependencies |
| 258 | + if (assetType === 'Models') { |
| 259 | + const dependencies = await extractGltfDependencies(components, absoluteOriginalPath) |
| 260 | + |
| 261 | + if (dependencies.textures.length === 0 && dependencies.binaries.length === 0) { |
| 262 | + continue |
| 263 | + } |
| 264 | + |
| 265 | + const allDependencies = [...dependencies.textures, ...dependencies.binaries] |
| 266 | + |
| 267 | + for (const depFileName of allDependencies) { |
| 268 | + const depOriginalPath = path.join(path.dirname(absoluteOriginalPath), depFileName) |
| 269 | + |
| 270 | + if (await fs.fileExists(depOriginalPath)) { |
| 271 | + const depNewPath = path.join(path.dirname(newAbsolutePath), depFileName) |
| 272 | + |
| 273 | + await fs.copyFile(depOriginalPath, depNewPath) |
| 274 | + |
| 275 | + const depOriginalRelative = path.join(path.dirname(originalPath), depFileName) |
| 276 | + const depNewRelative = path.relative(sceneRoot, depNewPath) |
| 277 | + pathMapping.set(depOriginalRelative, depNewRelative) |
| 278 | + |
| 279 | + logger.log(` ↳ ${depFileName}`) |
| 280 | + } |
| 281 | + } |
| 282 | + } |
| 283 | + } |
| 284 | + |
| 285 | + logger.log(`Migrated ${pathMapping.size} asset file(s)`) |
| 286 | + |
| 287 | + // Step 3: update components with new paths |
| 288 | + let updatedCount = 0 |
| 289 | + |
| 290 | + for (const component of engine.componentsIter()) { |
| 291 | + for (const [entity] of engine.getEntitiesWith(component)) { |
| 292 | + const componentData = component.get(entity) |
| 293 | + |
| 294 | + if (componentData) { |
| 295 | + const { data: updatedData, hasChanges } = replaceAssetPaths(componentData, pathMapping) |
| 296 | + |
| 297 | + if (hasChanges) { |
| 298 | + // only supporting LastWriteWinElementSetComponentDefinition (inspector does the same thing) |
| 299 | + if ('createOrReplace' in component) { |
| 300 | + component.createOrReplace(entity, updatedData) |
| 301 | + updatedCount++ |
| 302 | + } |
| 303 | + } |
| 304 | + } |
| 305 | + } |
| 306 | + } |
| 307 | + |
| 308 | + return updatedCount |
| 309 | +} |
0 commit comments