Ver Fonte

fix(archives): the timelapse player fits the screen with a portrait video (#3281)

- The viewer dialog is capped at the window height and the video at
  the window height minus the header and controls (object-contain,
  letterboxed), so a 9:16 timelapse from a rotated camera no longer
  pushes the controls off screen.
- The editor's video is capped at 55dvh the same way.
- The viewer's Edit, Download and Speed labels and skip tooltips are
  translated (timelapseViewer.speed added in all locales).
maziggy há 1 dia atrás
pai
commit
b602ea21fb
51 ficheiros alterados com 117 adições e 13 exclusões
  1. 3 0
      CHANGELOG.md
  2. 8 0
      frontend/src/__tests__/components/TimelapseEditorModalJob.test.tsx
  3. 66 0
      frontend/src/__tests__/components/TimelapseViewer.test.tsx
  4. 3 1
      frontend/src/components/TimelapseEditorModal.tsx
  5. 17 7
      frontend/src/components/TimelapseViewer.tsx
  6. 1 0
      frontend/src/i18n/locales/de.ts
  7. 1 0
      frontend/src/i18n/locales/en.ts
  8. 1 0
      frontend/src/i18n/locales/es.ts
  9. 1 0
      frontend/src/i18n/locales/fr.ts
  10. 1 0
      frontend/src/i18n/locales/it.ts
  11. 1 0
      frontend/src/i18n/locales/ja.ts
  12. 1 0
      frontend/src/i18n/locales/ko.ts
  13. 1 0
      frontend/src/i18n/locales/nl.ts
  14. 1 0
      frontend/src/i18n/locales/pt-BR.ts
  15. 1 0
      frontend/src/i18n/locales/ru.ts
  16. 1 0
      frontend/src/i18n/locales/sv.ts
  17. 1 0
      frontend/src/i18n/locales/tr.ts
  18. 1 0
      frontend/src/i18n/locales/uk.ts
  19. 1 0
      frontend/src/i18n/locales/zh-CN.ts
  20. 1 0
      frontend/src/i18n/locales/zh-TW.ts
  21. 0 0
      static/assets/ArchivesPage-8vBHyzCo.js
  22. 0 0
      static/assets/ArchivesPage-bnpiMQ83.js
  23. 0 0
      static/assets/CameraPage-CIl2Zkoz.js
  24. 0 0
      static/assets/FileManagerPage-DCVa_1EV.js
  25. 0 0
      static/assets/InventoryPage-CwNNmdnM.js
  26. 0 0
      static/assets/Layout-CejbX0wr.js
  27. 0 0
      static/assets/ModelSourcesPage-9Ky0Kxm4.js
  28. 0 0
      static/assets/PhotoGalleryModal-DZJosi1Y.js
  29. 0 0
      static/assets/PrintersPage-ScyysW61.js
  30. 0 0
      static/assets/SettingsPage-HnuOTVll.js
  31. 0 0
      static/assets/SliceModal-l5ksL4WM.js
  32. 1 1
      static/assets/SpoolBuddySettingsPage-B64al-SO.js
  33. 0 0
      static/assets/de-Y07IYI18.js
  34. 0 0
      static/assets/es-BTAbh73m.js
  35. 0 0
      static/assets/fr-DOkDc6Df.js
  36. 0 0
      static/assets/index-BYDR9bb5.js
  37. 1 0
      static/assets/index-CLHFMRO6.css
  38. 0 1
      static/assets/index-Cs9GKX3n.css
  39. 0 0
      static/assets/it-JnxaM7GA.js
  40. 0 0
      static/assets/ja-BBu9zMeB.js
  41. 0 0
      static/assets/ko-Boo838yz.js
  42. 0 0
      static/assets/locationSensorDefaults-CBsK44tA.js
  43. 0 0
      static/assets/nl-_09VLpWC.js
  44. 0 0
      static/assets/pt-BR-BRcE6p5_.js
  45. 0 0
      static/assets/ru-JOmPvHRx.js
  46. 0 0
      static/assets/sv-BrgzaxpK.js
  47. 0 0
      static/assets/tr-BJpf6Z8C.js
  48. 0 0
      static/assets/uk-Cf9gRJfz.js
  49. 0 0
      static/assets/zh-CN-CCek_yCc.js
  50. 0 0
      static/assets/zh-TW-pdp9uINk.js
  51. 3 3
      static/index.html

+ 3 - 0
CHANGELOG.md

@@ -174,6 +174,9 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **The timelapse player fits the screen with a portrait video (#3281, reported by @BurgerKerman)** — A timelapse from a camera turned to portrait with the External Camera **Rotation** setting came out taller than a landscape screen. The player grew with it, so the bottom of the video and the play, seek and speed controls were off screen and could not be reached.
+  - **Now:** the player is never taller than the window, and the video is scaled down to leave room for the controls, with black bars at the sides. A landscape timelapse is only scaled down when the window is too short for it. In the timelapse editor the video takes at most a little over half the window's height.
+  - **Translated:** the player's Edit, Download and Speed labels and the skip buttons' tooltips were always in English.
 - **A color set on the printer's touchscreen is named after the Bambu color it is (#3279, reported by @Lioncat55)** — A slot without an RFID tag reports a hex a step or two off Bambu Studio's, for example `#5A657B` for **Blue Grey** (`#5B6579`). No catalog entry matched it exactly, so the slot was called by a general name such as "Blue", while the printer's screen showed a gray.
   - **Now:** when no catalog entry has exactly that hex, the color is named after the closest **Bambu Lab** color in the Color Catalog, as long as it is close enough to be the same color.
   - **Within the slot's own range:** the range comes from the slot's material or, without RFID, from the filament preset picked on the printer's screen. A PETG Translucent slot is named from PETG Translucent colors, and a range Bambu makes never borrows a nearby color's name from another range. Only a filament outside Bambu's ranges is compared with all of them.

+ 8 - 0
frontend/src/__tests__/components/TimelapseEditorModalJob.test.tsx

@@ -62,6 +62,14 @@ describe('TimelapseEditorModal re-encode job', () => {
     vi.mocked(api.cancelTimelapseJob).mockReset().mockResolvedValue(job({ status: 'cancelled' }));
   });
 
+  it('keeps a portrait video short enough to leave the controls on screen (#3281)', async () => {
+    open();
+    await screen.findByRole('button', { name: /save/i });
+    const video = document.querySelector('video');
+    expect(video?.className).toMatch(/max-h-\[55dvh\]/);
+    expect(video?.className).toContain('object-contain');
+  });
+
   it('Save starts the job, shows its progress and closes with the result', async () => {
     vi.mocked(api.getTimelapseJob)
       .mockResolvedValueOnce(null)

+ 66 - 0
frontend/src/__tests__/components/TimelapseViewer.test.tsx

@@ -0,0 +1,66 @@
+/**
+ * The timelapse player fits the screen whatever the video's shape (#3281).
+ *
+ * The video was sized by the width of the dialog alone, and the dialog had no
+ * height limit. A portrait timelapse -- a 16:9 camera turned to 9:16 with the
+ * External Camera rotation -- came out taller than a landscape screen, and
+ * pushed the play, seek and speed controls below the bottom edge where they
+ * could not be reached.
+ *
+ * jsdom does no layout, so these pin the limits that make it fit: the dialog
+ * is never taller than the screen, and the video leaves room for the header
+ * and the controls under it.
+ */
+
+import { describe, it, expect, afterEach, beforeAll, vi } from 'vitest';
+import { screen } from '@testing-library/react';
+import { render } from '../utils';
+import i18n from '../../i18n';
+import { TimelapseViewer } from '../../components/TimelapseViewer';
+
+function renderViewer() {
+  return render(
+    <TimelapseViewer src="/timelapse.mp4" title="Bracket - Timelapse" downloadFilename="bracket.mp4" archiveId={7} onClose={vi.fn()} />,
+  );
+}
+
+describe('TimelapseViewer', () => {
+  beforeAll(() => {
+    // jsdom has no media playback; autoPlay must not throw.
+    vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined);
+    vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => {});
+  });
+
+  afterEach(async () => {
+    await i18n.changeLanguage('en');
+  });
+
+  it('keeps the dialog within the height of the screen', () => {
+    renderViewer();
+    const dialog = screen.getByTestId('timelapse-viewer');
+    expect(dialog.className).toMatch(/max-h-\[calc\(100dvh-2rem\)\]/);
+    expect(dialog.className).toContain('overflow-y-auto');
+  });
+
+  it('limits the video height and fits it inside that box', () => {
+    const { container } = renderViewer();
+    const video = container.querySelector('video');
+    expect(video).not.toBeNull();
+    expect(video!.className).toMatch(/max-h-\[calc\(100dvh-[0-9.]+rem\)\]/);
+    expect(video!.className).toContain('object-contain');
+  });
+
+  it('labels its buttons in the language of the page', async () => {
+    await i18n.loadLanguages('de');
+    await i18n.changeLanguage('de');
+    renderViewer();
+
+    expect(screen.getByText(i18n.t('common.edit'))).toBeInTheDocument();
+    expect(screen.getByText(i18n.t('common.download'))).toBeInTheDocument();
+    expect(screen.getByText(i18n.t('timelapseViewer.speed'))).toBeInTheDocument();
+    expect(screen.getByTitle(i18n.t('timelapseViewer.skipBack5s'))).toBeInTheDocument();
+    expect(screen.getByTitle(i18n.t('timelapseViewer.skipForward5s'))).toBeInTheDocument();
+    // German really was loaded, so the checks above did not pass on English.
+    expect(i18n.t('common.download')).not.toBe('Download');
+  });
+});

+ 3 - 1
frontend/src/components/TimelapseEditorModal.tsx

@@ -337,7 +337,9 @@ export function TimelapseEditorModal({
             <video
               ref={videoRef}
               src={timelapseSrc}
-              className="w-full rounded-lg bg-black"
+              // Short enough to leave the trim and speed controls on screen with
+              // a portrait timelapse (#3281); object-contain letterboxes it.
+              className="w-full max-h-[55dvh] object-contain rounded-lg bg-black"
               onClick={togglePlay}
               muted={!!audioUrl}
             />

+ 17 - 7
frontend/src/components/TimelapseViewer.tsx

@@ -1,4 +1,5 @@
 import { useState, useRef, useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
 import { X, Download, Film, Play, Pause, SkipBack, SkipForward, Pencil } from 'lucide-react';
 import { Button } from './Button';
 import { TimelapseEditorModal } from './TimelapseEditorModal';
@@ -23,6 +24,7 @@ export function TimelapseViewer({
   onClose,
   onEdit,
 }: TimelapseViewerProps) {
+  const { t } = useTranslation();
   const videoRef = useRef<HTMLVideoElement>(null);
   const [isPlaying, setIsPlaying] = useState(true);
   const [playbackRate, setPlaybackRate] = useState(1); // Default to 1x
@@ -107,7 +109,13 @@ export function TimelapseViewer({
 
   return (
     <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80">
-      <div className="relative bg-bambu-dark-secondary rounded-xl max-w-4xl w-full mx-4 overflow-hidden">
+      {/* Never taller than the screen, so the controls stay reachable whatever
+          the video's shape -- a portrait timelapse from a rotated camera
+          pushed them off the bottom (#3281). */}
+      <div
+        data-testid="timelapse-viewer"
+        className="relative bg-bambu-dark-secondary rounded-xl max-w-4xl w-full mx-4 max-h-[calc(100dvh-2rem)] overflow-y-auto"
+      >
         {/* Header */}
         <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
           <h3 className="text-lg font-semibold text-white flex items-center gap-2">
@@ -118,12 +126,12 @@ export function TimelapseViewer({
             {archiveId && (
               <Button variant="secondary" size="sm" onClick={() => setShowEditor(true)}>
                 <Pencil className="w-4 h-4" />
-                Edit
+                {t('common.edit')}
               </Button>
             )}
             <Button variant="secondary" size="sm" onClick={handleDownload}>
               <Download className="w-4 h-4" />
-              Download
+              {t('common.download')}
             </Button>
             <button
               onClick={onClose}
@@ -140,7 +148,9 @@ export function TimelapseViewer({
             ref={videoRef}
             src={src}
             autoPlay
-            className="w-full rounded-lg"
+            // Leaves room for the header and the controls below it (about
+            // 12rem together); object-contain letterboxes what is left over.
+            className="w-full max-h-[calc(100dvh-14rem)] object-contain bg-black rounded-lg"
             onClick={togglePlay}
           />
 
@@ -174,7 +184,7 @@ export function TimelapseViewer({
                 <button
                   onClick={skipBackward}
                   className="p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors"
-                  title="Skip back 5s"
+                  title={t('timelapseViewer.skipBack5s')}
                 >
                   <SkipBack className="w-5 h-5 text-bambu-gray" />
                 </button>
@@ -191,7 +201,7 @@ export function TimelapseViewer({
                 <button
                   onClick={skipForward}
                   className="p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors"
-                  title="Skip forward 5s"
+                  title={t('timelapseViewer.skipForward5s')}
                 >
                   <SkipForward className="w-5 h-5 text-bambu-gray" />
                 </button>
@@ -199,7 +209,7 @@ export function TimelapseViewer({
 
               {/* Right: Speed control */}
               <div className="flex items-center gap-2">
-                <span className="text-sm text-bambu-gray">Speed:</span>
+                <span className="text-sm text-bambu-gray whitespace-nowrap">{t('timelapseViewer.speed')}</span>
                 <div className="flex gap-1">
                   {SPEED_OPTIONS.map((speed) => (
                     <button

+ 1 - 0
frontend/src/i18n/locales/de.ts

@@ -7210,6 +7210,7 @@ export default {
 
   // Timelapse Viewer
   timelapseViewer: {
+    speed: 'Geschwindigkeit:',
     skipBack5s: '5s zurückspringen',
     skipForward5s: '5s vorspringen',
   },

+ 1 - 0
frontend/src/i18n/locales/en.ts

@@ -7263,6 +7263,7 @@ export default {
 
   // Timelapse Viewer
   timelapseViewer: {
+    speed: 'Speed:',
     skipBack5s: 'Skip back 5s',
     skipForward5s: 'Skip forward 5s',
   },

+ 1 - 0
frontend/src/i18n/locales/es.ts

@@ -7218,6 +7218,7 @@ export default {
 
   // Timelapse Viewer
   timelapseViewer: {
+    speed: 'Velocidad:',
     skipBack5s: 'Retroceder 5 s',
     skipForward5s: 'Avanzar 5 s',
   },

+ 1 - 0
frontend/src/i18n/locales/fr.ts

@@ -7198,6 +7198,7 @@ export default {
 
   // Timelapse Viewer
   timelapseViewer: {
+    speed: 'Vitesse :',
     skipBack5s: '-5s',
     skipForward5s: '+5s',
   },

+ 1 - 0
frontend/src/i18n/locales/it.ts

@@ -7197,6 +7197,7 @@ export default {
 
   // Timelapse Viewer
   timelapseViewer: {
+    speed: 'Velocità:',
     skipBack5s: 'Indietro 5s',
     skipForward5s: 'Avanti 5s',
   },

+ 1 - 0
frontend/src/i18n/locales/ja.ts

@@ -7211,6 +7211,7 @@ export default {
 
   // Timelapse Viewer
   timelapseViewer: {
+    speed: '速度:',
     skipBack5s: '5秒戻る',
     skipForward5s: '5秒進む',
   },

+ 1 - 0
frontend/src/i18n/locales/ko.ts

@@ -6872,6 +6872,7 @@ export default {
     dragToResize: '드래그하여 크기 조정'
   },
   timelapseViewer: {
+    speed: '속도:',
     skipBack5s: '5초 뒤로',
     skipForward5s: '5초 앞으로'
   },

+ 1 - 0
frontend/src/i18n/locales/nl.ts

@@ -7261,6 +7261,7 @@ export default {
 
   // Timelapse Viewer
   timelapseViewer: {
+    speed: 'Snelheid:',
     skipBack5s: '5 sec terug',
     skipForward5s: '5 sec vooruit',
   },

+ 1 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -7197,6 +7197,7 @@ export default {
 
   // Timelapse Viewer
   timelapseViewer: {
+    speed: 'Velocidade:',
     skipBack5s: 'Voltar 5s',
     skipForward5s: 'Avançar 5s',
   },

+ 1 - 0
frontend/src/i18n/locales/ru.ts

@@ -6859,6 +6859,7 @@ export default {
     dragToResize: "Перетащите, чтобы изменить размер",
   },
   timelapseViewer: {
+    speed: 'Скорость:',
     skipBack5s: "Назад на 5 с",
     skipForward5s: "Вперёд на 5 с",
   },

+ 1 - 0
frontend/src/i18n/locales/sv.ts

@@ -7259,6 +7259,7 @@ errors: {
 
   // Timelapse Viewer
   timelapseViewer: {
+    speed: 'Hastighet:',
     skipBack5s: 'Hoppa bakåt 5s',
     skipForward5s: 'Hoppa framåt 5s',
   },

+ 1 - 0
frontend/src/i18n/locales/tr.ts

@@ -7154,6 +7154,7 @@ export default {
 
   // Zaman Atlamalı Video Görüntüleyici
   timelapseViewer: {
+    speed: 'Hız:',
     skipBack5s: '5 sn geri al',
     skipForward5s: '5 sn ileri al',
   },

+ 1 - 0
frontend/src/i18n/locales/uk.ts

@@ -7251,6 +7251,7 @@ export default {
 
   // Timelapse Viewer
   timelapseViewer: {
+    speed: 'Швидкість:',
     skipBack5s: "Перейти на 5 секунд назад",
     skipForward5s: "Перейти вперед на 5 с",
   },

+ 1 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -7195,6 +7195,7 @@ export default {
 
   // Timelapse Viewer
   timelapseViewer: {
+    speed: '速度:',
     skipBack5s: '后退 5 秒',
     skipForward5s: '前进 5 秒',
   },

+ 1 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -7195,6 +7195,7 @@ export default {
 
   // Timelapse Viewer
   timelapseViewer: {
+    speed: '速度:',
     skipBack5s: '後退 5 秒',
     skipForward5s: '前進 5 秒',
   },

Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
static/assets/ArchivesPage-8vBHyzCo.js


Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
static/assets/ArchivesPage-bnpiMQ83.js


Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
static/assets/CameraPage-CIl2Zkoz.js


Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
static/assets/FileManagerPage-DCVa_1EV.js


Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
static/assets/InventoryPage-CwNNmdnM.js


Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
static/assets/Layout-CejbX0wr.js


Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
static/assets/ModelSourcesPage-9Ky0Kxm4.js


Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
static/assets/PhotoGalleryModal-DZJosi1Y.js


Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
static/assets/PrintersPage-ScyysW61.js


Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
static/assets/SettingsPage-HnuOTVll.js


Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
static/assets/SliceModal-l5ksL4WM.js


Diff do ficheiro suprimidas por serem muito extensas
+ 1 - 1
static/assets/SpoolBuddySettingsPage-B64al-SO.js


Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
static/assets/de-Y07IYI18.js


Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
static/assets/es-BTAbh73m.js


Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
static/assets/fr-DOkDc6Df.js


Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
static/assets/index-BYDR9bb5.js


Diff do ficheiro suprimidas por serem muito extensas
+ 1 - 0
static/assets/index-CLHFMRO6.css


Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 1
static/assets/index-Cs9GKX3n.css


Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
static/assets/it-JnxaM7GA.js


Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
static/assets/ja-BBu9zMeB.js


Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
static/assets/ko-Boo838yz.js


Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
static/assets/locationSensorDefaults-CBsK44tA.js


Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
static/assets/nl-_09VLpWC.js


Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
static/assets/pt-BR-BRcE6p5_.js


Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
static/assets/ru-JOmPvHRx.js


Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
static/assets/sv-BrgzaxpK.js


Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
static/assets/tr-BJpf6Z8C.js


Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
static/assets/uk-Cf9gRJfz.js


Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
static/assets/zh-CN-CCek_yCc.js


Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
static/assets/zh-TW-pdp9uINk.js


+ 3 - 3
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-BSJ3-ebL.js"></script>
+    <script type="module" crossorigin src="/assets/index-BYDR9bb5.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/client-DSw2TdH4.js">
     <link rel="modulepreload" crossorigin href="/assets/react-dom-PmRWqOqI.js">
@@ -120,8 +120,8 @@
     <link rel="modulepreload" crossorigin href="/assets/circle-minus-CpiTIx_j.js">
     <link rel="modulepreload" crossorigin href="/assets/ConnectionDiagnostic-jD6ObGRm.js">
     <link rel="modulepreload" crossorigin href="/assets/plateCheck-fqh7_usj.js">
-    <link rel="modulepreload" crossorigin href="/assets/Layout-F1iGI_1j.js">
-    <link rel="stylesheet" crossorigin href="/assets/index-Cs9GKX3n.css">
+    <link rel="modulepreload" crossorigin href="/assets/Layout-CejbX0wr.js">
+    <link rel="stylesheet" crossorigin href="/assets/index-CLHFMRO6.css">
   </head>
   <body>
     <div id="root"></div>

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff