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

220 lines
8.8 KiB
TypeScript

import type { Action } from '@/lib/types/action';
/**
* Pure, immutable edit operations on a scene's `actions` list, plus a factory
* for new actions. The ActionsBar timeline drives all of its editing — inline
* speech text, drag-to-add, reorder, element targeting, delete — through these,
* then persists the result with `useStageStore.updateScene(sceneId, { actions })`.
*/
/**
* Action types the timeline palette can add by drag — only the ones that stand
* alone. Whiteboard cues (板书 etc.) need an open→draw→close workflow with
* content/positioning, so they aren't bare-addable here.
*/
export type AddableType = 'speech' | 'spotlight' | 'laser';
/** Build a fresh action of the given type with a stable id. */
export function makeAction(type: AddableType, id: string): Action {
switch (type) {
case 'speech':
return { id, type: 'speech', text: '' } as unknown as Action;
case 'spotlight':
return { id, type: 'spotlight', elementId: '' } as unknown as Action;
case 'laser':
return { id, type: 'laser', elementId: '' } as unknown as Action;
}
}
/**
* Build a fresh discussion action with an empty topic. A discussion isn't
* drag-addable like the palette cues: it must be the LAST action and there is
* at most one per scene (mirrors the `action-parser` post-processing invariant),
* so it's appended via {@link appendDiscussion} rather than dropped at a slot.
*/
export function makeDiscussion(id: string): Action {
return { id, type: 'discussion', topic: '' } as unknown as Action;
}
/** Index of the scene's discussion action, or -1 when there is none. */
export function discussionIndex(actions: Action[]): number {
return actions.findIndex((a) => a.type === 'discussion');
}
/** Whether the scene already has a discussion (at most one is allowed). */
export function hasDiscussion(actions: Action[]): boolean {
return discussionIndex(actions) !== -1;
}
/**
* Append a discussion to the very end of the scene. No-op when one already
* exists, enforcing the at-most-one + terminal invariant.
*/
export function appendDiscussion(actions: Action[], id: string): Action[] {
if (hasDiscussion(actions)) return actions;
return [...actions, makeDiscussion(id)];
}
/**
* Cap an insertion slot so nothing can land AFTER the discussion — it stays
* terminal. Returns the slot unchanged when there is no discussion.
*/
export function clampInsertSlot(actions: Action[], slot: number): number {
const d = discussionIndex(actions);
return d === -1 ? slot : Math.min(slot, d);
}
/** Insert `action` so it lands at position `index` (clamped). */
export function insertAt(actions: Action[], index: number, action: Action): Action[] {
const i = Math.max(0, Math.min(index, actions.length));
return [...actions.slice(0, i), action, ...actions.slice(i)];
}
/** Remove the action at `index` (no-op if out of range). */
export function removeAt(actions: Action[], index: number): Action[] {
if (index < 0 || index >= actions.length) return actions;
return [...actions.slice(0, index), ...actions.slice(index + 1)];
}
/**
* Move the item at `from` to insertion slot `to` (an index into the ORIGINAL
* array, i.e. one of the n+1 gaps). No-op when the slot is where it already is.
*/
export function move(actions: Action[], from: number, to: number): Action[] {
if (from < 0 || from >= actions.length) return actions;
if (to === from || to === from + 1) return actions;
const next = actions.slice();
const [item] = next.splice(from, 1);
const dest = from < to ? to - 1 : to;
next.splice(Math.max(0, Math.min(dest, next.length)), 0, item);
return next;
}
/** Set a speech action's text (no-op if `index` isn't a speech action). */
export function setSpeechText(actions: Action[], index: number, text: string): Action[] {
const a = actions[index];
if (!a || a.type !== 'speech') return actions;
const next = actions.slice();
next[index] = { ...a, text } as Action;
return next;
}
/** Like {@link setSpeechText} but targets an action by id (index-stale-safe). */
export function setSpeechTextById(actions: Action[], id: string, text: string): Action[] {
const index = actions.findIndex((a) => a.id === id);
return index < 0 ? actions : setSpeechText(actions, index, text);
}
/**
* Edit a speech line's text AND drop its stamped audio fields (index-stale-safe).
* The cached audio blob is keyed by sceneOrder+actionId, not the text, so an
* edit must invalidate it or the stale audio would replay for the new wording —
* after this the line reads as un-voiced until regenerated. (Deleting the blob
* itself is done separately via `discardSpeechAudio`.)
*/
export function setSpeechTextClearAudioById(actions: Action[], id: string, text: string): Action[] {
const index = actions.findIndex((a) => a.id === id);
const a = actions[index];
if (!a || a.type !== 'speech') return actions;
const next = actions.slice();
const cleaned = { ...a, text } as Action & { audioId?: string; audioUrl?: string };
delete cleaned.audioId;
delete cleaned.audioUrl;
next[index] = cleaned;
return next;
}
/** Remove an action by id (index-stale-safe). */
export function removeById(actions: Action[], id: string): Action[] {
const index = actions.findIndex((a) => a.id === id);
return index < 0 ? actions : removeAt(actions, index);
}
/** Move an action (by id) to insertion slot `to` (index-stale-safe). */
export function moveById(actions: Action[], id: string, to: number): Action[] {
const index = actions.findIndex((a) => a.id === id);
return index < 0 ? actions : move(actions, index, to);
}
/** Nudge an action one slot left (`dir < 0`) or right (`dir > 0`), by id. */
export function moveByIdDir(actions: Action[], id: string, dir: number): Action[] {
const index = actions.findIndex((a) => a.id === id);
if (index < 0) return actions;
return dir < 0 ? move(actions, index, index - 1) : move(actions, index, index + 2);
}
/** Element-bound cue types whose `elementId` may be set. */
const ELEMENT_BOUND_TYPES = new Set(['spotlight', 'laser', 'play_video']);
/** Set an element-bound cue's `elementId` (no-op for other types / out of range). */
export function setElementId(actions: Action[], index: number, elementId: string): Action[] {
const a = actions[index];
if (!a || !ELEMENT_BOUND_TYPES.has(a.type)) return actions;
const next = actions.slice();
next[index] = { ...a, elementId } as Action;
return next;
}
/** Like {@link setElementId} but targets an action by id (index-stale-safe). */
export function setElementIdById(actions: Action[], id: string, elementId: string): Action[] {
const index = actions.findIndex((a) => a.id === id);
return index < 0 ? actions : setElementId(actions, index, elementId);
}
/** Stamp a speech action's cached `audioId` (no-op if not a speech action). */
export function setAudioId(actions: Action[], index: number, audioId: string): Action[] {
const a = actions[index];
if (!a || a.type !== 'speech') return actions;
const next = actions.slice();
next[index] = { ...a, audioId } as Action;
return next;
}
/** Like {@link setAudioId} but targets an action by id (index-stale-safe). */
export function setAudioIdById(actions: Action[], id: string, audioId: string): Action[] {
const index = actions.findIndex((a) => a.id === id);
return index < 0 ? actions : setAudioId(actions, index, audioId);
}
/** Set a discussion's `topic` by id (no-op for a missing id / non-discussion). */
export function setDiscussionTopicById(actions: Action[], id: string, topic: string): Action[] {
const index = actions.findIndex((a) => a.id === id);
const a = actions[index];
if (!a || a.type !== 'discussion') return actions;
const next = actions.slice();
next[index] = { ...a, topic } as Action;
return next;
}
/**
* Set (or, for an empty value, clear) one of a discussion's optional fields by
* id. `prompt` / `agentId` are optional, so an empty string drops the key
* rather than persisting a blank — keeping the serialized action clean.
*/
function setDiscussionOptionalById(
actions: Action[],
id: string,
field: 'prompt' | 'agentId',
value: string,
): Action[] {
const index = actions.findIndex((a) => a.id === id);
const a = actions[index];
if (!a || a.type !== 'discussion') return actions;
const next = actions.slice();
const updated = { ...a } as Action & { prompt?: string; agentId?: string };
if (value) updated[field] = value;
else delete updated[field];
next[index] = updated;
return next;
}
/** Set a discussion's `prompt` by id; an empty string clears it. */
export function setDiscussionPromptById(actions: Action[], id: string, prompt: string): Action[] {
return setDiscussionOptionalById(actions, id, 'prompt', prompt);
}
/** Set a discussion's initiating `agentId` by id; an empty string clears it. */
export function setDiscussionAgentById(actions: Action[], id: string, agentId: string): Action[] {
return setDiscussionOptionalById(actions, id, 'agentId', agentId);
}