Skip to content

Commit 0aed4e1

Browse files
committed
Add verbose video compression logging and fix attachment replacement
Add a dedicated VideoLogger that writes to the sandbox temp directory as video.log, and instrument the Writer video compression flow with detailed logs for export setup, file type selection, source track properties, backup handling, progress milestones, completion, cancellation, failure, metadata reload, and revert actions. Fix compressed video replacement on case-insensitive filesystems by exporting to UUID-based lowercase filenames and guarding DraftModel cleanup so the old-path removal does not delete the newly copied attachment when paths resolve to the same on-disk item. Also document the sandbox temp log location convention in AGENTS.md.
1 parent 7278ab2 commit 0aed4e1

6 files changed

Lines changed: 349 additions & 23 deletions

File tree

AGENTS.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
- Use Xcode at `/Applications/Xcode-16.4.0.app/`, if not found, then try `/Applications/Xcode.app/`
1+
- Use Xcode at `/Applications/Xcode.app/`, if not found, then try `/Applications/Xcode-16.4.0.app/`
22
- Must compile for macOS 12. Use `#available` / `@available` to adopt newer APIs (e.g. macOS 13, 14, 26) while maintaining macOS 12 compatibility, with graceful fallbacks for older versions.
33
- AI related features debug log `planet-ai-debug.log` can be found at `~/Library/Containers/xyz.planetable.Planet/Data/tmp/planet-ai-debug.log`
4+
- Sandbox-safe app logs should be written with `NSTemporaryDirectory()`; in Planet this resolves under `~/Library/Containers/xyz.planetable.Planet/Data/tmp/` (for example `planet-ai-debug.log`, `video.log`)
45
- For broken SwiftPM/Xcode derived data under `/tmp/planet-derived` (for example missing `Sparkle.xcframework`), remove `/tmp/planet-derived`, run `xcodebuild -resolvePackageDependencies -onlyUsePackageVersionsFromResolvedFile -project Planet.xcodeproj -scheme "Planet" -derivedDataPath /tmp/planet-derived`, then rebuild
5-
- When modifying Xcode project files (.xcodeproj/project.pbxproj), use the `xcodeproj` Ruby gem instead of editing the pbxproj file directly.
6+
- When modifying Xcode project files (.xcodeproj/project.pbxproj), use the `xcodeproj` Ruby gem instead of editing the pbxproj file directly.

Planet/Entities/DraftModel.swift

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -368,7 +368,9 @@ class DraftModel: Identifiable, Equatable, Hashable, Codable, ObservableObject {
368368
content = content.replacingOccurrences(of: oldName, with: newAttachment.name)
369369
}
370370

