diff --git a/simpletuner/static/css/training_metrics_report.css b/simpletuner/static/css/training_metrics_report.css
index 1bda806ff..40e66aa80 100644
--- a/simpletuner/static/css/training_metrics_report.css
+++ b/simpletuner/static/css/training_metrics_report.css
@@ -201,6 +201,7 @@ input {
}
.media-timeline {
+ min-width: 0;
display: flex;
gap: 6px;
margin-bottom: 12px;
@@ -208,7 +209,16 @@ input {
padding-bottom: 4px;
}
+#media-timeline {
+ position: sticky;
+ top: 0;
+ z-index: 3;
+ padding: 8px 0;
+ background: var(--report-bg);
+}
+
.media-step {
+ min-height: 44px;
flex: 0 0 auto;
padding: 6px 9px;
border: 1px solid var(--report-border);
@@ -288,7 +298,7 @@ input {
inset: 0;
z-index: 10;
display: grid;
- grid-template-rows: minmax(0, 1fr) auto auto;
+ grid-template-rows: minmax(0, 1fr) auto auto auto;
gap: 12px;
padding: 16px;
background: rgba(5, 7, 11, 0.95);
@@ -319,6 +329,13 @@ input {
max-height: none;
}
+#media-lightbox-steps {
+ width: 100%;
+ max-width: 600px;
+ justify-self: center;
+ margin-bottom: 0;
+}
+
.media-lightbox-toolbar {
display: flex;
justify-content: center;
@@ -326,8 +343,8 @@ input {
}
.media-lightbox-toolbar button {
- min-width: 36px;
- min-height: 36px;
+ min-width: 44px;
+ min-height: 44px;
border: 1px solid var(--report-border);
background: var(--report-panel);
color: var(--report-text);
diff --git a/simpletuner/templates/training_metrics_report.html b/simpletuner/templates/training_metrics_report.html
index 7f6409cbb..149f89217 100644
--- a/simpletuner/templates/training_metrics_report.html
+++ b/simpletuner/templates/training_metrics_report.html
@@ -52,6 +52,7 @@
Validation media
+
@@ -208,6 +209,7 @@
Validation media
next.disabled = lightboxIndex >= lightboxImages.length - 1;
size.textContent = lightboxActualSize ? 'Fit' : '1:1';
lightbox.hidden = false;
+ renderStepTimeline(document.getElementById('media-lightbox-steps'), lightboxSteps());
}
function openLightbox(item) {
@@ -217,22 +219,58 @@ Validation media
renderLightbox();
}
- function renderMedia() {
- const steps = Array.from(new Set(media.map((item) => Number(item.step)))).sort((a, b) => a - b);
- if (selectedStep === null || !steps.includes(selectedStep)) selectedStep = steps[steps.length - 1] ?? null;
- const timeline = document.getElementById('media-timeline');
+ function sameOutput(left, right) {
+ return String(left.label || '') === String(right.label || '')
+ && Number(left.index || 0) === Number(right.index || 0);
+ }
+
+ function lightboxSteps() {
+ const current = lightboxImages[lightboxIndex].item;
+ return Array.from(new Set(media
+ .filter((item) => item.type === 'image' && sameOutput(item, current))
+ .map((item) => Number(item.step)))).sort((a, b) => a - b);
+ }
+
+ function selectStep(step) {
+ const lightboxOpen = !document.getElementById('media-lightbox').hidden;
+ const current = lightboxOpen ? lightboxImages[lightboxIndex].item : null;
+ selectedStep = step;
+ renderMedia();
+ if (lightboxOpen) {
+ lightboxImages = selectedImages();
+ lightboxIndex = lightboxImages.findIndex((image) => sameOutput(image.item, current));
+ renderLightbox();
+ }
+ }
+
+ function changeLightboxStep(offset) {
+ const steps = lightboxSteps();
+ const next = steps[steps.indexOf(selectedStep) + offset];
+ if (next !== undefined) selectStep(next);
+ }
+
+ function renderStepTimeline(timeline, steps) {
timeline.innerHTML = '';
steps.forEach((step) => {
const button = document.createElement('button');
button.type = 'button';
button.className = `media-step${step === selectedStep ? ' active' : ''}`;
button.textContent = `Step ${step}`;
- button.onclick = () => {
- selectedStep = step;
- renderMedia();
- };
+ button.setAttribute('aria-pressed', String(step === selectedStep));
+ button.onclick = () => selectStep(step);
timeline.appendChild(button);
});
+ const active = timeline.querySelector('.active');
+ if (active) {
+ timeline.scrollLeft += active.getBoundingClientRect().left - timeline.getBoundingClientRect().left
+ - (timeline.clientWidth - active.offsetWidth) / 2;
+ }
+ }
+
+ function renderMedia() {
+ const steps = Array.from(new Set(media.map((item) => Number(item.step)))).sort((a, b) => a - b);
+ if (selectedStep === null || !steps.includes(selectedStep)) selectedStep = steps[steps.length - 1] ?? null;
+ renderStepTimeline(document.getElementById('media-timeline'), steps);
const grid = document.getElementById('media-grid');
grid.innerHTML = '';
@@ -311,6 +349,9 @@ Validation media
};
document.addEventListener('keydown', (event) => {
if (document.getElementById('media-lightbox').hidden) return;
+ if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) event.preventDefault();
+ if (event.key === 'ArrowUp') changeLightboxStep(-1);
+ if (event.key === 'ArrowDown') changeLightboxStep(1);
if (event.key === 'Escape') closeLightbox();
if (event.key === 'ArrowLeft') {
lightboxIndex = Math.max(0, lightboxIndex - 1);
diff --git a/tests/test_training_report_e2e.py b/tests/test_training_report_e2e.py
new file mode 100644
index 000000000..fe902d129
--- /dev/null
+++ b/tests/test_training_report_e2e.py
@@ -0,0 +1,177 @@
+"""Browser coverage for the standalone training report."""
+
+import json
+import os
+import tempfile
+import unittest
+from pathlib import Path
+
+from PIL import Image
+from selenium import webdriver
+from selenium.webdriver.common.by import By
+from selenium.webdriver.common.keys import Keys
+from selenium.webdriver.support.ui import WebDriverWait
+
+from simpletuner.helpers.training.local_metrics import MEDIA_FILENAME, render_static_report
+from tests.selenium_support import _chrome_options
+
+
+@unittest.skipUnless(os.environ.get("SIMPLETUNER_SELENIUM_TESTS") == "1", "Selenium tests disabled")
+class TrainingReportNavigationTests(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ options = _chrome_options()
+ options.add_experimental_option("prefs", {})
+ cls.driver = webdriver.Chrome(options=options)
+ cls.addClassCleanup(cls.driver.quit)
+
+ def setUp(self):
+ self.directory = tempfile.TemporaryDirectory()
+ self.addCleanup(self.directory.cleanup)
+ output = Path(self.directory.name)
+ records = []
+ for step in (100, 200, 300):
+ for index in range(12):
+ if step == 200 and index == 1:
+ continue
+ filename = f"{step}-{index}.png"
+ Image.new("RGB", (300, 400), (step % 256, index * 20, 80)).save(output / filename)
+ records.append(dict(step=step, index=index, label="Validation", type="image", path=filename))
+ # An unrelated output must not stand in for a missing sample.
+ records.append(dict(step=200, index=1, label="Other", type="image", path="200-0.png"))
+ (output / MEDIA_FILENAME).write_text("\n".join(json.dumps(record) for record in records))
+ self.driver.set_window_size(1000, 800)
+ self.output = output
+ self.driver.get(render_static_report(output).as_uri())
+
+ def element(self, element_id):
+ return self.driver.find_element(By.ID, element_id)
+
+ def open_sample(self, index):
+ self.driver.find_elements(By.CSS_SELECTOR, ".media-image-button")[index].click()
+
+ def assert_image(self, step, index):
+ image = self.driver.find_element(By.CSS_SELECTOR, "#media-lightbox-body img")
+ self.assertTrue(image.get_attribute("src").endswith(f"/{step}-{index}.png"))
+ WebDriverWait(self.driver, 5).until(
+ lambda driver: driver.execute_script("return arguments[0].complete && arguments[0].naturalWidth > 0", image)
+ )
+ self.assertIn(f"step {step}", self.element("media-lightbox-caption").text)
+ self.assertTrue(self.element("media-lightbox").is_displayed())
+
+ def test_keyboard_switches_steps_without_switching_sample_or_scrolling(self):
+ self.open_sample(1)
+ before = self.driver.execute_script("return window.scrollY")
+ self.driver.switch_to.active_element.send_keys(Keys.ARROW_UP)
+ self.assert_image(100, 1)
+ self.assertEqual(before, self.driver.execute_script("return window.scrollY"))
+ self.driver.switch_to.active_element.send_keys(Keys.ARROW_UP)
+ self.assert_image(100, 1)
+ self.driver.switch_to.active_element.send_keys(Keys.ARROW_DOWN)
+ self.assert_image(300, 1)
+ self.driver.switch_to.active_element.send_keys(Keys.ARROW_RIGHT)
+ self.assert_image(300, 2)
+ self.driver.switch_to.active_element.send_keys(Keys.ARROW_UP)
+ self.assert_image(200, 2)
+ self.driver.switch_to.active_element.send_keys(Keys.ARROW_LEFT)
+ self.assert_image(200, 0)
+ self.driver.switch_to.active_element.send_keys(Keys.ESCAPE)
+ self.assertFalse(self.element("media-lightbox").is_displayed())
+
+ def test_mobile_step_buttons_keep_lightbox_open_and_gallery_in_sync(self):
+ self.driver.execute_cdp_cmd(
+ "Emulation.setDeviceMetricsOverride",
+ {
+ "width": 390,
+ "height": 844,
+ "deviceScaleFactor": 1,
+ "mobile": True,
+ },
+ )
+ self.addCleanup(self.driver.execute_cdp_cmd, "Emulation.clearDeviceMetricsOverride", {})
+ self.open_sample(1)
+ buttons = self.driver.find_elements(By.CSS_SELECTOR, "#media-lightbox-steps .media-step")
+ self.assertEqual([button.text for button in buttons], ["Step 100", "Step 300"])
+ bounds = self.driver.execute_script("return arguments[0].getBoundingClientRect().toJSON()", buttons[0])
+ self.assertGreaterEqual(bounds["height"], 44)
+ self.driver.execute_cdp_cmd(
+ "Input.dispatchTouchEvent",
+ {
+ "type": "touchStart",
+ "touchPoints": [{"x": bounds["x"] + 10, "y": bounds["y"] + 10}],
+ },
+ )
+ self.driver.execute_cdp_cmd("Input.dispatchTouchEvent", {"type": "touchEnd", "touchPoints": []})
+ self.assert_image(100, 1)
+ self.assertLessEqual(self.element("media-lightbox-steps").rect["width"], 390)
+ self.element("media-lightbox-size").click()
+ self.driver.find_elements(By.CSS_SELECTOR, "#media-lightbox-steps .media-step")[1].click()
+ self.assert_image(300, 1)
+ self.assertEqual(self.element("media-lightbox-size").text, "Fit")
+ self.element("media-lightbox-close").click()
+ self.assertEqual(self.driver.find_element(By.CSS_SELECTOR, "#media-timeline .active").text, "Step 300")
+
+ def test_gallery_step_navigation_stays_visible_when_scrolling(self):
+ timeline = self.element("media-timeline")
+ self.driver.execute_script("window.scrollTo(0, arguments[0].offsetTop + 400)", timeline)
+ top = self.driver.execute_script("return arguments[0].getBoundingClientRect().top", timeline)
+ self.assertGreaterEqual(top, 0)
+ self.assertLessEqual(top, 20)
+ self.driver.find_elements(By.CSS_SELECTOR, "#media-timeline .media-step")[0].click()
+ self.assertEqual(self.driver.find_element(By.CSS_SELECTOR, "#media-timeline .active").text, "Step 100")
+
+ def test_single_step_keeps_sample_navigation_and_size_controls(self):
+ media_path = self.output / MEDIA_FILENAME
+ media_path.write_text(
+ "\n".join(line for line in media_path.read_text().splitlines() if json.loads(line)["step"] == 300)
+ )
+ self.driver.get(render_static_report(self.output).as_uri())
+ self.open_sample(0)
+ self.assertFalse(self.element("media-lightbox-prev").is_enabled())
+ for key in (Keys.ARROW_UP, Keys.ARROW_DOWN, Keys.ARROW_LEFT):
+ self.driver.switch_to.active_element.send_keys(key)
+ self.assert_image(300, 0)
+ self.element("media-lightbox-next").click()
+ self.assert_image(300, 1)
+ self.element("media-lightbox-size").click()
+ self.assertEqual(
+ self.driver.find_element(By.CSS_SELECTOR, "#media-lightbox-body img").get_attribute("class"), "actual-size"
+ )
+ self.element("media-lightbox-close").click()
+ self.open_sample(11)
+ self.assertFalse(self.element("media-lightbox-next").is_enabled())
+ self.driver.switch_to.active_element.send_keys(Keys.ARROW_RIGHT)
+ self.assert_image(300, 11)
+
+ def test_empty_report_has_no_navigation_or_lightbox(self):
+ (self.output / MEDIA_FILENAME).write_text("")
+ self.driver.get(render_static_report(self.output).as_uri())
+ self.assertEqual(self.element("media-grid").text, "No validation media")
+ self.assertFalse(self.driver.find_elements(By.CSS_SELECTOR, ".media-step"))
+ self.assertFalse(self.element("media-lightbox").is_displayed())
+
+ def test_mobile_timeline_reveals_current_step_in_long_runs(self):
+ records = [
+ dict(step=step, index=0, label="Validation", type="image", path="300-0.png") for step in range(100, 3100, 100)
+ ]
+ (self.output / MEDIA_FILENAME).write_text("\n".join(json.dumps(record) for record in records))
+ self.driver.execute_cdp_cmd(
+ "Emulation.setDeviceMetricsOverride",
+ {
+ "width": 390,
+ "height": 844,
+ "deviceScaleFactor": 1,
+ "mobile": True,
+ },
+ )
+ self.addCleanup(self.driver.execute_cdp_cmd, "Emulation.clearDeviceMetricsOverride", {})
+ self.driver.get(render_static_report(self.output).as_uri())
+ self.open_sample(0)
+ for key, expected in ((None, "Step 3000"), (Keys.ARROW_UP, "Step 2900")):
+ if key:
+ self.driver.switch_to.active_element.send_keys(key)
+ active = self.driver.find_element(By.CSS_SELECTOR, "#media-lightbox-steps .active")
+ self.assertEqual(active.text, expected)
+ bounds = self.driver.execute_script("return arguments[0].getBoundingClientRect().toJSON()", active)
+ self.assertGreaterEqual(bounds["left"], 0)
+ self.assertLessEqual(bounds["right"], 390)