首页
/ Twenty App 媒体录制实战:Media Notes 示例用标准 Web API 录制片段并经 uploadFile 挂载到 FILES 字段

Twenty App 媒体录制实战:Media Notes 示例用标准 Web API 录制片段并经 uploadFile 挂载到 FILES 字段

2026-09-05 10:48:25作者:裘晴惠Vivianne

Media Notes 是 Twenty 应用(Twenty app)仓库中的一个示例,演示了如何在 Twenty 前端组件(front component)的沙箱环境中使用标准 Web APInavigator.mediaDevices.getUserMediaMediaRecorder)完成语音/视频笔记录制,并通过 twenty-sdk/front-component 提供的 uploadFile 将录制得到的 Blob 上传到 FILES 类型字段。读完本文,你将掌握:该示例的完整应用结构(对象、角色、命令、前端组件)、录制状态机与错误处理在源码层面如何组织、uploadFile 背后的宿主通信链路,以及如何在本地 Twenty 实例上用 Playwright 假媒体设备跑通端到端回归测试。

一、示例定位:标准 Web API + 唯一的 Twenty 专属调用

Media Notes 的核心设计思想是:录制逻辑写得和在任意网页里一模一样。Twenty 的沙箱(sandbox)为前端组件补齐(polyfill)了 navigator.mediaDevices.getUserMediaMediaRecorder,因此应用代码不需要任何宿主侧的专有接口来“录东西”;整个示例中唯一 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.tsdefineApplication 声明应用元信息,并给出 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(列表里的显示名称字段);
  • recordingFILES):保存录制得到的音频或视频,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.tsmediaNote 对象授予读写记录权限(不可软删/硬删、不可改全局设置),保证登录用户能创建和查看 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 三个标志和一个 collectRecordedBlob Promise——该 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):

  1. 标记 isUserStopping = true,调用 mediaRecorder.stop(),等待 collectRecordedBlob 兑现,随后停止全部 media track;
  2. 若 Blob 为空、会话被取消或 recorder 处于错误态,分别返回 cancelled / failed
  3. 计算时长(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/webmwebmaudio/mp4m4avideo/mp4mp4audio/ogg/video/oggogg,未命中回退 webm。上传成功后得到 UploadedFrontComponentFile(含 fileIdurlpathmimeTypesize),组件随即用 <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

各步骤要点:

  1. npx nx build twenty-sdk:先构建 SDK,后面 CLI(app:publish / app:install)依赖 packages/twenty-sdk/dist/cli.cjs 产物;
  2. 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 约束);
  3. npx nx start:ci twenty-server &:必须用 start:ci 而不是 start——README 明确注释,watch 目标会 rimraf dist,把刚拷进去的前端产物删掉;
  4. app:publish --private && app:install:把示例应用发布为私有应用并安装到当前 workspace;
  5. FRONT_BASE_URL=http://localhost:3000 npx playwright test --project=setup --project=chromium:在示例目录下执行;先跑 setup(登录),再跑 chromium 项目。

5.1 Playwright 配置:为何只有 Chromium

playwright.config.tssetup 项目匹配 *.setup.tschromium 项目依赖 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: 1timeout: 90strace: 'retain-on-failure'screenshot: 'only-on-failure'testIdAttribute: 'data-testid'PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH 允许沙箱环境指向预装 Chromium 而非下载固定版本。

5.2 登录 setup 与测试断言

auth.setup.tsE2E_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 包含两条用例:

  1. “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 内的失败。
  2. “cancelling a recording resolves as cancelled”:点击录制后直接 Cancel,断言状态文本为 cancelled

各步骤截图最终落在 e2e/.results/screenshots/(即 playwright.config.ts 中的 outputDir: './e2e/.results')。

六、环境要求与适用前提

package.json 声明了明确的运行前提:

  • node ^24.5.0yarn >= 4.0.2packageManager 锁定 yarn@4.13.0)、twenty >= 2.24.0(目标 Twenty 实例版本);
  • 依赖 twenty-sdktwenty-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 环境变量覆盖。

七、小结:从该示例可复用的三个模式

  1. 标准 API 优先:媒体能力由宿主 polyfill,应用只写 getUserMedia + MediaRecorder 的原生代码,错误处理沿用 DOMException 语义,最大化与普通网页的互认度;
  2. 标识符分层:跨实例稳定的 universalIdentifier 用于声明,实例内的 fieldMetadataId 运行时解析——这是 Twenty 应用“一次编写、多 workspace 安装”的基本工作方式;
  3. 临时文件 → 挂载记录的持久化语义:上传只产生临时文件,createMediaNote 挂载才使其永久;据此把 attach 失败设计为可重试状态而非静默丢失,是该示例最值得借鉴的可靠性设计。
登录后查看全文
热门项目推荐
相关项目推荐