Twenty App 媒体录制实战:Media Notes 示例用标准 Web API 录制片段并经 uploadFile 挂载到 FILES 字段
Media Notes 是 Twenty 应用(Twenty app)仓库中的一个示例,演示了如何在 Twenty 前端组件(front component)的沙箱环境中使用标准 Web API(navigator.mediaDevices.getUserMedia 与 MediaRecorder)完成语音/视频笔记录制,并通过 twenty-sdk/front-component 提供的 uploadFile 将录制得到的 Blob 上传到 FILES 类型字段。读完本文,你将掌握:该示例的完整应用结构(对象、角色、命令、前端组件)、录制状态机与错误处理在源码层面如何组织、uploadFile 背后的宿主通信链路,以及如何在本地 Twenty 实例上用 Playwright 假媒体设备跑通端到端回归测试。
一、示例定位:标准 Web API + 唯一的 Twenty 专属调用
Media Notes 的核心设计思想是:录制逻辑写得和在任意网页里一模一样。Twenty 的沙箱(sandbox)为前端组件补齐(polyfill)了 navigator.mediaDevices.getUserMedia 和 MediaRecorder,因此应用代码不需要任何宿主侧的专有接口来“录东西”;整个示例中唯一 Twenty 专属的调用是 uploadFile(来自 twenty-sdk/front-component),它负责把录制好的 Blob 存进一个 FILES 字段。
完整的产品流程是:一个置顶的全局命令("Record media note")打开前端组件 → 组件用自己的 UI 录制语音或视频笔记 → 停止时上传 → 把文件挂载(attach)到一条 Media note 记录上 → 再从签名 URL 回放该文件。
整个示例位于 packages/twenty-apps/examples/media-notes,目录结构如下:
packages/twenty-apps/examples/media-notes/
├── e2e/
│ ├── auth.setup.ts # 登录与 workspace 会话的 setup 用例
│ └── media-notes.spec.ts # 端到端回归测试
├── src/
│ ├── command-menu-items/
│ │ └── open-media-notes.command-menu-item.ts
│ ├── components/
│ │ ├── media-notes-test-ids.ts
│ │ └── media-notes.front-component.tsx
│ ├── objects/
│ │ └── media-note.object.ts # Media note 对象与 FILES 字段
│ ├── roles/
│ │ └── default.role.ts
│ └── application.config.ts # 应用入口声明
├── playwright.config.ts
├── package.json
└── tsconfig.json
二、应用骨架:应用声明、对象与权限
2.1 应用入口 application.config.ts
application.config.ts 用 defineApplication 声明应用元信息,并给出 universalIdentifier(应用在全工作区范围内稳定的身份标识)与默认角色:
import { defineApplication } from 'twenty-sdk/define';
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from './roles/default.role';
export const APPLICATION_UNIVERSAL_IDENTIFIER =
'c832302c-e551-4b4f-b11c-19907888a284';
export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
displayName: 'Media Notes',
description:
'Example app demonstrating the recordAudio / recordVideo front component capability',
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
});
2.2 Media note 对象:承载录制片段的 FILES 字段
media-note.object.ts 定义了一个 mediaNote 对象,含两个字段:
title(TEXT):笔记标题,同时被设为labelIdentifierFieldMetadataUniversalIdentifier(列表里的显示名称字段);recording(FILES):保存录制得到的音频或视频,universalSettings: { maxNumberOfValues: 10 }限制单条记录最多挂 10 个文件值。
{
universalIdentifier: RECORDING_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.FILES,
label: 'Recording',
description: 'The captured audio or video note',
icon: 'IconMicrophone',
name: 'recording',
universalSettings: { maxNumberOfValues: 10 },
},
RECORDING_FIELD_UNIVERSAL_IDENTIFIER 会被导出,前端组件运行时依赖它来定位“往哪个字段上传”(见下节)。
2.3 默认角色与命令菜单项
default.role.ts 为 mediaNote 对象授予读写记录权限(不可软删/硬删、不可改全局设置),保证登录用户能创建和查看 Media note 记录。
open-media-notes.command-menu-item.ts 定义了打开组件的入口——一个置顶的 GLOBAL 命令:
export default defineCommandMenuItem({
universalIdentifier: '83d9d1ba-b042-41c1-94e8-892931d8663f',
label: 'Record media note',
shortLabel: 'Media note',
icon: 'IconMicrophone',
isPinned: true,
availabilityType: 'GLOBAL',
frontComponentUniversalIdentifier:
MEDIA_NOTES_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
});
isPinned: true + availabilityType: 'GLOBAL' 的组合,使其渲染为 Twenty 顶栏上的一个常驻动作按钮——e2e 测试正是靠点击这个 Record media note 按钮进入流程的。
三、前端组件:标准 Web 媒体 API 的完整录制实现
核心文件 media-notes.front-component.tsx(约 650 行)是本示例的技术密度所在,可以拆成四段来读。
3.1 运行时解析 fieldMetadataId:为什么需要分页拉对象列表
应用声明字段用的是 universalIdentifier(跨实例稳定),但上传时 uploadFile 需要的是当前 workspace 实例里的 fieldMetadataId。源码注释明确指出:metadata API 无法按 universalIdentifier 过滤,因此组件挂载后要分页遍历对象字段列表,直到找到目标字段:
const fetchRecordingFieldMetadataId = async (): Promise<string | null> => {
let cursor: string | null = null;
for (;;) {
const page: ObjectsPage = await fetchObjectsPage(cursor);
const recordingField = page.fields.find(
(field) =>
field.universalIdentifier === RECORDING_FIELD_UNIVERSAL_IDENTIFIER,
);
if (recordingField) {
return recordingField.id; // 拿到实例内的 fieldMetadataId
}
if (page.nextCursor === null) {
return null;
}
cursor = page.nextCursor;
}
};
其中 fetchObjectsPage 通过 MetadataApiClient 以游标分页(每页 OBJECTS_PAGE_SIZE = 100)请求 objects.edges.node.fieldsList。在解析出 recordingFieldMetadataId 之前,两个录制按钮保持 disabled——这是一个值得注意的“可上传性前置检查”。
3.2 录制引擎:getUserMedia + MediaRecorder 状态机
开始录制(handleStartRecording)的关键路径与任何网页上的录音/录像代码一致(源码 L264-L295):
const mediaStream = await navigator.mediaDevices.getUserMedia(
mediaType === 'audio' ? { audio: true } : { video: true, audio: true },
);
const mediaRecorder = new MediaRecorder(mediaStream);
const recordedChunks: Blob[] = [];
mediaRecorder.ondataavailable = (event) => {
const dataEvent = event as Event & { data: Blob };
if (dataEvent.data.size > 0) {
recordedChunks.push(dataEvent.data);
}
};
值得学习的是围绕这个“标准 API”构建的健壮性设计:
- 会话状态对象
RecordingSession:持有wasCancelled/isUserStopping/errorName三个标志和一个collectRecordedBlobPromise——该 Promise 在mediaRecorder.onstop时以new Blob(recordedChunks, { type: mediaRecorder.mimeType || ... })兑现。这使“停止”路径可以异步收集最终 Blob。 - 非应用主动触发的 stop 视为失败:如果 recorder 在没有用户请求停止的情况下触发
stop(伴随error事件,行为与原生 API 一致),说明宿主机启动失败或录制中途死亡,应用会停掉所有 track、释放设备并抛出failed:<reason>。 - track
onended视为取消:宿主侧的录制指示器(indicator)停止按钮、或设备被撤销,都会以标准 track ended 事件呈现;组件不需要宿主专有回调,只要对每条 track 挂track.onended,任一 track 死亡即停掉 recorder 与其余 track,返回cancelled。 - 同步防重入:
isStartingRef(useRef)在权限弹窗未决期间阻止第二次点击启动第二个采集流;isStopping/isAttaching/activeRecording状态共同覆盖“停止-上传”窗口,避免两个流程竞争。 - 错误名称映射表:把 DOMException 名称映射为应用自己的原因码,与标准 Web 语义完全一致:
| DOMException 名称 | 映射原因 |
|---|---|
NotAllowedError / SecurityError |
permission-denied |
NotFoundError / OverconstrainedError |
no-device |
NotReadableError |
busy |
NotSupportedError |
blocked |
| 其他 | unknown |
此外组件自己拥有录制 UX:计时器是组件自己渲染的 setInterval(每秒刷新 elapsedSeconds),时长上限 MAX_RECORDING_DURATION_SECONDS = 60 也是应用自定的——达到上限自动调用 stopAndSaveRef.current?.() 停止并保存。
3.3 停止即上传:uploadFile 的参数与命名约定
handleStopRecording 是流程的关键节点(源码 L385-L468):
- 标记
isUserStopping = true,调用mediaRecorder.stop(),等待collectRecordedBlob兑现,随后停止全部 media track; - 若 Blob 为空、会话被取消或 recorder 处于错误态,分别返回
cancelled/failed; - 计算时长(
Math.max(1, Math.round(...))秒),生成 ISO 时间戳文件名,然后调用uploadFile:
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const uploadResult = await uploadFile(recordedBlob, {
fieldMetadataId: recordingFieldMetadataId ?? '',
fileName: `${mediaType}-recording-${timestamp}.${getFileExtension(recordedBlob.type)}`,
});
文件扩展名由 MIME 类型映射表决定:audio/webm/video/webm → webm,audio/mp4 → m4a,video/mp4 → mp4,audio/ogg/video/ogg → ogg,未命中回退 webm。上传成功后得到 UploadedFrontComponentFile(含 fileId、url、path、mimeType、size),组件随即用 <audio> / <video> 元素以签名 URL 回放,并显示 mimeType · 时长s · KB数。
3.4 挂载到记录才“永久”:可重试的 attach 流程
源码中有一句关键注释:“把上传文件挂到记录上才使它永久——在那之前它只是一个 FILES 字段拥有的临时文件,所以这里的失败必须可恢复而非静默”(源码 L209-L240)。attach 通过 CoreApiClient 创建一条 mediaNote 记录:
const created = await new CoreApiClient().mutation({
createMediaNote: {
__args: {
data: {
title: `${pendingAttach.mediaType} note`,
recording: [
{
fileId: pendingAttach.file.fileId,
label: pendingAttach.file.path,
},
],
},
},
id: true,
},
});
如果 attach 抛错,failedAttach 状态保留待挂载的 { mediaType, file } 作为重试载荷,UI 展示 “The recording was uploaded but could not be attached to a media note. It stays temporary until it is attached.” 并提供 “Retry attaching” 按钮。成功则显示 Attached to media note {savedRecordId}。
四、uploadFile 的宿主通信链路
uploadFile 本身是一个极薄的转发层,位于 packages/twenty-sdk/src/sdk/front-component/functions/uploadFile.ts:
export const uploadFile: UploadFileFunction = (file, params) => {
const uploadFileFunction = frontComponentHostCommunicationApi.uploadFile;
if (!isDefined(uploadFileFunction)) {
throw new Error('uploadFileFunction is not set');
}
return uploadFileFunction(file, params);
};
从源码结构看,真正执行上传的是宿主页(Twenty 前端)注入到沙箱 worker 中的 frontComponentHostCommunicationApi.uploadFile 函数——应用代码运行在沙箱 worker 里,任何“与宿主交换能力”的调用(这里包括上传文件)都经由这个宿主通信 API 桥接。这也解释了为何 README 强调“沙箱为应用 polyfill 了 getUserMedia 与 MediaRecorder”:媒体能力与文件上传能力都由宿主注入,应用侧只写标准代码。
五、运行 e2e 回归测试(README 完整操作)
README 提供了驱动完整流程的 e2e 回归测试说明:测试用 Chromium 的假媒体设备跑完整流程(不需要真实麦克风或摄像头)。有一个前置条件容易被忽略:Cookie 会话是有凭证来源约束的,前端只从 API 自身 origin 认证(包括 workspace 子域名——登录流程最终会落在上面),所以前端构建产物要由后端服务器来提供,而不是跑在独立端口上:
# from the repo root
npx nx build twenty-sdk
NODE_ENV=production npx nx build twenty-front
npx nx build twenty-server
cp -r packages/twenty-front/build packages/twenty-server/dist/front
# start:ci, not start: the watch target would rimraf dist and delete the front
npx nx start:ci twenty-server &
node packages/twenty-sdk/dist/cli.cjs app:publish --private && node packages/twenty-sdk/dist/cli.cjs app:install
# then, from this directory
FRONT_BASE_URL=http://localhost:3000 npx playwright test --project=setup --project=chromium
各步骤要点:
npx nx build twenty-sdk:先构建 SDK,后面 CLI(app:publish/app:install)依赖packages/twenty-sdk/dist/cli.cjs产物;NODE_ENV=production npx nx build twenty-front+npx nx build twenty-server,然后cp -r packages/twenty-front/build packages/twenty-server/dist/front:让前端构建由服务器同域提供(见上文 origin 约束);npx nx start:ci twenty-server &:必须用start:ci而不是start——README 明确注释,watch 目标会rimraf dist,把刚拷进去的前端产物删掉;app:publish --private && app:install:把示例应用发布为私有应用并安装到当前 workspace;FRONT_BASE_URL=http://localhost:3000 npx playwright test --project=setup --project=chromium:在示例目录下执行;先跑setup(登录),再跑chromium项目。
5.1 Playwright 配置:为何只有 Chromium
playwright.config.ts 中 setup 项目匹配 *.setup.ts;chromium 项目依赖 setup,并做了针对性配置:
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: path.resolve(__dirname, 'e2e/.auth/user.json'),
permissions: ['microphone', 'camera'],
launchOptions: {
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,
args: [
'--use-fake-ui-for-media-stream',
'--use-fake-device-for-media-stream',
],
},
},
dependencies: ['setup'],
},
- 假媒体流参数
--use-fake-ui-for-media-stream(跳过真实权限弹窗)与--use-fake-device-for-media-stream(提供确定性合成媒体流)是 Chromium 专属,这就是配置注释中“只有 Chromium 项目”的原因; permissions: ['microphone', 'camera']授予媒体权限;FRONT_BASE_URL环境变量(默认http://localhost:3001)作为 baseURL,CI 命令用http://localhost:3000;- 其他值得注意的项:
workers: 1、timeout: 90s、trace: 'retain-on-failure'、screenshot: 'only-on-failure'、testIdAttribute: 'data-testid';PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH允许沙箱环境指向预装 Chromium 而非下载固定版本。
5.2 登录 setup 与测试断言
auth.setup.ts 以 E2E_LOGIN / E2E_PASSWORD / E2E_WORKSPACE_NAME 环境变量(默认 tim@apple.dev / tim@apple.dev / Apple)走“Continue with Email → 邮箱 → 密码 → Sign in”流程,处理多 workspace 的“Choose a workspace”选择页,等待 localStorage.tokenPairState 写入后,把登录后的 origin 写入 e2e/.auth/workspace-origin.txt,并把会话持久化为 e2e/.auth/user.json(storageState)。
media-notes.spec.ts 包含两条用例:
- “records an audio note end to end”:
- 通过顶栏
Record media note按钮打开组件,断言组件根节点可见(test id 定义在 media-notes-test-ids.ts); - 点击 “Record a voice note”,断言应用自有的录制计时器与停止按钮可见,等待 2.5 秒后截屏
01-recording.png; - 点击停止后断言状态文本为
captured、<audio>控件可见,且src含/file; - 一个细节很讲究:测试不只断言 URL 字符串,而是真的去 GET 这个签名 URL,要求返回 200 且 body 非空——注释解释了对某个文件守卫不服务的目录的签名 URL 在
src属性上看起来也“正确”,但实际会 401/403;且断言 body 长度而非 content-length,因为文件路由是流式响应、正常录制也没有该头; - 断言
Attached to media note出现(记录挂载完成,文件从临时变为永久),截屏02-captured.png; - 每条用例还挂了
console/pageerror/ 4xx+ 响应日志,便于从测试日志诊断沙箱 worker 内的失败。
- 通过顶栏
- “cancelling a recording resolves as cancelled”:点击录制后直接 Cancel,断言状态文本为
cancelled。
各步骤截图最终落在 e2e/.results/screenshots/(即 playwright.config.ts 中的 outputDir: './e2e/.results')。
六、环境要求与适用前提
package.json 声明了明确的运行前提:
node ^24.5.0、yarn >= 4.0.2(packageManager锁定yarn@4.13.0)、twenty >= 2.24.0(目标 Twenty 实例版本);- 依赖
twenty-sdk与twenty-client-sdk(均为 2.32.0)、React 19、@playwright/test ^1.60.0、TypeScript 5.9; - 脚本
test:e2e/test:e2e:ui分别对应 CLI 与 Playwright UI 模式。
适用前提提示:e2e 测试要求一个已构建并运行中的本地 Twenty 实例,且前端必须由 twenty-server 同域提供(而非独立端口);setup 用例的默认账号 tim@apple.dev 来自 Twenty 开发环境种子数据,若实例不同需通过 E2E_LOGIN / E2E_PASSWORD / E2E_WORKSPACE_NAME 环境变量覆盖。
七、小结:从该示例可复用的三个模式
- 标准 API 优先:媒体能力由宿主 polyfill,应用只写
getUserMedia+MediaRecorder的原生代码,错误处理沿用 DOMException 语义,最大化与普通网页的互认度; - 标识符分层:跨实例稳定的
universalIdentifier用于声明,实例内的fieldMetadataId运行时解析——这是 Twenty 应用“一次编写、多 workspace 安装”的基本工作方式; - 临时文件 → 挂载记录的持久化语义:上传只产生临时文件,
createMediaNote挂载才使其永久;据此把 attach 失败设计为可重试状态而非静默丢失,是该示例最值得借鉴的可靠性设计。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00