用 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 进行渲染。想想还是自己做吧,为了产生特效视频竟然绕了这麽一圈。

相关推荐
Morpheon9 小时前
Cursor 1.0 版本 GitHub MCP 全面指南:从安装到工作流增强
ide·github·cursor·mcp
LinXunFeng11 小时前
Flutter - GetX Helper 助你规范应用 tag
flutter·github·visual studio code
草梅友仁12 小时前
AI 图片文字翻译与视频字幕翻译工具推荐 | 2025 年第 23 周草梅周报
开源·github·aigc
qianmoQ17 小时前
GitHub 趋势日报 (2025年06月04日)
github
abcnull18 小时前
github中main与master,master无法合并到main
git·github
星哥说事19 小时前
使用VuePress2.X构建个人知识博客,并且用个人域名部署到GitHub Pages中
开源·github
勤劳打代码20 小时前
步步为营 —— Github Connection refused 分层诊断
github
寻月隐君20 小时前
深入解析 Rust 的面向对象编程:特性、实现与设计模式
后端·rust·github
qianmoQ1 天前
GitHub 趋势日报 (2025年05月31日)
github
油泼辣子多加1 天前
2025年06月06日Github流行趋势
github