修正清空范围人工帧确认

- 按帧范围清空时检查完整用户选择帧段,确保人工/AI 标注帧会触发二次确认
- 选择删除人工帧时删除这些帧中的全部 mask,选择保留时整帧保留
- 补充人工-only 帧触发弹窗、保留人工帧和删除人工帧的回归测试
- 更新需求冻结和设计冻结文档中的人工帧清空语义
This commit is contained in:
2026-05-04 01:38:18 +08:00
parent a680510db8
commit 3dc6c3402e
4 changed files with 130 additions and 11 deletions

View File

@@ -61,7 +61,7 @@
- Canvas 支持滚轮缩放、移动工具拖拽、鼠标坐标显示。 - Canvas 支持滚轮缩放、移动工具拖拽、鼠标坐标显示。
- Canvas 未选中特定 mask 时mask 显示顺序必须遵循右侧“语义分类树”拖拽得到的内部覆盖优先级:低优先级先渲染,高优先级后渲染并显示在上层;选中 mask 后可以为了编辑交互临时置顶。 - Canvas 未选中特定 mask 时mask 显示顺序必须遵循右侧“语义分类树”拖拽得到的内部覆盖优先级:低优先级先渲染,高优先级后渲染并显示在上层;选中 mask 后可以为了编辑交互临时置顶。
- 时间轴支持缩略图点击切帧、range 拖动切帧、视频处理进度条点击切帧、人工/AI 标注帧和自动传播帧标识点击切帧、键盘左右方向键切帧、播放/暂停顺序推进帧。 - 时间轴支持缩略图点击切帧、range 拖动切帧、视频处理进度条点击切帧、人工/AI 标注帧和自动传播帧标识点击切帧、键盘左右方向键切帧、播放/暂停顺序推进帧。
- 顶栏旧“清空片段遮罩”入口已移除;当前清空/DEL 只在目标 mask 存在传播链结果时进入范围选择。用户选择按帧范围清空后,必须复用时间轴范围选择并最终确认;范围内只清空同一传播链自动传播结果,不能清空无关人工绘制或独立 AI 智能分割 mask。按范围清空或清空所有传播帧时如果目标帧范围内包含人工绘制或独立 AI 智能分割 mask必须二次询问是否删除人工/AI 标注帧;用户选择否时这些帧整帧保留,只清空其它自动传播帧。用户取消确认时不能删除本地 mask、后端标注或传播历史条。 - 顶栏旧“清空片段遮罩”入口已移除;当前清空/DEL 只在目标 mask 存在传播链结果时进入范围选择。用户选择按帧范围清空后,必须复用时间轴范围选择并最终确认;范围内只清空同一传播链自动传播结果,不能清空无关人工绘制或独立 AI 智能分割 mask。按范围清空或清空所有传播帧时如果目标帧范围内包含人工绘制或独立 AI 智能分割 mask必须二次询问是否删除人工/AI 标注帧;用户选择是时删除这些人工/AI 标注帧中的全部 mask用户选择否时这些帧整帧保留,只清空其它自动传播帧。用户取消确认时不能删除本地 mask、后端标注或传播历史条。
- 用户在某帧选中 mask 后,如果切换到同一自动传播结果覆盖的其他帧,工作区应自动识别并选中目标帧中对应的传播 mask匹配依据为传播结果回显到 mask metadata 的 seed 来源和传播链字段,而不是仅凭标签或颜色。 - 用户在某帧选中 mask 后,如果切换到同一自动传播结果覆盖的其他帧,工作区应自动识别并选中目标帧中对应的传播 mask匹配依据为传播结果回显到 mask metadata 的 seed 来源和传播链字段,而不是仅凭标签或颜色。
- 播放帧率使用项目 `parse_fps``original_fps`,限制在 1 到 30 FPS。 - 播放帧率使用项目 `parse_fps``original_fps`,限制在 1 到 30 FPS。
- 时间轴显示当前帧时间和总时长,时间基准使用项目 `parse_fps``original_fps`,格式为 `mm:ss.cc` - 时间轴显示当前帧时间和总时长,时间基准使用项目 `parse_fps``original_fps`,格式为 `mm:ss.cc`
@@ -152,7 +152,7 @@
- 当前前端保存状态按钮会保存当前项目未保存 mask并会更新已标记为 dirty 的已保存 mask。 - 当前前端保存状态按钮会保存当前项目未保存 mask并会更新已标记为 dirty 的已保存 mask。
- 如果 dirty mask 携带的本地旧 `annotationId` 在后端已经不存在,前端保存链路必须先用当前后端标注列表做存在性预检,已知缺失的 id 直接用同一几何和 metadata 重新 `POST` 创建标注;如果预检后发生并发删除导致 `PATCH` 返回 404也必须降级为重新创建并重新拉取后端标注替换本地旧 id点击“开始传播”前的参考帧保存也必须复用该容错逻辑不能因陈旧 id 中断传播。 - 如果 dirty mask 携带的本地旧 `annotationId` 在后端已经不存在,前端保存链路必须先用当前后端标注列表做存在性预检,已知缺失的 id 直接用同一几何和 metadata 重新 `POST` 创建标注;如果预检后发生并发删除导致 `PATCH` 返回 404也必须降级为重新创建并重新拉取后端标注替换本地旧 id点击“开始传播”前的参考帧保存也必须复用该容错逻辑不能因陈旧 id 中断传播。
- 保存成功后,前端会重新拉取后端标注,并用后端 saved annotation 替换本次提交的 draft mask未提交的其他 draft mask 仍保留。 - 保存成功后,前端会重新拉取后端标注,并用后端 saved annotation 替换本次提交的 draft mask未提交的其他 draft mask 仍保留。
- 工作区“清空遮罩”只从左侧工具栏触发;当前帧有选中 mask 时以选中 mask 为对象,没有选中时以当前帧全部 mask 为对象。若目标 mask 没有关联其它传播帧,则直接删除当前帧已保存标注并清空当前帧未保存 mask不弹确认若目标 mask 存在传播链上的其它帧结果,则弹出范围确认,用户可在同一行选择“取消”、“只清当前帧”、“按帧范围选择”或“清空所有传播帧”;按帧范围选择进入和自动传播/布尔操作一致的时间轴范围选择模式,并在顶栏“确认清空”后最终确认。清空所有传播帧或范围帧时若目标帧范围包含人工/AI 标注帧,会二次询问是否删除;选择否会保留这些人工/AI 标注帧整帧,只同步清空其它同传播链自动传播结果,不能删除其它帧独立 AI 推理或人工标注 mask。 - 工作区“清空遮罩”只从左侧工具栏触发;当前帧有选中 mask 时以选中 mask 为对象,没有选中时以当前帧全部 mask 为对象。若目标 mask 没有关联其它传播帧,则直接删除当前帧已保存标注并清空当前帧未保存 mask不弹确认若目标 mask 存在传播链上的其它帧结果,则弹出范围确认,用户可在同一行选择“取消”、“只清当前帧”、“按帧范围选择”或“清空所有传播帧”;按帧范围选择进入和自动传播/布尔操作一致的时间轴范围选择模式,并在顶栏“确认清空”后最终确认。清空所有传播帧或范围帧时若目标帧范围包含人工/AI 标注帧,会二次询问是否删除;选择是会删除这些人工/AI 标注帧中的全部 mask选择否会保留这些人工/AI 标注帧整帧,只同步清空其它同传播链自动传播结果,不能删除其它帧独立 AI 推理或人工标注 mask。
- 工作区加载项目帧后会查询已保存标注并回显。 - 工作区加载项目帧后会查询已保存标注并回显。
- 工作区支持导入 GT mask 图片,前端调用 `POST /api/ai/import-gt-mask` - 工作区支持导入 GT mask 图片,前端调用 `POST /api/ai/import-gt-mask`
- 导入 GT Mask 时,前端必须让用户选择未知 maskid 处理策略:舍弃未知类别,或导入为“未定义类别”等待后续重新命名。 - 导入 GT Mask 时,前端必须让用户选择未知 maskid 处理策略:舍弃未知类别,或导入为“未定义类别”等待后续重新命名。

