這個管線自動生成及自動發佈的 X 的發文如下:
https://x.com/aiteacher37681/status/2026187321477931407?s=20

人類僅負責台本與影片的品質檢查,其他全部自動化。未來若品質可確保,便希望實現完全自動化,讓新增功能時甚至不需要確認台本。
我正在開發一個面向教師的 AI 校務支援服務。
原本是由自己製作 PR 影片,但隨著 AI 開發成為主流,新功能的增加變得容易。雖然可以製作新功能,但手動製作 PR 影片的速度卻拖慢了進度,實際情況是功能存在卻未被用戶使用或未被目標群體認知。
因此最近結合了 X 的 PR 自動發文批次,創建了一個附上 PR 影片後自動發文的系統。具體來說,是使用 Playwright 自動錄製瀏覽器操作,並使用 Remotion 合成品牌統一的推廣影片,然後 push 後由 GitHub Actions 自動發文到 X 的管線。
本文將介紹其架構與實現。
┌───────────────────────────────────────────────────────────┐
│ 1. Playwright 示範測試 │
│ tests/e2e/demo/*.spec.ts │
│ 自動執行瀏覽器操作 → 錄製 .webm 影片 │
└──────────────────────┬────────────────────────────────────┘
│ public/remotion/videos/*.webm
▼
┌───────────────────────────────────────────────────────────┐
│ 2. Remotion 合成 │
│ remotion/compositions/*.tsx │
│ Hook → Problem → Demo(錄製嵌入) → CTA │
└──────────────────────┬────────────────────────────────────┘
│ npm run remotion:render:all
▼
┌───────────────────────────────────────────────────────────┐
│ 3. 輸出 MP4 │
│ out/*.mp4 (1920x1080, H.264) │
│ 用於 SNS 發佈及 LP 刊登的推廣影片 │
└───────────────────────────────────────────────────────────┘
重點在於,Playwright 錄製的操作影片作為素材,Remotion 則將其製作成品牌化的推廣影片,構成一個兩階段的流程。
與一般的 E2E 測試不同,我們準備了專門的示範錄製設定檔。
// playwright.demo.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e/demo',
fullyParallel: false,
retries: 0,
workers: 1,
timeout: 120000,
projects: [
{
name: 'demo',
use: {
...devices['Desktop Chrome'],
headless: true,
storageState: 'playwright/.clerk/premium-user.json',
video: {
mode: 'on',
size: { width: 1920, height: 1080 },
},
},
},
],
});
video.mode: 'on' 及 size: { width: 1920, height: 1080 } 是重點。在測試執行過程中,瀏覽器畫面自動儲存為1080p的webm影片。
每個功能都有 tests/e2e/demo/ 目錄下的 spec 檔。與一般的 E2E 測試的不同之處在於,模擬 API 回傳理想數據,以及記錄希望展示的操作步驟作為「台本」。
// tests/e2e/demo/report-pc.spec.ts(節選)
test('使用 AI 預測、改寫、生成來創建所見', async ({ page }) => {
// 隱藏 Next.js 錯誤覆蓋層
await page.addStyleTag({
content: `
[data-nextjs-dialog-overlay],
[data-nextjs-toast],
nextjs-portal { display: none !important; }
`
});
// API 模擬:返回理想數據
await page.route('**/api/users/me', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
id: 1,
email: '[email protected]',
is_premium: true,
subscription_status: 'premium'
})
});
});
// AI 預測 API 模擬:返回根據輸入的預測文本
await page.route('**/api/ai/report-prediction', async (route) => {
const postData = route.request().postDataJSON();
let prediction = '意欲積極地參與。';
if (postData.currentText?.includes('國語的音讀發表會中,')) {
prediction = '場面的情況要很好地傳達,聲音的大小和語調要加以考量...';
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ prediction })
});
});
// AI 改寫 API 模擬(600 毫秒延遲顯示加載)
await page.route('**/api/ai/text-rewrite', async (route) => {
await new Promise(r => setTimeout(r, 600));
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ rewrittenText: '...' })
});
});
// ========== 示範操作情境 ==========
// 步驟 1: 轉到所見創建畫面,開始新建
await page.goto('http://localhost:8080/reports');
await page.click('button:has-text("新建所見")');
// ...選擇班級和學期以進行創建
// 步驟 2: AI 預測轉換(文字輸入 → Tab 確定)
await textarea.pressSequentially('國語的音讀發表會中,', { delay: 100 });
await page.waitForTimeout(2000);
await page.keyboard.press('Tab'); // 用 Tab 確定 AI 預測
// 步驟 3: AI 改寫 → 預覽 → 確定
await page.locator('button:has-text("AI 改寫")').click();
await page.waitForTimeout(1500);
await page.locator('button:has-text("確定這個內容")').first().click();
// 步驟 4: 切換到道德標籤
await page.locator('[role="tab"]:has-text("道德")').click();
// 步驟 5: AI 生成 → 輸入提示 → 確定
await page.locator('button:has-text("AI 生成")').click();
await promptInput.pressSequentially('有同情心並幫助朋友', { delay: 80 });
await page.keyboard.press('Enter');
// 步驟 6: 保存
await page.locator('button:has-text("更新所見")').click();
// 將影片保存到指定路徑
await page.close();
await page.video()?.saveAs('public/remotion/videos/report-editor.webm');
});
| 巧思 | 原因 |
|---|---|
使用 pressSequentially 逐字輸入 |
讓打字的過程看起來更真實 |
使用 mouse.move 平滑移動滑鼠游標 |
讓滑鼠的動作顯得更自然 |
| API 模擬中故意設置延遲(600-800 毫秒) | 自然表現 AI 處理的加載狀態 |
| 隱藏 Next.js 錯誤覆蓋層 | 避免開發伺服器的覆蓋層干擾畫面 |
| 捕捉所有 API 模擬 | 防止未定義的 API 請求導致畫面崩潰 |
使用 page.video()?.saveAs() |
將錄製直接保存到 Remotion 的素材路徑 |
將 Playwright 錄製的原始瀏覽器操作影片轉換為 Remotion 製作的品牌推廣影片。
每部影片由統一的四個部分構成。
┌─────────────────────────────────────────────────┐
│ Hook (0-2秒) │
│ 標誌 + 標語(附帶 Spring 動畫) │
│ 例: 「讓校務更聰明,使用 AI」 │
├─────────────────────────────────────────────────┤
│ Problem (2-4秒) │
│ 問題提示(淡入動畫) │
│ 例: 「每天忙於事務工作...」 │
├─────────────────────────────────────────────────┤
│ Solution Demo (4-12秒) │
│ ★ 播放录制的操作影片 ★ │
│ 可以調整 playbackRate 播放速度 │
├─────────────────────────────────────────────────┤
│ CTA (12-15秒) │
│ 「立即開始免費使用」+ 按鈕風格 UI + URL │
└─────────────────────────────────────────────────┘
// remotion/compositions/HomeDashboard.tsx
import { AbsoluteFill, staticFile } from 'remotion';
import { HookScene } from '../components/HookScene';
import { CtaScene } from '../components/CtaScene';
import { SceneContainer } from '../components/SceneContainer';
import { AnimatedVideo } from '../components/AnimatedVideo';
import { GradientBackground } from '../components/GradientBackground';
import { theme } from '../styles/theme';
const VIDEO_DASHBOARD = staticFile('remotion/videos/home-dashboard.webm');
export const HomeDashboard: React.FC = () => {
return (
<AbsoluteFill>
{/* Hook: 0-2秒 - 標誌和標語 */}
<SceneContainer startFrame={0} endFrame={60}>
<HookScene
title="讓校務更聰明,使用 AI"
gradient={theme.gradients.primary}
/>
</SceneContainer>
{/* Problem: 2-4秒 - 問題提示 */}
<SceneContainer startFrame={60} endFrame={120}>
<GradientBackground colors={['#f9fafb', '#eff6ff']}>
<TextOverlay
text="每天忙於事務工作..."
fontSize={56}
animation="fadeUp"
startFrame={60}
/>
</GradientBackground>
</SceneContainer>
{/* Solution Demo: 4-12秒 - 播放 Playwright 錄製 */}
<SceneContainer startFrame={120} endFrame={360}>
<GradientBackground colors={['#ffffff', '#f0f4ff']}>
<AnimatedVideo
src={VIDEO_DASHBOARD}
startFrame={120}
playbackRate={1.2} // 稍微快一點
/>
</GradientBackground>
</SceneContainer>
{/* CTA: 12-15秒 */}
<SceneContainer startFrame={360} endFrame={450}>
<CtaScene
text="立即開始免費使用"
gradient={theme.gradients.primary}
/>
</SceneContainer>
</AbsoluteFill>
);
};
在個人面談的示範影片中,依場景調整播放速度,希望慢慢展示的部分放慢速度,等候時間則快轉。
// remotion/compositions/ConferenceEditor.tsx
const VIDEO_SEGMENTS: VideoSegment[] = [
{ duration: 25, speed: 0.8 }, // 顯示表單(慢)
{ duration: 25, speed: 1.0 }, // 下拉選單操作
{ duration: 25, speed: 1.2 }, // 學生加載
{ duration: 100, speed: 1.0 }, // 輸入提示
{ duration: 50, speed: 1.5 }, // 生成按鈕
{ duration: 130, speed: 3.0 }, // AI 生成處理(快轉)
{ duration: 13, speed: 1.0 }, // 顯示結果
{ duration: 25, speed: 2.0 }, // 側邊欄操作(加快)
{ duration: 33, speed: 0.8 }, // textarea 輸入(慢)
{ duration: 83, speed: 1.5 }, // 保存+提醒
];
結合 Remotion 的 Sequence 和 OffthreadVideo,以不同的 playbackRate 應用於每個段落。AI 處理中使用三倍速極速播放,您希望展示的輸入場景則以0.8倍速緩慢播放,這樣的控制得以實現。
remotion/components/
├── AnimatedVideo.tsx # 影片嵌入(附加 Spring 動畫淡入)
├── HookScene.tsx # Hook 畫面(標誌+標題+粒子)
├── CtaScene.tsx # CTA 畫面(文字+按鈕+URL)
├── SceneContainer.tsx # 場景區間管理
├── GradientBackground.tsx # 漸層背景
├── TextOverlay.tsx # 文本覆蓋
├── TransitionWipe.tsx # 擺動轉場
├── SparkleEffect.tsx # 閃爍效果
├── CursorAnimation.tsx # 游標動畫
├── Logo.tsx # 顯示標誌
└── AnimatedScreenshot.tsx # 螢幕截圖動畫
透過這些組件的結合,當製作新功能的示範影片時,只需新增一個組合檔即可。
品牌顏色與 Spring 動畫的設定集中管理。
// remotion/styles/theme.ts
export const theme = {
gradients: {
primary: ['#3b82f6', '#2563eb', '#1d4ed8'],
hero: ['#eff6ff', '#ffffff', '#ede9fe'],
dark: ['#1e3a8a', '#1e40af', '#2563eb'],
ai: ['#8b5cf6', '#3b82f6', '#06b6d4'],
},
video: {
width: 1920,
height: 1080,
fps: 30,
durationInFrames: 450, // 15秒
},
} as const;
// remotion/styles/animations.ts
export const SPRING_CONFIGS = {
bouncy: { damping: 12, stiffness: 200, mass: 0.5 },
smooth: { damping: 20, stiffness: 120, mass: 0.8 },
gentle: { damping: 30, stiffness: 80, mass: 1 },
snappy: { damping: 15, stiffness: 300, mass: 0.3, overshootClamping: true },
};
有一個腳本可一次性將全部9個合成渲染為 MP4。
// remotion/scripts/render-all.ts
import { bundle } from '@remotion/bundler';
import { renderMedia, selectComposition } from '@remotion/renderer';
const COMPOSITIONS = [
{ id: 'HomeDashboard', output: 'home-dashboard.mp4' },
{ id: 'ClassManagement', output: 'class-management.mp4' },
{ id: 'SeatArrangement', output: 'seat-arrangement.mp4' },
{ id: 'ReportEditor', output: 'report-editor.mp4' },
{ id: 'NewsletterEditor', output: 'newsletter-editor.mp4' },
{ id: 'PeGrouping', output: 'pe-grouping.mp4' },
{ id: 'ClassDivision', output: 'class-division.mp4' },
{ id: 'ConferenceEditor', output: 'conference-editor.mp4' },
{ id: 'TextOnlyNewsletter', output: 'text-only-newsletter.mp4' },
];
async function main() {
// 將 Remotion 專案打包
const bundled = await bundle({ entryPoint });
for (const comp of COMPOSITIONS) {
const composition = await selectComposition({
serveUrl: bundled,
id: comp.id,
});
await renderMedia({
composition,
serveUrl: bundled,
codec: 'h264',
outputLocation: `out/${comp.output}`,
});
}
}
執行也非常簡單。
# 錄製示範影片(Playwright)
npm run test:demo
# 保存錄製素材
bash scripts/preserve-demo-videos.sh
# 渲染推廣影片(Remotion)
npm run remotion:render:all
# 也可以單獨渲染
npm run remotion:render:report
npm run remotion:render:newsletter
整合與另一個儲存庫(auto-post 儲存庫)中構建的X 的自動發文批次。當 push 後,GitHub Actions 會讀取台本內容,並附上 PR 影片自動發文到 X。
┌─────────────┐ ┌──────────────┐ ┌──────────────┐ ┌─────────────┐
│ push 進行 │ ──→ │ GitHub Actions │ ──→ │ 讀取台本的內容 │ ──→ │ 附帶 PR 影片的 │
│ │ │ 觸發作業 │ │ │ │ X 自動發文 │
└─────────────┘ └──────────────┘ └──────────────┘ └─────────────┘
人類只需確認品質並 push,便可完成從創建發文到媒體附加的全自動流程。
遺憾的是,X 的發文的觀看數自體並未有太大的變化。
然而,從發文中附上的連結直接流入網站的人數增加了約1.8~2倍。
儘管附加影片的發文並未必直接提升觀看數,卻有效提高了感興趣的人實際造訪服務的機率。
對於在個人開發中感到製作示範影片麻煩的人,建議嘗試 Playwright + Remotion 的組合。一旦建立管線,只需請 AI 製作 PR 影片後自動發文到 X的體驗相當舒適。
撰寫測試的同時獲得示範素材,這樣的雙重效果亦非常值得珍惜。
希望大家可以嘗試一下✨
PS:
感謝您閱讀到最後🙇♂️
如果您覺得「這篇文章不錯」,也希望能順便了解一下我們的服務網站😆(反向連結能提升SEO,對我們會有幫助!)