Fix host error handling and isolate analysis sessions; add IOSurface regressions

这个提交包含在:
dayu 于 2026-10-21 17:23:37 +08:00 提交
1 父节点 0310ca83ba
当前提交 2e59185b32
10 个文件已更改
+543 -27

没有与搜索条件相匹配的文件

+12 -18
查看文件
@@ -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` 标签。
+4 -4
查看文件
@@ -17,9 +17,9 @@
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<string>1.0.1</string>
<key>CFBundleVersion</key>
<string>1</string>
<string>2</string>
<key>NSHumanReadableCopyright</key>
<string></string>
<key>PlugInKit</key>
@@ -72,7 +72,7 @@
<key>uuid</key>
<string>FB14B0E6-59B2-4836-916F-CD0C559E9241</string>
<key>version</key>
<string>1.0</string>
<string>1.0.1</string>
</dict>
<dict>
<key>className</key>
@@ -84,7 +84,7 @@
<key>uuid</key>
<string>E0E432C8-3D71-4ABA-B006-E7F519E06C87</string>
<key>version</key>
<string>1.0</string>
<string>1.0.1</string>
<key>protocolNames</key>
<array>
<string>FxOnScreenControl</string>
+3 -3
查看文件
@@ -208,8 +208,7 @@ static CIImage *PGImageFromTile(FxImageTile *tile) {
}
}
- (BOOL)renderDestinationImage:(FxImageTile *)destination sourceImages:(NSArray<FxImageTile *> *)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)];
@@ -247,7 +246,6 @@ static CIImage *PGImageFromTile(FxImageTile *tile) {
@catch(NSException *exception) {return PGFail(error,exception.reason?:@"图像处理失败。");}
return YES;
}
}
- (void)setStatus:(NSString *)status {
id<FxParameterSettingAPI_v5> api=[_apiManager apiForProtocol:@protocol(FxParameterSettingAPI_v5)];
[api setStringParameterValue:status toParameter:PGStatus];
@@ -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<FxParameterSettingAPI_v5> api=[_apiManager apiForProtocol:@protocol(FxParameterSettingAPI_v5)];
@@ -307,6 +306,7 @@ static CIImage *PGImageFromTile(FxImageTile *tile) {
return ok;
}
- (BOOL)cleanupAnalysis:(NSError **)error {
_analyzing=NO;
id<FxParameterSettingAPI_v5> api=[_apiManager apiForProtocol:@protocol(FxParameterSettingAPI_v5)];
NSData *data=nil;
@synchronized(_analysisFaceProcessor) {data=[_analysisFaceProcessor trackingDataForTarget:_analysisSettings.targetIndex];}
@@ -17,9 +17,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<string>1.0.1</string>
<key>CFBundleVersion</key>
<string>1</string>
<string>2</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSHumanReadableCopyright</key>
@@ -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`。
二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 603 KiB

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 697 KiB

可执行文件
+75
查看文件
@@ -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()
可执行文件
+23
查看文件
@@ -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" "$@"
+373
查看文件
@@ -0,0 +1,373 @@
#import <Foundation/Foundation.h>
#import <FxPlug/FxPlugSDK.h>
#import <CoreImage/CoreImage.h>
#import <IOSurface/IOSurface.h>
#import <Metal/Metal.h>
#import <CoreVideo/CoreVideo.h>
#import <ImageIO/ImageIO.h>
#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 <PROAPIAccessing>
@property NSMutableDictionary<NSNumber *, id> *values;
@property NSMutableDictionary<NSNumber *, NSDictionary *> *definitions;
@property NSMutableSet<NSString *> *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;
}