Skip to content

Commit

Permalink
Viewer export options (#333)
Browse files Browse the repository at this point in the history
  • Loading branch information
slimbuck authored Dec 12, 2024
1 parent 7eeb716 commit 90d1758
Show file tree
Hide file tree
Showing 15 changed files with 921 additions and 237 deletions.
471 changes: 306 additions & 165 deletions package-lock.json

Large diffs are not rendered by default.

7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,18 +34,19 @@
"@rollup/plugin-terser": "^0.4.4",
"@rollup/plugin-typescript": "^12.1.1",
"@types/wicg-file-system-access": "^2023.10.5",
"@typescript-eslint/eslint-plugin": "^8.17.0",
"@typescript-eslint/parser": "^8.17.0",
"@typescript-eslint/eslint-plugin": "^8.18.0",
"@typescript-eslint/parser": "^8.18.0",
"autoprefixer": "^10.4.20",
"concurrently": "^9.1.0",
"cors": "^2.8.5",
"cross-env": "^7.0.3",
"eslint": "^9.16.0",
"eslint-import-resolver-typescript": "^3.7.0",
"globals": "^15.13.0",
"i18next": "^24.0.5",
"i18next": "^24.1.0",
"i18next-browser-languagedetector": "^8.0.2",
"jest": "^29.7.0",
"jszip": "^3.10.1",
"playcanvas": "^2.3.3",
"postcss": "^8.4.49",
"rollup": "^4.28.1",
Expand Down
1 change: 1 addition & 0 deletions rollup.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ const application = {
}
},
{ src: 'src/manifest.json' },
{ src: 'node_modules/jszip/dist/jszip.js' },
{ src: 'static/images', dest: 'static' },
{ src: 'static/icons', dest: 'static' },
{ src: 'static/lib', dest: 'static' },
Expand Down
100 changes: 59 additions & 41 deletions src/file-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { ElementType } from './element';
import { Events } from './events';
import { Scene } from './scene';
import { Splat } from './splat';
import { WriteFunc, serializePly, serializePlyCompressed, serializeSplat, serializeViewer } from './splat-serialize';
import { WriteFunc, serializePly, serializePlyCompressed, serializeSplat, serializeViewer, ViewerExportOptions } from './splat-serialize';
import { localize } from './ui/localization';

// ts compiler and vscode find this type, but eslint does not
Expand All @@ -22,33 +22,40 @@ interface SceneWriteOptions {
type: ExportType;
filename?: string;
stream?: FileSystemWritableFileStream;
viewerExportOptions?: ViewerExportOptions
}

