跳转到内容

把 SDK 接进剪辑器 UI

AelionSDK 不绑定 React、Vue 或任何状态管理库。接入时最重要的不是选框架,而是分清哪些数据属于工程,哪些只是当前界面状态。

状态例子放在哪里
工程数据轨道、片段、效果、Marker、输出规格Project JSON
引擎状态revision、播放状态、diagnostic、导出 JobSession
媒体运行时File、签名 URL、索引、decoder、proxyMediaProvider
界面状态zoom、scroll、hover、面板开关、拖拽草稿你的 UI store

播放头通常是界面状态;项目重新打开后不一定要回到上次位置。审核意见需要跨设备保存时,用 Marker。片段是否“当前选中”一般不用写进 Project,除非产品明确需要协作选区。

鼠标 / 键盘 / 菜单
↓
产品命令:检查选择、权限、吸附和锁定状态
↓
SDK Transaction Command
↓
project-changed(commit)
↓
更新 Timeline / Inspector / 自动保存 / 预览

Project snapshot 是工程的最终结果。UI 可以在拖动时做临时视觉反馈,但命令失败后必须回到最新 snapshot,不能让界面里的一份可变副本继续领先于 SDK。

不论使用哪种框架,都可以把 SDK 生命周期封装成普通对象:

import {
Aelion,
ProductionMediaProvider,
attachPreviewCanvas,
type AelionSessionApi,
type PreviewCanvasController,
} from '@aelionsdk/sdk';
interface EditorRuntime {
media: ProductionMediaProvider;
session: AelionSessionApi;
preview: PreviewCanvasController;
dispose(): Promise<void>;
}
async function createEditorRuntime(
project: unknown,
canvas: HTMLCanvasElement,
bindAssets: (media: ProductionMediaProvider) => Promise<void>,
): Promise<EditorRuntime> {
const media = new ProductionMediaProvider();
await bindAssets(media);
const session = await Aelion.createSession({ media });
await session.loadProject(project);
const preview = attachPreviewCanvas(session, canvas, {
quality: 'adaptive',
fit: 'contain',
pauseWhenHidden: true,
});
await preview.render(0);
return {
media,
session,
preview,
dispose: async () => {
preview.dispose();
await session.dispose();
media.dispose();
},
};
}

React 中可以在 effect 里创建并在 cleanup 中调用 dispose();Vue 可以放在 onMounted/onBeforeUnmount;路由切换也使用同一套顺序。

初始化过程中任一步失败时,也要释放已经创建的前置对象。实际产品可以在 try/catch 中记录当前阶段,或使用自己的 disposable helper。

const unsubscribers = [
session.subscribe('project-loaded', event => {
store.setProject(session.getSnapshot().project, event.revision);
}),
session.subscribe('project-changed', event => {
store.setProject(event.commit.snapshot, event.commit.revision);
store.setHistory(session.transaction.canUndo, session.transaction.canRedo);
}),
session.subscribe('diagnostic', event => {
store.addDiagnostic(event.diagnostic);
}),
session.subscribe('stats-changed', event => {
performancePanel.update(event.stats);
}),
];
function unsubscribeAll(): void {
for (const unsubscribe of unsubscribers) unsubscribe();
}

不要把每一帧 stats 都放进会触发整棵组件树重渲染的全局 store。预览时间码和性能数据可以用局部订阅、节流或专门的 external store。

时间线视口至少维护:

interface TimelineViewport {
pixelsPerSecond: number;
scrollLeftPx: number;
}
function clientXToTimeUs(clientX: number, laneLeft: number, viewport: TimelineViewport): number {
const contentX = clientX - laneLeft + viewport.scrollLeftPx;
const seconds = contentX / viewport.pixelsPerSecond;
return Math.max(0, Math.round(seconds * 1_000_000));
}

得到微秒后再做吸附和帧量化。29.97/59.94 fps 不要用小数帧率硬算,使用 frames() 或 core 时间 helper。

Zoom、scroll 和 lane 高度不写入 Project。它们只影响“怎么展示时间线”,不影响最终成片。

