Warning, /firebird/firebird-ng/src/app/pages/main-display/main-display.component.ts is written in an unsupported language. File is not indexed.
0001 import {
0002 Component,
0003 OnInit,
0004 AfterViewInit,
0005 Input,
0006 ViewChild, OnDestroy, TemplateRef, ElementRef, signal
0007 } from '@angular/core';
0008
0009 import {ALL_GROUPS, GeometryService} from '../../services/geometry.service';
0010 import {GameControllerService} from '../../services/game-controller.service';
0011 import {ConfigService} from '../../services/config.service';
0012
0013 import {SceneTreeComponent} from '../../components/scene-tree/scene-tree.component';
0014 import {ShellComponent} from '../../components/shell/shell.component';
0015 import {ToolPanelComponent} from '../../components/tool-panel/tool-panel.component';
0016 import {EventSelectorComponent} from '../../components/event-selector/event-selector.component';
0017 import {GeometryClippingComponent} from '../../components/geometry-clipping/geometry-clipping.component';
0018 import {PhoenixThreeFacade} from "../../utils/phoenix-three-facade";
0019
0020 import {MatSnackBar} from '@angular/material/snack-bar';
0021 import {MatIcon} from '@angular/material/icon';
0022 import { MatIconButton} from '@angular/material/button';
0023 import {MatTooltip} from '@angular/material/tooltip';
0024 import {EventDisplay} from "phoenix-event-display";
0025
0026 import {PerfStatsComponent} from "../../components/perf-stats/perf-stats.component";
0027 import {EventDisplayService} from "../../services/event-display.service";
0028 import {EventTimeControlComponent} from "../../components/event-time-control/event-time-control.component";
0029 import {ServerConfigService} from "../../services/server-config.service";
0030 import {CubeViewportControlComponent} from "../../components/cube-viewport-control/cube-viewport-control.component";
0031 import {LegendWindowComponent} from "../../components/legend-window/legend-window.component";
0032 import {PainterConfigPageComponent} from "../../services/configurator/painter-config-page.component";
0033 import {NgIf} from "@angular/common";
0034 import {TrackPainterConfig} from "../../services/track-painter-config";
0035 import {ObjectRaycastComponent} from "../../components/object-raycast/object-raycast.component";
0036 import {MatProgressSpinner} from "@angular/material/progress-spinner";
0037 import {SceneExportComponent} from "../../components/scene-export/scene-export";
0038 import {AnimationSettingsComponent} from "../../components/animation-settings/animation-settings.component";
0039 import GUI from 'lil-gui';
0040 import {ConfigProperty} from "../../utils/config-property";
0041 import JSZip from 'jszip';
0042
0043
0044
0045 /**
0046 * This MainDisplayComponent:
0047 * - Initializes and uses ThreeService (which sets up scene, camera, controls, etc.).
0048 * - Loads geometry via GeometryService, attaches it to scene.
0049 * - Loads event data (Dex or custom) via DataModelService, builds objects in "EventData" group.
0050 * - Uses EicAnimationsManager for collisions/expansions.
0051 * - Has leftover UI logic for sliders, time stepping, left/right pane toggling, etc.
0052 * - Has *no* references to phoenix-event-display or eventDisplay.
0053 */
0054 @Component({
0055 selector: 'app-main-display',
0056 templateUrl: './main-display.component.html',
0057 styleUrls: ['./main-display.component.scss'],
0058 imports: [
0059 MatIcon,
0060 MatTooltip,
0061 MatIconButton,
0062 SceneTreeComponent,
0063 ShellComponent,
0064 ToolPanelComponent,
0065 EventSelectorComponent,
0066 GeometryClippingComponent,
0067 PerfStatsComponent,
0068 EventTimeControlComponent,
0069 CubeViewportControlComponent,
0070 LegendWindowComponent,
0071 PainterConfigPageComponent,
0072 NgIf,
0073 ObjectRaycastComponent,
0074 MatProgressSpinner,
0075 SceneExportComponent,
0076 AnimationSettingsComponent,
0077 ]
0078 })
0079 export class MainDisplayComponent implements OnInit, AfterViewInit, OnDestroy {
0080 /** Automatically load geometry and event data on init (set to false for tests) */
0081 @Input() isAutoLoadOnInit = true;
0082
0083 @Input()
0084 eventDataImportOptions: string[] = []; // example, if you used them in UI
0085
0086 @ViewChild('displayHeaderControls', {static: true})
0087 displayHeaderControls!: TemplateRef<any>;
0088
0089 @ViewChild('eventDisplay')
0090 eventDisplayDiv!: ElementRef;
0091
0092 // For referencing child components
0093 @ViewChild(ShellComponent)
0094 displayShellComponent!: ShellComponent;
0095
0096 @ViewChild(SceneTreeComponent)
0097 geometryTreeComponent: SceneTreeComponent | null | undefined;
0098
0099 @ViewChild(CubeViewportControlComponent)
0100 private cubeControl!: CubeViewportControlComponent;
0101
0102 geometryUrl = new ConfigProperty('geometry.selectedGeometry', 'https://eic.github.io/epic/artifacts/tgeo/epic_craterlake.root');
0103 geometryFastAndUgly = new ConfigProperty('geometry.FastDefaultMaterial', false);
0104 geometryCutListName = new ConfigProperty('geometry.cutListName', "off");
0105 dexJsonEventSource = new ConfigProperty('events.dexEventsSource', '');
0106 rootEventSource = new ConfigProperty('events.rootEventSource', '');
0107 rootEventRange = new ConfigProperty('events.rootEventRange', '0-5');
0108
0109 message = "";
0110
0111 loaded: boolean = false;
0112
0113 // The geometry group switching index, used in cycleGeometry()
0114 private geometryGroupSwitchingIndex = ALL_GROUPS.length;
0115 currentGeometry: string = 'All';
0116
0117 // UI toggles
0118 isLeftPaneOpen: boolean = false;
0119 isRightPaneOpen: boolean = false;
0120
0121 // Loading indicators
0122 loadingDex = signal(false);
0123 loadingEdm = signal(false);
0124 loadingGeometry = signal(false);
0125
0126 // lil GUI for right panel
0127 lilGui = new GUI();
0128 showGui = false;
0129
0130 // video recording
0131 offlineRecording = signal(false);
0132 offlineProgress = signal('');
0133 private offlineAbort: AbortController | null = null;
0134 capturedFrames: Blob[] = [];
0135 captureOverrideResolution = true;
0136 captureWidth = 3840;
0137 captureHeight = 2160;
0138
0139 // Phoenix API
0140 private facade: PhoenixThreeFacade = new PhoenixThreeFacade(new EventDisplay());
0141
0142 constructor(
0143 private controller: GameControllerService,
0144 private snackBar: MatSnackBar,
0145 public eventDisplay: EventDisplayService,
0146 private config: ConfigService,
0147 private serverConfig: ServerConfigService,
0148 public geomService: GeometryService,
0149 ) {
0150 config.addConfig(this.geometryUrl);
0151 config.addConfig(this.geometryFastAndUgly);
0152 config.addConfig(this.geometryCutListName);
0153 config.addConfig(this.dexJsonEventSource);
0154 config.addConfig(this.rootEventSource);
0155 config.addConfig(this.rootEventRange);
0156 }
0157
0158
0159 async ngOnInit() {
0160 // Initialize the ThreeService scene/camera/renderer/controls
0161 this.eventDisplay.initThree('eventDisplay');
0162
0163 // The facade will be initialized in three.service
0164 this.facade.initializeScene()
0165
0166
0167 this.controller.buttonY.onPress.subscribe((value) => {
0168 if (value) {
0169 // TODO this.cycleGeometry();
0170 }
0171 });
0172 }
0173
0174
0175 // 2) AFTER VIEW INIT => handle resizing with DisplayShell or window
0176 ngAfterViewInit(): void {
0177
0178 if (this.isAutoLoadOnInit) {
0179 // Load JSON based data files
0180 this.initDexEventSource();
0181
0182 // Load Root file based data files
0183 this.initRootData();
0184 }
0185
0186 if (this.displayShellComponent) {
0187 const resizeInvoker = () => {
0188 setTimeout(() => {
0189 this.onRendererElementResize();
0190 }, 100);
0191 };
0192 this.displayShellComponent.onVisibilityChangeLeft.subscribe(resizeInvoker);
0193 this.displayShellComponent.onVisibilityChangeRight.subscribe(resizeInvoker);
0194 this.displayShellComponent.onEndResizeLeft.subscribe(() => this.onRendererElementResize());
0195 this.displayShellComponent.onEndResizeRight.subscribe(() => this.onRendererElementResize());
0196 }
0197
0198 this.initCubeViewportControl();
0199
0200 window.addEventListener('resize', () => {
0201 this.onRendererElementResize();
0202 });
0203
0204 // When sidebar is collapsed/opened, the main container, i.e. #eventDisplay offsetWidth is not yet updated.
0205 // This leads to a not proper resize processing. We add 100ms delay before calling a function
0206 const this_obj = this;
0207 const resizeInvoker = function () {
0208 setTimeout(() => {
0209 this_obj.onRendererElementResize();
0210 }, 100); // 100 milliseconds = 0.1 seconds
0211 };
0212 resizeInvoker();
0213
0214 // Loads the geometry (do it last as it might be long)
0215 if (this.isAutoLoadOnInit) {
0216 this.initGeometry();
0217 }
0218
0219 // Init gui
0220 this.lilGui.add(this, 'cameraToCenter').name('Camera to center');
0221 this.lilGui.add(this, 'cameraToFarForward').name('Camera to Far Forward');
0222 this.lilGui.add(this, 'makeScreenshot').name('Make Screenshot');
0223
0224 this.lilGui.add(this.eventDisplay.three.perspectiveCamera.position, 'x').name('Camera x[mm]').decimals(2).listen();
0225 this.lilGui.add(this.eventDisplay.three.perspectiveCamera.position, 'y').name('Camera y[mm]').decimals(2).listen();
0226 this.lilGui.add(this.eventDisplay.three.perspectiveCamera.position, 'z').name('Camera z[mm]').decimals(2).listen();
0227
0228 this.lilGui.add(this.eventDisplay.three.controls.target, 'x').name("Pivot x[mm]").decimals(1).listen();
0229 this.lilGui.add(this.eventDisplay.three.controls.target, 'y').name("Pivot y[mm]").decimals(1).listen();
0230 this.lilGui.add(this.eventDisplay.three.controls.target, 'z').name("Pivot z[mm]").decimals(1).listen();
0231
0232 this.lilGui.add(this.eventDisplay.three, "showBVHDebug");
0233
0234 // GUI settings
0235 this.lilGui.domElement.style.top = '64px';
0236 this.lilGui.domElement.style.right = '120px';
0237 this.lilGui.domElement.style.display = 'none';
0238
0239 this.mediaSource.addEventListener('sourceopen', this.handleSourceOpen, false);
0240
0241 // Video Capture controls
0242 const videoFolder = this.lilGui.addFolder('Video Capture');
0243 videoFolder.close();
0244 videoFolder.add(this, 'startRecording').name('Start recording');
0245 videoFolder.add(this, 'stopRecording').name('Stop recording');
0246 videoFolder.add(this, 'download').name('Download recording');
0247
0248 // High Resolution Capture controls
0249 const captureFolder = this.lilGui.addFolder('High Resolution Capture');
0250 captureFolder.close();
0251 captureFolder.add(this, 'captureOverrideResolution').name('Override resolution');
0252 captureFolder.add(this, 'captureWidth', 640, 7680, 1).name('Width');
0253 captureFolder.add(this, 'captureHeight', 360, 4320, 1).name('Height');
0254 captureFolder.add(this, 'startOfflineRecording').name('▶ Start Capture');
0255 captureFolder.add(this, 'stopOfflineRecording').name('⏹ Stop');
0256 captureFolder.add(this, 'downloadFrames').name('💾 Download Frames');
0257
0258 }
0259
0260 // 3) UI - Toggling panes
0261 toggleLeftPane() {
0262 this.displayShellComponent?.toggleLeftPane();
0263 this.isLeftPaneOpen = !this.isLeftPaneOpen;
0264 }
0265
0266 toggleRightPane() {
0267 this.displayShellComponent?.toggleRightPane();
0268 this.isRightPaneOpen = !this.isRightPaneOpen;
0269 }
0270
0271 // 4) Method to initialize CubeViewportControl with the existing Three.js objects
0272 private initCubeViewportControl(): void {
0273 const {scene, camera, renderer} = this.eventDisplay.three;
0274 if (this.cubeControl && scene && camera && renderer) {
0275 // Pass the external scene, camera, and renderer to the cube control
0276 this.cubeControl.initWithExternalScene(scene, camera, renderer);
0277 this.cubeControl.gizmo.attachControls(this.eventDisplay.three.controls);
0278 this.cubeControl.gizmo.camera
0279 }
0280
0281 const thisPointer = this;
0282 this.eventDisplay.three.addFrameCallback(() => {
0283 if (thisPointer.cubeControl?.gizmo) {
0284 thisPointer.cubeControl.gizmo.render();
0285 }
0286 });
0287 }
0288
0289
0290 showError(message: string) {
0291 this.snackBar.open(message, 'Dismiss', {
0292 duration: 7000, // Auto-dismiss after X ms
0293 // verticalPosition: 'top', // Place at the top of the screen
0294 panelClass: ['error-snackbar']
0295 });
0296 }
0297
0298
0299 ngOnDestroy(): void {
0300 // Clear the custom controls when leaving the page
0301 }
0302
0303
0304 // Called when we want to recalculate the size of the canvas
0305 private onRendererElementResize() {
0306 let {width, height} = this.displayShellComponent.getMainAreaVisibleDimensions();
0307 console.log(`[RendererResize] New size: ${width}x${height} px`);
0308
0309 // Delegate resizing to ThreeService
0310 this.eventDisplay.three.setSize(width, height);
0311 if (this.cubeControl?.gizmo) {
0312 this.cubeControl.gizmo.update();
0313 }
0314 }
0315
0316 // 10) SCENE TREE / UI
0317 private updateSceneTreeComponent() {
0318 // Example: rename lights
0319 const scene = this.eventDisplay.three.scene;
0320 if (this.geometryTreeComponent) {
0321 this.geometryTreeComponent.refreshSceneTree();
0322 }
0323 }
0324
0325 toggleCameraControls() {
0326 this.showGui = !this.showGui;
0327
0328 // Toggle GUI visibility
0329 const guiElement = this.lilGui.domElement;
0330 if (this.showGui) {
0331 guiElement.style.display = 'block';
0332 } else {
0333 guiElement.style.display = 'none';
0334 }
0335 }
0336
0337
0338 selectedConfigItem: any = null;
0339
0340 onConfigureItemClicked(type: string) {
0341 if (type === 'track') {
0342 this.selectedConfigItem = {
0343 name: 'Track A',
0344 type: 'track',
0345 config: new TrackPainterConfig()
0346 };
0347 }
0348
0349 this.toggleRightPane();
0350 }
0351
0352 private initDexEventSource() {
0353
0354 // We set loadingDex=false to be safe
0355 this.loadingDex.set(false);
0356
0357 let dexUrl = this.config.getConfig<string>('events.dexEventsSource')?.value;
0358
0359 if (!dexUrl || dexUrl.trim().length === 0) {
0360 console.log("[main-display]: No event data source specified. Skip loadDexData.");
0361 }
0362 // Check if we have the same data
0363 else if (this.eventDisplay.lastLoadedDexUrl === dexUrl) {
0364 console.log(`[main-display]: Event data (DEX) url is the same as before: '${dexUrl}', skip loading.`);
0365 }
0366 // Try to load
0367 else {
0368 this.loadingDex.set(true);
0369 this.eventDisplay.loadDexData(dexUrl).catch(error => {
0370 const msg = `Error loading events: ${error}`;
0371 console.error(`[main-display]: ${msg}`);
0372 this.showError(msg);
0373 }).then(() => {
0374 console.log("[main-display]: Event data loaded.");
0375 this.updateSceneTreeComponent();
0376 }).finally(()=>{
0377 this.loadingDex.set(false); // switch off loading indicator
0378 });
0379 }
0380 }
0381
0382
0383 private initRootData() {
0384 let url = (
0385 this.config.getConfig<string>('events.rootEventSource')
0386 ?? this.config.createConfig('events.rootEventSource', '')
0387 ).subject.getValue();
0388
0389 let eventRange = (
0390 this.config.getConfig<string>('events.rootEventRange')
0391 ?? this.config.createConfig('events.rootEventRange', '')
0392 ).subject.getValue();
0393
0394
0395 // Do we have url?
0396 if (!url || url.trim().length === 0) {
0397 console.log("[main-display]: No Edm4Eic source specified. Nothing to load");
0398 return;
0399 }
0400
0401 // Do we have event Range?
0402 if (!eventRange || eventRange.trim().length === 0) {
0403 console.log("[main-display]: Event Range specified. Trying '0', to load the first event");
0404 eventRange = "0";
0405 }
0406
0407 // Check if we have the same data
0408 if (this.eventDisplay.lastLoadedRootUrl === url && this.eventDisplay.lastLoadedRootEventRange === eventRange) {
0409 console.log(`[main-display]: Edm url is the same as before: '${url}', eventRange: '${eventRange}' - skip loading.`);
0410 return;
0411 }
0412
0413 // Try to load
0414 else {
0415 this.loadingEdm.set(true);
0416 this.eventDisplay.loadRootData(url, eventRange).catch(error => {
0417 const msg = `Error loading events: ${error}`;
0418 console.error(`[main-display]: ${msg}`);
0419 this.showError(msg);
0420 }).then(() => {
0421 console.log("[main-display]: Event data loaded.");
0422 this.updateSceneTreeComponent();
0423 }).finally(()=>{
0424 this.loadingEdm.set(false); // switch off loading indicator
0425 });
0426 }
0427 }
0428
0429
0430 /**
0431 * Cancel any ongoing geometry loading operation
0432 */
0433 cancelGeometryLoading(): void {
0434 if (this.geomService.isLoading()) {
0435 console.log("[main-display]: Cancelling geometry loading...");
0436 this.geomService.cancelLoading();
0437 }
0438 }
0439
0440 private initGeometry() {
0441 const url = (this.config.getConfigOrCreate<string>('geometry.selectedGeometry', '')).value;
0442
0443 if (!url || url.trim().length === 0) {
0444 console.log("[main-display]: No geometry specified. Skip loadGeometry ");
0445 return;
0446 }
0447
0448 if (this.eventDisplay.lastLoadedGeometryUrl === url) {
0449 console.log(`[main-display]: Geometry url is the same as before: '${url}', skip loading`);
0450 return;
0451 }
0452
0453 // Cancel any existing geometry load before starting a new one
0454 this.cancelGeometryLoading();
0455
0456 this.loadingGeometry.set(true);
0457 this.eventDisplay.loadGeometry(url)
0458 .then((result) => {
0459 // Only update UI if geometry was actually loaded (not cancelled)
0460 if (result !== undefined) {
0461 this.updateSceneTreeComponent();
0462 console.log("[main-display]: Geometry loaded");
0463 } else {
0464 console.log("[main-display]: Geometry loading was cancelled");
0465 }
0466 })
0467 .catch(error => {
0468 const msg = `Error loading geometry: ${error}`;
0469 console.error(`[main-display]: ${msg}`);
0470 this.showError("Error loading Geometry. Open 'Configure' to change. Press F12->Console for logs");
0471 })
0472 .finally(() => this.loadingGeometry.set(false));
0473 }
0474
0475
0476 animateWithCollision() {
0477 this.eventDisplay.animateWithCollision();
0478 }
0479
0480 cameraToCenter() {
0481 this.eventDisplay.three.camera.position.setX(-3600);
0482 this.eventDisplay.three.camera.position.setY(2900);
0483 this.eventDisplay.three.camera.position.setZ(-4700);
0484 this.eventDisplay.three.controls.target.setX(0);
0485 this.eventDisplay.three.controls.target.setY(0);
0486 this.eventDisplay.three.controls.target.setZ(0);
0487 }
0488
0489 cameraToFarForward() {
0490 this.eventDisplay.three.camera.position.setX(8000);
0491 this.eventDisplay.three.camera.position.setY(7500);
0492 this.eventDisplay.three.camera.position.setZ(40000);
0493 this.eventDisplay.three.controls.target.setX(0);
0494 this.eventDisplay.three.controls.target.setY(0);
0495 this.eventDisplay.three.controls.target.setZ(30000);
0496 }
0497
0498 makeScreenshot() {
0499 const renderer = this.eventDisplay.three.renderer;
0500 // Render one frame to ensure the canvas is up to date
0501 renderer.render(this.eventDisplay.three.scene, this.eventDisplay.three.camera);
0502 // Use toDataURL for broad browser compatibility (works in Firefox)
0503 const dataUrl = renderer.domElement.toDataURL('image/png');
0504 const link = document.createElement('a');
0505 link.href = dataUrl;
0506 link.download = `firebird-screenshot-${Date.now()}.png`;
0507 document.body.appendChild(link);
0508 link.click();
0509 document.body.removeChild(link);
0510 }
0511
0512 mediaSource = new MediaSource();
0513
0514 mediaRecorder?: MediaRecorder;
0515 recordedBlobs = [];
0516 sourceBuffer!:SourceBuffer;
0517 originalSize?:{width:number, height:number}|null;
0518
0519
0520 handleSourceOpen(event: any) {
0521 console.log('MediaSource opened');
0522 this.sourceBuffer = this.mediaSource.addSourceBuffer('video/webm; codecs="vp8"');
0523 console.log('Source buffer: ', this.sourceBuffer);
0524 }
0525
0526 handleDataAvailable(event:any) {
0527 if (event.data && event.data.size > 0) {
0528 // @ts-ignore
0529 this.recordedBlobs.push(event.data);
0530 }
0531 }
0532
0533 handleStop(event: any) {
0534 console.log('Recorder stopped: ', event);
0535 const superBuffer = new Blob(this.recordedBlobs, {type: 'video/webm'});
0536 const url = window.URL.createObjectURL(superBuffer);
0537 const a = document.createElement('a');
0538 a.style.display = 'none';
0539 a.href = url;
0540 a.download = 'recording.webm';
0541 document.body.appendChild(a);
0542 a.click();
0543 window.URL.revokeObjectURL(url);
0544 document.body.removeChild(a);
0545 }
0546
0547 startRecording() {
0548 // Save current size so we can restore later
0549
0550 const stream = this.eventDisplay.three.renderer.domElement.captureStream(60);
0551 this.recordedBlobs = [];
0552
0553 const optionsList = [
0554 { mimeType: 'video/webm;codecs=vp9', videoBitsPerSecond: 200_000_000 }, // 200 Mbps for 4K
0555 { mimeType: 'video/webm;codecs=vp8', videoBitsPerSecond: 200_000_000 },
0556 { mimeType: 'video/webm', videoBitsPerSecond: 200_000_000 },
0557 ];
0558
0559 let recorder: MediaRecorder | null = null;
0560 for (const options of optionsList) {
0561 if (MediaRecorder.isTypeSupported(options.mimeType)) {
0562 try {
0563 recorder = new MediaRecorder(stream, options);
0564 console.log('Created MediaRecorder with', options);
0565 break;
0566 } catch (e) {
0567 console.warn('Failed with options', options, e);
0568 }
0569 }
0570 }
0571
0572 if (!recorder) {
0573 alert('MediaRecorder is not supported by this browser.');
0574 return;
0575 }
0576
0577 this.mediaRecorder = recorder;
0578 this.mediaRecorder.onstop = (event) => this.handleStop(event);
0579 this.mediaRecorder.ondataavailable = (event) => this.handleDataAvailable(event);
0580 this.mediaRecorder.start(100);
0581 }
0582
0583 stopRecording() {
0584 this.mediaRecorder?.stop();
0585 console.log('Recorded Blobs: ', this.recordedBlobs);
0586 }
0587
0588
0589
0590
0591 download() {
0592 const blob = new Blob(this.recordedBlobs, {type: 'video/webm'});
0593 const url = window.URL.createObjectURL(blob);
0594 const a = document.createElement('a');
0595 a.style.display = 'none';
0596 a.href = url;
0597 a.download = 'test.webm';
0598 document.body.appendChild(a);
0599 a.click();
0600 setTimeout(() => {
0601 document.body.removeChild(a);
0602 window.URL.revokeObjectURL(url);
0603 }, 100);
0604 }
0605
0606 async startOfflineRecording() {
0607 if (this.offlineRecording()) return;
0608 this.snackBar.open('Capture may distort video in your browser. Resulting captures will be fine.', 'OK', { duration: 5000 });
0609 this.offlineRecording.set(true);
0610 this.offlineProgress.set('Preparing...');
0611
0612 this.offlineAbort = new AbortController();
0613 let frames: Blob[] = [];
0614
0615 try {
0616 frames = await this.eventDisplay.captureFramesOffline({
0617 overrideResolution: this.captureOverrideResolution,
0618 width: this.captureWidth,
0619 height: this.captureHeight,
0620 eventTimeStep: 0.1,
0621 includeCollision: true,
0622 signal: this.offlineAbort.signal,
0623 onProgress: (current, total) => {
0624 if (total > 0) {
0625 this.offlineProgress.set(`Frame ${current} / ${total}`);
0626 } else {
0627 this.offlineProgress.set(`Frame ${current} (collision phase)`);
0628 }
0629 },
0630 });
0631
0632 if (this.offlineAbort.signal.aborted) {
0633 this.offlineProgress.set(`Stopped. Captured ${frames.length} frames.`);
0634 }
0635 } catch (err) {
0636 console.error('Offline recording failed:', err);
0637 this.showError(`Offline recording failed: ${err}`);
0638 this.offlineRecording.set(false);
0639 return;
0640 }
0641
0642 // Store frames so we can download later even after stopping
0643 this.capturedFrames = frames;
0644 this.offlineRecording.set(false);
0645
0646 if (frames.length > 0) {
0647 this.offlineProgress.set(`${frames.length} frames ready. Use "Download frames" button.`);
0648 }
0649 }
0650
0651 stopOfflineRecording() {
0652 this.offlineAbort?.abort();
0653 }
0654
0655 async downloadFrames() {
0656 if (this.capturedFrames.length === 0) {
0657 this.showError('No frames captured yet.');
0658 return;
0659 }
0660
0661 const total = this.capturedFrames.length;
0662
0663 // Prefer File System Access API (Chromium) — writes each PNG directly,
0664 // no memory spike at all. Falls back to chunked ZIPs for Firefox/Safari.
0665 if ('showDirectoryPicker' in window) {
0666 try {
0667 await this.downloadFramesToDirectory(total);
0668 return;
0669 } catch (err: any) {
0670 if (err.name === 'AbortError') return; // user cancelled picker
0671 console.warn('Directory picker failed, falling back to chunked ZIPs:', err);
0672 }
0673 }
0674
0675 await this.downloadFramesAsChunkedZips(total);
0676 }
0677
0678 /** Write each frame as an individual PNG into a user-chosen folder. */
0679 private async downloadFramesToDirectory(total: number) {
0680 this.offlineProgress.set('Choose a folder to save frames...');
0681 const dirHandle = await (window as any).showDirectoryPicker({ mode: 'readwrite' });
0682
0683 const yieldToUI = () => new Promise(resolve => setTimeout(resolve, 0));
0684 this.snackBar.open(`Saving ${total} frames to folder...`, undefined, { duration: 0 });
0685
0686 for (let i = 0; i < total; i++) {
0687 const name = `frame_${String(i).padStart(6, '0')}.png`;
0688 const fileHandle = await dirHandle.getFileHandle(name, { create: true });
0689 const writable = await fileHandle.createWritable();
0690 await writable.write(this.capturedFrames[i]);
0691 await writable.close();
0692
0693 if (i % 10 === 0) {
0694 this.offlineProgress.set(`Saving frame ${i + 1} / ${total}...`);
0695 await yieldToUI();
0696 }
0697 }
0698
0699 this.offlineProgress.set(`Done! Saved ${total} frames to folder.`);
0700 this.snackBar.open(`Saved ${total} frames`, 'OK', { duration: 5000 });
0701 }
0702
0703 /**
0704 * Fallback: split frames into small ZIPs (~200 frames each) so no single
0705 * ZIP blob exceeds browser ArrayBuffer limits.
0706 */
0707 private async downloadFramesAsChunkedZips(total: number) {
0708 const CHUNK_SIZE = 200;
0709 const numChunks = Math.ceil(total / CHUNK_SIZE);
0710
0711 this.snackBar.open(
0712 `Saving ${total} frames in ${numChunks} ZIP file(s)...`, undefined, { duration: 0 }
0713 );
0714
0715 const yieldToUI = () => new Promise(resolve => setTimeout(resolve, 0));
0716
0717 for (let chunk = 0; chunk < numChunks; chunk++) {
0718 const start = chunk * CHUNK_SIZE;
0719 const end = Math.min(start + CHUNK_SIZE, total);
0720
0721 this.offlineProgress.set(
0722 `ZIP ${chunk + 1}/${numChunks}: packing frames ${start}–${end - 1}...`
0723 );
0724 await yieldToUI();
0725
0726 const zip = new JSZip();
0727 const folder = zip.folder('frames')!;
0728 for (let i = start; i < end; i++) {
0729 folder.file(`frame_${String(i).padStart(6, '0')}.png`, this.capturedFrames[i]);
0730 }
0731
0732 const blob = await zip.generateAsync(
0733 { type: 'blob', compression: 'STORE' },
0734 (meta) => this.offlineProgress.set(
0735 `ZIP ${chunk + 1}/${numChunks}: ${meta.percent.toFixed(0)}%`
0736 )
0737 );
0738
0739 const url = URL.createObjectURL(blob);
0740 const a = document.createElement('a');
0741 a.href = url;
0742 a.download = numChunks === 1
0743 ? 'frames_4k.zip'
0744 : `frames_4k_part${String(chunk + 1).padStart(2, '0')}.zip`;
0745 a.click();
0746
0747 // Give browser time to start the download before revoking
0748 await new Promise(resolve => setTimeout(resolve, 1000));
0749 URL.revokeObjectURL(url);
0750 await yieldToUI();
0751 }
0752
0753 this.offlineProgress.set(`Done! Downloaded ${total} frames in ${numChunks} ZIP(s).`);
0754 this.snackBar.open(`Downloaded ${total} frames`, 'OK', { duration: 5000 });
0755 }
0756 }