const filePickerTypes = {
'ply': [{
const filePickerTypes: { [key: string]: FilePickerAcceptType } = {
'ply': {
description: 'Gaussian Splat PLY File',
accept: {
'application/ply': ['.ply']
}
}],
'compressed-ply': [{
},
'compressed-ply': {
description: 'Compressed Gaussian Splat PLY File',
accept: {
'application/ply': ['.ply']
}
}],
'splat': [{
},
'splat': {
description: 'Gaussian Splat File',
accept: {
'application/octet-stream': ['.splat']
}
}],
'viewer': [{
description: 'Viewer App',
},
'htmlViewer': {
description: 'Viewer HTML',
accept: {
'text/html': ['.html']
}
}]
},
'packageViewer': {
description: 'Viewer ZIP',
accept: {
'application/zip': ['.zip']
}
}
};

let fileHandle: FileSystemFileHandle = null;
Expand Down Expand Up @@ -240,7 +247,7 @@ const initFileHandler = (scene: Scene, events: Events, dropTarget: HTMLElement,
const handles = await window.showOpenFilePicker({
id: 'SuperSplatFileOpen',
multiple: true,
types: [filePickerTypes.ply, filePickerTypes.splat] as FilePickerAcceptType[]
types: [filePickerTypes.ply, filePickerTypes.splat]
});
for (let i = 0; i < handles.length; i++) {
const handle = handles[i];
Expand Down Expand Up @@ -287,7 +294,7 @@ const initFileHandler = (scene: Scene, events: Events, dropTarget: HTMLElement,
try {
const handle = await window.showSaveFilePicker({
id: 'SuperSplatFileSave',
types: filePickerTypes.ply as FilePickerAcceptType[],
types: [filePickerTypes.ply],
suggestedName: fileHandle?.name ?? splat.filename ?? 'scene.ply'
});
await events.invoke('scene.write', {
Expand Down Expand Up @@ -315,51 +322,62 @@ const initFileHandler = (scene: Scene, events: Events, dropTarget: HTMLElement,
'viewer': '-viewer.html'
};

const removeExtension = (filename: string) => {
return filename.substring(0, filename.length - path.getExtension(filename).length);
};

const replaceExtension = (filename: string, extension: string) => {
const removeExtension = (filename: string) => {
return filename.substring(0, filename.length - path.getExtension(filename).length);
};
return `${removeExtension(filename)}${extension}`;
};

const splats = getSplats();
const splat = splats[0];
const filename = outputFilename ?? replaceExtension(splat.filename, extensions[type]);
let filename = outputFilename ?? replaceExtension(splat.filename, extensions[type]);

if (window.showSaveFilePicker) {
const hasFilePicker = window.showSaveFilePicker;

let viewerExportOptions;
if (type === 'viewer') {
// show viewer export options
viewerExportOptions = await events.invoke('show.viewerExportPopup', hasFilePicker ? null : filename);

// return if user cancelled
if (!viewerExportOptions) {
return;
}

if (hasFilePicker) {
filename = replaceExtension(filename, viewerExportOptions.type === 'html' ? '.html' : '.zip');
} else {
filename = viewerExportOptions.filename;
}
}

if (hasFilePicker) {
try {
const filePickerType = type === 'viewer' ? (viewerExportOptions.type === 'html' ? filePickerTypes.htmlViewer : filePickerTypes.packageViewer) : filePickerTypes[type];

const fileHandle = await window.showSaveFilePicker({
id: 'SuperSplatFileExport',
types: filePickerTypes[type] as FilePickerAcceptType[],
types: [filePickerType],
suggestedName: filename
});
await events.invoke('scene.write', {
type: type,
stream: await fileHandle.createWritable()
type,
stream: await fileHandle.createWritable(),
viewerExportOptions
});
} catch (error) {
if (error.name !== 'AbortError') {
console.error(error);
}
}
} else {
const result = await events.invoke('showPopup', {
type: 'okcancel',
header: exportType === 'saveAs' ? 'SAVE AS' : 'EXPORT',
message: 'Please enter a filename',
value: filename
});

if (result.action === 'ok') {
await events.invoke('scene.write', {
type: type,
filename: result.value
});
}
await events.invoke('scene.write', { type, filename, viewerExportOptions });
}
});

const writeScene = async (type: ExportType, writeFunc: WriteFunc) => {
const writeScene = async (type: ExportType, writeFunc: WriteFunc, viewerExportOptions?: ViewerExportOptions) => {
const splats = getSplats();
const events = splats[0].scene.events;

Expand All @@ -379,7 +397,7 @@ const initFileHandler = (scene: Scene, events: Events, dropTarget: HTMLElement,
await serializeSplat(options, writeFunc);
break;
case 'viewer':
await serializeViewer(options, writeFunc);
await serializeViewer(splats, viewerExportOptions, writeFunc);
break;
}
};
Expand All @@ -393,7 +411,7 @@ const initFileHandler = (scene: Scene, events: Events, dropTarget: HTMLElement,
setTimeout(resolve);
});

const { stream } = options;
const { stream, filename, type, viewerExportOptions } = options;

if (stream) {
// writer must keep track of written bytes because JS streams don't
Expand All @@ -404,10 +422,10 @@ const initFileHandler = (scene: Scene, events: Events, dropTarget: HTMLElement,
};

await stream.seek(0);
await writeScene(options.type, writeFunc);
await writeScene(type, writeFunc, viewerExportOptions);
await stream.truncate(cursor);
await stream.close();
} else if (options.filename) {
} else if (filename) {
// safari and firefox: concatenate data into single buffer for old-school download
let data: Uint8Array = null;
let cursor = 0;
Expand All @@ -430,8 +448,8 @@ const initFileHandler = (scene: Scene, events: Events, dropTarget: HTMLElement,
cursor += chunk.byteLength;
}
};
await writeScene(options.type, writeFunc);
download(options.filename, (cursor === data.byteLength) ? data : new Uint8Array(data.buffer, 0, cursor));
await writeScene(type, writeFunc, viewerExportOptions);
download(filename, (cursor === data.byteLength) ? data : new Uint8Array(data.buffer, 0, cursor));
}
} catch (error) {
events.invoke('showPopup', {
Expand Down
3 changes: 3 additions & 0 deletions src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@
<meta property="twitter:description" content="SuperSplat is an advanced browser-based editor for manipulating and optimizing 3D Gaussian Splats. It is open source and engine agnostic." />
<meta property="twitter:image" content="https://playcanvas.com/supersplat/editor/static/images/header.webp" />

<!-- jszip -->
<script src="jszip.js"></script>

<!-- Service worker -->
<script>
const sw = navigator.serviceWorker;
Expand Down
57 changes: 41 additions & 16 deletions src/splat-serialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ type SerializeOptions = {
maxSHBands: number;
};

type ViewerExportOptions = {
type: 'html' | 'zip';
shBands: number;
startPosition: 'default' | 'viewport' | 'pose';
backgroundColor: number[];
fov: number;
filename?: string;
};

const generatedByString = `Generated by SuperSplat ${version}`;

const countTotalSplats = (splats: Splat[]) => {
Expand Down Expand Up @@ -826,40 +835,56 @@ const encodeBase64 = (bytes: Uint8Array) => {
return window.btoa(binary);
};

const serializeViewer = async (options: SerializeOptions, write: WriteFunc) => {
const { splats } = options;
const serializeViewer = async (splats: Splat[], options: ViewerExportOptions, write: WriteFunc) => {
const { events } = splats[0].scene;

// create compressed PLY data
let compressedData: Uint8Array;
await serializePlyCompressed(options, (data, finalWrite) => {
await serializePlyCompressed({ splats, maxSHBands: options.shBands }, (data, finalWrite) => {
compressedData = data;
});

const plyModel = encodeBase64(compressedData);

// use camera clear color
const bgClr = events.invoke('bgClr');
const fov = events.invoke('camera.fov');
const pose = events.invoke('camera.poses')?.[0];
const p = pose && pose.position;
const t = pose && pose.target;
const bgClr = options.backgroundColor;
const fov = options.fov;

let pose;
if (options.startPosition === 'pose') {
pose = events.invoke('camera.poses')?.[0];
} else if (options.startPosition === 'viewport') {
pose = events.invoke('camera.getPose');
}

const p = pose?.position;
const t = pose?.target;

const html = ViewerHtmlTemplate
.replace('{{backgroundColor}}', `rgb(${bgClr.r * 255} ${bgClr.g * 255} ${bgClr.b * 255})`)
.replace('{{clearColor}}', `${bgClr.r} ${bgClr.g} ${bgClr.b}`)
.replace('{{backgroundColor}}', `rgb(${bgClr[0] * 255} ${bgClr[1] * 255} ${bgClr[2] * 255})`)
.replace('{{clearColor}}', `${bgClr[0]} ${bgClr[1]} ${bgClr[2]}`)
.replace('{{fov}}', `${fov.toFixed(2)}`)
.replace('{{resetPosition}}', pose ? `new Vec3(${p.x}, ${p.y}, ${p.z})` : 'null')
.replace('{{resetTarget}}', pose ? `new Vec3(${t.x}, ${t.y}, ${t.z})` : 'null')
.replace('{{plyModel}}', plyModel);

await write(new TextEncoder().encode(html), true);
.replace('{{plyModel}}', options.type === 'html' ? `data:application/ply;base64,${encodeBase64(compressedData)}` : 'scene.compressed.ply');

if (options.type === 'html') {
await write(new TextEncoder().encode(html), true);
} else {
/* global JSZip */
// @ts-ignore
const zip = new JSZip();
zip.file('index.html', html);
zip.file('scene.compressed.ply', compressedData);
const result = await zip.generateAsync({ type: 'uint8array' });
await write(result, true);
}
};

export {
WriteFunc,
serializePly,
serializePlyCompressed,
serializeSplat,
serializeViewer
serializeViewer,
SerializeOptions,
ViewerExportOptions
};
2 changes: 1 addition & 1 deletion src/templates/viewer-html-template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ const template = /* html */ `
<pc-asset id="camera-controls" src="https://cdn.jsdelivr.net/npm/[email protected]/scripts/esm/camera-controls.mjs" preload></pc-asset>
<pc-asset id="xr-controllers" src="https://cdn.jsdelivr.net/npm/[email protected]/scripts/esm/xr-controllers.mjs" preload></pc-asset>
<pc-asset id="xr-navigation" src="https://cdn.jsdelivr.net/npm/[email protected]/scripts/esm/xr-navigation.mjs" preload></pc-asset>
<pc-asset id="ply" type="gsplat" src="data:application/ply;base64,{{plyModel}}"></pc-asset>
<pc-asset id="ply" type="gsplat" src="{{plyModel}}"></pc-asset>
<pc-scene>
<!-- Camera (with XR support) -->
<pc-entity name="camera root">
Expand Down
12 changes: 11 additions & 1 deletion src/ui/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { Spinner } from './spinner';
import { Tooltips } from './tooltips';
import { ViewCube } from './view-cube';
import { ViewPanel } from './view-panel';
import { ViewerExportPopup } from './viewer-export-popup';
import { version } from '../../package.json';

class EditorUI {
Expand Down Expand Up @@ -124,11 +125,16 @@ class EditorUI {

// message popup
const popup = new Popup(topContainer);
topContainer.append(popup);

// shortcuts popup
const shortcutsPopup = new ShortcutsPopup();

// export popup
const viewerExportPopup = new ViewerExportPopup(events);

topContainer.append(popup);
topContainer.append(viewerExportPopup);

appContainer.append(editorContainer);
appContainer.append(tooltipsContainer);
appContainer.append(topContainer);
Expand All @@ -148,6 +154,10 @@ class EditorUI {
shortcutsPopup.hidden = false;
});

events.function('show.viewerExportPopup', (filename?: string) => {
return viewerExportPopup.show(filename);
});

events.function('show.about', () => {
return this.popup.show({
type: 'info',
Expand Down
Loading

0 comments on commit 90d1758

Please sign in to comment.