|
@@ -347,6 +347,12 @@ _active_prints: dict[tuple[int, str], int] = {}
|
|
|
# captures the better-framed pre-bed-drop moment without us having to force
|
|
# captures the better-framed pre-bed-drop moment without us having to force
|
|
|
# timelapse on at dispatch (the #1397 mechanism that caused #1721's per-layer
|
|
# timelapse on at dispatch (the #1397 mechanism that caused #1721's per-layer
|
|
|
# nozzle parking on slicer profiles with Timelapse Type = Smooth).
|
|
# nozzle parking on slicer profiles with Timelapse Type = Smooth).
|
|
|
|
|
+#
|
|
|
|
|
+# #2708: the bytes in here are ALWAYS already rotated by the printer's
|
|
|
|
|
+# camera_rotation. `on_finish_photo_moment` owns that, because one of its
|
|
|
|
|
+# sources (the #1867 in-print bank) is rotated before it ever reaches the
|
|
|
|
|
+# bank and the others are not — so the consumer can't tell them apart and
|
|
|
|
|
+# must not rotate again.
|
|
|
_stage22_finish_frames: dict[int, bytes] = {}
|
|
_stage22_finish_frames: dict[int, bytes] = {}
|
|
|
|
|
|
|
|
# #1790: per-printer producer-done event. Set by `on_finish_photo_moment` in its
|
|
# #1790: per-printer producer-done event. Set by `on_finish_photo_moment` in its
|
|
@@ -3982,6 +3988,7 @@ async def _capture_finish_photo_from_timelapse(
|
|
|
archive_id: int,
|
|
archive_id: int,
|
|
|
archive_dir: Path,
|
|
archive_dir: Path,
|
|
|
timeout: float | None = None,
|
|
timeout: float | None = None,
|
|
|
|
|
+ rotation: int = 0,
|
|
|
) -> tuple[str | None, bool]:
|
|
) -> tuple[str | None, bool]:
|
|
|
"""Wait for the per-print timelapse to land on the archive and extract its
|
|
"""Wait for the per-print timelapse to land on the archive and extract its
|
|
|
last frame as the finish photo (#1397).
|
|
last frame as the finish photo (#1397).
|
|
@@ -4001,11 +4008,16 @@ async def _capture_finish_photo_from_timelapse(
|
|
|
video landed (whether or not extraction worked), because in that case
|
|
video landed (whether or not extraction worked), because in that case
|
|
|
waiting longer changes nothing. The caller uses that to decide between
|
|
waiting longer changes nothing. The caller uses that to decide between
|
|
|
falling back permanently and scheduling a background upgrade.
|
|
falling back permanently and scheduling a background upgrade.
|
|
|
|
|
+
|
|
|
|
|
+ ``rotation`` is the printer's camera_rotation, applied to the extracted
|
|
|
|
|
+ still (#2708) so this source agrees with every other finish-photo source.
|
|
|
|
|
+ The archived video itself is the printer's own file and is left alone —
|
|
|
|
|
+ rotating it would mean re-encoding it.
|
|
|
"""
|
|
"""
|
|
|
import uuid
|
|
import uuid
|
|
|
|
|
|
|
|
from backend.app.models.archive import PrintArchive
|
|
from backend.app.models.archive import PrintArchive
|
|
|
- from backend.app.services.camera import extract_video_last_frame
|
|
|
|
|
|
|
+ from backend.app.services.camera import apply_camera_rotation_to_file, extract_video_last_frame
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
@@ -4028,6 +4040,7 @@ async def _capture_finish_photo_from_timelapse(
|
|
|
filename = f"finish_{timestamp}_{uuid.uuid4().hex[:8]}.jpg"
|
|
filename = f"finish_{timestamp}_{uuid.uuid4().hex[:8]}.jpg"
|
|
|
output_path = photos_dir / filename
|
|
output_path = photos_dir / filename
|
|
|
if await extract_video_last_frame(video_path, output_path):
|
|
if await extract_video_last_frame(video_path, output_path):
|
|
|
|
|
+ await apply_camera_rotation_to_file(output_path, rotation, logger)
|
|
|
logger.info(
|
|
logger.info(
|
|
|
"[PHOTO-BG] Extracted finish photo from timelapse %s for archive %s",
|
|
"[PHOTO-BG] Extracted finish photo from timelapse %s for archive %s",
|
|
|
video_path.name,
|
|
video_path.name,
|
|
@@ -4052,7 +4065,7 @@ async def _capture_finish_photo_from_timelapse(
|
|
|
await asyncio.sleep(poll_interval)
|
|
await asyncio.sleep(poll_interval)
|
|
|
|
|
|
|
|
|
|
|
|
|
-async def _upgrade_finish_photo_from_timelapse(archive_id: int, archive_dir: Path) -> None:
|
|
|
|
|
|
|
+async def _upgrade_finish_photo_from_timelapse(archive_id: int, archive_dir: Path, rotation: int = 0) -> None:
|
|
|
"""Add the timelapse's last frame to an archive after the fact (#2704).
|
|
"""Add the timelapse's last frame to an archive after the fact (#2704).
|
|
|
|
|
|
|
|
The print-complete notification waits only ~60s for the video, because
|
|
The print-complete notification waits only ~60s for the video, because
|
|
@@ -4071,7 +4084,7 @@ async def _upgrade_finish_photo_from_timelapse(archive_id: int, archive_dir: Pat
|
|
|
logger = logging.getLogger(__name__)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
filename, _ = await _capture_finish_photo_from_timelapse(
|
|
filename, _ = await _capture_finish_photo_from_timelapse(
|
|
|
- archive_id, archive_dir, timeout=_FINISH_PHOTO_UPGRADE_TIMEOUT_SECONDS
|
|
|
|
|
|
|
+ archive_id, archive_dir, timeout=_FINISH_PHOTO_UPGRADE_TIMEOUT_SECONDS, rotation=rotation
|
|
|
)
|
|
)
|
|
|
if not filename:
|
|
if not filename:
|
|
|
logger.info("[PHOTO-UPGRADE] No timelapse frame for archive %s; keeping the live grab", archive_id)
|
|
logger.info("[PHOTO-UPGRADE] No timelapse frame for archive %s; keeping the live grab", archive_id)
|
|
@@ -4356,6 +4369,11 @@ async def on_finish_photo_moment(printer_id: int, data: dict):
|
|
|
return
|
|
return
|
|
|
|
|
|
|
|
frame_bytes: bytes | None = None
|
|
frame_bytes: bytes | None = None
|
|
|
|
|
+ # #2708: the banked frame arrives already rotated — it comes from
|
|
|
|
|
+ # `_capture_snapshot_for_notification`, which rotates before returning.
|
|
|
|
|
+ # Every other source below is a raw grab. Tracking which lets us store
|
|
|
|
|
+ # exactly one rotation in `_stage22_finish_frames` either way.
|
|
|
|
|
+ frame_already_rotated = False
|
|
|
|
|
|
|
|
# #1867: on the FINISH-state fallback the End G-code (e.g. SwapMod
|
|
# #1867: on the FINISH-state fallback the End G-code (e.g. SwapMod
|
|
|
# plate-swap) has already run, so a live grab now captures the swapped
|
|
# plate-swap) has already run, so a live grab now captures the swapped
|
|
@@ -4367,6 +4385,7 @@ async def on_finish_photo_moment(printer_id: int, data: dict):
|
|
|
banked = _inprint_frame_bank.get(printer_id)
|
|
banked = _inprint_frame_bank.get(printer_id)
|
|
|
if banked:
|
|
if banked:
|
|
|
frame_bytes = banked
|
|
frame_bytes = banked
|
|
|
|
|
+ frame_already_rotated = True
|
|
|
logger.info(
|
|
logger.info(
|
|
|
"[FINISH-PHOTO-MOMENT] using banked in-print frame (%d bytes) — "
|
|
"[FINISH-PHOTO-MOMENT] using banked in-print frame (%d bytes) — "
|
|
|
"avoids post-swap live grab on stage-22-less firmware",
|
|
"avoids post-swap live grab on stage-22-less firmware",
|
|
@@ -4420,6 +4439,8 @@ async def on_finish_photo_moment(printer_id: int, data: dict):
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
if frame_bytes:
|
|
if frame_bytes:
|
|
|
|
|
+ if not frame_already_rotated:
|
|
|
|
|
+ frame_bytes = _apply_camera_rotation(frame_bytes, printer, logger)
|
|
|
_stage22_finish_frames[printer_id] = frame_bytes
|
|
_stage22_finish_frames[printer_id] = frame_bytes
|
|
|
else:
|
|
else:
|
|
|
logger.warning(
|
|
logger.warning(
|
|
@@ -5307,6 +5328,7 @@ async def on_print_complete(printer_id: int, data: dict):
|
|
|
photo_filename, timelapse_still_pending = await _capture_finish_photo_from_timelapse(
|
|
photo_filename, timelapse_still_pending = await _capture_finish_photo_from_timelapse(
|
|
|
archive_id=archive_id,
|
|
archive_id=archive_id,
|
|
|
archive_dir=archive_dir,
|
|
archive_dir=archive_dir,
|
|
|
|
|
+ rotation=getattr(printer, "camera_rotation", 0),
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
# #1721: replacement framing path — on_finish_photo_moment
|
|
# #1721: replacement framing path — on_finish_photo_moment
|
|
@@ -5334,7 +5356,9 @@ async def on_print_complete(printer_id: int, data: dict):
|
|
|
)
|
|
)
|
|
|
cached_frame = _stage22_finish_frames.pop(printer_id, None)
|
|
cached_frame = _stage22_finish_frames.pop(printer_id, None)
|
|
|
if cached_frame:
|
|
if cached_frame:
|
|
|
- cached_frame = _apply_camera_rotation(cached_frame, printer, logger)
|
|
|
|
|
|
|
+ # Already rotated by the producer (#2708) — rotating again
|
|
|
|
|
+ # here would undo the fix on the banked-frame path, whose
|
|
|
|
|
+ # bytes reach the cache having been rotated once already.
|
|
|
photos_dir = archive_dir / "photos"
|
|
photos_dir = archive_dir / "photos"
|
|
|
photos_dir.mkdir(parents=True, exist_ok=True)
|
|
photos_dir.mkdir(parents=True, exist_ok=True)
|
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
@@ -5405,6 +5429,7 @@ async def on_print_complete(printer_id: int, data: dict):
|
|
|
access_code=printer.access_code,
|
|
access_code=printer.access_code,
|
|
|
model=printer.model,
|
|
model=printer.model,
|
|
|
archive_dir=archive_dir,
|
|
archive_dir=archive_dir,
|
|
|
|
|
+ rotation=getattr(printer, "camera_rotation", 0),
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
# Write phase: attach the photo in a fresh short-lived session.
|
|
# Write phase: attach the photo in a fresh short-lived session.
|
|
@@ -5436,7 +5461,9 @@ async def on_print_complete(printer_id: int, data: dict):
|
|
|
# gallery never lists.
|
|
# gallery never lists.
|
|
|
if timelapse_still_pending:
|
|
if timelapse_still_pending:
|
|
|
spawn_background_task(
|
|
spawn_background_task(
|
|
|
- _upgrade_finish_photo_from_timelapse(archive_id, archive_dir),
|
|
|
|
|
|
|
+ _upgrade_finish_photo_from_timelapse(
|
|
|
|
|
+ archive_id, archive_dir, rotation=getattr(printer, "camera_rotation", 0)
|
|
|
|
|
+ ),
|
|
|
name=f"finish-photo-upgrade-{archive_id}",
|
|
name=f"finish-photo-upgrade-{archive_id}",
|
|
|
)
|
|
)
|
|
|
|
|
|