diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..25feae6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI + +on: + push: + branches: [master, "release/**"] + pull_request: + +jobs: + build: + runs-on: macos-15 + steps: + - uses: actions/checkout@v5 + + - name: Install XcodeGen + run: brew install xcodegen + + - name: Check release hygiene + run: Scripts/check-release-hygiene.sh + + - name: Generate project + run: xcodegen generate --spec ModernAppExtension/project.yml + + - name: Build modern extension + run: | + xcodebuild \ + -project ModernAppExtension/GLTFQuickLook.xcodeproj \ + -scheme GLTFQuickLook \ + -configuration Release \ + -derivedDataPath "$RUNNER_TEMP/GLTFQuickLookDerivedData" \ + ARCHS=arm64 \ + ONLY_ACTIVE_ARCH=NO \ + CODE_SIGN_IDENTITY=- \ + build + + - name: Verify embedded extensions + run: | + APP="$RUNNER_TEMP/GLTFQuickLookDerivedData/Build/Products/Release/GLTFQuickLook.app" + test -d "$APP/Contents/PlugIns/GLTFPreview.appex" + test -d "$APP/Contents/PlugIns/GLTFThumbnail.appex" + codesign --verify --deep --strict "$APP" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..df71214 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,68 @@ +name: Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + version: + description: Artifact version, for example 1.1.0-beta.1 + required: true + default: 1.1.0-beta.1 + +permissions: + contents: write + +jobs: + package: + runs-on: macos-15 + steps: + - uses: actions/checkout@v5 + + - name: Install XcodeGen + run: brew install xcodegen + + - name: Select version + id: version + env: + EVENT_NAME: ${{ github.event_name }} + TAG_NAME: ${{ github.ref_name }} + MANUAL_VERSION: ${{ inputs.version }} + run: | + if [[ "$EVENT_NAME" == "push" ]]; then + echo "value=${TAG_NAME#v}" >> "$GITHUB_OUTPUT" + else + echo "value=$MANUAL_VERSION" >> "$GITHUB_OUTPUT" + fi + + - name: Build release archive + env: + VERSION: ${{ steps.version.outputs.value }} + DERIVED_DATA: ${{ runner.temp }}/GLTFQuickLookRelease + run: Scripts/package-release.sh + + - name: Upload workflow artifact + uses: actions/upload-artifact@v4 + with: + name: GLTFQuickLook-${{ steps.version.outputs.value }}-macos-arm64 + path: | + dist/*.zip + dist/*.sha256 + + - name: Publish GitHub release + if: github.event_name == 'push' + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.version.outputs.value }} + run: | + prerelease=() + if [[ "$VERSION" == *-* ]]; then + prerelease=(--prerelease) + fi + gh release create "v$VERSION" \ + dist/*.zip \ + dist/*.sha256 \ + --title "GLTFQuickLook $VERSION" \ + --generate-notes \ + "${prerelease[@]}" diff --git a/.gitignore b/.gitignore index f04ed77..35226bd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # Xcode # build/ +dist/ +.DS_Store +**/.DS_Store *.pbxuser !default.pbxuser *.mode1v3 @@ -13,9 +16,11 @@ xcuserdata *.xccheckout *.moved-aside DerivedData +ModernAppExtension/GLTFQuickLook.xcodeproj/ *.hmap *.ipa *.xcuserstate +*.xcresult # CocoaPods # @@ -35,4 +40,3 @@ Carthage/Build Framework/ *~ *.swp - diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ba32883 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,30 @@ +# Changelog + +All notable changes to GLTFQuickLook are documented here. + +## 1.1.0-beta.1 - 2026-09-02 + +### Added + +- Autonomous extended-attribute caches for sidecar-based glTF documents. +- Continuous, targeted preparation of Downloads and user-selected folders. +- Unreal `.props.txt` material and texture reconstruction. +- Support for dense Sketchfab documents and oversized animated uModel exports. +- Preservation of the first 10 animations in compacted character caches. +- Skin-weight normalization and optional pure-red vertex-color cleanup. + +### Fixed + +- Finder thumbnail generation for prepared sidecar documents. +- Quick Look memory growth and repeated full-folder rescans. +- Scene framing and loading stability for large or dense models. +- Light and Dark Mode previews now use the native Quick Look background. + +### Distribution + +- Requires macOS 12 or later. +- The downloadable beta targets Apple Silicon and is ad hoc signed, not notarized. + +## 1.0.1 + +- Added the first modern Quick Look App Extension implementation. diff --git a/LICENSE b/LICENSE index eaf9bc6..e10ff32 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,7 @@ MIT License Copyright (c) 2017 magicien +Copyright (c) 2026 Hectorlizard contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/ModernAppExtension/App/AppDelegate.swift b/ModernAppExtension/App/AppDelegate.swift index 5dd36c6..9f640db 100644 --- a/ModernAppExtension/App/AppDelegate.swift +++ b/ModernAppExtension/App/AppDelegate.swift @@ -1,10 +1,289 @@ import Cocoa +import OSLog -@main class AppDelegate: NSObject, NSApplicationDelegate { + private let logger = Logger(subsystem: "com.hectorlizard.GLTFQuickLook", category: "HostApp") + private let watchedFoldersKey = "WatchedFolderPaths" + private let automaticPreparationDelay: TimeInterval = 1.5 + private var statusItem: NSStatusItem? + private var statusLabelItem = NSMenuItem(title: "Prêt", action: nil, keyEquivalent: "") + private lazy var unrealMaterialToggleItem = NSMenuItem( + title: "Enrichir avec matériaux Unreal", + action: #selector(toggleUnrealMaterialEnrichment(_:)), + keyEquivalent: "" + ) + private lazy var redVertexColorToggleItem = NSMenuItem( + title: "Ignorer les vertex colors rouge pur", + action: #selector(toggleUniformRedVertexColorIgnoring(_:)), + keyEquivalent: "" + ) + private var folderMonitor: FolderEventMonitor? + private var automaticPreparationWorkItem: DispatchWorkItem? + private var pendingAutomaticTargetPaths: Set = [] + private var isPreparing = false + func applicationDidFinishLaunching(_ aNotification: Notification) { - // Just print and exit gracefully, we just need Finder to register UTIs and Extensions. - print("GLTFQuickLook App registered. You can close this app.") + configureStatusItem() + addDefaultWatchedFoldersIfNeeded() + + let savedFolderURLs = watchedFolderURLs() + print("GLTFQuickLook host launched with \(savedFolderURLs.count) watched folders") + logger.notice("Host app launched with \(savedFolderURLs.count, privacy: .public) watched folders") + if savedFolderURLs.isEmpty { + logger.notice("No watched folders yet, opening folder picker") + presentFolderPicker() + return + } + + refreshFolderMonitoring() + statusLabelItem.title = "Surveillance active" + } + + func application(_ application: NSApplication, open urls: [URL]) { + let folderURLs = urls.map { $0.hasDirectoryPath ? $0 : $0.deletingLastPathComponent() } + addWatchedFolders(folderURLs) + refreshFolderMonitoring() + prepare(folderURLs: folderURLs, notifyUser: true) + } + + @objc private func chooseFolders(_ sender: Any?) { + presentFolderPicker() + } + + @objc private func rescanFolders(_ sender: Any?) { + prepare(folderURLs: watchedFolderURLs(), notifyUser: true) + } + + @objc private func toggleUnrealMaterialEnrichment(_ sender: Any?) { + let newValue = !PreparedDocumentSettings.isUnrealMaterialEnrichmentEnabled() + PreparedDocumentSettings.setUnrealMaterialEnrichmentEnabled(newValue) + updateToggleStates() + prepare(folderURLs: watchedFolderURLs(), notifyUser: true) + } + + @objc private func toggleUniformRedVertexColorIgnoring(_ sender: Any?) { + let newValue = !PreparedDocumentSettings.isUniformRedVertexColorIgnoringEnabled() + PreparedDocumentSettings.setUniformRedVertexColorIgnoringEnabled(newValue) + updateToggleStates() + prepare(folderURLs: watchedFolderURLs(), notifyUser: true) + } + + @objc private func quitApp(_ sender: Any?) { NSApplication.shared.terminate(nil) } + + private func configureStatusItem() { + let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + item.button?.title = "GLTFQL" + + let menu = NSMenu() + menu.addItem(withTitle: "Ajouter des dossiers…", action: #selector(chooseFolders(_:)), keyEquivalent: "") + menu.addItem(withTitle: "Réanalyser les dossiers", action: #selector(rescanFolders(_:)), keyEquivalent: "") + menu.addItem(unrealMaterialToggleItem) + menu.addItem(redVertexColorToggleItem) + menu.addItem(.separator()) + statusLabelItem.isEnabled = false + menu.addItem(statusLabelItem) + menu.addItem(.separator()) + menu.addItem(withTitle: "Quitter", action: #selector(quitApp(_:)), keyEquivalent: "q") + menu.items.forEach { $0.target = self } + updateToggleStates() + + item.menu = menu + statusItem = item + } + + private func updateToggleStates() { + unrealMaterialToggleItem.state = PreparedDocumentSettings.isUnrealMaterialEnrichmentEnabled() ? .on : .off + redVertexColorToggleItem.state = PreparedDocumentSettings.isUniformRedVertexColorIgnoringEnabled() ? .on : .off + } + + private func presentFolderPicker() { + let panel = NSOpenPanel() + panel.title = "Choisir les dossiers glTF a preparer" + panel.message = "Selectionne les dossiers contenant tes exports .gltf pour generer automatiquement le cache Quick Look." + panel.prompt = "Preparer" + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = true + + if panel.runModal() == .OK { + addWatchedFolders(panel.urls) + refreshFolderMonitoring() + prepare(folderURLs: panel.urls, notifyUser: true) + } + } + + private func watchedFolderURLs() -> [URL] { + let paths = UserDefaults.standard.stringArray(forKey: watchedFoldersKey) ?? [] + return paths.map { URL(fileURLWithPath: $0, isDirectory: true) } + } + + private func addDefaultWatchedFoldersIfNeeded() { + let defaultPaths = defaultWatchedFolderURLs() + .map { $0.standardizedFileURL.path } + guard !defaultPaths.isEmpty else { + return + } + + let existingPaths = Set(UserDefaults.standard.stringArray(forKey: watchedFoldersKey) ?? []) + let missingPaths = defaultPaths.filter { !existingPaths.contains($0) } + guard !missingPaths.isEmpty else { + return + } + + let mergedPaths = Array(existingPaths.union(missingPaths)).sorted() + UserDefaults.standard.set(mergedPaths, forKey: watchedFoldersKey) + let joinedPaths = missingPaths.joined(separator: ", ") + logger.notice("Added default watched folders: \(joinedPaths, privacy: .public)") + } + + private func defaultWatchedFolderURLs() -> [URL] { + let downloadsURL = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Downloads", isDirectory: true) + + guard FileManager.default.fileExists(atPath: downloadsURL.path) else { + return [] + } + + return [downloadsURL] + } + + private func addWatchedFolders(_ urls: [URL]) { + let existingPaths = Set(UserDefaults.standard.stringArray(forKey: watchedFoldersKey) ?? []) + let newPaths = urls + .map { $0.standardizedFileURL.path } + .filter { FileManager.default.fileExists(atPath: $0) } + + let mergedPaths = Array(existingPaths.union(newPaths)).sorted() + UserDefaults.standard.set(mergedPaths, forKey: watchedFoldersKey) + } + + private func refreshFolderMonitoring() { + automaticPreparationWorkItem?.cancel() + automaticPreparationWorkItem = nil + pendingAutomaticTargetPaths.removeAll() + + folderMonitor?.stop() + folderMonitor = nil + + let folderURLs = watchedFolderURLs() + guard !folderURLs.isEmpty else { + statusLabelItem.title = "Aucun dossier configuré" + return + } + + let monitor = FolderEventMonitor(rootURLs: folderURLs) { [weak self] changedTargets in + DispatchQueue.main.async { + self?.scheduleAutomaticPreparation(for: changedTargets) + } + } + monitor.start() + folderMonitor = monitor + statusLabelItem.title = "Surveillance active" + logger.notice("Watching \(folderURLs.count, privacy: .public) folders for automatic preparation") + } + + private func scheduleAutomaticPreparation(for urls: [URL]) { + let targetPaths = urls.map { $0.standardizedFileURL.path } + guard !targetPaths.isEmpty else { + return + } + + pendingAutomaticTargetPaths.formUnion(targetPaths) + automaticPreparationWorkItem?.cancel() + + let workItem = DispatchWorkItem { [weak self] in + self?.runScheduledAutomaticPreparationIfPossible() + } + automaticPreparationWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + automaticPreparationDelay, execute: workItem) + + if !isPreparing { + statusLabelItem.title = "Changements détectés…" + } + } + + private func runScheduledAutomaticPreparationIfPossible() { + guard !pendingAutomaticTargetPaths.isEmpty else { + return + } + + guard !isPreparing else { + let workItem = DispatchWorkItem { [weak self] in + self?.runScheduledAutomaticPreparationIfPossible() + } + automaticPreparationWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + automaticPreparationDelay, execute: workItem) + return + } + + let folderURLs = pendingAutomaticTargetPaths + .sorted() + .map { path -> URL in + var isDirectory: ObjCBool = false + FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) + return URL(fileURLWithPath: path, isDirectory: isDirectory.boolValue) + } + pendingAutomaticTargetPaths.removeAll() + prepare(folderURLs: folderURLs, notifyUser: false) + } + + private func prepare(folderURLs: [URL], notifyUser: Bool) { + guard !isPreparing else { + logger.notice("Skipping prepare request because another preparation is already running") + return + } + guard !folderURLs.isEmpty else { + statusLabelItem.title = "Aucun dossier configuré" + logger.notice("Skipping prepare request because no folder is configured") + if notifyUser { + presentFolderPicker() + } + return + } + + isPreparing = true + statusLabelItem.title = "Préparation en cours…" + print("Preparing \(folderURLs.count) folders") + logger.notice("Preparing \(folderURLs.count, privacy: .public) folders") + + Task.detached(priority: .userInitiated) { [logger] in + let summary = PreparedDocumentPreparer.prepare(urls: folderURLs, logger: logger) + await MainActor.run { + self.isPreparing = false + self.statusLabelItem.title = summary.failures.isEmpty + ? "Surveillance active" + : "Échecs: \(summary.failures.count)" + self.logger.notice( + """ + Preparation finished folders=\(summary.foldersScanned, privacy: .public) seen=\(summary.documentsSeen, privacy: .public) prepared=\(summary.documentsPrepared, privacy: .public) skipped=\(summary.documentsSkipped, privacy: .public) failures=\(summary.failures.count, privacy: .public) + """ + ) + if notifyUser { + self.presentSummary(summary) + } + self.runScheduledAutomaticPreparationIfPossible() + } + } + } + + private func presentSummary(_ summary: PreparationRunSummary) { + let alert = NSAlert() + alert.messageText = "Préparation Quick Look terminée" + alert.informativeText = """ + Dossiers scannés : \(summary.foldersScanned) + Fichiers .gltf vus : \(summary.documentsSeen) + Nouveaux caches : \(summary.documentsPrepared) + Déjà à jour : \(summary.documentsSkipped) + Échecs : \(summary.failures.count) + Matériaux Unreal : \(PreparedDocumentSettings.isUnrealMaterialEnrichmentEnabled() ? "activés" : "désactivés") + Vertex colors rouge pur : \(PreparedDocumentSettings.isUniformRedVertexColorIgnoringEnabled() ? "ignorés" : "conservés") + """ + alert.alertStyle = summary.failures.isEmpty ? .informational : .warning + if let firstFailure = summary.failures.first { + alert.informativeText += "\n\nPremier échec :\n\(firstFailure)" + } + alert.runModal() + } } diff --git a/ModernAppExtension/App/FolderEventMonitor.swift b/ModernAppExtension/App/FolderEventMonitor.swift new file mode 100644 index 0000000..2a07c42 --- /dev/null +++ b/ModernAppExtension/App/FolderEventMonitor.swift @@ -0,0 +1,146 @@ +import CoreServices +import Foundation + +final class FolderEventMonitor { + typealias ChangeHandler = ([URL]) -> Void + + private static let relevantEventFlags = FSEventStreamEventFlags( + kFSEventStreamEventFlagItemCreated | + kFSEventStreamEventFlagItemRemoved | + kFSEventStreamEventFlagItemRenamed | + kFSEventStreamEventFlagItemModified | + kFSEventStreamEventFlagRootChanged + ) + + private static let fileEventFlag = FSEventStreamEventFlags(kFSEventStreamEventFlagItemIsFile) + private static let rootChangedFlag = FSEventStreamEventFlags(kFSEventStreamEventFlagRootChanged) + private static let streamFlags = FSEventStreamCreateFlags( + kFSEventStreamCreateFlagUseCFTypes | + kFSEventStreamCreateFlagFileEvents | + kFSEventStreamCreateFlagNoDefer + ) + private static let watchedExtensions: Set = [ + "gltf", "glb", "bin", + "png", "jpg", "jpeg", "webp", "bmp", "gif", "tga", + "ktx", "ktx2", "basis", "dds", "txt" + ] + + private let rootURLs: [URL] + private let rootPaths: [String] + private let onChange: ChangeHandler + private var stream: FSEventStreamRef? + + init(rootURLs: [URL], onChange: @escaping ChangeHandler) { + self.rootURLs = rootURLs.map(\.standardizedFileURL) + self.rootPaths = self.rootURLs.map(\.path) + self.onChange = onChange + } + + deinit { + stop() + } + + func start() { + guard stream == nil, !rootPaths.isEmpty else { + return + } + + var context = FSEventStreamContext( + version: 0, + info: UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()), + retain: nil, + release: nil, + copyDescription: nil + ) + + guard let newStream = FSEventStreamCreate( + kCFAllocatorDefault, + FolderEventMonitor.eventCallback, + &context, + rootPaths as CFArray, + FSEventStreamEventId(kFSEventStreamEventIdSinceNow), + 0.5, + FolderEventMonitor.streamFlags + ) else { + return + } + + FSEventStreamSetDispatchQueue(newStream, DispatchQueue.main) + FSEventStreamStart(newStream) + stream = newStream + } + + func stop() { + guard let stream else { + return + } + + FSEventStreamStop(stream) + FSEventStreamInvalidate(stream) + FSEventStreamRelease(stream) + self.stream = nil + } + + private func handleEvents(paths: [String], flags: [FSEventStreamEventFlags]) { + var changedTargets: Set = [] + + for (path, flag) in zip(paths, flags) { + guard isRelevantEvent(path: path, flag: flag) else { + continue + } + + guard rootPaths.contains(where: { path == $0 || path.hasPrefix($0 + "/") }) else { + continue + } + + let fileURL = URL(fileURLWithPath: path).standardizedFileURL + let pathExtension = fileURL.pathExtension.lowercased() + if pathExtension == "gltf" { + changedTargets.insert(fileURL.path) + } else { + changedTargets.insert(fileURL.deletingLastPathComponent().path) + } + } + + guard !changedTargets.isEmpty else { + return + } + + let changedURLs = changedTargets + .sorted() + .map { path -> URL in + var isDirectory: ObjCBool = false + FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) + return URL(fileURLWithPath: path, isDirectory: isDirectory.boolValue) + } + onChange(changedURLs) + } + + private func isRelevantEvent(path: String, flag: FSEventStreamEventFlags) -> Bool { + guard (flag & Self.relevantEventFlags) != 0 else { + return false + } + + if (flag & Self.rootChangedFlag) != 0 { + return true + } + + guard (flag & Self.fileEventFlag) != 0 else { + return false + } + + let pathExtension = URL(fileURLWithPath: path).pathExtension.lowercased() + return Self.watchedExtensions.contains(pathExtension) + } + + private static let eventCallback: FSEventStreamCallback = { _, info, eventCount, eventPathsPointer, eventFlagsPointer, _ in + guard let info else { + return + } + + let monitor = Unmanaged.fromOpaque(info).takeUnretainedValue() + let eventPaths = Unmanaged.fromOpaque(eventPathsPointer).takeUnretainedValue() as? [String] ?? [] + let eventFlags = Array(UnsafeBufferPointer(start: eventFlagsPointer, count: Int(eventCount))) + monitor.handleEvents(paths: eventPaths, flags: eventFlags) + } +} diff --git a/ModernAppExtension/App/Info.plist b/ModernAppExtension/App/Info.plist index 6c192aa..63b7c36 100644 --- a/ModernAppExtension/App/Info.plist +++ b/ModernAppExtension/App/Info.plist @@ -15,9 +15,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.0 + $(MARKETING_VERSION) CFBundleVersion - 1 + $(CURRENT_PROJECT_VERSION) LSUIElement UTExportedTypeDeclarations diff --git a/ModernAppExtension/App/main.swift b/ModernAppExtension/App/main.swift new file mode 100644 index 0000000..cec266e --- /dev/null +++ b/ModernAppExtension/App/main.swift @@ -0,0 +1,6 @@ +import AppKit + +let app = NSApplication.shared +let delegate = AppDelegate() +app.delegate = delegate +_ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv) diff --git a/ModernAppExtension/PreviewExtension/Info.plist b/ModernAppExtension/PreviewExtension/Info.plist index 351d880..64d54d9 100644 --- a/ModernAppExtension/PreviewExtension/Info.plist +++ b/ModernAppExtension/PreviewExtension/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType XPC! CFBundleShortVersionString - 1.0 + $(MARKETING_VERSION) CFBundleVersion - 1 + $(CURRENT_PROJECT_VERSION) NSExtension NSExtensionAttributes diff --git a/ModernAppExtension/PreviewExtension/Preview.entitlements b/ModernAppExtension/PreviewExtension/Preview.entitlements new file mode 100644 index 0000000..852fa1a --- /dev/null +++ b/ModernAppExtension/PreviewExtension/Preview.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/ModernAppExtension/PreviewExtension/PreviewViewController.swift b/ModernAppExtension/PreviewExtension/PreviewViewController.swift index fd2ab4e..59f5ceb 100644 --- a/ModernAppExtension/PreviewExtension/PreviewViewController.swift +++ b/ModernAppExtension/PreviewExtension/PreviewViewController.swift @@ -1,36 +1,61 @@ import Cocoa +import OSLog import Quartz import SceneKit -import GLTFSceneKit + +private final class TransparentSceneView: SCNView { + override var isOpaque: Bool { false } +} class PreviewViewController: NSViewController, QLPreviewingController { + private let logger = Logger(subsystem: "com.hectorlizard.GLTFQuickLook", category: "Preview") var sceneView: SCNView! override func loadView() { - self.sceneView = SCNView(frame: NSRect(x: 0, y: 0, width: 800, height: 600)) + self.sceneView = TransparentSceneView(frame: NSRect(x: 0, y: 0, width: 800, height: 600)) self.sceneView.autoresizingMask = [.width, .height] - self.sceneView.backgroundColor = NSColor.windowBackgroundColor + // Let the Quick Look host supply its native light/dark background. + self.sceneView.backgroundColor = .clear + self.sceneView.wantsLayer = true + self.sceneView.layer?.isOpaque = false self.sceneView.allowsCameraControl = true self.sceneView.autoenablesDefaultLighting = true + self.sceneView.rendersContinuously = false self.view = self.sceneView } func preparePreviewOfFile(at url: URL, completionHandler handler: @escaping (Error?) -> Void) { DispatchQueue.global(qos: .userInitiated).async { - do { - let source = try GLTFSceneSource(url: url) - let scene = try source.scene() - - DispatchQueue.main.async { - self.sceneView.scene = scene - handler(nil) - } - } catch { - DispatchQueue.main.async { - handler(error) + autoreleasepool { + do { + let loadedScene = try SceneLoadCoordinator.loadScene(at: url) + let loadResult = SceneLoadCoordinator.optimizedRenderScene(from: loadedScene, purpose: "preview") + + DispatchQueue.main.async { + self.sceneView.scene = nil + self.applyRenderingConfiguration(isDenseScene: loadResult.isDenseScene) + self.sceneView.scene = loadResult.scene + self.sceneView.pointOfView = loadResult.pointOfView + self.logger.notice( + """ + Preview loaded for \(url.lastPathComponent, privacy: .public) geometry=\(loadResult.geometryNodeCount, privacy: .public) cameras=\(loadResult.cameraNodeCount, privacy: .public) dense=\(loadResult.isDenseScene, privacy: .public) + """ + ) + handler(nil) + } + } catch { + self.logger.error("Preview failed for \(url.path, privacy: .public): \(error.localizedDescription, privacy: .public)") + DispatchQueue.main.async { + handler(error) + } } } } } + + private func applyRenderingConfiguration(isDenseScene: Bool) { + sceneView.antialiasingMode = isDenseScene ? .none : .multisampling4X + sceneView.preferredFramesPerSecond = isDenseScene ? 15 : 60 + } } diff --git a/ModernAppExtension/README.md b/ModernAppExtension/README.md index 9ba650a..4265e6e 100644 --- a/ModernAppExtension/README.md +++ b/ModernAppExtension/README.md @@ -1,29 +1,74 @@ -# GLTFQuickLook Modern App Extension +# Modern App Extension -This folder contains the modern macOS App Extension implementation of GLTFQuickLook for macOS 10.15 (Catalina) and newer, including full support for Apple Silicon (M1/M2/M3) and upcoming macOS versions like Sequoia and Tahoe. +This directory contains the macOS 12+ implementation of GLTFQuickLook. The host +is a menu-bar application that embeds a preview extension and a thumbnail +extension. Unlike the legacy `.qlgenerator`, the host stays open so it can prepare +sidecar-dependent models before sandboxed Quick Look processes request them. -Apple deprecated `.qlgenerator` plugins and requires QuickLook extensions to be embedded inside a macOS application bag (`.app`). +## Runtime Model -## Installation +GLB documents and self-contained glTF files are rendered directly. A `.gltf` that +references external buffers or textures is converted into a self-contained cache. +The cache is stored in an extended attribute on the source document, never by +rewriting the original model. -1. Download the latest `GLTFQuickLook.app` release. -2. Drag and drop `GLTFQuickLook.app` into your `/Applications` folder. -3. Open the app once (it will launch and exit immediately, registering the QuickLook extensions with macOS). -4. Select any `.gltf` or `.glb` file in Finder and press `Space` to preview. +The app watches `~/Downloads` by default and any additional folders selected from +the `GLTFQL` menu. Relevant file changes trigger a targeted, debounced preparation +rather than a full-library rescan. -## Build Setup +Optional compatibility processing includes: -To build this modern extension from source, you need [XcodeGen](https://github.com/yonaskolb/XcodeGen) and macOS 12.0+ with Xcode. +- Unreal material reconstruction from sibling `Materials` and `Textures` trees. +- Removal of uniform pure-red vertex colors used as export artifacts. +- Inlining of external buffers, images, and data dependencies. +- Normalization of integer skin weights rejected by SceneKit. +- Compaction of exceptional uModel exports with at least 100 animations or 10,000 + accessors; the prepared cache retains the first 10 animations. + +The interactive SceneKit view is transparent and lets the Quick Look host provide +the correct Light or Dark Mode background. + +## Build + +Install XcodeGen and generate the project from the repository root: ```bash -# Install XcodeGen brew install xcodegen +xcodegen generate --spec ModernAppExtension/project.yml +open ModernAppExtension/GLTFQuickLook.xcodeproj +``` + +GLTFSceneKit is pinned in `project.yml` to the revision used by the release build. +The `GLTFQuickLook` scheme builds the host and both extensions. `GLTFCachePrep` is +the command-line preparation utility used for diagnostics. + +For a reproducible ad hoc signed artifact: + +```bash +Scripts/package-release.sh +``` + +Artifacts are written to `dist/`. The public beta is Apple Silicon only; other +architectures may be built from source but are not part of the tested binary. + +## Gatekeeper -# Generate the Xcode Project -xcodegen +The public beta is ad hoc signed and not notarized. Users can approve it from +**System Settings > Privacy & Security > Open Anyway**. If macOS keeps the embedded +extensions quarantined, remove quarantine from the complete bundle: -# Open the project -open GLTFQuickLook.xcodeproj +```bash +xattr -dr com.apple.quarantine /Applications/GLTFQuickLook.app +open /Applications/GLTFQuickLook.app ``` -You can then build the `GLTFQuickLook` scheme directly from Xcode. Swift Package Manager will automatically fetch `GLTFSceneKit`. +## Cache Limitations + +Prepared caches depend on extended attributes. Filesystems that do not support +large extended attributes can reject or discard them. Copying a glTF file through +some archives, cloud providers, or Windows-oriented volumes can also remove the +cache; using **Reanalyser les dossiers** regenerates it without modifying the source +model. + +The host scans only local folders configured by the user plus `~/Downloads`. It +does not upload model data or include analytics. diff --git a/ModernAppExtension/Shared/GLTFDocumentInliner.swift b/ModernAppExtension/Shared/GLTFDocumentInliner.swift new file mode 100644 index 0000000..08e5167 --- /dev/null +++ b/ModernAppExtension/Shared/GLTFDocumentInliner.swift @@ -0,0 +1,206 @@ +import CryptoKit +import Foundation +import UniformTypeIdentifiers + +struct PreparedGLTFDocument { + let data: Data + let metadata: PreparedDocumentCacheMetadata +} + +enum GLTFDocumentInliner { + static func prepareDocument( + at url: URL, + options: PreparedDocumentPreparationOptions = .current() + ) throws -> PreparedGLTFDocument { + let documentData = try Data(contentsOf: url) + var rootObject = try parsedRootObject(from: documentData) + let baseDirectoryURL = url.deletingLastPathComponent() + var additionalResourceURLs: [URL] = [] + + if options.enrichUsingUnrealMaterialProps { + let enrichmentResult = UnrealMaterialEnricher.enrich(rootObject: &rootObject, documentURL: url) + additionalResourceURLs = enrichmentResult.additionalResourceURLs + } + + let normalizesSkinWeights = try SkinWeightNormalizer.normalize( + &rootObject, + relativeTo: baseDirectoryURL + ) + + if options.ignoreUniformRedVertexColors { + VertexColorSanitizer.removeUniformRedVertexColors( + from: &rootObject, + relativeTo: baseDirectoryURL + ) + } + + let optimizesOversizedAnimations = OversizedAnimationOptimizer.shouldOptimize(rootObject) + let maximumBufferByteLengths = OversizedAnimationOptimizer.optimize(&rootObject) + + let resourceURLs = localResourceURLs(in: rootObject, relativeTo: baseDirectoryURL) + additionalResourceURLs + + if var buffers = rootObject["buffers"] as? [[String: Any]] { + for index in buffers.indices { + guard let uri = buffers[index]["uri"] as? String else { + continue + } + guard let resourceURL = localResourceURL(for: uri, relativeTo: baseDirectoryURL) else { + continue + } + buffers[index]["uri"] = try dataURI( + for: resourceURL, + fallbackMimeType: "application/octet-stream", + maximumByteLength: maximumBufferByteLengths[index] + ) + } + rootObject["buffers"] = buffers + } + + if var images = rootObject["images"] as? [[String: Any]] { + for index in images.indices { + guard let uri = images[index]["uri"] as? String else { + continue + } + guard let resourceURL = localResourceURL(for: uri, relativeTo: baseDirectoryURL) else { + continue + } + images[index]["uri"] = try dataURI(for: resourceURL, fallbackMimeType: inferredMimeType(for: resourceURL)) + } + rootObject["images"] = images + } + + let preparedData = try JSONSerialization.data(withJSONObject: rootObject, options: []) + let metadata = try makeMetadata( + for: documentData, + resourceURLs: resourceURLs, + preparationFlavor: options.preparationFlavor( + optimizingOversizedAnimations: optimizesOversizedAnimations, + normalizingSkinWeights: normalizesSkinWeights + ) + ) + return PreparedGLTFDocument(data: preparedData, metadata: metadata) + } + + static func metadata( + for url: URL, + options: PreparedDocumentPreparationOptions = .current() + ) throws -> PreparedDocumentCacheMetadata { + let documentData = try Data(contentsOf: url) + let rootObject = try parsedRootObject(from: documentData) + var resourceURLs = localResourceURLs(in: rootObject, relativeTo: url.deletingLastPathComponent()) + + if options.enrichUsingUnrealMaterialProps { + var enrichedRootObject = rootObject + let enrichmentResult = UnrealMaterialEnricher.enrich(rootObject: &enrichedRootObject, documentURL: url) + resourceURLs += enrichmentResult.additionalResourceURLs + } + + return try makeMetadata( + for: documentData, + resourceURLs: resourceURLs, + preparationFlavor: options.preparationFlavor( + optimizingOversizedAnimations: OversizedAnimationOptimizer.shouldOptimize(rootObject), + normalizingSkinWeights: SkinWeightNormalizer.requiresNormalization(rootObject) + ) + ) + } + + private static func parsedRootObject(from documentData: Data) throws -> [String: Any] { + guard let rootObject = try JSONSerialization.jsonObject(with: documentData) as? [String: Any] else { + throw CocoaError(.fileReadCorruptFile) + } + return rootObject + } + + private static func localResourceURLs(in rootObject: [String: Any], relativeTo baseDirectoryURL: URL) -> [URL] { + let bufferURIs = ((rootObject["buffers"] as? [[String: Any]]) ?? []).compactMap { $0["uri"] as? String } + let imageURIs = ((rootObject["images"] as? [[String: Any]]) ?? []).compactMap { $0["uri"] as? String } + + var seenPaths: Set = [] + var urls: [URL] = [] + for uri in bufferURIs + imageURIs { + guard let resourceURL = localResourceURL(for: uri, relativeTo: baseDirectoryURL) else { + continue + } + let standardizedPath = resourceURL.standardizedFileURL.path + guard seenPaths.insert(standardizedPath).inserted else { + continue + } + urls.append(resourceURL) + } + return urls + } + + static func localResourceURL(for uri: String, relativeTo baseDirectoryURL: URL) -> URL? { + guard !uri.isEmpty, !uri.hasPrefix("data:") else { + return nil + } + + if let parsedURL = URL(string: uri), let scheme = parsedURL.scheme, !scheme.isEmpty { + guard scheme == "file" else { + return nil + } + return parsedURL.standardizedFileURL + } + + let path = uri.removingPercentEncoding ?? uri + return URL(fileURLWithPath: path, relativeTo: baseDirectoryURL).standardizedFileURL + } + + private static func dataURI( + for fileURL: URL, + fallbackMimeType: String, + maximumByteLength: Int? = nil + ) throws -> String { + let data: Data + if let maximumByteLength { + let handle = try FileHandle(forReadingFrom: fileURL) + defer { try? handle.close() } + data = try handle.read(upToCount: maximumByteLength) ?? Data() + guard data.count == maximumByteLength else { + throw CocoaError(.fileReadCorruptFile) + } + } else { + data = try Data(contentsOf: fileURL) + } + let mimeType = inferredMimeType(for: fileURL, fallback: fallbackMimeType) + return "data:\(mimeType);base64,\(data.base64EncodedString())" + } + + private static func makeMetadata( + for documentData: Data, + resourceURLs: [URL], + preparationFlavor: String + ) throws -> PreparedDocumentCacheMetadata { + let sourceDigest = SHA256.hash(data: documentData).map { String(format: "%02x", $0) }.joined() + var seenPaths: Set = [] + let resources = try resourceURLs.compactMap { resourceURL -> PreparedDocumentResourceFingerprint? in + let standardizedURL = resourceURL.standardizedFileURL + guard seenPaths.insert(standardizedURL.path).inserted else { + return nil + } + + let values = try resourceURL.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey]) + return PreparedDocumentResourceFingerprint( + path: standardizedURL.path, + fileSize: Int64(values.fileSize ?? 0), + modificationIntervalSince1970: values.contentModificationDate?.timeIntervalSince1970 ?? 0 + ) + }.sorted { $0.path < $1.path } + + return PreparedDocumentCacheMetadata( + version: 2, + preparationFlavor: preparationFlavor, + sourceSHA256: sourceDigest, + resources: resources + ) + } + + private static func inferredMimeType(for fileURL: URL, fallback: String = "application/octet-stream") -> String { + if let type = UTType(filenameExtension: fileURL.pathExtension), + let preferredMIMEType = type.preferredMIMEType { + return preferredMIMEType + } + return fallback + } +} diff --git a/ModernAppExtension/Shared/OversizedAnimationOptimizer.swift b/ModernAppExtension/Shared/OversizedAnimationOptimizer.swift new file mode 100644 index 0000000..e10f1d0 --- /dev/null +++ b/ModernAppExtension/Shared/OversizedAnimationOptimizer.swift @@ -0,0 +1,262 @@ +import Foundation + +enum OversizedAnimationOptimizer { + // Quick Look needs a representative animation set, not thousands of clips at once. + private static let animationThreshold = 100 + private static let accessorThreshold = 10_000 + private static let retainedAnimationCount = 10 + + static func shouldOptimize(_ rootObject: [String: Any]) -> Bool { + guard ((rootObject["extensionsRequired"] as? [Any]) ?? []).isEmpty else { + return false + } + + let animationCount = (rootObject["animations"] as? [Any])?.count ?? 0 + let accessorCount = (rootObject["accessors"] as? [Any])?.count ?? 0 + return animationCount >= animationThreshold || accessorCount >= accessorThreshold + } + + static func optimize(_ rootObject: inout [String: Any]) -> [Int: Int] { + guard shouldOptimize(rootObject) else { + return [:] + } + + let retainedAnimations = Array( + ((rootObject["animations"] as? [[String: Any]]) ?? []).prefix(retainedAnimationCount) + ) + var usedAccessorIndices = collectStaticAccessorIndices(in: rootObject) + collectAnimationAccessorIndices(in: retainedAnimations, into: &usedAccessorIndices) + guard !usedAccessorIndices.isEmpty, + let accessors = rootObject["accessors"] as? [[String: Any]], + let bufferViews = rootObject["bufferViews"] as? [[String: Any]] else { + return [:] + } + + let retainedAccessorIndices = usedAccessorIndices.sorted() + let accessorIndexMap = indexMap(for: retainedAccessorIndices) + let retainedAccessors = retainedAccessorIndices.compactMap { index in + accessors.indices.contains(index) ? accessors[index] : nil + } + guard retainedAccessors.count == retainedAccessorIndices.count else { + return [:] + } + + var usedBufferViewIndices = collectBufferViewIndices(from: retainedAccessors) + collectImageBufferViewIndices(in: rootObject, into: &usedBufferViewIndices) + + let retainedBufferViewIndices = usedBufferViewIndices.sorted() + let bufferViewIndexMap = indexMap(for: retainedBufferViewIndices) + let retainedBufferViews = retainedBufferViewIndices.compactMap { index in + bufferViews.indices.contains(index) ? bufferViews[index] : nil + } + guard retainedBufferViews.count == retainedBufferViewIndices.count else { + return [:] + } + + rewriteAccessorReferences(in: &rootObject, using: accessorIndexMap) + rootObject["animations"] = rewriteAnimations(retainedAnimations, using: accessorIndexMap) + rootObject["accessors"] = retainedAccessors.map { + rewritingBufferViewReferences(in: $0, using: bufferViewIndexMap) + } + rootObject["bufferViews"] = retainedBufferViews + rewriteImageBufferViewReferences(in: &rootObject, using: bufferViewIndexMap) + + let maximumByteLengths = maximumUsedByteLengths(for: retainedBufferViews) + if var buffers = rootObject["buffers"] as? [[String: Any]] { + for index in buffers.indices { + if let maximumByteLength = maximumByteLengths[index] { + buffers[index]["byteLength"] = maximumByteLength + } + } + rootObject["buffers"] = buffers + } + + return maximumByteLengths + } + + private static func collectStaticAccessorIndices(in rootObject: [String: Any]) -> Set { + var indices: Set = [] + + for mesh in (rootObject["meshes"] as? [[String: Any]]) ?? [] { + for primitive in (mesh["primitives"] as? [[String: Any]]) ?? [] { + if let index = primitive["indices"] as? Int { + indices.insert(index) + } + for index in ((primitive["attributes"] as? [String: Int]) ?? [:]).values { + indices.insert(index) + } + for target in (primitive["targets"] as? [[String: Int]]) ?? [] { + indices.formUnion(target.values) + } + } + } + + for skin in (rootObject["skins"] as? [[String: Any]]) ?? [] { + if let index = skin["inverseBindMatrices"] as? Int { + indices.insert(index) + } + } + return indices + } + + private static func collectBufferViewIndices(from accessors: [[String: Any]]) -> Set { + var indices: Set = [] + for accessor in accessors { + if let index = accessor["bufferView"] as? Int { + indices.insert(index) + } + if let sparse = accessor["sparse"] as? [String: Any] { + if let sparseIndices = sparse["indices"] as? [String: Any], + let index = sparseIndices["bufferView"] as? Int { + indices.insert(index) + } + if let sparseValues = sparse["values"] as? [String: Any], + let index = sparseValues["bufferView"] as? Int { + indices.insert(index) + } + } + } + return indices + } + + private static func collectAnimationAccessorIndices( + in animations: [[String: Any]], + into indices: inout Set + ) { + for animation in animations { + for sampler in (animation["samplers"] as? [[String: Any]]) ?? [] { + if let index = sampler["input"] as? Int { + indices.insert(index) + } + if let index = sampler["output"] as? Int { + indices.insert(index) + } + } + } + } + + private static func rewriteAnimations( + _ animations: [[String: Any]], + using indexMap: [Int: Int] + ) -> [[String: Any]] { + animations.map { animation in + var rewrittenAnimation = animation + if var samplers = animation["samplers"] as? [[String: Any]] { + for index in samplers.indices { + if let oldInput = samplers[index]["input"] as? Int { + samplers[index]["input"] = indexMap[oldInput] + } + if let oldOutput = samplers[index]["output"] as? Int { + samplers[index]["output"] = indexMap[oldOutput] + } + } + rewrittenAnimation["samplers"] = samplers + } + return rewrittenAnimation + } + } + + private static func collectImageBufferViewIndices( + in rootObject: [String: Any], + into indices: inout Set + ) { + for image in (rootObject["images"] as? [[String: Any]]) ?? [] { + if let index = image["bufferView"] as? Int { + indices.insert(index) + } + } + } + + private static func rewriteAccessorReferences( + in rootObject: inout [String: Any], + using indexMap: [Int: Int] + ) { + if var meshes = rootObject["meshes"] as? [[String: Any]] { + for meshIndex in meshes.indices { + guard var primitives = meshes[meshIndex]["primitives"] as? [[String: Any]] else { + continue + } + for primitiveIndex in primitives.indices { + if let oldIndex = primitives[primitiveIndex]["indices"] as? Int { + primitives[primitiveIndex]["indices"] = indexMap[oldIndex] + } + if let attributes = primitives[primitiveIndex]["attributes"] as? [String: Int] { + primitives[primitiveIndex]["attributes"] = attributes.compactMapValues { indexMap[$0] } + } + if let targets = primitives[primitiveIndex]["targets"] as? [[String: Int]] { + primitives[primitiveIndex]["targets"] = targets.map { + $0.compactMapValues { indexMap[$0] } + } + } + } + meshes[meshIndex]["primitives"] = primitives + } + rootObject["meshes"] = meshes + } + + if var skins = rootObject["skins"] as? [[String: Any]] { + for index in skins.indices { + if let oldIndex = skins[index]["inverseBindMatrices"] as? Int { + skins[index]["inverseBindMatrices"] = indexMap[oldIndex] + } + } + rootObject["skins"] = skins + } + } + + private static func rewritingBufferViewReferences( + in accessor: [String: Any], + using indexMap: [Int: Int] + ) -> [String: Any] { + var rewritten = accessor + if let oldIndex = rewritten["bufferView"] as? Int { + rewritten["bufferView"] = indexMap[oldIndex] + } + if var sparse = rewritten["sparse"] as? [String: Any] { + if var indices = sparse["indices"] as? [String: Any], + let oldIndex = indices["bufferView"] as? Int { + indices["bufferView"] = indexMap[oldIndex] + sparse["indices"] = indices + } + if var values = sparse["values"] as? [String: Any], + let oldIndex = values["bufferView"] as? Int { + values["bufferView"] = indexMap[oldIndex] + sparse["values"] = values + } + rewritten["sparse"] = sparse + } + return rewritten + } + + private static func rewriteImageBufferViewReferences( + in rootObject: inout [String: Any], + using indexMap: [Int: Int] + ) { + guard var images = rootObject["images"] as? [[String: Any]] else { + return + } + for index in images.indices { + if let oldIndex = images[index]["bufferView"] as? Int { + images[index]["bufferView"] = indexMap[oldIndex] + } + } + rootObject["images"] = images + } + + private static func maximumUsedByteLengths(for bufferViews: [[String: Any]]) -> [Int: Int] { + var maximums: [Int: Int] = [:] + for bufferView in bufferViews { + guard let bufferIndex = bufferView["buffer"] as? Int, + let byteLength = bufferView["byteLength"] as? Int else { + continue + } + let end = (bufferView["byteOffset"] as? Int ?? 0) + byteLength + maximums[bufferIndex] = max(maximums[bufferIndex] ?? 0, end) + } + return maximums + } + + private static func indexMap(for retainedIndices: [Int]) -> [Int: Int] { + Dictionary(uniqueKeysWithValues: retainedIndices.enumerated().map { ($1, $0) }) + } +} diff --git a/ModernAppExtension/Shared/PreparedDocumentAttributeStore.swift b/ModernAppExtension/Shared/PreparedDocumentAttributeStore.swift new file mode 100644 index 0000000..6e0a9a7 --- /dev/null +++ b/ModernAppExtension/Shared/PreparedDocumentAttributeStore.swift @@ -0,0 +1,66 @@ +import Foundation + +struct PreparedDocumentCacheMetadata: Codable, Equatable { + let version: Int + let preparationFlavor: String + let sourceSHA256: String + let resources: [PreparedDocumentResourceFingerprint] +} + +struct PreparedDocumentResourceFingerprint: Codable, Equatable { + let path: String + let fileSize: Int64 + let modificationIntervalSince1970: TimeInterval +} + +enum PreparedDocumentAttributeStore { + static let payloadAttributeName = PreparedDocumentFileCache.attributeName + static let metadataAttributeName = "com.hectorlizard.GLTFQuickLook.PreparedGLTFMetadata" + + static func metadata(for url: URL) -> PreparedDocumentCacheMetadata? { + guard let data = data(for: metadataAttributeName, at: url) else { + return nil + } + return try? JSONDecoder().decode(PreparedDocumentCacheMetadata.self, from: data) + } + + static func write(preparedData: Data, metadata: PreparedDocumentCacheMetadata, to url: URL) throws { + let metadataData = try JSONEncoder().encode(metadata) + try set(data: preparedData, for: payloadAttributeName, at: url) + try set(data: metadataData, for: metadataAttributeName, at: url) + } + + private static func data(for attributeName: String, at url: URL) -> Data? { + let path = url.path + let size = getxattr(path, attributeName, nil, 0, 0, 0) + guard size >= 0 else { + return nil + } + + let expectedCount = Int(size) + var data = Data(count: expectedCount) + let readCount = data.withUnsafeMutableBytes { bytes -> ssize_t in + getxattr(path, attributeName, bytes.baseAddress, expectedCount, 0, 0) + } + + guard readCount >= 0 else { + return nil + } + + if readCount != expectedCount { + data.removeSubrange(Int(readCount).. Data? { + guard originalURL.pathExtension.lowercased() == "gltf" else { + return nil + } + + let path = originalURL.path + let name = attributeName + + let size = getxattr(path, name, nil, 0, 0, 0) + guard size >= 0 else { + return nil + } + + let expectedCount = Int(size) + var data = Data(count: expectedCount) + let readCount = data.withUnsafeMutableBytes { bytes -> ssize_t in + getxattr(path, name, bytes.baseAddress, expectedCount, 0, 0) + } + + guard readCount >= 0 else { + logger.error("Prepared xattr read failed for \(path, privacy: .public)") + return nil + } + + if readCount != expectedCount { + data.removeSubrange(Int(readCount).. String { + var parts = ["base-v1"] + if enrichUsingUnrealMaterialProps { + parts.append("unreal-materials-v1") + } + if ignoreUniformRedVertexColors { + parts.append("ignore-red-vertex-colors-v1") + } + if optimizingOversizedAnimations { + parts.append("limit-oversized-animations-10-v1") + } + if normalizingSkinWeights { + parts.append("normalize-skin-weights-v1") + } + return parts.joined(separator: "+") + } + + static func current() -> PreparedDocumentPreparationOptions { + PreparedDocumentPreparationOptions( + enrichUsingUnrealMaterialProps: PreparedDocumentSettings.isUnrealMaterialEnrichmentEnabled(), + ignoreUniformRedVertexColors: PreparedDocumentSettings.isUniformRedVertexColorIgnoringEnabled() + ) + } +} + +enum PreparedDocumentSettings { + static let suiteName = "com.hectorlizard.GLTFQuickLook" + static let unrealMaterialEnrichmentKey = "EnableUnrealMaterialEnrichment" + static let ignoreUniformRedVertexColorsKey = "IgnoreUniformRedVertexColors" + + static func isUnrealMaterialEnrichmentEnabled() -> Bool { + defaults.bool(forKey: unrealMaterialEnrichmentKey) + } + + static func setUnrealMaterialEnrichmentEnabled(_ enabled: Bool) { + defaults.set(enabled, forKey: unrealMaterialEnrichmentKey) + } + + static func isUniformRedVertexColorIgnoringEnabled() -> Bool { + if defaults.object(forKey: ignoreUniformRedVertexColorsKey) == nil { + return true + } + return defaults.bool(forKey: ignoreUniformRedVertexColorsKey) + } + + static func setUniformRedVertexColorIgnoringEnabled(_ enabled: Bool) { + defaults.set(enabled, forKey: ignoreUniformRedVertexColorsKey) + } + + private static var defaults: UserDefaults { + UserDefaults(suiteName: suiteName) ?? .standard + } +} diff --git a/ModernAppExtension/Shared/PreparedDocumentPreparer.swift b/ModernAppExtension/Shared/PreparedDocumentPreparer.swift new file mode 100644 index 0000000..3ec4bf3 --- /dev/null +++ b/ModernAppExtension/Shared/PreparedDocumentPreparer.swift @@ -0,0 +1,102 @@ +import Foundation +import OSLog + +struct PreparationRunSummary { + var foldersScanned = 0 + var documentsSeen = 0 + var documentsPrepared = 0 + var documentsSkipped = 0 + var failures: [String] = [] +} + +enum PreparedDocumentPreparer { + static func prepare( + urls: [URL], + logger: Logger? = nil, + options: PreparedDocumentPreparationOptions = .current() + ) -> PreparationRunSummary { + var summary = PreparationRunSummary() + let fileManager = FileManager.default + + for url in urls { + autoreleasepool { + if url.hasDirectoryPath { + prepareDirectory( + url, + fileManager: fileManager, + logger: logger, + options: options, + summary: &summary + ) + } else if url.pathExtension.lowercased() == "gltf" { + prepareDocument(url, logger: logger, options: options, summary: &summary) + } + } + } + + return summary + } + + private static func prepareDirectory( + _ directoryURL: URL, + fileManager: FileManager, + logger: Logger?, + options: PreparedDocumentPreparationOptions, + summary: inout PreparationRunSummary + ) { + guard fileManager.fileExists(atPath: directoryURL.path) else { + summary.failures.append("Dossier introuvable: \(directoryURL.path)") + return + } + + summary.foldersScanned += 1 + + guard let enumerator = fileManager.enumerator( + at: directoryURL, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles, .skipsPackageDescendants] + ) else { + summary.failures.append("Enumeration impossible: \(directoryURL.path)") + return + } + + for case let fileURL as URL in enumerator where fileURL.pathExtension.lowercased() == "gltf" { + autoreleasepool { + prepareDocument(fileURL, logger: logger, options: options, summary: &summary) + } + } + } + + private static func prepareDocument( + _ fileURL: URL, + logger: Logger?, + options: PreparedDocumentPreparationOptions, + summary: inout PreparationRunSummary + ) { + summary.documentsSeen += 1 + + autoreleasepool { + do { + let currentMetadata = try GLTFDocumentInliner.metadata(for: fileURL, options: options) + if PreparedDocumentAttributeStore.metadata(for: fileURL) == currentMetadata { + summary.documentsSkipped += 1 + return + } + + let preparedDocument = try GLTFDocumentInliner.prepareDocument(at: fileURL, options: options) + try PreparedDocumentAttributeStore.write( + preparedData: preparedDocument.data, + metadata: preparedDocument.metadata, + to: fileURL + ) + summary.documentsPrepared += 1 + logger?.notice("Prepared cache for \(fileURL.path, privacy: .public)") + print("Prepared \(fileURL.path)") + } catch { + summary.failures.append("\(fileURL.lastPathComponent): \(error.localizedDescription)") + logger?.error("Preparation failed for \(fileURL.path, privacy: .public): \(error.localizedDescription, privacy: .public)") + print("Failed \(fileURL.path): \(error.localizedDescription)") + } + } + } +} diff --git a/ModernAppExtension/Shared/SceneLoadCoordinator.swift b/ModernAppExtension/Shared/SceneLoadCoordinator.swift new file mode 100644 index 0000000..62f15bc --- /dev/null +++ b/ModernAppExtension/Shared/SceneLoadCoordinator.swift @@ -0,0 +1,163 @@ +import Foundation +import GLTFSceneKit +import OSLog +import SceneKit + +struct SceneLoadResult { + let scene: SCNScene + let pointOfView: SCNNode + let geometryNodeCount: Int + let cameraNodeCount: Int + let totalNodeCount: Int + let isDenseScene: Bool +} + +enum SceneLoadCoordinator { + private static let logger = Logger(subsystem: "com.hectorlizard.GLTFQuickLook", category: "SceneLoad") + private static let denseSceneGeometryThreshold = 4_000 + private static let denseSceneNodeThreshold = 10_000 + + static func loadScene(at url: URL) throws -> SceneLoadResult { + let fileScope = url.startAccessingSecurityScopedResource() + let directoryURL = url.deletingLastPathComponent() + let directoryScope = directoryURL.startAccessingSecurityScopedResource() + + logger.debug("Loading scene for \(url.path, privacy: .public) fileScope=\(fileScope, privacy: .public) directoryScope=\(directoryScope, privacy: .public)") + + defer { + if directoryScope { + directoryURL.stopAccessingSecurityScopedResource() + } + if fileScope { + url.stopAccessingSecurityScopedResource() + } + } + + let source = makeSceneSource(for: url) + let scene = try source.scene() + + let initialGeometryNodeCount = countNodes(in: scene.rootNode) { $0.geometry != nil } + let initialTotalNodeCount = countNodes(in: scene.rootNode) { _ in true } + let cameraNodes = collectNodes(in: scene.rootNode) { $0.camera != nil } + + let isDenseScene = shouldTreatAsDenseScene( + geometryNodeCount: initialGeometryNodeCount, + totalNodeCount: initialTotalNodeCount + ) + let pointOfView = cameraNodes.first ?? makeFallbackCamera(for: scene) + + let (boundsMin, boundsMax) = scene.rootNode.boundingBox + logger.debug( + """ + Scene loaded for \(url.lastPathComponent, privacy: .public) geometryNodes=\(initialGeometryNodeCount, privacy: .public) totalNodes=\(initialTotalNodeCount, privacy: .public) cameras=\(cameraNodes.count, privacy: .public) dense=\(isDenseScene, privacy: .public) boundsMin=(\(boundsMin.x, privacy: .public), \(boundsMin.y, privacy: .public), \(boundsMin.z, privacy: .public)) boundsMax=(\(boundsMax.x, privacy: .public), \(boundsMax.y, privacy: .public), \(boundsMax.z, privacy: .public)) + """ + ) + + return SceneLoadResult( + scene: scene, + pointOfView: pointOfView, + geometryNodeCount: initialGeometryNodeCount, + cameraNodeCount: cameraNodes.count, + totalNodeCount: initialTotalNodeCount, + isDenseScene: isDenseScene + ) + } + + static func optimizedRenderScene(from loadResult: SceneLoadResult, purpose: StaticString) -> SceneLoadResult { + guard loadResult.isDenseScene else { + return loadResult + } + + logger.notice( + """ + Simplifying dense scene for \(purpose, privacy: .public) geometryNodes=\(loadResult.geometryNodeCount, privacy: .public) totalNodes=\(loadResult.totalNodeCount, privacy: .public) + """ + ) + + let optimizedScene = SCNScene() + let flattenedRoot = loadResult.scene.rootNode.flattenedClone() + optimizedScene.rootNode.addChildNode(flattenedRoot) + let pointOfView = makeFallbackCamera(for: optimizedScene) + let geometryNodeCount = countNodes(in: optimizedScene.rootNode) { $0.geometry != nil } + let totalNodeCount = countNodes(in: optimizedScene.rootNode) { _ in true } + + logger.notice( + """ + Dense scene simplified for \(purpose, privacy: .public) geometryNodes=\(geometryNodeCount, privacy: .public) totalNodes=\(totalNodeCount, privacy: .public) + """ + ) + + return SceneLoadResult( + scene: optimizedScene, + pointOfView: pointOfView, + geometryNodeCount: geometryNodeCount, + cameraNodeCount: 0, + totalNodeCount: totalNodeCount, + isDenseScene: true + ) + } + + private static func makeSceneSource(for url: URL) -> GLTFSceneSource { + if let preparedDocumentData = PreparedDocumentFileCache.preparedDocumentData(for: url, logger: logger) { + logger.notice("Using prepared document cache for \(url.path, privacy: .public)") + return GLTFSceneSource(data: preparedDocumentData) + } + logger.notice("Using source glTF directly for \(url.path, privacy: .public)") + return GLTFSceneSource(url: url) + } + + private static func countNodes(in rootNode: SCNNode, where predicate: (SCNNode) -> Bool) -> Int { + var count = 0 + rootNode.enumerateChildNodes { node, _ in + if predicate(node) { + count += 1 + } + } + return count + } + + private static func collectNodes(in rootNode: SCNNode, where predicate: (SCNNode) -> Bool) -> [SCNNode] { + var nodes: [SCNNode] = [] + rootNode.enumerateChildNodes { node, _ in + if predicate(node) { + nodes.append(node) + } + } + return nodes + } + + private static func shouldTreatAsDenseScene(geometryNodeCount: Int, totalNodeCount: Int) -> Bool { + geometryNodeCount >= denseSceneGeometryThreshold || totalNodeCount >= denseSceneNodeThreshold + } + + private static func makeFallbackCamera(for scene: SCNScene) -> SCNNode { + let (boundsMin, boundsMax) = scene.rootNode.boundingBox + let center = SCNVector3( + x: (boundsMin.x + boundsMax.x) * 0.5, + y: (boundsMin.y + boundsMax.y) * 0.5, + z: (boundsMin.z + boundsMax.z) * 0.5 + ) + + let dx = CGFloat(boundsMax.x - boundsMin.x) + let dy = CGFloat(boundsMax.y - boundsMin.y) + let dz = CGFloat(boundsMax.z - boundsMin.z) + let radius = max(sqrt(dx * dx + dy * dy + dz * dz) * 0.5, 0.001) + + let camera = SCNCamera() + camera.fieldOfView = 50 + camera.zNear = 0.001 + camera.zFar = max(radius * 100, 100) + + let node = SCNNode() + node.name = "__GLTFQuickLookFallbackCamera" + node.camera = camera + node.position = SCNVector3( + x: CGFloat(center.x), + y: CGFloat(center.y) + radius * 0.15, + z: CGFloat(center.z) + radius * 2.8 + ) + node.look(at: center) + scene.rootNode.addChildNode(node) + return node + } +} diff --git a/ModernAppExtension/Shared/SkinWeightNormalizer.swift b/ModernAppExtension/Shared/SkinWeightNormalizer.swift new file mode 100644 index 0000000..003a5fc --- /dev/null +++ b/ModernAppExtension/Shared/SkinWeightNormalizer.swift @@ -0,0 +1,192 @@ +import Foundation + +enum SkinWeightNormalizer { + // SceneKit requires floating-point skin weights even though glTF also permits normalized integers. + private static let floatComponentType = 5_126 + private static let unsignedByteComponentType = 5_121 + private static let unsignedShortComponentType = 5_123 + + static func requiresNormalization(_ rootObject: [String: Any]) -> Bool { + guard let accessors = rootObject["accessors"] as? [[String: Any]] else { + return false + } + return weightAccessorIndices(in: rootObject).contains { index in + guard accessors.indices.contains(index) else { return false } + let accessor = accessors[index] + return accessor["normalized"] as? Bool == true + && supportedIntegerComponentTypes.contains(accessor["componentType"] as? Int ?? 0) + } + } + + static func normalize( + _ rootObject: inout [String: Any], + relativeTo baseDirectoryURL: URL + ) throws -> Bool { + guard requiresNormalization(rootObject), + var accessors = rootObject["accessors"] as? [[String: Any]], + var bufferViews = rootObject["bufferViews"] as? [[String: Any]], + var buffers = rootObject["buffers"] as? [[String: Any]] else { + return false + } + + var normalizedData = Data() + var normalizedAccessors: [(index: Int, byteOffset: Int, byteLength: Int)] = [] + + for accessorIndex in weightAccessorIndices(in: rootObject).sorted() { + guard accessors.indices.contains(accessorIndex) else { continue } + let accessor = accessors[accessorIndex] + guard accessor["normalized"] as? Bool == true, + let componentType = accessor["componentType"] as? Int, + supportedIntegerComponentTypes.contains(componentType), + let bufferViewIndex = accessor["bufferView"] as? Int, + bufferViews.indices.contains(bufferViewIndex) else { + continue + } + + let floatData = try normalizedFloatData( + for: accessor, + bufferView: bufferViews[bufferViewIndex], + buffers: buffers, + relativeTo: baseDirectoryURL + ) + while normalizedData.count % MemoryLayout.alignment != 0 { + normalizedData.append(0) + } + let byteOffset = normalizedData.count + normalizedData.append(floatData) + normalizedAccessors.append((accessorIndex, byteOffset, floatData.count)) + } + + guard !normalizedAccessors.isEmpty else { + return false + } + + let bufferIndex = buffers.count + buffers.append([ + "byteLength": normalizedData.count, + "uri": "data:application/octet-stream;base64,\(normalizedData.base64EncodedString())" + ]) + + for normalizedAccessor in normalizedAccessors { + let bufferViewIndex = bufferViews.count + bufferViews.append([ + "buffer": bufferIndex, + "byteOffset": normalizedAccessor.byteOffset, + "byteLength": normalizedAccessor.byteLength + ]) + accessors[normalizedAccessor.index]["bufferView"] = bufferViewIndex + accessors[normalizedAccessor.index]["byteOffset"] = 0 + accessors[normalizedAccessor.index]["componentType"] = floatComponentType + accessors[normalizedAccessor.index].removeValue(forKey: "normalized") + } + + rootObject["accessors"] = accessors + rootObject["bufferViews"] = bufferViews + rootObject["buffers"] = buffers + return true + } + + private static var supportedIntegerComponentTypes: Set { + [unsignedByteComponentType, unsignedShortComponentType] + } + + private static func weightAccessorIndices(in rootObject: [String: Any]) -> Set { + var indices: Set = [] + for mesh in (rootObject["meshes"] as? [[String: Any]]) ?? [] { + for primitive in (mesh["primitives"] as? [[String: Any]]) ?? [] { + for (semantic, index) in (primitive["attributes"] as? [String: Int]) ?? [:] + where semantic.hasPrefix("WEIGHTS_") { + indices.insert(index) + } + } + } + return indices + } + + private static func normalizedFloatData( + for accessor: [String: Any], + bufferView: [String: Any], + buffers: [[String: Any]], + relativeTo baseDirectoryURL: URL + ) throws -> Data { + guard let componentType = accessor["componentType"] as? Int, + let componentCount = componentCount(for: accessor["type"] as? String), + let vectorCount = accessor["count"] as? Int, + let bufferIndex = bufferView["buffer"] as? Int, + buffers.indices.contains(bufferIndex), + let uri = buffers[bufferIndex]["uri"] as? String else { + throw CocoaError(.fileReadCorruptFile) + } + + let bytesPerComponent = componentType == unsignedByteComponentType ? 1 : 2 + let packedStride = bytesPerComponent * componentCount + let sourceStride = bufferView["byteStride"] as? Int ?? packedStride + let sourceOffset = (bufferView["byteOffset"] as? Int ?? 0) + + (accessor["byteOffset"] as? Int ?? 0) + let sourceLength = sourceStride * max(0, vectorCount - 1) + packedStride + let sourceData = try readBufferRange( + uri: uri, + offset: sourceOffset, + length: sourceLength, + relativeTo: baseDirectoryURL + ) + + var floats: [Float] = [] + floats.reserveCapacity(vectorCount * componentCount) + for vectorIndex in 0.. Data { + if uri.hasPrefix("data:"), + let commaIndex = uri.firstIndex(of: ","), + let data = Data(base64Encoded: String(uri[uri.index(after: commaIndex)...])) { + guard offset >= 0, length >= 0, offset + length <= data.count else { + throw CocoaError(.fileReadCorruptFile) + } + return data.subdata(in: offset..<(offset + length)) + } + + guard let resourceURL = GLTFDocumentInliner.localResourceURL( + for: uri, + relativeTo: baseDirectoryURL + ) else { + throw CocoaError(.fileReadNoPermission) + } + let handle = try FileHandle(forReadingFrom: resourceURL) + defer { try? handle.close() } + try handle.seek(toOffset: UInt64(offset)) + let data = try handle.read(upToCount: length) ?? Data() + guard data.count == length else { + throw CocoaError(.fileReadCorruptFile) + } + return data + } + + private static func componentCount(for accessorType: String?) -> Int? { + switch accessorType { + case "SCALAR": return 1 + case "VEC2": return 2 + case "VEC3": return 3 + case "VEC4": return 4 + default: return nil + } + } +} diff --git a/ModernAppExtension/Shared/UnrealMaterialEnricher.swift b/ModernAppExtension/Shared/UnrealMaterialEnricher.swift new file mode 100644 index 0000000..8138245 --- /dev/null +++ b/ModernAppExtension/Shared/UnrealMaterialEnricher.swift @@ -0,0 +1,419 @@ +import CoreGraphics +import Foundation +import ImageIO + +struct UnrealMaterialEnrichmentResult { + let additionalResourceURLs: [URL] +} + +enum UnrealMaterialEnricher { + static func enrich(rootObject: inout [String: Any], documentURL: URL) -> UnrealMaterialEnrichmentResult { + guard var materials = rootObject["materials"] as? [[String: Any]], !materials.isEmpty else { + return UnrealMaterialEnrichmentResult(additionalResourceURLs: []) + } + + var materialLibrary = UnrealMaterialLibrary(documentURL: documentURL) + var dependencyPaths: Set = [] + var images = rootObject["images"] as? [[String: Any]] ?? [] + var textures = rootObject["textures"] as? [[String: Any]] ?? [] + var imageIndicesByKey: [String: Int] = [:] + var textureIndicesByKey: [String: Int] = [:] + + for (index, image) in images.enumerated() { + if let uri = image["uri"] as? String { + imageIndicesByKey["uri:\(uri)"] = index + } + } + + for (index, texture) in textures.enumerated() { + if let source = texture["source"] as? Int { + textureIndicesByKey["source:\(source)"] = index + } + } + + for materialIndex in materials.indices { + guard let materialName = materials[materialIndex]["name"] as? String, + let props = materialLibrary.props(forMaterialNamed: materialName) else { + continue + } + + dependencyPaths.insert(props.propsFileURL.standardizedFileURL.path) + + var material = materials[materialIndex] + var pbr = material["pbrMetallicRoughness"] as? [String: Any] ?? [:] + + if let baseColorTextureIndex = textureIndex( + for: props.textureReference(exactNames: ["Color", "Diffuse Map"], containsKeywords: ["diffuse map", "albedo map"]), + images: &images, + textures: &textures, + imageIndicesByKey: &imageIndicesByKey, + textureIndicesByKey: &textureIndicesByKey, + dependencyPaths: &dependencyPaths + ) { + pbr["baseColorTexture"] = ["index": baseColorTextureIndex] + pbr["baseColorFactor"] = [1.0, 1.0, 1.0, 1.0] + } + + if let normalTextureIndex = textureIndex( + for: props.textureReference(exactNames: ["Normal", "Normal Map"], containsKeywords: ["normal map"]), + images: &images, + textures: &textures, + imageIndicesByKey: &imageIndicesByKey, + textureIndicesByKey: &textureIndicesByKey, + dependencyPaths: &dependencyPaths + ) { + material["normalTexture"] = ["index": normalTextureIndex] + } + + if let emissiveTextureIndex = textureIndex( + for: props.textureReference(exactNames: ["Emissive", "Emissive Map"], containsKeywords: ["emissive map"]), + images: &images, + textures: &textures, + imageIndicesByKey: &imageIndicesByKey, + textureIndicesByKey: &textureIndicesByKey, + dependencyPaths: &dependencyPaths + ) { + material["emissiveTexture"] = ["index": emissiveTextureIndex] + material["emissiveFactor"] = props.vectorParameter(named: "Emissive Color")?.prefix(3).map { $0 } ?? [1.0, 1.0, 1.0] + } + + if let packedTexture = makeMetallicRoughnessTexture(from: props, dependencyPaths: &dependencyPaths), + let metallicRoughnessTextureIndex = textureIndex( + for: packedTexture, + images: &images, + textures: &textures, + imageIndicesByKey: &imageIndicesByKey, + textureIndicesByKey: &textureIndicesByKey + ) { + pbr["metallicRoughnessTexture"] = ["index": metallicRoughnessTextureIndex] + pbr["metallicFactor"] = 1.0 + pbr["roughnessFactor"] = 1.0 + } + + if let armeTextureIndex = textureIndex( + for: props.textureReference(exactNames: ["ARME Map"], containsKeywords: ["arme map"]), + images: &images, + textures: &textures, + imageIndicesByKey: &imageIndicesByKey, + textureIndicesByKey: &textureIndicesByKey, + dependencyPaths: &dependencyPaths + ) { + material["occlusionTexture"] = ["index": armeTextureIndex] + } + + switch props.blendMode { + case .masked: + material["alphaMode"] = "MASK" + if let opacityMaskClipValue = props.opacityMaskClipValue { + material["alphaCutoff"] = opacityMaskClipValue + } + case .translucent: + material["alphaMode"] = "BLEND" + case .opaque, .none: + break + } + + if let twoSided = props.twoSided { + material["doubleSided"] = twoSided + } + + material["pbrMetallicRoughness"] = pbr + materials[materialIndex] = material + } + + rootObject["materials"] = materials + if images.isEmpty { + rootObject.removeValue(forKey: "images") + } else { + rootObject["images"] = images + } + if textures.isEmpty { + rootObject.removeValue(forKey: "textures") + } else { + rootObject["textures"] = textures + } + + let dependencyURLs = dependencyPaths + .sorted() + .map { URL(fileURLWithPath: $0) } + return UnrealMaterialEnrichmentResult(additionalResourceURLs: dependencyURLs) + } + + private static func textureIndex( + for textureReference: UnrealTextureReference?, + images: inout [[String: Any]], + textures: inout [[String: Any]], + imageIndicesByKey: inout [String: Int], + textureIndicesByKey: inout [String: Int], + dependencyPaths: inout Set + ) -> Int? { + guard let textureReference else { + return nil + } + + if let fileURL = textureReference.source?.fileURL { + dependencyPaths.insert(fileURL.standardizedFileURL.path) + } + + return textureIndex( + for: MaterialTextureSource.textureReference(textureReference), + images: &images, + textures: &textures, + imageIndicesByKey: &imageIndicesByKey, + textureIndicesByKey: &textureIndicesByKey + ) + } + + private static func textureIndex( + for textureSource: MaterialTextureSource, + images: inout [[String: Any]], + textures: inout [[String: Any]], + imageIndicesByKey: inout [String: Int], + textureIndicesByKey: inout [String: Int] + ) -> Int? { + let imageKey = textureSource.cacheKey + let imageIndex: Int + + if let cachedImageIndex = imageIndicesByKey[imageKey] { + imageIndex = cachedImageIndex + } else { + let imageURI: String + switch textureSource { + case let .textureReference(textureReference): + guard let source = textureReference.source else { + return nil + } + switch source { + case let .file(fileURL): + imageURI = fileURL.standardizedFileURL.absoluteString + case let .solidGray(grayValue): + guard let generatedImageURI = makeSolidGrayDataURI(grayValue: grayValue) else { + return nil + } + imageURI = generatedImageURI + } + case let .dataURI(dataURI, _): + imageURI = dataURI + } + + images.append(["uri": imageURI]) + imageIndex = images.count - 1 + imageIndicesByKey[imageKey] = imageIndex + } + + let textureKey = "source:\(imageIndex)" + if let cachedTextureIndex = textureIndicesByKey[textureKey] { + return cachedTextureIndex + } + + textures.append(["source": imageIndex]) + let textureIndex = textures.count - 1 + textureIndicesByKey[textureKey] = textureIndex + return textureIndex + } + + private static func makeMetallicRoughnessTexture( + from props: UnrealMaterialProps, + dependencyPaths: inout Set + ) -> MaterialTextureSource? { + if let armeTexture = props.textureReference(exactNames: ["ARME Map"], containsKeywords: ["arme map"]) { + if let fileURL = armeTexture.source?.fileURL { + dependencyPaths.insert(fileURL.standardizedFileURL.path) + } + return .textureReference(armeTexture) + } + + let roughnessTexture = props.textureReference( + exactNames: ["Roughness", "Roughness Map"], + containsKeywords: ["roughness map"] + ) + let metallicTexture = props.textureReference( + exactNames: ["Metal", "Metal Map", "Metalness Map"], + containsKeywords: ["metal map", "metalness map"] + ) + + guard roughnessTexture != nil || metallicTexture != nil else { + return nil + } + + if let fileURL = roughnessTexture?.source?.fileURL { + dependencyPaths.insert(fileURL.standardizedFileURL.path) + } + if let fileURL = metallicTexture?.source?.fileURL { + dependencyPaths.insert(fileURL.standardizedFileURL.path) + } + + guard let packedDataURI = try? makePackedMetallicRoughnessDataURI( + roughnessSource: roughnessTexture?.source, + metallicSource: metallicTexture?.source + ) else { + return nil + } + + let cacheKey = [ + roughnessTexture?.unrealAssetPath ?? "roughness:none", + metallicTexture?.unrealAssetPath ?? "metal:none" + ].joined(separator: "|") + return .dataURI(packedDataURI, cacheKey: "packed:\(cacheKey)") + } + + private static func makeSolidGrayDataURI(grayValue: UInt8) -> String? { + guard let pngData = try? makePackedTexturePNGData( + width: 1, + height: 1, + greenChannel: [grayValue], + blueChannel: [grayValue] + ) else { + return nil + } + return "data:image/png;base64,\(pngData.base64EncodedString())" + } + + private static func makePackedMetallicRoughnessDataURI( + roughnessSource: UnrealResolvedTextureSource?, + metallicSource: UnrealResolvedTextureSource? + ) throws -> String { + let targetSize = try preferredTextureSize(roughnessSource: roughnessSource, metallicSource: metallicSource) + let roughnessChannel = try makeGrayscaleChannel( + from: roughnessSource, + width: targetSize.width, + height: targetSize.height, + defaultValue: 255 + ) + let metallicChannel = try makeGrayscaleChannel( + from: metallicSource, + width: targetSize.width, + height: targetSize.height, + defaultValue: 0 + ) + let pngData = try makePackedTexturePNGData( + width: targetSize.width, + height: targetSize.height, + greenChannel: roughnessChannel, + blueChannel: metallicChannel + ) + return "data:image/png;base64,\(pngData.base64EncodedString())" + } + + private static func preferredTextureSize( + roughnessSource: UnrealResolvedTextureSource?, + metallicSource: UnrealResolvedTextureSource? + ) throws -> (width: Int, height: Int) { + if let roughnessSource, case let .file(fileURL) = roughnessSource, + let size = try imageSize(for: fileURL) { + return size + } + if let metallicSource, case let .file(fileURL) = metallicSource, + let size = try imageSize(for: fileURL) { + return size + } + return (1, 1) + } + + private static func imageSize(for fileURL: URL) throws -> (width: Int, height: Int)? { + guard let imageSource = CGImageSourceCreateWithURL(fileURL as CFURL, nil), + let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [CFString: Any], + let width = properties[kCGImagePropertyPixelWidth] as? Int, + let height = properties[kCGImagePropertyPixelHeight] as? Int else { + return nil + } + return (width, height) + } + + private static func makeGrayscaleChannel( + from source: UnrealResolvedTextureSource?, + width: Int, + height: Int, + defaultValue: UInt8 + ) throws -> [UInt8] { + guard let source else { + return Array(repeating: defaultValue, count: width * height) + } + + switch source { + case let .solidGray(grayValue): + return Array(repeating: grayValue, count: width * height) + case let .file(fileURL): + let rgbaPixels = try loadRGBA8Pixels(from: fileURL, width: width, height: height) + return stride(from: 0, to: rgbaPixels.count, by: 4).map { rgbaPixels[$0] } + } + } + + private static func loadRGBA8Pixels(from fileURL: URL, width: Int, height: Int) throws -> [UInt8] { + guard let imageSource = CGImageSourceCreateWithURL(fileURL as CFURL, nil), + let image = CGImageSourceCreateImageAtIndex(imageSource, 0, nil) else { + throw CocoaError(.fileReadCorruptFile) + } + + let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB() + var pixels = [UInt8](repeating: 0, count: width * height * 4) + guard let context = CGContext( + data: &pixels, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: width * 4, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { + throw CocoaError(.coderInvalidValue) + } + + context.interpolationQuality = .high + context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) + return pixels + } + + private static func makePackedTexturePNGData( + width: Int, + height: Int, + greenChannel: [UInt8], + blueChannel: [UInt8] + ) throws -> Data { + var pixels = [UInt8](repeating: 255, count: width * height * 4) + for pixelIndex in 0..<(width * height) { + let byteOffset = pixelIndex * 4 + pixels[byteOffset] = 0 + pixels[byteOffset + 1] = greenChannel[pixelIndex] + pixels[byteOffset + 2] = blueChannel[pixelIndex] + pixels[byteOffset + 3] = 255 + } + + let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB() + guard let context = CGContext( + data: &pixels, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: width * 4, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ), let image = context.makeImage() else { + throw CocoaError(.coderInvalidValue) + } + + let data = NSMutableData() + guard let destination = CGImageDestinationCreateWithData(data, "public.png" as CFString, 1, nil) else { + throw CocoaError(.coderInvalidValue) + } + CGImageDestinationAddImage(destination, image, nil) + guard CGImageDestinationFinalize(destination) else { + throw CocoaError(.coderInvalidValue) + } + return data as Data + } +} + +private enum MaterialTextureSource { + case textureReference(UnrealTextureReference) + case dataURI(String, cacheKey: String) + + var cacheKey: String { + switch self { + case let .textureReference(textureReference): + return "texture:\(textureReference.unrealAssetPath)" + case let .dataURI(_, cacheKey): + return cacheKey + } + } +} diff --git a/ModernAppExtension/Shared/UnrealMaterialProps.swift b/ModernAppExtension/Shared/UnrealMaterialProps.swift new file mode 100644 index 0000000..64159e7 --- /dev/null +++ b/ModernAppExtension/Shared/UnrealMaterialProps.swift @@ -0,0 +1,312 @@ +import Foundation + +enum UnrealBlendMode { + case opaque + case masked + case translucent +} + +enum UnrealResolvedTextureSource: Hashable { + case file(URL) + case solidGray(UInt8) + + var fileURL: URL? { + switch self { + case let .file(url): + return url + case .solidGray: + return nil + } + } +} + +struct UnrealTextureReference: Hashable { + let parameterName: String + let unrealAssetPath: String + let source: UnrealResolvedTextureSource? +} + +struct UnrealMaterialProps { + let propsFileURL: URL + let parentMaterialPath: String? + let textureParameters: [String: UnrealTextureReference] + let vectorParameters: [String: [Double]] + let blendMode: UnrealBlendMode? + let twoSided: Bool? + let opacityMaskClipValue: Double? + + func textureReference( + exactNames: [String], + containsKeywords: [String] = [] + ) -> UnrealTextureReference? { + let exactLookup = Dictionary( + uniqueKeysWithValues: textureParameters.map { key, value in + (key.lowercased(), value) + } + ) + + for exactName in exactNames { + if let match = exactLookup[exactName.lowercased()] { + return match + } + } + + guard !containsKeywords.isEmpty else { + return nil + } + + let candidates = textureParameters + .sorted { $0.key < $1.key } + .map(\.value) + + for keyword in containsKeywords { + if let match = candidates.first(where: { $0.parameterName.lowercased().contains(keyword.lowercased()) }) { + return match + } + } + + return nil + } + + func vectorParameter(named parameterName: String) -> [Double]? { + vectorParameters.first { $0.key.caseInsensitiveCompare(parameterName) == .orderedSame }?.value + } +} + +struct UnrealMaterialLibrary { + private static let textureExtensions = ["png", "jpg", "jpeg", "webp", "tga", "bmp", "gif", "dds", "ktx", "ktx2", "basis"] + + private let exportRootURL: URL? + private let materialsRootURL: URL? + private let propsURLsByMaterialName: [String: URL] + private var parsedPropsCache: [String: UnrealMaterialProps] = [:] + + init(documentURL: URL) { + exportRootURL = Self.findAncestor(named: "Modèles exportés", from: documentURL) + + if let contentRootURL = Self.findAncestor(named: "Content", from: documentURL) { + let materialsRootCandidate = contentRootURL.appendingPathComponent("Materials", isDirectory: true) + if FileManager.default.fileExists(atPath: materialsRootCandidate.path) { + materialsRootURL = materialsRootCandidate + } else { + materialsRootURL = nil + } + } else { + materialsRootURL = nil + } + + propsURLsByMaterialName = Self.indexMaterialProps(in: materialsRootURL) + } + + mutating func props(forMaterialNamed materialName: String) -> UnrealMaterialProps? { + if let cached = parsedPropsCache[materialName] { + return cached + } + + guard let propsFileURL = propsURLsByMaterialName[materialName] else { + return nil + } + + guard let parsedProps = parseMaterialProps(at: propsFileURL) else { + return nil + } + + parsedPropsCache[materialName] = parsedProps + return parsedProps + } + + private static func findAncestor(named ancestorName: String, from url: URL) -> URL? { + var currentURL = url.deletingLastPathComponent() + while currentURL.path != "/" { + if currentURL.lastPathComponent == ancestorName { + return currentURL + } + currentURL.deleteLastPathComponent() + } + return nil + } + + private static func indexMaterialProps(in materialsRootURL: URL?) -> [String: URL] { + guard let materialsRootURL else { + return [:] + } + + var result: [String: URL] = [:] + guard let enumerator = FileManager.default.enumerator( + at: materialsRootURL, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles, .skipsPackageDescendants] + ) else { + return [:] + } + + for case let fileURL as URL in enumerator { + guard fileURL.lastPathComponent.hasSuffix(".props.txt") else { + continue + } + + let materialName = String(fileURL.lastPathComponent.dropLast(".props.txt".count)) + result[materialName] = fileURL + } + + return result + } + + private mutating func parseMaterialProps(at propsFileURL: URL) -> UnrealMaterialProps? { + guard let content = try? String(contentsOf: propsFileURL, encoding: .utf8) else { + return nil + } + + var parentMaterialPath: String? + var currentParameterName: String? + var textureParameters: [String: UnrealTextureReference] = [:] + var vectorParameters: [String: [Double]] = [:] + var blendMode: UnrealBlendMode? + var twoSided: Bool? + var opacityMaskClipValue: Double? + + for line in content.components(separatedBy: .newlines) { + if parentMaterialPath == nil, + let match = firstMatch(in: line, pattern: #"Parent = [^']*'([^']+)'"#) { + parentMaterialPath = match + continue + } + + if let match = firstMatch(in: line, pattern: #"ParameterInfo = \{ Name=(.+) \}"#) { + currentParameterName = match + continue + } + + if let parameterName = currentParameterName, + let match = firstMatch(in: line, pattern: #"ParameterValue = Texture2D'([^']+)'"#) { + textureParameters[parameterName] = UnrealTextureReference( + parameterName: parameterName, + unrealAssetPath: match, + source: resolveTextureSource(forUnrealAssetPath: match) + ) + continue + } + + if let parameterName = currentParameterName, + let values = parseVectorParameterValue(from: line) { + vectorParameters[parameterName] = values + continue + } + + if line.contains("BlendMode = BLEND_Masked") { + blendMode = .masked + continue + } + + if line.contains("BlendMode = BLEND_Translucent") { + blendMode = .translucent + continue + } + + if line.contains("BlendMode = BLEND_Opaque") { + blendMode = .opaque + continue + } + + if let match = firstMatch(in: line, pattern: #"TwoSided = (true|false)"#) { + twoSided = NSString(string: match).boolValue + continue + } + + if let match = firstMatch(in: line, pattern: #"OpacityMaskClipValue = ([0-9.]+)"#), + let parsedValue = Double(match) { + opacityMaskClipValue = parsedValue + } + } + + return UnrealMaterialProps( + propsFileURL: propsFileURL, + parentMaterialPath: parentMaterialPath, + textureParameters: textureParameters, + vectorParameters: vectorParameters, + blendMode: blendMode, + twoSided: twoSided, + opacityMaskClipValue: opacityMaskClipValue + ) + } + + private func resolveTextureSource(forUnrealAssetPath unrealAssetPath: String) -> UnrealResolvedTextureSource? { + if let fileURL = resolveTextureFileURL(forUnrealAssetPath: unrealAssetPath) { + return .file(fileURL) + } + + return resolveSolidTexture(forUnrealAssetPath: unrealAssetPath) + } + + private func resolveTextureFileURL(forUnrealAssetPath unrealAssetPath: String) -> URL? { + guard let exportRootURL else { + return nil + } + + let stemPath = NSString(string: unrealAssetPath).deletingPathExtension + let relativePath = stemPath.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + + for textureExtension in Self.textureExtensions { + let candidateURL = exportRootURL + .appendingPathComponent(relativePath) + .appendingPathExtension(textureExtension) + if FileManager.default.fileExists(atPath: candidateURL.path) { + return candidateURL.standardizedFileURL + } + } + + return nil + } + + private func resolveSolidTexture(forUnrealAssetPath unrealAssetPath: String) -> UnrealResolvedTextureSource? { + let textureName = URL(fileURLWithPath: NSString(string: unrealAssetPath).deletingPathExtension).lastPathComponent + guard let match = firstMatch(in: textureName, pattern: #"T_(\d+)_(\d+)_(\d+)$"#) else { + return nil + } + + let channels = match.split(separator: "_").compactMap { UInt8($0) } + guard let firstChannel = channels.first else { + return nil + } + + return .solidGray(firstChannel) + } + + private func parseVectorParameterValue(from line: String) -> [Double]? { + guard let match = firstMatch(in: line, pattern: #"\{ R=([^,]+), G=([^,]+), B=([^,]+), A=([^}]+) \}"#) else { + return nil + } + + return match + .split(separator: ",") + .compactMap { Double($0.trimmingCharacters(in: .whitespaces)) } + } + + private func firstMatch(in text: String, pattern: String) -> String? { + guard let regularExpression = try? NSRegularExpression(pattern: pattern) else { + return nil + } + + let range = NSRange(text.startIndex.. 1 else { + return nil + } + + let captures = (1.. String? in + guard let captureRange = Range(match.range(at: captureIndex), in: text) else { + return nil + } + return String(text[captureRange]) + } + return captures.joined(separator: ",") + } +} diff --git a/ModernAppExtension/Shared/VertexColorSanitizer.swift b/ModernAppExtension/Shared/VertexColorSanitizer.swift new file mode 100644 index 0000000..ce82dfc --- /dev/null +++ b/ModernAppExtension/Shared/VertexColorSanitizer.swift @@ -0,0 +1,258 @@ +import Foundation + +enum VertexColorSanitizer { + static func removeUniformRedVertexColors( + from rootObject: inout [String: Any], + relativeTo baseDirectoryURL: URL + ) { + guard var meshes = rootObject["meshes"] as? [[String: Any]], + let accessors = rootObject["accessors"] as? [[String: Any]], + let bufferViews = rootObject["bufferViews"] as? [[String: Any]], + let buffers = rootObject["buffers"] as? [[String: Any]] else { + return + } + + let sampler = AccessorColorSampler( + accessors: accessors, + bufferViews: bufferViews, + buffers: buffers, + baseDirectoryURL: baseDirectoryURL + ) + + var meshChanged = false + + for meshIndex in meshes.indices { + guard var primitives = meshes[meshIndex]["primitives"] as? [[String: Any]] else { + continue + } + + var primitiveChanged = false + + for primitiveIndex in primitives.indices { + guard var attributes = primitives[primitiveIndex]["attributes"] as? [String: Any], + let colorAccessorIndex = attributes["COLOR_0"] as? Int else { + continue + } + + guard sampler.isUniformPrimaryRedColor(accessorIndex: colorAccessorIndex) else { + continue + } + + attributes.removeValue(forKey: "COLOR_0") + primitives[primitiveIndex]["attributes"] = attributes + primitiveChanged = true + } + + if primitiveChanged { + meshes[meshIndex]["primitives"] = primitives + meshChanged = true + } + } + + if meshChanged { + rootObject["meshes"] = meshes + } + } +} + +private struct AccessorColorSampler { + let accessors: [[String: Any]] + let bufferViews: [[String: Any]] + let buffers: [[String: Any]] + let baseDirectoryURL: URL + + func isUniformPrimaryRedColor(accessorIndex: Int) -> Bool { + guard accessorIndex >= 0, accessorIndex < accessors.count else { + return false + } + + let accessor = accessors[accessorIndex] + guard let accessorBufferViewIndex = accessor["bufferView"] as? Int, + accessorBufferViewIndex >= 0, + accessorBufferViewIndex < bufferViews.count else { + return false + } + + let componentType = accessor["componentType"] as? Int ?? -1 + let accessorType = accessor["type"] as? String ?? "" + let componentCount = vectorComponentCount(for: accessorType) + guard componentCount == 3 || componentCount == 4 else { + return false + } + + guard let format = componentFormat(for: componentType) else { + return false + } + + let bufferView = bufferViews[accessorBufferViewIndex] + guard let bufferIndex = bufferView["buffer"] as? Int, + bufferIndex >= 0, + bufferIndex < buffers.count else { + return false + } + + let buffer = buffers[bufferIndex] + guard let bufferURI = buffer["uri"] as? String, + let bufferURL = GLTFDocumentInliner.localResourceURL(for: bufferURI, relativeTo: baseDirectoryURL), + let rawData = try? Data(contentsOf: bufferURL) else { + return false + } + + let count = accessor["count"] as? Int ?? 0 + guard count > 0 else { + return false + } + + let normalized = accessor["normalized"] as? Bool ?? false + let bufferViewOffset = bufferView["byteOffset"] as? Int ?? 0 + let accessorOffset = accessor["byteOffset"] as? Int ?? 0 + let stride = bufferView["byteStride"] as? Int ?? (format.size * componentCount) + let startOffset = bufferViewOffset + accessorOffset + let sampleIndices = sampledIndices(count: count, maxSamples: 16) + + var referenceColor: [Double]? + + for sampleIndex in sampleIndices { + let byteOffset = startOffset + (sampleIndex * stride) + guard let color = readColor( + data: rawData, + byteOffset: byteOffset, + componentCount: componentCount, + format: format, + normalized: normalized + ) else { + return false + } + + if let referenceColor { + guard approximatelyEqual(color, referenceColor) else { + return false + } + } else { + referenceColor = color + } + } + + guard let referenceColor else { + return false + } + + return isPrimaryRed(referenceColor) + } + + private func readColor( + data: Data, + byteOffset: Int, + componentCount: Int, + format: ComponentFormat, + normalized: Bool + ) -> [Double]? { + let totalSize = format.size * componentCount + guard byteOffset >= 0, byteOffset + totalSize <= data.count else { + return nil + } + + return data.withUnsafeBytes { rawBuffer in + guard let baseAddress = rawBuffer.baseAddress?.advanced(by: byteOffset) else { + return nil + } + + switch format { + case .uint8: + let pointer = baseAddress.assumingMemoryBound(to: UInt8.self) + return (0.. Bool { + guard color.count >= 3 else { + return false + } + + let red = color[0] + let green = color[1] + let blue = color[2] + let alpha = color.count >= 4 ? color[3] : 1.0 + + return red >= 0.99 && + green <= 0.01 && + blue <= 0.01 && + alpha >= 0.99 + } + + private func approximatelyEqual(_ lhs: [Double], _ rhs: [Double], tolerance: Double = 0.002) -> Bool { + guard lhs.count == rhs.count else { + return false + } + + for (leftValue, rightValue) in zip(lhs, rhs) { + if abs(leftValue - rightValue) > tolerance { + return false + } + } + return true + } + + private func sampledIndices(count: Int, maxSamples: Int) -> [Int] { + guard count > maxSamples else { + return Array(0.. Int { + switch type { + case "SCALAR": + return 1 + case "VEC2": + return 2 + case "VEC3": + return 3 + case "VEC4": + return 4 + default: + return 0 + } + } + + private func componentFormat(for componentType: Int) -> ComponentFormat? { + switch componentType { + case 5121: + return .uint8 + case 5123: + return .uint16 + case 5126: + return .float32 + default: + return nil + } + } +} + +private enum ComponentFormat { + case uint8 + case uint16 + case float32 + + var size: Int { + switch self { + case .uint8: + return 1 + case .uint16: + return 2 + case .float32: + return 4 + } + } +} diff --git a/ModernAppExtension/ThumbnailExtension/Info.plist b/ModernAppExtension/ThumbnailExtension/Info.plist index e304708..95edc18 100644 --- a/ModernAppExtension/ThumbnailExtension/Info.plist +++ b/ModernAppExtension/ThumbnailExtension/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType XPC! CFBundleShortVersionString - 1.0 + $(MARKETING_VERSION) CFBundleVersion - 1 + $(CURRENT_PROJECT_VERSION) NSExtension NSExtensionAttributes diff --git a/ModernAppExtension/ThumbnailExtension/Thumbnail.entitlements b/ModernAppExtension/ThumbnailExtension/Thumbnail.entitlements new file mode 100644 index 0000000..852fa1a --- /dev/null +++ b/ModernAppExtension/ThumbnailExtension/Thumbnail.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/ModernAppExtension/ThumbnailExtension/ThumbnailProvider.swift b/ModernAppExtension/ThumbnailExtension/ThumbnailProvider.swift index f1c9780..8b2efc8 100644 --- a/ModernAppExtension/ThumbnailExtension/ThumbnailProvider.swift +++ b/ModernAppExtension/ThumbnailExtension/ThumbnailProvider.swift @@ -1,9 +1,12 @@ import Cocoa import QuickLookThumbnailing +import OSLog import SceneKit -import GLTFSceneKit class ThumbnailProvider: QLThumbnailProvider { + private let logger = Logger(subsystem: "com.hectorlizard.GLTFQuickLook", category: "Thumbnail") + private static let renderSemaphore = DispatchSemaphore(value: 1) + private static let denseSceneMaxRenderDimension: CGFloat = 512 override func provideThumbnail(for request: QLFileThumbnailRequest, _ handler: @escaping (QLThumbnailReply?, Error?) -> Void) { let size = request.maximumSize @@ -11,27 +14,70 @@ class ThumbnailProvider: QLThumbnailProvider { // Use a QLThumbnailReply with drawing context to correctly handle scaling let reply = QLThumbnailReply(contextSize: size) { () -> Bool in guard let context = NSGraphicsContext.current?.cgContext else { return false } - - do { - let source = try GLTFSceneSource(url: request.fileURL) - let scene = try source.scene() - - let renderer = SCNRenderer(device: nil, options: nil) - renderer.scene = scene - renderer.autoenablesDefaultLighting = true - - let image = renderer.snapshot(atTime: 0.0, with: size, antialiasingMode: .multisampling4X) - - if let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) { - context.draw(cgImage, in: CGRect(origin: .zero, size: size)) - return true + + Self.renderSemaphore.wait() + defer { Self.renderSemaphore.signal() } + + return autoreleasepool { + do { + let loadedScene = try SceneLoadCoordinator.loadScene(at: request.fileURL) + let renderScene = SceneLoadCoordinator.optimizedRenderScene(from: loadedScene, purpose: "thumbnail") + let renderSize = self.denseSceneRenderSize(for: size, isDenseScene: renderScene.isDenseScene) + let antialiasingMode: SCNAntialiasingMode = renderScene.isDenseScene ? .none : .multisampling4X + + self.logger.notice( + """ + Loaded thumbnail scene for \(request.fileURL.lastPathComponent, privacy: .public) geometry=\(renderScene.geometryNodeCount, privacy: .public) cameras=\(renderScene.cameraNodeCount, privacy: .public) dense=\(renderScene.isDenseScene, privacy: .public) renderSize=\(Int(renderSize.width), privacy: .public)x\(Int(renderSize.height), privacy: .public) + """ + ) + + let renderer = SCNRenderer(device: nil, options: nil) + renderer.scene = renderScene.scene + renderer.autoenablesDefaultLighting = true + renderer.pointOfView = renderScene.pointOfView + + let image = renderer.snapshot(atTime: 0.0, with: renderSize, antialiasingMode: antialiasingMode) + renderer.scene = nil + + if let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) { + context.draw(cgImage, in: CGRect(origin: .zero, size: size)) + self.logger.notice("Thumbnail rendered via direct cgImage for \(request.fileURL.lastPathComponent, privacy: .public)") + return true + } + + if let tiffRepresentation = image.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiffRepresentation), + let cgImage = bitmap.cgImage { + context.draw(cgImage, in: CGRect(origin: .zero, size: size)) + self.logger.notice("Thumbnail rendered via TIFF fallback for \(request.fileURL.lastPathComponent, privacy: .public)") + return true + } + + self.logger.error("Thumbnail snapshot did not yield drawable image data for \(request.fileURL.path, privacy: .public)") + } catch { + self.logger.error("Thumbnail failed for \(request.fileURL.path, privacy: .public): \(error.localizedDescription, privacy: .public)") } - } catch { - print("Error generating thumbnail: \(error)") + return false } - return false } handler(reply, nil) } + + private func denseSceneRenderSize(for requestedSize: CGSize, isDenseScene: Bool) -> CGSize { + guard isDenseScene else { + return requestedSize + } + + let largestDimension = max(requestedSize.width, requestedSize.height) + guard largestDimension > Self.denseSceneMaxRenderDimension else { + return requestedSize + } + + let scale = Self.denseSceneMaxRenderDimension / largestDimension + return CGSize( + width: max(1, floor(requestedSize.width * scale)), + height: max(1, floor(requestedSize.height * scale)) + ) + } } diff --git a/ModernAppExtension/Tools/CachePrep/main.swift b/ModernAppExtension/Tools/CachePrep/main.swift new file mode 100644 index 0000000..d75a307 --- /dev/null +++ b/ModernAppExtension/Tools/CachePrep/main.swift @@ -0,0 +1,27 @@ +import Foundation +import OSLog + +let logger = Logger(subsystem: "com.hectorlizard.GLTFQuickLook", category: "CachePrepTool") +let arguments = Array(CommandLine.arguments.dropFirst()) + +guard !arguments.isEmpty else { + fputs("Usage: GLTFCachePrep [ ...]\n", stderr) + exit(64) +} + +let urls = arguments.map { URL(fileURLWithPath: $0) } +let summary = PreparedDocumentPreparer.prepare(urls: urls, logger: logger) + +print("") +print("Folders scanned: \(summary.foldersScanned)") +print("glTF files seen: \(summary.documentsSeen)") +print("Prepared caches: \(summary.documentsPrepared)") +print("Skipped caches: \(summary.documentsSkipped)") +print("Failures: \(summary.failures.count)") + +if !summary.failures.isEmpty { + print("") + print("First failure:") + print(summary.failures[0]) + exit(1) +} diff --git a/ModernAppExtension/project.yml b/ModernAppExtension/project.yml index a4ca89d..21cd9b2 100644 --- a/ModernAppExtension/project.yml +++ b/ModernAppExtension/project.yml @@ -3,23 +3,39 @@ options: bundleIdPrefix: com.hectorlizard settings: CODE_SIGN_IDENTITY: "-" - MARKETING_VERSION: "1.0" - CURRENT_PROJECT_VERSION: "1" + CODE_SIGN_INJECT_BASE_ENTITLEMENTS: NO + MARKETING_VERSION: "1.1.0" + CURRENT_PROJECT_VERSION: "2" packages: GLTFSceneKit: url: https://github.com/magicien/GLTFSceneKit.git - branch: master + revision: a9587ec5c6515ee7d036533fdd909815a9a718ef targets: GLTFQuickLook: type: application platform: macOS deploymentTarget: "12.0" - sources: App + sources: + - path: App + - path: Shared + includes: + - GLTFDocumentInliner.swift + - OversizedAnimationOptimizer.swift + - SkinWeightNormalizer.swift + - PreparedDocumentAttributeStore.swift + - PreparedDocumentPreparationOptions.swift + - PreparedDocumentFileCache.swift + - PreparedDocumentPreparer.swift + - UnrealMaterialEnricher.swift + - UnrealMaterialProps.swift + - VertexColorSanitizer.swift settings: PRODUCT_BUNDLE_IDENTIFIER: com.hectorlizard.GLTFQuickLook info: path: App/Info.plist properties: + CFBundleShortVersionString: $(MARKETING_VERSION) + CFBundleVersion: $(CURRENT_PROJECT_VERSION) LSUIElement: true UTExportedTypeDeclarations: - UTTypeIdentifier: org.khronos.gltf @@ -38,18 +54,42 @@ targets: - target: GLTFPreview - target: GLTFThumbnail + GLTFCachePrep: + type: tool + platform: macOS + deploymentTarget: "12.0" + sources: + - path: Tools/CachePrep + - path: Shared + includes: + - GLTFDocumentInliner.swift + - OversizedAnimationOptimizer.swift + - SkinWeightNormalizer.swift + - PreparedDocumentAttributeStore.swift + - PreparedDocumentPreparationOptions.swift + - PreparedDocumentFileCache.swift + - PreparedDocumentPreparer.swift + - UnrealMaterialEnricher.swift + - UnrealMaterialProps.swift + - VertexColorSanitizer.swift + GLTFPreview: type: app-extension platform: macOS deploymentTarget: "12.0" - sources: PreviewExtension + sources: + - path: PreviewExtension + - path: Shared settings: PRODUCT_BUNDLE_IDENTIFIER: com.hectorlizard.GLTFQuickLook.GLTFPreview ENABLE_HARDENED_RUNTIME: YES ENABLE_APP_SANDBOX: YES + CODE_SIGN_ENTITLEMENTS: PreviewExtension/Preview.entitlements info: path: PreviewExtension/Info.plist properties: + CFBundleShortVersionString: $(MARKETING_VERSION) + CFBundleVersion: $(CURRENT_PROJECT_VERSION) CFBundleDisplayName: GLTF Preview NSExtension: NSExtensionAttributes: @@ -64,14 +104,19 @@ targets: type: app-extension platform: macOS deploymentTarget: "12.0" - sources: ThumbnailExtension + sources: + - path: ThumbnailExtension + - path: Shared settings: PRODUCT_BUNDLE_IDENTIFIER: com.hectorlizard.GLTFQuickLook.GLTFThumbnail ENABLE_HARDENED_RUNTIME: YES ENABLE_APP_SANDBOX: YES + CODE_SIGN_ENTITLEMENTS: ThumbnailExtension/Thumbnail.entitlements info: path: ThumbnailExtension/Info.plist properties: + CFBundleShortVersionString: $(MARKETING_VERSION) + CFBundleVersion: $(CURRENT_PROJECT_VERSION) CFBundleDisplayName: GLTF Thumbnail NSExtension: NSExtensionAttributes: diff --git a/README.md b/README.md index 54c23eb..8a3f3c1 100644 --- a/README.md +++ b/README.md @@ -1,51 +1,106 @@ # GLTFQuickLook -macOS QuickLook plugin for glTF files. (.gltf/.glb) + +Finder thumbnails and interactive Quick Look previews for `.gltf` and `.glb` +files on modern macOS. ![GLTFQuickLook preview](gltf.gif) -## System Requirements +## Modern Extension -- macOS 10.13 (High Sierra) or later +The modern application is the recommended version for macOS 12 and later. The +current downloadable build targets Apple Silicon. It embeds both a Quick Look +preview extension and a Finder thumbnail extension. -> **Note for macOS 10.15+ and Apple Silicon**: Apple has deprecated `.qlgenerator` plugins and they are fully unsupported in macOS 15 (Sequoia). There are now two versions of this project available: -> - **Legacy** (`.qlgenerator`): For macOS 10.13 and 10.14 (see instructions below). -> - **Modern** (App Extension): For macOS 10.15+, including native Apple Silicon support. See the [ModernAppExtension folder](ModernAppExtension/) for details. +Highlights: -## Install (Modern - macOS 10.15+) +- Interactive SceneKit previews with a native transparent Quick Look background. +- Finder thumbnails for binary GLB and sidecar-based glTF documents. +- Automatic preparation of downloaded and user-selected glTF folders. +- Support for dense Sketchfab exports and large uModel documents. +- Optional Unreal material reconstruction from sibling `.props.txt` and texture files. +- Oversized animated exports retain their first 10 animation clips in the prepared cache. +- Source models are never rewritten; compatibility data is stored in extended attributes. -1. Download the latest `GLTFQuickLook.app` release from [Releases](https://github.com/magicien/GLTFQuickLook/releases/latest). -2. Drag and drop `GLTFQuickLook.app` into your `/Applications` folder. -3. Open the app **once** (it will casually launch and exit, registering the extension with macOS). -4. Run `qlmanage -r` to reload QuickLook plugins. +## Install -## Install (Legacy - macOS 10.13, 10.14) +The beta download is ad hoc signed rather than Apple-notarized. macOS may show a +warning because the build does not come from the App Store or an identified +developer. -### Using [Homebrew Cask](https://github.com/phinze/homebrew-cask) +1. Download `GLTFQuickLook-1.1.0-beta.1-macos-arm64.zip` from this repository's + [Releases](https://github.com/Hectorlizard/GLTFQuickLook/releases) page. +2. Move `GLTFQuickLook.app` to `/Applications`. +3. Open the app. If macOS blocks it, open **System Settings > Privacy & Security**, + click **Open Anyway**, then confirm. +4. If Quick Look still refuses to load the extensions, run: -- Run `brew install gltfquicklook` -- Run `xattr -r -d com.apple.quarantine ~/Library/QuickLook/GLTFQuickLook.qlgenerator` to allow GLTFQuickLook.qlgenerator to run. + ```bash + xattr -dr com.apple.quarantine /Applications/GLTFQuickLook.app + open /Applications/GLTFQuickLook.app + ``` -### Manually +5. Keep the `GLTFQL` menu-bar app running. `~/Downloads` is monitored + automatically; use **Ajouter des dossiers...** in the menu for other model libraries. -1. Download **GLTFQuickLook_vX.X.X.zip** from [Releases](https://github.com/magicien/GLTFQuickLook/releases/latest). -2. Put **GLTFQuickLook.qlgenerator** (in the zip file) into `/Library/QuickLook` (for all users) or `~/Library/QuickLook` (only for the logged-in user). -3. Run `sudo xattr -r -d com.apple.quarantine /Library/QuickLook/GLTFQuickLook.qlgenerator` or `xattr -r -d com.apple.quarantine ~/Library/QuickLook/GLTFQuickLook.qlgenerator` to allow GLTFQuickLook.qlgenerator to run. -4. Run `qlmanage -r` command to reload QuickLook plugins. +Select a `.gltf` or `.glb` file in Finder and press Space. Thumbnail preparation +can take a little while when a folder contains many large models. -## Build +## Privacy and Storage + +GLTFQuickLook works locally and does not upload models or usage data. The host app +monitors `~/Downloads` plus folders explicitly selected by the user. For sidecar +glTF documents, it stores a self-contained prepared copy as an extended attribute +on the source `.gltf`; the original file and its animation set remain unchanged. + +Extended attributes work well on APFS and most local macOS volumes, but some +network, cloud, archive, or non-Mac filesystems can remove them or impose a size +limit. In that case the original model can still be opened by other software, but +the prepared Quick Look cache may need to be regenerated. -### Modern App Extension (Recommended for macOS 12+) -See the [ModernAppExtension/README.md](ModernAppExtension/README.md) for build instructions using `xcodegen` and Swift Package Manager. +## Troubleshooting -### Legacy (.qlgenerator) -It needs to install [Carthage](https://github.com/Carthage/Carthage) to get frameworks. +If previews disappear after a macOS update or after replacing the app: + +```bash +open /Applications/GLTFQuickLook.app +qlmanage -r +killall Finder ``` -$ git clone https://github.com/magicien/GLTFQuickLook.git -$ cd GLTFQuickLook -$ carthage bootstrap --platform mac -$ xcodebuild + +Then use **Reanalyser les dossiers** from the `GLTFQL` menu. Avoid installing two +copies of the app at the same time because macOS may register the wrong extension +bundle. + +## Build + +The modern project requires Xcode, Swift Package Manager, and +[XcodeGen](https://github.com/yonaskolb/XcodeGen). GLTFSceneKit is pinned to the +revision validated for this release. + +```bash +brew install xcodegen +xcodegen generate --spec ModernAppExtension/project.yml +xcodebuild \ + -project ModernAppExtension/GLTFQuickLook.xcodeproj \ + -scheme GLTFQuickLook \ + -configuration Release \ + build ``` -## See also +Run `Scripts/package-release.sh` to produce the same ad hoc signed Apple Silicon +ZIP and SHA-256 checksum used by the GitHub release workflow. + +## Legacy Plugin + +The original `.qlgenerator` implementation remains in this repository for older +macOS versions. Apple no longer supports that extension mechanism on current +macOS releases. See the [original project](https://github.com/magicien/GLTFQuickLook) +for its historical installation instructions. + +## Credits + +GLTFQuickLook was created by [magicien](https://github.com/magicien). The modern +App Extension and current compatibility work are maintained by +[Hectorlizard](https://github.com/Hectorlizard). -- [GLTFSceneKit](https://github.com/magicien/GLTFSceneKit/) - glTF loader for SceneKit +Released under the [MIT License](LICENSE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..a6ade73 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,15 @@ +# Security Policy + +## Supported Version + +Security fixes are applied to the latest modern App Extension release. The legacy +`.qlgenerator` is retained for historical use and is not actively maintained. + +## Reporting a Vulnerability + +Please use GitHub's private **Report a vulnerability** form in the Security tab of +this repository. Do not attach private or licensed 3D assets to a public issue. + +GLTFQuickLook parses untrusted model files inside macOS Quick Look processes. A +report should include the affected release, macOS version, reproduction steps, and +a minimal redistributable test model when possible. diff --git a/Scripts/check-release-hygiene.sh b/Scripts/check-release-hygiene.sh new file mode 100755 index 0000000..3a1f6ed --- /dev/null +++ b/Scripts/check-release-hygiene.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +set -euo pipefail + +cd "$(git rev-parse --show-toplevel)" + +failed=0 + +if git ls-files -z | grep -zE '(^|/)\.DS_Store$' >/dev/null; then + echo "error: a .DS_Store file is tracked" + failed=1 +fi + +if git grep -nIE '(/Users/|/Volumes/|file:///)' -- \ + . \ + ':!Scripts/check-release-hygiene.sh' \ + ':!Scripts/package-release.sh'; then + echo "error: a tracked file contains a machine-specific absolute path" + failed=1 +fi + +if git grep -nIE '(BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY|API[_-]?KEY[[:space:]]*=)' -- .; then + echo "error: a tracked file resembles a credential" + failed=1 +fi + +if (( failed != 0 )); then + exit 1 +fi + +echo "Release hygiene checks passed." diff --git a/Scripts/package-release.sh b/Scripts/package-release.sh new file mode 100755 index 0000000..30460bd --- /dev/null +++ b/Scripts/package-release.sh @@ -0,0 +1,88 @@ +#!/bin/bash + +set -euo pipefail + +ROOT_DIR="$(git rev-parse --show-toplevel)" +PROJECT_DIR="$ROOT_DIR/ModernAppExtension" +if [[ -z "${DERIVED_DATA:-}" ]]; then + DERIVED_DATA=$(mktemp -d "${TMPDIR:-/tmp}/GLTFQuickLookRelease.XXXXXX") +fi +DIST_DIR="${DIST_DIR:-$ROOT_DIR/dist}" +VERSION="${VERSION:-1.1.0-beta.1}" +BUNDLE_VERSION="${BUNDLE_VERSION:-${VERSION%%-*}}" +ARCH="${ARCH:-arm64}" +ARTIFACT_NAME="GLTFQuickLook-${VERSION}-macos-${ARCH}" +TEMP_ARCHIVE="$DIST_DIR/.${ARTIFACT_NAME}.$$.zip" +TEMP_CHECKSUM="$DIST_DIR/.${ARTIFACT_NAME}.$$.zip.sha256" + +command -v xcodegen >/dev/null || { + echo "error: xcodegen is required (brew install xcodegen)" >&2 + exit 1 +} + +"$ROOT_DIR/Scripts/check-release-hygiene.sh" + +mkdir -p "$DERIVED_DATA" "$DIST_DIR" + +xcodegen generate --spec "$PROJECT_DIR/project.yml" + +xcodebuild \ + -project "$PROJECT_DIR/GLTFQuickLook.xcodeproj" \ + -scheme GLTFQuickLook \ + -configuration Release \ + -derivedDataPath "$DERIVED_DATA" \ + ARCHS="$ARCH" \ + ONLY_ACTIVE_ARCH=NO \ + CODE_SIGN_IDENTITY=- \ + build + +APP_PATH="$DERIVED_DATA/Build/Products/Release/GLTFQuickLook.app" +test -d "$APP_PATH" + +codesign --verify --deep --strict "$APP_PATH" + +for bundle in \ + "$APP_PATH" \ + "$APP_PATH/Contents/PlugIns/GLTFPreview.appex" \ + "$APP_PATH/Contents/PlugIns/GLTFThumbnail.appex"; do + actual_version=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$bundle/Contents/Info.plist") + if [[ "$actual_version" != "$BUNDLE_VERSION" ]]; then + echo "error: $bundle has version $actual_version, expected $BUNDLE_VERSION" >&2 + exit 1 + fi +done + +for extension in \ + "$APP_PATH/Contents/PlugIns/GLTFPreview.appex" \ + "$APP_PATH/Contents/PlugIns/GLTFThumbnail.appex"; do + entitlements=$(codesign -d --entitlements :- "$extension" 2>/dev/null) + grep -q 'com.apple.security.app-sandbox' <<< "$entitlements" + if grep -q 'com.apple.security.get-task-allow' <<< "$entitlements"; then + echo "error: debug entitlement found in $extension" >&2 + exit 1 + fi +done + +while IFS= read -r -d '' binary; do + if strings "$binary" | grep -Eq '(/Users/|/Volumes/)'; then + echo "error: build-machine path found in $binary" >&2 + exit 1 + fi +done < <(find "$APP_PATH" -type f -perm -111 -print0) + +ditto -c -k --keepParent --norsrc --noextattr --noqtn --noacl \ + "$APP_PATH" "$TEMP_ARCHIVE" + +if unzip -Z1 "$TEMP_ARCHIVE" | grep -Eq '(^|/)(\.DS_Store|__MACOSX)(/|$)'; then + echo "error: archive contains Finder metadata" >&2 + exit 1 +fi + +checksum=$(shasum -a 256 "$TEMP_ARCHIVE" | awk '{print $1}') +printf '%s %s\n' "$checksum" "$ARTIFACT_NAME.zip" > "$TEMP_CHECKSUM" +mv -f "$TEMP_ARCHIVE" "$DIST_DIR/$ARTIFACT_NAME.zip" +mv -f "$TEMP_CHECKSUM" "$DIST_DIR/$ARTIFACT_NAME.zip.sha256" + +echo "Created:" +echo " $DIST_DIR/$ARTIFACT_NAME.zip" +echo " $DIST_DIR/$ARTIFACT_NAME.zip.sha256"