Skip to content

Commit 819c4bf

Browse files
committed
feat: Implement Model Management System
- Added ModelManager for handling model loading, saving, and management. - Introduced ModelMetaLoader for loading model metadata based on different formats. - Created ModelsMeta struct to manage model information and metadata storage. - Updated URL extension methods to use a new appending method for path components. - Enhanced VCamSceneDataStore and other components to utilize the new URL methods. - Added ModelListView for displaying and managing models in the UI. - Implemented localization for model management actions and messages. - Refactored migration logic to support new model management features. - Updated various UI components to integrate model loading and management functionalities.
1 parent 174d2e7 commit 819c4bf

File tree

16 files changed

+817
-67
lines changed

16 files changed

+817
-67
lines changed
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import AppKit
2+
import Observation
3+
import VCamEntity
4+
5+
@Observable
6+
public final class ModelManager {
7+
public static let shared = ModelManager()
8+
9+
public private(set) var models: [ModelsMeta.ModelInfo] = []
10+
public private(set) var lastLoadedModelId: UUID?
11+
12+
private let fileManager = FileManager.default
13+
14+
private init() {
15+
loadMeta()
16+
validateModels()
17+
}
18+
19+
#if DEBUG
20+
public init(models: [ModelsMeta.ModelInfo], lastLoadedModelId: UUID? = nil) {
21+
self.models = models
22+
self.lastLoadedModelId = lastLoadedModelId
23+
}
24+
#endif
25+
26+
public var lastLoadedModel: ModelsMeta.ModelInfo? {
27+
guard let id = lastLoadedModelId else { return nil }
28+
return models.first { $0.id == id }
29+
}
30+
31+
public func setLastLoadedModel(_ model: ModelsMeta.ModelInfo) {
32+
lastLoadedModelId = model.id
33+
saveMeta()
34+
}
35+
36+
@MainActor
37+
public func saveModel(from source: URL, name: String? = nil) async throws -> ModelsMeta.ModelInfo {
38+
#if FEATURE_3
39+
let baseName = name ?? source.deletingPathExtension().lastPathComponent
40+
#else
41+
let baseName = name ?? source.lastPathComponent
42+
#endif
43+
let directoryName = generateUniqueDirectoryName(baseName: baseName)
44+
let modelDirectory = ModelsMeta.modelDirectory(ofName: directoryName)
45+
try fileManager.createDirectoryIfNeeded(at: modelDirectory)
46+
47+
let destinationURL = modelDirectory.appending(path: ModelsMeta.modelFileName)
48+
if fileManager.fileExists(atPath: destinationURL.path) {
49+
try fileManager.removeItem(at: destinationURL)
50+
}
51+
try fileManager.copyItem(at: source, to: destinationURL)
52+
53+
let modelInfo = ModelsMeta.ModelInfo(name: directoryName, type: ModelsMeta.modelType)
54+
await saveThumbnail(for: modelInfo)
55+
addModel(modelInfo)
56+
return modelInfo
57+
}
58+
59+
@MainActor
60+
private func saveThumbnail(for model: ModelsMeta.ModelInfo) async {
61+
let metadata = await Task.detached(priority: .utility) {
62+
try? ModelMetaLoader.load(from: model.modelURL)
63+
}.value
64+
65+
if let image = metadata?.image {
66+
saveThumbnail(image, for: model)
67+
}
68+
}
69+
70+
public func deleteModel(_ model: ModelsMeta.ModelInfo) throws {
71+
let modelDirectory = model.rootURL
72+
if fileManager.fileExists(atPath: modelDirectory.path) {
73+
try fileManager.removeItem(at: modelDirectory)
74+
}
75+
removeModel(model)
76+
}
77+
78+
@MainActor
79+
public func duplicateModel(_ model: ModelsMeta.ModelInfo) async throws -> ModelsMeta.ModelInfo {
80+
guard model.status == .valid else {
81+
throw ModelManagerError.modelURLNotFound
82+
}
83+
return try await saveModel(from: model.modelURL, name: "\(model.name)_copy")
84+
}
85+
86+
public func moveModel(fromOffsets source: IndexSet, toOffset destination: Int) {
87+
models.move(fromOffsets: source, toOffset: destination)
88+
saveMeta()
89+
}
90+
91+
public func renameModel(_ model: ModelsMeta.ModelInfo, to newName: String) {
92+
let trimmedName = newName.trimmingCharacters(in: .whitespacesAndNewlines)
93+
guard !trimmedName.isEmpty, trimmedName != model.localizedName else { return }
94+
95+
if let index = models.firstIndex(where: { $0.id == model.id }) {
96+
models[index].displayName = trimmedName
97+
}
98+
saveMeta()
99+
}
100+
101+
public func refresh() {
102+
validateModels()
103+
}
104+
105+
private func generateUniqueDirectoryName(baseName: String) -> String {
106+
var name = baseName
107+
var counter = 1
108+
while models.contains(where: { $0.name == name }) || fileManager.fileExists(atPath: ModelsMeta.modelDirectory(ofName: name).path) {
109+
counter += 1
110+
name = "\(baseName)_\(counter)"
111+
}
112+
return name
113+
}
114+
115+
private func validateModels() {
116+
for i in models.indices {
117+
let url = models[i].modelURL
118+
models[i].status = fileManager.fileExists(atPath: url.path) ? .valid : .missing
119+
}
120+
scanForNewModels()
121+
saveMeta()
122+
}
123+
124+
private func scanForNewModels() {
125+
guard fileManager.fileExists(atPath: ModelsMeta.modelsDirectory.path) else { return }
126+
127+
do {
128+
let contents = try fileManager.contentsOfDirectory(
129+
at: ModelsMeta.modelsDirectory,
130+
includingPropertiesForKeys: [.creationDateKey],
131+
options: [.skipsHiddenFiles]
132+
)
133+
134+
let existingNames = Set(models.map { $0.name })
135+
136+
for directory in contents {
137+
let name = directory.lastPathComponent
138+
guard name != "meta.json", !existingNames.contains(name) else { continue }
139+
140+
let modelFile = directory.appending(path: ModelsMeta.modelFileName)
141+
guard fileManager.fileExists(atPath: modelFile.path) else { continue }
142+
143+
let attributes = try? fileManager.attributesOfItem(atPath: modelFile.path)
144+
let createdAt = attributes?[.creationDate] as? Date ?? Date()
145+
let modelInfo = ModelsMeta.ModelInfo(name: name, type: ModelsMeta.modelType, createdAt: createdAt, status: .valid)
146+
models.append(modelInfo)
147+
}
148+
} catch {
149+
print("Failed to scan models: \(error)")
150+
}
151+
}
152+
153+
private func addModel(_ model: ModelsMeta.ModelInfo) {
154+
guard !models.contains(where: { $0.id == model.id }) else { return }
155+
models.insert(model, at: 0)
156+
saveMeta()
157+
}
158+
159+
private func removeModel(_ model: ModelsMeta.ModelInfo) {
160+
models.removeAll { $0.id == model.id }
161+
if lastLoadedModelId == model.id {
162+
lastLoadedModelId = nil
163+
}
164+
saveMeta()
165+
}
166+
167+
private func loadMeta() {
168+
guard fileManager.fileExists(atPath: ModelsMeta.metaURL.path),
169+
let data = try? Data(contentsOf: ModelsMeta.metaURL),
170+
let meta = try? JSONDecoder().decode(ModelsMeta.self, from: data) else {
171+
return
172+
}
173+
models = meta.models
174+
lastLoadedModelId = meta.lastModelId
175+
}
176+
177+
private func saveMeta() {
178+
do {
179+
try fileManager.createDirectoryIfNeeded(at: ModelsMeta.modelsDirectory)
180+
let meta = ModelsMeta(models: models, lastModelId: lastLoadedModelId)
181+
let encoder = JSONEncoder()
182+
let data = try encoder.encode(meta)
183+
try data.write(to: ModelsMeta.metaURL)
184+
} catch {
185+
print("Failed to save meta: \(error)")
186+
}
187+
}
188+
189+
private func saveThumbnail(_ image: NSImage, for model: ModelsMeta.ModelInfo) {
190+
guard let tiffData = image.tiffRepresentation,
191+
let bitmap = NSBitmapImageRep(data: tiffData),
192+
let pngData = bitmap.representation(using: .png, properties: [:]) else {
193+
return
194+
}
195+
196+
do {
197+
try pngData.write(to: model.rootURL.appending(path: ModelsMeta.ModelInfo.thumbnailFileName))
198+
} catch {
199+
print("Failed to save thumbnail: \(error)")
200+
}
201+
}
202+
}
203+
204+
public enum ModelManagerError: Error {
205+
case modelURLNotFound
206+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import AppKit
2+
#if FEATURE_3
3+
import VRMKit
4+
#endif
5+
6+
public struct ModelMeta: Hashable {
7+
public var name: String
8+
public var image: NSImage?
9+
}
10+
11+
enum ModelMetaLoader {
12+
#if FEATURE_3
13+
static func load(from url: URL) throws -> ModelMeta {
14+
let loader = VRMLoader()
15+
do {
16+
let vrm1 = try loader.load(VRM1.self, withURL: url)
17+
return ModelMeta(
18+
name: vrm1.meta.name,
19+
image: try? loader.loadThumbnail(from: vrm1)
20+
)
21+
} catch {
22+
let vrm0 = try loader.load(VRM.self, withURL: url)
23+
return ModelMeta(
24+
name: vrm0.meta.title ?? url.deletingPathExtension().lastPathComponent,
25+
image: try? loader.loadThumbnail(from: vrm0)
26+
)
27+
}
28+
}
29+
#else
30+
static func load(from url: URL) throws -> ModelMeta {
31+
let modelJsonURL = resolveLive2DModelJSON(from: url)
32+
let name = modelJsonURL.map { live2DModelName(from: $0) } ?? fallbackName(for: url)
33+
return ModelMeta(name: name)
34+
}
35+
36+
private static func resolveLive2DModelJSON(from url: URL) -> URL? {
37+
let fileManager = FileManager.default
38+
var isDirectory: ObjCBool = false
39+
40+
if fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory) {
41+
if isDirectory.boolValue {
42+
return live2DModelJSON(in: url)
43+
}
44+
if isLive2DModelJSON(url) {
45+
return url
46+
}
47+
return nil
48+
}
49+
50+
return nil
51+
}
52+
53+
private static func live2DModelJSON(in directory: URL) -> URL? {
54+
guard let contents = try? FileManager.default.contentsOfDirectory(
55+
at: directory,
56+
includingPropertiesForKeys: [.isRegularFileKey],
57+
options: [.skipsHiddenFiles]
58+
) else {
59+
return nil
60+
}
61+
62+
let candidates = contents
63+
.filter { isLive2DModelJSON($0) }
64+
.sorted { $0.lastPathComponent < $1.lastPathComponent }
65+
66+
return candidates.first
67+
}
68+
69+
private static func isLive2DModelJSON(_ url: URL) -> Bool {
70+
let fileName = url.lastPathComponent.lowercased()
71+
return fileName.hasSuffix(".model3.json") || fileName.hasSuffix(".model.json")
72+
}
73+
74+
private static func live2DModelName(from url: URL) -> String {
75+
url.deletingPathExtension().deletingPathExtension().lastPathComponent
76+
}
77+
78+
private static func fallbackName(for url: URL) -> String {
79+
var isDirectory: ObjCBool = false
80+
if FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory), !isDirectory.boolValue {
81+
return url.deletingPathExtension().lastPathComponent
82+
}
83+
return url.lastPathComponent
84+
}
85+
#endif
86+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import AppKit
2+
import VCamEntity
3+
4+
public struct ModelsMeta: Codable, Sendable {
5+
var models: [ModelInfo]
6+
var lastModelId: UUID?
7+
8+
public static var modelsDirectory: URL {
9+
.applicationSupportDirectoryWithBundleID.appending(path: "models")
10+
}
11+
12+
#if FEATURE_3
13+
public static let modelFileName = "model.vrm"
14+
public static let modelType: ModelType = .vrm
15+
#else
16+
public static let modelFileName = "live2d"
17+
public static let modelType: ModelType = .live2d
18+
#endif
19+
20+
public static var metaURL: URL {
21+
modelsDirectory.appending(path: "meta.json")
22+
}
23+
24+
public static func modelDirectory(ofName name: String) -> URL {
25+
modelsDirectory.appending(path: name)
26+
}
27+
28+
public struct ModelInfo: Identifiable, Codable, Equatable, Hashable, Sendable {
29+
public let id: UUID
30+
public var name: String
31+
public var displayName: String?
32+
public let type: ModelType
33+
public let createdAt: Date
34+
public var status: ModelStatus
35+
36+
public enum ModelStatus: String, Codable, Hashable, Sendable {
37+
case valid
38+
case missing
39+
}
40+
41+
public init(
42+
id: UUID = UUID(),
43+
name: String,
44+
displayName: String? = nil,
45+
type: ModelType,
46+
createdAt: Date = .now,
47+
status: ModelStatus = .valid
48+
) {
49+
self.id = id
50+
self.name = name
51+
self.displayName = displayName
52+
self.type = type
53+
self.createdAt = createdAt
54+
self.status = status
55+
}
56+
57+
public var localizedName: String {
58+
displayName ?? name
59+
}
60+
}
61+
}
62+
63+
extension ModelsMeta.ModelInfo {
64+
static var thumbnailFileName: String { "thumbnail.png" }
65+
66+
public var rootURL: URL {
67+
ModelsMeta.modelDirectory(ofName: name)
68+
}
69+
70+
public var modelURL: URL {
71+
rootURL.appending(path: ModelsMeta.modelFileName)
72+
}
73+
74+
public var thumbnail: NSImage? {
75+
let thumbnailURL = rootURL.appending(path: Self.thumbnailFileName)
76+
return NSImage(contentsOfFile: thumbnailURL.path)
77+
}
78+
}

0 commit comments

Comments
 (0)