View File

@@ -158,7 +158,7 @@
21. 新 mask 会带上当前选择的模板分类元数据,包括 `classId``className``classZIndex``metadata.source=ai_segmentation` 和保存状态 `draft` 21. 新 mask 会带上当前选择的模板分类元数据,包括 `classId``className``classZIndex``metadata.source=ai_segmentation` 和保存状态 `draft`
20. 顶栏保存状态按钮按当前项目待保存数量显示为“保存 X 个改动”或“已全部保存”;用户点击保存后,前端将像素 `segmentation` 转成 normalized `mask_data.polygons`;未保存 mask 调用 `POST /api/ai/annotate`dirty mask 会先读取当前后端标注 id 列表,已知存在的 id 调用 `PATCH /api/ai/annotations/{annotation_id}`,已知缺失的本地旧 id 直接保留同一 `mask_data`、几何、分类和传播 lineage metadata 改用 `POST /api/ai/annotate` 重新创建;如果预检后发生并发删除导致 `PATCH` 返回 404也会降级为重新创建并在随后回显时排除本地旧 mask id保存成功后本次提交的 draft mask id 会从本地保留列表中排除,并由后端 saved annotation 回显替换。 20. 顶栏保存状态按钮按当前项目待保存数量显示为“保存 X 个改动”或“已全部保存”;用户点击保存后,前端将像素 `segmentation` 转成 normalized `mask_data.polygons`;未保存 mask 调用 `POST /api/ai/annotate`dirty mask 会先读取当前后端标注 id 列表,已知存在的 id 调用 `PATCH /api/ai/annotations/{annotation_id}`,已知缺失的本地旧 id 直接保留同一 `mask_data`、几何、分类和传播 lineage metadata 改用 `POST /api/ai/annotate` 重新创建;如果预检后发生并发删除导致 `PATCH` 返回 404也会降级为重新创建并在随后回显时排除本地旧 mask id保存成功后本次提交的 draft mask id 会从本地保留列表中排除,并由后端 saved annotation 回显替换。
21. 工作区加载项目帧后通过 `GET /api/ai/annotations` 取回已保存标注并转成前端 mask。 21. 工作区加载项目帧后通过 `GET /api/ai/annotations` 取回已保存标注并转成前端 mask。
22. 工作区“清空遮罩”只从左侧工具栏触发;如果当前帧存在选中 mask则以当前帧选中 mask 为清空对象,否则以当前帧全部 mask 为清空对象。如果清空对象没有关联其它传播帧,直接删除当前帧已保存标注并清除当前帧本地 mask不弹确认如果存在传播链结果`VideoWorkspace` 弹出范围选择,用户可在同一行选择取消、只清当前帧、按帧范围选择或清空当前帧及同传播链所有自动传播帧;按帧范围选择复用时间轴范围选择并在顶栏“确认清空”后最终确认。按范围清空或清空所有传播帧时,如果目标帧范围包含人工/AI 标注帧,会二次询问是否删除;选择否时这些帧整帧保留,只清其它自动传播帧。本操作不删除其它帧独立 AI 推理或人工 mask。左侧工具栏的 `DEL` 按钮和键盘 Delete/Backspace 删除整块 mask 时复用同一传播链范围确认;删除已保存标注前会通过 `GET /api/ai/annotations` 预检当前项目仍存在的 annotation id只对存在的 id 发送 `DELETE` 22. 工作区“清空遮罩”只从左侧工具栏触发;如果当前帧存在选中 mask则以当前帧选中 mask 为清空对象,否则以当前帧全部 mask 为清空对象。如果清空对象没有关联其它传播帧,直接删除当前帧已保存标注并清除当前帧本地 mask不弹确认如果存在传播链结果`VideoWorkspace` 弹出范围选择,用户可在同一行选择取消、只清当前帧、按帧范围选择或清空当前帧及同传播链所有自动传播帧;按帧范围选择复用时间轴范围选择并在顶栏“确认清空”后最终确认。按范围清空或清空所有传播帧时,如果目标帧范围包含人工/AI 标注帧,会二次询问是否删除;选择是会删除这些人工/AI 标注帧中的全部 mask选择否时这些帧整帧保留,只清其它自动传播帧。本操作不删除其它帧独立 AI 推理或人工 mask。左侧工具栏的 `DEL` 按钮和键盘 Delete/Backspace 删除整块 mask 时复用同一传播链范围确认;删除已保存标注前会通过 `GET /api/ai/annotations` 预检当前项目仍存在的 annotation id只对存在的 id 发送 `DELETE`
### 视频片段传播 ### 视频片段传播

