用 Azure 云容器实现 Adobe After Effects 的视频渲染

几年前开了个油管的音乐频道,音乐类型的内容若不配个好看显眼的主视觉就不够吸引人,於是我开始调研其他频道都用些啥。

最常见的效果大概就像这样: 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 进行渲染。想想还是自己做吧,为了产生特效视频竟然绕了这麽一圈。

相关推荐
王解6 小时前
Jest项目实战(4):将工具库顺利迁移到GitHub的完整指南
单元测试·github
油泼辣子多加6 小时前
2024年11月4日Github流行趋势
github
梓羽玩Python7 小时前
推荐一款用了5年的全能下载神器:Motrix!全平台支持,不限速下载网盘文件就靠它!
程序员·开源·github
小牛itbull14 小时前
ReactPress:重塑内容管理的未来
react.js·github·reactpress
鱼满满记1 天前
1.6K+ Star!GenAIScript:一个可自动化的GenAI脚本环境
人工智能·ai·github
梦魇梦狸º1 天前
腾讯轻量云服务器docker拉取不到镜像的问题:拉取超时
docker·容器·github
Huazie1 天前
一篇搞定 Hexo Diversity 主题接入!支持多主题自由切换!
javascript·github·hexo
草明2 天前
Nginx 做反向代理,一个服务优先被使用,当无法提供服务时才使用其他的备用服务
运维·nginx·github
马里嗷2 天前
Puppeteer - 掌控浏览器自动化的开源利器
后端·github