feat(project): optimize image imports and converter

- Optimize imported scene preview images (smart WebP/JPEG/PNG, preserve alpha, keep pixel size)

- Update converter to re-encode existing image assets with same algorithm

- Improve import/export progress overlay and reduce presentation slide stutter

Made-with: Cursor
This commit is contained in:
Ivan Fontosh
2026-04-23 17:59:57 +08:00
parent 1d051f8bf9
commit 8f8eef53c9
33 changed files with 3684 additions and 68 deletions
+46
View File
@@ -124,6 +124,52 @@
background: var(--bg0);
}
.progressOverlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
}
.progressModal {
width: min(520px, calc(100vw - 32px));
border-radius: 12px;
padding: 14px;
background: rgba(25, 28, 38, 0.92);
border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.55);
}
.progressTitle {
font-weight: 700;
margin-bottom: 10px;
}
.progressBar {
height: 10px;
border-radius: 999px;
overflow: hidden;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.progressFill {
height: 100%;
width: 0%;
background: rgba(167, 139, 250, 0.85);
}
.progressMeta {
margin-top: 10px;
display: flex;
justify-content: space-between;
opacity: 0.9;
font-size: 12px;
}
.inspectorTitle {
font-weight: 800;
margin-bottom: 12px;
+41 -6
View File
@@ -23,6 +23,7 @@ type SceneCard = {
title: string;
active: boolean;
previewAssetId: AssetId | null;
previewThumbAssetId: AssetId | null;
previewAssetType: 'image' | 'video' | null;
previewVideoAutostart: boolean;
previewRotationDeg: 0 | 90 | 180 | 270;
@@ -92,6 +93,7 @@ export function EditorApp() {
title: s.title,
active: s.id === state.selectedSceneId,
previewAssetId: s.previewAssetId,
previewThumbAssetId: s.previewThumbAssetId,
previewAssetType: s.previewAssetType,
previewVideoAutostart: s.previewVideoAutostart,
previewRotationDeg: s.previewRotationDeg,
@@ -263,6 +265,28 @@ export function EditorApp() {
return (
<>
{state.zipProgress
? createPortal(
<div className={styles.progressOverlay} role="dialog" aria-label="Прогресс операции">
<div className={styles.progressModal}>
<div className={styles.progressTitle}>
{state.zipProgress.kind === 'import' ? 'Импорт проекта' : 'Экспорт проекта'}
</div>
<div className={styles.progressBar}>
<div
className={styles.progressFill}
style={{ width: `${String(Math.max(0, Math.min(100, state.zipProgress.percent)))}%` }}
/>
</div>
<div className={styles.progressMeta}>
<div>{state.zipProgress.detail ?? state.zipProgress.stage}</div>
<div>{state.zipProgress.percent}%</div>
</div>
</div>
</div>,
document.body,
)
: null}
<LayoutShell
bodyOverlay={bodyOverlay}
topBar={
@@ -1276,7 +1300,8 @@ type SceneListCardProps = {
};
function SceneListCard({ scene, onSelect, onDeleteScene }: SceneListCardProps) {
const url = useAssetUrl(scene.previewAssetId);
const thumbUrl = useAssetUrl(scene.previewThumbAssetId);
const previewUrl = useAssetUrl(scene.previewAssetId);
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null);
useEffect(() => {
@@ -1315,21 +1340,31 @@ function SceneListCard({ scene, onSelect, onDeleteScene }: SceneListCardProps) {
if (e.key === 'Enter' || e.key === ' ') onSelect();
}}
>
<div className={url ? styles.sceneThumb : styles.sceneThumbEmpty}>
{url && scene.previewAssetType === 'image' ? (
<div className={thumbUrl || previewUrl ? styles.sceneThumb : styles.sceneThumbEmpty}>
{thumbUrl ? (
<div className={styles.sceneThumbInner}>
<RotatedImage
url={url}
url={thumbUrl}
rotationDeg={scene.previewRotationDeg}
mode="cover"
loading="lazy"
decoding="async"
/>
</div>
) : url && scene.previewAssetType === 'video' ? (
) : previewUrl && scene.previewAssetType === 'image' ? (
<div className={styles.sceneThumbInner}>
<RotatedImage
url={previewUrl}
rotationDeg={scene.previewRotationDeg}
mode="cover"
loading="lazy"
decoding="async"
/>
</div>
) : previewUrl && scene.previewAssetType === 'video' ? (
<div className={styles.sceneThumbInner}>
<video
src={url}
src={previewUrl}
muted
playsInline
preload="metadata"
+32 -6
View File
@@ -35,6 +35,7 @@ export type SceneGraphSceneAudioSummary = {
export type SceneGraphSceneCard = {
title: string;
previewAssetId: AssetId | null;
previewThumbAssetId: AssetId | null;
previewAssetType: 'image' | 'video' | null;
previewVideoAutostart: boolean;
previewRotationDeg: 0 | 90 | 180 | 270;
@@ -69,6 +70,7 @@ type SceneCardData = {
title: string;
active: boolean;
previewAssetId: AssetId | null;
previewThumbAssetId: AssetId | null;
previewAssetType: 'image' | 'video' | null;
previewVideoAutostart: boolean;
previewRotationDeg: 0 | 90 | 180 | 270;
@@ -129,7 +131,8 @@ function IconVideoPreviewAutostart() {
}
function SceneCardNode({ data }: NodeProps<SceneCardData>) {
const url = useAssetUrl(data.previewAssetId);
const thumbUrl = useAssetUrl(data.previewThumbAssetId);
const previewUrl = useAssetUrl(data.previewAssetId);
const cardClass = [styles.card, data.active ? styles.cardActive : ''].filter(Boolean).join(' ');
const showCornerVideo = data.previewIsVideo;
const showCornerAudio = data.hasSceneAudio;
@@ -139,11 +142,11 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
<div className={cardClass}>
<div className={styles.previewShell}>
{data.isStartScene ? <div className={styles.badgeStart}>НАЧАЛО</div> : null}
{url && data.previewAssetType === 'image' ? (
{thumbUrl ? (
<div className={styles.previewFill}>
{data.previewRotationDeg === 0 ? (
<img
src={url}
src={thumbUrl}
alt=""
className={styles.imageCover}
draggable={false}
@@ -152,7 +155,7 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
/>
) : (
<RotatedImage
url={url}
url={thumbUrl}
rotationDeg={data.previewRotationDeg}
mode="cover"
loading="lazy"
@@ -161,9 +164,31 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
/>
)}
</div>
) : url && data.previewAssetType === 'video' ? (
) : previewUrl && data.previewAssetType === 'image' ? (
<div className={styles.previewFill}>
{data.previewRotationDeg === 0 ? (
<img
src={previewUrl}
alt=""
className={styles.imageCover}
draggable={false}
loading="lazy"
decoding="async"
/>
) : (
<RotatedImage
url={previewUrl}
rotationDeg={data.previewRotationDeg}
mode="cover"
loading="lazy"
decoding="async"
style={{ width: '100%', height: '100%' }}
/>
)}
</div>
) : previewUrl && data.previewAssetType === 'video' ? (
<video
src={url}
src={previewUrl}
muted
playsInline
preload="metadata"
@@ -322,6 +347,7 @@ function SceneGraphCanvas({
title: c?.title ?? '',
active,
previewAssetId: c?.previewAssetId ?? null,
previewThumbAssetId: c?.previewThumbAssetId ?? null,
previewAssetType: c?.previewAssetType ?? null,
previewVideoAutostart: c?.previewVideoAutostart ?? false,
previewRotationDeg: c?.previewRotationDeg ?? 0,
@@ -42,6 +42,7 @@ void test('buildNextSceneCardById: does not change refs when irrelevant fields c
connections: [],
layout: { x: 0, y: 0 },
previewAssetId: null,
previewThumbAssetId: null,
previewAssetType: null,
previewVideoAutostart: false,
previewRotationDeg: 0,
@@ -79,6 +80,7 @@ void test('buildNextSceneCardById: changes card when title changes', () => {
connections: [],
layout: { x: 0, y: 0 },
previewAssetId: null,
previewThumbAssetId: null,
previewAssetType: null,
previewVideoAutostart: false,
previewRotationDeg: 0,
@@ -38,6 +38,7 @@ export function buildNextSceneCardById(
if (
prevCard?.title === s.title &&
prevCard.previewAssetId === s.previewAssetId &&
prevCard.previewThumbAssetId === s.previewThumbAssetId &&
prevCard.previewAssetType === s.previewAssetType &&
prevCard.previewVideoAutostart === s.previewVideoAutostart &&
prevCard.previewRotationDeg === s.previewRotationDeg &&
@@ -49,6 +50,7 @@ export function buildNextSceneCardById(
nextMap[id] = {
title: s.title,
previewAssetId: s.previewAssetId,
previewThumbAssetId: s.previewThumbAssetId,
previewAssetType: s.previewAssetType,
previewVideoAutostart: s.previewVideoAutostart,
previewRotationDeg: s.previewRotationDeg,
+51 -2
View File
@@ -10,6 +10,7 @@ type State = {
projects: ProjectSummary[];
project: Project | null;
selectedSceneId: SceneId | null;
zipProgress: { kind: 'import' | 'export'; percent: number; stage: string; detail?: string } | null;
};
type Actions = {
@@ -27,6 +28,7 @@ type Actions = {
title?: string;
description?: string;
previewAssetId?: AssetId | null;
previewThumbAssetId?: AssetId | null;
previewAssetType?: 'image' | 'video' | null;
previewVideoAutostart?: boolean;
previewRotationDeg?: 0 | 90 | 180 | 270;
@@ -58,7 +60,12 @@ function randomId(prefix: string): string {
export function useProjectState(licenseActive: boolean): readonly [State, Actions] {
const api = getDndApi();
const [state, setState] = useState<State>({ projects: [], project: null, selectedSceneId: null });
const [state, setState] = useState<State>({
projects: [],
project: null,
selectedSceneId: null,
zipProgress: null,
});
const projectRef = useRef<Project | null>(null);
/** Bumps on mutations / refresh; initial license load only applies if still current (avoids racing late list/get over newer state). */
const projectDataEpochRef = useRef(0);
@@ -66,6 +73,43 @@ export function useProjectState(licenseActive: boolean): readonly [State, Action
projectRef.current = state.project;
}, [state.project]);
useEffect(() => {
const offImport = api.on(ipcChannels.project.importZipProgress, (evt) => {
const e = evt as unknown as { percent: number; stage: string; detail?: string };
setState((s) => ({
...s,
zipProgress: {
kind: 'import',
percent: e.percent,
stage: e.stage,
...(e.detail ? { detail: e.detail } : null),
},
}));
if (e.stage === 'done' || e.percent >= 100) {
setTimeout(() => setState((s) => ({ ...s, zipProgress: null })), 450);
}
});
const offExport = api.on(ipcChannels.project.exportZipProgress, (evt) => {
const e = evt as unknown as { percent: number; stage: string; detail?: string };
setState((s) => ({
...s,
zipProgress: {
kind: 'export',
percent: e.percent,
stage: e.stage,
...(e.detail ? { detail: e.detail } : null),
},
}));
if (e.stage === 'done' || e.percent >= 100) {
setTimeout(() => setState((s) => ({ ...s, zipProgress: null })), 450);
}
});
return () => {
offImport();
offExport();
};
}, [api]);
const actions = useMemo<Actions>(() => {
const refreshProjects = async () => {
projectDataEpochRef.current += 1;
@@ -99,6 +143,7 @@ export function useProjectState(licenseActive: boolean): readonly [State, Action
title: `Новая сцена`,
description: '',
previewAssetId: null,
previewThumbAssetId: null,
previewAssetType: null,
previewVideoAutostart: false,
previewRotationDeg: 0,
@@ -153,6 +198,7 @@ export function useProjectState(licenseActive: boolean): readonly [State, Action
title?: string;
description?: string;
previewAssetId?: AssetId | null;
previewThumbAssetId?: AssetId | null;
previewAssetType?: 'image' | 'video' | null;
previewVideoAutostart?: boolean;
previewRotationDeg?: 0 | 90 | 180 | 270;
@@ -171,6 +217,9 @@ export function useProjectState(licenseActive: boolean): readonly [State, Action
...(patch.title !== undefined ? { title: patch.title } : null),
...(patch.description !== undefined ? { description: patch.description } : null),
...(patch.previewAssetId !== undefined ? { previewAssetId: patch.previewAssetId } : null),
...(patch.previewThumbAssetId !== undefined
? { previewThumbAssetId: patch.previewThumbAssetId }
: null),
...(patch.previewAssetType !== undefined ? { previewAssetType: patch.previewAssetType } : null),
...(patch.previewVideoAutostart !== undefined
? { previewVideoAutostart: patch.previewVideoAutostart }
@@ -342,7 +391,7 @@ export function useProjectState(licenseActive: boolean): readonly [State, Action
if (!licenseActive) {
queueMicrotask(() => {
projectDataEpochRef.current += 1;
setState({ projects: [], project: null, selectedSceneId: null });
setState({ projects: [], project: null, selectedSceneId: null, zipProgress: null });
});
return;
}