View File

@@ -1035,11 +1035,122 @@ describe('VideoWorkspace', () => {
expect(screen.getByText('是否删除人工/AI 标注帧')).toBeInTheDocument(); expect(screen.getByText('是否删除人工/AI 标注帧')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '否,保留人工帧' })); fireEvent.click(screen.getByRole('button', { name: '否,保留人工帧' }));
await waitFor(() => expect(screen.getByText('已保留人工/AI 标注帧,没有可清空的自动传播遮罩')).toBeInTheDocument()); await waitFor(() => expect(screen.getByText('已保留人工/AI 标注帧,本次没有其它可清空的自动传播遮罩')).toBeInTheDocument());
expect(apiMock.deleteAnnotation).not.toHaveBeenCalled(); expect(apiMock.deleteAnnotation).not.toHaveBeenCalled();
expect(useStore.getState().masks.map((mask) => mask.id).sort()).toEqual(['annotation-1', 'annotation-10', 'annotation-11', 'annotation-20']); expect(useStore.getState().masks.map((mask) => mask.id).sort()).toEqual(['annotation-1', 'annotation-10', 'annotation-11', 'annotation-20']);
}); });
it('asks about manual-only frames inside the selected clear range before clearing propagated masks', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
{ id: 12, project_id: 1, frame_index: 2, image_url: '/frame-2.jpg', width: 640, height: 360 },
]);
apiMock.deleteAnnotation.mockResolvedValue(undefined);
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toHaveLength(3));
act(() => {
useStore.setState({
masks: [
{ id: 'annotation-1', annotationId: '1', frameId: '10', pathData: 'M 0 0 Z', label: 'Seed', color: '#06b6d4', saved: true, saveStatus: 'saved' },
{
id: 'annotation-11',
annotationId: '11',
frameId: '11',
pathData: 'M 4 4 Z',
label: 'Manual edit',
color: '#f97316',
saved: true,
saveStatus: 'saved',
},
{
id: 'annotation-20',
annotationId: '20',
frameId: '12',
pathData: 'M 2 2 Z',
label: 'Propagated',
color: '#06b6d4',
saved: true,
saveStatus: 'saved',
metadata: { source: 'sam2_propagation', source_annotation_id: 1, source_mask_id: 'annotation-1', propagation_seed_key: 'annotation:1' },
},
],
});
});
fireEvent.click(screen.getByTitle('清空遮罩'));
fireEvent.click(screen.getByRole('button', { name: '按帧范围选择' }));
fireEvent.change(screen.getByLabelText('传播起始帧'), { target: { value: '2' } });
fireEvent.change(screen.getByLabelText('传播结束帧'), { target: { value: '3' } });
fireEvent.click(screen.getByRole('button', { name: '确认清空' }));
const confirmButtons = screen.getAllByRole('button', { name: '确认清空' });
fireEvent.click(confirmButtons[confirmButtons.length - 1]);
expect(screen.getByText('是否删除人工/AI 标注帧')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '否,保留人工帧' }));
await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('20'));
expect(apiMock.deleteAnnotation).not.toHaveBeenCalledWith('11');
expect(useStore.getState().masks.map((mask) => mask.id).sort()).toEqual(['annotation-1', 'annotation-11']);
});
it('deletes all masks on manual frames when the user confirms manual-frame clearing', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
{ id: 12, project_id: 1, frame_index: 2, image_url: '/frame-2.jpg', width: 640, height: 360 },
]);
apiMock.deleteAnnotation.mockResolvedValue(undefined);
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toHaveLength(3));
act(() => {
useStore.setState({
masks: [
{ id: 'annotation-1', annotationId: '1', frameId: '10', pathData: 'M 0 0 Z', label: 'Seed', color: '#06b6d4', saved: true, saveStatus: 'saved' },
{
id: 'annotation-10',
annotationId: '10',
frameId: '11',
pathData: 'M 1 1 Z',
label: 'Propagated',
color: '#06b6d4',
saved: true,
saveStatus: 'saved',
metadata: { source: 'sam2_propagation', source_annotation_id: 1, source_mask_id: 'annotation-1', propagation_seed_key: 'annotation:1' },
},
{ id: 'annotation-11', annotationId: '11', frameId: '11', pathData: 'M 4 4 Z', label: 'Manual edit', color: '#f97316', saved: true, saveStatus: 'saved' },
{
id: 'annotation-20',
annotationId: '20',
frameId: '12',
pathData: 'M 2 2 Z',
label: 'Propagated',
color: '#06b6d4',
saved: true,
saveStatus: 'saved',
metadata: { source: 'sam2_propagation', source_annotation_id: 1, source_mask_id: 'annotation-1', propagation_seed_key: 'annotation:1' },
},
],
});
});
fireEvent.click(screen.getByTitle('清空遮罩'));
fireEvent.click(screen.getByRole('button', { name: '按帧范围选择' }));
fireEvent.change(screen.getByLabelText('传播起始帧'), { target: { value: '2' } });
fireEvent.change(screen.getByLabelText('传播结束帧'), { target: { value: '3' } });
fireEvent.click(screen.getByRole('button', { name: '确认清空' }));
const confirmButtons = screen.getAllByRole('button', { name: '确认清空' });
fireEvent.click(confirmButtons[confirmButtons.length - 1]);
fireEvent.click(screen.getByRole('button', { name: '是,删除人工帧' }));
await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('10'));
expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('11');
expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('20');
expect(useStore.getState().masks.map((mask) => mask.id).sort()).toEqual(['annotation-1']);
});
it('auto-saves pending masks before exporting segmentation results', async () => { it('auto-saves pending masks before exporting segmentation results', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([ apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 }, { id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },

View File

@@ -943,15 +943,16 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
const requestClearMasksWithManualFrameConfirm = useCallback(( const requestClearMasksWithManualFrameConfirm = useCallback((
maskIdsToClear: string[], maskIdsToClear: string[],
messageScope: string, messageScope: string,
options?: { resetRangeAfterClear?: boolean }, options?: { manualFrameScopeIds?: string[]; resetRangeAfterClear?: boolean },
) => { ) => {
const latestMasks = useStore.getState().masks; const latestMasks = useStore.getState().masks;
const targetMaskIdSet = new Set(maskIdsToClear); const targetMaskIdSet = new Set(maskIdsToClear);
const targetMasks = latestMasks.filter((mask) => targetMaskIdSet.has(mask.id)); const targetMasks = latestMasks.filter((mask) => targetMaskIdSet.has(mask.id));
const targetFrameIds = new Set(targetMasks.map((mask) => String(mask.frameId))); const targetFrameIds = new Set(targetMasks.map((mask) => String(mask.frameId)));
const manualScopeFrameIds = new Set((options?.manualFrameScopeIds || Array.from(targetFrameIds)).map(String));
const manualFrameIds = new Set( const manualFrameIds = new Set(
latestMasks latestMasks
.filter((mask) => targetFrameIds.has(String(mask.frameId)) && !isPropagatedMask(mask)) .filter((mask) => manualScopeFrameIds.has(String(mask.frameId)) && !isPropagatedMask(mask))
.map((mask) => String(mask.frameId)), .map((mask) => String(mask.frameId)),
); );
@@ -966,13 +967,17 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
.map((frameId) => frameNumberById.get(frameId)) .map((frameId) => frameNumberById.get(frameId))
.filter((frameNumber): frameNumber is number => Boolean(frameNumber)) .filter((frameNumber): frameNumber is number => Boolean(frameNumber))
.sort((a, b) => a - b); .sort((a, b) => a - b);
const manualFrameMaskIds = latestMasks
.filter((mask) => manualFrameIds.has(String(mask.frameId)))
.map((mask) => mask.id);
const autoOnlyMaskIds = targetMasks const autoOnlyMaskIds = targetMasks
.filter((mask) => !manualFrameIds.has(String(mask.frameId))) .filter((mask) => !manualFrameIds.has(String(mask.frameId)))
.map((mask) => mask.id); .map((mask) => mask.id);
const allMaskIds = Array.from(new Set([...autoOnlyMaskIds, ...manualFrameMaskIds]));
setPendingCurrentClearConfirm(null); setPendingCurrentClearConfirm(null);
setPendingClearManualFrameConfirm({ setPendingClearManualFrameConfirm({
allMaskIds: maskIdsToClear, allMaskIds,
autoOnlyMaskIds, autoOnlyMaskIds,
manualFrameNumbers, manualFrameNumbers,
messageScope, messageScope,
@@ -989,7 +994,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
if (targetMaskIds.length === 0) { if (targetMaskIds.length === 0) {
setPendingClearManualFrameConfirm(null); setPendingClearManualFrameConfirm(null);
if (pendingClearManualFrameConfirm.resetRangeAfterClear) resetClearPropagationRangeSelection(); if (pendingClearManualFrameConfirm.resetRangeAfterClear) resetClearPropagationRangeSelection();
setStatusMessage('已保留人工/AI 标注帧,没有可清空的自动传播遮罩'); setStatusMessage('已保留人工/AI 标注帧,本次没有其它可清空的自动传播遮罩');
return; return;
} }
@@ -1067,13 +1072,16 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
}, [frames, pendingClearPropagationRangeRequest, propagationEndFrame, propagationStartFrame, totalFrames]); }, [frames, pendingClearPropagationRangeRequest, propagationEndFrame, propagationStartFrame, totalFrames]);
const executeClearPropagationFrameRange = useCallback(async (confirmState: ClearPropagationRangeConfirmState) => { const executeClearPropagationFrameRange = useCallback(async (confirmState: ClearPropagationRangeConfirmState) => {
const manualFrameScopeIds = frames
.slice(confirmState.rangeStartIndex, confirmState.rangeEndIndex + 1)
.map((frame) => String(frame.id));
requestClearMasksWithManualFrameConfirm( requestClearMasksWithManualFrameConfirm(
confirmState.targetMaskIds, confirmState.targetMaskIds,
`${confirmState.rangeStartIndex + 1}-${confirmState.rangeEndIndex + 1} 帧传播链`, `${confirmState.rangeStartIndex + 1}-${confirmState.rangeEndIndex + 1} 帧传播链`,
{ resetRangeAfterClear: true }, { manualFrameScopeIds, resetRangeAfterClear: true },
); );
setPendingClearPropagationRangeConfirm(null); setPendingClearPropagationRangeConfirm(null);
}, [requestClearMasksWithManualFrameConfirm]); }, [frames, requestClearMasksWithManualFrameConfirm]);
const handleBooleanFrameRangeRequest = useCallback((request: BooleanFrameRangeRequest) => { const handleBooleanFrameRangeRequest = useCallback((request: BooleanFrameRangeRequest) => {
const candidateFrameNumbers = request.candidateFrameIds const candidateFrameNumbers = request.candidateFrameIds
@@ -2272,7 +2280,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
</p> </p>
<p className="mt-2 text-xs leading-relaxed text-amber-100/70"> <p className="mt-2 text-xs leading-relaxed text-amber-100/70">
/AI /AI
</p> </p>
<div className="mt-5 grid grid-cols-3 gap-2"> <div className="mt-5 grid grid-cols-3 gap-2">
<button <button