add detailed jpg quality opts & thumbnail pass
Docker server image / build-and-push (push) Successful in 3m48s

This commit is contained in:
2026-03-30 05:05:27 -07:00
parent 72d4d521e3
commit 30cbfaadad
10 changed files with 644 additions and 190 deletions
@@ -19,7 +19,10 @@ class ConversionSession:
conversion_id: str
source_filename: str
resolution: conversion_pb2.ConversionResolution
full_resolution: conversion_pb2.ConversionResolution
thumbnail_resolution: conversion_pb2.ConversionResolution
full_jpeg_quality: int
thumbnail_jpeg_quality: int
bucket_name: str
upload_object_key: str
status: conversion_pb2.ConversionStatus
@@ -45,6 +45,10 @@ _RESOLUTION_PRESET_BY_PROTO = {
conversion_pb2.CONVERSION_RESOLUTION_QHD: RESOLUTION_QHD,
conversion_pb2.CONVERSION_RESOLUTION_UHD: RESOLUTION_UHD,
}
_DEFAULT_FULL_RESOLUTION = conversion_pb2.CONVERSION_RESOLUTION_FHD
_DEFAULT_THUMBNAIL_RESOLUTION = conversion_pb2.CONVERSION_RESOLUTION_SD
_DEFAULT_FULL_JPEG_QUALITY = 85
_DEFAULT_THUMBNAIL_JPEG_QUALITY = 75
class ConversionServiceImpl(conversion_connect.ConversionService):
@@ -69,11 +73,18 @@ class ConversionServiceImpl(conversion_connect.ConversionService):
raise ConnectError(Code.INVALID_ARGUMENT, "source_filename is required")
if not source_filename.lower().endswith(".pptx"):
raise ConnectError(Code.INVALID_ARGUMENT, "only .pptx input is supported")
resolution = request.resolution
if resolution == conversion_pb2.CONVERSION_RESOLUTION_UNSPECIFIED:
resolution = conversion_pb2.CONVERSION_RESOLUTION_FHD
if resolution not in _RESOLUTION_PRESET_BY_PROTO:
raise ConnectError(Code.INVALID_ARGUMENT, "resolution is invalid")
full_resolution, full_jpeg_quality = self._resolve_raster_options(
request.full,
default_resolution=_DEFAULT_FULL_RESOLUTION,
default_jpeg_quality=_DEFAULT_FULL_JPEG_QUALITY,
field_name="full",
)
thumbnail_resolution, thumbnail_jpeg_quality = self._resolve_raster_options(
request.thumbnail,
default_resolution=_DEFAULT_THUMBNAIL_RESOLUTION,
default_jpeg_quality=_DEFAULT_THUMBNAIL_JPEG_QUALITY,
field_name="thumbnail",
)
ksuid = Ksuid()
conversion_id = str(ksuid)
@@ -109,7 +120,10 @@ class ConversionServiceImpl(conversion_connect.ConversionService):
session = ConversionSession(
conversion_id=conversion_id,
source_filename=source_filename,
resolution=resolution,
full_resolution=full_resolution,
thumbnail_resolution=thumbnail_resolution,
full_jpeg_quality=full_jpeg_quality,
thumbnail_jpeg_quality=thumbnail_jpeg_quality,
bucket_name=bucket_name,
upload_object_key=upload_key,
status=conversion_pb2.CONVERSION_STATUS_PENDING,
@@ -226,11 +240,15 @@ class ConversionServiceImpl(conversion_connect.ConversionService):
"""Execute conversion flow and persist terminal state in memory."""
started_at = time.monotonic()
logger.info(
"Starting conversion conversion_id=%s source_filename=%s resolution=%s "
"Starting conversion conversion_id=%s source_filename=%s "
"full[resolution=%s,jpeg_quality=%d] thumbnail[resolution=%s,jpeg_quality=%d] "
"timeout_caps_s[pptx_to_pdf_total=%d,pdf_to_images_total=%d]",
session.conversion_id,
session.source_filename,
conversion_pb2.ConversionResolution.Name(session.resolution),
conversion_pb2.ConversionResolution.Name(session.full_resolution),
session.full_jpeg_quality,
conversion_pb2.ConversionResolution.Name(session.thumbnail_resolution),
session.thumbnail_jpeg_quality,
self._config.conversion_pptx_to_pdf_timeout_seconds,
self._config.conversion_pdf_to_images_timeout_seconds,
)
@@ -250,7 +268,12 @@ class ConversionServiceImpl(conversion_connect.ConversionService):
convert_pptx_to_slidedeck,
source_path,
work_dir,
resolution=_RESOLUTION_PRESET_BY_PROTO[session.resolution],
full_resolution=_RESOLUTION_PRESET_BY_PROTO[session.full_resolution],
thumbnail_resolution=_RESOLUTION_PRESET_BY_PROTO[
session.thumbnail_resolution
],
full_jpeg_quality=session.full_jpeg_quality,
thumbnail_jpeg_quality=session.thumbnail_jpeg_quality,
pptx_to_pdf_timeout_s=self._config.conversion_pptx_to_pdf_timeout_seconds,
pdf_to_images_timeout_s=self._config.conversion_pdf_to_images_timeout_seconds,
pptx_to_pdf_base_timeout_s=self._config.conversion_pptx_to_pdf_base_timeout_seconds,
@@ -266,14 +289,21 @@ class ConversionServiceImpl(conversion_connect.ConversionService):
)
logger.info(
"Resolved conversion plan conversion_id=%s source_filename=%s "
"resolution=%s inferred_dpi=%d output_size=%dx%d "
"full[resolution=%s,size=%dx%d,jpeg_quality=%d] "
"thumbnail[resolution=%s,size=%dx%d,jpeg_quality=%d] "
"inferred_dpi=%d "
"computed_timeouts_s[pptx_to_pdf_total=%d,pdf_to_images_total=%d,pdf_to_images_per_page=%d]",
session.conversion_id,
session.source_filename,
conversion_pb2.ConversionResolution.Name(session.resolution),
result.inferred_dpi,
conversion_pb2.ConversionResolution.Name(session.full_resolution),
result.width,
result.height,
session.full_jpeg_quality,
conversion_pb2.ConversionResolution.Name(session.thumbnail_resolution),
result.thumbnail_width,
result.thumbnail_height,
session.thumbnail_jpeg_quality,
result.inferred_dpi,
result.pptx_to_pdf_timeout_s,
result.pdf_to_images_timeout_s,
result.pdf_to_images_page_timeout_s,
@@ -282,7 +312,7 @@ class ConversionServiceImpl(conversion_connect.ConversionService):
session,
phase=conversion_pb2.CONVERSION_PHASE_UPLOADING_RESULTS,
current_progress=0,
max_progress=len(result.slides),
max_progress=len(result.slides) * 2,
)
session.slide_deck = await asyncio.to_thread(
self._upload_and_build_slide_deck,
@@ -291,6 +321,8 @@ class ConversionServiceImpl(conversion_connect.ConversionService):
result.source_filename,
result.width,
result.height,
result.thumbnail_width,
result.thumbnail_height,
lambda current, max_value: self._set_session_progress(
session,
phase=conversion_pb2.CONVERSION_PHASE_UPLOADING_RESULTS,
@@ -358,12 +390,16 @@ class ConversionServiceImpl(conversion_connect.ConversionService):
source_filename: str,
width: int,
height: int,
thumbnail_width: int,
thumbnail_height: int,
progress_callback: Callable[[int, int], None] | None = None,
) -> conversion_pb2.SlideDeck:
"""Upload generated slide images and construct API response payload."""
response_slides: list[conversion_pb2.Slide] = []
slide_total = len(slides)
for slide_index, slide in enumerate(slides, start=1):
upload_total = slide_total * 2
upload_index = 0
for slide in slides:
object_key = f"output/slide-{slide.index:04d}{slide.image_path.suffix}"
self._store.fput_object(session.bucket_name, object_key, slide.image_path)
image_url = self._store.presigned_get_url(
@@ -371,15 +407,33 @@ class ConversionServiceImpl(conversion_connect.ConversionService):
object_key,
ttl_seconds=self._config.s3_session_ttl_seconds,
)
upload_index += 1
if progress_callback is not None:
progress_callback(upload_index, upload_total)
thumbnail_object_key = (
f"output/thumb/slide-{slide.index:04d}{slide.thumbnail_path.suffix}"
)
self._store.fput_object(
session.bucket_name,
thumbnail_object_key,
slide.thumbnail_path,
)
thumbnail_image_url = self._store.presigned_get_url(
session.bucket_name,
thumbnail_object_key,
ttl_seconds=self._config.s3_session_ttl_seconds,
)
upload_index += 1
response_slides.append(
conversion_pb2.Slide(
index=slide.index,
notes_plain=slide.notes_plain,
image_url=image_url,
thumbnail_image_url=thumbnail_image_url,
)
)
if progress_callback is not None:
progress_callback(slide_index, slide_total)
progress_callback(upload_index, upload_total)
return conversion_pb2.SlideDeck(
conversion_id=session.conversion_id,
@@ -388,8 +442,38 @@ class ConversionServiceImpl(conversion_connect.ConversionService):
created_at=_to_timestamp(utc_now()),
width=width,
height=height,
thumbnail_width=thumbnail_width,
thumbnail_height=thumbnail_height,
)
def _resolve_raster_options(
self,
options: conversion_pb2.SlideRasterOptions,
*,
default_resolution: conversion_pb2.ConversionResolution,
default_jpeg_quality: int,
field_name: str,
) -> tuple[conversion_pb2.ConversionResolution, int]:
"""Resolve per-tier raster options with defaults and validation."""
resolution = options.resolution
if resolution == conversion_pb2.CONVERSION_RESOLUTION_UNSPECIFIED:
resolution = default_resolution
if resolution not in _RESOLUTION_PRESET_BY_PROTO:
raise ConnectError(Code.INVALID_ARGUMENT, f"{field_name}.resolution is invalid")
jpeg_quality = default_jpeg_quality
if options.HasField("jpeg"):
quality = options.jpeg.quality
if quality == 0:
jpeg_quality = default_jpeg_quality
elif 1 <= quality <= 100:
jpeg_quality = quality
else:
raise ConnectError(
Code.INVALID_ARGUMENT,
f"{field_name}.jpeg.quality must be 0 or between 1 and 100",
)
return resolution, jpeg_quality
async def _delayed_cleanup(self, session: ConversionSession) -> None:
"""Delete storage resources after the configured session retention period."""
try: