几年前开了个油管的音乐频道,音乐类型的内容若不配个好看显眼的主视觉就不够吸引人,於是我开始调研其他频道都用些啥。
最常见的效果大概就像这样: NCS - Copyright Free Music
右边那个圆圈会随着音乐的播放而律动,音乐中不同频率的震动会体现在圆圈不同地方的形状变化。 我觉得这效果还不错,想拿来套用看,可惜一套就又是一个坑,就此跌入 After Effects 的魔幻世界。
首先我也给自己科普一下 Adobe After Effects 是一个视频後制剪辑软件,当然它的功能也不止於此,举凡调色、特效、杂七杂八都有,而我需要的是替每首歌搭配一个类似刚才圈圈圈圈风格的效果,这样目标还算明确。
几经波折我终於做好一个雏形: [mashup] Kamasi Washington - Street Fighter Mas X 萬芳 - 好風景
视频在渲染过程中我的笔记本就 hang 在那边感觉不是很方便,且我的笔记本配置太 low,平均渲染一分钟时长的视频会需要一分半钟,想想还是挺麻烦,因此决定把这项艰难的任务交给云代劳了。
看下这整件事放在 Azure 上的架构 原先我用笔记本渲染视频,现在改为我先制作一个有安装 After Effects 的 docker image 并放在 Container Registry 上,另外起了一个 Storage Account 放置每个专案的相关素材 (例如 mp3 / 图片),并让笔记本挂载。接着再起一个依照调用次数计费的 Function App consumption plan 做为启动节点、监视状态等功能用的後台,透过後台间接启动 App Service 分配节点与容器、从 Container Registry 载入 image、挂载 Storage Account 取得渲染过程需要用到的素材并保存渲染完成的视频、过程中若有多台节点同时运行,则需要用到 redis 防止重入以及上个业务逻辑锁,当任务结束後就自我销毁,最後我再从笔记本中的挂载目录取得完成品。
开整,先从创建资源开始
json
{
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"resourcePrefix": {
"comments": "Resource Prefix",
"type": "string",
"defaultValue": "[concat('winae', '-', uniqueString(newGuid()))]",
"metadata": {
"description": "Do not modify this name"
}
},
"encryptionKey": {
"comments": "Encryption Key",
"type": "string",
"defaultValue": "R2W8WuO2iJvtxecwAjED1ItRUtr54jzQw8YRN+pzL9A=",
"metadata": {
"description": "Specify one key to encrypt the Storage Account connection string, you can use the default one or the one which generated after the deployment."
}
}
},
"variables": {
"storageAccountName": "[concat(replace(parameters('resourcePrefix'),'-', ''), 'sa')]",
"storageAccountSkuName": "Standard_LRS",
"storageAccountSupportsHttpsTrafficOnly": true,
"storageAccountMinimumTlsVersion": "TLS1_2",
"storageAccountDefaultToOAuthAuthentication": true,
"storageAccountIsShareSoftDeleteEnabled": true,
"storageAccountShareSoftDeleteRetentionDays": 7,
"storageAccountFileShareName": "winae-file",
"storageAccountFileShareShareQuota": 5120,
"storageAccountFileShareEnabledProtocols": "SMB",
"redisName": "[concat(parameters('resourcePrefix'), '-', 'ctrl')]",
"redisSkuName": "Basic",
"redisSkuFamily": "C",
"redisSkuCapacity": 0,
"redisEnableNonSslPort": true,
"redisRedisVersion": "6",
"registryName": "[concat(replace(parameters('resourcePrefix'),'-', ''), 'cr')]",
"registrySkuName": "Standard",
"registryPublicNetworkAccess": "Enabled",
"registryZoneRedundancy": "disabled",
"registryAdminUserEnabled": true,
"planName": "[concat(parameters('resourcePrefix'), '-', 'plan')]",
"planKind": "linux",
"planSkuTier": "Dynamic",
"planSkuName": "Y1",
"planWorkerSize": "0",
"planWorkerSizeId": "0",
"planNumberOfWorkers": "1",
"planReserved": true,
"functionName": "[concat(parameters('resourcePrefix'), '-', 'func')]",
"functionKind": "functionapp,linux",
"functionSiteConfigAppSettingsFUNCTIONS_EXTENSION_VERSION": "~4",
"functionSiteConfigAppSettingsFUNCTIONS_WORKER_RUNTIME": "node",
"functionSiteConfigAppSettingsWEBSITE_CONTENTSHARE": "winae-func",
"functionSiteConfigUse32BitWorkerProcess": false,
"functionSiteConfigFtpsState": "FtpsOnly",
"functionSiteConfigLinuxFxVersion": "Node|18",
"functionClientAffinityEnabled": false,
"functionPublicNetworkAccess": "Enabled",
"functionHttpsOnly": true,
"functionServerFarmId": "[concat('subscriptions/', subscription().subscriptionId, '/resourcegroups/', resourceGroup().name, '/providers/Microsoft.Web/serverfarms/', variables('planName'))]",
"scriptName": "[concat(replace(parameters('resourcePrefix'),'-', ''), 'sh')]"
},
"resources": [
{
"comments": "Storage Account",
"apiVersion": "2022-05-01",
"type": "Microsoft.Storage/storageAccounts",
"location": "[resourceGroup().location]",
"name": "[variables('storageAccountName')]",
"tags": {},
"sku": {
"name": "[variables('storageAccountSkuName')]"
},
"properties": {
"supportsHttpsTrafficOnly": "[variables('storageAccountSupportsHttpsTrafficOnly')]",
"minimumTlsVersion": "[variables('storageAccountMinimumTlsVersion')]",
"defaultToOAuthAuthentication": "[variables('storageAccountDefaultToOAuthAuthentication')]"
}
},
{
"comments": "Storage Account: fileservices",
"apiVersion": "2022-05-01",
"type": "Microsoft.Storage/storageAccounts/fileservices",
"name": "[concat(variables('storageAccountName'), '/default')]",
"properties": {
"shareDeleteRetentionPolicy": {
"enabled": "[variables('storageAccountIsShareSoftDeleteEnabled')]",
"days": "[variables('storageAccountShareSoftDeleteRetentionDays')]"
}
},
"dependsOn": [
"[concat('Microsoft.Storage/storageAccounts/', variables('storageAccountName'))]"
]
},
{
"comments": "Storage Account: fileshares",
"apiVersion": "2021-04-01",
"type": "Microsoft.Storage/storageAccounts/fileServices/shares",
"location": "[resourceGroup().location]",
"name": "[concat(variables('storageAccountName'), '/default/', variables('storageAccountFileShareName'))]",
"dependsOn": [
"[resourceId('Microsoft.Storage/storageAccounts/fileServices', variables('storageAccountName'), 'default')]",
"[resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountName'))]"
],
"properties": {
"shareQuota": "[variables('storageAccountFileShareShareQuota')]",
"enabledProtocols": "[variables('storageAccountFileShareEnabledProtocols')]"
}
},
{
"comments": "Redis",
"apiVersion": "2023-08-01",
"type": "Microsoft.Cache/redis",
"location": "[resourceGroup().location]",
"name": "[variables('redisName')]",
"dependsOn": [],
"properties": {
"sku": {
"name": "[variables('redisSkuName')]",
"family": "[variables('redisSkuFamily')]",
"capacity": "[variables('redisSkuCapacity')]"
},
"redisConfiguration": {},
"enableNonSslPort": "[variables('redisEnableNonSslPort')]",
"redisVersion": "[variables('redisRedisVersion')]"
},
"tags": {}
},
{
"comments": "ACR",
"apiVersion": "2022-02-01-preview",
"type": "Microsoft.ContainerRegistry/registries",
"location": "[resourceGroup().location]",
"name": "[variables('registryName')]",
"sku": {
"name": "[variables('registrySkuName')]"
},
"dependsOn": [],
"tags": {},
"properties": {
"publicNetworkAccess": "[variables('registryPublicNetworkAccess')]",
"zoneRedundancy": "[variables('registryZoneRedundancy')]",
"adminUserEnabled": "[variables('registryAdminUserEnabled')]"
}
},
{
"comments": "ASP nested resources due to Storage Account",
"apiVersion": "2017-05-10",
"type": "Microsoft.Resources/deployments",
"name": "ASPResourcesDeployment",
"dependsOn": [
"[concat('Microsoft.Storage/storageAccounts/', variables('storageAccountName'))]"
],
"properties": {
"mode": "Incremental",
"template": {
"$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#",
"contentVersion": "1.0.0.0",
"parameters": {},
"variables": {},
"resources": [
{
"comments": "ASP",
"apiVersion": "2018-11-01",
"type": "Microsoft.Web/serverfarms",
"location": "[resourceGroup().location]",
"name": "[variables('planName')]",
"kind": "[variables('planKind')]",
"sku": {
"Tier": "[variables('planSkuTier')]",
"Name": "[variables('planSkuName')]"
},
"tags": {},
"dependsOn": [],
"properties": {
"name": "[variables('planName')]",
"workerSize": "[variables('planWorkerSize')]",
"workerSizeId": "[variables('planWorkerSizeId')]",
"numberOfWorkers": "[variables('planNumberOfWorkers')]",
"reserved": "[variables('planReserved')]"
}
},
{
"comments": "Function App",
"apiVersion": "2018-11-01",
"type": "Microsoft.Web/sites",
"location": "[resourceGroup().location]",
"name": "[variables('functionName')]",
"kind": "[variables('functionKind')]",
"tags": {},
"dependsOn": [
"[concat('Microsoft.Web/serverfarms/', variables('planName'))]"
],
"properties": {
"name": "[variables('functionName')]",
"siteConfig": {
"appSettings": [
{
"name": "FUNCTIONS_EXTENSION_VERSION",
"value": "[variables('functionSiteConfigAppSettingsFUNCTIONS_EXTENSION_VERSION')]"
},
{
"name": "FUNCTIONS_WORKER_RUNTIME",
"value": "[variables('functionSiteConfigAppSettingsFUNCTIONS_WORKER_RUNTIME')]"
},
{
"name": "AzureWebJobsStorage",
"value": "[concat('DefaultEndpointsProtocol=https;AccountName=',variables('storageAccountName'),';AccountKey=',listKeys(resourceId('Microsoft.Storage/storageAccounts',variables('storageAccountName')),'2019-06-01').keys[0].value,';EndpointSuffix=','core.windows.net')]"
},
{
"name": "WEBSITE_CONTENTAZUREFILECONNECTIONSTRING",
"value": "[concat('DefaultEndpointsProtocol=https;AccountName=',variables('storageAccountName'),';AccountKey=',listKeys(resourceId('Microsoft.Storage/storageAccounts',variables('storageAccountName')),'2019-06-01').keys[0].value,';EndpointSuffix=','core.windows.net')]"
},
{
"name": "WEBSITE_CONTENTSHARE",
"value": "[variables('functionSiteConfigAppSettingsWEBSITE_CONTENTSHARE')]"
}
],
"cors": {
"allowedOrigins": []
},
"use32BitWorkerProcess": "[variables('functionSiteConfigUse32BitWorkerProcess')]",
"ftpsState": "[variables('functionSiteConfigFtpsState')]",
"linuxFxVersion": "[variables('functionSiteConfigLinuxFxVersion')]"
},
"clientAffinityEnabled": "[variables('functionClientAffinityEnabled')]",
"virtualNetworkSubnetId": null,
"publicNetworkAccess": "[variables('functionPublicNetworkAccess')]",
"httpsOnly": "[variables('functionHttpsOnly')]",
"serverFarmId": "[variables('functionServerFarmId')]"
}
},
{
"comments": "CMD",
"apiVersion": "2020-10-01",
"type": "Microsoft.Resources/deploymentScripts",
"location": "[resourceGroup().location]",
"name": "[variables('scriptName')]",
"kind": "AzurePowerShell",
"properties": {
"azPowerShellVersion": "8.3",
"scriptContent": "[concat('function Create-AesManagedObject($key, $IV, $mode) { $aesManaged = New-Object \"System.Security.Cryptography.AesManaged\"; if ($mode=\"CBC\") { $aesManaged.Mode = [System.Security.Cryptography.CipherMode]::CBC } elseif ($mode=\"CFB\") {$aesManaged.Mode = [System.Security.Cryptography.CipherMode]::CFB} elseif ($mode=\"CTS\") {$aesManaged.Mode = [System.Security.Cryptography.CipherMode]::CTS} elseif ($mode=\"ECB\") {$aesManaged.Mode = [System.Security.Cryptography.CipherMode]::ECB} elseif ($mode=\"OFB\"){$aesManaged.Mode = [System.Security.Cryptography.CipherMode]::OFB} $aesManaged.Padding = [System.Security.Cryptography.PaddingMode]::PKCS7; $aesManaged.BlockSize = 128; $aesManaged.KeySize = 256; if ($IV) { if ($IV.getType().Name -eq \"String\") { $aesManaged.IV = [System.Convert]::FromBase64String($IV); } else { $aesManaged.IV = $IV; } } if ($key) { if ($key.getType().Name -eq \"String\") { $aesManaged.Key = [System.Convert]::FromBase64String($key); } else { $aesManaged.Key = $key; } } $aesManaged; }', 'function Encrypt-String($key, $plaintext) {$bytes = [System.Text.Encoding]::UTF8.GetBytes($plaintext);$aesManaged = Create-AesManagedObject $key;$encryptor = $aesManaged.CreateEncryptor();$encryptedData = $encryptor.TransformFinalBlock($bytes, 0, $bytes.Length);[byte[]] $fullData = $aesManaged.IV + $encryptedData;[System.Convert]::ToBase64String($fullData);}', '$k=New-Object \"System.Security.Cryptography.AesManaged\";$k.Mode=[System.Security.Cryptography.CipherMode]::ECB;$k.Padding=[System.Security.Cryptography.PaddingMode]::PKCS7;$k.BlockSize=128;$k.KeySize=256;$k.GenerateKey();$key1=[System.Convert]::ToBase64String($k.Key);', '$key2=\"', parameters('encryptionKey'), '\";', '$pass=\"', listKeys(resourceId('Microsoft.Storage/storageAccounts',variables('storageAccountName')),'2019-06-01').keys[0].value, '\"; ', '$username=\"', variables('storageAccountName'), '\";', '$enc1 = Encrypt-String $key1 \"${username}|${pass}\"; $enc2 = Encrypt-String $key2 \"${username}|${pass}\";', 'echo \"============\"; echo \"Key 1 (auto-generated)\"; echo $key1; echo \"Startup script 1 (auto-generated)\"; echo \"powershell.exe C:/Users/ContainerAdministrator/winae-wrapper.ps1 $enc1 \"; echo \"============\"; echo \"Key 2 (user-defined)\"; echo $key2; echo \"Startup script 2 (user-defined)\"; echo \"powershell.exe C:/Users/ContainerAdministrator/winae-wrapper.ps1 $enc2 \"; ')]",
"arguments": "",
"timeout": "PT1H",
"cleanupPreference": "OnSuccess",
"retentionInterval": "P1D"
}
}
],
"outputs": {}
}
}
}
],
"outputs": {}
}
咱们在这个 ARM template 中定义了日後创建资源的前缀名称(避免重名),同时指派一组 ECD 对称加密的 key 会在之後用到,接着在布署过程中会创建 Container Registry / Storage Account / Function App / Redis 这些资源。
接着搞定 docker image 的事
bash
# WINAE: a Windows based containerized Adobe After Effects renderer
FROM mcr.microsoft.com/windows/server:10.0.20348.1726
# Default PS1
SHELL ["powershell", "-Command", "$ErrorActionPreference = 'Stop'; $ProgressPreference = 'SilentlyContinue';"]
# Install Choco
COPY aepack/install-choco.ps1 install-choco.ps1
RUN Set-ExecutionPolicy Bypass -Scope Process -Force; \
./install-choco.ps1 ; \
del ./install-choco.ps1
# Install Common Tools through Choco (ffmpeg redis are must)
RUN choco install -y unzip vim ntop.portable azcopy10 ffmpeg redis
# Install Adobe After Effects
COPY aepack/AE_en_US_WIN_64.zip AE_en_US_WIN_64.zip
RUN Set-ExecutionPolicy Bypass -Scope Process -Force; \
move "AE_en_US_WIN_64.zip" "C:\Users\ContainerAdministrator" ; \
cd "C:\Users\ContainerAdministrator" ; \
unzip AE_en_US_WIN_64.zip ; \
cd AE ; \
cd Build ; \
cmd.exe /C "setup.exe --silent --INSTALLLANGUAGE=en_US" ; \
cd "C:\Users\ContainerAdministrator" ; \
del AE_en_US_WIN_64.zip ; \
rm -r AE
# Create symbolic link for aerender.exe and start render node
RUN Set-ExecutionPolicy Bypass -Scope Process -Force; \
New-Item -ItemType SymbolicLink -Path "C:\Users\ContainerAdministrator\AE" -Target "C:\Program` Files\Adobe\Adobe` After` Effects` 2022\Support` Files"; \
New-Item -Path "C:\Users\All` Users\Documents" -Name "ae_render_only_node.txt" -ItemType File
# Install Plugin: Rowbyte Plexus
COPY aepack/Rowbyte.zip Rowbyte.zip
RUN Set-ExecutionPolicy Bypass -Scope Process -Force; \
move "Rowbyte.zip" "C:\Program` Files\Adobe\Adobe` After` Effects` 2022\Support` Files\Plug-ins" ; \
cd "C:\Program` Files\Adobe\Adobe` After` Effects` 2022\Support` Files\Plug-ins" ; \
unzip "Rowbyte.zip" ; \
del "Rowbyte.zip"
# Register Plugin: Rowbyte Plexus
COPY aepack/RWBYTE.zip RWBYTE.zip
RUN Set-ExecutionPolicy Bypass -Scope Process -Force; \
move "RWBYTE.zip" "C:\Users\All` Users" ; \
cd "C:\Users\All` Users" ; \
unzip "RWBYTE.zip" ; \
del "RWBYTE.zip"
# Mount File Share from Storage Account and Launch
COPY script/winae-wrapper.ps1 C:/Users/ContainerAdministrator/winae-wrapper.ps1
CMD C:/Users/ContainerAdministrator/winae-wrapper.ps1
基本也就是起个 Win Server 然後安装相关的软件,其中 Rowbyte Plexus 这个软件是 After Effects 的一个外挂,体现视频随音乐律动的特效所用,在过程中比较麻烦的部分就是这些软件的取得与注册方式。
正版 Adobe 软件需要透过官方网站取得,使用官网下载的 package 进行安装後会自带注册信息,就不需要再调整注册表什麽的。至於外挂就比较麻烦,因为品牌众多,且每间开发商的注册方式都不同,因此需要另外找个闲置用的机器不断调适,测出实际注册的方式(例如注册文件 / 注册表二进制机码等等)。由上面这个 Dockerfile 可以看出在过程中先安装了 Adobe After Effects 之後再上一个软链,再之後接着安装外挂与注册外挂。
Dockerfile 的最後一句是容器启动用的脚本 winae-wrapper.ps1,接着看看这边
shell
# Specify your encryption key (or use this default one)
$key = "R2W8WuO2iJvtxecwAjED1ItRUtr54jzQw8YRN+pzL9A=";
$mydec = Decrypt-String $key $Args[0];
$mydec = $mydec.Split("|");
$username = $mydec[0];
$password = $mydec[1];
# Mount Storage Account
# AppService only enable A/B Drives to mount
$secpasswd = ConvertTo-SecureString $password -AsPlainText -Force
$cred = New-Object System.Management.Automation.PSCredential("localhost\${username}", $secpasswd)
New-PSDrive -Name A -PSProvider FileSystem -Root "\\${username}.file.core.windows.net\winae-file" -Persist -Credential $cred -Scope Global
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
# Business Logic Wrapper
# AppService use Hyper-V such that stdout could not be catch for app log, and startup command in Dockerfile will be override, please use startup command instead.
# powershell.exe C:/Users/ContainerAdministrator/winae-wrapper.ps1 [encryption]
# PSDrive mount is session oriented, so we cannot go to A drive from our login (either SSH or console).
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
A:/tool/winae.ps1
这个脚本的目的是让容器可以透过 Storage Account 的密钥挂载文件系统,原先可直接把 Storage 的用户名跟密钥写进去,可是当未来 Storage 更换了或者密钥更新了,为此再重新 build 一个 image 就太麻烦(平均每次要 30-40 分钟),所以就使用先前提到的对称加密方式,在 Storage 创建时即生成一组 Key 与加密後的字符串,往後咱们只需要把 Key 写到脚本中,字符串就可以使用参数的形式带入脚本。
当容器启动後且挂载 Storage 後,就可以在之後的代码中实现渲染素材并产生 output 的业务逻辑了。
剩下的业务逻辑连同整个专案我就放上 GitHub,现在用 GitHub Copilot 写东西很方便可说是无脑产出,当然效能跟安全性还是堪虑,不过做为自己用的工具这样就够了。
theringe/winae: A Windows based containerized Adobe After Effects renderer (github.com)
原先本打算网上找家 renderfarm 付钱了事就算了,结果市面上能够渲染 AE 视频的云产品使用起来特别复杂,例如直接起台虚机 + 一个线上磁碟,转档前需要先把文件统一使用某个後台上传至线上磁碟,再 RDP 进虚机启动虚机内部的 AE 进行渲染。想想还是自己做吧,为了产生特效视频竟然绕了这麽一圈。