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:
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user