feat(scenes): add scene darkness reveal and fix project reopen crash
Add darkenScene with Opening brush in presentation, persist reveal strokes per scene during a session, and close projects properly on return to home. Harden dnd asset streaming to avoid Windows main-process crashes when reopening projects. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
@@ -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<string, SceneDarknessRevealStroke[]>();
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { app, BrowserWindow, dialog, Menu, protocol } from 'electron';
|
import { app, BrowserWindow, dialog, Menu, protocol } from 'electron';
|
||||||
|
|
||||||
import { ipcChannels, type SessionState } from '../shared/ipc/contracts';
|
import { ipcChannels, type SessionState } from '../shared/ipc/contracts';
|
||||||
|
import type { Project } from '../shared/types';
|
||||||
import {
|
import {
|
||||||
PROJECT_ZIP_OPEN_DIALOG_FILTER,
|
PROJECT_ZIP_OPEN_DIALOG_FILTER,
|
||||||
PROJECT_ZIP_SAVE_DIALOG_FILTER,
|
PROJECT_ZIP_SAVE_DIALOG_FILTER,
|
||||||
@@ -11,6 +12,7 @@ import {
|
|||||||
} from '../shared/project/projectZipExtension';
|
} from '../shared/project/projectZipExtension';
|
||||||
|
|
||||||
import { EffectsStore } from './effects/effectsStore';
|
import { EffectsStore } from './effects/effectsStore';
|
||||||
|
import { SceneDarknessStore } from './effects/sceneDarknessStore';
|
||||||
import { installIpcRouter, registerHandler, setLicenseAssert } from './ipc/router';
|
import { installIpcRouter, registerHandler, setLicenseAssert } from './ipc/router';
|
||||||
import { LicenseService } from './license/licenseService';
|
import { LicenseService } from './license/licenseService';
|
||||||
import { ZipProjectStore } from './project/zipStore';
|
import { ZipProjectStore } from './project/zipStore';
|
||||||
@@ -117,6 +119,7 @@ function installAppMenuForSession(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const effectsStore = new EffectsStore();
|
const effectsStore = new EffectsStore();
|
||||||
|
const sceneDarknessStore = new SceneDarknessStore();
|
||||||
const videoStore = new VideoPlaybackStore();
|
const videoStore = new VideoPlaybackStore();
|
||||||
|
|
||||||
function emitEffectsState(): void {
|
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 {
|
function emitVideoState(): void {
|
||||||
const state = videoStore.getState();
|
const state = videoStore.getState();
|
||||||
for (const win of BrowserWindow.getAllWindows()) {
|
for (const win of BrowserWindow.getAllWindows()) {
|
||||||
@@ -267,7 +284,11 @@ async function main() {
|
|||||||
registerHandler(ipcChannels.license.clearToken, () => licenseService.clearToken());
|
registerHandler(ipcChannels.license.clearToken, () => licenseService.clearToken());
|
||||||
registerHandler(ipcChannels.license.acceptEula, ({ version }) => licenseService.acceptEula(version));
|
registerHandler(ipcChannels.license.acceptEula, ({ version }) => licenseService.acceptEula(version));
|
||||||
registerHandler(ipcChannels.windows.openMultiWindow, () => {
|
registerHandler(ipcChannels.windows.openMultiWindow, () => {
|
||||||
|
sceneDarknessStore.resetSession();
|
||||||
openMultiWindow();
|
openMultiWindow();
|
||||||
|
const project = projectStore.getOpenProject();
|
||||||
|
if (project) syncSceneDarknessForProject(project);
|
||||||
|
emitSceneDarknessState();
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
registerHandler(ipcChannels.windows.closeMultiWindow, () => {
|
registerHandler(ipcChannels.windows.closeMultiWindow, () => {
|
||||||
@@ -303,6 +324,15 @@ async function main() {
|
|||||||
emitSessionState();
|
emitSessionState();
|
||||||
return { project };
|
return { project };
|
||||||
});
|
});
|
||||||
|
registerHandler(ipcChannels.project.close, async () => {
|
||||||
|
await projectStore.closeOpenProject();
|
||||||
|
effectsStore.clear();
|
||||||
|
sceneDarknessStore.resetSession();
|
||||||
|
emitEffectsState();
|
||||||
|
emitSceneDarknessState();
|
||||||
|
emitSessionState();
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
registerHandler(ipcChannels.project.get, () => {
|
registerHandler(ipcChannels.project.get, () => {
|
||||||
return { project: projectStore.getOpenProject() };
|
return { project: projectStore.getOpenProject() };
|
||||||
});
|
});
|
||||||
@@ -313,7 +343,10 @@ async function main() {
|
|||||||
registerHandler(ipcChannels.project.setCurrentScene, async ({ sceneId }) => {
|
registerHandler(ipcChannels.project.setCurrentScene, async ({ sceneId }) => {
|
||||||
await projectStore.updateProject((p) => ({ ...p, currentSceneId: sceneId, currentGraphNodeId: null }));
|
await projectStore.updateProject((p) => ({ ...p, currentSceneId: sceneId, currentGraphNodeId: null }));
|
||||||
effectsStore.clear();
|
effectsStore.clear();
|
||||||
|
const project = projectStore.getOpenProject();
|
||||||
|
if (project) syncSceneDarknessForProject(project);
|
||||||
emitEffectsState();
|
emitEffectsState();
|
||||||
|
emitSceneDarknessState();
|
||||||
emitSessionState();
|
emitSessionState();
|
||||||
return { currentSceneId: projectStore.getOpenProject()?.currentSceneId ?? null };
|
return { currentSceneId: projectStore.getOpenProject()?.currentSceneId ?? null };
|
||||||
});
|
});
|
||||||
@@ -327,7 +360,10 @@ async function main() {
|
|||||||
currentSceneId: gn ? gn.sceneId : null,
|
currentSceneId: gn ? gn.sceneId : null,
|
||||||
}));
|
}));
|
||||||
effectsStore.clear();
|
effectsStore.clear();
|
||||||
|
const project = projectStore.getOpenProject();
|
||||||
|
if (project) syncSceneDarknessForProject(project);
|
||||||
emitEffectsState();
|
emitEffectsState();
|
||||||
|
emitSceneDarknessState();
|
||||||
emitSessionState();
|
emitSessionState();
|
||||||
const p = projectStore.getOpenProject();
|
const p = projectStore.getOpenProject();
|
||||||
return {
|
return {
|
||||||
@@ -337,6 +373,11 @@ async function main() {
|
|||||||
});
|
});
|
||||||
registerHandler(ipcChannels.project.updateScene, async ({ sceneId, patch }) => {
|
registerHandler(ipcChannels.project.updateScene, async ({ sceneId, patch }) => {
|
||||||
const next = await projectStore.updateScene(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();
|
emitSessionState();
|
||||||
return { scene: next };
|
return { scene: next };
|
||||||
});
|
});
|
||||||
@@ -524,6 +565,15 @@ async function main() {
|
|||||||
return { ok: true };
|
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, () => {
|
registerHandler(ipcChannels.video.getState, () => {
|
||||||
return { state: videoStore.getState() };
|
return { state: videoStore.getState() };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -457,6 +457,7 @@ export class ZipProjectStore {
|
|||||||
previewThumbAssetId: null,
|
previewThumbAssetId: null,
|
||||||
previewVideoAutostart: false,
|
previewVideoAutostart: false,
|
||||||
previewRotationDeg: 0,
|
previewRotationDeg: 0,
|
||||||
|
darkenScene: false,
|
||||||
} satisfies Scene);
|
} satisfies Scene);
|
||||||
|
|
||||||
const next: Scene = {
|
const next: Scene = {
|
||||||
@@ -472,6 +473,7 @@ export class ZipProjectStore {
|
|||||||
? { previewVideoAutostart: patch.previewVideoAutostart }
|
? { previewVideoAutostart: patch.previewVideoAutostart }
|
||||||
: null),
|
: null),
|
||||||
...(patch.previewRotationDeg !== undefined ? { previewRotationDeg: patch.previewRotationDeg } : null),
|
...(patch.previewRotationDeg !== undefined ? { previewRotationDeg: patch.previewRotationDeg } : null),
|
||||||
|
...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null),
|
||||||
...(patch.settings ? { settings: { ...base.settings, ...patch.settings } } : null),
|
...(patch.settings ? { settings: { ...base.settings, ...patch.settings } } : null),
|
||||||
...(patch.media ? { media: { ...base.media, ...patch.media } } : null),
|
...(patch.media ? { media: { ...base.media, ...patch.media } } : null),
|
||||||
...(patch.layout ? { layout: { ...base.layout, ...patch.layout } } : null),
|
...(patch.layout ? { layout: { ...base.layout, ...patch.layout } } : null),
|
||||||
@@ -760,6 +762,16 @@ export class ZipProjectStore {
|
|||||||
await this.packZipExclusive(open.cacheDir, open.zipPath);
|
await this.packZipExclusive(open.cacheDir, open.zipPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async closeOpenProject(): Promise<void> {
|
||||||
|
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<Project> {
|
async renameOpenProject(name: string, fileBaseName: string): Promise<Project> {
|
||||||
const open = this.openProject;
|
const open = this.openProject;
|
||||||
if (!open) throw new Error('No open project');
|
if (!open) throw new Error('No open project');
|
||||||
@@ -1123,6 +1135,7 @@ function normalizeScene(s: Scene): Scene {
|
|||||||
);
|
);
|
||||||
const previewThumbAssetId =
|
const previewThumbAssetId =
|
||||||
(s as unknown as { previewThumbAssetId?: AssetId | null }).previewThumbAssetId ?? null;
|
(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 rawAudios = Array.isArray(raw.audios) ? raw.audios : [];
|
||||||
const audios = rawAudios
|
const audios = rawAudios
|
||||||
@@ -1150,6 +1163,7 @@ function normalizeScene(s: Scene): Scene {
|
|||||||
previewThumbAssetId,
|
previewThumbAssetId,
|
||||||
previewVideoAutostart,
|
previewVideoAutostart,
|
||||||
previewRotationDeg,
|
previewRotationDeg,
|
||||||
|
darkenScene,
|
||||||
layout: layoutIn ?? { x: 0, y: 0 },
|
layout: layoutIn ?? { x: 0, y: 0 },
|
||||||
media: {
|
media: {
|
||||||
videos: raw.videos ?? [],
|
videos: raw.videos ?? [],
|
||||||
|
|||||||
@@ -33,22 +33,39 @@ export function registerDndAssetProtocol(projectStore: ZipProjectStore): void {
|
|||||||
const start = Number(m[1]);
|
const start = Number(m[1]);
|
||||||
const endRaw = m[2] ? Number(m[2]) : total - 1;
|
const endRaw = m[2] ? Number(m[2]) : total - 1;
|
||||||
const end = Math.min(endRaw, total - 1);
|
const end = Math.min(endRaw, total - 1);
|
||||||
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end < start) {
|
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || start >= total || end < start) {
|
||||||
return new Response(null, { status: 416 });
|
return new Response(null, {
|
||||||
|
status: 416,
|
||||||
|
headers: {
|
||||||
|
'Content-Range': `bytes */${String(total)}`,
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const len = end - start + 1;
|
const len = end - start + 1;
|
||||||
const fh = await fs.open(info.absPath, 'r');
|
const fh = await fs.open(info.absPath, 'r');
|
||||||
try {
|
try {
|
||||||
const buf = Buffer.alloc(len);
|
const buf = Buffer.alloc(len);
|
||||||
await fh.read(buf, 0, len, start);
|
const { bytesRead } = await fh.read(buf, 0, len, start);
|
||||||
return new Response(buf, {
|
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,
|
status: 206,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': info.mime,
|
'Content-Type': info.mime,
|
||||||
'Accept-Ranges': 'bytes',
|
'Accept-Ranges': 'bytes',
|
||||||
'Content-Range': `bytes ${String(start)}-${String(end)}/${String(total)}`,
|
'Content-Range': `bytes ${String(start)}-${String(actualEnd)}/${String(total)}`,
|
||||||
'Content-Length': String(len),
|
'Content-Length': String(body.length),
|
||||||
'Cache-Control': 'public, max-age=300',
|
'Cache-Control': 'no-store',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
@@ -62,8 +79,7 @@ export function registerDndAssetProtocol(projectStore: ZipProjectStore): void {
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': info.mime,
|
'Content-Type': info.mime,
|
||||||
'Accept-Ranges': 'bytes',
|
'Accept-Ranges': 'bytes',
|
||||||
'Content-Length': String(buf.length),
|
'Cache-Control': 'no-store',
|
||||||
'Cache-Control': 'public, max-age=300',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import type { GraphNodeId, Scene, SceneId } from '../../shared/types';
|
|||||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||||
import { getDndApi } from '../shared/dndApi';
|
import { getDndApi } from '../shared/dndApi';
|
||||||
import { PixiEffectsOverlay } from '../shared/effects/PxiEffectsOverlay';
|
import { PixiEffectsOverlay } from '../shared/effects/PxiEffectsOverlay';
|
||||||
|
import { SceneDarknessOverlay } from '../shared/effects/SceneDarknessOverlay';
|
||||||
import { useEffectsState } from '../shared/effects/useEffectsState';
|
import { useEffectsState } from '../shared/effects/useEffectsState';
|
||||||
|
import { useSceneDarknessState } from '../shared/effects/useSceneDarknessState';
|
||||||
import { Button } from '../shared/ui/controls';
|
import { Button } from '../shared/ui/controls';
|
||||||
import { Surface } from '../shared/ui/Surface';
|
import { Surface } from '../shared/ui/Surface';
|
||||||
|
|
||||||
@@ -49,6 +51,7 @@ export function ControlApp() {
|
|||||||
const tRef = useRef(t);
|
const tRef = useRef(t);
|
||||||
tRef.current = t;
|
tRef.current = t;
|
||||||
const [fxState, fx] = useEffectsState();
|
const [fxState, fx] = useEffectsState();
|
||||||
|
const [sdState, sd] = useSceneDarknessState();
|
||||||
const [session, setSession] = useState<SessionState | null>(null);
|
const [session, setSession] = useState<SessionState | null>(null);
|
||||||
const historyRef = useRef<GraphNodeId[]>([]);
|
const historyRef = useRef<GraphNodeId[]>([]);
|
||||||
const [history, setHistory] = useState<GraphNodeId[]>([]);
|
const [history, setHistory] = useState<GraphNodeId[]>([]);
|
||||||
@@ -69,7 +72,18 @@ export function ControlApp() {
|
|||||||
const previewHostRef = useRef<HTMLDivElement | null>(null);
|
const previewHostRef = useRef<HTMLDivElement | null>(null);
|
||||||
const previewVideoRef = useRef<HTMLVideoElement | null>(null);
|
const previewVideoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
const brushRef = useRef<{
|
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 };
|
startN?: { x: number; y: number };
|
||||||
points?: { x: number; y: number; tMs: number }[];
|
points?: { x: number; y: number; tMs: number }[];
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
@@ -145,6 +159,7 @@ export function ControlApp() {
|
|||||||
const currentScene =
|
const currentScene =
|
||||||
project && session?.currentSceneId ? project.scenes[session.currentSceneId] : undefined;
|
project && session?.currentSceneId ? project.scenes[session.currentSceneId] : undefined;
|
||||||
const isVideoPreviewScene = currentScene?.previewAssetType === 'video';
|
const isVideoPreviewScene = currentScene?.previewAssetType === 'video';
|
||||||
|
const isDarkenScene = Boolean(currentScene?.darkenScene) && !isVideoPreviewScene;
|
||||||
const sceneAudioRefs = useMemo(() => currentScene?.media.audios ?? [], [currentScene]);
|
const sceneAudioRefs = useMemo(() => currentScene?.media.audios ?? [], [currentScene]);
|
||||||
// Keep this memo as narrow as possible: project changes on scene switch,
|
// Keep this memo as narrow as possible: project changes on scene switch,
|
||||||
// but campaign audio list/config often does not.
|
// but campaign audio list/config often does not.
|
||||||
@@ -609,6 +624,13 @@ export function ControlApp() {
|
|||||||
draftPaintRafRef.current = requestAnimationFrame(() => {
|
draftPaintRafRef.current = requestAnimationFrame(() => {
|
||||||
draftPaintRafRef.current = 0;
|
draftPaintRafRef.current = 0;
|
||||||
setDraftFxTick((x) => x + 1);
|
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) {
|
if (b.tool === 'darkness' && b.points && b.points.length > 0) {
|
||||||
const last = b.points[b.points.length - 1];
|
const last = b.points[b.points.length - 1];
|
||||||
if (last === undefined) return;
|
if (last === undefined) return;
|
||||||
@@ -1137,6 +1171,24 @@ export function ControlApp() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{isDarkenScene ? (
|
||||||
|
<div className={styles.effectsGroup}>
|
||||||
|
<div className={styles.subsectionLabel}>{t('control.darknessControl')}</div>
|
||||||
|
<div className={styles.iconRow}>
|
||||||
|
<Button
|
||||||
|
variant={tool.tool === 'exploreBrush' ? 'primary' : 'ghost'}
|
||||||
|
iconOnly
|
||||||
|
title={t('control.explorerBrush')}
|
||||||
|
ariaLabel={t('control.explorerBrush')}
|
||||||
|
onClick={() =>
|
||||||
|
void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'exploreBrush' } })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className={styles.iconGlyph}>🔦</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className={styles.radiusRow}>
|
<div className={styles.radiusRow}>
|
||||||
<div className={styles.radiusLabel}>{t('control.brushRadius')}</div>
|
<div className={styles.radiusLabel}>{t('control.brushRadius')}</div>
|
||||||
<input
|
<input
|
||||||
@@ -1235,6 +1287,13 @@ export function ControlApp() {
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{previewContentRect ? (
|
||||||
|
<SceneDarknessOverlay
|
||||||
|
state={sdState}
|
||||||
|
overlayAlpha={0.5}
|
||||||
|
viewport={previewContentRect}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<div
|
<div
|
||||||
ref={brushCursorElRef}
|
ref={brushCursorElRef}
|
||||||
className={styles.brushCursor}
|
className={styles.brushCursor}
|
||||||
@@ -1269,6 +1328,15 @@ export function ControlApp() {
|
|||||||
startN: p,
|
startN: p,
|
||||||
points: [{ x: p.x, y: p.y, tMs: Date.now() }],
|
points: [{ x: p.x, y: p.y, tMs: Date.now() }],
|
||||||
};
|
};
|
||||||
|
if (tool.tool === 'exploreBrush') {
|
||||||
|
void sd.dispatch({
|
||||||
|
kind: 'draft.set',
|
||||||
|
draft: {
|
||||||
|
points: [{ x: p.x, y: p.y, tMs: Date.now() }],
|
||||||
|
radiusN: tool.radiusN,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
setDraftFxTick((x) => x + 1);
|
setDraftFxTick((x) => x + 1);
|
||||||
}}
|
}}
|
||||||
onPointerMove={(e) => {
|
onPointerMove={(e) => {
|
||||||
@@ -1296,6 +1364,9 @@ export function ControlApp() {
|
|||||||
void commitStroke();
|
void commitStroke();
|
||||||
}}
|
}}
|
||||||
onPointerCancel={() => {
|
onPointerCancel={() => {
|
||||||
|
if (brushRef.current?.tool === 'exploreBrush') {
|
||||||
|
void sd.dispatch({ kind: 'draft.set', draft: null });
|
||||||
|
}
|
||||||
brushRef.current = null;
|
brushRef.current = null;
|
||||||
setDraftFxTick((x) => x + 1);
|
setDraftFxTick((x) => x + 1);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ void test('ControlApp: эффекты в пульте, иконки с тулт
|
|||||||
assert.ok(src.includes("t('control.tools')"));
|
assert.ok(src.includes("t('control.tools')"));
|
||||||
assert.ok(src.includes("t('control.fieldEffects')"));
|
assert.ok(src.includes("t('control.fieldEffects')"));
|
||||||
assert.ok(src.includes("t('control.actionEffects')"));
|
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("t('control.sunbeam')"));
|
||||||
assert.ok(src.includes("title={t('control.water')}"));
|
assert.ok(src.includes("title={t('control.water')}"));
|
||||||
assert.ok(src.includes("title={t('control.darkness')}"));
|
assert.ok(src.includes("title={t('control.darkness')}"));
|
||||||
|
|||||||
@@ -623,6 +623,7 @@ export function EditorApp() {
|
|||||||
previewAssetType={sc?.previewAssetType ?? null}
|
previewAssetType={sc?.previewAssetType ?? null}
|
||||||
previewVideoAutostart={sc?.previewVideoAutostart ?? false}
|
previewVideoAutostart={sc?.previewVideoAutostart ?? false}
|
||||||
previewRotationDeg={sc?.previewRotationDeg ?? 0}
|
previewRotationDeg={sc?.previewRotationDeg ?? 0}
|
||||||
|
darkenScene={sc?.darkenScene ?? false}
|
||||||
previewBusy={previewBusy}
|
previewBusy={previewBusy}
|
||||||
mediaAssets={sceneMediaAssets}
|
mediaAssets={sceneMediaAssets}
|
||||||
audioRefs={sceneAudioRefs}
|
audioRefs={sceneAudioRefs}
|
||||||
@@ -632,6 +633,9 @@ export function EditorApp() {
|
|||||||
onPreviewVideoAutostartChange={(next) =>
|
onPreviewVideoAutostartChange={(next) =>
|
||||||
void actions.updateScene(sid, { previewVideoAutostart: next })
|
void actions.updateScene(sid, { previewVideoAutostart: next })
|
||||||
}
|
}
|
||||||
|
onDarkenSceneChange={(next) =>
|
||||||
|
void actions.updateScene(sid, { darkenScene: next })
|
||||||
|
}
|
||||||
onTitleChange={(title) => void actions.updateScene(sid, { title })}
|
onTitleChange={(title) => void actions.updateScene(sid, { title })}
|
||||||
onDescriptionChange={(description) =>
|
onDescriptionChange={(description) =>
|
||||||
void actions.updateScene(sid, { description })
|
void actions.updateScene(sid, { description })
|
||||||
@@ -1681,11 +1685,13 @@ type SceneInspectorProps = {
|
|||||||
previewAssetType: 'image' | 'video' | null;
|
previewAssetType: 'image' | 'video' | null;
|
||||||
previewVideoAutostart: boolean;
|
previewVideoAutostart: boolean;
|
||||||
previewRotationDeg: 0 | 90 | 180 | 270;
|
previewRotationDeg: 0 | 90 | 180 | 270;
|
||||||
|
darkenScene: boolean;
|
||||||
previewBusy: boolean;
|
previewBusy: boolean;
|
||||||
mediaAssets: MediaAsset[];
|
mediaAssets: MediaAsset[];
|
||||||
audioRefs: SceneAudioRef[];
|
audioRefs: SceneAudioRef[];
|
||||||
onAudioRefsChange: (next: SceneAudioRef[]) => void;
|
onAudioRefsChange: (next: SceneAudioRef[]) => void;
|
||||||
onPreviewVideoAutostartChange: (next: boolean) => void;
|
onPreviewVideoAutostartChange: (next: boolean) => void;
|
||||||
|
onDarkenSceneChange: (next: boolean) => void;
|
||||||
onTitleChange: (v: string) => void;
|
onTitleChange: (v: string) => void;
|
||||||
onDescriptionChange: (v: string) => void;
|
onDescriptionChange: (v: string) => void;
|
||||||
onImportPreview: () => void;
|
onImportPreview: () => void;
|
||||||
@@ -1788,11 +1794,13 @@ function SceneInspector({
|
|||||||
previewAssetType,
|
previewAssetType,
|
||||||
previewVideoAutostart,
|
previewVideoAutostart,
|
||||||
previewRotationDeg,
|
previewRotationDeg,
|
||||||
|
darkenScene,
|
||||||
previewBusy,
|
previewBusy,
|
||||||
mediaAssets,
|
mediaAssets,
|
||||||
audioRefs,
|
audioRefs,
|
||||||
onAudioRefsChange,
|
onAudioRefsChange,
|
||||||
onPreviewVideoAutostartChange,
|
onPreviewVideoAutostartChange,
|
||||||
|
onDarkenSceneChange,
|
||||||
onTitleChange,
|
onTitleChange,
|
||||||
onDescriptionChange,
|
onDescriptionChange,
|
||||||
onImportPreview,
|
onImportPreview,
|
||||||
@@ -1877,6 +1885,19 @@ function SceneInspector({
|
|||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
{previewAssetId && previewAssetType === 'image' ? (
|
||||||
|
<>
|
||||||
|
<div className={styles.spacer6} />
|
||||||
|
<label className={styles.checkboxLabel}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={darkenScene}
|
||||||
|
onChange={(e) => onDarkenSceneChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className={styles.spanSm}>{t('scene.darkenScene')}</span>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
<div className={styles.spacer6} />
|
<div className={styles.spacer6} />
|
||||||
<div className={styles.labelSm}>{t('scene.audio')}</div>
|
<div className={styles.labelSm}>{t('scene.audio')}</div>
|
||||||
<div className={styles.audioDrop}>
|
<div className={styles.audioDrop}>
|
||||||
|
|||||||
@@ -281,6 +281,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
'scene.change': 'Изменить',
|
'scene.change': 'Изменить',
|
||||||
'scene.clear': 'Очистить',
|
'scene.clear': 'Очистить',
|
||||||
'scene.autostart': 'Автостарт',
|
'scene.autostart': 'Автостарт',
|
||||||
|
'scene.darkenScene': 'Затемнить сцену',
|
||||||
'scene.rotate': 'Повернуть',
|
'scene.rotate': 'Повернуть',
|
||||||
'scene.audio': 'АУДИО СЦЕНЫ',
|
'scene.audio': 'АУДИО СЦЕНЫ',
|
||||||
'scene.removeTitle': 'Убрать из сцены',
|
'scene.removeTitle': 'Убрать из сцены',
|
||||||
@@ -319,6 +320,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
'control.fire': 'Огонь',
|
'control.fire': 'Огонь',
|
||||||
'control.water': 'Вода',
|
'control.water': 'Вода',
|
||||||
'control.darkness': 'Тьма',
|
'control.darkness': 'Тьма',
|
||||||
|
'control.darknessControl': 'Управление затемнением',
|
||||||
|
'control.explorerBrush': 'Кисть Открытия',
|
||||||
'control.lightning': 'Молния',
|
'control.lightning': 'Молния',
|
||||||
'control.sunbeam': 'Луч света',
|
'control.sunbeam': 'Луч света',
|
||||||
'control.freeze': 'Заморозка',
|
'control.freeze': 'Заморозка',
|
||||||
@@ -601,6 +604,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
'scene.change': 'Change',
|
'scene.change': 'Change',
|
||||||
'scene.clear': 'Clear',
|
'scene.clear': 'Clear',
|
||||||
'scene.autostart': 'Autostart',
|
'scene.autostart': 'Autostart',
|
||||||
|
'scene.darkenScene': 'Darken scene',
|
||||||
'scene.rotate': 'Rotate',
|
'scene.rotate': 'Rotate',
|
||||||
'scene.audio': 'SCENE AUDIO',
|
'scene.audio': 'SCENE AUDIO',
|
||||||
'scene.removeTitle': 'Remove from scene',
|
'scene.removeTitle': 'Remove from scene',
|
||||||
@@ -639,6 +643,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
'control.fire': 'Fire',
|
'control.fire': 'Fire',
|
||||||
'control.water': 'Water',
|
'control.water': 'Water',
|
||||||
'control.darkness': 'Darkness',
|
'control.darkness': 'Darkness',
|
||||||
|
'control.darknessControl': 'Darkness control',
|
||||||
|
'control.explorerBrush': 'Opening brush',
|
||||||
'control.lightning': 'Lightning',
|
'control.lightning': 'Lightning',
|
||||||
'control.sunbeam': 'Sunbeam',
|
'control.sunbeam': 'Sunbeam',
|
||||||
'control.freeze': 'Freeze',
|
'control.freeze': 'Freeze',
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ type Actions = {
|
|||||||
previewAssetType?: 'image' | 'video' | null;
|
previewAssetType?: 'image' | 'video' | null;
|
||||||
previewVideoAutostart?: boolean;
|
previewVideoAutostart?: boolean;
|
||||||
previewRotationDeg?: 0 | 90 | 180 | 270;
|
previewRotationDeg?: 0 | 90 | 180 | 270;
|
||||||
|
darkenScene?: boolean;
|
||||||
settings?: Partial<Scene['settings']>;
|
settings?: Partial<Scene['settings']>;
|
||||||
media?: Partial<Scene['media']>;
|
media?: Partial<Scene['media']>;
|
||||||
layout?: { x: number; y: number };
|
layout?: { x: number; y: number };
|
||||||
@@ -140,6 +141,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
|||||||
};
|
};
|
||||||
|
|
||||||
const closeProject = async () => {
|
const closeProject = async () => {
|
||||||
|
await api.invoke(ipcChannels.project.close, {});
|
||||||
setState((s) => ({ ...s, project: null, selectedSceneId: null }));
|
setState((s) => ({ ...s, project: null, selectedSceneId: null }));
|
||||||
await refreshProjects();
|
await refreshProjects();
|
||||||
};
|
};
|
||||||
@@ -157,6 +159,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
|||||||
previewAssetType: null,
|
previewAssetType: null,
|
||||||
previewVideoAutostart: false,
|
previewVideoAutostart: false,
|
||||||
previewRotationDeg: 0,
|
previewRotationDeg: 0,
|
||||||
|
darkenScene: false,
|
||||||
media: { videos: [], audios: [] },
|
media: { videos: [], audios: [] },
|
||||||
settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true },
|
settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true },
|
||||||
connections: [],
|
connections: [],
|
||||||
@@ -212,6 +215,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
|||||||
previewAssetType?: 'image' | 'video' | null;
|
previewAssetType?: 'image' | 'video' | null;
|
||||||
previewVideoAutostart?: boolean;
|
previewVideoAutostart?: boolean;
|
||||||
previewRotationDeg?: 0 | 90 | 180 | 270;
|
previewRotationDeg?: 0 | 90 | 180 | 270;
|
||||||
|
darkenScene?: boolean;
|
||||||
settings?: Partial<Scene['settings']>;
|
settings?: Partial<Scene['settings']>;
|
||||||
media?: Partial<Scene['media']>;
|
media?: Partial<Scene['media']>;
|
||||||
layout?: { x: number; y: number };
|
layout?: { x: number; y: number };
|
||||||
@@ -237,6 +241,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
|||||||
...(patch.previewRotationDeg !== undefined
|
...(patch.previewRotationDeg !== undefined
|
||||||
? { previewRotationDeg: patch.previewRotationDeg }
|
? { previewRotationDeg: patch.previewRotationDeg }
|
||||||
: null),
|
: null),
|
||||||
|
...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null),
|
||||||
...(patch.settings ? { settings: { ...scene.settings, ...patch.settings } } : null),
|
...(patch.settings ? { settings: { ...scene.settings, ...patch.settings } } : null),
|
||||||
...(patch.media ? { media: { ...scene.media, ...patch.media } } : null),
|
...(patch.media ? { media: { ...scene.media, ...patch.media } } : null),
|
||||||
layout: patch.layout ? { ...scene.layout, ...patch.layout } : scene.layout,
|
layout: patch.layout ? { ...scene.layout, ...patch.layout } : scene.layout,
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import { computeTimeSec } from '../../main/video/videoPlaybackStore';
|
|||||||
import type { SessionState } from '../../shared/ipc/contracts';
|
import type { SessionState } from '../../shared/ipc/contracts';
|
||||||
|
|
||||||
import { PixiEffectsOverlay } from './effects/PxiEffectsOverlay';
|
import { PixiEffectsOverlay } from './effects/PxiEffectsOverlay';
|
||||||
|
import { SceneDarknessOverlay } from './effects/SceneDarknessOverlay';
|
||||||
import { useEffectsState } from './effects/useEffectsState';
|
import { useEffectsState } from './effects/useEffectsState';
|
||||||
|
import { useSceneDarknessState } from './effects/useSceneDarknessState';
|
||||||
import styles from './PresentationView.module.css';
|
import styles from './PresentationView.module.css';
|
||||||
import { RotatedImage } from './RotatedImage';
|
import { RotatedImage } from './RotatedImage';
|
||||||
import { useAssetUrl } from './useAssetImageUrl';
|
import { useAssetUrl } from './useAssetImageUrl';
|
||||||
@@ -25,6 +27,7 @@ export function PresentationView({
|
|||||||
showEffects = true,
|
showEffects = true,
|
||||||
}: PresentationViewProps) {
|
}: PresentationViewProps) {
|
||||||
const [fxState] = useEffectsState();
|
const [fxState] = useEffectsState();
|
||||||
|
const [sdState] = useSceneDarknessState();
|
||||||
const [vp] = useVideoPlaybackState();
|
const [vp] = useVideoPlaybackState();
|
||||||
const videoElRef = useRef<HTMLVideoElement | null>(null);
|
const videoElRef = useRef<HTMLVideoElement | null>(null);
|
||||||
const [contentRect, setContentRect] = React.useState<{ x: number; y: number; w: number; h: number } | null>(
|
const [contentRect, setContentRect] = React.useState<{ x: number; y: number; w: number; h: number } | null>(
|
||||||
@@ -132,6 +135,9 @@ export function PresentationView({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{showEffects && scene?.previewAssetType === 'image' && scene.darkenScene && contentRect ? (
|
||||||
|
<SceneDarknessOverlay state={sdState} overlayAlpha={1} viewport={contentRect} />
|
||||||
|
) : null}
|
||||||
{showTitle ? (
|
{showTitle ? (
|
||||||
<div className={styles.titleWrap}>
|
<div className={styles.titleWrap}>
|
||||||
<div className={compact ? styles.titleCompact : styles.titleFull}>
|
<div className={compact ? styles.titleCompact : styles.titleFull}>
|
||||||
|
|||||||
@@ -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<HTMLCanvasElement | null>(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 (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
aria-hidden
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: viewport.x,
|
||||||
|
top: viewport.y,
|
||||||
|
width: viewport.w,
|
||||||
|
height: viewport.h,
|
||||||
|
opacity: overlayAlpha,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 2,
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<void> },
|
||||||
|
] {
|
||||||
|
const api = getDndApi();
|
||||||
|
const [state, setState] = useState<SceneDarknessState | null>(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;
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ import type {
|
|||||||
Project,
|
Project,
|
||||||
ProjectId,
|
ProjectId,
|
||||||
Scene,
|
Scene,
|
||||||
|
SceneDarknessEvent,
|
||||||
|
SceneDarknessState,
|
||||||
SceneId,
|
SceneId,
|
||||||
VideoPlaybackEvent,
|
VideoPlaybackEvent,
|
||||||
VideoPlaybackState,
|
VideoPlaybackState,
|
||||||
@@ -27,6 +29,7 @@ export const ipcChannels = {
|
|||||||
list: 'project.list',
|
list: 'project.list',
|
||||||
create: 'project.create',
|
create: 'project.create',
|
||||||
open: 'project.open',
|
open: 'project.open',
|
||||||
|
close: 'project.close',
|
||||||
saveNow: 'project.saveNow',
|
saveNow: 'project.saveNow',
|
||||||
get: 'project.get',
|
get: 'project.get',
|
||||||
updateScene: 'project.updateScene',
|
updateScene: 'project.updateScene',
|
||||||
@@ -68,6 +71,11 @@ export const ipcChannels = {
|
|||||||
dispatch: 'effects.dispatch',
|
dispatch: 'effects.dispatch',
|
||||||
stateChanged: 'effects.stateChanged',
|
stateChanged: 'effects.stateChanged',
|
||||||
},
|
},
|
||||||
|
sceneDarkness: {
|
||||||
|
getState: 'sceneDarkness.getState',
|
||||||
|
dispatch: 'sceneDarkness.dispatch',
|
||||||
|
stateChanged: 'sceneDarkness.stateChanged',
|
||||||
|
},
|
||||||
video: {
|
video: {
|
||||||
getState: 'video.getState',
|
getState: 'video.getState',
|
||||||
dispatch: 'video.dispatch',
|
dispatch: 'video.dispatch',
|
||||||
@@ -117,6 +125,7 @@ export type UpdaterProgressEvent = {
|
|||||||
export type IpcEventMap = {
|
export type IpcEventMap = {
|
||||||
[ipcChannels.session.stateChanged]: { state: SessionState };
|
[ipcChannels.session.stateChanged]: { state: SessionState };
|
||||||
[ipcChannels.effects.stateChanged]: { state: EffectsState };
|
[ipcChannels.effects.stateChanged]: { state: EffectsState };
|
||||||
|
[ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState };
|
||||||
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
|
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
|
||||||
[ipcChannels.license.statusChanged]: { snapshot: LicenseSnapshot };
|
[ipcChannels.license.statusChanged]: { snapshot: LicenseSnapshot };
|
||||||
[ipcChannels.windows.multiWindowStateChanged]: { open: boolean };
|
[ipcChannels.windows.multiWindowStateChanged]: { open: boolean };
|
||||||
@@ -154,6 +163,10 @@ export type IpcInvokeMap = {
|
|||||||
req: { projectId: ProjectId };
|
req: { projectId: ProjectId };
|
||||||
res: { project: Project };
|
res: { project: Project };
|
||||||
};
|
};
|
||||||
|
[ipcChannels.project.close]: {
|
||||||
|
req: Record<string, never>;
|
||||||
|
res: { ok: true };
|
||||||
|
};
|
||||||
[ipcChannels.project.get]: {
|
[ipcChannels.project.get]: {
|
||||||
req: Record<string, never>;
|
req: Record<string, never>;
|
||||||
res: { project: Project | null };
|
res: { project: Project | null };
|
||||||
@@ -270,6 +283,14 @@ export type IpcInvokeMap = {
|
|||||||
req: { event: EffectsEvent };
|
req: { event: EffectsEvent };
|
||||||
res: { ok: true };
|
res: { ok: true };
|
||||||
};
|
};
|
||||||
|
[ipcChannels.sceneDarkness.getState]: {
|
||||||
|
req: Record<string, never>;
|
||||||
|
res: { state: SceneDarknessState };
|
||||||
|
};
|
||||||
|
[ipcChannels.sceneDarkness.dispatch]: {
|
||||||
|
req: { event: SceneDarknessEvent };
|
||||||
|
res: { ok: true };
|
||||||
|
};
|
||||||
[ipcChannels.video.getState]: {
|
[ipcChannels.video.getState]: {
|
||||||
req: Record<string, never>;
|
req: Record<string, never>;
|
||||||
res: { state: VideoPlaybackState };
|
res: { state: VideoPlaybackState };
|
||||||
@@ -304,6 +325,7 @@ export type SessionState = {
|
|||||||
export type LegacyIpcEventMap = {
|
export type LegacyIpcEventMap = {
|
||||||
[ipcChannels.session.stateChanged]: { state: SessionState };
|
[ipcChannels.session.stateChanged]: { state: SessionState };
|
||||||
[ipcChannels.effects.stateChanged]: { state: EffectsState };
|
[ipcChannels.effects.stateChanged]: { state: EffectsState };
|
||||||
|
[ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState };
|
||||||
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
|
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
|
||||||
[ipcChannels.license.statusChanged]: Record<string, never>;
|
[ipcChannels.license.statusChanged]: Record<string, never>;
|
||||||
};
|
};
|
||||||
@@ -316,6 +338,7 @@ export type ScenePatch = {
|
|||||||
previewThumbAssetId?: AssetId | null;
|
previewThumbAssetId?: AssetId | null;
|
||||||
previewVideoAutostart?: boolean;
|
previewVideoAutostart?: boolean;
|
||||||
previewRotationDeg?: 0 | 90 | 180 | 270;
|
previewRotationDeg?: 0 | 90 | 180 | 270;
|
||||||
|
darkenScene?: boolean;
|
||||||
settings?: Partial<Scene['settings']>;
|
settings?: Partial<Scene['settings']>;
|
||||||
media?: Partial<Scene['media']>;
|
media?: Partial<Scene['media']>;
|
||||||
layout?: Partial<Scene['layout']>;
|
layout?: Partial<Scene['layout']>;
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ export type Scene = {
|
|||||||
previewVideoAutostart: boolean;
|
previewVideoAutostart: boolean;
|
||||||
/** Поворот превью в градусах (0/90/180/270). */
|
/** Поворот превью в градусах (0/90/180/270). */
|
||||||
previewRotationDeg: 0 | 90 | 180 | 270;
|
previewRotationDeg: 0 | 90 | 180 | 270;
|
||||||
|
/** В режиме показа: сцена начинается полностью затемнённой; мастер «раскрывает» кистью. */
|
||||||
|
darkenScene: boolean;
|
||||||
media: SceneMediaRefs;
|
media: SceneMediaRefs;
|
||||||
settings: SceneSettings;
|
settings: SceneSettings;
|
||||||
connections: SceneId[];
|
connections: SceneId[];
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type EffectToolType =
|
|||||||
| 'sunbeam'
|
| 'sunbeam'
|
||||||
| 'poisonCloud'
|
| 'poisonCloud'
|
||||||
| 'freeze'
|
| 'freeze'
|
||||||
|
| 'exploreBrush'
|
||||||
| 'eraser';
|
| 'eraser';
|
||||||
|
|
||||||
export type EffectInstanceType =
|
export type EffectInstanceType =
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from './domain';
|
export * from './domain';
|
||||||
export * from './effects';
|
export * from './effects';
|
||||||
export * from './ids';
|
export * from './ids';
|
||||||
|
export * from './sceneDarkness';
|
||||||
export * from './videoPlayback';
|
export * from './videoPlayback';
|
||||||
|
|||||||
@@ -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 };
|
||||||
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
"build:obfuscate": "node scripts/build.mjs --production --obfuscate",
|
"build:obfuscate": "node scripts/build.mjs --production --obfuscate",
|
||||||
"lint": "eslint . --max-warnings 0",
|
"lint": "eslint . --max-warnings 0",
|
||||||
"typecheck": "tsc -p tsconfig.eslint.json --noEmit",
|
"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": "prettier . --check",
|
||||||
"format:write": "prettier . --write",
|
"format:write": "prettier . --write",
|
||||||
"postinstall": "patch-package",
|
"postinstall": "patch-package",
|
||||||
|
|||||||
Reference in New Issue
Block a user