Einzelbild-Export (24 fps) für Sonnenfinsternis-Simulation hinzufügen
Ein neuer Button rendert die Zeitleiste der aktuell aktiven Ansicht (Weltraum oder Standort) Bild für Bild bei 24 fps und lädt sie als ZIP herunter. Jedes Frame wird einzeln an einen neuen PHP-Endpunkt hochgeladen und in einem Temp-Verzeichnis abgelegt statt im Browser gesammelt zu werden - das hält den Speicherbedarf sowohl client- als auch serverseitig unabhängig von der Frame-Anzahl vorhersagbar (wichtig bei mehreren tausend Frames). Das serverseitige Zippen kommt ohne ZipArchive-Modul aus und liefert die Datei in festen Blöcken statt sie komplett im Speicher zu puffern. Zusätzlich lässt sich der exportierte Zeitbereich per Von/Bis-Uhrzeit einschränken, statt immer die komplette Finsternis zu exportieren. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
session_start();
|
||||
|
||||
const SF_EXPORT_MAX_FRAME_BYTES = 8 * 1024 * 1024;
|
||||
const SF_EXPORT_MAX_INDEX = 99999;
|
||||
const SF_EXPORT_TTL_SECONDS = 3 * 3600;
|
||||
|
||||
function sfExportJson(array $payload, int $status = 200): never
|
||||
{
|
||||
http_response_code($status);
|
||||
header('Content-Type: application/json; charset=UTF-8');
|
||||
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
|
||||
function sfExportRequireLogin(): void
|
||||
{
|
||||
if (!isset($_SESSION['user_id'])) {
|
||||
sfExportJson(['success' => false, 'message' => 'Bitte anmelden.'], 403);
|
||||
}
|
||||
}
|
||||
|
||||
function sfExportRequireToken(): void
|
||||
{
|
||||
$sessionToken = isset($_SESSION['sonnenfinsternis_export_token']) && is_string($_SESSION['sonnenfinsternis_export_token'])
|
||||
? $_SESSION['sonnenfinsternis_export_token']
|
||||
: '';
|
||||
$submittedToken = isset($_REQUEST['token']) ? (string) $_REQUEST['token'] : '';
|
||||
|
||||
if ($sessionToken === '' || $submittedToken === '' || !hash_equals($sessionToken, $submittedToken)) {
|
||||
sfExportJson(['success' => false, 'message' => 'Sicherheitsprüfung fehlgeschlagen. Bitte Seite neu laden.'], 403);
|
||||
}
|
||||
}
|
||||
|
||||
function sfExportDir(string $exportId): string
|
||||
{
|
||||
return sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'sfexport_' . $exportId;
|
||||
}
|
||||
|
||||
function sfExportRequireOwnedExportId(): string
|
||||
{
|
||||
$exportId = isset($_REQUEST['exportId']) ? (string) $_REQUEST['exportId'] : '';
|
||||
if (!preg_match('/^[a-f0-9]{32}$/', $exportId) || !isset($_SESSION['sonnenfinsternis_export_ids'][$exportId])) {
|
||||
sfExportJson(['success' => false, 'message' => 'Unbekannte oder abgelaufene Export-ID.'], 403);
|
||||
}
|
||||
return $exportId;
|
||||
}
|
||||
|
||||
function sfExportDeleteDir(string $dir): void
|
||||
{
|
||||
if (!is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
foreach (glob($dir . DIRECTORY_SEPARATOR . '*') ?: [] as $file) {
|
||||
@unlink($file);
|
||||
}
|
||||
@rmdir($dir);
|
||||
}
|
||||
|
||||
function sfExportSweepStale(): void
|
||||
{
|
||||
$base = sys_get_temp_dir();
|
||||
foreach (glob($base . DIRECTORY_SEPARATOR . 'sfexport_*', GLOB_ONLYDIR) ?: [] as $dir) {
|
||||
$mtime = @filemtime($dir);
|
||||
if ($mtime !== false && (time() - $mtime) > SF_EXPORT_TTL_SECONDS) {
|
||||
sfExportDeleteDir($dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Store-Format-ZIP-Writer (kein ZipArchive-Modul vorausgesetzt) ─────────
|
||||
|
||||
function sfZipLocalHeader(string $name, int $crc, int $size, int $dosTime, int $dosDate): string
|
||||
{
|
||||
return pack(
|
||||
'VvvvvvVVVvv',
|
||||
0x04034b50, 20, 0, 0, $dosTime, $dosDate, $crc, $size, $size, strlen($name), 0
|
||||
) . $name;
|
||||
}
|
||||
|
||||
function sfZipCentralHeader(string $name, int $crc, int $size, int $dosTime, int $dosDate, int $offset): string
|
||||
{
|
||||
return pack(
|
||||
'VvvvvvvVVVvvvvvVV',
|
||||
0x02014b50, 20, 20, 0, 0, $dosTime, $dosDate, $crc, $size, $size,
|
||||
strlen($name), 0, 0, 0, 0, 0, $offset
|
||||
) . $name;
|
||||
}
|
||||
|
||||
function sfBuildZip(array $sourceFiles, string $zipPath): void
|
||||
{
|
||||
$fh = fopen($zipPath, 'wb');
|
||||
if ($fh === false) {
|
||||
throw new RuntimeException('ZIP-Datei konnte nicht angelegt werden.');
|
||||
}
|
||||
|
||||
$dt = getdate();
|
||||
$dosTime = ($dt['hours'] << 11) | ($dt['minutes'] << 5) | intdiv($dt['seconds'], 2);
|
||||
$dosDate = (($dt['year'] - 1980) << 9) | ($dt['mon'] << 5) | $dt['mday'];
|
||||
|
||||
$centralBlob = '';
|
||||
$offset = 0;
|
||||
|
||||
foreach ($sourceFiles as $name => $path) {
|
||||
$data = file_get_contents($path);
|
||||
if ($data === false) {
|
||||
fclose($fh);
|
||||
@unlink($zipPath);
|
||||
throw new RuntimeException('Frame-Datei konnte nicht gelesen werden: ' . $path);
|
||||
}
|
||||
$crc = crc32($data);
|
||||
$size = strlen($data);
|
||||
|
||||
fwrite($fh, sfZipLocalHeader($name, $crc, $size, $dosTime, $dosDate));
|
||||
fwrite($fh, $data);
|
||||
unset($data);
|
||||
|
||||
$centralBlob .= sfZipCentralHeader($name, $crc, $size, $dosTime, $dosDate, $offset);
|
||||
$offset += 30 + strlen($name) + $size;
|
||||
}
|
||||
|
||||
$centralStart = $offset;
|
||||
fwrite($fh, $centralBlob);
|
||||
fwrite($fh, pack(
|
||||
'VvvvvVVv',
|
||||
0x06054b50, 0, 0, count($sourceFiles), count($sourceFiles), strlen($centralBlob), $centralStart, 0
|
||||
));
|
||||
|
||||
fclose($fh);
|
||||
}
|
||||
|
||||
// ─── Routing ────────────────────────────────────────────────────────────────
|
||||
|
||||
sfExportRequireLogin();
|
||||
sfExportRequireToken();
|
||||
|
||||
$action = isset($_REQUEST['action']) ? (string) $_REQUEST['action'] : '';
|
||||
|
||||
if ($action === 'start') {
|
||||
sfExportSweepStale();
|
||||
|
||||
$exportId = bin2hex(random_bytes(16));
|
||||
$dir = sfExportDir($exportId);
|
||||
if (!mkdir($dir, 0700, true) && !is_dir($dir)) {
|
||||
sfExportJson(['success' => false, 'message' => 'Temp-Verzeichnis konnte nicht angelegt werden.'], 500);
|
||||
}
|
||||
$_SESSION['sonnenfinsternis_export_ids'][$exportId] = time();
|
||||
|
||||
sfExportJson(['success' => true, 'exportId' => $exportId]);
|
||||
}
|
||||
|
||||
if ($action === 'frame') {
|
||||
$exportId = sfExportRequireOwnedExportId();
|
||||
|
||||
$index = isset($_REQUEST['index']) ? (string) $_REQUEST['index'] : '';
|
||||
if (!ctype_digit($index) || (int) $index > SF_EXPORT_MAX_INDEX) {
|
||||
sfExportJson(['success' => false, 'message' => 'Ungültiger Frame-Index.'], 400);
|
||||
}
|
||||
|
||||
$raw = file_get_contents('php://input', false, null, 0, SF_EXPORT_MAX_FRAME_BYTES + 1);
|
||||
if ($raw === false || $raw === '' || strlen($raw) > SF_EXPORT_MAX_FRAME_BYTES) {
|
||||
sfExportJson(['success' => false, 'message' => 'Ungültige oder zu große Bilddaten.'], 400);
|
||||
}
|
||||
|
||||
$filename = sfExportDir($exportId) . DIRECTORY_SEPARATOR . sprintf('frame_%05d.png', (int) $index);
|
||||
if (file_put_contents($filename, $raw) === false) {
|
||||
sfExportJson(['success' => false, 'message' => 'Frame konnte nicht gespeichert werden.'], 500);
|
||||
}
|
||||
|
||||
sfExportJson(['success' => true]);
|
||||
}
|
||||
|
||||
if ($action === 'cancel') {
|
||||
$exportId = sfExportRequireOwnedExportId();
|
||||
sfExportDeleteDir(sfExportDir($exportId));
|
||||
unset($_SESSION['sonnenfinsternis_export_ids'][$exportId]);
|
||||
|
||||
sfExportJson(['success' => true]);
|
||||
}
|
||||
|
||||
if ($action === 'finish') {
|
||||
$exportId = sfExportRequireOwnedExportId();
|
||||
$dir = sfExportDir($exportId);
|
||||
|
||||
$frameFiles = glob($dir . DIRECTORY_SEPARATOR . 'frame_*.png') ?: [];
|
||||
sort($frameFiles);
|
||||
if ($frameFiles === []) {
|
||||
sfExportJson(['success' => false, 'message' => 'Keine Frames für diesen Export gefunden.'], 404);
|
||||
}
|
||||
|
||||
$sourceFiles = [];
|
||||
foreach ($frameFiles as $path) {
|
||||
$sourceFiles[basename($path)] = $path;
|
||||
}
|
||||
|
||||
// Das Zippen tausender Frames dauert laenger als PHPs Standard-Zeitlimit
|
||||
// (30s) - hier ist es ein legitimer, einmaliger Vorgang fuer einen
|
||||
// eingeloggten Nutzer, daher wird das Limit fuer diesen Request aufgehoben.
|
||||
set_time_limit(0);
|
||||
|
||||
$zipPath = $dir . DIRECTORY_SEPARATOR . 'export.zip';
|
||||
try {
|
||||
sfBuildZip($sourceFiles, $zipPath);
|
||||
} catch (Throwable $e) {
|
||||
sfExportDeleteDir($dir);
|
||||
unset($_SESSION['sonnenfinsternis_export_ids'][$exportId]);
|
||||
sfExportJson(['success' => false, 'message' => 'ZIP konnte nicht erstellt werden: ' . $e->getMessage()], 500);
|
||||
}
|
||||
|
||||
header('Content-Type: application/zip');
|
||||
header('Content-Disposition: attachment; filename="sonnenfinsternis_export_' . substr($exportId, 0, 8) . '.zip"');
|
||||
header('Content-Length: ' . (string) filesize($zipPath));
|
||||
|
||||
// Aktive Output-Buffer wuerden die komplette (potenziell mehrere GB grosse)
|
||||
// ZIP-Datei im Speicher aufsammeln statt sie durchzureichen - das war die
|
||||
// Ursache des Speicherfehlers. Deshalb Buffer abschalten und die Datei in
|
||||
// festen Bloecken direkt ausgeben, statt readfile() das ini-abhaengige
|
||||
// Verhalten zu ueberlassen.
|
||||
while (ob_get_level() > 0) {
|
||||
ob_end_clean();
|
||||
}
|
||||
|
||||
$out = fopen($zipPath, 'rb');
|
||||
if ($out !== false) {
|
||||
while (!feof($out)) {
|
||||
echo fread($out, 1024 * 1024);
|
||||
flush();
|
||||
}
|
||||
fclose($out);
|
||||
}
|
||||
|
||||
sfExportDeleteDir($dir);
|
||||
unset($_SESSION['sonnenfinsternis_export_ids'][$exportId]);
|
||||
exit;
|
||||
}
|
||||
|
||||
sfExportJson(['success' => false, 'message' => 'Unbekannte Aktion.'], 400);
|
||||
@@ -3880,12 +3880,14 @@ body.solarsystem-page:not(.jupitersystem-page) .solarsystem-stage-card {
|
||||
}
|
||||
|
||||
.solarsystem-input-row input[type="date"],
|
||||
.solarsystem-input-row input[type="time"],
|
||||
.solarsystem-input-row select,
|
||||
.solarsystem-sidebar input[type="range"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.solarsystem-input-row input[type="date"],
|
||||
.solarsystem-input-row input[type="time"],
|
||||
.solarsystem-input-row select,
|
||||
.solarsystem-multiselect {
|
||||
min-height: 2.6rem;
|
||||
@@ -3899,6 +3901,7 @@ body.solarsystem-page:not(.jupitersystem-page) .solarsystem-stage-card {
|
||||
}
|
||||
|
||||
.solarsystem-input-row input[type="date"],
|
||||
.solarsystem-input-row input[type="time"],
|
||||
.solarsystem-input-row select {
|
||||
padding: 0.55rem 0.8rem;
|
||||
}
|
||||
|
||||
+255
-11
@@ -18,6 +18,11 @@ session_start();
|
||||
$loggedIn = isset($_SESSION['user_id']);
|
||||
$pageTitle = 'Sonnenfinsternis – AstroTools';
|
||||
$bodyClass = 'solarsystem-page';
|
||||
|
||||
if (!isset($_SESSION['sonnenfinsternis_export_token']) || !is_string($_SESSION['sonnenfinsternis_export_token'])) {
|
||||
$_SESSION['sonnenfinsternis_export_token'] = bin2hex(random_bytes(16));
|
||||
}
|
||||
$sonnenfinsternisExportToken = $_SESSION['sonnenfinsternis_export_token'];
|
||||
?>
|
||||
<?php require __DIR__ . '/header.php'; ?>
|
||||
<?php
|
||||
@@ -68,6 +73,20 @@ $userAlt = isset($currentLocation['elevation']) ? (float)$currentLocation['eleva
|
||||
<div class="solarsystem-label">Kontaktzeiten (UTC)</div>
|
||||
<table class="data-table" id="eclipseContactTable"></table>
|
||||
</div>
|
||||
|
||||
<div class="solarsystem-sidebar-block">
|
||||
<div class="solarsystem-label">Export</div>
|
||||
<label class="solarsystem-label eclipse-control-label" for="exportFromTime">Von (UTC)</label>
|
||||
<div class="solarsystem-input-row">
|
||||
<input type="time" step="1" id="exportFromTime">
|
||||
</div>
|
||||
<label class="solarsystem-label eclipse-control-label" for="exportToTime">Bis (UTC)</label>
|
||||
<div class="solarsystem-input-row">
|
||||
<input type="time" step="1" id="exportToTime">
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-full" id="btnExportFrames" disabled>Als Einzelbilder exportieren (24 fps)</button>
|
||||
<p class="hint" id="exportStatus"></p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="solarsystem-stage-wrap" id="eclipseStageWrap">
|
||||
@@ -122,6 +141,8 @@ $userAlt = isset($currentLocation['elevation']) ? (float)$currentLocation['eleva
|
||||
<script type="module">
|
||||
const USER_LAT = <?= $userLat !== null ? $userLat : 'null' ?>;
|
||||
const USER_LON = <?= $userLon !== null ? $userLon : 'null' ?>;
|
||||
const EXPORT_TOKEN = <?= json_encode($sonnenfinsternisExportToken, JSON_UNESCAPED_SLASHES) ?>;
|
||||
const EXPORT_ENDPOINT = 'ajax/sonnenfinsternis_export.php';
|
||||
const USER_ALT = <?= $userAlt !== null ? $userAlt : '0' ?>;
|
||||
|
||||
const AE = window.Astronomy;
|
||||
@@ -134,6 +155,8 @@ const D_ME_VIS = 8.0;
|
||||
const EARTH_TILT = 23.44 * Math.PI / 180;
|
||||
const DEG2RAD = Math.PI / 180;
|
||||
const RAD2DEG = 180 / Math.PI;
|
||||
const EXPORT_WIDTH = 1280;
|
||||
const EXPORT_HEIGHT = Math.round(EXPORT_WIDTH * 0.58);
|
||||
const LOCAL_MATCH_HOURS = 18;
|
||||
const D_EM_VIS = 3.1;
|
||||
const SUN_RADIUS_KM = 695700;
|
||||
@@ -149,6 +172,10 @@ const canvasLocal = document.getElementById('eclipseLocalCanvas');
|
||||
const localHint = document.getElementById('eclipseLocalHint');
|
||||
const btnSpaceView = document.getElementById('btnSpaceView');
|
||||
const btnLocalView = document.getElementById('btnLocalView');
|
||||
const btnExportFrames = document.getElementById('btnExportFrames');
|
||||
const exportStatus = document.getElementById('exportStatus');
|
||||
const exportFromTimeInput = document.getElementById('exportFromTime');
|
||||
const exportToTimeInput = document.getElementById('exportToTime');
|
||||
const stageWrap = document.getElementById('eclipseStageWrap');
|
||||
const infoPanel = document.getElementById('eclipseInfoPanel');
|
||||
const infoBody = document.getElementById('eclipseInfoBody');
|
||||
@@ -172,6 +199,10 @@ let currentLocal = null;
|
||||
let localObserver = null;
|
||||
let geometryContacts = null;
|
||||
let viewMode = 'space';
|
||||
let exportInProgress = false;
|
||||
let exportCancelRequested = false;
|
||||
let exportFromAnchor = null;
|
||||
let exportToAnchor = null;
|
||||
let simTime = 0;
|
||||
let playing = false;
|
||||
let playSpeed = 10;
|
||||
@@ -215,7 +246,7 @@ async function init() {
|
||||
|
||||
THREE = await import('./js/three.module.js');
|
||||
|
||||
renderer = new THREE.WebGLRenderer({ canvas: canvas3d, antialias: true, alpha: false });
|
||||
renderer = new THREE.WebGLRenderer({ canvas: canvas3d, antialias: true, alpha: false, preserveDrawingBuffer: true });
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
renderer.toneMappingExposure = 1.15;
|
||||
@@ -498,6 +529,8 @@ function selectEclipse(idx) {
|
||||
playing = false;
|
||||
btnPlay.textContent = '▶ Play';
|
||||
lastRAF = performance.now();
|
||||
btnExportFrames.disabled = false;
|
||||
populateExportRange();
|
||||
}
|
||||
|
||||
function findLocalCircumstances(globalEclipse) {
|
||||
@@ -610,16 +643,6 @@ function drawLocalView() {
|
||||
}
|
||||
localHint.classList.remove('is-visible');
|
||||
|
||||
// Static stars
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const sx = (i * 137.508 + 60) % W;
|
||||
const sy = (i * 79.317 + 40) % H;
|
||||
ctx.fillStyle = `rgba(255,255,255,${(0.15 + (i % 7) * 0.05).toFixed(2)})`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(sx, sy, 0.4 + (i % 5) * 0.15, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
const cx = W / 2, cy = H / 2;
|
||||
const R = Math.min(W, H) * 0.32;
|
||||
const sc = R / geo.sunAngR;
|
||||
@@ -677,6 +700,217 @@ function setView(mode) {
|
||||
if (mode === 'local') drawLocalView();
|
||||
}
|
||||
|
||||
function fmtTimeInput(date) {
|
||||
return date.toISOString().slice(11, 19);
|
||||
}
|
||||
|
||||
// Belegt die Von/Bis-Felder mit dem vollen Simulationszeitraum der aktuell
|
||||
// gewaehlten Finsternis vor. Die Ankerdaten merken sich den UTC-Kalendertag
|
||||
// jedes Feldes, damit beim Auslesen (parseExportRange) aus der reinen Uhrzeit
|
||||
// wieder ein eindeutiges Datum rekonstruiert werden kann.
|
||||
function populateExportRange() {
|
||||
const { min, max } = timeRange();
|
||||
exportFromAnchor = simDateAt(min);
|
||||
exportToAnchor = simDateAt(max);
|
||||
exportFromTimeInput.value = fmtTimeInput(exportFromAnchor);
|
||||
exportToTimeInput.value = fmtTimeInput(exportToAnchor);
|
||||
}
|
||||
|
||||
function combineAnchorAndTime(anchorDate, timeStr) {
|
||||
const [h, m, s] = timeStr.split(':').map(Number);
|
||||
return new Date(Date.UTC(
|
||||
anchorDate.getUTCFullYear(), anchorDate.getUTCMonth(), anchorDate.getUTCDate(),
|
||||
h || 0, m || 0, s || 0
|
||||
));
|
||||
}
|
||||
|
||||
// Liest die vom Nutzer eingestellten Von/Bis-Zeiten, verankert sie am
|
||||
// jeweiligen Kalendertag der Vorbelegung und begrenzt sie auf den technisch
|
||||
// gueltigen Simulationszeitraum der Finsternis.
|
||||
function parseExportRange() {
|
||||
const { min: fullMin, max: fullMax } = timeRange();
|
||||
if (!exportFromAnchor || !exportToAnchor || !exportFromTimeInput.value || !exportToTimeInput.value) {
|
||||
return { min: fullMin, max: fullMax };
|
||||
}
|
||||
|
||||
const fromDate = combineAnchorAndTime(exportFromAnchor, exportFromTimeInput.value);
|
||||
const toDate = combineAnchorAndTime(exportToAnchor, exportToTimeInput.value);
|
||||
const peakMs = eventPeakDate().getTime();
|
||||
|
||||
const min = Math.max(fullMin, Math.min(fullMax, (fromDate.getTime() - peakMs) / 60000));
|
||||
const max = Math.max(fullMin, Math.min(fullMax, (toDate.getTime() - peakMs) / 60000));
|
||||
|
||||
if (max <= min) {
|
||||
throw new Error('Die "Bis"-Zeit muss nach der "Von"-Zeit liegen.');
|
||||
}
|
||||
|
||||
return { min, max };
|
||||
}
|
||||
|
||||
// ─── Einzelbild-Export (24 fps) ─────────────────────────────────────────────
|
||||
// Jedes Frame wird einzeln gerendert und sofort an einen PHP-Endpunkt
|
||||
// hochgeladen, der es in einem Temp-Verzeichnis ablegt - der Browser haelt nie
|
||||
// mehr als ein Bild gleichzeitig im Speicher. Am Ende baut der Server daraus
|
||||
// die ZIP-Datei und liefert sie direkt aus (siehe public/ajax/sonnenfinsternis_export.php).
|
||||
|
||||
// Bei vielen tausend sequentiellen Requests (ein Upload pro Frame) reicht ein
|
||||
// einzelner kurzer Netzwerk-/Server-Haenger (z.B. der PHP-Entwicklungsserver
|
||||
// unter Dauerlast), um fetch() mit "Failed to fetch" abbrechen zu lassen. Echte
|
||||
// Server-Antworten (Auth-/Validierungsfehler) werden dagegen sofort durchgereicht,
|
||||
// da ein erneuter Versuch dort nichts aendern wuerde.
|
||||
async function exportApiCall(action, params, body, maxAttempts = 4) {
|
||||
const qs = new URLSearchParams({ action, token: EXPORT_TOKEN, ...params });
|
||||
let lastErr;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`${EXPORT_ENDPOINT}?${qs}`, {
|
||||
method: 'POST',
|
||||
body: body || null
|
||||
});
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
if (attempt < maxAttempts) await delay(300 * attempt);
|
||||
continue;
|
||||
}
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.success) {
|
||||
throw new Error(data.message || `Server-Fehler (${res.status})`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
throw lastErr;
|
||||
}
|
||||
|
||||
function delay(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function dataUrlToBytes(dataUrl) {
|
||||
const base64 = dataUrl.slice(dataUrl.indexOf(',') + 1);
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
// Rendert Frame `i` und liest ihn als PNG-Bytes aus. canvas.toDataURL() liefert die
|
||||
// Bilddaten synchron als Base64-String zurueck (statt ueber canvas.toBlob(), das
|
||||
// Chromium bei groesseren Bildern intern ueber eine Temp-Datei abwickelt - greift in
|
||||
// dem Moment z.B. ein Virenscanner oder OneDrive darauf zu, schlaegt das Auslesen
|
||||
// mit einem NotReadableError fehl). So wird dieser Umweg komplett vermieden.
|
||||
async function captureFrame(canvas, isSpaceView) {
|
||||
const maxAttempts = 3;
|
||||
let lastErr;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
if (isSpaceView) {
|
||||
if (renderer.getContext().isContextLost()) {
|
||||
throw new Error('WebGL-Kontext verloren (vermutlich Grafikspeicher-Engpass).');
|
||||
}
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
return dataUrlToBytes(canvas.toDataURL('image/png'));
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
if (attempt < maxAttempts) await delay(150 * attempt);
|
||||
}
|
||||
}
|
||||
throw lastErr;
|
||||
}
|
||||
|
||||
async function exportFrames() {
|
||||
if (!currentEclipse || exportInProgress) return;
|
||||
|
||||
let min, max;
|
||||
try {
|
||||
({ min, max } = parseExportRange());
|
||||
} catch (err) {
|
||||
exportStatus.textContent = err.message;
|
||||
return;
|
||||
}
|
||||
|
||||
const realSeconds = (max - min) * 60 / playSpeed;
|
||||
const frameCount = Math.max(1, Math.round(realSeconds * 24));
|
||||
const totalFrames = frameCount + 1;
|
||||
|
||||
const ok = window.confirm(
|
||||
`Export mit ${totalFrames} Einzelbildern bei aktueller Geschwindigkeit (×${playSpeed}) starten?\n` +
|
||||
`Größere Geschwindigkeiten erzeugen weniger Bilder. Fortfahren?`
|
||||
);
|
||||
if (!ok) return;
|
||||
|
||||
const savedSimTime = simTime;
|
||||
const savedPlaying = playing;
|
||||
playing = false;
|
||||
exportInProgress = true;
|
||||
exportCancelRequested = false;
|
||||
btnExportFrames.textContent = 'Abbrechen';
|
||||
btnSpaceView.disabled = true;
|
||||
btnLocalView.disabled = true;
|
||||
|
||||
const activeCanvas = viewMode === 'local' ? canvasLocal : canvas3d;
|
||||
const dtMin = (max - min) / frameCount;
|
||||
|
||||
// Feste, moderate Export-Aufloesung statt der Live-Anzeigegroesse (die je nach
|
||||
// Bildschirm/DPI ein Vielfaches groesser sein kann) - haelt den Speicherbedarf
|
||||
// pro Frame vorhersagbar und begrenzt.
|
||||
renderer.setSize(EXPORT_WIDTH, EXPORT_HEIGHT, false);
|
||||
camera.aspect = EXPORT_WIDTH / EXPORT_HEIGHT;
|
||||
camera.updateProjectionMatrix();
|
||||
canvasLocal.width = EXPORT_WIDTH;
|
||||
canvasLocal.height = EXPORT_HEIGHT;
|
||||
|
||||
let exportId = null;
|
||||
|
||||
try {
|
||||
exportStatus.textContent = 'Starte Export…';
|
||||
({ exportId } = await exportApiCall('start', {}));
|
||||
|
||||
for (let i = 0; i <= frameCount; i++) {
|
||||
if (exportCancelRequested) {
|
||||
await exportApiCall('cancel', { exportId });
|
||||
exportStatus.textContent = 'Export abgebrochen.';
|
||||
return;
|
||||
}
|
||||
simTime = min + i * dtMin;
|
||||
updateSim();
|
||||
let buf;
|
||||
try {
|
||||
buf = await captureFrame(activeCanvas, viewMode === 'space');
|
||||
} catch (err) {
|
||||
throw new Error(`Frame ${i + 1}/${totalFrames}: ${err.message || err}`);
|
||||
}
|
||||
await exportApiCall('frame', { exportId, index: i }, buf);
|
||||
|
||||
exportStatus.textContent = `Frame ${i + 1}/${totalFrames} hochgeladen…`;
|
||||
}
|
||||
|
||||
exportStatus.textContent = 'Erzeuge ZIP-Datei…';
|
||||
const qs = new URLSearchParams({ action: 'finish', token: EXPORT_TOKEN, exportId });
|
||||
window.location.href = `${EXPORT_ENDPOINT}?${qs}`;
|
||||
exportStatus.textContent = `Export abgeschlossen (${totalFrames} Bilder).`;
|
||||
} catch (err) {
|
||||
console.error('Einzelbild-Export fehlgeschlagen', err);
|
||||
exportStatus.textContent = `Export fehlgeschlagen: ${err.message || err}`;
|
||||
if (exportId) {
|
||||
try { await exportApiCall('cancel', { exportId }); } catch (_) { /* bestmoeglich aufraeumen */ }
|
||||
}
|
||||
} finally {
|
||||
exportInProgress = false;
|
||||
exportCancelRequested = false;
|
||||
btnExportFrames.textContent = 'Als Einzelbilder exportieren (24 fps)';
|
||||
btnSpaceView.disabled = false;
|
||||
updateLocalViewAvailability();
|
||||
simTime = savedSimTime;
|
||||
playing = savedPlaying;
|
||||
resizeAll();
|
||||
updateSim();
|
||||
if (viewMode === 'space') renderer.render(scene, camera);
|
||||
lastRAF = performance.now();
|
||||
}
|
||||
}
|
||||
|
||||
function geometryEventKind() {
|
||||
return currentEclipse?.kind || null;
|
||||
}
|
||||
@@ -1176,6 +1410,8 @@ function eventPeakDate() {
|
||||
function renderLoop(now) {
|
||||
requestAnimationFrame(renderLoop);
|
||||
|
||||
if (exportInProgress) return;
|
||||
|
||||
if (playing && currentEclipse) {
|
||||
const dt = (now - lastRAF) / 1000;
|
||||
lastRAF = now;
|
||||
@@ -1465,6 +1701,14 @@ function setupInteraction() {
|
||||
|
||||
btnSpaceView.addEventListener('click', () => setView('space'));
|
||||
btnLocalView.addEventListener('click', () => setView('local'));
|
||||
|
||||
btnExportFrames.addEventListener('click', () => {
|
||||
if (exportInProgress) {
|
||||
exportCancelRequested = true;
|
||||
} else {
|
||||
exportFrames();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
init().catch(err => {
|
||||
|
||||
Reference in New Issue
Block a user