feat(scenes): rich scene descriptions and control viewer window

Add TipTap editing in the editor and an Electron window on the control panel to read the current scene description.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-15 09:49:40 +08:00
parent 35a6e979eb
commit cfa3959fb3
26 changed files with 1795 additions and 60 deletions
+30 -10
View File
@@ -3,9 +3,14 @@ import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 're
import { pickEraseTargetId } from '../../shared/effectEraserHitTest';
import { ipcChannels } from '../../shared/ipc/contracts';
import type { SessionState } from '../../shared/ipc/contracts';
import { isNodeInMainStoryline, isNodeInSideStoryline, listSideStoryStarts } from '../../shared/graph/sceneGraphLineage';
import {
isNodeInMainStoryline,
isNodeInSideStoryline,
listSideStoryStarts,
} from '../../shared/graph/sceneGraphLineage';
import type { GraphNodeId, Scene, SceneId } from '../../shared/types';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { isSceneDescriptionEmpty } from '../editor/sceneDescriptionHtml';
import { getDndApi } from '../shared/dndApi';
import { RotatedImage } from '../shared/RotatedImage';
import { PixiEffectsOverlay } from '../shared/effects/PxiEffectsOverlay';
@@ -48,15 +53,7 @@ function playLightningEffectSound(): void {
}
}
function SideStoryTile({
scene,
title,
onClick,
}: {
scene: Scene;
title: string;
onClick: () => void;
}) {
function SideStoryTile({ scene, title, onClick }: { scene: Scene; title: string; onClick: () => void }) {
const thumbUrl = useAssetUrl(scene.previewThumbAssetId ?? scene.previewAssetId);
const previewUrl = useAssetUrl(scene.previewAssetId);
const imageUrl = thumbUrl ?? (scene.previewAssetType === 'image' ? previewUrl : null);
@@ -213,6 +210,8 @@ export function ControlApp() {
project && session?.currentSceneId ? project.scenes[session.currentSceneId] : undefined;
const isVideoPreviewScene = currentScene?.previewAssetType === 'video';
const isDarkenScene = Boolean(currentScene?.darkenScene) && !isVideoPreviewScene;
const sceneDescription = currentScene?.description ?? '';
const hasSceneDescription = !isSceneDescriptionEmpty(sceneDescription);
const sceneAudioRefs = useMemo(() => currentScene?.media.audios ?? [], [currentScene]);
// Keep this memo as narrow as possible: project changes on scene switch,
// but campaign audio list/config often does not.
@@ -1149,6 +1148,27 @@ export function ControlApp() {
<Surface className={styles.remote}>
<div className={styles.remoteTitle}>{t('control.remoteTitle')}</div>
<div className={styles.spacer12} />
<div className={styles.sectionLabel}>{t('control.instruments')}</div>
<div className={styles.spacer8} />
<div className={styles.iconRow}>
<Button
variant="ghost"
iconOnly
disabled={!hasSceneDescription}
title={hasSceneDescription ? t('control.descriptionTool') : t('control.descriptionMissing')}
ariaLabel={hasSceneDescription ? t('control.descriptionTool') : t('control.descriptionMissing')}
onClick={() => {
void api.invoke(ipcChannels.windows.openSceneDescription, { html: sceneDescription }).catch((err) => {
console.error('[control] openSceneDescription failed', err);
});
}}
>
<span className={styles.iconGlyph} aria-hidden>
📖
</span>
</Button>
</div>
<div className={styles.spacer12} />
{!isVideoPreviewScene ? (
<>
<div className={styles.sectionLabel}>{t('control.effects')}</div>