Files
RyukGram/src/SCIFFmpeg.m
T
faroukbmiled 2977873932 [release] RyukGram v1.2.2
- Profile Analyzer (beta) — follower/following scans with mutuals, non-followbacks, new/lost trackers, and profile change history; searchable lists with batch follow/unfollow
- Theme settings — force dark mode, Full OLED, OLED chat theme, and keyboard theme picker
- Confirm story like
- Confirm story emoji reaction
- Swipe down to dismiss media viewer
- Manually add users to story/chat exclusion lists by username
- Keep stories visually seen locally
- Auto-scroll reels mode
- Quality picker: audio-only and raw photo download rows
- Clear cache button with optional auto-clear interval
- Spanish, Russian, Korean, Arabic, and Chinese (Traditional) translations
- About page with version, credits, and links
- Release notes popup on first launch of a new version
- Anonymous live viewing
- Toggle live comments
- Disappearing DM media overlay — action button, mark-as-viewed eye, and audio toggle
- Hide RyukGram UI on screenshots, screen recordings, and mirroring
- Open link from clipboard — long-press the search tab
- Messages-only mode: optional "Hide tab bar" sub-toggle
- Fake profile stats — verified badge and follower/following/post counts on your own profile
- Language switcher + import/export localization from Debug
- Reveal poll/slider vote counts and quiz answers on stories and reels before interacting
- Force legacy Quiz sticker back into the story composer tray
- Advanced experimental features menu — toggle hidden IG experiments (QuickSnap, Homecoming, Prism, Direct Notes reply types) with apply-on-restart batching and a crash-loop auto-reset
- Shortcut to Advanced experimental features from the General experimental features section

- Push notifications render with rich previews on sideload again
- IG 426 compatibility across story audio toggle, like confirmation, seen-on-like, live comments, notes audio download
- Call confirm split into separate voice-call and video-call toggles
- Messages-only mode: tab swiping disabled
- Settings quick-access broken in non-English languages
- Story seen-receipt block restored on IG v426
- Block selected mode no longer marks listed stories as seen
- Hide explore posts grid works again on recent IG versions
- Hide suggested stories no longer breaks profile highlights
- Hide trending searches now also hides the category chip bar
- Story eye long-press menu opens next to the button
- Disable video autoplay: tap-to-play now works on videos inside carousels
- Disable vanish mode swipe fixed on IG 426

- "Confirm shh mode" renamed to "Confirm vanish mode" across all languages
- Confirm sticker interaction split into separate story and highlight toggles
- Shared link embed presets: added eeinstagram.com and vxinstagram.com
- Downloaded media filenames follow `@username_context_timestamp`
- Reels pause mode: optional tap-to-mute on photo reels
- Backup & Restore — scope picker with live preview for Settings / Excluded lists / Analyzer data
- Profile Analyzer: filter by Not verified
- Settings header: tap to open a sheet with GitHub and Telegram channel links

