Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Viewer export options #333

Merged
merged 4 commits into from
Dec 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading