Skip to content

Commit 806a32d

Browse files
authored
fix: video export fixes (#3378)
* fix: video export fixes Signed-off-by: Ihor Dykhta <dikhta.igor@gmail.com> * cleanup; patch on mount Signed-off-by: Ihor Dykhta <dikhta.igor@gmail.com> * tests Signed-off-by: Ihor Dykhta <dikhta.igor@gmail.com> --------- Signed-off-by: Ihor Dykhta <dikhta.igor@gmail.com>
1 parent 0255e95 commit 806a32d

4 files changed

Lines changed: 322 additions & 8 deletions

File tree

src/components/src/modals/export-video-modal.spec.tsx

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,25 @@ jest.mock('@hubble.gl/react', () => ({
1919
KeplerUIContext: MockKeplerUIContext
2020
}));
2121

22+
const mockComputeDeckEffects = jest.fn(() => []);
23+
24+
jest.mock('@kepler.gl/utils', () => {
25+
const actual = jest.requireActual('@kepler.gl/utils');
26+
return {
27+
...actual,
28+
computeDeckEffects: (...args) => mockComputeDeckEffects(...args),
29+
patchDeckRendererForPostProcessing: jest.fn()
30+
};
31+
});
32+
33+
jest.mock('@kepler.gl/effects', () => ({
34+
DeckShadowCompositingEffect: jest.fn().mockImplementation(() => ({
35+
id: 'shadow-compositing-effect',
36+
postRender: jest.fn(),
37+
cleanup: jest.fn()
38+
}))
39+
}));
40+
2241
jest.mock('./hubble-utils', () => ({
2342
getStaticMapProps: jest.fn((_state, _onChange, _token) => ({
2443
latitude: 37.7,
@@ -39,6 +58,7 @@ jest.mock('./hubble-utils', () => ({
3958
}));
4059

4160
import ExportVideoModalFactory from './export-video-modal';
61+
import {DeckShadowCompositingEffect} from '@kepler.gl/effects';
4262

4363
function renderWithThemeLocal(component) {
4464
return render(
@@ -97,6 +117,7 @@ async function renderAndWaitForPanel(props = DEFAULT_PROPS) {
97117
describe('ExportVideoModal', () => {
98118
beforeEach(() => {
99119
jest.clearAllMocks();
120+
mockComputeDeckEffects.mockReturnValue([]);
100121
});
101122

102123
test('renders without crashing', async () => {
@@ -186,4 +207,131 @@ describe('ExportVideoModal', () => {
186207
panelProps.onTripFrameUpdate(42);
187208
expect(DEFAULT_PROPS.visStateActions.setLayerAnimationTime).toHaveBeenCalledWith(42);
188209
});
210+
211+
describe('NO_MAP_ID handling', () => {
212+
const NO_MAP_PROPS = {
213+
...DEFAULT_PROPS,
214+
mapStyle: {
215+
styleType: 'no_map',
216+
mapStyles: {
217+
no_map: {
218+
id: 'no_map',
219+
label: 'No Basemap',
220+
// url is intentionally missing to exercise the data-URI patch
221+
icon: '',
222+
layerGroups: [],
223+
colorMode: 'NONE',
224+
style: {version: 8, sources: {}, layers: []}
225+
}
226+
},
227+
bottomMapStyle: null,
228+
topMapStyle: null
229+
}
230+
};
231+
232+
test('renders without crashing when styleType is NO_MAP_ID', async () => {
233+
const {container} = await renderAndWaitForPanel(NO_MAP_PROPS);
234+
expect(container.querySelector('.export-video-modal')).toBeInTheDocument();
235+
});
236+
237+
test('injects data-URI empty style URL into mapData when NO_MAP_ID entry has no url', async () => {
238+
await renderAndWaitForPanel(NO_MAP_PROPS);
239+
240+
const panelProps = mockExportVideoPanelContainer.mock.calls[0][0];
241+
const noMapEntry = panelProps.mapData.mapStyle.mapStyles.no_map;
242+
243+
expect(noMapEntry.url).toBeDefined();
244+
expect(noMapEntry.url).toMatch(/^data:application\/json,/);
245+
246+
const encoded = noMapEntry.url.replace('data:application/json,', '');
247+
const decoded = JSON.parse(decodeURIComponent(encoded));
248+
expect(decoded).toEqual({version: 8, sources: {}, layers: []});
249+
});
250+
251+
test('preserves original mapStyle when NO_MAP_ID entry already has a url', async () => {
252+
const propsWithUrl = {
253+
...DEFAULT_PROPS,
254+
mapStyle: {
255+
styleType: 'no_map',
256+
mapStyles: {
257+
no_map: {
258+
id: 'no_map',
259+
url: 'https://existing-style.json'
260+
}
261+
},
262+
bottomMapStyle: null,
263+
topMapStyle: null
264+
}
265+
};
266+
267+
await renderAndWaitForPanel(propsWithUrl);
268+
269+
const panelProps = mockExportVideoPanelContainer.mock.calls[0][0];
270+
expect(panelProps.mapData.mapStyle).toBe(propsWithUrl.mapStyle);
271+
});
272+
});
273+
274+
describe('shadow compositing fallback', () => {
275+
const mockEffect = {
276+
id: 'light-shadow-1',
277+
type: 'light-and-shadow',
278+
isEnabled: true,
279+
clone: jest.fn(() => ({...mockEffect, clone: mockEffect.clone}))
280+
};
281+
282+
function propsWithEffects(effects) {
283+
return {
284+
...DEFAULT_PROPS,
285+
visState: {
286+
...DEFAULT_PROPS.visState,
287+
effects,
288+
effectOrder: effects.map(e => e.id)
289+
}
290+
};
291+
}
292+
293+
test('adds DeckShadowCompositingEffect when shadows exist but no postRender effect', async () => {
294+
const shadowDeckEffect = {id: 'lighting', shadow: true};
295+
mockComputeDeckEffects.mockReturnValue([shadowDeckEffect]);
296+
297+
await renderAndWaitForPanel(propsWithEffects([mockEffect]));
298+
299+
const panelProps = mockExportVideoPanelContainer.mock.calls[0][0];
300+
const effects = panelProps.deckProps.effects;
301+
302+
expect(effects.length).toBe(2);
303+
expect(effects[0]).toBe(shadowDeckEffect);
304+
expect(effects[1].id).toBe('shadow-compositing-effect');
305+
expect(DeckShadowCompositingEffect).toHaveBeenCalled();
306+
});
307+
308+
test('does not add DeckShadowCompositingEffect when a postRender effect already exists', async () => {
309+
const shadowDeckEffect = {id: 'lighting', shadow: true};
310+
const postProcessEffect = {id: 'fog', postRender: jest.fn()};
311+
mockComputeDeckEffects.mockReturnValue([shadowDeckEffect, postProcessEffect]);
312+
313+
await renderAndWaitForPanel(propsWithEffects([mockEffect]));
314+
315+
const panelProps = mockExportVideoPanelContainer.mock.calls[0][0];
316+
const effects = panelProps.deckProps.effects;
317+
318+
expect(effects.length).toBe(2);
319+
expect(effects[0]).toBe(shadowDeckEffect);
320+
expect(effects[1]).toBe(postProcessEffect);
321+
expect(effects.every(e => e.id !== 'shadow-compositing-effect')).toBe(true);
322+
});
323+
324+
test('does not add DeckShadowCompositingEffect when no shadow effect exists', async () => {
325+
const nonShadowEffect = {id: 'lighting', shadow: false};
326+
mockComputeDeckEffects.mockReturnValue([nonShadowEffect]);
327+
328+
await renderAndWaitForPanel(propsWithEffects([mockEffect]));
329+
330+
const panelProps = mockExportVideoPanelContainer.mock.calls[0][0];
331+
const effects = panelProps.deckProps.effects;
332+
333+
expect(effects.length).toBe(1);
334+
expect(effects[0]).toBe(nonShadowEffect);
335+
});
336+
});
189337
});

src/components/src/modals/export-video-modal.tsx

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,15 @@
44
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
55
import styled, {ThemeProvider, useTheme} from 'styled-components';
66

7-
import {DEFAULT_MAPBOX_API_URL} from '@kepler.gl/constants';
7+
import {DEFAULT_MAPBOX_API_URL, NO_MAP_ID, EMPTY_MAPBOX_STYLE} from '@kepler.gl/constants';
88
import {FormattedMessage} from '@kepler.gl/localization';
99
import {Viewport, ExportVideo, Effect} from '@kepler.gl/types';
10-
import {onViewPortChange, computeDeckEffects} from '@kepler.gl/utils';
10+
import {
11+
onViewPortChange,
12+
computeDeckEffects,
13+
patchDeckRendererForPostProcessing
14+
} from '@kepler.gl/utils';
15+
import {DeckShadowCompositingEffect} from '@kepler.gl/effects';
1116
import {MapStyle} from '@kepler.gl/reducers';
1217
import {UIStateActions, VisStateActions} from '@kepler.gl/actions';
1318

@@ -191,6 +196,8 @@ const HUBBLE_PANEL_OVERHEAD = 2 * 32 + 24 + 280;
191196
const EXPORT_VIDEO_MODAL_MAX_WIDTH = 1080;
192197
const MODAL_HORIZONTAL_PADDING = 144;
193198

199+
let _shadowCompositingEffect: InstanceType<typeof DeckShadowCompositingEffect> | null = null;
200+
194201
const ExportVideoModalFactory = () => {
195202
const ExportVideoModal: React.FC<ExportVideoModalProps> = ({
196203
mapboxApiAccessToken,
@@ -206,23 +213,53 @@ const ExportVideoModalFactory = () => {
206213
}) => {
207214
const [hubble, setHubble] = useState<HubbleModule | null>(_hubbleModule);
208215

216+
useEffect(() => {
217+
patchDeckRendererForPostProcessing();
218+
}, []);
219+
209220
useEffect(() => {
210221
if (!hubble) {
211222
loadHubble().then(setHubble);
212223
}
213224
}, [hubble]);
214225

226+
useEffect(() => {
227+
return () => {
228+
if (_shadowCompositingEffect) {
229+
_shadowCompositingEffect.cleanup();
230+
_shadowCompositingEffect = null;
231+
}
232+
};
233+
}, []);
234+
215235
const exportVideoWidth = useMemo(() => {
216236
const modalInnerW =
217237
Math.min(EXPORT_VIDEO_MODAL_MAX_WIDTH, containerW * 0.7, containerW) -
218238
MODAL_HORIZONTAL_PADDING;
219239
return Math.max(320, Math.min(540, modalInnerW - HUBBLE_PANEL_OVERHEAD));
220240
}, [containerW]);
221241

222-
const keplerState = useMemo(
223-
() => ({visState, mapState, mapStyle}),
224-
[visState, mapState, mapStyle]
225-
);
242+
const keplerState = useMemo(() => {
243+
if (mapStyle?.styleType === NO_MAP_ID) {
244+
const noMapEntry = mapStyle.mapStyles?.[NO_MAP_ID];
245+
if (noMapEntry && !noMapEntry.url) {
246+
const emptyStyleUrl =
247+
'data:application/json,' + encodeURIComponent(JSON.stringify(EMPTY_MAPBOX_STYLE));
248+
return {
249+
visState,
250+
mapState,
251+
mapStyle: {
252+
...mapStyle,
253+
mapStyles: {
254+
...mapStyle.mapStyles,
255+
[NO_MAP_ID]: {...noMapEntry, url: emptyStyleUrl}
256+
}
257+
}
258+
};
259+
}
260+
}
261+
return {visState, mapState, mapStyle};
262+
}, [visState, mapState, mapStyle]);
226263

227264
const onUpdateMap = useCallback(
228265
(viewPort: Viewport) => {
@@ -243,15 +280,29 @@ const ExportVideoModalFactory = () => {
243280
const deckEffects = useMemo(() => {
244281
if (videoEffects.length === 0) return [];
245282
const effectOrder = visState.effectOrder || videoEffects.map((e: Effect) => e.id);
246-
return computeDeckEffects({
283+
const effects = computeDeckEffects({
247284
visState: {...visState, effects: videoEffects, effectOrder},
248285
mapState,
249286
isExport: true
250287
});
288+
289+
const hasShadow = effects.some((e: any) => e.shadow === true);
290+
const hasPostProcess = effects.some((e: any) => typeof e.postRender === 'function');
291+
if (hasShadow && !hasPostProcess) {
292+
if (!_shadowCompositingEffect) {
293+
_shadowCompositingEffect = new DeckShadowCompositingEffect();
294+
}
295+
effects.push(_shadowCompositingEffect as any);
296+
}
297+
298+
return effects;
251299
}, [visState, videoEffects, mapState]);
252300

253301
const deckPropsWithEffects = useMemo(
254-
() => ({...hubbleDeckGlProps, effects: deckEffects}),
302+
() => ({
303+
...hubbleDeckGlProps,
304+
effects: deckEffects
305+
}),
255306
[hubbleDeckGlProps, deckEffects]
256307
);
257308

src/effects/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@
33

44
export {default as Effect} from './effect';
55
export {createEffect} from './utils';
6+
export {DeckShadowCompositingEffect} from './shader-passes/shadow-compositing';

0 commit comments

Comments
 (0)