- Thanks to Furamako for the Spanish translation
- Thanks to [ZomkaDEV](https://github.com/ZomkaDEV) for the Russian translation
- Thanks to [@ch1tmdgus](https://github.com/ch1tmdgus) (N4C) for the Korean translation
- Thanks to [@bruuhim](https://github.com/bruuhim) for the Arabic translation
- Thanks to [@jaydenjcpy](https://github.com/jaydenjcpy) for the Chinese (Traditional) translation
- Thanks to [@darthplagueiswise](https://github.com/darthplagueiswise) (Radan) for the experimental flag feature set
- Thanks to [@asdfzxcvbn](https://github.com/asdfzxcvbn) for [zxPluginsInject](https://github.com/asdfzxcvbn/zxPluginsInject) and [ipapatch](https://github.com/asdfzxcvbn/ipapatch)

- Preserved unsent messages can't be removed via "Delete for you"; pull-to-refresh clears them (warning available in settings)
- "Delete for you" detection uses a ~2s window after the local action — a real unsend landing in that window may be missed (rare)
- With Liquid Glass buttons + Hide UI on capture both on, the DM eye leaves an empty glass bubble in captures
2026-04-24 02:50:30 +01:00

602 lines
26 KiB
Objective-C

#import "SCIFFmpeg.h"
#import "ActionButton/SCIMediaActions.h"
#import <UIKit/UIKit.h>
#import <objc/runtime.h>
#import <objc/message.h>
#import <dlfcn.h>
#import <libkern/OSAtomic.h>
static Class FFmpegKitClass = nil;
static Class FFmpegSessionClass = nil;
static Class ReturnCodeClass = nil;
static BOOL sciFFmpegLoaded = NO;
static BOOL sciFFmpegChecked = NO;
// Cancellation state. All access to sciActiveURLSessions goes through sciCancelQueue.
static volatile int32_t sciCancelRequested = 0;
static NSHashTable<NSURLSession *> *sciActiveURLSessions = nil;
static dispatch_queue_t sciCancelQueue(void) {
static dispatch_queue_t q;
static dispatch_once_t once;
dispatch_once(&once, ^{
q = dispatch_queue_create("com.ryuk.scinsta.ffmpeg.cancel", DISPATCH_QUEUE_SERIAL);
sciActiveURLSessions = [NSHashTable weakObjectsHashTable];
});
return q;
}
static void sciRegisterSession(NSURLSession *session) {
if (!session) return;
dispatch_queue_t q = sciCancelQueue();
dispatch_sync(q, ^{ [sciActiveURLSessions addObject:session]; });
}
static void sciUnregisterSession(NSURLSession *session) {
if (!session) return;
dispatch_queue_t q = sciCancelQueue();
dispatch_sync(q, ^{ [sciActiveURLSessions removeObject:session]; });
}
static NSArray<NSURLSession *> *sciActiveSessionsSnapshot(void) {
__block NSArray *out = @[];
dispatch_queue_t q = sciCancelQueue();
dispatch_sync(q, ^{ out = [sciActiveURLSessions allObjects] ?: @[]; });
return out;
}
// Resolve the directory our dylib lives in (works for any injection method)
static NSString *sciDylibDir(void) {
Dl_info info;
if (dladdr((void *)sciDylibDir, &info) && info.dli_fname) {
NSString *path = [[NSString stringWithUTF8String:info.dli_fname] stringByDeletingLastPathComponent];
return path;
}
return nil;
}
static void sciLoadFFmpegKit(void) {
if (sciFFmpegChecked) return;
sciFFmpegChecked = YES;
NSMutableArray *paths = [NSMutableArray arrayWithArray:@[
// Sideload (Feather): .bundle copied to app root
[[[NSBundle mainBundle] bundlePath] stringByAppendingPathComponent:@"RyukGram.bundle/ffmpegkit.framework/ffmpegkit"],
// Sideload (cyan): injected into Frameworks/
[[[NSBundle mainBundle] privateFrameworksPath] stringByAppendingPathComponent:@"ffmpegkit.framework/ffmpegkit"],
// Jailbreak rootless
@"/var/jb/Library/Application Support/RyukGram.bundle/ffmpegkit.framework/ffmpegkit",
@"/var/jb/Library/MobileSubstrate/DynamicLibraries/ffmpegkit.framework/ffmpegkit",
// Jailbreak rootful
@"/Library/Application Support/RyukGram.bundle/ffmpegkit.framework/ffmpegkit",
@"/Library/MobileSubstrate/DynamicLibraries/ffmpegkit.framework/ffmpegkit",
]];
// Relative to our own dylib
NSString *dylibDir = sciDylibDir();
if (dylibDir) {
[paths insertObject:[dylibDir stringByAppendingPathComponent:@"ffmpegkit.framework/ffmpegkit"] atIndex:0];
}
NSFileManager *fm = [NSFileManager defaultManager];
void *handle = NULL;
NSMutableArray *dlErrors = [NSMutableArray array];
for (NSString *fwPath in paths) {
if (![fm fileExistsAtPath:fwPath]) continue;
// Preload deps (renamed _sci dir, original binary name)
NSString *fwDir = [[fwPath stringByDeletingLastPathComponent] stringByDeletingLastPathComponent];
NSArray *deps = @[@"libavutil", @"libswresample", @"libswscale",
@"libavcodec", @"libavformat", @"libavfilter", @"libavdevice"];
for (NSString *dep in deps) {
// Try _sci first (sideload), then original (jailbreak)
NSString *sciPath = [NSString stringWithFormat:@"%@/%@_sci.framework/%@", fwDir, dep, dep];
NSString *origPath = [NSString stringWithFormat:@"%@/%@.framework/%@", fwDir, dep, dep];
if ([fm fileExistsAtPath:sciPath]) dlopen(sciPath.UTF8String, RTLD_NOW | RTLD_GLOBAL);
else if ([fm fileExistsAtPath:origPath]) dlopen(origPath.UTF8String, RTLD_NOW | RTLD_GLOBAL);
}
handle = dlopen(fwPath.UTF8String, RTLD_NOW | RTLD_GLOBAL);
if (handle) {
NSLog(@"[SCInsta] FFmpegKit loaded from %@", fwPath);
break;
}
const char *err = dlerror();
[dlErrors addObject:[NSString stringWithFormat:@"%@\n%s", [fwPath lastPathComponent], err ?: "unknown"]];
}
if (!handle) {
NSLog(@"[SCInsta] FFmpegKit not available");
for (NSString *e in dlErrors) NSLog(@"[SCInsta] dlopen: %@", e);
dispatch_async(dispatch_get_main_queue(), ^{
NSMutableString *msg = [NSMutableString stringWithString:@"dlopen errors:\n"];
for (NSString *e in dlErrors) [msg appendFormat:@"%@\n\n", e];
[msg appendString:@"\nTried paths:\n"];
NSFileManager *fm2 = [NSFileManager defaultManager];
for (NSString *p in paths) {
BOOL exists = [fm2 fileExistsAtPath:p];
[msg appendFormat:@"%@ %@\n", exists ? @"" : @"", [p lastPathComponent]];
if (!exists) {
NSString *parent = [p stringByDeletingLastPathComponent];
NSString *grandparent = [parent stringByDeletingLastPathComponent];
[msg appendFormat:@" dir: %@ %@\n dir: %@ %@\n",
[fm2 fileExistsAtPath:parent] ? @"" : @"", [parent lastPathComponent],
[fm2 fileExistsAtPath:grandparent] ? @"" : @"", [grandparent lastPathComponent]];
}
}
NSString *bundlePath = [[NSBundle mainBundle] bundlePath];
NSArray *rootContents = [fm2 contentsOfDirectoryAtPath:bundlePath error:nil];
[msg appendString:@"\nApp bundle root:\n"];
for (NSString *item in rootContents)
if ([item containsString:@"RyukGram"] || [item containsString:@"ffmpeg"] || [item containsString:@".bundle"])
[msg appendFormat:@" %@\n", item];
NSString *fwPath = [[NSBundle mainBundle] privateFrameworksPath];
NSArray *fwContents = [fm2 contentsOfDirectoryAtPath:fwPath error:nil];
[msg appendString:@"\nFrameworks/:\n"];
for (NSString *item in fwContents)
if ([item containsString:@"ffmpeg"] || [item containsString:@"libav"] || [item containsString:@"libsw"] || [item containsString:@"RyukGram"])
[msg appendFormat:@" %@\n", item];
UIAlertController *alert = [UIAlertController alertControllerWithTitle:SCILocalized(@"FFmpegKit Debug")
message:msg preferredStyle:UIAlertControllerStyleAlert];
NSString *copyMsg = [msg copy];
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Copy") style:UIAlertActionStyleDefault handler:^(UIAlertAction *a) {
[UIPasteboard generalPasteboard].string = copyMsg;
}]];
[alert addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleCancel handler:nil]];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
UIViewController *root = [UIApplication sharedApplication].keyWindow.rootViewController;
while (root.presentedViewController) root = root.presentedViewController;
[root presentViewController:alert animated:YES completion:nil];
});
});
return;
}
FFmpegKitClass = NSClassFromString(@"FFmpegKit");
FFmpegSessionClass = NSClassFromString(@"FFmpegSession");
ReturnCodeClass = NSClassFromString(@"ReturnCode");
if (FFmpegKitClass) {
sciFFmpegLoaded = YES;
NSLog(@"[SCInsta] FFmpegKit ready");
} else {
NSLog(@"[SCInsta] FFmpegKit classes not found after dlopen");
dlclose(handle);
}
}
@implementation SCIFFmpeg
+ (BOOL)isAvailable {
sciLoadFFmpegKit();
return sciFFmpegLoaded;
}
+ (BOOL)isCancelled {
return sciCancelRequested == 1;
}
+ (void)cancelAll {
OSAtomicCompareAndSwap32(0, 1, &sciCancelRequested);
for (NSURLSession *s in sciActiveSessionsSnapshot()) {
@try { [s invalidateAndCancel]; } @catch (__unused id e) {}
}
// Class-level cancel stops any running FFmpeg session.
if (FFmpegKitClass) {
SEL cancelSel = NSSelectorFromString(@"cancel");
if ([FFmpegKitClass respondsToSelector:cancelSel]) {
@try { ((void(*)(id, SEL))objc_msgSend)(FFmpegKitClass, cancelSel); }
@catch (__unused id e) {}
}
}
// Grace period so the next download can proceed.
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)),
dispatch_get_main_queue(), ^{
OSAtomicCompareAndSwap32(1, 0, &sciCancelRequested);
});
}
+ (void)executeCommand:(NSString *)command
completion:(void(^)(BOOL success, NSString *output))completion {
if (![self isAvailable]) {
if (completion) completion(NO, @"FFmpegKit not available");
return;
}
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
@try {
SEL executeSel = NSSelectorFromString(@"execute:");
if (![FFmpegKitClass respondsToSelector:executeSel]) {
dispatch_async(dispatch_get_main_queue(), ^{
if (completion) completion(NO, @"FFmpegKit execute: not found");
});
return;
}
id session = ((id(*)(id, SEL, id))objc_msgSend)(FFmpegKitClass, executeSel, command);
if (!session) {
dispatch_async(dispatch_get_main_queue(), ^{
if (completion) completion(NO, @"FFmpegKit session nil");
});
return;
}
id returnCode = nil;
SEL rcSel = NSSelectorFromString(@"getReturnCode");
if ([session respondsToSelector:rcSel]) {
returnCode = ((id(*)(id, SEL))objc_msgSend)(session, rcSel);
}
BOOL success = NO;
if (ReturnCodeClass && returnCode) {
SEL isSuccessSel = NSSelectorFromString(@"isSuccess:");
if ([ReturnCodeClass respondsToSelector:isSuccessSel]) {
success = ((BOOL(*)(id, SEL, id))objc_msgSend)(ReturnCodeClass, isSuccessSel, returnCode);
}
}
NSString *output = nil;
SEL outputSel = NSSelectorFromString(@"getOutput");
if ([session respondsToSelector:outputSel]) {
output = ((id(*)(id, SEL))objc_msgSend)(session, outputSel);
}
dispatch_async(dispatch_get_main_queue(), ^{
if (completion) completion(success, output);
});
} @catch (NSException *e) {
dispatch_async(dispatch_get_main_queue(), ^{
if (completion) completion(NO, [NSString stringWithFormat:@"Exception: %@", e.reason]);
});
}
});
}
+ (void)probeCommand:(NSString *)command
completion:(void(^)(BOOL success, NSString *output))completion {
if (![self isAvailable]) {
if (completion) completion(NO, @"FFmpegKit not available");
return;
}
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
@try {
Class probeClass = NSClassFromString(@"FFprobeKit");
SEL executeSel = NSSelectorFromString(@"execute:");
if (!probeClass || ![probeClass respondsToSelector:executeSel]) {
dispatch_async(dispatch_get_main_queue(), ^{
if (completion) completion(NO, @"FFprobeKit not found");
});
return;
}
id session = ((id(*)(id, SEL, id))objc_msgSend)(probeClass, executeSel, command);
NSString *output = nil;
SEL outputSel = NSSelectorFromString(@"getOutput");
if (session && [session respondsToSelector:outputSel]) {
output = ((id(*)(id, SEL))objc_msgSend)(session, outputSel);
}
id returnCode = nil;
SEL rcSel = NSSelectorFromString(@"getReturnCode");
if (session && [session respondsToSelector:rcSel]) {
returnCode = ((id(*)(id, SEL))objc_msgSend)(session, rcSel);
}
BOOL success = NO;
if (ReturnCodeClass && returnCode) {
SEL isSuccessSel = NSSelectorFromString(@"isSuccess:");
if ([ReturnCodeClass respondsToSelector:isSuccessSel])
success = ((BOOL(*)(id, SEL, id))objc_msgSend)(ReturnCodeClass, isSuccessSel, returnCode);
}
dispatch_async(dispatch_get_main_queue(), ^{
if (completion) completion(success, output);
});
} @catch (NSException *e) {
dispatch_async(dispatch_get_main_queue(), ^{
if (completion) completion(NO, e.reason);
});
}
});
}
+ (void)convertAudioAtPath:(NSString *)inputPath
toFormat:(NSString *)format
bitrate:(NSString *)bitrate
completion:(void(^)(NSURL *outputURL, NSError *error))completion {
if (![self isAvailable]) {
if (completion) completion(nil, [NSError errorWithDomain:@"SCIFFmpeg" code:1
userInfo:@{NSLocalizedDescriptionKey: @"FFmpegKit not available"}]);
return;
}
NSString *outputPath = [NSTemporaryDirectory() stringByAppendingPathComponent:
[NSString stringWithFormat:@"sci_audio_%@.%@", [[NSUUID UUID] UUIDString], format]];
NSString *codecFlag;
if ([format isEqualToString:@"mp3"]) {
codecFlag = [NSString stringWithFormat:@"-c:a libmp3lame -b:a %@", bitrate ?: @"192k"];
} else {
codecFlag = [NSString stringWithFormat:@"-c:a aac -b:a %@", bitrate ?: @"192k"];
}
NSString *cmd = [NSString stringWithFormat:
@"-y -hide_banner -loglevel error -i '%@' -vn -map a %@ '%@'",
inputPath, codecFlag, outputPath];
[self executeCommand:cmd completion:^(BOOL success, NSString *output) {
if (success && [[NSFileManager defaultManager] fileExistsAtPath:outputPath]) {
if (completion) completion([NSURL fileURLWithPath:outputPath], nil);
} else {
if (completion) completion(nil, [NSError errorWithDomain:@"SCIFFmpeg" code:4
userInfo:@{NSLocalizedDescriptionKey: output ?: @"Audio conversion failed"}]);
}
}];
}
+ (void)muxVideoURL:(NSURL *)videoURL
audioURL:(NSURL *)audioURL
preset:(NSString *)preset
progress:(void(^)(float progress, NSString *stage))progressBlock
completion:(void(^)(NSURL *outputURL, NSError *error))completion {
[self muxVideoURL:videoURL audioURL:audioURL preset:preset
progress:progressBlock completion:completion cancelOut:nil];
}
+ (void)muxVideoURL:(NSURL *)videoURL
audioURL:(NSURL *)audioURL
preset:(NSString *)preset
progress:(void(^)(float progress, NSString *stage))progressBlock
completion:(void(^)(NSURL *outputURL, NSError *error))completion
cancelOut:(void(^)(void (^cancelBlock)(void)))cancelOut {
if (![self isAvailable]) {
if (completion) completion(nil, [NSError errorWithDomain:@"SCIFFmpeg" code:1
userInfo:@{NSLocalizedDescriptionKey: @"FFmpegKit not available"}]);
return;
}
__block BOOL completionCalled = NO;
void (^finish)(NSURL *, NSError *) = ^(NSURL *url, NSError *err) {
if (completionCalled) return;
completionCalled = YES;
dispatch_async(dispatch_get_main_queue(), ^{
if (completion) completion(url, err);
});
};
// Per-call cancellation — scoped to this mux only.
__block volatile int32_t thisCancelled = 0;
__block NSURLSession *bgSessionRef = nil;
__block long ffmpegSidRef = 0;
BOOL (^isCancelledLocal)(void) = ^BOOL{ return thisCancelled == 1; };
void (^cancelSelf)(void) = ^{
OSAtomicCompareAndSwap32(0, 1, &thisCancelled);
NSURLSession *s = bgSessionRef;
if (s) { @try { [s invalidateAndCancel]; } @catch (__unused id e) {} }
long sid = ffmpegSidRef;
if (sid && FFmpegKitClass) {
SEL cancelSel = NSSelectorFromString(@"cancel:");
if ([FFmpegKitClass respondsToSelector:cancelSel]) {
@try { ((void(*)(id, SEL, long))objc_msgSend)(FFmpegKitClass, cancelSel, sid); }
@catch (__unused id e) {}
}
}
};
if (cancelOut) cancelOut(cancelSelf);
void (^report)(float, NSString *) = ^(float p, NSString *s) {
if (!progressBlock || isCancelledLocal()) return;
dispatch_async(dispatch_get_main_queue(), ^{ progressBlock(p, s); });
};
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
NSString *tmpDir = NSTemporaryDirectory();
// Intermediates stay UUID-named; the muxed output uses the stem.
NSString *videoPath = [tmpDir stringByAppendingPathComponent:
[NSString stringWithFormat:@"sci_video_%@.mp4", [[NSUUID UUID] UUIDString]]];
NSString *audioPath = [tmpDir stringByAppendingPathComponent:
[NSString stringWithFormat:@"sci_audio_%@.m4a", [[NSUUID UUID] UUIDString]]];
NSString *outStem = [SCIMediaActions currentFilenameStem]
?: [NSString stringWithFormat:@"sci_muxed_%@", [[NSUUID UUID] UUIDString]];
NSString *outputPath = [tmpDir stringByAppendingPathComponent:
[NSString stringWithFormat:@"%@.mp4", outStem]];
NSError *(^cancelledError)(void) = ^NSError *{
return [NSError errorWithDomain:@"SCIFFmpeg" code:NSUserCancelledError
userInfo:@{NSLocalizedDescriptionKey: @"Cancelled"}];
};
void (^cleanupTmp)(void) = ^{
NSFileManager *fm = [NSFileManager defaultManager];
[fm removeItemAtPath:videoPath error:nil];
[fm removeItemAtPath:audioPath error:nil];
[fm removeItemAtPath:outputPath error:nil];
};
report(0.0, @"Downloading video...");
dispatch_semaphore_t sem = dispatch_semaphore_create(0);
__block NSMutableData *videoAccum = [NSMutableData data];
__block NSError *videoErr = nil;
NSURLSession *bgSession = [NSURLSession sessionWithConfiguration:
[NSURLSessionConfiguration ephemeralSessionConfiguration]];
bgSessionRef = bgSession;
sciRegisterSession(bgSession);
NSURLSessionDownloadTask *videoTask = [bgSession downloadTaskWithURL:videoURL
completionHandler:^(NSURL *loc, NSURLResponse *resp, NSError *err) {
videoErr = err;
if (loc) videoAccum = [[NSMutableData alloc] initWithContentsOfURL:loc];
dispatch_semaphore_signal(sem);
}];
[videoTask resume];
while (dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, 200 * NSEC_PER_MSEC)) != 0) {
if (isCancelledLocal()) {
[videoTask cancel];
break;
}
int64_t received = videoTask.countOfBytesReceived;
int64_t expected = videoTask.countOfBytesExpectedToReceive;
if (expected > 0) {
float frac = (float)received / (float)expected;
report(frac * 0.8f, @"Downloading video...");
}
}
if (isCancelledLocal()) {
sciUnregisterSession(bgSession);
[bgSession invalidateAndCancel];
cleanupTmp();
finish(nil, cancelledError());
return;
}
if (!videoAccum.length) {
sciUnregisterSession(bgSession);
[bgSession invalidateAndCancel];
cleanupTmp();
NSString *desc = videoErr ? videoErr.localizedDescription : @"Empty response";
finish(nil, [NSError errorWithDomain:@"SCIFFmpeg" code:2
userInfo:@{NSLocalizedDescriptionKey:
[NSString stringWithFormat:@"Failed to download video: %@", desc]}]);
return;
}
[videoAccum writeToFile:videoPath atomically:YES];
report(0.8f, @"Downloading audio...");
BOOL hasAudio = (audioURL != nil);
if (hasAudio) {
__block NSMutableData *audioAccum = nil;
__block NSURLSessionDownloadTask *audioTask = nil;
audioTask = [bgSession downloadTaskWithURL:audioURL
completionHandler:^(NSURL *loc, NSURLResponse *resp, NSError *err) {
if (loc) audioAccum = [[NSMutableData alloc] initWithContentsOfURL:loc];
dispatch_semaphore_signal(sem);
}];
[audioTask resume];
while (dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, 200 * NSEC_PER_MSEC)) != 0) {
if (isCancelledLocal()) { [audioTask cancel]; break; }
}
if (isCancelledLocal()) {
sciUnregisterSession(bgSession);
[bgSession invalidateAndCancel];
cleanupTmp();
finish(nil, cancelledError());
return;
}
if (audioAccum.length) {
[audioAccum writeToFile:audioPath atomically:YES];
} else {
hasAudio = NO;
}
}
sciUnregisterSession(bgSession);
[bgSession invalidateAndCancel];
report(0.9f, @"Encoding...");
// Encoding speed → videotoolbox bitrate
NSString *encFlags;
if ([preset isEqualToString:@"max"]) {
encFlags = @"-b:v 50M -profile:v high -level 5.1 -coder cabac";
} else if ([preset isEqualToString:@"fast"]) {
encFlags = @"-b:v 20M";
} else if ([preset isEqualToString:@"veryfast"]) {
encFlags = @"-b:v 12M";
} else {
encFlags = @"-b:v 8M -realtime 1";
}
NSString *cmd;
if (hasAudio) {
cmd = [NSString stringWithFormat:
@"-y -hide_banner "
@"-analyzeduration 1M -probesize 1M -fflags +genpts "
@"-i '%@' -i '%@' "
@"-map 0:v:0 -map 1:a:0 "
@"-c:a copy -c:v h264_videotoolbox %@ -allow_sw 1 "
@"-movflags +faststart -shortest '%@'",
videoPath, audioPath, encFlags, outputPath];
} else {
cmd = [NSString stringWithFormat:
@"-y -hide_banner "
@"-analyzeduration 1M -probesize 1M -fflags +genpts "
@"-i '%@' "
@"-c:v h264_videotoolbox %@ -allow_sw 1 "
@"-movflags +faststart '%@'",
videoPath, encFlags, outputPath];
}
// executeAsync returns the session synchronously so we can capture its id
// for per-session cancel.
__block BOOL ffSuccess = NO;
__block NSString *ffOutput = nil;
dispatch_semaphore_t ffSem = dispatch_semaphore_create(0);
id (^ffCallback)(id) = ^id(id session) {
SEL rcSel = NSSelectorFromString(@"getReturnCode");
if ([session respondsToSelector:rcSel]) {
id rc = ((id(*)(id, SEL))objc_msgSend)(session, rcSel);
if (ReturnCodeClass && rc) {
SEL isSuccessSel = NSSelectorFromString(@"isSuccess:");
if ([ReturnCodeClass respondsToSelector:isSuccessSel])
ffSuccess = ((BOOL(*)(id, SEL, id))objc_msgSend)(ReturnCodeClass, isSuccessSel, rc);
}
}
SEL outSel = NSSelectorFromString(@"getOutput");
if ([session respondsToSelector:outSel])
ffOutput = ((id(*)(id, SEL))objc_msgSend)(session, outSel);
dispatch_semaphore_signal(ffSem);
return nil;
};
SEL asyncSel = NSSelectorFromString(@"executeAsync:withCompleteCallback:");
if ([FFmpegKitClass respondsToSelector:asyncSel]) {
id session = ((id(*)(id, SEL, id, id))objc_msgSend)(FFmpegKitClass, asyncSel, cmd, ffCallback);
SEL sidSel = NSSelectorFromString(@"getSessionId");
if (session && [session respondsToSelector:sidSel]) {
ffmpegSidRef = ((long(*)(id, SEL))objc_msgSend)(session, sidSel);
}
dispatch_semaphore_wait(ffSem, DISPATCH_TIME_FOREVER);
} else {
// Fallback: synchronous execute (coarse cancel only).
[SCIFFmpeg executeCommand:cmd completion:^(BOOL ok, NSString *out) {
ffSuccess = ok; ffOutput = out; dispatch_semaphore_signal(ffSem);
}];
dispatch_semaphore_wait(ffSem, DISPATCH_TIME_FOREVER);
}
NSFileManager *fm = [NSFileManager defaultManager];
[fm removeItemAtPath:videoPath error:nil];
[fm removeItemAtPath:audioPath error:nil];
if (isCancelledLocal()) {
[fm removeItemAtPath:outputPath error:nil];
finish(nil, cancelledError());
return;
}
if (ffSuccess && [fm fileExistsAtPath:outputPath]) {
finish([NSURL fileURLWithPath:outputPath], nil);
} else {
[fm removeItemAtPath:outputPath error:nil];
finish(nil, [NSError errorWithDomain:@"SCIFFmpeg" code:3
userInfo:@{NSLocalizedDescriptionKey: ffOutput ?: @"FFmpeg mux failed"}]);
}
});
}
@end