Files
wehub-resource-sync c48612c494
CI / E2E Tests (push) Has been cancelled
CI / Lint, Typecheck & Unit Tests (push) Has been cancelled
Docs Build / Build docs site (push) Has been cancelled
Publish @openmaic packages / Build, validate & publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:23 +08:00

127 lines
4.5 KiB
TypeScript

import { nanoid } from 'nanoid';
import type { Slide, SlideTheme, PPTElement } from '@openmaic/dsl';
import type { Scene, SlideContent } from '@/lib/types/stage';
import type { Action } from '@/lib/types/action';
import { createElementIdMap } from '@/lib/utils/element';
import { CURRENT_SLIDE_CONTENT_SCHEMA_VERSION } from '@/lib/edit/slide-schema';
const DEFAULT_THEME: SlideTheme = {
backgroundColor: '#ffffff',
themeColors: ['#5b9bd5', '#ed7d31', '#a5a5a5', '#ffc000', '#4472c4'],
fontColor: '#333333',
fontName: 'Microsoft YaHei',
outline: { color: '#d14424', width: 2, style: 'solid' },
shadow: { h: 0, v: 0, blur: 10, color: '#000000' },
};
/**
* Build a fresh blank slide scene for `+ Add slide` in the Pro mode rail.
* Matches the SceneBuilder default theme so user-added slides look the
* same as AI-generated ones until customized.
*
* Starts with NO actions: the playback engine dwells on a zero-action scene
* (showing the slide for a short beat) rather than skipping it, so a fresh
* slide is playable without seeding a meaningless empty speech clip. The empty
* script timeline surfaces inline as a "fill me in" cue for the user / MAIC
* Agent instead.
*/
export function createBlankSlideScene(stageId: string, title: string, order: number): Scene {
const slide: Slide = {
id: nanoid(),
viewportSize: 1000,
viewportRatio: 0.5625,
theme: DEFAULT_THEME,
elements: [],
background: { type: 'solid', color: '#ffffff' },
};
const content: SlideContent = {
type: 'slide',
schemaVersion: CURRENT_SLIDE_CONTENT_SCHEMA_VERSION,
canvas: slide,
};
const actions: Action[] = [];
return {
id: nanoid(),
stageId,
type: 'slide',
title,
order,
content,
actions,
createdAt: Date.now(),
updatedAt: Date.now(),
};
}
/**
* Build a duplicate of an existing slide scene. Deep-clones the slide
* payload and reassigns every element id (and group id) so React keys +
* downstream selection state can't collide with the source slide while
* grouped elements keep sharing a new common group id. The new scene
* gets a fresh scene id; caller is responsible for placing it in the
* scenes array (via `insertSceneAfter`).
*/
export function duplicateSlideScene(source: Scene, copySuffix: string, order: number): Scene {
if (source.type !== 'slide') {
throw new Error('duplicateSlideScene: source scene is not a slide');
}
const sourceContent = source.content as SlideContent;
const { elIdMap, groupIdMap } = createElementIdMap(sourceContent.canvas.elements);
const clonedElements: PPTElement[] = sourceContent.canvas.elements.map((element) => ({
...element,
id: elIdMap[element.id],
...(element.groupId ? { groupId: groupIdMap[element.groupId] } : {}),
}));
const clonedSlide: Slide = {
...sourceContent.canvas,
id: nanoid(),
elements: clonedElements,
};
const content: SlideContent = {
...sourceContent,
schemaVersion: CURRENT_SLIDE_CONTENT_SCHEMA_VERSION,
canvas: clonedSlide,
};
const title = copySuffix ? `${source.title} ${copySuffix}` : source.title;
// Clone the playback actions too: reseed each action id (so the copy doesn't
// share audio-cache keys / React keys with the source), remap element-bound
// cues onto the cloned element ids, and drop the stale audioId so the copy
// re-derives / regenerates its own narration audio.
const clonedActions = source.actions?.map((action) => {
// Deep-clone so nested cue data (chart/table/line data, points, themeColors)
// isn't shared by reference with the source — same reason the canvas
// elements are deep-cloned above.
const next: Record<string, unknown> = { ...structuredClone(action), id: nanoid() };
const elId = next.elementId;
if (typeof elId === 'string' && elIdMap[elId]) next.elementId = elIdMap[elId];
// Drop both audio refs — playback prefers audioUrl, so leaving it would make
// the copy keep playing the source's server-generated TTS.
delete next.audioId;
delete next.audioUrl;
return next as unknown as Action;
});
return {
...source,
id: nanoid(),
// A duplicate is NOT generated from the source's outline, so it must not
// inherit `outlineId` — otherwise the editor agent would resolve the copy's
// context to the original slide's outline. Cleared so resolveSceneOutline
// falls back to the copy's own title / content.
outlineId: undefined,
title,
order,
content,
actions: clonedActions,
createdAt: Date.now(),
updatedAt: Date.now(),
};
}