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

849 lines
30 KiB
TypeScript

'use client';
import { useCallback, useRef } from 'react';
import { useStageStore } from '@/lib/store/stage';
import { isSceneEditLocked } from '@/lib/edit/regen-lock';
import { getCurrentModelConfig } from '@/lib/utils/model-config';
import { useSettingsStore } from '@/lib/store/settings';
import { db } from '@/lib/utils/database';
import type {
SceneOutline,
PdfImage,
ImageMapping,
UserRequirements,
} from '@/lib/types/generation';
import type { AgentInfo } from '@/lib/generation/generation-pipeline';
import type { Scene } from '@/lib/types/stage';
import type { SpeechAction } from '@/lib/types/action';
import { splitLongSpeechActions } from '@/lib/audio/tts-utils';
import { measureAudioDuration } from '@/lib/audio/audio-duration';
import { isTTSProviderEnabled } from '@/lib/audio/provider-enablement';
import { resolveAgentVoiceOptions, pickNarratorAgent } from '@/lib/audio/agent-voice';
import { useAgentRegistry } from '@/lib/orchestration/registry/store';
import { generateMediaForOutlines } from '@/lib/media/media-orchestrator';
import { lazyBoundedMap, mapWithConcurrency } from '@/lib/utils/concurrency';
import { createLogger } from '@/lib/logger';
import {
isAbortError,
withGenerationRetry,
type GenerationRetryOptions,
} from '@/lib/generation/generation-retry';
const log = createLogger('SceneGenerator');
interface SceneContentResult {
success: boolean;
content?: unknown;
effectiveOutline?: SceneOutline;
error?: string;
errorCode?: string;
statusCode?: number;
}
interface SceneActionsResult {
success: boolean;
scene?: Scene;
previousSpeeches?: string[];
error?: string;
errorCode?: string;
statusCode?: number;
}
type ClientRetryOptions<T> = Partial<
Omit<GenerationRetryOptions<T>, 'label' | 'shouldRetryResult' | 'signal'>
>;
function getApiHeaders(): HeadersInit {
const config = getCurrentModelConfig();
const settings = useSettingsStore.getState();
const imageProviderConfig = settings.imageProvidersConfig?.[settings.imageProviderId];
const videoProviderConfig = settings.videoProvidersConfig?.[settings.videoProviderId];
return {
'Content-Type': 'application/json',
'x-model': config.modelString || '',
'x-api-key': config.apiKey || '',
'x-base-url': config.baseUrl || '',
'x-provider-type': config.providerType || '',
// Image generation provider
'x-image-provider': settings.imageProviderId || '',
'x-image-model': settings.imageModelId || '',
'x-image-api-key': imageProviderConfig?.apiKey || '',
'x-image-base-url': imageProviderConfig?.baseUrl || '',
// Video generation provider
'x-video-provider': settings.videoProviderId || '',
'x-video-model': settings.videoModelId || '',
'x-video-api-key': videoProviderConfig?.apiKey || '',
'x-video-base-url': videoProviderConfig?.baseUrl || '',
// Media generation toggles
'x-image-generation-enabled': String(settings.imageGenerationEnabled ?? false),
'x-video-generation-enabled': String(settings.videoGenerationEnabled ?? false),
};
}
function withThinkingConfig<T extends Record<string, unknown>>(body: T): T {
const { thinkingConfig } = getCurrentModelConfig();
return thinkingConfig ? ({ ...body, thinkingConfig } as T) : body;
}
async function readJsonResponse(response: Response): Promise<Record<string, unknown>> {
return response.json().catch(() => ({
error: response.statusText || 'Request failed',
}));
}
function createHttpError(
response: Response,
data: { details?: unknown; error?: unknown; errorCode?: unknown },
fallback: string,
): Error & { errorCode?: string; statusCode?: number } {
const message =
typeof data.details === 'string'
? data.details
: typeof data.error === 'string'
? data.error
: `${fallback}: HTTP ${response.status}`;
const error = new Error(message) as Error & { errorCode?: string; statusCode?: number };
if (typeof data.errorCode === 'string') {
error.errorCode = data.errorCode;
}
error.statusCode = response.status;
return error;
}
function messageFromError(error: unknown, fallback: string): string {
return error instanceof Error ? error.message : fallback;
}
function errorMeta(error: unknown): Pick<SceneContentResult, 'errorCode' | 'statusCode'> {
if (!error || typeof error !== 'object') return {};
const record = error as { errorCode?: unknown; statusCode?: unknown };
return {
...(typeof record.errorCode === 'string' ? { errorCode: record.errorCode } : {}),
...(typeof record.statusCode === 'number' ? { statusCode: record.statusCode } : {}),
};
}
/** Call POST /api/generate/scene-content (step 1) */
export async function fetchSceneContent(
params: {
outline: SceneOutline;
allOutlines: SceneOutline[];
stageId: string;
pdfImages?: PdfImage[];
imageMapping?: ImageMapping;
stageInfo: {
name: string;
description?: string;
language?: string;
style?: string;
};
agents?: AgentInfo[];
languageDirective?: string;
requirements?: UserRequirements;
},
signal?: AbortSignal,
retryOptions?: ClientRetryOptions<SceneContentResult>,
): Promise<SceneContentResult> {
try {
return await withGenerationRetry(
async () => {
const response = await fetch('/api/generate/scene-content', {
method: 'POST',
headers: getApiHeaders(),
body: JSON.stringify(withThinkingConfig(params)),
signal,
});
const data = await readJsonResponse(response);
if (!response.ok) {
throw createHttpError(response, data, 'Scene content request failed');
}
return data as unknown as SceneContentResult;
},
{
label: `scene content "${params.outline.title}"`,
shouldRetryResult: (result) => !result.success || !result.content,
...retryOptions,
signal,
},
);
} catch (error) {
if (isAbortError(error)) throw error;
return {
success: false,
error: messageFromError(error, 'Content generation failed'),
...errorMeta(error),
};
}
}
/** Call POST /api/generate/scene-actions (step 2) */
export async function fetchSceneActions(
params: {
outline: SceneOutline;
allOutlines: SceneOutline[];
content: unknown;
stageId: string;
agents?: AgentInfo[];
previousSpeeches?: string[];
userProfile?: string;
languageDirective?: string;
},
signal?: AbortSignal,
retryOptions?: ClientRetryOptions<SceneActionsResult>,
): Promise<SceneActionsResult> {
try {
return await withGenerationRetry(
async () => {
const response = await fetch('/api/generate/scene-actions', {
method: 'POST',
headers: getApiHeaders(),
body: JSON.stringify(withThinkingConfig(params)),
signal,
});
const data = await readJsonResponse(response);
if (!response.ok) {
throw createHttpError(response, data, 'Scene actions request failed');
}
return data as unknown as SceneActionsResult;
},
{
label: `scene actions "${params.outline.title}"`,
shouldRetryResult: (result) => !result.success || !result.scene,
...retryOptions,
signal,
},
);
} catch (error) {
if (isAbortError(error)) throw error;
return {
success: false,
error: messageFromError(error, 'Actions generation failed'),
...errorMeta(error),
};
}
}
interface TTSApiResponse {
success?: boolean;
base64?: string;
format?: string;
error?: string;
details?: string;
}
/** Generate TTS for one speech action and store in IndexedDB */
export async function generateAndStoreTTS(
audioId: string,
text: string,
language?: string,
signal?: AbortSignal,
retryOptions?: ClientRetryOptions<TTSApiResponse>,
): Promise<void> {
const settings = useSettingsStore.getState();
if (settings.ttsProviderId === 'browser-native-tts') return;
// Don't server-generate against a disabled/unconfigured provider (#665).
if (
!isTTSProviderEnabled(
settings.ttsProviderId,
settings.ttsProvidersConfig?.[settings.ttsProviderId],
)
)
return;
const ttsProviderConfig = settings.ttsProvidersConfig?.[settings.ttsProviderId];
// Narration is the teacher's voice — resolve it from the teacher agent profile
// through the single resolver (registers + references by id for stable timbre).
const teacher = pickNarratorAgent(useAgentRegistry.getState().listAgents());
const providerOptions = await resolveAgentVoiceOptions(teacher, {
providerId: settings.ttsProviderId,
providerConfig: ttsProviderConfig,
voiceId: settings.ttsVoice,
language,
});
const data = await withGenerationRetry(
async () => {
const response = await fetch('/api/generate/tts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text,
audioId,
ttsProviderId: settings.ttsProviderId,
ttsModelId: ttsProviderConfig?.modelId,
ttsVoice: settings.ttsVoice,
ttsSpeed: settings.ttsSpeed,
ttsApiKey: ttsProviderConfig?.apiKey || undefined,
// Managed providers resolve their base URL server-side; only send the
// client's own base URL (custom providers).
ttsBaseUrl:
ttsProviderConfig?.baseUrl || ttsProviderConfig?.customDefaultBaseUrl || undefined,
ttsProviderOptions: providerOptions,
}),
signal,
});
const data = (await readJsonResponse(response)) as TTSApiResponse;
if (!response.ok) {
throw createHttpError(response, data, 'TTS request failed');
}
return data;
},
{
label: `tts "${audioId}"`,
shouldRetryResult: (result) => !result.success || !result.base64 || !result.format,
...retryOptions,
signal,
},
);
if (!data.success || !data.base64 || !data.format) {
const err = new Error(
data.details || data.error || 'TTS request failed: invalid response payload',
);
log.warn('TTS failed for', audioId, ':', err);
throw err;
}
const binary = atob(data.base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
const blob = new Blob([bytes], { type: `audio/${data.format}` });
// Measure duration once at store time so video export (#854) can map this
// clip onto a timeline without re-decoding. null → leave undefined; the audio
// still persists and plays.
const duration = measureAudioDuration(bytes, data.format) ?? undefined;
await db.audioFiles.put({
id: audioId,
blob,
duration,
format: data.format,
createdAt: Date.now(),
});
}
/** Generate TTS for all speech actions in a scene. Returns result. */
async function generateTTSForScene(
scene: Scene,
language?: string,
signal?: AbortSignal,
): Promise<{ success: boolean; failedCount: number; error?: string }> {
const providerId = useSettingsStore.getState().ttsProviderId;
scene.actions = splitLongSpeechActions(scene.actions || [], providerId);
const speechActions = scene.actions.filter(
(a): a is SpeechAction => a.type === 'speech' && !!a.text,
);
if (speechActions.length === 0) return { success: true, failedCount: 0 };
let failedCount = 0;
let lastError: string | undefined;
// Use scene order to make audio IDs unique across scenes
// This prevents audio collision when action IDs are sequential (e.g., action_1, action_2)
const sceneOrder = scene.order;
// Generate + store one action's audio. Failures are counted, not thrown, so
// one bad clip never aborts the rest of the scene.
const generateOne = async (action: SpeechAction) => {
// Include scene order in audioId to prevent collision across scenes
const audioId = `tts_s${sceneOrder}_${action.id}`;
action.audioId = audioId;
try {
await generateAndStoreTTS(audioId, action.text, language, signal);
} catch (error) {
if (isAbortError(error)) throw error;
failedCount++;
lastError = error instanceof Error ? error.message : `TTS failed for action ${action.id}`;
log.warn('TTS generation failed:', {
providerId,
actionId: action.id,
sceneOrder,
audioId,
textLength: action.text.length,
error: lastError,
});
}
};
// #660 follow-up: speech actions within a scene are independent — each renders
// its own audio under its own audioId, with no cross-action ordering — so when
// the server opts into parallel generation, render them with bounded
// concurrency (reusing the PARALLEL_SCENE_CONCURRENCY knob) instead of one at a
// time. Default (0 / unset) keeps the original strictly-serial behaviour.
const ttsConcurrency = Math.max(
0,
Math.floor(useSettingsStore.getState().parallelSceneConcurrency ?? 0),
);
if (ttsConcurrency > 1 && speechActions.length > 1) {
await mapWithConcurrency(speechActions, ttsConcurrency, generateOne);
} else {
for (const action of speechActions) {
await generateOne(action);
}
}
return {
success: failedCount === 0,
failedCount,
error: lastError,
};
}
export interface UseSceneGeneratorOptions {
onSceneGenerated?: (scene: Scene, index: number) => void;
onSceneFailed?: (outline: SceneOutline, error: string) => void;
onPhaseChange?: (phase: 'content' | 'actions', outline: SceneOutline) => void;
onComplete?: () => void;
}
export interface GenerationParams {
pdfImages?: PdfImage[];
imageMapping?: ImageMapping;
stageInfo: {
name: string;
description?: string;
language?: string;
style?: string;
};
agents?: AgentInfo[];
userProfile?: string;
languageDirective?: string;
}
export function useSceneGenerator(options: UseSceneGeneratorOptions = {}) {
const abortRef = useRef(false);
const generatingRef = useRef(false);
const mediaAbortRef = useRef<AbortController | null>(null);
const fetchAbortRef = useRef<AbortController | null>(null);
const lastParamsRef = useRef<GenerationParams | null>(null);
const generateRemainingRef = useRef<((params: GenerationParams) => Promise<void>) | null>(null);
const store = useStageStore;
const generateRemaining = useCallback(
async (params: GenerationParams) => {
lastParamsRef.current = params;
if (generatingRef.current) return;
generatingRef.current = true;
abortRef.current = false;
const removeGeneratingOutline = (outlineId: string) => {
const current = store.getState().generatingOutlines;
if (!current.some((o) => o.id === outlineId)) return;
store.getState().setGeneratingOutlines(current.filter((o) => o.id !== outlineId));
};
// Create a new AbortController for this generation run
fetchAbortRef.current = new AbortController();
const signal = fetchAbortRef.current.signal;
const state = store.getState();
const { outlines, scenes, stage } = state;
const startEpoch = state.generationEpoch;
if (!stage || outlines.length === 0) {
generatingRef.current = false;
return;
}
store.getState().setGenerationStatus('generating');
// Determine pending outlines
const completedOrders = new Set(scenes.map((s) => s.order));
const pending = outlines
.filter((o) => !completedOrders.has(o.order))
.sort((a, b) => a.order - b.order);
if (pending.length === 0) {
store.getState().setGenerationStatus('completed');
store.getState().setGeneratingOutlines([]);
store.getState().setGenerationComplete(true);
options.onComplete?.();
generatingRef.current = false;
return;
}
store.getState().setGeneratingOutlines(pending);
// Launch media generation in parallel — does not block content/action generation
mediaAbortRef.current = new AbortController();
generateMediaForOutlines(outlines, stage.id, mediaAbortRef.current.signal).catch((err) => {
log.warn('Media generation error:', err);
});
// Get previousSpeeches from last completed scene
let previousSpeeches: string[] = [];
const sortedScenes = [...scenes].sort((a, b) => a.order - b.order);
if (sortedScenes.length > 0) {
const lastScene = sortedScenes[sortedScenes.length - 1];
previousSpeeches = (lastScene.actions || [])
.filter((a): a is SpeechAction => a.type === 'speech')
.map((a) => a.text);
}
// #572: opt-in parallel content fetch. Concurrency is server-configured
// (PARALLEL_SCENE_CONCURRENCY), default 0 = off, so out-of-box behaviour is
// unchanged.
const parallelConcurrency = Math.max(
0,
// Belt-and-suspenders: the value is already clamped server-side and again
// in the settings store; re-clamp here so a stale/garbage store value can
// never spawn an unbounded fetch fan-out.
Math.floor(useSettingsStore.getState().parallelSceneConcurrency ?? 0),
);
const useParallelContent = parallelConcurrency > 1 && pending.length > 1;
// Pipelined generation loop (#572). When parallelism is on, scene *content*
// fetches are kicked off up front with bounded concurrency (lazyBoundedMap)
// but CONSUMED IN ORDER inside the serial loop below — there is no barrier.
// So the first scene paints after content(1)+actions(1)+TTS(1) (same as
// serial) while later content fetches run hidden behind earlier scenes'
// actions/TTS. Content has no cross-scene dependency, so running it ahead is
// safe; actions + TTS stay strictly serial to preserve previousSpeeches
// threading and the pause-on-failure UX. With parallelism off this is exactly
// the original one-at-a-time loop.
try {
const fetchContent = (outline: SceneOutline) =>
fetchSceneContent(
{
outline,
allOutlines: outlines,
stageId: stage.id,
pdfImages: params.pdfImages,
imageMapping: params.imageMapping,
stageInfo: params.stageInfo,
agents: params.agents,
languageDirective: params.languageDirective,
},
signal,
);
// Pre-warm content fetches (<= parallelConcurrency in flight), keyed by
// outline id. Each promise resolves to a result and never rejects, so an
// unexpected throw routes through the same mark-failed path as the serial
// loop instead of taking sibling fetches down with it.
const contentPromises = useParallelContent
? new Map(
lazyBoundedMap(
pending,
parallelConcurrency,
async (outline): Promise<SceneContentResult> => {
options.onPhaseChange?.('content', outline);
try {
return await fetchContent(outline);
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : 'Content generation failed',
};
}
},
{
shouldContinue: () =>
!abortRef.current && store.getState().generationEpoch === startEpoch,
},
).map((promise, i) => [pending[i].id, promise] as const),
)
: null;
let pausedByFailureOrAbort = false;
let hadContentFailure = false;
for (const outline of pending) {
if (abortRef.current || store.getState().generationEpoch !== startEpoch) {
store.getState().setGenerationStatus('paused');
pausedByFailureOrAbort = true;
break;
}
store.getState().setCurrentGeneratingOrder(outline.order);
// Step 1: content — await this outline's pre-warmed fetch (parallel),
// which usually resolved while the previous scene's actions/TTS ran; or
// fetch it now (serial).
let contentResult: SceneContentResult;
if (contentPromises) {
contentResult = (await contentPromises.get(outline.id)) ?? {
success: false,
error: 'Content generation failed',
};
} else {
options.onPhaseChange?.('content', outline);
contentResult = await fetchContent(outline);
}
if (!contentResult.success || !contentResult.content) {
if (abortRef.current || store.getState().generationEpoch !== startEpoch) {
pausedByFailureOrAbort = true;
break;
}
store.getState().addFailedOutline(outline);
options.onSceneFailed?.(outline, contentResult.error || 'Content generation failed');
if (contentPromises) {
// Parallel: surface the failure but keep going with the other scenes
// (their content is already in flight).
hadContentFailure = true;
removeGeneratingOutline(outline.id);
continue;
}
// Serial: pause the batch (unchanged behaviour).
store.getState().setGenerationStatus('paused');
pausedByFailureOrAbort = true;
break;
}
if (abortRef.current || store.getState().generationEpoch !== startEpoch) {
store.getState().setGenerationStatus('paused');
pausedByFailureOrAbort = true;
break;
}
// Step 2: Generate actions + assemble scene
options.onPhaseChange?.('actions', outline);
const actionsResult = await fetchSceneActions(
{
outline: contentResult.effectiveOutline || outline,
allOutlines: outlines,
content: contentResult.content,
stageId: stage.id,
agents: params.agents,
previousSpeeches,
userProfile: params.userProfile,
languageDirective: params.languageDirective,
},
signal,
);
if (actionsResult.success && actionsResult.scene) {
const scene = actionsResult.scene;
const settings = useSettingsStore.getState();
// TTS generation — failure means the whole scene fails
if (
settings.ttsEnabled &&
settings.ttsProviderId !== 'browser-native-tts' &&
isTTSProviderEnabled(
settings.ttsProviderId,
settings.ttsProvidersConfig?.[settings.ttsProviderId],
)
) {
const ttsResult = await generateTTSForScene(
scene,
params.languageDirective || params.stageInfo.language,
signal,
);
if (!ttsResult.success) {
if (abortRef.current || store.getState().generationEpoch !== startEpoch) {
pausedByFailureOrAbort = true;
break;
}
store.getState().addFailedOutline(outline);
options.onSceneFailed?.(outline, ttsResult.error || 'TTS generation failed');
store.getState().setGenerationStatus('paused');
pausedByFailureOrAbort = true;
break;
}
}
// Epoch changed — stage switched, discard this scene
if (store.getState().generationEpoch !== startEpoch) {
pausedByFailureOrAbort = true;
break;
}
removeGeneratingOutline(outline.id);
store.getState().addScene(scene);
options.onSceneGenerated?.(scene, outline.order);
previousSpeeches = actionsResult.previousSpeeches || [];
} else {
if (abortRef.current || store.getState().generationEpoch !== startEpoch) {
pausedByFailureOrAbort = true;
break;
}
store.getState().addFailedOutline(outline);
options.onSceneFailed?.(outline, actionsResult.error || 'Actions generation failed');
store.getState().setGenerationStatus('paused');
pausedByFailureOrAbort = true;
break;
}
}
if (!abortRef.current && !pausedByFailureOrAbort) {
if (hadContentFailure) {
// Parallel content phase left some outlines failed but kept going;
// surface them for retry instead of signalling a clean completion.
store.getState().setGenerationStatus('paused');
} else {
store.getState().setGenerationStatus('completed');
store.getState().setGeneratingOutlines([]);
store.getState().setGenerationComplete(true);
options.onComplete?.();
}
}
} catch (err: unknown) {
// AbortError is expected when stop() is called — don't treat as failure
if (isAbortError(err)) {
log.info('Generation aborted');
store.getState().setGenerationStatus('paused');
} else {
throw err;
}
} finally {
generatingRef.current = false;
fetchAbortRef.current = null;
}
},
[options, store],
);
// Keep ref in sync so retrySingleOutline can call it
generateRemainingRef.current = generateRemaining;
const stop = useCallback(() => {
abortRef.current = true;
store.getState().bumpGenerationEpoch();
fetchAbortRef.current?.abort();
mediaAbortRef.current?.abort();
}, [store]);
const isGenerating = useCallback(() => generatingRef.current, []);
/** Retry a single failed outline from scratch (content → actions → TTS). */
const retrySingleOutline = useCallback(
async (outlineId: string) => {
const state = store.getState();
const outline = state.failedOutlines.find((o) => o.id === outlineId);
const params = lastParamsRef.current;
if (!outline || !state.stage || !params) return;
// Regen-lock (#571): never silently replace a scene that is open in
// edit mode. Failed outlines have no completed scene yet so this is
// structurally a no-op today, but the guard is in place for the
// moment a "regenerate a successful scene" path routes through here.
const lockedScene = state.scenes.find((s) => s.order === outline.order);
if (
lockedScene &&
isSceneEditLocked({
sceneId: lockedScene.id,
mode: state.mode,
currentSceneId: state.currentSceneId,
})
) {
return;
}
const removeGeneratingOutline = () => {
const current = store.getState().generatingOutlines;
if (!current.some((o) => o.id === outlineId)) return;
store.getState().setGeneratingOutlines(current.filter((o) => o.id !== outlineId));
};
// Remove from failed list and mark as generating
store.getState().retryFailedOutline(outlineId);
store.getState().setGenerationStatus('generating');
const currentGenerating = store.getState().generatingOutlines;
if (!currentGenerating.some((o) => o.id === outline.id)) {
store.getState().setGeneratingOutlines([...currentGenerating, outline]);
}
const abortController = new AbortController();
const signal = abortController.signal;
try {
// Step 1: Content
const contentResult = await fetchSceneContent(
{
outline,
allOutlines: state.outlines,
stageId: state.stage.id,
pdfImages: params.pdfImages,
imageMapping: params.imageMapping,
stageInfo: params.stageInfo,
agents: params.agents,
languageDirective: params.languageDirective,
},
signal,
);
if (!contentResult.success || !contentResult.content) {
store.getState().addFailedOutline(outline);
return;
}
// Step 2: Actions
const sortedScenes = [...store.getState().scenes].sort((a, b) => a.order - b.order);
const lastScene = sortedScenes[sortedScenes.length - 1];
const previousSpeeches = lastScene
? (lastScene.actions || [])
.filter((a): a is SpeechAction => a.type === 'speech')
.map((a) => a.text)
: [];
const actionsResult = await fetchSceneActions(
{
outline: contentResult.effectiveOutline || outline,
allOutlines: state.outlines,
content: contentResult.content,
stageId: state.stage.id,
agents: params.agents,
previousSpeeches,
userProfile: params.userProfile,
languageDirective: params.languageDirective,
},
signal,
);
if (!actionsResult.success || !actionsResult.scene) {
store.getState().addFailedOutline(outline);
return;
}
// Step 3: TTS
const settings = useSettingsStore.getState();
if (
settings.ttsEnabled &&
settings.ttsProviderId !== 'browser-native-tts' &&
isTTSProviderEnabled(
settings.ttsProviderId,
settings.ttsProvidersConfig?.[settings.ttsProviderId],
)
) {
const ttsResult = await generateTTSForScene(
actionsResult.scene,
params.languageDirective || params.stageInfo.language,
signal,
);
if (!ttsResult.success) {
store.getState().addFailedOutline(outline);
return;
}
}
removeGeneratingOutline();
store.getState().addScene(actionsResult.scene);
// Resume remaining generation if there are pending outlines
if (store.getState().generatingOutlines.length > 0 && lastParamsRef.current) {
generateRemainingRef.current?.(lastParamsRef.current);
} else {
// This retry may have materialized the final outstanding slide. The
// generateRemaining completion path is not reached on the retry flow,
// so mark completion here too — otherwise a later delete would treat
// the orphaned outline as pending and regenerate it.
store.getState().markGenerationCompleteIfDone();
}
} catch (err) {
if (!isAbortError(err)) {
store.getState().addFailedOutline(outline);
}
}
},
[store],
);
return { generateRemaining, retrySingleOutline, stop, isGenerating };
}