diff --git a/README.md b/README.md index 3fcf335..ff888eb 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,22 @@ -# PaintFCP FxPlug4 +# 大鱼人像美化 · beatygril -独立 FxPlug4 滤镜程序源码工程。修改前的 `beatygril` 是 Apple Tileable Remote Brightness 模板;在此基础上实现新的滤镜。第三方参考 APP、二进制和资源不进入仓库。 +基于本仓库的 FxPlug4 初始工程制作的人像美化滤镜,支持 Motion 和 Final Cut Pro。源码使用 Objective-C、Vision、Core Image 与 Metal。 -## 工程 +功能包括磨皮、肤色、细节、牙齿美白、眼睛提亮、九类脸型调整、画面选脸、三目标跟踪和蒙版预览。原版 MSE18V2 的静态分析依据、近似实现与尚未对齐的部分见 [分析说明](docs/analysis/REA-分析与复刻说明.md)。本版尚未完成原版逐像素与复杂运动跟踪对照。 -- `beatygril/beatygril.xcodeproj`:Xcode 工程,`Wrapper Application` 与 `XPC Service` 两个 target。 -- `beatygril/beatygril/Plugin`:Objective-C `FxTileableEffect` 实现、Metal GPU shader 和设备缓存。 -- `beatygril/beatygril/Wrapper Application`:插件外层 APP 和沙盒 entitlement。 -- 原始参数:Brightness,默认 1.0,参数范围 0–100,滑块范围 0–10;RGB 乘亮度、Alpha 保持。 +## 使用 -## 本机依赖与构建入口 +正式安装路径为 `/Library/Plug-Ins/FxPlug/beatygril.app`。Motion 滤镜分类为「大鱼」,滤镜名「大鱼人像美化」。Final Cut Pro 使用配套 Motion 效果。先调高中频磨皮,再按需调肤色与脸型;勾选「只处理选中人脸」可在画面点击选脸,随后选择跟踪目标、向前/向后分析,数据随工程保存。 -盘点环境:Xcode 27.1(27A9275),FxPlug.framework 4.3.4、PluginManager.framework 1.7.4;附加 SDK 为 `/Library/Developer/SDKs/FxPlug.sdk`。SDK 内含开发 headers,运行 framework 位于 `/Library/Developer/Frameworks`。项目初始最低部署目标为 macOS 27.2。 +## 开发和恢复 + +需要 Xcode 27.1、FxPlug SDK 4.3.4 与 PluginManager 1.7.4;最低运行系统 macOS 13。正式产物为 arm64/x86_64 通用版本。 ```sh -xcodebuild -list -project beatygril/beatygril.xcodeproj -xcodebuild -project beatygril/beatygril.xcodeproj \ - -scheme 'Wrapper Application' -configuration Release \ - -derivedDataPath /tmp/paintfcp-build \ - CODE_SIGN_STYLE=Manual \ - CODE_SIGN_IDENTITY='Developer ID Application: zhong luo (B3W25FX5H5)' \ - DEVELOPMENT_TEAM=B3W25FX5H5 build +./scripts/test.sh +xcodebuild -project beatygril/beatygril.xcodeproj -scheme 'Wrapper Application' -configuration Release -derivedDataPath /tmp/paintfcp-build build ``` -初始工程复制并签名 FxPlug 与 PluginManager 两个 framework;正式交付必须依次完成 Release 构建、内到外签名、Apple 公证 Accepted、票据粘附与验证、唯一正式路径安装及宿主发现验证。初始基线仅核验工程可读取和依赖,不代表已完成构建或交付。 +工程从 `/Library/Developer/SDKs/FxPlug.sdk` 读取头文件与链接信息,从 `/Library/Developer/Frameworks` 嵌入可运行的 Apple 框架。构建默认使用团队 B3W25FX5H5 的 Developer ID Application 身份;正式交付需继续完成公证、粘附和安装。 -参考 [RECOVERY.md](RECOVERY.md) 恢复源码与依赖。签名证书、私钥和公证凭据均由本机钥匙串管理,不随源码保存。 +私有源码仓库:`https://dayu.media/git/dayu/paintfcp-fxplug.git`。恢复入口见 [RECOVERY.md](RECOVERY.md),当前验证记录见 [验证记录](docs/验证记录.md)。原始模板保存在 `baseline-20261021` 标签。 diff --git a/beatygril/beatygril/Plugin/Info.plist b/beatygril/beatygril/Plugin/Info.plist index fed2a3c..4de2cc0 100644 --- a/beatygril/beatygril/Plugin/Info.plist +++ b/beatygril/beatygril/Plugin/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType XPC! CFBundleShortVersionString - 1.0 + 1.0.1 CFBundleVersion - 1 + 2 NSHumanReadableCopyright PlugInKit @@ -72,7 +72,7 @@ uuid FB14B0E6-59B2-4836-916F-CD0C559E9241 version - 1.0 + 1.0.1 className @@ -84,7 +84,7 @@ uuid E0E432C8-3D71-4ABA-B006-E7F519E06C87 version - 1.0 + 1.0.1 protocolNames FxOnScreenControl diff --git a/beatygril/beatygril/Plugin/beatygrilPlugIn.m b/beatygril/beatygril/Plugin/beatygrilPlugIn.m index 8d308e4..2d70c35 100644 --- a/beatygril/beatygril/Plugin/beatygrilPlugIn.m +++ b/beatygril/beatygril/Plugin/beatygrilPlugIn.m @@ -208,8 +208,7 @@ static CIImage *PGImageFromTile(FxImageTile *tile) { } } - (BOOL)renderDestinationImage:(FxImageTile *)destination sourceImages:(NSArray *)sources pluginState:(NSData *)state atTime:(CMTime)time error:(NSError **)error { - @autoreleasepool { - if(sources.count==0||!sources.firstObject.ioSurface||!destination.ioSurface||state.length>320*1024*1024) return PGFail(error,@"宿主传入的画面或状态无效。"); + if(sources.count==0||!sources.firstObject.ioSurface||!destination.ioSurface||![state isKindOfClass:NSData.class]||state.length==0||state.length>320*1024*1024) return PGFail(error,@"宿主传入的画面或状态无效。"); NSDictionary *plist=[NSPropertyListSerialization propertyListWithData:state options:NSPropertyListImmutable format:nil error:error]; if(![plist isKindOfClass:NSDictionary.class]||![plist[@"settings"] isKindOfClass:NSData.class]||[plist[@"settings"] length]!=sizeof(PGBeautySettings)) return PGFail(error,@"渲染状态版本无效。"); PGBeautySettings s;[plist[@"settings"] getBytes:&s length:sizeof(s)]; @@ -246,7 +245,6 @@ static CIImage *PGImageFromTile(FxImageTile *tile) { @try {[context render:output toIOSurface:(__bridge IOSurfaceRef)destination.ioSurface bounds:CGRectMake(0,0,width,height) colorSpace:nil];} @catch(NSException *exception) {return PGFail(error,exception.reason?:@"图像处理失败。");} return YES; - } } - (void)setStatus:(NSString *)status { id api=[_apiManager apiForProtocol:@protocol(FxParameterSettingAPI_v5)]; @@ -277,6 +275,7 @@ static CIImage *PGImageFromTile(FxImageTile *tile) { - (void)analyzeForward {[self startAnalysisForward:YES];} - (void)analyzeBackward {[self startAnalysisForward:NO];} - (void)clearTracking { + if(_analyzing) {[self setStatus:@"请先结束或取消分析,再清除跟踪数据。"];return;} PGBeautySettings s=[self settingsAtTime:[self currentHostTime]]; @synchronized(_faceProcessor) {[_faceProcessor setTrackingData:nil forTarget:s.targetIndex];_loadedTracks=nil;} id api=[_apiManager apiForProtocol:@protocol(FxParameterSettingAPI_v5)]; @@ -307,6 +306,7 @@ static CIImage *PGImageFromTile(FxImageTile *tile) { return ok; } - (BOOL)cleanupAnalysis:(NSError **)error { + _analyzing=NO; id api=[_apiManager apiForProtocol:@protocol(FxParameterSettingAPI_v5)]; NSData *data=nil; @synchronized(_analysisFaceProcessor) {data=[_analysisFaceProcessor trackingDataForTarget:_analysisSettings.targetIndex];} diff --git a/beatygril/beatygril/Wrapper Application/Info.plist b/beatygril/beatygril/Wrapper Application/Info.plist index 32d5337..16fb060 100644 --- a/beatygril/beatygril/Wrapper Application/Info.plist +++ b/beatygril/beatygril/Wrapper Application/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.0 + 1.0.1 CFBundleVersion - 1 + 2 LSMinimumSystemVersion $(MACOSX_DEPLOYMENT_TARGET) NSHumanReadableCopyright diff --git a/docs/analysis/REA-分析与复刻说明.md b/docs/analysis/REA-分析与复刻说明.md new file mode 100644 index 0000000..f235713 --- /dev/null +++ b/docs/analysis/REA-分析与复刻说明.md @@ -0,0 +1,51 @@ +# MSE18V2 分析与 beatygril 复刻 + +分析日期:2026-10-21。目标为用户指定的 `/Applications/MSE18V2.app`,原件保持只读。实现基于 PaintFCP 内原始 FxPlug4 工程,产品路径和插件 UUID 独立于原版。 + +## 证据与方法 + +REA 直接打开内嵌插件。原包为 arm64/x86_64 通用 Mach-O,Ghidra 无法保证通用包的分析切片,故用 `lipo -thin arm64` 创建临时分析副本,再用 REA/Ghidra 12.1.4 分析。所有反编译文本均为机器生成伪代码,属于静态观察与实现推断,不代表原始源码或运行记录。 + +- 原始插件 SHA-256:`6c5d74bc844a5c5f745718b48ea10ec6635d08aa08a5b6dc6e62653980849f8a`。 +- arm64 插件切片:`f01e8c0b7e2676b2961950b0e48efdd2589af218e4b36de7d39f5d1c76852bfb`。 +- 外层 APP arm64 切片:`958d2ae106a023febe244397402cdc3f1977cbc529811335fca15b93ed569a10`。 +- 证据包:[插件](rea-plugin.json)、[外层 APP](rea-host.json)。参考 APP、metallib 和 LUT 保留在原位置,没有打包到本项目。 + +|问题|静态结果与证据|结论状态| +|---|---|---| +|APP 与插件职责|外层 `AppDelegate::applicationDidFinishLaunching:` 调用启动图窗口;主要功能在内嵌 `MSSuperSkinSmoothingPlugIn`。`ev_e57649ee7791e0ee7f491c2659e8aece223aca06c5a95f45406c5e27e5b0974b`|已回答| +|参数与范围|`addParametersWithError:` 的常规参数含磨皮、肤色、细节、牙齿、九项脸型、选脸、跟踪和蒙版;多数原生参数隐藏,交由自定义面板。`ev_4ab9fb8e1643d141ad84355f7fde14e9269f94adadf9348b1175bf308cbcb7c1`|已回答主要参数,动态面板全部交互未运行覆盖| +|处理顺序|`YUCIHighPassSkinSmoothing::outputImage` 与插件 `applySkinSmoothingToImage:` 提供局部细节、调色、锐化、预设、高频细节与牙齿处理入口。`ev_65936f315a3978bebcafd04e028acc5609c1c092282c05e0ca08cf2b279bc9ad`|已回答主链| +|磨皮算法|局部/全局细节 helper 使用双 Gaussian 与亮度频带压缩;备用蒙版存在 G/B overlay、高通和三次 hard-light。`ev_0ea0cc3a7596fe3d6f4023cc4ab539bb81f65c6fdb3f28074712847e9dd048b8`|已回答主链,部分 mask 公式采用独立实现| +|人脸与跟踪|Vision landmarks、原版 Metal 位移网格、OpenCV 动态库、历史脸框/关键点、选择及分析数据均有证据。`renderDestinationImage:` 及函数检索见插件证据包|部分回答;运动遮挡、切镜与完整身份逻辑未等价验证| +|像素一致性|未获得原版相同输入/参数的可信渲染对照|未验证,不能声称逐像素一致| + +## 已实现的功能 + +- 中频磨皮、强度、半径、高频细节平滑、锐化、五官保护及各大功能分组开关。 +- 肤色提亮、色相、饱和度、均匀肤色;原色及自然白、冷白、暖白、红润白、小麦、古铜六个程序化风格。 +- 牙齿美白和眼睛提亮,基于 Vision 面部区域构建软蒙版。 +- 瘦脸、大眼、短脸、窄脸、下巴、V 脸、下颌、颧骨、小头和小头范围。 +- 全脸/选中脸、画面点击选脸、多人高速检测、防跳脸范围、脸框和跟踪框。 +- 三个独立跟踪目标,向前/向后分析,清除当前目标;精确 CMTime 数据持久化到宿主工程,可逆序和任意顺序读取。 +- 蒙版阈值/柔度/边缘保护、皮肤/牙齿/眼睛通道预览与反转、包含几何变形的整体混合。 +- 保留透明度、完整输入范围、非零图像原点与宿主上下原点;原生 FxPlug 参数可由宿主保存和制作关键帧。 + +## 复刻边界 + +本交付为功能相近的独立实现,算法结果存在以下差异: + +1. 原版六个肤色 LUT 由独立颜色变换替代,风格名称接近,颜色值不相同。 +2. 原版人脸网格/曲线位移采用本项目的平滑椭圆采样位移替代。各脸型权重和曲线、眼嘴软蒙版由本项目编写。 +3. 皮肤支持区域使用 Vision 面部区域及独立的肤色/高通备用蒙版;原版完整皮肤分割、时间稳定和细部 mask 尚未等价恢复。 +4. 本版最多保存三个独立目标、每次最多处理十六张脸。原版存在动态目标列表与旧版三目标字段,本次未完整恢复动态列表上限及全部分页操作。 +5. 本版逐帧 Vision 分析并保存几何数据,渲染仅读取记录;原版 OpenCV 光流与时间稳定策略没有照搬。真实运动、多人交叉、遮挡、快速切镜的身份保持尚需专项对照。 +6. 界面使用宿主的分组滑块与开关;原版卡片面板的布局没有照搬。眼睛提亮在本版可用,所分析原版主链实际将该项输入设为零。 + +跟踪数据每目标保存上限为 64 MB;超过上限时保留此前已保存的数据并报告错误。长素材应分段分析。仅实际验证通过的内容计入完成;本说明中的未知项不因编译或离屏测试通过而自动消除。 + +## 验证 + +`./scripts/test.sh` 运行实际 Metal/Core Image 像素测试及 Vision 人脸测试。测试素材是 scikit-image 发布的 NASA 公共领域人像,出处见 `tests/fixtures/ATTRIBUTION.txt`。测试包含实际人脸 landmarks、真实几何变化、五官蒙版、单次混合、透明度和跟踪存储恢复。 + +正式签名、公证、安装、宿主发现/加载以及最终远程恢复结果记录在 `docs/验证记录.md`。 diff --git a/docs/analysis/beauty-preview.png b/docs/analysis/beauty-preview.png new file mode 100644 index 0000000..35ace47 Binary files /dev/null and b/docs/analysis/beauty-preview.png differ diff --git a/docs/analysis/face-preview.png b/docs/analysis/face-preview.png new file mode 100644 index 0000000..9a5bab3 Binary files /dev/null and b/docs/analysis/face-preview.png differ diff --git a/scripts/deliver.py b/scripts/deliver.py new file mode 100755 index 0000000..75f61ed --- /dev/null +++ b/scripts/deliver.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Release, notarize and install the unique FxPlug app under its delivery lock. +The final prompt keeps the lock held while native-host validation is performed. +""" +import json, os, pathlib, plistlib, shutil, subprocess, sys, time +root=pathlib.Path(__file__).resolve().parent.parent +bundle_id='com.dayu.beatygril' +lock=pathlib.Path('/Users/dayu/.codex/app-delivery-locks')/(bundle_id+'.lock') +lock.parent.mkdir(parents=True,exist_ok=True) +try:lock.mkdir() +except FileExistsError:raise SystemExit('Delivery lock occupied; inspect owner.json and the owner process') +(lock/'owner.json').write_text(json.dumps({'pid':os.getpid(),'cwd':str(root),'bundle_id':bundle_id})) +staging=pathlib.Path('/tmp/paintfcp-build-20261021') +logs=root/'build'/'delivery';logs.mkdir(parents=True,exist_ok=True) +app=staging/'Build/Products/Release/beatygril.app' +installed=pathlib.Path('/Library/Plug-Ins/FxPlug/beatygril.app') +archive=logs/'notarization.zip' +identity='Developer ID Application: zhong luo (B3W25FX5H5)' +keychain='/Users/dayu/Library/Keychains/login.keychain-db' +previous=logs/'previous-beatygril.app' +def run(args, log=None): + if log: + with open(log,'w') as output:subprocess.run(args,cwd=root,stdout=output,stderr=subprocess.STDOUT,check=True) + return '' + return subprocess.run(args,cwd=root,text=True,capture_output=True,check=True).stdout +try: + identities=run(['security','find-identity','-v','-p','codesigning',keychain]) + if identity not in identities:raise RuntimeError('Valid Developer ID Application identity is unavailable') + print('Building universal Release…',flush=True) + run(['xcodebuild','-project','beatygril/beatygril.xcodeproj','-scheme','Wrapper Application','-configuration','Release','-derivedDataPath',str(staging),'-destination','generic/platform=macOS','build'],logs/'build.log') + plugin=app/'Contents/PlugIns/beatygril XPC Service.pluginkit' + for framework in sorted((plugin/'Contents/Frameworks').glob('*.framework')): + run(['codesign','--force','--timestamp','--options','runtime','--sign',identity,str(framework)]) + run(['codesign','--force','--timestamp','--options','runtime','--sign',identity,str(plugin)]) + run(['codesign','--force','--timestamp','--options','runtime','--entitlements',str(root/'beatygril/beatygril/Wrapper Application/SandboxEntitlements.entitlements'),'--sign',identity,str(app)]) + run(['codesign','--verify','--deep','--strict',str(app)]) + run(['ditto','-c','-k','--keepParent',str(app),str(archive)]) + print('Submitting signed app to Apple notarization…',flush=True) + response=run(['xcrun','notarytool','submit',str(archive),'--keychain-profile','codex-dayu-notary-B3W25FX5H5','--keychain',keychain,'--wait','--output-format','json']) + (logs/'notarization.json').write_text(response) + result=json.loads(response) + if result.get('status')!='Accepted': + if result.get('id'): + (logs/'notarization-details.json').write_text(run(['xcrun','notarytool','log',result['id'],'--keychain-profile','codex-dayu-notary-B3W25FX5H5','--keychain',keychain])) + raise RuntimeError('Apple did not accept notarization; original installed app retained') + run(['xcrun','stapler','staple',str(app)]) + run(['xcrun','stapler','validate',str(app)]) + if installed.exists(): + info=plistlib.loads((installed/'Contents/Info.plist').read_bytes()) + if info.get('CFBundleIdentifier')!=bundle_id:raise RuntimeError('The installation path belongs to another product') + if previous.exists():raise RuntimeError('Previous recovery app already exists') + shutil.move(str(installed),str(previous)) + try: + shutil.copytree(app,installed,symlinks=True) + run(['codesign','--verify','--deep','--strict',str(installed)]) + run(['xcrun','stapler','validate',str(installed)]) + run(['spctl','--assess','--type','execute','--verbose=2',str(installed)]) + info=plistlib.loads((installed/'Contents/Info.plist').read_bytes()) + signature=subprocess.run(['codesign','-dvvv',str(installed)],capture_output=True,text=True,check=True).stderr + if info.get('CFBundleIdentifier')!=bundle_id or 'TeamIdentifier=B3W25FX5H5' not in signature:raise RuntimeError('Installed app identity mismatch') + (logs/'installed-signature.txt').write_text(signature) + run(['pluginkit','-a',str(installed/'Contents/PlugIns/beatygril XPC Service.pluginkit')]) + except Exception: + if installed.exists():shutil.rmtree(installed) + if previous.exists():shutil.move(str(previous),str(installed)) + raise + print(json.dumps({'status':'installed','path':str(installed),'notarization':result}),flush=True) + input('HOST_VALIDATION_PENDING: press Return only after host verification and duplicate inventory.\n') + # Only this task’s known generated product copies are removed. + if previous.exists():shutil.rmtree(previous) + if archive.exists():archive.unlink() + if staging.exists():shutil.rmtree(staging) + print('Delivery staging and notarization archive cleaned.',flush=True) +finally: + (lock/'owner.json').unlink();lock.rmdir() diff --git a/scripts/test-host.sh b/scripts/test-host.sh new file mode 100755 index 0000000..ef9c7f6 --- /dev/null +++ b/scripts/test-host.sh @@ -0,0 +1,23 @@ +#!/bin/zsh +set -euo pipefail +cd "${0:A:h:h}" +output_dir=$(mktemp -d /tmp/paintfcp-host-tests.XXXXXX) +trap 'rm -rf "$output_dir"' EXIT +plugin_dir="$PWD/beatygril/beatygril/Plugin" +sdk_frameworks=/Library/Developer/SDKs/FxPlug.sdk/Library/Frameworks +runtime_frameworks=/Library/Developer/Frameworks +compiler_extra=() +if [[ "${PAINTFCP_HOST_ASAN:-0}" == 1 ]]; then + compiler_extra=(-fsanitize=address -g) +fi +xcrun clang "${compiler_extra[@]}" -fobjc-arc -fmodules -Wall -Wextra -Wno-unused-parameter \ + -DCI_SILENCE_GL_DEPRECATION -mmacosx-version-min=13.0 \ + -I "$plugin_dir" -F "$sdk_frameworks" -F "$runtime_frameworks" \ + -Wl,-rpath,"$runtime_frameworks" \ + "$plugin_dir/PGBeautyRenderer.m" "$plugin_dir/PGFaceProcessor.m" \ + "$plugin_dir/beatygrilPlugIn.m" tests/host_adapter_tests.m \ + -framework Foundation -framework CoreImage -framework CoreGraphics \ + -framework CoreMedia -framework CoreVideo -framework IOSurface -framework Metal \ + -framework Vision -framework ImageIO -framework FxPlug -framework PluginManager \ + -o "$output_dir/host-tests" +"$output_dir/host-tests" "$PWD/tests/fixtures/astronaut.png" "$@" diff --git a/tests/host_adapter_tests.m b/tests/host_adapter_tests.m new file mode 100644 index 0000000..8091c15 --- /dev/null +++ b/tests/host_adapter_tests.m @@ -0,0 +1,373 @@ +#import +#import +#import +#import +#import +#import +#import +#import "beatygrilPlugIn.h" +#import "PGParameterIDs.h" + +// This adapter simulates host callbacks, but uses real IOSurface buffers and the +// production FxPlug entry points. It does not simulate FCP plugin discovery/UI. +@interface PGHostAPI : NSObject +@property NSMutableDictionary *values; +@property NSMutableDictionary *definitions; +@property NSMutableSet *disabledAPIs; +@property NSInteger depth; +@property BOOL duplicate; +@property UInt32 rejectedParameter; +@property CMTime hostTime; +@property CMTime lastReadTime; +@property CMTime lastSetBoolTime; +@property NSUInteger forwardRequests; +@property NSUInteger backwardRequests; +@property BOOL rejectAnalysis; +@property BOOL targetAtCurrentTime; +@property UInt32 rejectedWriteParameter; +@end + +@implementation PGHostAPI +- (instancetype)init { + if ((self = [super init])) { + _values = [NSMutableDictionary dictionary]; _definitions = [NSMutableDictionary dictionary]; + _disabledAPIs = [NSMutableSet set]; _hostTime = CMTimeMake(120, 24); + } + return self; +} +- (id)apiForProtocol:(Protocol *)protocol { + NSString *name = NSStringFromProtocol(protocol); + if ([_disabledAPIs containsObject:name]) return nil; + if ([@[@"FxParameterCreationAPI_v5", @"FxParameterRetrievalAPI_v6", @"FxParameterSettingAPI_v5", @"FxCustomParameterActionAPI_v4", @"FxAnalysisAPI"] containsObject:name]) return self; + return nil; +} +- (BOOL)define:(UInt32)identifier kind:(NSString *)kind value:(id)value flags:(FxParameterFlags)flags extra:(NSDictionary *)extra { + if (_definitions[@(identifier)]) _duplicate = YES; + NSMutableDictionary *definition = [@{@"kind":kind, @"flags":@(flags)} mutableCopy]; + if (extra) [definition addEntriesFromDictionary:extra]; + _definitions[@(identifier)] = definition; + if (value) _values[@(identifier)] = value; + return identifier != _rejectedParameter; +} +- (BOOL)addFloatSliderWithName:(NSString *)name parameterID:(UInt32)identifier defaultValue:(double)value parameterMin:(double)minimum parameterMax:(double)maximum sliderMin:(double)sliderMin sliderMax:(double)sliderMax delta:(double)delta parameterFlags:(FxParameterFlags)flags { + return [self define:identifier kind:@"float" value:@(value) flags:flags extra:@{@"name":name, @"min":@(minimum), @"max":@(maximum)}]; +} +- (BOOL)addPercentSliderWithName:(NSString *)name parameterID:(UInt32)identifier defaultValue:(double)value parameterMin:(double)minimum parameterMax:(double)maximum sliderMin:(double)sliderMin sliderMax:(double)sliderMax delta:(double)delta parameterFlags:(FxParameterFlags)flags { + return [self define:identifier kind:@"percent" value:@(value) flags:flags extra:@{@"name":name, @"min":@(minimum), @"max":@(maximum)}]; +} +- (BOOL)addToggleButtonWithName:(NSString *)name parameterID:(UInt32)identifier defaultValue:(BOOL)value parameterFlags:(FxParameterFlags)flags { + return [self define:identifier kind:@"toggle" value:@(value) flags:flags extra:@{@"name":name}]; +} +- (BOOL)addPopupMenuWithName:(NSString *)name parameterID:(UInt32)identifier defaultValue:(int)value menuEntries:(NSArray *)entries parameterFlags:(FxParameterFlags)flags { + return [self define:identifier kind:@"popup" value:@(value) flags:flags extra:@{@"name":name, @"entries":entries}]; +} +- (BOOL)addPointParameterWithName:(NSString *)name parameterID:(UInt32)identifier defaultX:(double)x defaultY:(double)y parameterFlags:(FxParameterFlags)flags { + return [self define:identifier kind:@"point" value:@[@(x), @(y)] flags:flags extra:@{@"name":name}]; +} +- (BOOL)addPushButtonWithName:(NSString *)name parameterID:(UInt32)identifier selector:(SEL)selector parameterFlags:(FxParameterFlags)flags { + return [self define:identifier kind:@"button" value:nil flags:flags extra:@{@"name":name, @"selector":NSStringFromSelector(selector)}]; +} +- (BOOL)addStringParameterWithName:(NSString *)name parameterID:(UInt32)identifier defaultValue:(NSString *)value parameterFlags:(FxParameterFlags)flags { + return [self define:identifier kind:@"string" value:value flags:flags extra:@{@"name":name}]; +} +- (BOOL)startParameterSubGroup:(NSString *)name parameterID:(UInt32)identifier parameterFlags:(FxParameterFlags)flags { + _depth++; + return [self define:identifier kind:@"group" value:nil flags:flags extra:@{@"name":name}]; +} +- (BOOL)endParameterSubGroup { _depth--; return _depth >= 0; } +- (BOOL)getFloatValue:(double *)value fromParameter:(UInt32)identifier atTime:(CMTime)time { + _lastReadTime = time; id object = _values[@(identifier)]; + if (![object isKindOfClass:NSNumber.class]) return NO; *value = [object doubleValue]; return YES; +} +- (BOOL)getIntValue:(int *)value fromParameter:(UInt32)identifier atTime:(CMTime)time { + _lastReadTime = time; + if (identifier == PGTarget && _targetAtCurrentTime) { *value = CMTimeCompare(time, _hostTime) == 0 ? 2 : 0; return YES; } + id object = _values[@(identifier)]; if (![object isKindOfClass:NSNumber.class]) return NO; *value = [object intValue]; return YES; +} +- (BOOL)getBoolValue:(BOOL *)value fromParameter:(UInt32)identifier atTime:(CMTime)time { + _lastReadTime = time; id object = _values[@(identifier)]; + if (![object isKindOfClass:NSNumber.class]) return NO; *value = [object boolValue]; return YES; +} +- (BOOL)getXValue:(double *)x YValue:(double *)y fromParameter:(UInt32)identifier atTime:(CMTime)time { + _lastReadTime = time; NSArray *point = _values[@(identifier)]; + if (![point isKindOfClass:NSArray.class] || point.count != 2) return NO; + *x = [point[0] doubleValue]; *y = [point[1] doubleValue]; return YES; +} +- (BOOL)getStringParameterValue:(NSString **)value fromParameter:(UInt32)identifier { + id object = _values[@(identifier)]; if (![object isKindOfClass:NSString.class]) return NO; *value = object; return YES; +} +- (BOOL)setStringParameterValue:(NSString *)value toParameter:(UInt32)identifier { + if (identifier == _rejectedWriteParameter) return NO; + _values[@(identifier)] = value; return YES; +} +- (BOOL)setBoolValue:(BOOL)value toParameter:(UInt32)identifier atTime:(CMTime)time { _lastSetBoolTime = time; _values[@(identifier)] = @(value); return YES; } +- (CMTime)currentTime { return _hostTime; } +- (BOOL)startForwardAnalysis:(FxAnalysisLocation)location error:(NSError **)error { + _forwardRequests++; return !_rejectAnalysis; +} +- (BOOL)startBackwardAnalysis:(FxAnalysisLocation)location error:(NSError **)error { + _backwardRequests++; return !_rejectAnalysis; +} +@end + +@interface PGHostTile : FxImageTile +@property (strong) IOSurface *storage; +@property FxRect pixelBounds; +@property FxRect fullBounds; +@property FxImageOrigin origin; +@property uint64_t registry; +@property CGColorSpaceRef space; +@end +@implementation PGHostTile +- (IOSurface *)ioSurface { return _storage; } +- (FxRect)tilePixelBounds { return _pixelBounds; } +- (FxRect)imagePixelBounds { return _fullBounds; } +- (FxImageOrigin)imageOrigin { return _origin; } +- (uint64_t)deviceRegistryID { return _registry; } +- (CGColorSpaceRef)colorSpace { return _space; } +- (void)dealloc { if (_space) CGColorSpaceRelease(_space); } +@end + +static NSUInteger Checks = 0, Failures = 0; +static BOOL RectEqual(FxRect a, FxRect b) { return a.left == b.left && a.bottom == b.bottom && a.right == b.right && a.top == b.top; } +static void Check(BOOL condition, NSString *description) { + Checks++; + if (!condition) { Failures++; fprintf(stderr, "FAIL: %s\n", description.UTF8String); } + else if (getenv("PAINTFCP_HOST_TEST_VERBOSE")) fprintf(stdout, "PASS: %s\n", description.UTF8String); +} +static PGHostTile *Tile(NSUInteger width, NSUInteger height, NSInteger left, NSInteger bottom, FxImageOrigin origin) { + IOSurfaceRef surface = IOSurfaceCreate((__bridge CFDictionaryRef)@{ + (__bridge id)kIOSurfaceWidth:@(width), (__bridge id)kIOSurfaceHeight:@(height), + (__bridge id)kIOSurfaceBytesPerElement:@4, (__bridge id)kIOSurfaceBytesPerRow:@(width * 4), + (__bridge id)kIOSurfacePixelFormat:@(kCVPixelFormatType_32BGRA), + (__bridge id)kIOSurfaceAllocSize:@(width * height * 4)}); + PGHostTile *tile = [PGHostTile new]; tile.storage = CFBridgingRelease(surface); + tile.pixelBounds = (FxRect){(SInt32)left, (SInt32)bottom, (SInt32)(left + width), (SInt32)(bottom + height)}; + tile.fullBounds = tile.pixelBounds; tile.origin = origin; + tile.registry = MTLCreateSystemDefaultDevice().registryID; + tile.space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB); + return tile; +} +static void Fill(PGHostTile *tile) { + IOSurfaceRef surface = (__bridge IOSurfaceRef)tile.storage; + IOSurfaceLock(surface, 0, NULL); + unsigned char *bytes = IOSurfaceGetBaseAddress(surface); size_t row = IOSurfaceGetBytesPerRow(surface); + for (size_t y = 0; y < IOSurfaceGetHeight(surface); y++) for (size_t x = 0; x < IOSurfaceGetWidth(surface); x++) { + unsigned char *p = bytes + y * row + x * 4; + p[0] = 20 + x % 150; p[1] = 35 + y % 170; p[2] = 45 + (x * 3 + y * 5) % 180; p[3] = 255; + } + IOSurfaceUnlock(surface, 0, NULL); +} +static NSData *SurfaceBytes(PGHostTile *tile) { + IOSurfaceRef surface = (__bridge IOSurfaceRef)tile.storage; + IOSurfaceLock(surface, kIOSurfaceLockReadOnly, NULL); + size_t width = IOSurfaceGetWidth(surface), height = IOSurfaceGetHeight(surface), row = IOSurfaceGetBytesPerRow(surface); + NSMutableData *result = [NSMutableData dataWithLength:width * height * 4]; + for (size_t y = 0; y < height; y++) memcpy((unsigned char *)result.mutableBytes + y * width * 4, (unsigned char *)IOSurfaceGetBaseAddress(surface) + y * row, width * 4); + IOSurfaceUnlock(surface, kIOSurfaceLockReadOnly, NULL); + return result; +} +static NSUInteger MaximumDifference(NSData *a, NSData *b) { + if (a.length != b.length) return NSUIntegerMax; + const unsigned char *x = a.bytes, *y = b.bytes; NSUInteger maximum = 0; + for (NSUInteger i = 0; i < a.length; i++) maximum = MAX(maximum, (NSUInteger)abs(x[i] - y[i])); + return maximum; +} +static NSData *ExpectedCrop(PGHostTile *source, NSUInteger x, NSUInteger y, NSUInteger width, NSUInteger height, BOOL flip) { + NSData *sourceBytes = SurfaceBytes(source); size_t sourceWidth = IOSurfaceGetWidth((__bridge IOSurfaceRef)source.storage); + NSMutableData *result = [NSMutableData dataWithLength:width * height * 4]; + for (NSUInteger row = 0; row < height; row++) { + NSUInteger sourceRow = flip ? IOSurfaceGetHeight((__bridge IOSurfaceRef)source.storage) - 1 - (row + y) : row + y; + memcpy((unsigned char *)result.mutableBytes + row * width * 4, (const unsigned char *)sourceBytes.bytes + (sourceRow * sourceWidth + x) * 4, width * 4); + } + return result; +} +static NSData *Snapshot(beatygrilPlugIn *plugin, CMTime time) { + NSData *state = nil; NSError *error = nil; + Check([plugin pluginState:&state atTime:time quality:kFxQuality_HIGH error:&error] && state.length > 0 && !error, @"plugin snapshot serializes through host parameter API"); + return state; +} +static BOOL Render(beatygrilPlugIn *plugin, PGHostTile *destination, NSArray *sources, NSData *state, CMTime time, NSError **error, BOOL *escaped) { + @try { return [plugin renderDestinationImage:destination sourceImages:sources pluginState:state atTime:time error:error]; } + @catch (NSException *exception) { + if (escaped) *escaped = YES; + fprintf(stderr, "EXCEPTION: %s\n", exception.reason.UTF8String); return NO; + } +} +static void RejectState(beatygrilPlugIn *plugin, PGHostTile *destination, PGHostTile *source, NSData *state, NSString *description) { + NSError *error = nil; BOOL escaped = NO; + BOOL rendered = Render(plugin, destination, @[source], state, kCMTimeZero, &error, &escaped); + Check(!rendered && !escaped && error != nil, description); +} +static NSData *PlistState(id value) { return [NSPropertyListSerialization dataWithPropertyList:value format:NSPropertyListBinaryFormat_v1_0 options:0 error:nil]; } +static void WritePortrait(PGHostTile *tile, CIImage *image) { + CIContext *context = [CIContext contextWithOptions:@{kCIContextWorkingColorSpace:NSNull.null, kCIContextOutputColorSpace:NSNull.null}]; + [context render:image toIOSurface:(__bridge IOSurfaceRef)tile.storage bounds:image.extent colorSpace:nil]; +} +static BOOL Preview(PGHostTile *before, PGHostTile *after, NSString *path) { + CIContext *context = [CIContext contextWithOptions:@{kCIContextWorkingColorSpace:NSNull.null, kCIContextOutputColorSpace:NSNull.null}]; + size_t width = IOSurfaceGetWidth((__bridge IOSurfaceRef)before.storage), height = IOSurfaceGetHeight((__bridge IOSurfaceRef)before.storage); + CGColorSpaceRef space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB); + CGContextRef canvas = CGBitmapContextCreate(NULL, width * 2, height, 8, width * 2 * 4, space, (CGBitmapInfo)kCGImageAlphaPremultipliedLast); + NSArray *tiles = @[before, after]; + for (NSUInteger index = 0; index < 2; index++) { + PGHostTile *tile = tiles[index]; + CIImage *image = [CIImage imageWithIOSurface:(__bridge IOSurfaceRef)tile.storage options:@{kCIImageColorSpace:NSNull.null}]; + CGImageRef panel = [context createCGImage:image fromRect:CGRectMake(0, 0, width, height)]; + CGContextDrawImage(canvas, CGRectMake(width * index, 0, width, height), panel); CGImageRelease(panel); + } + CGImageRef output = CGBitmapContextCreateImage(canvas); + CGImageDestinationRef destination = CGImageDestinationCreateWithURL((__bridge CFURLRef)[NSURL fileURLWithPath:path], CFSTR("public.png"), 1, NULL); + BOOL ok = NO; + if (destination && output) { CGImageDestinationAddImage(destination, output, NULL); ok = CGImageDestinationFinalize(destination); } + if (destination) CFRelease(destination); CGImageRelease(output); CGContextRelease(canvas); CGColorSpaceRelease(space); + return ok; +} + +int main(int argc, const char *argv[]) { + setbuf(stdout, NULL); + @autoreleasepool { + PGHostAPI *api = [PGHostAPI new]; beatygrilPlugIn *plugin = [[beatygrilPlugIn alloc] initWithAPIManager:api]; + NSError *error = nil; + Check([plugin addParametersWithError:&error] && !error, @"host creates complete parameter set"); + UInt32 identifiers[] = {PGAmount,PGBrightness,PGRadius,PGSharpness,PGMix,PGThreshold,PGSoftness,PGEdgeProtect,PGShowMask,PGMidtone,PGHue,PGSaturation,PGHighPass,PGThin,PGFaceBoxes,PGSingleFace,PGSelection,PGShort,PGNarrow,PGChin,PGVFace,PGJaw,PGCheekbone,PGSmallHead,PGSmallHeadRange,PGFeatureProtect,PGTeeth,PGEyeBright,PGBigEye,PGTrackBoxes,PGFastMulti,PGJumpGuard,PGEvenTone,PGTonePreset,PGInvertMask,PGDebugChannel,PGTrackEnabled,PGTarget,PGAnalyzeForward,PGAnalyzeBackward,PGClearTrack,PGStatus,PGTrackData1,PGTrackData2,PGTrackData3,PGSmoothingEnabled,PGColorEnabled,PGShapeEnabled,PGDetailEnabled}; + for (NSUInteger i = 0; i < sizeof(identifiers) / sizeof(UInt32); i++) Check(api.definitions[@(identifiers[i])] != nil, [NSString stringWithFormat:@"required parameter %u exists", identifiers[i]]); + Check(api.depth == 0 && !api.duplicate && api.definitions.count == sizeof(identifiers) / sizeof(UInt32) + 6, @"parameter groups close and IDs are unique"); + Check([api.values[@(PGMidtone)] doubleValue] == 0 && [api.values[@(PGMix)] doubleValue] == 1, @"default detail and mix form identity filter"); + Check([api.definitions[@(PGShort)][@"min"] doubleValue] == -1 && [api.definitions[@(PGShort)][@"max"] doubleValue] == 1, @"signed face control exposes both directions"); + Check([api.definitions[@(PGTrackData1)][@"flags"] unsignedLongLongValue] & kFxParameterFlag_HIDDEN, @"persistent tracking data stays hidden in host UI"); + NSDictionary *defaults = [api.values copy]; + NSDictionary *properties = nil; + Check([plugin properties:&properties error:&error] && [properties[kFxPropertyKey_DesiredProcessingColorInfo] intValue] == kFxImageColorInfo_RGB_GAMMA_VIDEO, @"adapter requests host gamma-video samples"); + NSData *state = Snapshot(plugin, kCMTimeZero); + NSDictionary *decoded = [NSPropertyListSerialization propertyListWithData:state options:0 format:nil error:nil]; + Check([decoded[@"tracks"] count] == 3 && [decoded[@"settings"] length] == sizeof(PGBeautySettings), @"snapshot contains fixed settings schema and three target slots"); + PGBeautySettings settings; [decoded[@"settings"] getBytes:&settings length:sizeof(settings)]; + Check(settings.version == 1 && settings.mix == 1 && settings.midtoneDetail == 0, @"snapshot round-trips default numeric state"); + api.values[@(PGThin)] = @5; api.values[@(PGRadius)] = @(-9); api.values[@(PGSelection)] = @[@(-1), @2]; + settings = [plugin settingsAtTime:api.hostTime]; + Check(settings.faceThin == 1 && settings.radius == 1 && settings.selectionX == 0 && settings.selectionY == 1, @"host parameter limits and normalized selection are enforced"); + api.values[@(PGShapeEnabled)] = @NO; + Check([plugin settingsAtTime:kCMTimeZero].faceThin == 0, @"disabling face group disables shape amount"); + api.values = [defaults mutableCopy]; + PGHostTile *source = Tile(96, 80, 0, 0, kFxImageOrigin_BOTTOM_LEFT), *destination = Tile(96, 80, 0, 0, kFxImageOrigin_BOTTOM_LEFT); + Fill(source); + Check(Render(plugin, destination, @[source], state, kCMTimeZero, &error, NULL), @"production adapter renders real BGRA IOSurface"); + NSUInteger difference = MaximumDifference(SurfaceBytes(source), SurfaceBytes(destination)); + printf("Default IOSurface maximum byte difference: %lu\n", (unsigned long)difference); + Check(difference <= 1, @"default adapter output preserves gamma-video numeric samples"); + CGColorSpaceRelease(destination.space); destination.space = CGColorSpaceCreateWithName(kCGColorSpaceLinearSRGB); + Check(Render(plugin, destination, @[source], state, kCMTimeZero, &error, NULL), @"numeric host render accepts destination color-space metadata"); + difference = MaximumDifference(SurfaceBytes(source), SurfaceBytes(destination)); + printf("Alternate destination tag maximum byte difference: %lu\n", (unsigned long)difference); + Check(difference <= 1, @"host color-space tags do not double-convert already-requested gamma-video samples"); + CGColorSpaceRelease(destination.space); destination.space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB); + FxRect rectangle; + Check([plugin destinationImageRect:&rectangle sourceImages:@[source] destinationImage:destination pluginState:state atTime:kCMTimeZero error:&error] && RectEqual(rectangle, source.fullBounds), @"destination bounds preserve full input extent"); + Check([plugin sourceTileRect:&rectangle sourceImageIndex:0 sourceImages:@[source] destinationTileRect:destination.pixelBounds destinationImage:destination pluginState:state atTime:kCMTimeZero error:&error] && RectEqual(rectangle, source.fullBounds), @"adapter requests full source buffer for face processing"); + RejectState(plugin, destination, source, nil, @"nil snapshot is rejected with NSError, without escaping exception"); + RejectState(plugin, destination, source, [NSData data], @"empty snapshot is rejected safely"); + RejectState(plugin, destination, source, (NSData *)(id)@"wrong type", @"non-NSData snapshot is rejected safely"); + RejectState(plugin, destination, source, [@"bad data" dataUsingEncoding:NSUTF8StringEncoding], @"malformed snapshot is rejected safely"); + RejectState(plugin, destination, source, PlistState(@[]), @"non-dictionary snapshot is rejected safely"); + RejectState(plugin, destination, source, PlistState(@{@"settings":[NSData data], @"tracks":@[@"",@"",@""]}), @"wrong settings size is rejected safely"); + settings = PGBeautyDefaults(); settings.version = 99; + RejectState(plugin, destination, source, PlistState(@{@"settings":[NSData dataWithBytes:&settings length:sizeof(settings)], @"tracks":@[@"",@"",@""]}), @"unknown saved settings version is rejected safely"); + settings.version = 1; + RejectState(plugin, destination, source, PlistState(@{@"settings":[NSData dataWithBytes:&settings length:sizeof(settings)], @"tracks":@[@"",@""]}), @"wrong target slot count is rejected safely"); + RejectState(plugin, destination, source, PlistState(@{@"settings":[NSData dataWithBytes:&settings length:sizeof(settings)], @"tracks":@[@"",@7,@""]}), @"non-string target slot is rejected safely"); + for (NSUInteger top = 0; top < 2; top++) { + FxImageOrigin origin = top ? kFxImageOrigin_TOP_LEFT : kFxImageOrigin_BOTTOM_LEFT; + PGHostTile *input = Tile(48, 32, 37, -21, origin), *output = Tile(48, 32, 37, -21, origin); Fill(input); + Check(Render(plugin, output, @[input], state, kCMTimeZero, &error, NULL), @"nonzero-origin tile renders"); + Check(MaximumDifference(SurfaceBytes(input), SurfaceBytes(output)) <= 1, top ? @"top-left tile preserves sample rows at nonzero origin" : @"bottom-left tile preserves sample rows at nonzero origin"); + output.origin = top ? kFxImageOrigin_BOTTOM_LEFT : kFxImageOrigin_TOP_LEFT; + Check(Render(plugin, output, @[input], state, kCMTimeZero, &error, NULL), @"opposite-origin tile renders"); + Check(MaximumDifference(ExpectedCrop(input, 0, 0, 48, 32, YES), SurfaceBytes(output)) <= 1, @"opposite image origins flip rows exactly once"); + } + PGHostTile *full = Tile(48, 32, 37, -21, kFxImageOrigin_BOTTOM_LEFT), *crop = Tile(16, 12, 43, -17, kFxImageOrigin_BOTTOM_LEFT); Fill(full); + Check(Render(plugin, crop, @[full], state, kCMTimeZero, &error, NULL), @"destination sub-tile renders from full input buffer"); + // IOSurface memory rows are top-down. In a BOTTOM_LEFT image, a logical + // lower-edge offset of 4 selects physical row 32 - 4 - 12 = 16. + NSData *expectedCrop = ExpectedCrop(full, 6, 16, 16, 12, NO), *actualCrop = SurfaceBytes(crop); + NSUInteger cropDifference = MaximumDifference(expectedCrop, actualCrop); + if (cropDifference > 1) { + const unsigned char *a = expectedCrop.bytes, *b = actualCrop.bytes; + fprintf(stderr, "Crop max difference %lu, first expected BGRA %u/%u/%u/%u, actual %u/%u/%u/%u\n", (unsigned long)cropDifference, a[0], a[1], a[2], a[3], b[0], b[1], b[2], b[3]); + } + Check(cropDifference <= 1, @"destination sub-tile translates global pixel bounds correctly"); + full.origin = kFxImageOrigin_TOP_LEFT; crop.origin = kFxImageOrigin_TOP_LEFT; + Check(Render(plugin, crop, @[full], state, kCMTimeZero, &error, NULL), @"top-left destination sub-tile renders"); + Check(MaximumDifference(ExpectedCrop(full, 6, 4, 16, 12, NO), SurfaceBytes(crop)) <= 1, @"top-left sub-tile selects correct physical source rows"); + + NSString *fixture = argc > 1 ? @(argv[1]) : @"tests/fixtures/astronaut.png"; + CIImage *portrait = [CIImage imageWithContentsOfURL:[NSURL fileURLWithPath:fixture] options:@{kCIImageColorSpace:NSNull.null}]; + Check(portrait != nil, @"NASA portrait opens for full adapter test"); + PGHostTile *portraitSource = Tile(512, 512, 0, 0, kFxImageOrigin_BOTTOM_LEFT), *portraitOutput = Tile(512, 512, 0, 0, kFxImageOrigin_BOTTOM_LEFT); + WritePortrait(portraitSource, portrait); + api.values[@(PGThin)] = @0.8; api.values[@(PGBigEye)] = @0.6; api.values[@(PGMidtone)] = @0.8; api.values[@(PGBrightness)] = @0.25; api.values[@(PGMix)] = @0; + NSData *zeroMix = Snapshot(plugin, kCMTimeZero); + Check(Render(plugin, portraitOutput, @[portraitSource], zeroMix, kCMTimeZero, &error, NULL), @"zero overall mix renders with active skin and face controls"); + Check(MaximumDifference(SurfaceBytes(portraitSource), SurfaceBytes(portraitOutput)) <= 1, @"zero mix also disables geometry and color/detail output"); + api.values[@(PGThin)] = @0.2; api.values[@(PGBigEye)] = @0; api.values[@(PGMix)] = @1; + NSData *beautyState = Snapshot(plugin, kCMTimeZero); + Check(Render(plugin, portraitOutput, @[portraitSource], beautyState, kCMTimeZero, &error, NULL), @"real portrait renders through face, skin and IOSurface adapter"); + Check(MaximumDifference(SurfaceBytes(portraitSource), SurfaceBytes(portraitOutput)) > 5, @"active portrait settings produce visible full-chain change"); + if (argc > 2) Check(Preview(portraitSource, portraitOutput, @(argv[2])), @"full adapter before/after preview writes PNG"); + + api.values = [defaults mutableCopy]; api.targetAtCurrentTime = YES; + api.values[@(PGSelection)] = @[@0.43, @0.76]; + NSData *oldRenderState = Snapshot(plugin, api.hostTime); + [plugin analyzeForward]; + Check(api.forwardRequests == 1, @"forward analyze button calls host analysis API"); + CMTimeRange desired, clip = CMTimeRangeMake(kCMTimeZero, CMTimeMake(240, 24)); + [plugin desiredAnalysisTimeRange:&desired forInputWithTimeRange:clip error:&error]; + Check(CMTimeCompare(desired.start, api.hostTime) == 0 && CMTimeCompare(CMTimeRangeGetEnd(desired), CMTimeRangeGetEnd(clip)) == 0, @"analysis seed comes from current host playhead time"); + [plugin analyzeForward]; + Check(api.forwardRequests == 1, @"second analyze click is blocked before host setup callback"); + Check([plugin setupAnalysisForTimeRange:desired frameDuration:CMTimeMake(1, 24) error:&error], @"host setup starts analysis session"); + api.values[@(PGTrackData3)] = @"preserve-until-analysis-finishes"; + [plugin clearTracking]; + Check([api.values[@(PGTrackData3)] isEqualToString:@"preserve-until-analysis-finishes"], @"clear is blocked while selected target analysis is active"); + api.values[@(PGTrackData3)] = @""; + Check([plugin analyzeFrame:portraitSource atTime:api.hostTime error:&error], @"analyzer accepts real portrait IOSurface"); + Check(Render(plugin, portraitOutput, @[portraitSource], oldRenderState, api.hostTime, &error, NULL), @"rendering an older snapshot is allowed during analysis"); + Check([plugin analyzeFrame:portraitSource atTime:CMTimeAdd(api.hostTime, CMTimeMake(1, 24)) error:&error], @"analysis continues after old snapshot render"); + Check([plugin cleanupAnalysis:&error], @"analysis persists target data into host parameters"); + NSString *target = api.values[@(PGTrackData3)]; + Check(target.length > 0 && [api.values[@(PGTrackData1)] length] == 0 && [api.values[@(PGTrackData2)] length] == 0, @"analysis stores only target selected at current host time"); + NSData *tracking = [[NSData alloc] initWithBase64EncodedString:target options:0]; + NSDictionary *trackingArchive = tracking ? [NSPropertyListSerialization propertyListWithData:tracking options:0 format:nil error:nil] : nil; + Check([trackingArchive[@"frames"] count] == 2, @"old render snapshot does not erase either newly analyzed frame"); + Check([api.values[@(PGTrackEnabled)] boolValue] && CMTimeCompare(api.lastSetBoolTime, api.hostTime) == 0, @"analysis enables saved target at host seed time"); + NSData *trackedState = Snapshot(plugin, api.hostTime); + beatygrilPlugIn *restoredPlugin = [[beatygrilPlugIn alloc] initWithAPIManager:api]; + Check(Render(restoredPlugin, portraitOutput, @[portraitSource], trackedState, api.hostTime, &error, NULL), @"fresh plugin instance restores target data from serialized host snapshot"); + [plugin analyzeBackward]; + Check(api.backwardRequests == 1, @"backward analyze button calls host API"); + [plugin desiredAnalysisTimeRange:&desired forInputWithTimeRange:clip error:&error]; + Check(CMTimeCompare(desired.start, kCMTimeZero) == 0 && CMTimeCompare(CMTimeRangeGetEnd(desired), CMTimeAdd(api.hostTime, CMTimeMake(1, 24))) == 0, @"backward analysis range includes the current host frame"); + [plugin setupAnalysisForTimeRange:desired frameDuration:CMTimeMake(1, 24) error:&error]; [plugin cleanupAnalysis:&error]; + [plugin clearTracking]; Check([api.values[@(PGTrackData3)] length] == 0, @"clear button uses current host target"); + PGHostAPI *writeFailureAPI = [PGHostAPI new]; + beatygrilPlugIn *writeFailurePlugin = [[beatygrilPlugIn alloc] initWithAPIManager:writeFailureAPI]; + [writeFailurePlugin addParametersWithError:&error]; [writeFailurePlugin analyzeForward]; + [writeFailurePlugin setupAnalysisForTimeRange:clip frameDuration:CMTimeMake(1, 24) error:&error]; + [writeFailurePlugin analyzeFrame:portraitSource atTime:writeFailureAPI.hostTime error:&error]; + writeFailureAPI.rejectedWriteParameter = PGTrackData1; error = nil; + Check(![writeFailurePlugin cleanupAnalysis:&error] && error != nil, @"host refusing saved tracking data reports persistence failure"); + writeFailureAPI.rejectedWriteParameter = 0; + [writeFailurePlugin analyzeForward]; + Check(writeFailureAPI.forwardRequests == 2, @"persistence failure releases analysis busy state for retry"); + PGHostAPI *unavailable = [PGHostAPI new]; [unavailable.disabledAPIs addObject:@"FxParameterCreationAPI_v5"]; + beatygrilPlugIn *missingPlugin = [[beatygrilPlugIn alloc] initWithAPIManager:unavailable]; + error = nil; Check(![missingPlugin addParametersWithError:&error] && error != nil, @"missing host creation API reports error"); + [unavailable.disabledAPIs removeAllObjects]; unavailable.rejectedParameter = PGThin; + error = nil; Check(![missingPlugin addParametersWithError:&error] && error != nil, @"host rejecting one control fails complete parameter creation"); + [unavailable.disabledAPIs addObject:@"FxParameterRetrievalAPI_v6"]; + NSData *invalid = nil; error = nil; + Check(![missingPlugin pluginState:&invalid atTime:kCMTimeZero quality:kFxQuality_HIGH error:&error] && error != nil, @"missing host retrieval API rejects snapshot"); + printf("Host adapter: %lu checks, %lu failures\n", (unsigned long)Checks, (unsigned long)Failures); + } + return Failures ? 1 : 0; +}