v0.4.0: scroll log, worker slots, disk skip, color gradient, parallel export

This commit is contained in:
Ein Anderssono
2026-06-12 14:03:18 +02:00
parent e888f7cad1
commit 3d3c4a4742
15 changed files with 1609 additions and 181 deletions
+17 -2
View File
@@ -1,10 +1,19 @@
#ifndef PHOTOKIT_BRIDGE_H
#define PHOTOKIT_BRIDGE_H
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
typedef struct {
int active;
double progress;
int64_t bytes_done;
int64_t bytes_total;
} export_progress_t;
int photos_request_access(void);
char *photos_list_albums_json(void);
@@ -15,13 +24,15 @@ char *photos_export_preview_json(
const char *asset_id,
const char *output_dir,
int target_size,
int index
int index,
int slot_index
);
char *photos_export_original_json(
const char *asset_id,
const char *output_dir,
int index
int index,
int slot_index
);
char *photos_list_tree_json(void);
@@ -32,6 +43,10 @@ int photos_request_is_cancelled(void);
void photos_free_string(char *value);
export_progress_t *photos_get_progress_slots(void);
int photos_get_progress_slot_count(void);
void photos_reset_progress_slots(void);
#ifdef __cplusplus
}
#endif
+289 -32
View File
@@ -1,10 +1,24 @@
#import <Foundation/Foundation.h>
#import <AppKit/AppKit.h>
#import <Photos/Photos.h>
#import <UniformTypeIdentifiers/UniformTypeIdentifiers.h>
#import <objc/message.h>
#import "photokit_bridge.h"
static volatile int photos_cancelled = 0;
#define PROGRESS_SLOT_COUNT 3
static export_progress_t progress_slots[PROGRESS_SLOT_COUNT];
static void reset_slot(int slot_index) {
if (slot_index >= 0 && slot_index < PROGRESS_SLOT_COUNT) {
progress_slots[slot_index].active = 0;
progress_slots[slot_index].progress = 0;
progress_slots[slot_index].bytes_done = 0;
progress_slots[slot_index].bytes_total = 0;
}
}
static NSDictionary *make_error_dict(NSString *message) {
return @{@"error": message};
}
@@ -192,6 +206,60 @@ char *photos_list_albums_json(void) {
return json_from_object(@{@"albums": list});
}
static NSString *resource_type_string(NSInteger type) {
switch (type) {
case 1: return @"photo";
case 2: return @"video";
case 3: return @"audio";
case 4: return @"alternatePhoto";
case 5: return @"fullSizePhoto";
case 6: return @"fullSizeVideo";
case 7: return @"adjustmentData";
case 8: return @"adjustmentBasePhoto";
case 9: return @"pairedVideo";
case 10: return @"fullSizePairedVideo";
case 11: return @"adjustmentBasePairedVideo";
case 12: return @"adjustmentBaseVideo";
default: return [NSString stringWithFormat:@"other(%ld)", (long)type];
}
}
static NSString *media_type_string(PHAssetMediaType type) {
switch (type) {
case PHAssetMediaTypeImage: return @"image";
case PHAssetMediaTypeVideo: return @"video";
case PHAssetMediaTypeAudio: return @"audio";
default: return @"unknown";
}
}
static NSString *iso8601_string(NSDate *date) {
if (!date) return nil;
static NSDateFormatter *fmt = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
fmt = [[NSDateFormatter alloc] init];
fmt.locale = [NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"];
fmt.dateFormat = @"yyyy-MM-dd'T'HH:mm:ssZ";
});
return [fmt stringFromDate:date];
}
#import <objc/message.h>
static BOOL resource_is_locally_available(PHAssetResource *res) {
@try {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
SEL sel = @selector(isLocallyAvailable);
if ([res respondsToSelector:sel]) {
return ((BOOL (*)(id, SEL))objc_msgSend)(res, sel);
}
#pragma clang diagnostic pop
} @catch (NSException *e) {}
return NO;
}
static NSString *asset_cloud_status_string(PHAsset *asset) {
@try {
id cloudId = [asset performSelector:@selector(cloudIdentifier)];
@@ -200,16 +268,17 @@ static NSString *asset_cloud_status_string(PHAsset *asset) {
}
} @catch (NSException *exception) {
}
PHAssetResource *resource = [PHAssetResource assetResourcesForAsset:asset].firstObject;
PHAssetResource *resource = nil;
@try {
resource = [PHAssetResource assetResourcesForAsset:asset].firstObject;
} @catch (NSException *e) {
resource = nil;
}
if (resource) {
@try {
id locallyAvailable = [resource performSelector:@selector(isLocallyAvailable)];
if (locallyAvailable && [locallyAvailable boolValue]) {
return @"local";
}
return @"cloud";
} @catch (NSException *exception) {
if (resource_is_locally_available(resource)) {
return @"local";
}
return @"cloud";
}
return @"local";
}
@@ -236,16 +305,53 @@ char *photos_list_assets_json(const char *album_id) {
PHAsset *asset = assets[i];
NSString *lid = asset.localIdentifier;
NSString *filename = nil;
NSArray<PHAssetResource *> *resources = [PHAssetResource assetResourcesForAsset:asset];
NSArray<PHAssetResource *> *resources = nil;
@try {
resources = [PHAssetResource assetResourcesForAsset:asset];
} @catch (NSException *e) {
resources = @[];
}
if (resources.count > 0) {
filename = resources.firstObject.originalFilename;
}
NSString *cloudStatus = asset_cloud_status_string(asset);
[list addObject:@{
NSString *mediaTypeStr = media_type_string(asset.mediaType);
NSString *creationDateStr = iso8601_string(asset.creationDate);
NSMutableArray *resourcesList = [NSMutableArray arrayWithCapacity:resources.count];
for (PHAssetResource *res in resources) {
NSString *resTypeStr = resource_type_string(res.type);
NSString *uti = res.uniformTypeIdentifier ?: @"";
BOOL isLocal = resource_is_locally_available(res);
[resourcesList addObject:@{
@"type": resTypeStr,
@"filename": res.originalFilename ?: @"",
@"uti": uti,
@"local": @(isLocal)
}];
}
NSMutableDictionary *dict = [NSMutableDictionary dictionaryWithDictionary:@{
@"id": lid ?: @"",
@"filename": filename ?: @"",
@"cloud": cloudStatus
@"cloud": cloudStatus,
@"mediaType": mediaTypeStr,
@"pixelWidth": @(asset.pixelWidth),
@"pixelHeight": @(asset.pixelHeight),
@"duration": @(asset.duration),
@"isFavorite": @(asset.isFavorite)
}];
if (creationDateStr) {
dict[@"creationDate"] = creationDateStr;
}
if (@available(macOS 12, *)) {
dict[@"hasAdjustments"] = @(asset.hasAdjustments);
}
if (resourcesList.count > 0) {
dict[@"resources"] = resourcesList;
}
[list addObject:dict];
}
return json_from_object(@{@"assets": list, @"total": @(assets.count)});
@@ -266,23 +372,32 @@ char *photos_list_tree_json(void) {
}
char *photos_export_preview_json(const char *asset_id, const char *output_dir, int target_size, int index) {
if (!asset_id || !output_dir) return json_from_object(make_error_dict(@"asset_id and output_dir required"));
#define RETURN_PREVIEW(x) do { reset_slot(slot_index); return (x); } while(0)
char *photos_export_preview_json(const char *asset_id, const char *output_dir, int target_size, int index, int slot_index) {
if (!asset_id || !output_dir) RETURN_PREVIEW(json_from_object(make_error_dict(@"asset_id and output_dir required")));
if (target_size <= 0) target_size = 1024;
NSString *nsAssetId = [NSString stringWithUTF8String:asset_id];
NSString *nsOutputDir = [NSString stringWithUTF8String:output_dir];
if (!nsAssetId || !nsOutputDir) return json_from_object(make_error_dict(@"invalid UTF-8 in arguments"));
if (!nsAssetId || !nsOutputDir) RETURN_PREVIEW(json_from_object(make_error_dict(@"invalid UTF-8 in arguments")));
if (!ensure_directory(nsOutputDir)) {
return json_from_object(make_error_dict(@"failed to create output directory"));
RETURN_PREVIEW(json_from_object(make_error_dict(@"failed to create output directory")));
}
PHFetchResult<PHAsset *> *fetch = [PHAsset fetchAssetsWithLocalIdentifiers:@[nsAssetId] options:nil];
if (fetch.count == 0) return json_from_object(make_error_dict(@"asset not found"));
if (fetch.count == 0) RETURN_PREVIEW(json_from_object(make_error_dict(@"asset not found")));
PHAsset *asset = fetch.firstObject;
if (slot_index >= 0 && slot_index < PROGRESS_SLOT_COUNT) {
progress_slots[slot_index].active = 1;
progress_slots[slot_index].progress = 0;
progress_slots[slot_index].bytes_done = 0;
progress_slots[slot_index].bytes_total = 0;
}
PHImageManager *im = [PHImageManager defaultManager];
CGFloat scale = (CGFloat)target_size;
CGSize targetCGSize = CGSizeMake(scale, scale);
@@ -291,6 +406,11 @@ char *photos_export_preview_json(const char *asset_id, const char *output_dir, i
imgOpts.resizeMode = PHImageRequestOptionsResizeModeExact;
imgOpts.networkAccessAllowed = YES;
imgOpts.synchronous = YES;
imgOpts.progressHandler = ^(double progress, NSError * _Nullable error, BOOL * _Nonnull stop, NSDictionary * _Nullable info) {
if (slot_index >= 0 && slot_index < PROGRESS_SLOT_COUNT) {
progress_slots[slot_index].progress = progress;
}
};
dispatch_semaphore_t sem = dispatch_semaphore_create(0);
__block NSData *imageData = nil;
@@ -313,21 +433,35 @@ char *photos_export_preview_json(const char *asset_id, const char *output_dir, i
}];
if (!semaphore_wait_with_timeout(sem, 120)) {
return json_from_object(@{@"error": @"timeout waiting for image", @"cloud": asset_cloud_status_string(asset)});
RETURN_PREVIEW(json_from_object(@{@"error": @"timeout waiting for image", @"cloud": asset_cloud_status_string(asset)}));
}
if (!imageData) {
return json_from_object(@{@"error": @"failed to render image", @"cloud": asset_cloud_status_string(asset)});
RETURN_PREVIEW(json_from_object(@{@"error": @"failed to render image", @"cloud": asset_cloud_status_string(asset)}));
}
NSString *safe = sanitized_asset_identifier(asset.localIdentifier);
NSString *filename = [NSString stringWithFormat:@"%04lu_%@.jpg", (unsigned long)index, safe];
NSString *filepath = [nsOutputDir stringByAppendingPathComponent:filename];
NSFileManager *fm = [NSFileManager defaultManager];
NSDictionary *existingAttrs = [fm attributesOfItemAtPath:filepath error:nil];
if (existingAttrs) {
NSNumber *existingSize = existingAttrs[NSFileSize];
if (existingSize && existingSize.unsignedLongValue > 0) {
RETURN_PREVIEW(json_from_object(@{
@"filename": filename,
@"size": existingSize,
@"cloud": asset_cloud_status_string(asset),
@"skipped": @YES
}));
}
}
NSError *writeErr = nil;
if (![imageData writeToFile:filepath options:NSDataWritingAtomic error:&writeErr]) {
NSString *msg = writeErr ? writeErr.localizedDescription : @"unknown write error";
return json_from_object(@{@"error": [NSString stringWithFormat:@"write failed: %@", msg], @"cloud": asset_cloud_status_string(asset)});
RETURN_PREVIEW(json_from_object(@{@"error": [NSString stringWithFormat:@"write failed: %@", msg], @"cloud": asset_cloud_status_string(asset)}));
}
NSNumber *fileSize = nil;
@@ -336,32 +470,47 @@ char *photos_export_preview_json(const char *asset_id, const char *output_dir, i
fileSize = attrs[NSFileSize];
}
return json_from_object(@{
RETURN_PREVIEW(json_from_object(@{
@"filename": filename,
@"size": fileSize ?: @0,
@"cloud": asset_cloud_status_string(asset)
});
}));
}
#undef RETURN_PREVIEW
char *photos_export_original_json(const char *asset_id, const char *output_dir, int index) {
if (!asset_id || !output_dir) return json_from_object(make_error_dict(@"asset_id and output_dir required"));
#define RETURN_ORIGINAL(x) do { reset_slot(slot_index); return (x); } while(0)
char *photos_export_original_json(const char *asset_id, const char *output_dir, int index, int slot_index) {
if (!asset_id || !output_dir) RETURN_ORIGINAL(json_from_object(make_error_dict(@"asset_id and output_dir required")));
NSString *nsAssetId = [NSString stringWithUTF8String:asset_id];
NSString *nsOutputDir = [NSString stringWithUTF8String:output_dir];
if (!nsAssetId || !nsOutputDir) return json_from_object(make_error_dict(@"invalid UTF-8 in arguments"));
if (!nsAssetId || !nsOutputDir) RETURN_ORIGINAL(json_from_object(make_error_dict(@"invalid UTF-8 in arguments")));
if (!ensure_directory(nsOutputDir)) {
return json_from_object(make_error_dict(@"failed to create output directory"));
RETURN_ORIGINAL(json_from_object(make_error_dict(@"failed to create output directory")));
}
PHFetchResult<PHAsset *> *fetch = [PHAsset fetchAssetsWithLocalIdentifiers:@[nsAssetId] options:nil];
if (fetch.count == 0) return json_from_object(make_error_dict(@"asset not found"));
if (fetch.count == 0) RETURN_ORIGINAL(json_from_object(make_error_dict(@"asset not found")));
PHAsset *asset = fetch.firstObject;
NSArray<PHAssetResource *> *resources = [PHAssetResource assetResourcesForAsset:asset];
if (slot_index >= 0 && slot_index < PROGRESS_SLOT_COUNT) {
progress_slots[slot_index].active = 1;
progress_slots[slot_index].progress = 0;
progress_slots[slot_index].bytes_done = 0;
progress_slots[slot_index].bytes_total = 0;
}
NSArray<PHAssetResource *> *resources = nil;
@try {
resources = [PHAssetResource assetResourcesForAsset:asset];
} @catch (NSException *e) {
resources = @[];
}
if (resources.count == 0) {
return json_from_object(@{@"error": @"no resources", @"cloud": asset_cloud_status_string(asset)});
RETURN_ORIGINAL(json_from_object(@{@"error": @"no resources", @"cloud": asset_cloud_status_string(asset)}));
}
PHAssetResource *resource = resources.firstObject;
@@ -371,11 +520,38 @@ char *photos_export_original_json(const char *asset_id, const char *output_dir,
}
NSString *filepath = unique_path_for_filename(nsOutputDir, filename, (NSUInteger)index);
NSFileManager *fm = [NSFileManager defaultManager];
{
NSString *checkPath = [nsOutputDir stringByAppendingPathComponent:filename];
NSDictionary *existingAttrs = [fm attributesOfItemAtPath:checkPath error:nil];
if (existingAttrs) {
NSNumber *existingSize = existingAttrs[NSFileSize];
if (existingSize && existingSize.unsignedLongValue > 0) {
RETURN_ORIGINAL(json_from_object(@{
@"filename": filename,
@"size": existingSize,
@"cloud": asset_cloud_status_string(asset),
@"skipped": @YES
}));
}
}
}
NSURL *fileURL = [NSURL fileURLWithPath:filepath];
if ([fm fileExistsAtPath:filepath]) {
[fm removeItemAtPath:filepath error:nil];
}
PHAssetResourceManager *rm = [PHAssetResourceManager defaultManager];
PHAssetResourceRequestOptions *opts = [[PHAssetResourceRequestOptions alloc] init];
opts.networkAccessAllowed = YES;
opts.progressHandler = ^(double progress) {
if (slot_index >= 0 && slot_index < PROGRESS_SLOT_COUNT) {
progress_slots[slot_index].progress = progress;
}
};
dispatch_semaphore_t sem = dispatch_semaphore_create(0);
__block NSError *writeErr = nil;
@@ -387,11 +563,79 @@ char *photos_export_original_json(const char *asset_id, const char *output_dir,
dispatch_semaphore_signal(sem);
}];
if (!semaphore_wait_with_timeout(sem, 120)) {
return json_from_object(@{@"error": @"timeout waiting for resource", @"cloud": asset_cloud_status_string(asset)});
RETURN_ORIGINAL(json_from_object(@{@"error": @"timeout waiting for resource", @"cloud": asset_cloud_status_string(asset)}));
}
if (writeErr) {
return json_from_object(@{@"error": [NSString stringWithFormat:@"write failed: %@", writeErr.localizedDescription], @"cloud": asset_cloud_status_string(asset)});
PHImageRequestOptions *imgOpts = [[PHImageRequestOptions alloc] init];
imgOpts.networkAccessAllowed = YES;
imgOpts.synchronous = NO;
dispatch_semaphore_t sem2 = dispatch_semaphore_create(0);
__block NSData *fallbackData = nil;
__block NSError *fallbackErr = nil;
__block NSString *fallbackUTI = nil;
[[PHImageManager defaultManager] requestImageDataAndOrientationForAsset:asset
options:imgOpts
resultHandler:^(NSData *imageData, NSString *dataUTI, CGImagePropertyOrientation orientation, NSDictionary *info) {
if (info[PHImageErrorKey]) {
fallbackErr = info[PHImageErrorKey];
} else if (imageData) {
fallbackData = imageData;
fallbackUTI = dataUTI;
} else {
fallbackErr = [NSError errorWithDomain:@"photoscli" code:-1 userInfo:@{NSLocalizedDescriptionKey: @"no data"}];
}
dispatch_semaphore_signal(sem2);
}];
if (!semaphore_wait_with_timeout(sem2, 120)) {
RETURN_ORIGINAL(json_from_object(@{@"error": @"timeout waiting for image data", @"cloud": asset_cloud_status_string(asset)}));
}
if (fallbackErr || !fallbackData) {
NSString *detail = writeErr.localizedDescription;
if (fallbackErr) {
detail = [NSString stringWithFormat:@"%@; fallback: %@", detail, fallbackErr.localizedDescription];
}
RETURN_ORIGINAL(json_from_object(@{@"error": [NSString stringWithFormat:@"write failed: %@", detail], @"cloud": asset_cloud_status_string(asset)}));
}
NSString *ext = nil;
if (fallbackUTI) {
UTType *uti = [UTType typeWithIdentifier:fallbackUTI];
if (uti && uti.preferredFilenameExtension) {
ext = uti.preferredFilenameExtension;
}
}
if (ext.length == 0) {
ext = @"dng";
}
NSString *baseFilename = [filename stringByDeletingPathExtension];
if (baseFilename.length == 0) {
baseFilename = sanitized_asset_identifier(asset.localIdentifier);
}
NSString *fallbackFilename = [NSString stringWithFormat:@"%@.%@", baseFilename, ext];
NSString *fallbackPath = [nsOutputDir stringByAppendingPathComponent:fallbackFilename];
NSError *writeFallbackErr = nil;
if (![fallbackData writeToFile:fallbackPath options:NSDataWritingAtomic error:&writeFallbackErr]) {
RETURN_ORIGINAL(json_from_object(@{@"error": [NSString stringWithFormat:@"write failed: %@", writeFallbackErr.localizedDescription], @"cloud": asset_cloud_status_string(asset)}));
}
NSNumber *fileSize = nil;
NSDictionary *attrs = [[NSFileManager defaultManager] attributesOfItemAtPath:fallbackPath error:nil];
if (attrs) {
fileSize = attrs[NSFileSize];
}
RETURN_ORIGINAL(json_from_object(@{
@"filename": fallbackFilename,
@"size": fileSize ?: @0,
@"cloud": asset_cloud_status_string(asset)
}));
}
NSString *writtenFilename = [filepath lastPathComponent];
@@ -401,12 +645,13 @@ char *photos_export_original_json(const char *asset_id, const char *output_dir,
fileSize = attrs[NSFileSize];
}
return json_from_object(@{
RETURN_ORIGINAL(json_from_object(@{
@"filename": writtenFilename,
@"size": fileSize ?: @0,
@"cloud": asset_cloud_status_string(asset)
});
}));
}
#undef RETURN_ORIGINAL
void photos_free_string(char *value) {
if (value) free(value);
@@ -419,3 +664,15 @@ void photos_request_cancel(void) {
int photos_request_is_cancelled(void) {
return photos_cancelled;
}
export_progress_t *photos_get_progress_slots(void) {
return progress_slots;
}
int photos_get_progress_slot_count(void) {
return PROGRESS_SLOT_COUNT;
}
void photos_reset_progress_slots(void) {
memset(progress_slots, 0, sizeof(progress_slots));
}
+19 -2
View File
@@ -1,5 +1,8 @@
#include <stdlib.h>
#include <string.h>
#include "../bridge/photokit_bridge.h"
static export_progress_t stub_progress_slots[3];
static char *alloc_json(const char *s) {
size_t len = strlen(s);
@@ -46,18 +49,20 @@ char *photos_list_assets_json(const char *album_id) {
return alloc_json(stub_assets_json);
}
char *photos_export_preview_json(const char *asset_id, const char *output_dir, int target_size, int index) {
char *photos_export_preview_json(const char *asset_id, const char *output_dir, int target_size, int index, int slot_index) {
(void)asset_id;
(void)output_dir;
(void)target_size;
(void)index;
(void)slot_index;
return maybe_alloc_json(stub_export_preview_json);
}
char *photos_export_original_json(const char *asset_id, const char *output_dir, int index) {
char *photos_export_original_json(const char *asset_id, const char *output_dir, int index, int slot_index) {
(void)asset_id;
(void)output_dir;
(void)index;
(void)slot_index;
return maybe_alloc_json(stub_export_original_json);
}
@@ -84,4 +89,16 @@ void photos_test_set_export_preview_json(const char *json) {
void photos_test_set_export_original_json(const char *json) {
stub_export_original_json = json;
}
export_progress_t *photos_get_progress_slots(void) {
return stub_progress_slots;
}
int photos_get_progress_slot_count(void) {
return 3;
}
void photos_reset_progress_slots(void) {
memset(stub_progress_slots, 0, sizeof(stub_progress_slots));
}