371-
if oldPath != newAttachment.path, FileManager.default.fileExists(atPath: oldPath.path) {
371+
if oldPath != newAttachment.path,
372+
!DraftModel.fileURLsReferenceSameItem(oldPath, newAttachment.path),
373+
FileManager.default.fileExists(atPath: oldPath.path) {
372374
try FileManager.default.removeItem(at: oldPath)
373375
}
374376

@@ -632,6 +634,19 @@ class DraftModel: Identifiable, Equatable, Hashable, Codable, ObservableObject {
632634

633635
// MARK: -
634636

637+
private static func fileURLsReferenceSameItem(_ lhs: URL, _ rhs: URL) -> Bool {
638+
guard
639+
let lhsIdentifier = try? lhs.resourceValues(forKeys: [.fileResourceIdentifierKey])
640+
.fileResourceIdentifier,
641+
let rhsIdentifier = try? rhs.resourceValues(forKeys: [.fileResourceIdentifierKey])
642+
.fileResourceIdentifier
643+
else {
644+
return false
645+
}
646+
647+
return (lhsIdentifier as AnyObject).isEqual(rhsIdentifier)
648+
}
649+
635650
private func processAttachment(
636651
forFileName name: String,
637652
atFilePath targetPath: URL,

Planet/Log/PlanetLogger.swift

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,57 @@ enum PlanetLogger {
6969
}
7070
}
7171

72+
enum VideoLogger {
73+
private static let logURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
74+
.appendingPathComponent("video.log", isDirectory: false)
75+
private static let queue = DispatchQueue(label: "xyz.planetable.VideoLogger")
76+
private static let formatter: ISO8601DateFormatter = {
77+
let formatter = ISO8601DateFormatter()
78+
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
79+
return formatter
80+
}()
81+
82+
static func log(_ message: String) {
83+
queue.async {
84+
let normalized = message.replacingOccurrences(of: "\r\n", with: "\n")
85+
let lines = normalized.split(separator: "\n", omittingEmptySubsequences: false)
86+
guard !lines.isEmpty else { return }
87+
88+
let timestamp = formatter.string(from: Date())
89+
let payload = lines.map { "[\(timestamp)] \($0)" }.joined(separator: "\n") + "\n"
90+
guard let data = payload.data(using: .utf8) else { return }
91+
92+
if !FileManager.default.fileExists(atPath: logURL.path) {
93+
_ = FileManager.default.createFile(atPath: logURL.path, contents: nil)
94+
}
95+
96+
if let handle = try? FileHandle(forWritingTo: logURL) {
97+
handle.seekToEndOfFile()
98+
handle.write(data)
99+
handle.closeFile()
100+
}
101+
102+
LogFileTrimmer.trimIfNeeded(at: logURL)
103+
}
104+
}
105+
106+
static var logPath: String { logURL.path }
107+
108+
static func readAll() -> String {
109+
guard let data = try? Data(contentsOf: logURL) else { return "" }
110+
return String(data: data, encoding: .utf8) ?? ""
111+
}
112+
113+
static func clear() {
114+
queue.async {
115+
if let handle = try? FileHandle(forWritingTo: logURL) {
116+
handle.truncateFile(atOffset: 0)
117+
handle.closeFile()
118+
}
119+
}
120+
}
121+
}
122+
72123
enum PerfLogger {
73124
private static let logURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
74125
.appendingPathComponent("perf.log", isDirectory: false)

Planet/Writer/VideoCompressionJob.swift

Lines changed: 146 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,57 @@ private struct ExportSessionReference: @unchecked Sendable {
55
let session: AVAssetExportSession
66
}
77

8+
private func videoCompressionFileSizeBytes(at url: URL) -> Int64? {
9+
guard let fileSize = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize else {
10+
return nil
11+
}
12+
return Int64(fileSize)
13+
}
14+
15+
private func videoCompressionFormatBytes(_ value: Int64?) -> String {
16+
guard let value else {
17+
return "nil"
18+
}
19+
return "\(value)"
20+
}
21+
22+
private func videoCompressionFormatSeconds(_ time: CMTime) -> String {
23+
let seconds = CMTimeGetSeconds(time)
24+
guard seconds.isFinite else {
25+
return "indefinite"
26+
}
27+
return String(format: "%.3f", seconds)
28+
}
29+
30+
private func videoCompressionFormatSize(_ size: CGSize) -> String {
31+
String(format: "%.0fx%.0f", size.width, size.height)
32+
}
33+
34+
private func videoCompressionFormatTransform(_ transform: CGAffineTransform) -> String {
35+
String(
36+
format: "[a=%.3f,b=%.3f,c=%.3f,d=%.3f,tx=%.3f,ty=%.3f]",
37+
transform.a,
38+
transform.b,
39+
transform.c,
40+
transform.d,
41+
transform.tx,
42+
transform.ty
43+
)
44+
}
45+
46+
private func videoCompressionFormatFileType(_ fileType: AVFileType?) -> String {
47+
fileType?.rawValue ?? "nil"
48+
}
49+
50+
private func videoCompressionFormatString(_ value: String?) -> String {
51+
value ?? "nil"
52+
}
53+
54+
private func videoCompressionDescribeError(_ error: Error) -> String {
55+
let nsError = error as NSError
56+
return "domain=\(nsError.domain) code=\(nsError.code) description=\(nsError.localizedDescription)"
57+
}
58+
859
struct VideoCompressionJob {
960
private struct SourceColorProperties {
1061
let colorPrimaries: String?
@@ -37,6 +88,10 @@ struct VideoCompressionJob {
3788
videoComposition.colorTransferFunction = colorTransferFunction
3889
videoComposition.colorYCbCrMatrix = colorYCbCrMatrix
3990
}
91+
92+
var debugDescription: String {
93+
"containsHDR=\(containsHDR) colorPrimaries=\(videoCompressionFormatString(colorPrimaries)) colorTransferFunction=\(videoCompressionFormatString(colorTransferFunction)) colorYCbCrMatrix=\(videoCompressionFormatString(colorYCbCrMatrix))"
94+
}
4095
}
4196

4297
enum Option: String, CaseIterable, Identifiable {
@@ -125,6 +180,10 @@ struct VideoCompressionJob {
125180
let roundedDown = floor(value / 2) * 2
126181
return max(2, roundedDown)
127182
}
183+
184+
var debugDescription: String {
185+
"id=\(id) title=\(title) preset=\(exportPresetName) usesHEVC=\(usesHEVC) boundingSize=\(videoCompressionFormatSize(landscapeBoundingSize))"
186+
}
128187
}
129188

130189
struct PreparedExport {
@@ -163,36 +222,73 @@ struct VideoCompressionJob {
163222
let option: Option
164223

165224
func prepareExport() async throws -> PreparedExport {
166-
let asset = AVURLAsset(url: sourceURL)
167-
guard let session = AVAssetExportSession(asset: asset, presetName: option.exportPresetName) else {
168-
throw CompressionError.exportSessionUnavailable
169-
}
225+
VideoLogger.log(
226+
"[VideoCompressionJob] prepareExport start source=\(sourceURL.path) sourceSizeBytes=\(videoCompressionFormatBytes(videoCompressionFileSizeBytes(at: sourceURL))) option={\(option.debugDescription)}"
227+
)
170228

171-
let outputFileType = try preferredOutputFileType(for: session)
172-
let outputURL = try makeOutputURL(for: outputFileType)
173-
session.outputURL = outputURL
174-
session.outputFileType = outputFileType
175-
session.shouldOptimizeForNetworkUse = true
176-
session.videoComposition = try await makeVideoComposition(for: asset)
229+
do {
230+
let asset = AVURLAsset(url: sourceURL)
231+
guard let session = AVAssetExportSession(asset: asset, presetName: option.exportPresetName) else {
232+
VideoLogger.log(
233+
"[VideoCompressionJob] prepareExport could not create AVAssetExportSession source=\(sourceURL.path) preset=\(option.exportPresetName)"
234+
)
235+
throw CompressionError.exportSessionUnavailable
236+
}
237+
238+
VideoLogger.log(
239+
"[VideoCompressionJob] export session created preset=\(option.exportPresetName) supportedFileTypes=\(session.supportedFileTypes.map(\.rawValue).joined(separator: ","))"
240+
)
241+
242+
let outputFileType = try preferredOutputFileType(for: session)
243+
let outputURL = try makeOutputURL(for: outputFileType)
244+
session.outputURL = outputURL
245+
session.outputFileType = outputFileType
246+
session.shouldOptimizeForNetworkUse = true
247+
session.videoComposition = try await makeVideoComposition(for: asset)
177248

178-
return PreparedExport(session: session, outputURL: outputURL)
249+
VideoLogger.log(
250+
"[VideoCompressionJob] prepareExport ready outputURL=\(outputURL.path) outputFileType=\(outputFileType.rawValue) renderSize=\(videoCompressionFormatSize(session.videoComposition?.renderSize ?? .zero)) frameDurationSeconds=\(videoCompressionFormatSeconds(session.videoComposition?.frameDuration ?? .invalid)) optimizeForNetworkUse=\(session.shouldOptimizeForNetworkUse)"
251+
)
252+
253+
return PreparedExport(session: session, outputURL: outputURL)
254+
} catch {
255+
VideoLogger.log(
256+
"[VideoCompressionJob] prepareExport failed source=\(sourceURL.path) option=\(option.id) error=\(videoCompressionDescribeError(error))"
257+
)
258+
throw error
259+
}
179260
}
180261

181262
static func export(_ session: AVAssetExportSession) async throws {
263+
VideoLogger.log(
264+
"[VideoCompressionJob] export start outputURL=\(session.outputURL?.path ?? "nil") outputFileType=\(videoCompressionFormatFileType(session.outputFileType)) progress=\(String(format: "%.3f", session.progress))"
265+
)
182266
let reference = ExportSessionReference(session: session)
183267
try await withCheckedThrowingContinuation { continuation in
184268
reference.session.exportAsynchronously {
185269
switch reference.session.status {
186270
case .completed:
271+
VideoLogger.log(
272+
"[VideoCompressionJob] export completed outputURL=\(reference.session.outputURL?.path ?? "nil") progress=\(String(format: "%.3f", reference.session.progress))"
273+
)
187274
continuation.resume()
188275
case .cancelled:
276+
VideoLogger.log(
277+
"[VideoCompressionJob] export cancelled outputURL=\(reference.session.outputURL?.path ?? "nil") progress=\(String(format: "%.3f", reference.session.progress))"
278+
)
189279
continuation.resume(throwing: CancellationError())
190280
case .failed:
281+
VideoLogger.log(
282+
"[VideoCompressionJob] export failed outputURL=\(reference.session.outputURL?.path ?? "nil") progress=\(String(format: "%.3f", reference.session.progress)) error=\(videoCompressionDescribeError(reference.session.error ?? CompressionError.exportFailed(nil)))"
283+
)
191284
continuation.resume(
192285
throwing: reference.session.error
193286
?? CompressionError.exportFailed(nil)
194287
)
195288
default:
289+
VideoLogger.log(
290+
"[VideoCompressionJob] export ended unexpectedly status=\(reference.session.status.rawValue) outputURL=\(reference.session.outputURL?.path ?? "nil") progress=\(String(format: "%.3f", reference.session.progress)) error=\(videoCompressionDescribeError(reference.session.error ?? CompressionError.exportFailed(nil)))"
291+
)
196292
continuation.resume(
197293
throwing: reference.session.error
198294
?? CompressionError.exportFailed(nil)
@@ -204,6 +300,9 @@ struct VideoCompressionJob {
204300

205301
private func makeVideoComposition(for asset: AVAsset) async throws -> AVMutableVideoComposition {
206302
guard let videoTrack = try await asset.loadTracks(withMediaType: .video).first else {
303+
VideoLogger.log(
304+
"[VideoCompressionJob] asset has no readable video track source=\(sourceURL.path)"
305+
)
207306
throw CompressionError.invalidVideoTrack
208307
}
209308

@@ -213,6 +312,9 @@ struct VideoCompressionJob {
213312
formatDescriptions: formatDescriptions
214313
)
215314
if sourceColorProperties.containsHDR && !option.usesHEVC {
315+
VideoLogger.log(
316+
"[VideoCompressionJob] rejecting non-HEVC preset for HDR source source=\(sourceURL.path) option=\(option.id)"
317+
)
216318
throw CompressionError.hdrRequiresHEVC
217319
}
218320

@@ -222,12 +324,19 @@ struct VideoCompressionJob {
222324
let sourceBounds = CGRect(origin: .zero, size: naturalSize).applying(preferredTransform)
223325
let sourceSize = CGSize(width: abs(sourceBounds.width), height: abs(sourceBounds.height))
224326
guard sourceSize.width > 0, sourceSize.height > 0 else {
327+
VideoLogger.log(
328+
"[VideoCompressionJob] invalid transformed video size source=\(sourceURL.path) naturalSize=\(videoCompressionFormatSize(naturalSize)) preferredTransform=\(videoCompressionFormatTransform(preferredTransform))"
329+
)
225330
throw CompressionError.invalidVideoTrack
226331
}
227332

228333
let renderSize = option.renderSize(for: sourceSize)
229334
let frameRate = (try? await videoTrack.load(.nominalFrameRate)) ?? 0
230335

336+
VideoLogger.log(
337+
"[VideoCompressionJob] source track details source=\(sourceURL.path) durationSeconds=\(videoCompressionFormatSeconds(duration)) naturalSize=\(videoCompressionFormatSize(naturalSize)) transformedSize=\(videoCompressionFormatSize(sourceSize)) nominalFrameRate=\(String(format: "%.3f", frameRate)) preferredTransform=\(videoCompressionFormatTransform(preferredTransform)) colorProperties={\(sourceColorProperties.debugDescription)} option=\(option.id)"
338+
)
339+
231340
let videoComposition = AVMutableVideoComposition()
232341
videoComposition.renderSize = renderSize
233342
videoComposition.frameDuration = CMTime(
@@ -236,20 +345,24 @@ struct VideoCompressionJob {
236345
)
237346
sourceColorProperties.apply(to: videoComposition)
238347

348+
let transform = scaledTransform(
349+
naturalSize: naturalSize,
350+
preferredTransform: preferredTransform,
351+
renderSize: renderSize
352+
)
239353
let layerInstruction = AVMutableVideoCompositionLayerInstruction(assetTrack: videoTrack)
240354
layerInstruction.setTransform(
241-
scaledTransform(
242-
naturalSize: naturalSize,
243-
preferredTransform: preferredTransform,
244-
renderSize: renderSize
245-
),
355+
transform,
246356
at: .zero
247357
)
248358

249359
let instruction = AVMutableVideoCompositionInstruction()
250360
instruction.timeRange = CMTimeRange(start: .zero, duration: duration)
251361
instruction.layerInstructions = [layerInstruction]
252362
videoComposition.instructions = [instruction]
363+
VideoLogger.log(
364+
"[VideoCompressionJob] video composition prepared renderSize=\(videoCompressionFormatSize(renderSize)) frameDurationSeconds=\(videoCompressionFormatSeconds(videoComposition.frameDuration)) transform=\(videoCompressionFormatTransform(transform)) instructionDurationSeconds=\(videoCompressionFormatSeconds(duration))"
365+
)
253366
return videoComposition
254367
}
255368

@@ -264,12 +377,23 @@ struct VideoCompressionJob {
264377
preferredTypes = [.mov, .mp4, .m4v]
265378
}
266379

380+
VideoLogger.log(
381+
"[VideoCompressionJob] resolving output file type sourceExtension=\(sourceURL.pathExtension.lowercased()) preferredTypes=\(preferredTypes.map(\.rawValue).joined(separator: ",")) supportedFileTypes=\(session.supportedFileTypes.map(\.rawValue).joined(separator: ","))"
382+
)
383+
267384
if let outputFileType = preferredTypes.first(where: session.supportedFileTypes.contains) {
385+
VideoLogger.log(
386+
"[VideoCompressionJob] selected output file type=\(outputFileType.rawValue)"
387+
)
268388
return outputFileType
269389
}
270390
if let outputFileType = session.supportedFileTypes.first {
391+
VideoLogger.log(
392+
"[VideoCompressionJob] selected fallback output file type=\(outputFileType.rawValue)"
393+
)
271394
return outputFileType
272395
}
396+
VideoLogger.log("[VideoCompressionJob] output file type unavailable")
273397
throw CompressionError.outputFileTypeUnavailable
274398
}
275399

@@ -281,10 +405,14 @@ struct VideoCompressionJob {
281405
withIntermediateDirectories: true
282406
)
283407

284-
let fileName = sourceURL.deletingPathExtension().lastPathComponent
285-
return temporaryDirectory
408+
let fileName = UUID().uuidString.lowercased()
409+
let outputURL = temporaryDirectory
286410
.appendingPathComponent(fileName, isDirectory: false)
287411
.appendingPathExtension(fileExtension(for: fileType))
412+
VideoLogger.log(
413+
"[VideoCompressionJob] created temporary export directory=\(temporaryDirectory.path) outputURL=\(outputURL.path)"
414+
)
415+
return outputURL
288416
}
289417

290418
private func fileExtension(for fileType: AVFileType) -> String {

0 commit comments

Comments
 (0)