菜单、快捷键、右键菜单和触控按钮应调用同一组产品命令:

async function splitSelectionAtPlayhead(): Promise<void> {
const { selectedItemId, playheadUs } = store.getState();
if (selectedItemId === undefined) return;
const project = session.getSnapshot().project;
const item = project?.items[selectedItemId];
if (item === undefined) return;
if (
playheadUs <= item.range.startUs ||
playheadUs >= item.range.startUs + item.range.durationUs
) {
toast('请把播放头放到片段内部');
return;
}
session.transaction.commands.splitItem({
itemId: item.id,
rightItemId: nextEntityId('item'),
atUs: playheadUs,
label: '切分片段',
});
}

产品命令层统一处理:是否有选择、轨道是否锁定、用户权限、联动开关、错误文案和埋点。UI 组件不需要各自重复这些判断。

滑块拖动不应产生几十条撤销记录:

const interaction = session.transaction.beginInteractive({
label: '调整透明度',
baseRevision: session.revision!,
});
function updateOpacity(value: number): void {
interaction.update(tx => {
tx.setField('items', itemId, ['visual', 'opacity'], value);
});
}
function finishOpacity(): void {
interaction.commit();
}
function cancelOpacity(): void {
interaction.cancel();
}

字段路径必须匹配当前 Item 结构。普通产品 Inspector 更适合封装明确操作;Material 参数面板则可以根据 parameter schema 生成控件、范围和缺省值。

资源优先级建议:

  1. 播放和用户正在拖动的主预览;
  2. 当前可见区域的缩略图和波形;
  3. 预取、后台分析和不可见区域缩略图;
  4. 本地导出或远程任务根据产品策略单独排队。

主预览只创建一个 PreviewCanvasController。2.0 的 session.media.thumbnail() 和 session.media.filmstrip() 会与播放共享有界、串行的解码预算,时间线不会为每个片段各启动一个竞争 decoder。单元格滚出可见时间带就取消请求,并关闭每一个返回的 bitmap:

const controller = new AbortController();
const thumbnail = await session.media.thumbnail({
assetId,
sourceTimeUs: 2_000_000,
maxDimension: 320,
signal: controller.signal,
});
try {
paintThumbnail(thumbnail);
} finally {
thumbnail.close();
}
const strip = await session.media.filmstrip({ itemId, count: 8, frameHeight: 64 });
try {
paintFilmstrip(strip.bitmap, strip.frameWidth, strip.timesUs);
} finally {
strip.bitmap.close();
}

player.subscribe() 的唯一监听者拥有帧,必须关闭每一张 frame bitmap。只需要播放头时间的组件改用轻量且允许多个监听者的 subscribeTime():

const unsubscribeTime = session.player.subscribeTime(({ timeUs, state }) => {
playheadStore.update(timeUs, state);
});
// 时间线不再被查看时:
unsubscribeTime();
await session.player.reset();

pause() 会保留 decoder 和音频 transport 以便快速继续播放;reset() 会释放它们,并让 Player 回到 idle。

媒体探测、波形、缩略图、保存、导出和上传至少区分:

type TaskState =
| { status: 'queued' }
| { status: 'running'; progress?: number }
| { status: 'completed' }
| { status: 'failed'; code?: string; message: string }
| { status: 'cancelled' };

任务被新请求取代、组件卸载或用户取消时传递 AbortSignal。导出 UI 还要显示“导出的是启动时的 revision”,因为用户可以继续编辑,但当前 Job 不会跟随变化。

打开:

  1. 读取 Project JSON;
  2. 根据 Asset locator 重新授权并注册素材;
  3. 创建 Session,执行 loadProject();
  4. 连接 Preview Controller;
  5. 订阅变化、诊断和必要统计;
  6. 渲染当前播放头。

关闭:

  1. 停止 UI 新请求并取消任务;
  2. 取消 Session 订阅;
  3. dispose Preview Controller;
  4. await Session dispose;
  5. dispose Media Provider;
  6. 清空只属于这个工程的 UI state。

参考实现见Quickstart和参考编辑器。工程保存请继续阅读保存、恢复与迁移。