Add saved pixel rotation for JPG, PNG and TIFF v0.9.2

This commit is contained in:
dmo committed 2026-07-25 10:42:25 +02:00
1 parent 34602e4ebd
commit ad52a95d4f
9 files changed
+251 -17

No files matched your search

+56 -7
View File
@@ -40,6 +40,7 @@ const elements = {
viewerEmpty: $('#viewer-empty'),
viewerContent: $('#viewer-content'),
preview: $('#photo-preview'),
rotationStatus: $('#rotation-status'),
filename: $('#current-filename'),
filesize: $('#current-filesize'),
position: $('#viewer-position'),
@@ -208,6 +209,7 @@ async function scanFolder() {
fileValues: null,
metadataLoaded: false,
metadataPresent: false,
pendingRotation: 0,
});
}
@@ -447,7 +449,9 @@ async function selectPhoto(index, force = false) {
elements.filename.textContent = photo.name;
elements.filesize.textContent = `${photo.format} · ${formatBytes(file.size)} · modified ${new Date(file.lastModified).toLocaleString()}`;
elements.position.textContent = `${index + 1} / ${state.photos.length}`;
resetView();
state.zoom = 1;
state.rotation = normalizeRotation(photo.pendingRotation || 0);
applyView();
restoreValues(photo.values);
updatePhotoStatus(photo);
updateNavigation();
@@ -626,9 +630,11 @@ async function savePhoto(moveNext) {
payload.append('longitude', values.longitude);
payload.append('location_name', values.locationName);
}
const rotationToSave = supportsPixelRotation(photo) ? normalizeRotation(state.rotation) : 0;
payload.append('rotation', String(rotationToSave));
const saveStarted = performance.now();
setSaveStatus('saving', 'Uploading and rewriting metadata…', `${photo.name} · large RAW files can take a while`);
setSaveStatus('saving', 'Uploading and applying changes…', `${photo.name} · large files can take a while`);
const response = await fetch('/api/process', { method: 'POST', body: payload });
if (!response.ok) {
let message = `Save failed (${response.status}).`;
@@ -637,6 +643,7 @@ async function savePhoto(moveNext) {
}
const responseReceivedAt = performance.now();
const rotationMode = response.headers.get('x-pixel-rotation') || 'none';
const firstByteSeconds = (responseReceivedAt - saveStarted) / 1000;
const writable = await photo.handle.createWritable({ keepExistingData: false });
let timing;
@@ -700,6 +707,8 @@ async function savePhoto(moveNext) {
photo.fileValues = { ...savedValues };
photo.metadataLoaded = true;
photo.metadataPresent = true;
photo.pendingRotation = 0;
state.rotation = 0;
photo.lastSaveTiming = timing;
saveFolderState();
setSaveStatus(
@@ -707,7 +716,10 @@ async function savePhoto(moveNext) {
'Saved in place',
`${formatMetadataSummary(savedValues)} · ${formatSaveTiming(timing)}`,
);
showToast(`Saved ${photo.name}: ${formatSaveTiming(timing)}`);
const rotationNote = rotationMode === 'jpeg-reencoded'
? ' · rotation applied; JPEG re-encoded at high quality'
: rotationMode !== 'none' ? ' · pixel rotation applied' : '';
showToast(`Saved ${photo.name}: ${formatSaveTiming(timing)}${rotationNote}`);
updateProgress();
const oldThumb = state.thumbUrls.get(photo.name);
@@ -1075,8 +1087,45 @@ async function searchAddress() {
function applyView() {
elements.preview.style.transform = `scale(${state.zoom}) rotate(${state.rotation}deg)`;
updateRotationStatus();
}
function normalizeRotation(rotation) {
return ((Number(rotation) % 360) + 360) % 360;
}
function supportsPixelRotation(photo) {
return Boolean(photo && ['JPG', 'PNG', 'TIFF'].includes(photo.format));
}
function updateRotationStatus() {
const rotation = normalizeRotation(state.rotation);
const photo = state.photos[state.currentIndex];
elements.rotationStatus.classList.toggle('hidden', rotation === 0 || !photo);
if (!rotation || !photo) return;
const supported = supportsPixelRotation(photo);
elements.rotationStatus.classList.toggle('preview-only', !supported);
elements.rotationStatus.textContent = supported
? `Rotation ${rotation}° will be applied to the image when saved`
: `Preview rotation ${rotation}° · ${photo.format} files are not altered`;
}
function setPendingRotation(rotation) {
state.rotation = normalizeRotation(rotation);
const photo = state.photos[state.currentIndex];
if (photo) photo.pendingRotation = state.rotation;
applyView();
}
function fitView() {
state.zoom = 1;
applyView();
}
function resetView() {
state.zoom = 1;
setPendingRotation(0);
}
function resetView() { state.zoom = 1; state.rotation = 0; applyView(); }
for (const button of elements.openButtons) button.addEventListener('click', openFolder);
elements.rescan.addEventListener('click', scanFolder);
@@ -1102,9 +1151,9 @@ for (const input of [elements.latitude, elements.longitude]) {
elements.locationName.addEventListener('input', markLocationDirty);
elements.zoomOut.addEventListener('click', () => { state.zoom = Math.max(.25, state.zoom - .15); applyView(); });
elements.zoomIn.addEventListener('click', () => { state.zoom = Math.min(4, state.zoom + .15); applyView(); });
elements.fit.addEventListener('click', resetView);
elements.rotateLeft.addEventListener('click', () => { state.rotation -= 90; applyView(); });
elements.rotateRight.addEventListener('click', () => { state.rotation += 90; applyView(); });
elements.fit.addEventListener('click', fitView);
elements.rotateLeft.addEventListener('click', () => setPendingRotation(state.rotation - 90));
elements.rotateRight.addEventListener('click', () => setPendingRotation(state.rotation + 90));
elements.resetView.addEventListener('click', resetView);
elements.timeMode.addEventListener('change', () => elements.timeField.classList.toggle('hidden', elements.timeMode.value !== 'known'));
elements.description.addEventListener('input', () => { elements.descriptionCount.textContent = `${elements.description.value.length} / 2000`; captureCurrentValues(); });