diff --git a/app/main/effects/sceneDarknessStore.test.ts b/app/main/effects/sceneDarknessStore.test.ts new file mode 100644 index 0000000..658875f --- /dev/null +++ b/app/main/effects/sceneDarknessStore.test.ts @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { SceneDarknessStore } from './sceneDarknessStore'; + +void test('SceneDarknessStore: сохраняет штрихи при переключении сцен', () => { + const store = new SceneDarknessStore(); + store.switchScene('scene_a', true); + store.dispatch({ + kind: 'stroke.add', + stroke: { + id: 's1', + seed: 1, + createdAtMs: 100, + points: [{ x: 0.5, y: 0.5, tMs: 100 }], + radiusN: 0.08, + }, + }); + + store.switchScene('scene_b', true); + assert.equal(store.getState().strokes.length, 0); + + store.switchScene('scene_a', true); + assert.equal(store.getState().strokes.length, 1); + assert.equal(store.getState().strokes[0]?.id, 's1'); +}); + +void test('SceneDarknessStore: resetSession очищает кэш', () => { + const store = new SceneDarknessStore(); + store.switchScene('scene_a', true); + store.dispatch({ + kind: 'stroke.add', + stroke: { + id: 's1', + seed: 1, + createdAtMs: 100, + points: [{ x: 0.2, y: 0.2, tMs: 100 }], + radiusN: 0.08, + }, + }); + store.resetSession(); + store.switchScene('scene_a', true); + assert.equal(store.getState().strokes.length, 0); +}); + +void test('SceneDarknessStore: draft синхронизируется и сбрасывается при commit', () => { + const store = new SceneDarknessStore(); + store.switchScene('scene_a', true); + store.dispatch({ + kind: 'draft.set', + draft: { points: [{ x: 0.1, y: 0.1, tMs: 1 }], radiusN: 0.05 }, + }); + assert.ok(store.getState().draft); + store.dispatch({ + kind: 'stroke.add', + stroke: { + id: 's1', + seed: 1, + createdAtMs: 100, + points: [{ x: 0.1, y: 0.1, tMs: 1 }], + radiusN: 0.05, + }, + }); + assert.equal(store.getState().draft, null); + assert.equal(store.getState().strokes.length, 1); +}); diff --git a/app/main/effects/sceneDarknessStore.ts b/app/main/effects/sceneDarknessStore.ts new file mode 100644 index 0000000..6e9647c --- /dev/null +++ b/app/main/effects/sceneDarknessStore.ts @@ -0,0 +1,80 @@ +import type { SceneDarknessEvent, SceneDarknessRevealStroke, SceneDarknessState } from '../../shared/types'; + +function emptyState(): SceneDarknessState { + return { + revision: 1, + enabled: false, + cacheKey: null, + strokes: [], + draft: null, + }; +} + +export class SceneDarknessStore { + private state: SceneDarknessState = emptyState(); + /** Кэш раскрытых областей по ключу (graphNodeId или sceneId) на время сессии показа. */ + private cache = new Map(); + private currentKey: string | null = null; + + getState(): SceneDarknessState { + return { ...this.state, strokes: [...this.state.strokes] }; + } + + /** Сброс кэша при новом запуске показа (кнопка «Запустить»). */ + resetSession(): void { + this.cache.clear(); + this.currentKey = null; + this.state = emptyState(); + } + + /** + * Переключение сцены: сохраняем текущие штрихи в кэш и загружаем состояние новой сцены. + */ + switchScene(cacheKey: string | null, enabled: boolean): SceneDarknessState { + if (this.currentKey !== null) { + this.cache.set(this.currentKey, [...this.state.strokes]); + } + this.currentKey = cacheKey; + const strokes = + cacheKey && enabled ? [...(this.cache.get(cacheKey) ?? [])] : []; + this.state = { + revision: this.state.revision + 1, + enabled, + cacheKey, + strokes, + draft: null, + }; + return this.getState(); + } + + dispatch(event: SceneDarknessEvent): SceneDarknessState { + if (!this.state.enabled) return this.getState(); + switch (event.kind) { + case 'draft.set': + this.state = { + ...this.state, + revision: this.state.revision + 1, + draft: event.draft, + }; + return this.getState(); + case 'stroke.add': { + const strokes = [...this.state.strokes, event.stroke]; + if (this.currentKey) { + this.cache.set(this.currentKey, strokes); + } + this.state = { + ...this.state, + revision: this.state.revision + 1, + strokes, + draft: null, + }; + return this.getState(); + } + default: { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _x: never = event; + return this.getState(); + } + } + } +} diff --git a/app/main/index.ts b/app/main/index.ts index 7da0589..948c3d9 100644 --- a/app/main/index.ts +++ b/app/main/index.ts @@ -1,6 +1,7 @@ import { app, BrowserWindow, dialog, Menu, protocol } from 'electron'; import { ipcChannels, type SessionState } from '../shared/ipc/contracts'; +import type { Project } from '../shared/types'; import { PROJECT_ZIP_OPEN_DIALOG_FILTER, PROJECT_ZIP_SAVE_DIALOG_FILTER, @@ -11,6 +12,7 @@ import { } from '../shared/project/projectZipExtension'; import { EffectsStore } from './effects/effectsStore'; +import { SceneDarknessStore } from './effects/sceneDarknessStore'; import { installIpcRouter, registerHandler, setLicenseAssert } from './ipc/router'; import { LicenseService } from './license/licenseService'; import { ZipProjectStore } from './project/zipStore'; @@ -117,6 +119,7 @@ function installAppMenuForSession(): void { } const effectsStore = new EffectsStore(); +const sceneDarknessStore = new SceneDarknessStore(); const videoStore = new VideoPlaybackStore(); function emitEffectsState(): void { @@ -126,6 +129,20 @@ function emitEffectsState(): void { } } +function emitSceneDarknessState(): void { + const state = sceneDarknessStore.getState(); + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.send(ipcChannels.sceneDarkness.stateChanged, { state }); + } +} + +function syncSceneDarknessForProject(project: Project): void { + const cacheKey = project.currentGraphNodeId ?? project.currentSceneId ?? null; + const scene = project.currentSceneId ? project.scenes[project.currentSceneId] : undefined; + const enabled = Boolean(scene?.darkenScene) && scene?.previewAssetType === 'image'; + sceneDarknessStore.switchScene(cacheKey, enabled); +} + function emitVideoState(): void { const state = videoStore.getState(); for (const win of BrowserWindow.getAllWindows()) { @@ -267,7 +284,11 @@ async function main() { registerHandler(ipcChannels.license.clearToken, () => licenseService.clearToken()); registerHandler(ipcChannels.license.acceptEula, ({ version }) => licenseService.acceptEula(version)); registerHandler(ipcChannels.windows.openMultiWindow, () => { + sceneDarknessStore.resetSession(); openMultiWindow(); + const project = projectStore.getOpenProject(); + if (project) syncSceneDarknessForProject(project); + emitSceneDarknessState(); return { ok: true }; }); registerHandler(ipcChannels.windows.closeMultiWindow, () => { @@ -303,6 +324,15 @@ async function main() { emitSessionState(); return { project }; }); + registerHandler(ipcChannels.project.close, async () => { + await projectStore.closeOpenProject(); + effectsStore.clear(); + sceneDarknessStore.resetSession(); + emitEffectsState(); + emitSceneDarknessState(); + emitSessionState(); + return { ok: true }; + }); registerHandler(ipcChannels.project.get, () => { return { project: projectStore.getOpenProject() }; }); @@ -313,7 +343,10 @@ async function main() { registerHandler(ipcChannels.project.setCurrentScene, async ({ sceneId }) => { await projectStore.updateProject((p) => ({ ...p, currentSceneId: sceneId, currentGraphNodeId: null })); effectsStore.clear(); + const project = projectStore.getOpenProject(); + if (project) syncSceneDarknessForProject(project); emitEffectsState(); + emitSceneDarknessState(); emitSessionState(); return { currentSceneId: projectStore.getOpenProject()?.currentSceneId ?? null }; }); @@ -327,7 +360,10 @@ async function main() { currentSceneId: gn ? gn.sceneId : null, })); effectsStore.clear(); + const project = projectStore.getOpenProject(); + if (project) syncSceneDarknessForProject(project); emitEffectsState(); + emitSceneDarknessState(); emitSessionState(); const p = projectStore.getOpenProject(); return { @@ -337,6 +373,11 @@ async function main() { }); registerHandler(ipcChannels.project.updateScene, async ({ sceneId, patch }) => { const next = await projectStore.updateScene(sceneId, patch); + const project = projectStore.getOpenProject(); + if (project && project.currentSceneId === sceneId && patch.darkenScene !== undefined) { + syncSceneDarknessForProject(project); + emitSceneDarknessState(); + } emitSessionState(); return { scene: next }; }); @@ -524,6 +565,15 @@ async function main() { return { ok: true }; }); + registerHandler(ipcChannels.sceneDarkness.getState, () => { + return { state: sceneDarknessStore.getState() }; + }); + registerHandler(ipcChannels.sceneDarkness.dispatch, ({ event }) => { + sceneDarknessStore.dispatch(event); + emitSceneDarknessState(); + return { ok: true }; + }); + registerHandler(ipcChannels.video.getState, () => { return { state: videoStore.getState() }; }); diff --git a/app/main/project/zipStore.ts b/app/main/project/zipStore.ts index db97eda..8d6b3d8 100644 --- a/app/main/project/zipStore.ts +++ b/app/main/project/zipStore.ts @@ -457,6 +457,7 @@ export class ZipProjectStore { previewThumbAssetId: null, previewVideoAutostart: false, previewRotationDeg: 0, + darkenScene: false, } satisfies Scene); const next: Scene = { @@ -472,6 +473,7 @@ export class ZipProjectStore { ? { previewVideoAutostart: patch.previewVideoAutostart } : null), ...(patch.previewRotationDeg !== undefined ? { previewRotationDeg: patch.previewRotationDeg } : null), + ...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null), ...(patch.settings ? { settings: { ...base.settings, ...patch.settings } } : null), ...(patch.media ? { media: { ...base.media, ...patch.media } } : null), ...(patch.layout ? { layout: { ...base.layout, ...patch.layout } } : null), @@ -760,6 +762,16 @@ export class ZipProjectStore { await this.packZipExclusive(open.cacheDir, open.zipPath); } + async closeOpenProject(): Promise { + if (!this.openProject) return; + await this.saveNow(); + await this.waitWhilePacking(); + await this.projectWriteChain; + this.saveQueued = false; + this.openProject = null; + this.projectSession += 1; + } + async renameOpenProject(name: string, fileBaseName: string): Promise { const open = this.openProject; if (!open) throw new Error('No open project'); @@ -1123,6 +1135,7 @@ function normalizeScene(s: Scene): Scene { ); const previewThumbAssetId = (s as unknown as { previewThumbAssetId?: AssetId | null }).previewThumbAssetId ?? null; + const darkenScene = Boolean((s as unknown as { darkenScene?: boolean }).darkenScene); const rawAudios = Array.isArray(raw.audios) ? raw.audios : []; const audios = rawAudios @@ -1150,6 +1163,7 @@ function normalizeScene(s: Scene): Scene { previewThumbAssetId, previewVideoAutostart, previewRotationDeg, + darkenScene, layout: layoutIn ?? { x: 0, y: 0 }, media: { videos: raw.videos ?? [], diff --git a/app/main/protocol/dndAssetProtocol.ts b/app/main/protocol/dndAssetProtocol.ts index 8d09b8b..4b7a3eb 100644 --- a/app/main/protocol/dndAssetProtocol.ts +++ b/app/main/protocol/dndAssetProtocol.ts @@ -33,22 +33,39 @@ export function registerDndAssetProtocol(projectStore: ZipProjectStore): void { const start = Number(m[1]); const endRaw = m[2] ? Number(m[2]) : total - 1; const end = Math.min(endRaw, total - 1); - if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end < start) { - return new Response(null, { status: 416 }); + if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || start >= total || end < start) { + return new Response(null, { + status: 416, + headers: { + 'Content-Range': `bytes */${String(total)}`, + 'Cache-Control': 'no-store', + }, + }); } const len = end - start + 1; const fh = await fs.open(info.absPath, 'r'); try { const buf = Buffer.alloc(len); - await fh.read(buf, 0, len, start); - return new Response(buf, { + const { bytesRead } = await fh.read(buf, 0, len, start); + if (bytesRead <= 0) { + return new Response(null, { + status: 416, + headers: { + 'Content-Range': `bytes */${String(total)}`, + 'Cache-Control': 'no-store', + }, + }); + } + const body = bytesRead === len ? buf : Buffer.from(buf.subarray(0, bytesRead)); + const actualEnd = start + bytesRead - 1; + return new Response(body, { status: 206, headers: { 'Content-Type': info.mime, 'Accept-Ranges': 'bytes', - 'Content-Range': `bytes ${String(start)}-${String(end)}/${String(total)}`, - 'Content-Length': String(len), - 'Cache-Control': 'public, max-age=300', + 'Content-Range': `bytes ${String(start)}-${String(actualEnd)}/${String(total)}`, + 'Content-Length': String(body.length), + 'Cache-Control': 'no-store', }, }); } finally { @@ -62,8 +79,7 @@ export function registerDndAssetProtocol(projectStore: ZipProjectStore): void { headers: { 'Content-Type': info.mime, 'Accept-Ranges': 'bytes', - 'Content-Length': String(buf.length), - 'Cache-Control': 'public, max-age=300', + 'Cache-Control': 'no-store', }, }); } catch { diff --git a/app/renderer/control/ControlApp.tsx b/app/renderer/control/ControlApp.tsx index c4117b6..564d774 100644 --- a/app/renderer/control/ControlApp.tsx +++ b/app/renderer/control/ControlApp.tsx @@ -7,7 +7,9 @@ import type { GraphNodeId, Scene, SceneId } from '../../shared/types'; import { useEditorI18n } from '../editor/i18n/EditorI18nContext'; import { getDndApi } from '../shared/dndApi'; import { PixiEffectsOverlay } from '../shared/effects/PxiEffectsOverlay'; +import { SceneDarknessOverlay } from '../shared/effects/SceneDarknessOverlay'; import { useEffectsState } from '../shared/effects/useEffectsState'; +import { useSceneDarknessState } from '../shared/effects/useSceneDarknessState'; import { Button } from '../shared/ui/controls'; import { Surface } from '../shared/ui/Surface'; @@ -49,6 +51,7 @@ export function ControlApp() { const tRef = useRef(t); tRef.current = t; const [fxState, fx] = useEffectsState(); + const [sdState, sd] = useSceneDarknessState(); const [session, setSession] = useState(null); const historyRef = useRef([]); const [history, setHistory] = useState([]); @@ -69,7 +72,18 @@ export function ControlApp() { const previewHostRef = useRef(null); const previewVideoRef = useRef(null); const brushRef = useRef<{ - tool: 'fog' | 'fire' | 'rain' | 'water' | 'darkness' | 'lightning' | 'sunbeam' | 'poisonCloud' | 'freeze' | 'eraser'; + tool: + | 'fog' + | 'fire' + | 'rain' + | 'water' + | 'darkness' + | 'lightning' + | 'sunbeam' + | 'poisonCloud' + | 'freeze' + | 'exploreBrush' + | 'eraser'; startN?: { x: number; y: number }; points?: { x: number; y: number; tMs: number }[]; } | null>(null); @@ -145,6 +159,7 @@ export function ControlApp() { const currentScene = project && session?.currentSceneId ? project.scenes[session.currentSceneId] : undefined; const isVideoPreviewScene = currentScene?.previewAssetType === 'video'; + const isDarkenScene = Boolean(currentScene?.darkenScene) && !isVideoPreviewScene; 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. @@ -609,6 +624,13 @@ export function ControlApp() { draftPaintRafRef.current = requestAnimationFrame(() => { draftPaintRafRef.current = 0; setDraftFxTick((x) => x + 1); + const b = brushRef.current; + if (b?.tool === 'exploreBrush' && b.points) { + void sd.dispatch({ + kind: 'draft.set', + draft: { points: b.points, radiusN: toolRef.current.radiusN }, + }); + } }); } @@ -711,6 +733,18 @@ export function ControlApp() { }, }); } + if (b.tool === 'exploreBrush' && b.points && b.points.length > 0) { + await sd.dispatch({ + kind: 'stroke.add', + stroke: { + id: `sd_${String(createdAtMs)}_${String(seed)}`, + seed, + createdAtMs, + points: b.points, + radiusN: tool.radiusN, + }, + }); + } if (b.tool === 'darkness' && b.points && b.points.length > 0) { const last = b.points[b.points.length - 1]; if (last === undefined) return; @@ -1137,6 +1171,24 @@ export function ControlApp() { + {isDarkenScene ? ( +
+
{t('control.darknessControl')}
+
+ +
+
+ ) : null}
{t('control.brushRadius')}
+ {previewContentRect ? ( + + ) : null}
x + 1); }} onPointerMove={(e) => { @@ -1296,6 +1364,9 @@ export function ControlApp() { void commitStroke(); }} onPointerCancel={() => { + if (brushRef.current?.tool === 'exploreBrush') { + void sd.dispatch({ kind: 'draft.set', draft: null }); + } brushRef.current = null; setDraftFxTick((x) => x + 1); }} diff --git a/app/renderer/control/controlApp.effectsPanel.test.ts b/app/renderer/control/controlApp.effectsPanel.test.ts index 050ed55..7d3f722 100644 --- a/app/renderer/control/controlApp.effectsPanel.test.ts +++ b/app/renderer/control/controlApp.effectsPanel.test.ts @@ -51,6 +51,10 @@ void test('ControlApp: эффекты в пульте, иконки с тулт assert.ok(src.includes("t('control.tools')")); assert.ok(src.includes("t('control.fieldEffects')")); assert.ok(src.includes("t('control.actionEffects')")); + assert.ok(src.includes("t('control.darknessControl')")); + assert.ok(src.includes("t('control.explorerBrush')")); + assert.ok(src.includes('SceneDarknessOverlay')); + assert.ok(src.includes('useSceneDarknessState')); assert.ok(src.includes("t('control.sunbeam')")); assert.ok(src.includes("title={t('control.water')}")); assert.ok(src.includes("title={t('control.darkness')}")); diff --git a/app/renderer/editor/EditorApp.tsx b/app/renderer/editor/EditorApp.tsx index 76155bd..f6a7fea 100644 --- a/app/renderer/editor/EditorApp.tsx +++ b/app/renderer/editor/EditorApp.tsx @@ -623,6 +623,7 @@ export function EditorApp() { previewAssetType={sc?.previewAssetType ?? null} previewVideoAutostart={sc?.previewVideoAutostart ?? false} previewRotationDeg={sc?.previewRotationDeg ?? 0} + darkenScene={sc?.darkenScene ?? false} previewBusy={previewBusy} mediaAssets={sceneMediaAssets} audioRefs={sceneAudioRefs} @@ -632,6 +633,9 @@ export function EditorApp() { onPreviewVideoAutostartChange={(next) => void actions.updateScene(sid, { previewVideoAutostart: next }) } + onDarkenSceneChange={(next) => + void actions.updateScene(sid, { darkenScene: next }) + } onTitleChange={(title) => void actions.updateScene(sid, { title })} onDescriptionChange={(description) => void actions.updateScene(sid, { description }) @@ -1681,11 +1685,13 @@ type SceneInspectorProps = { previewAssetType: 'image' | 'video' | null; previewVideoAutostart: boolean; previewRotationDeg: 0 | 90 | 180 | 270; + darkenScene: boolean; previewBusy: boolean; mediaAssets: MediaAsset[]; audioRefs: SceneAudioRef[]; onAudioRefsChange: (next: SceneAudioRef[]) => void; onPreviewVideoAutostartChange: (next: boolean) => void; + onDarkenSceneChange: (next: boolean) => void; onTitleChange: (v: string) => void; onDescriptionChange: (v: string) => void; onImportPreview: () => void; @@ -1788,11 +1794,13 @@ function SceneInspector({ previewAssetType, previewVideoAutostart, previewRotationDeg, + darkenScene, previewBusy, mediaAssets, audioRefs, onAudioRefsChange, onPreviewVideoAutostartChange, + onDarkenSceneChange, onTitleChange, onDescriptionChange, onImportPreview, @@ -1877,6 +1885,19 @@ function SceneInspector({ ) : null}
+ {previewAssetId && previewAssetType === 'image' ? ( + <> +
+ + + ) : null}
{t('scene.audio')}
diff --git a/app/renderer/editor/i18n/editorMessages.ts b/app/renderer/editor/i18n/editorMessages.ts index be6e00c..0e07d0b 100644 --- a/app/renderer/editor/i18n/editorMessages.ts +++ b/app/renderer/editor/i18n/editorMessages.ts @@ -281,6 +281,7 @@ export const EDITOR_MESSAGES: Record> = { 'scene.change': 'Изменить', 'scene.clear': 'Очистить', 'scene.autostart': 'Автостарт', + 'scene.darkenScene': 'Затемнить сцену', 'scene.rotate': 'Повернуть', 'scene.audio': 'АУДИО СЦЕНЫ', 'scene.removeTitle': 'Убрать из сцены', @@ -319,6 +320,8 @@ export const EDITOR_MESSAGES: Record> = { 'control.fire': 'Огонь', 'control.water': 'Вода', 'control.darkness': 'Тьма', + 'control.darknessControl': 'Управление затемнением', + 'control.explorerBrush': 'Кисть Открытия', 'control.lightning': 'Молния', 'control.sunbeam': 'Луч света', 'control.freeze': 'Заморозка', @@ -601,6 +604,7 @@ export const EDITOR_MESSAGES: Record> = { 'scene.change': 'Change', 'scene.clear': 'Clear', 'scene.autostart': 'Autostart', + 'scene.darkenScene': 'Darken scene', 'scene.rotate': 'Rotate', 'scene.audio': 'SCENE AUDIO', 'scene.removeTitle': 'Remove from scene', @@ -639,6 +643,8 @@ export const EDITOR_MESSAGES: Record> = { 'control.fire': 'Fire', 'control.water': 'Water', 'control.darkness': 'Darkness', + 'control.darknessControl': 'Darkness control', + 'control.explorerBrush': 'Opening brush', 'control.lightning': 'Lightning', 'control.sunbeam': 'Sunbeam', 'control.freeze': 'Freeze', diff --git a/app/renderer/editor/state/projectState.ts b/app/renderer/editor/state/projectState.ts index 5fc721c..0ceabe8 100644 --- a/app/renderer/editor/state/projectState.ts +++ b/app/renderer/editor/state/projectState.ts @@ -32,6 +32,7 @@ type Actions = { previewAssetType?: 'image' | 'video' | null; previewVideoAutostart?: boolean; previewRotationDeg?: 0 | 90 | 180 | 270; + darkenScene?: boolean; settings?: Partial; media?: Partial; layout?: { x: number; y: number }; @@ -140,6 +141,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts) }; const closeProject = async () => { + await api.invoke(ipcChannels.project.close, {}); setState((s) => ({ ...s, project: null, selectedSceneId: null })); await refreshProjects(); }; @@ -157,6 +159,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts) previewAssetType: null, previewVideoAutostart: false, previewRotationDeg: 0, + darkenScene: false, media: { videos: [], audios: [] }, settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true }, connections: [], @@ -212,6 +215,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts) previewAssetType?: 'image' | 'video' | null; previewVideoAutostart?: boolean; previewRotationDeg?: 0 | 90 | 180 | 270; + darkenScene?: boolean; settings?: Partial; media?: Partial; layout?: { x: number; y: number }; @@ -237,6 +241,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts) ...(patch.previewRotationDeg !== undefined ? { previewRotationDeg: patch.previewRotationDeg } : null), + ...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null), ...(patch.settings ? { settings: { ...scene.settings, ...patch.settings } } : null), ...(patch.media ? { media: { ...scene.media, ...patch.media } } : null), layout: patch.layout ? { ...scene.layout, ...patch.layout } : scene.layout, diff --git a/app/renderer/shared/PresentationView.tsx b/app/renderer/shared/PresentationView.tsx index 3557a5f..3819eed 100644 --- a/app/renderer/shared/PresentationView.tsx +++ b/app/renderer/shared/PresentationView.tsx @@ -4,7 +4,9 @@ import { computeTimeSec } from '../../main/video/videoPlaybackStore'; import type { SessionState } from '../../shared/ipc/contracts'; import { PixiEffectsOverlay } from './effects/PxiEffectsOverlay'; +import { SceneDarknessOverlay } from './effects/SceneDarknessOverlay'; import { useEffectsState } from './effects/useEffectsState'; +import { useSceneDarknessState } from './effects/useSceneDarknessState'; import styles from './PresentationView.module.css'; import { RotatedImage } from './RotatedImage'; import { useAssetUrl } from './useAssetImageUrl'; @@ -25,6 +27,7 @@ export function PresentationView({ showEffects = true, }: PresentationViewProps) { const [fxState] = useEffectsState(); + const [sdState] = useSceneDarknessState(); const [vp] = useVideoPlaybackState(); const videoElRef = useRef(null); const [contentRect, setContentRect] = React.useState<{ x: number; y: number; w: number; h: number } | null>( @@ -132,6 +135,9 @@ export function PresentationView({ } /> ) : null} + {showEffects && scene?.previewAssetType === 'image' && scene.darkenScene && contentRect ? ( + + ) : null} {showTitle ? (
diff --git a/app/renderer/shared/effects/SceneDarknessOverlay.tsx b/app/renderer/shared/effects/SceneDarknessOverlay.tsx new file mode 100644 index 0000000..41fc482 --- /dev/null +++ b/app/renderer/shared/effects/SceneDarknessOverlay.tsx @@ -0,0 +1,96 @@ +import React, { useEffect, useRef } from 'react'; + +import type { SceneDarknessRevealStroke, SceneDarknessState } from '../../../shared/types'; + +export type SceneDarknessOverlayProps = { + state: SceneDarknessState | null; + viewport?: { x: number; y: number; w: number; h: number }; + /** 1.0 — полностью чёрный (Presentation); 0.5 — полупрозрачный (Control). */ + overlayAlpha: number; + style?: React.CSSProperties; +}; + +function drawRevealStroke( + ctx: CanvasRenderingContext2D, + stroke: SceneDarknessRevealStroke | { points: { x: number; y: number }[]; radiusN: number }, + w: number, + h: number, +): void { + const pts = stroke.points; + if (pts.length === 0) return; + const r = stroke.radiusN * Math.min(w, h); + if (pts.length === 1) { + const p = pts[0]; + if (!p) return; + ctx.beginPath(); + ctx.arc(p.x * w, p.y * h, r, 0, Math.PI * 2); + ctx.fill(); + return; + } + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.lineWidth = r * 2; + ctx.strokeStyle = 'rgba(0,0,0,1)'; + ctx.beginPath(); + const first = pts[0]; + if (!first) return; + ctx.moveTo(first.x * w, first.y * h); + for (let i = 1; i < pts.length; i++) { + const p = pts[i]; + if (!p) continue; + ctx.lineTo(p.x * w, p.y * h); + } + ctx.stroke(); +} + +export function SceneDarknessOverlay({ + state, + viewport, + overlayAlpha, + style, +}: SceneDarknessOverlayProps) { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || !state?.enabled || !viewport) return; + const w = Math.max(1, Math.round(viewport.w)); + const h = Math.max(1, Math.round(viewport.h)); + if (canvas.width !== w) canvas.width = w; + if (canvas.height !== h) canvas.height = h; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + ctx.globalCompositeOperation = 'source-over'; + ctx.fillStyle = '#000000'; + ctx.fillRect(0, 0, w, h); + + ctx.globalCompositeOperation = 'destination-out'; + for (const stroke of state.strokes) { + drawRevealStroke(ctx, stroke, w, h); + } + if (state.draft && state.draft.points.length > 0) { + drawRevealStroke(ctx, state.draft, w, h); + } + }, [state, viewport]); + + if (!state?.enabled || !viewport) return null; + + return ( + + ); +} diff --git a/app/renderer/shared/effects/useSceneDarknessState.ts b/app/renderer/shared/effects/useSceneDarknessState.ts new file mode 100644 index 0000000..32bdb62 --- /dev/null +++ b/app/renderer/shared/effects/useSceneDarknessState.ts @@ -0,0 +1,31 @@ +import { useEffect, useState } from 'react'; + +import { ipcChannels } from '../../../shared/ipc/contracts'; +import type { SceneDarknessEvent, SceneDarknessState } from '../../../shared/types'; +import { getDndApi } from '../dndApi'; + +export function useSceneDarknessState(): readonly [ + SceneDarknessState | null, + { dispatch: (event: SceneDarknessEvent) => Promise }, +] { + const api = getDndApi(); + const [state, setState] = useState(null); + + useEffect(() => { + void api.invoke(ipcChannels.sceneDarkness.getState, {}).then((r) => { + setState(r.state); + }); + return api.on(ipcChannels.sceneDarkness.stateChanged, ({ state: next }) => { + setState(next); + }); + }, [api]); + + return [ + state, + { + dispatch: async (event) => { + await api.invoke(ipcChannels.sceneDarkness.dispatch, { event }); + }, + }, + ] as const; +} diff --git a/app/shared/ipc/contracts.ts b/app/shared/ipc/contracts.ts index 2c5b8b7..8f09e5e 100644 --- a/app/shared/ipc/contracts.ts +++ b/app/shared/ipc/contracts.ts @@ -8,6 +8,8 @@ import type { Project, ProjectId, Scene, + SceneDarknessEvent, + SceneDarknessState, SceneId, VideoPlaybackEvent, VideoPlaybackState, @@ -27,6 +29,7 @@ export const ipcChannels = { list: 'project.list', create: 'project.create', open: 'project.open', + close: 'project.close', saveNow: 'project.saveNow', get: 'project.get', updateScene: 'project.updateScene', @@ -68,6 +71,11 @@ export const ipcChannels = { dispatch: 'effects.dispatch', stateChanged: 'effects.stateChanged', }, + sceneDarkness: { + getState: 'sceneDarkness.getState', + dispatch: 'sceneDarkness.dispatch', + stateChanged: 'sceneDarkness.stateChanged', + }, video: { getState: 'video.getState', dispatch: 'video.dispatch', @@ -117,6 +125,7 @@ export type UpdaterProgressEvent = { export type IpcEventMap = { [ipcChannels.session.stateChanged]: { state: SessionState }; [ipcChannels.effects.stateChanged]: { state: EffectsState }; + [ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState }; [ipcChannels.video.stateChanged]: { state: VideoPlaybackState }; [ipcChannels.license.statusChanged]: { snapshot: LicenseSnapshot }; [ipcChannels.windows.multiWindowStateChanged]: { open: boolean }; @@ -154,6 +163,10 @@ export type IpcInvokeMap = { req: { projectId: ProjectId }; res: { project: Project }; }; + [ipcChannels.project.close]: { + req: Record; + res: { ok: true }; + }; [ipcChannels.project.get]: { req: Record; res: { project: Project | null }; @@ -270,6 +283,14 @@ export type IpcInvokeMap = { req: { event: EffectsEvent }; res: { ok: true }; }; + [ipcChannels.sceneDarkness.getState]: { + req: Record; + res: { state: SceneDarknessState }; + }; + [ipcChannels.sceneDarkness.dispatch]: { + req: { event: SceneDarknessEvent }; + res: { ok: true }; + }; [ipcChannels.video.getState]: { req: Record; res: { state: VideoPlaybackState }; @@ -304,6 +325,7 @@ export type SessionState = { export type LegacyIpcEventMap = { [ipcChannels.session.stateChanged]: { state: SessionState }; [ipcChannels.effects.stateChanged]: { state: EffectsState }; + [ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState }; [ipcChannels.video.stateChanged]: { state: VideoPlaybackState }; [ipcChannels.license.statusChanged]: Record; }; @@ -316,6 +338,7 @@ export type ScenePatch = { previewThumbAssetId?: AssetId | null; previewVideoAutostart?: boolean; previewRotationDeg?: 0 | 90 | 180 | 270; + darkenScene?: boolean; settings?: Partial; media?: Partial; layout?: Partial; diff --git a/app/shared/types/domain.ts b/app/shared/types/domain.ts index 9c7089e..63e9f3b 100644 --- a/app/shared/types/domain.ts +++ b/app/shared/types/domain.ts @@ -90,6 +90,8 @@ export type Scene = { previewVideoAutostart: boolean; /** Поворот превью в градусах (0/90/180/270). */ previewRotationDeg: 0 | 90 | 180 | 270; + /** В режиме показа: сцена начинается полностью затемнённой; мастер «раскрывает» кистью. */ + darkenScene: boolean; media: SceneMediaRefs; settings: SceneSettings; connections: SceneId[]; diff --git a/app/shared/types/effects.ts b/app/shared/types/effects.ts index 7755480..1275b7d 100644 --- a/app/shared/types/effects.ts +++ b/app/shared/types/effects.ts @@ -8,6 +8,7 @@ export type EffectToolType = | 'sunbeam' | 'poisonCloud' | 'freeze' + | 'exploreBrush' | 'eraser'; export type EffectInstanceType = diff --git a/app/shared/types/index.ts b/app/shared/types/index.ts index e54d6ec..cfcc01d 100644 --- a/app/shared/types/index.ts +++ b/app/shared/types/index.ts @@ -1,4 +1,5 @@ export * from './domain'; export * from './effects'; export * from './ids'; +export * from './sceneDarkness'; export * from './videoPlayback'; diff --git a/app/shared/types/sceneDarkness.ts b/app/shared/types/sceneDarkness.ts new file mode 100644 index 0000000..cb56989 --- /dev/null +++ b/app/shared/types/sceneDarkness.ts @@ -0,0 +1,22 @@ +import type { NPoint } from './effects'; + +export type SceneDarknessRevealStroke = { + id: string; + seed: number; + createdAtMs: number; + points: NPoint[]; + radiusN: number; +}; + +export type SceneDarknessState = { + revision: number; + /** Текущая сцена помечена «Затемнить сцену» (только для изображений). */ + enabled: boolean; + cacheKey: string | null; + strokes: SceneDarknessRevealStroke[]; + draft: { points: NPoint[]; radiusN: number } | null; +}; + +export type SceneDarknessEvent = + | { kind: 'draft.set'; draft: { points: NPoint[]; radiusN: number } | null } + | { kind: 'stroke.add'; stroke: SceneDarknessRevealStroke }; diff --git a/package.json b/package.json index 95a918b..4076582 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "build:obfuscate": "node scripts/build.mjs --production --obfuscate", "lint": "eslint . --max-warnings 0", "typecheck": "tsc -p tsconfig.eslint.json --noEmit", - "test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs", + "test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs", "format": "prettier . --check", "format:write": "prettier . --write", "postinstall": "patch-package",