diff --git a/app/main/foundry/foundryImport.ts b/app/main/foundry/foundryImport.ts index dcb1b26..12fab9c 100644 --- a/app/main/foundry/foundryImport.ts +++ b/app/main/foundry/foundryImport.ts @@ -379,7 +379,7 @@ export async function buildProjectFromFoundryDocuments( const assetId = await resolveAndImport(ctx, manifest, audioPath); if (!assetId) continue; if (ctx.assets[assetId]?.type !== 'audio') continue; - audioRefs.push({ assetId, autoplay: true, loop: true }); + audioRefs.push({ assetId, autoplay: true, loop: true, volume: 1 }); } scenes[sceneId] = { diff --git a/app/main/project/assetPrune.test.ts b/app/main/project/assetPrune.test.ts index 75a20eb..faacb4d 100644 --- a/app/main/project/assetPrune.test.ts +++ b/app/main/project/assetPrune.test.ts @@ -17,11 +17,11 @@ void test('collectReferencedAssetIds: превью, видео и аудио', ( previewThumbAssetId: 'th' as AssetId, media: { videos: ['v1' as AssetId], - audios: [{ assetId: 'a1' as AssetId, autoplay: true, loop: true }], + audios: [{ assetId: 'a1' as AssetId, autoplay: true, loop: true, volume: 1 }], }, }, }, - campaignAudios: [{ assetId: 'ca1' as AssetId, autoplay: true, loop: true }], + campaignAudios: [{ assetId: 'ca1' as AssetId, autoplay: true, loop: true, volume: 1 }], materials: [{ id: 'm1', name: 'Map', assetId: 'mat1' as AssetId }], npcs: [{ id: 'n1', name: 'Guard', avatarAssetId: 'npc1' as AssetId }], } as unknown as Project; diff --git a/app/main/project/zipRead.test.ts b/app/main/project/zipRead.test.ts index 0f92848..7f3f1d2 100644 --- a/app/main/project/zipRead.test.ts +++ b/app/main/project/zipRead.test.ts @@ -69,7 +69,7 @@ void test('readProjectJsonFromZip: campaignAudios round-trips inside project.jso }, scenes: {}, assets: {}, - campaignAudios: [{ assetId, autoplay: true, loop: false }], + campaignAudios: [{ assetId, autoplay: true, loop: false, volume: 1 }], currentSceneId: null, currentGraphNodeId: null, sceneGraphNodes: [], diff --git a/app/main/project/zipStore.ts b/app/main/project/zipStore.ts index 8a86d81..4839f72 100644 --- a/app/main/project/zipStore.ts +++ b/app/main/project/zipStore.ts @@ -45,12 +45,13 @@ import type { ProjectNpcGroup, ProjectNpcRelation, Scene, + SceneAudioRef, SceneGraphEdge, SceneGraphNode, SceneId, SceneTrap, } from '../../shared/types'; -import { PROJECT_SCHEMA_VERSION } from '../../shared/types'; +import { normalizeSceneAudioRef, PROJECT_SCHEMA_VERSION } from '../../shared/types'; import { normalizeMaterialLegend } from '../../shared/types/materialLegend'; import { normalizeSceneToken, type SceneToken } from '../../shared/types/appTokens'; import { @@ -817,7 +818,14 @@ export class ZipProjectStore { : null), ...(patch.grid !== undefined ? { grid: normalizeSceneGrid(patch.grid) } : null), ...(patch.settings ? { settings: { ...base.settings, ...patch.settings } } : null), - ...(patch.media ? { media: { ...base.media, ...patch.media } } : null), + ...(patch.media + ? { + media: { + videos: patch.media.videos ?? base.media.videos, + audios: (patch.media.audios ?? base.media.audios).map((a) => normalizeSceneAudioRef(a)), + }, + } + : null), ...(patch.layout ? { layout: { ...base.layout, ...patch.layout } } : null), }; @@ -1180,8 +1188,9 @@ export class ZipProjectStore { else media.audios.push({ assetId: asset.id, - autoplay: sc.settings.autoplayAudio, - loop: sc.settings.loopAudio, + autoplay: false, + loop: false, + volume: 1, }); } return { @@ -1232,7 +1241,7 @@ export class ZipProjectStore { for (const asset of staged) { assets[asset.id] = asset; if (asset.type !== 'audio') continue; - campaignAudios.push({ assetId: asset.id, autoplay: false, loop: false }); + campaignAudios.push({ assetId: asset.id, autoplay: false, loop: false, volume: 1 }); } return { ...p, assets, campaignAudios }; }); @@ -1245,7 +1254,10 @@ export class ZipProjectStore { async setCampaignAudios(audios: Project['campaignAudios']): Promise { const open = this.openProject; if (!open) throw new Error('No open project'); - await this.updateProject((p) => ({ ...p, campaignAudios: Array.isArray(audios) ? audios : [] })); + const normalized = (Array.isArray(audios) ? audios : []) + .filter((a) => a && typeof a === 'object' && a.assetId) + .map((a) => normalizeSceneAudioRef(a)); + await this.updateProject((p) => ({ ...p, campaignAudios: normalized })); const latest = this.getOpenProject(); if (!latest) throw new Error('No open project'); return latest; @@ -2624,20 +2636,26 @@ function normalizeScene(s: Scene): Scene { const audios = rawAudios .map((a) => { if (typeof a === 'string') { - return { + return normalizeSceneAudioRef({ assetId: a as AssetId, autoplay: Boolean((s.settings as { autoplayAudio?: boolean } | undefined)?.autoplayAudio), loop: Boolean((s.settings as { loopAudio?: boolean } | undefined)?.loopAudio), - }; + volume: 1, + }); } if (a && typeof a === 'object') { - const obj = a as { assetId?: AssetId; autoplay?: boolean; loop?: boolean }; + const obj = a as { assetId?: AssetId; autoplay?: boolean; loop?: boolean; volume?: unknown }; if (!obj.assetId) return null; - return { assetId: obj.assetId, autoplay: Boolean(obj.autoplay), loop: Boolean(obj.loop) }; + return normalizeSceneAudioRef({ + assetId: obj.assetId, + autoplay: Boolean(obj.autoplay), + loop: Boolean(obj.loop), + volume: obj.volume, + }); } return null; }) - .filter((x): x is { assetId: AssetId; autoplay: boolean; loop: boolean } => Boolean(x)); + .filter((x): x is SceneAudioRef => Boolean(x)); return { ...s, @@ -2682,15 +2700,20 @@ function normalizeProject(p: Project): Project { const rawCampaignAudios = (p as unknown as { campaignAudios?: unknown[] }).campaignAudios; const campaignAudios = (Array.isArray(rawCampaignAudios) ? rawCampaignAudios : []) .map((a) => { - if (typeof a === 'string') return { assetId: a as AssetId, autoplay: false, loop: false }; + if (typeof a === 'string') return normalizeSceneAudioRef({ assetId: a as AssetId, autoplay: false, loop: false, volume: 1 }); if (a && typeof a === 'object') { - const obj = a as { assetId?: AssetId; autoplay?: boolean; loop?: boolean }; + const obj = a as { assetId?: AssetId; autoplay?: boolean; loop?: boolean; volume?: unknown }; if (!obj.assetId) return null; - return { assetId: obj.assetId, autoplay: Boolean(obj.autoplay), loop: Boolean(obj.loop) }; + return normalizeSceneAudioRef({ + assetId: obj.assetId, + autoplay: Boolean(obj.autoplay), + loop: Boolean(obj.loop), + volume: obj.volume, + }); } return null; }) - .filter((x): x is { assetId: AssetId; autoplay: boolean; loop: boolean } => Boolean(x)); + .filter((x): x is SceneAudioRef => Boolean(x)); const rawMaterials = (p as unknown as { materials?: unknown[] }).materials; const materials = (Array.isArray(rawMaterials) ? rawMaterials : []) .map((m) => { diff --git a/app/renderer/control/ControlApp.tsx b/app/renderer/control/ControlApp.tsx index 44e9948..9b3056f 100644 --- a/app/renderer/control/ControlApp.tsx +++ b/app/renderer/control/ControlApp.tsx @@ -107,15 +107,30 @@ function clampAudioGain(v: number): number { return Math.max(0, Math.min(1, v)); } -function readAudioGain(gains: Map, assetId: string): number { - return gains.get(assetId) ?? 1; +function readAudioGain(gains: Map, assetId: string, projectDefault = 1): number { + return gains.get(assetId) ?? clampAudioGain(projectDefault); } /** Применяет пользовательскую громкость; `factor` — для fade in/out (0…1). */ -function applyAudioGain(el: HTMLAudioElement, gains: Map, assetId: string, factor = 1): void { +function applyAudioGain( + el: HTMLAudioElement, + gains: Map, + assetId: string, + factor = 1, + projectDefault = 1, +): void { + if (!gains.has(assetId)) gains.set(assetId, clampAudioGain(projectDefault)); el.volume = clampAudioGain(readAudioGain(gains, assetId) * factor); } +function seedAudioGainFromRef( + gains: Map, + assetId: string, + projectVolume: number | undefined, +): void { + if (!gains.has(assetId)) gains.set(assetId, clampAudioGain(projectVolume ?? 1)); +} + /** Файл из `app/renderer/public/molniya.mp3` — рядом с `control.html` в dev и в dist. */ function lightningEffectSoundUrl(): string { return new URL('molniya.mp3', window.location.href).href; @@ -615,6 +630,7 @@ export function ControlApp() { void (async () => { const loaded: { ref: (typeof sceneAudioRefs)[number]; el: HTMLAudioElement }[] = []; for (const item of sceneAudioRefs) { + seedAudioGainFromRef(sceneAudioGainRef.current, item.assetId, item.volume); const r = await api.invoke(ipcChannels.project.assetFileUrl, { assetId: item.assetId }); if (sceneAudioLoadRunRef.current !== runId) return; if (!r.url) continue; @@ -622,7 +638,7 @@ export function ControlApp() { el.loop = item.loop; el.preload = 'auto'; if (item.autoplay) el.volume = 0; - else applyAudioGain(el, sceneAudioGainRef.current, item.assetId); + else applyAudioGain(el, sceneAudioGainRef.current, item.assetId, 1, item.volume); sceneAudioMetaRef.current.set(item.assetId, { lastPlayError: null }); el.addEventListener('play', () => setSceneAudioStateTick((x) => x + 1)); el.addEventListener('pause', () => setSceneAudioStateTick((x) => x + 1)); @@ -725,6 +741,7 @@ export function ControlApp() { void (async () => { const loaded: { ref: (typeof campaignAudioRefs)[number]; el: HTMLAudioElement }[] = []; for (const item of campaignAudioRefs) { + seedAudioGainFromRef(campaignAudioGainRef.current, item.assetId, item.volume); const r = await api.invoke(ipcChannels.project.assetFileUrl, { assetId: item.assetId }); if (campaignAudioLoadRunRef.current !== runId) return; if (!r.url) continue; @@ -732,7 +749,7 @@ export function ControlApp() { el.loop = item.loop; el.preload = 'auto'; if (item.autoplay) el.volume = 0; - else applyAudioGain(el, campaignAudioGainRef.current, item.assetId); + else applyAudioGain(el, campaignAudioGainRef.current, item.assetId, 1, item.volume); campaignAudioMetaRef.current.set(item.assetId, { lastPlayError: null }); el.addEventListener('play', () => setCampaignAudioStateTick((x) => x + 1)); el.addEventListener('pause', () => setCampaignAudioStateTick((x) => x + 1)); @@ -3111,7 +3128,7 @@ export function ControlApp() { statusLabel={st.label} {...(st.detail ? { statusDetail: st.detail } : {})} audioEl={el} - initialGain={readAudioGain(sceneAudioGainRef.current, ref.assetId)} + initialGain={readAudioGain(sceneAudioGainRef.current, ref.assetId, ref.volume)} gainMap={sceneAudioGainRef.current} playTitle={t('control.transportPlay')} playLabel={t('control.transportPlay')} @@ -3189,7 +3206,7 @@ export function ControlApp() { } : {})} audioEl={el} - initialGain={readAudioGain(campaignAudioGainRef.current, ref.assetId)} + initialGain={readAudioGain(campaignAudioGainRef.current, ref.assetId, ref.volume)} gainMap={campaignAudioGainRef.current} playTitle={ !allowCampaignAudio ? t('control.pauseCampaignTitle') : t('control.transportPlay') diff --git a/app/renderer/editor/EditorApp.module.css b/app/renderer/editor/EditorApp.module.css index 2873188..7096a49 100644 --- a/app/renderer/editor/EditorApp.module.css +++ b/app/renderer/editor/EditorApp.module.css @@ -1110,7 +1110,7 @@ .audioList { display: grid; gap: 6px; - max-height: 160px; + max-height: 280px; overflow: auto; } @@ -1120,10 +1120,32 @@ border-radius: var(--radius-xs); background: var(--color-overlay-dark-3); display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 8px; - align-items: center; + grid-template-columns: minmax(0, 1fr); + gap: 6px; + align-items: start; min-width: 0; + cursor: default; +} + +.audioDragHandle { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + cursor: grab; + user-select: none; +} + +.audioDragHandle:active { + cursor: grabbing; +} + +.audioDragGrip { + flex-shrink: 0; + color: var(--text2); + font-size: 10px; + letter-spacing: -2px; + line-height: 1; } .audioName { @@ -1135,13 +1157,35 @@ .audioControls { color: var(--text2); - flex-shrink: 0; display: flex; + flex-wrap: wrap; gap: 8px; align-items: center; min-width: 0; } +.audioVolumeLabel { + display: inline-flex; + align-items: center; + gap: 4px; + min-width: 0; +} + +.audioVolumeRange { + width: 72px; + accent-color: var(--accent, #3b82f6); +} + +.audioPreviewBtn { + border: 0; + background: transparent; + color: inherit; + cursor: pointer; + padding: 0 4px; + line-height: 1; + font-size: 12px; +} + .audioRemove { flex-shrink: 0; min-width: 28px; diff --git a/app/renderer/editor/EditorApp.tsx b/app/renderer/editor/EditorApp.tsx index ce47903..11f776f 100644 --- a/app/renderer/editor/EditorApp.tsx +++ b/app/renderer/editor/EditorApp.tsx @@ -30,6 +30,7 @@ import type { } from '../../shared/types'; import { AppLogo } from '../shared/branding/AppLogo'; import { ContainedVideo } from '../shared/ContainedVideo'; +import { stopAllEditorAudio } from '../shared/audio/editorAudioStop'; import { getDndApi } from '../shared/dndApi'; import { RotatedImage } from '../shared/RotatedImage'; import { Button, Input } from '../shared/ui/controls'; @@ -38,6 +39,7 @@ import { useAssetUrl } from '../shared/useAssetImageUrl'; import { AppAboutModal, InstructionsModal } from './about/EditorAboutModals'; import styles from './EditorApp.module.css'; +import { EditorAudioTrackList } from './EditorAudioTrackList'; import { filterAudioFilePaths, partitionSceneMediaDrops, @@ -366,6 +368,7 @@ export function EditorApp() { const launchFromGraphNode = useCallback( (graphNodeId: GraphNodeId, playerIds?: string[]) => { if (!licenseActive || launching) return; + stopAllEditorAudio(); setLaunching(true); setRunMenuOpen(false); setLaunchPlayersOpen(false); @@ -508,6 +511,7 @@ export function EditorApp() { // ignore } off = getDndApi().on(ipcChannels.windows.multiWindowStateChanged, ({ open }) => { + if (open) stopAllEditorAudio(); setPresentationOpen(open); }); })(); @@ -2531,7 +2535,6 @@ function CampaignInspector({ onDropAudio, }: CampaignInspectorProps) { const { t } = useEditorI18n(); - const audioById = useMemo(() => new Map(audioRefs.map((a) => [a.assetId, a])), [audioRefs]); const audioDrop = useFileDropZone({ onDropPaths: onDropAudio, filterPaths: filterAudioFilePaths, @@ -2547,71 +2550,19 @@ function CampaignInspector({ onDrop={audioDrop.onDrop} > {audioDrop.dragOver ?
{t('drop.hintAudio')}
: null} - {mediaAssets.filter((a) => a.type === 'audio').length === 0 ? ( + {audioRefs.length === 0 ? (
{t('campaign.noFiles')}
) : ( <> -
- {mediaAssets - .filter((a) => a.type === 'audio') - .map((a) => ( -
- {a.originalName} - - - - - -
- ))} -
+ )} @@ -2651,7 +2602,6 @@ function SceneInspector({ const { t } = useEditorI18n(); const [descriptionModalOpen, setDescriptionModalOpen] = useState(false); const previewUrl = useAssetUrl(previewAssetId); - const audioById = useMemo(() => new Map(audioRefs.map((a) => [a.assetId, a])), [audioRefs]); const descriptionEmpty = isSceneDescriptionEmpty(description); const descriptionPreviewHtml = useMemo( () => (descriptionEmpty ? '' : sanitizeSceneDescriptionHtml(description)), @@ -2835,71 +2785,19 @@ function SceneInspector({ onDrop={sceneAudioDrop.onDrop} > {sceneAudioDrop.dragOver ?
{t('drop.hintAudio')}
: null} - {mediaAssets.filter((a) => a.type === 'audio').length === 0 ? ( + {audioRefs.length === 0 ? (
{t('campaign.noFiles')}
) : ( <> -
- {mediaAssets - .filter((a) => a.type === 'audio') - .map((a) => ( -
- {a.originalName} - - - - - -
- ))} -
+ )} diff --git a/app/renderer/editor/EditorAudioTrackList.tsx b/app/renderer/editor/EditorAudioTrackList.tsx new file mode 100644 index 0000000..3e4fab9 --- /dev/null +++ b/app/renderer/editor/EditorAudioTrackList.tsx @@ -0,0 +1,287 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; + +import { ipcChannels } from '../../shared/ipc/contracts'; +import { + clampSceneAudioVolume, + type AssetId, + type MediaAsset, + type SceneAudioRef, +} from '../../shared/types'; +import { getDndApi } from '../shared/dndApi'; +import { useEditorI18n } from './i18n/EditorI18nContext'; +import { registerEditorAudioStopper } from '../shared/audio/editorAudioStop'; + +import styles from './EditorApp.module.css'; + +const AUDIO_REF_DND = 'application/x-dnd-scene-audio-ref'; + +function reorderRefs(refs: SceneAudioRef[], draggedId: AssetId, targetId: AssetId): SceneAudioRef[] { + if (draggedId === targetId) return refs; + const from = refs.findIndex((r) => r.assetId === draggedId); + const to = refs.findIndex((r) => r.assetId === targetId); + if (from < 0 || to < 0) return refs; + const next = [...refs]; + const [item] = next.splice(from, 1); + if (!item) return refs; + next.splice(to, 0, item); + return next; +} + +/** Локальный draft — не пишет в проект на каждый tick ползунка (иначе мигает граф). */ +function AudioVolumeSlider({ + assetId, + volume, + isPlaying, + previewElRef, + label, + onCommit, +}: { + assetId: AssetId; + volume: number; + isPlaying: boolean; + previewElRef: React.MutableRefObject; + label: string; + onCommit: (volume: number) => void; +}) { + const [draft, setDraft] = useState(() => clampSceneAudioVolume(volume)); + const draftRef = useRef(draft); + draftRef.current = draft; + + useEffect(() => { + setDraft(clampSceneAudioVolume(volume)); + }, [assetId, volume]); + + const applyLocal = (v: number) => { + const next = clampSceneAudioVolume(v); + setDraft(next); + if (isPlaying && previewElRef.current) previewElRef.current.volume = next; + }; + + const commit = () => { + const next = clampSceneAudioVolume(draftRef.current); + if (next === clampSceneAudioVolume(volume)) return; + onCommit(next); + }; + + return ( + + ); +} + +export type EditorAudioTrackListProps = { + audioRefs: SceneAudioRef[]; + mediaAssets: MediaAsset[]; + removeTitle: string; + onAudioRefsChange: (next: SceneAudioRef[]) => void; +}; + +export function EditorAudioTrackList({ + audioRefs, + mediaAssets, + removeTitle, + onAudioRefsChange, +}: EditorAudioTrackListProps) { + const { t } = useEditorI18n(); + const assetById = useMemo(() => new Map(mediaAssets.map((a) => [a.id, a])), [mediaAssets]); + const [playingId, setPlayingId] = useState(null); + const previewElRef = useRef(null); + + useEffect(() => { + return () => { + const el = previewElRef.current; + if (el) { + el.pause(); + el.src = ''; + previewElRef.current = null; + } + }; + }, []); + + useEffect(() => { + return registerEditorAudioStopper(() => { + const el = previewElRef.current; + if (el) { + el.pause(); + el.onended = null; + el.src = ''; + } + previewElRef.current = null; + setPlayingId(null); + }); + }, []); + + const stopPreview = () => { + const el = previewElRef.current; + if (el) { + el.pause(); + el.onended = null; + el.src = ''; + } + previewElRef.current = null; + setPlayingId(null); + }; + + const togglePreview = async (assetId: AssetId, volume: number) => { + if (playingId === assetId) { + stopPreview(); + return; + } + stopPreview(); + try { + const r = await getDndApi().invoke(ipcChannels.project.assetFileUrl, { assetId }); + const url = r.url; + if (!url) return; + const el = new Audio(url); + el.volume = clampSceneAudioVolume(volume); + el.onended = () => { + previewElRef.current = null; + setPlayingId(null); + }; + previewElRef.current = el; + setPlayingId(assetId); + await el.play(); + } catch { + stopPreview(); + } + }; + + if (audioRefs.length === 0) { + return ( +
{t('campaign.noFiles')}
+ ); + } + + return ( +
+ {audioRefs.map((ref) => { + const asset = assetById.get(ref.assetId); + const name = asset?.originalName ?? ref.assetId; + const volume = clampSceneAudioVolume(ref.volume); + const isPlaying = playingId === ref.assetId; + return ( +
{ + if (e.dataTransfer.types.includes(AUDIO_REF_DND)) e.preventDefault(); + }} + onDrop={(e) => { + e.preventDefault(); + e.stopPropagation(); + const dragged = e.dataTransfer.getData(AUDIO_REF_DND) as AssetId; + if (!dragged) return; + const next = reorderRefs(audioRefs, dragged, ref.assetId); + if (next !== audioRefs) onAudioRefsChange(next); + }} + > +
{ + e.dataTransfer.setData(AUDIO_REF_DND, ref.assetId); + e.dataTransfer.effectAllowed = 'move'; + }} + > + + ⋮⋮ + + {name} +
+ + + + { + const next = audioRefs.map((x) => + x.assetId === ref.assetId ? { ...x, volume: v } : x, + ); + onAudioRefsChange(next); + }} + /> + + + +
+ ); + })} +
+ ); +} diff --git a/app/renderer/editor/MaterialsModals.tsx b/app/renderer/editor/MaterialsModals.tsx index 65c34ab..a58eb31 100644 --- a/app/renderer/editor/MaterialsModals.tsx +++ b/app/renderer/editor/MaterialsModals.tsx @@ -11,6 +11,7 @@ import styles from './EditorApp.module.css'; import { filterMaterialImagePaths, getDroppedFileEntries, + nameFromFilePath, pickFirstMaterialImagePath, useFileDropZone, } from './fileDrop'; @@ -86,6 +87,11 @@ export function MaterialEditModal({ const setPreviewFromPathAndUrl = (path: string, previewUrl: string) => { setFilePath(path); + setName((prev) => { + if (prev.trim()) return prev; + const fromFile = nameFromFilePath(path); + return fromFile || prev; + }); setLocalPreviewUrl((prev) => { if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev); return previewUrl; diff --git a/app/renderer/editor/fileDrop.ts b/app/renderer/editor/fileDrop.ts index 5fe61c7..41b905c 100644 --- a/app/renderer/editor/fileDrop.ts +++ b/app/renderer/editor/fileDrop.ts @@ -69,10 +69,14 @@ export function isPreviewMediaPath(filePath: string): boolean { return PREVIEW_EXTENSIONS.has(fileExtension(filePath)); } -export function sceneTitleFromMediaPath(filePath: string): string { +export function nameFromFilePath(filePath: string): string { const base = filePath.split(/[/\\]/).pop() ?? filePath; const dot = base.lastIndexOf('.'); - const title = (dot > 0 ? base.slice(0, dot) : base).trim(); + return (dot > 0 ? base.slice(0, dot) : base).trim(); +} + +export function sceneTitleFromMediaPath(filePath: string): string { + const title = nameFromFilePath(filePath); return title.length > 0 ? title : 'Новая сцена'; } diff --git a/app/renderer/editor/graph/sceneCardById.test.ts b/app/renderer/editor/graph/sceneCardById.test.ts index e35a40f..24ff7c0 100644 --- a/app/renderer/editor/graph/sceneCardById.test.ts +++ b/app/renderer/editor/graph/sceneCardById.test.ts @@ -42,7 +42,7 @@ void test('buildNextSceneCardById: does not change refs when irrelevant fields c id: sid, title: 'T', description: 'A', - media: { videos: [], audios: [{ assetId: 'a1' as AssetId, autoplay: false, loop: true }] }, + media: { videos: [], audios: [{ assetId: 'a1' as AssetId, autoplay: false, loop: true, volume: 1 }] }, settings: { autoplayVideo: false, autoplayAudio: false, loopVideo: false, loopAudio: false }, connections: [], layout: { x: 0, y: 0 }, diff --git a/app/renderer/editor/i18n/editorMessages.ts b/app/renderer/editor/i18n/editorMessages.ts index b58d6e5..37c2618 100644 --- a/app/renderer/editor/i18n/editorMessages.ts +++ b/app/renderer/editor/i18n/editorMessages.ts @@ -411,6 +411,9 @@ export const EDITOR_MESSAGES: Record> = { 'campaign.noFiles': 'Файлов пока нет. Добавьте аудио.', 'campaign.auto': 'Авто', 'campaign.loop': 'Цикл', + 'campaign.volume': 'Громк.', + 'campaign.preview': 'Прослушать', + 'campaign.previewStop': 'Стоп', 'campaign.removeTitle': 'Убрать из кампании', 'campaign.upload': 'Загрузить', 'drop.hintAudio': 'Перетащите аудиофайлы сюда', @@ -1154,6 +1157,9 @@ export const EDITOR_MESSAGES: Record> = { 'campaign.noFiles': 'No files yet. Add audio.', 'campaign.auto': 'Auto', 'campaign.loop': 'Loop', + 'campaign.volume': 'Vol.', + 'campaign.preview': 'Preview', + 'campaign.previewStop': 'Stop', 'campaign.removeTitle': 'Remove from campaign', 'campaign.upload': 'Upload', 'drop.hintAudio': 'Drop audio files here', diff --git a/app/renderer/npcs/NpcEditModal.tsx b/app/renderer/npcs/NpcEditModal.tsx index dfb3cd0..b2365f2 100644 --- a/app/renderer/npcs/NpcEditModal.tsx +++ b/app/renderer/npcs/NpcEditModal.tsx @@ -8,6 +8,7 @@ import editorStyles from '../editor/EditorApp.module.css'; import { filterMaterialImagePaths, getDroppedFileEntries, + nameFromFilePath, pickFirstMaterialImagePath, useFileDropZone, } from '../editor/fileDrop'; @@ -111,6 +112,11 @@ export function NpcEditModal({ const setPreviewFromPathAndUrl = (path: string, previewUrl: string) => { setFilePath(path); + setName((prev) => { + if (prev.trim()) return prev; + const fromFile = nameFromFilePath(path); + return fromFile || prev; + }); setLocalPreviewUrl((prev) => { if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev); return previewUrl; diff --git a/app/renderer/sceneEditor/TokenEditModal.tsx b/app/renderer/sceneEditor/TokenEditModal.tsx index 7348d00..05a7731 100644 --- a/app/renderer/sceneEditor/TokenEditModal.tsx +++ b/app/renderer/sceneEditor/TokenEditModal.tsx @@ -6,6 +6,7 @@ import type { AppToken } from '../../shared/types'; import { filterMaterialImagePaths, getDroppedFileEntries, + nameFromFilePath, pickFirstMaterialImagePath, useFileDropZone, } from '../editor/fileDrop'; @@ -56,6 +57,11 @@ export function TokenEditModal({ open, initial, existingNames, onClose, onSaved const setPreviewFromPathAndUrl = (path: string, previewUrl: string) => { setFilePath(path); + setName((prev) => { + if (prev.trim()) return prev; + const fromFile = nameFromFilePath(path); + return fromFile || prev; + }); setLocalPreviewUrl((prev) => { if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev); return previewUrl || null; diff --git a/app/renderer/shared/audio/editorAudioStop.ts b/app/renderer/shared/audio/editorAudioStop.ts new file mode 100644 index 0000000..4c42337 --- /dev/null +++ b/app/renderer/shared/audio/editorAudioStop.ts @@ -0,0 +1,20 @@ +/** Регистрация стопперов превью/плееров редактора — глушим при запуске игры. */ + +const stoppers = new Set<() => void>(); + +export function registerEditorAudioStopper(stop: () => void): () => void { + stoppers.add(stop); + return () => { + stoppers.delete(stop); + }; +} + +export function stopAllEditorAudio(): void { + for (const stop of [...stoppers]) { + try { + stop(); + } catch { + /* ignore */ + } + } +} diff --git a/app/renderer/shared/playlists/PlaylistsPanel.module.css b/app/renderer/shared/playlists/PlaylistsPanel.module.css index 99b29b9..8836f19 100644 --- a/app/renderer/shared/playlists/PlaylistsPanel.module.css +++ b/app/renderer/shared/playlists/PlaylistsPanel.module.css @@ -87,6 +87,16 @@ border: 1px solid var(--scene-list-selected-border); } +.playlistRowPlaying { + box-shadow: inset 2px 0 0 var(--accent2); +} + +.playlistPlayingMark { + color: var(--accent2); + font-size: 10px; + flex-shrink: 0; +} + .trackRowMatch { outline: 1px dashed var(--accent2); } diff --git a/app/renderer/shared/playlists/PlaylistsPanel.tsx b/app/renderer/shared/playlists/PlaylistsPanel.tsx index 125dbc2..6bdfa4f 100644 --- a/app/renderer/shared/playlists/PlaylistsPanel.tsx +++ b/app/renderer/shared/playlists/PlaylistsPanel.tsx @@ -13,6 +13,7 @@ import { filterAudioFilePaths, useFileDropZone } from '../../editor/fileDrop'; import editorStyles from '../../editor/EditorApp.module.css'; import { useEditorI18n } from '../../editor/i18n/EditorI18nContext'; import { getDndApi } from '../dndApi'; +import { registerEditorAudioStopper } from '../audio/editorAudioStop'; import { Button, Input } from '../ui/controls'; import { PlaylistPlayerBar } from './PlaylistPlayerBar'; @@ -81,37 +82,54 @@ export function PlaylistsPanel({ } }, [selected]); - const audioElRef = useRef(null); - const [audioEl, setAudioEl] = useState(null); - const [playing, setPlaying] = useState(false); - const [gain, setGain] = useState(1); - const [queuePlaylistId, setQueuePlaylistId] = useState(null); - const [queueIndex, setQueueIndex] = useState(0); - const queueRef = useRef<{ playlistId: PlaylistId; index: number } | null>(null); + const elsRef = useRef>(new Map()); + const queuesRef = useRef>(new Map()); + const [playerUi, setPlayerUi] = useState< + Record + >({}); + const playerUiRef = useRef(playerUi); + playerUiRef.current = playerUi; + const playAtRef = useRef<(playlist: AppPlaylist, index: number) => Promise>(async () => undefined); + const statePlaylistsRef = useRef(state.playlists); + statePlaylistsRef.current = state.playlists; - const queuePlaylist = useMemo( - () => (queuePlaylistId ? state.playlists.find((p) => p.id === queuePlaylistId) ?? null : null), - [queuePlaylistId, state.playlists], - ); - const currentTrack = queuePlaylist?.tracks[queueIndex] ?? null; - const currentAudio = currentTrack ? audioById.get(currentTrack.audioId) ?? null : null; - - const stopPlayback = useCallback(() => { - const el = audioElRef.current; - if (el) { - el.pause(); - el.removeAttribute('src'); - el.load(); - } - audioElRef.current = null; - setAudioEl(null); - setPlaying(false); - queueRef.current = null; - setQueuePlaylistId(null); - setQueueIndex(0); + const bumpPlayer = useCallback((playlistId: PlaylistId, patch: Partial<{ index: number; playing: boolean; gain: number }>) => { + setPlayerUi((prev) => { + const cur = prev[playlistId] ?? { index: 0, playing: false, gain: 1 }; + return { ...prev, [playlistId]: { ...cur, ...patch } }; + }); }, []); - useEffect(() => () => stopPlayback(), [stopPlayback]); + const destroyPlayer = useCallback((playlistId: PlaylistId) => { + const el = elsRef.current.get(playlistId); + if (el) { + el.pause(); + el.onended = null; + el.onplay = null; + el.onpause = null; + el.removeAttribute('src'); + el.load(); + elsRef.current.delete(playlistId); + } + queuesRef.current.delete(playlistId); + setPlayerUi((prev) => { + if (!(playlistId in prev)) return prev; + const next = { ...prev }; + delete next[playlistId]; + return next; + }); + }, []); + + const stopAllPlayback = useCallback(() => { + for (const id of [...elsRef.current.keys()]) destroyPlayer(id); + }, [destroyPlayer]); + + useEffect(() => () => stopAllPlayback(), [stopAllPlayback]); + + useEffect(() => { + if (readOnly) return undefined; + return registerEditorAudioStopper(() => stopAllPlayback()); + }, [readOnly, stopAllPlayback]); const playAt = useCallback( async (playlist: AppPlaylist, index: number) => { @@ -119,61 +137,57 @@ export function PlaylistsPanel({ if (!track) return; const { url } = await api.invoke(ipcChannels.playlists.audioUrl, { id: track.audioId }); if (!url) return; - let el = audioElRef.current; + + let el = elsRef.current.get(playlist.id); if (!el) { el = new Audio(); el.preload = 'auto'; - audioElRef.current = el; - setAudioEl(el); + elsRef.current.set(playlist.id, el); + el.onended = () => { + const qIndex = queuesRef.current.get(playlist.id) ?? 0; + const pl = statePlaylistsRef.current.find((p) => p.id === playlist.id); + if (!pl || pl.tracks.length === 0) { + destroyPlayer(playlist.id); + return; + } + const next = (qIndex + 1) % pl.tracks.length; + void playAtRef.current(pl, next); + }; + el.onplay = () => bumpPlayer(playlist.id, { playing: true }); + el.onpause = () => bumpPlayer(playlist.id, { playing: false }); } + + const gain = playerUiRef.current[playlist.id]?.gain ?? 1; el.volume = gain; el.src = url; - queueRef.current = { playlistId: playlist.id, index }; - setQueuePlaylistId(playlist.id); - setQueueIndex(index); + queuesRef.current.set(playlist.id, index); + bumpPlayer(playlist.id, { index, playing: false, gain }); try { await el.play(); - setPlaying(true); + bumpPlayer(playlist.id, { index, playing: true }); } catch { - setPlaying(false); + bumpPlayer(playlist.id, { index, playing: false }); } }, - [api, gain], + [api, bumpPlayer, destroyPlayer], ); + playAtRef.current = playAt; - useEffect(() => { - const el = audioElRef.current; - if (!el) return; - const onEnded = () => { - const q = queueRef.current; - if (!q) return; - const pl = state.playlists.find((p) => p.id === q.playlistId); - if (!pl || pl.tracks.length === 0) { - stopPlayback(); - return; - } - const next = (q.index + 1) % pl.tracks.length; - void playAt(pl, next); - }; - const onPlay = () => setPlaying(true); - const onPause = () => setPlaying(false); - el.addEventListener('ended', onEnded); - el.addEventListener('play', onPlay); - el.addEventListener('pause', onPause); - return () => { - el.removeEventListener('ended', onEnded); - el.removeEventListener('play', onPlay); - el.removeEventListener('pause', onPause); - }; - }, [audioEl, playAt, state.playlists, stopPlayback]); + const selectedPlayer = selected ? playerUi[selected.id] : undefined; + const selectedEl = selected ? elsRef.current.get(selected.id) ?? null : null; + const selectedQueueIndex = selectedPlayer?.index ?? 0; + const selectedPlaying = Boolean(selectedPlayer?.playing); + const selectedGain = selectedPlayer?.gain ?? 1; + const selectedCurrentTrack = selected?.tracks[selectedQueueIndex] ?? null; + const selectedCurrentAudio = selectedCurrentTrack + ? audioById.get(selectedCurrentTrack.audioId) ?? null + : null; const step = (delta: number) => { - const q = queueRef.current; - const pl = q ? state.playlists.find((p) => p.id === q.playlistId) : selected; - if (!pl || pl.tracks.length === 0) return; - const base = q?.playlistId === pl.id ? q.index : 0; - const next = (base + delta + pl.tracks.length) % pl.tracks.length; - void playAt(pl, next); + if (!selected || selected.tracks.length === 0) return; + const base = playerUi[selected.id]?.index ?? 0; + const next = (base + delta + selected.tracks.length) % selected.tracks.length; + void playAt(selected, next); }; const createPlaylist = async () => { @@ -223,7 +237,7 @@ export function PlaylistsPanel({ if (!pendingDelete) return; const id = pendingDelete.id; setPendingDelete(null); - if (queueRef.current?.playlistId === id) stopPlayback(); + destroyPlayer(id); await api.invoke(ipcChannels.playlists.delete, { id }); setSelectedId((cur) => (cur === id ? null : cur)); }; @@ -252,7 +266,11 @@ export function PlaylistsPanel({ )) @@ -360,7 +379,9 @@ export function PlaylistsPanel({ const title = audio?.title ?? labels.untitled; const highlighted = query.trim() && filtered.matchedAudioIds.has(tr.audioId); const isCurrent = - queuePlaylistId === selected.id && queueIndex === idx && Boolean(currentTrack); + selectedPlayer?.playing === true && + selectedQueueIndex === idx && + Boolean(selectedCurrentTrack); return (
{ + if (!selected) return; + const el = elsRef.current.get(selected.id); + if (el) el.volume = g; + bumpPlayer(selected.id, { gain: g }); + }} onTogglePlay={() => { - const el = audioElRef.current; + if (!selected) return; + const el = elsRef.current.get(selected.id); if (!el) { - if (selected && selected.tracks.length > 0) void playAt(selected, 0); + if (selected.tracks.length > 0) void playAt(selected, 0); return; } - if (el.paused) void el.play().then(() => setPlaying(true)).catch(() => setPlaying(false)); - else { - el.pause(); - setPlaying(false); - } + if (el.paused) void el.play().catch(() => bumpPlayer(selected.id, { playing: false })); + else el.pause(); }} onPrev={() => step(-1)} onNext={() => step(1)} diff --git a/app/shared/types/domain.ts b/app/shared/types/domain.ts index 5beaa83..708758e 100644 --- a/app/shared/types/domain.ts +++ b/app/shared/types/domain.ts @@ -110,8 +110,29 @@ export type SceneAudioRef = { assetId: AssetId; autoplay: boolean; loop: boolean; + /** Громкость 0…1; дефолт на пульте при старте сессии. */ + volume: number; }; +export function clampSceneAudioVolume(v: unknown): number { + if (typeof v !== 'number' || !Number.isFinite(v)) return 1; + return Math.max(0, Math.min(1, v)); +} + +export function normalizeSceneAudioRef(raw: { + assetId: AssetId; + autoplay?: boolean; + loop?: boolean; + volume?: unknown; +}): SceneAudioRef { + return { + assetId: raw.assetId, + autoplay: Boolean(raw.autoplay), + loop: Boolean(raw.loop), + volume: clampSceneAudioVolume(raw.volume), + }; +} + export type SceneMediaRefs = { videos: AssetId[]; audios: SceneAudioRef[]; diff --git a/app/shared/types/sceneAudioRef.test.ts b/app/shared/types/sceneAudioRef.test.ts new file mode 100644 index 0000000..0da49f2 --- /dev/null +++ b/app/shared/types/sceneAudioRef.test.ts @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { clampSceneAudioVolume, normalizeSceneAudioRef } from '../../shared/types/domain'; +import type { AssetId } from '../../shared/types/ids'; + +void test('clampSceneAudioVolume clamps to 0…1', () => { + assert.equal(clampSceneAudioVolume(0.5), 0.5); + assert.equal(clampSceneAudioVolume(-1), 0); + assert.equal(clampSceneAudioVolume(2), 1); + assert.equal(clampSceneAudioVolume(undefined), 1); + assert.equal(clampSceneAudioVolume(NaN), 1); +}); + +void test('normalizeSceneAudioRef defaults volume to 1', () => { + const id = 'a1' as AssetId; + assert.deepEqual(normalizeSceneAudioRef({ assetId: id, autoplay: true, loop: false }), { + assetId: id, + autoplay: true, + loop: false, + volume: 1, + }); + assert.deepEqual(normalizeSceneAudioRef({ assetId: id, autoplay: false, loop: true, volume: 0.25 }), { + assetId: id, + autoplay: false, + loop: true, + volume: 0.25, + }); +}); diff --git a/package.json b/package.json index 9c8956f..03c4666 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/shared/ui/secondaryWindows.stability.test.ts app/renderer/npcs/tiptapEditorSafe.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/shared/graph/activeStorylineScenes.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/help/helpLinkify.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/control/controlApp.audioPerf.networkRegression.test.ts app/renderer/control/controlApp.brushPerf.networkRegression.test.ts app/renderer/shared/useAssetImageUrl.cache.test.ts app/main/sessionIpc.phase6.networkRegression.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/renderer/shared/traps/trapActivation.test.ts app/renderer/shared/videoSceneMapParity.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/preferences/appPreferences.test.ts app/main/playlists/playlistsStore.test.ts app/main/sessionTimer/timerSessionStore.test.ts app/main/initiative/initiativeSessionStore.test.ts app/main/diceRoll/diceRollSessionStore.test.ts app/main/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/effects/sceneMarkerStore.test.ts app/shared/sceneMarkerEraser.test.ts app/main/sceneTraps/sceneTrapsStore.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 app/main/license/machineFingerprint.test.ts app/shared/types/appPlayers.test.ts app/shared/types/npcDisposition.test.ts app/shared/types/npcExternalUrl.test.ts app/main/npcs/npcsOverlayStore.test.ts app/shared/types/sceneGrid.test.ts app/shared/types/sceneGridSnap.test.ts app/shared/types/sceneDarkness.test.ts app/shared/types/materialLegend.test.ts app/main/tokens/tokenGridSnapSessionStore.test.ts app/shared/players/playerTeams.test.ts app/shared/players/launchPlayersSelection.test.ts app/main/players/playersStore.test.ts app/main/players/sceneNpcTokensSessionStore.test.ts app/main/players/scenePlayerTokensSessionStore.test.ts app/main/tokens/sceneTokensSessionStore.test.ts app/shared/ipc/contracts.players.test.ts app/shared/types/containMediaRect.test.ts app/shared/types/scenePreviewRotation.test.ts app/shared/types/tokenPath.test.ts app/shared/types/tokenPathPlayback.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs scripts/release-native-prep.test.mjs scripts/verify-packaged-sharp.test.mjs app/main/project/sharpRuntime.test.mjs", + "test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/shared/ui/secondaryWindows.stability.test.ts app/renderer/npcs/tiptapEditorSafe.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/shared/graph/activeStorylineScenes.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/help/helpLinkify.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/control/controlApp.audioPerf.networkRegression.test.ts app/renderer/control/controlApp.brushPerf.networkRegression.test.ts app/renderer/shared/useAssetImageUrl.cache.test.ts app/main/sessionIpc.phase6.networkRegression.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/renderer/shared/traps/trapActivation.test.ts app/renderer/shared/videoSceneMapParity.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/preferences/appPreferences.test.ts app/main/playlists/playlistsStore.test.ts app/main/sessionTimer/timerSessionStore.test.ts app/main/initiative/initiativeSessionStore.test.ts app/main/diceRoll/diceRollSessionStore.test.ts app/main/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/effects/sceneMarkerStore.test.ts app/shared/sceneMarkerEraser.test.ts app/main/sceneTraps/sceneTrapsStore.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 app/main/license/machineFingerprint.test.ts app/shared/types/sceneAudioRef.test.ts app/shared/types/appPlayers.test.ts app/shared/types/npcDisposition.test.ts app/shared/types/npcExternalUrl.test.ts app/main/npcs/npcsOverlayStore.test.ts app/shared/types/sceneGrid.test.ts app/shared/types/sceneGridSnap.test.ts app/shared/types/sceneDarkness.test.ts app/shared/types/materialLegend.test.ts app/main/tokens/tokenGridSnapSessionStore.test.ts app/shared/players/playerTeams.test.ts app/shared/players/launchPlayersSelection.test.ts app/main/players/playersStore.test.ts app/main/players/sceneNpcTokensSessionStore.test.ts app/main/players/scenePlayerTokensSessionStore.test.ts app/main/tokens/sceneTokensSessionStore.test.ts app/shared/ipc/contracts.players.test.ts app/shared/types/containMediaRect.test.ts app/shared/types/scenePreviewRotation.test.ts app/shared/types/tokenPath.test.ts app/shared/types/tokenPathPlayback.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs scripts/release-native-prep.test.mjs scripts/verify-packaged-sharp.test.mjs app/main/project/sharpRuntime.test.mjs", "format": "prettier . --check", "format:write": "prettier . --write", "postinstall": "patch-package",