Skip to content

Commit 8fcf84d

Browse files
shrimblyclaude
andcommitted
add inline raster preview and responsive selection layout
- inline Sixel/Kitty source frame preview with chart click-to-inspect - shared image transcode helpers that preserve alpha; HDR filter refinements - compact layout tier keeps the preview and chart in the viewport on short terminals - vertically center the processing screen - Start Over resets setup to step 1 and Cancel fully exits the TUI - require textual>=5.0 with textual-image and Pillow; CI matrix updates Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f4ee4af commit 8fcf84d

31 files changed

Lines changed: 2813 additions & 558 deletions

‎.github/workflows/ci.yml‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,53 @@ jobs:
4545

4646
- name: Build wheel
4747
run: python -m pip wheel . --no-deps --wheel-dir dist
48+
49+
minimum-textual:
50+
runs-on: ubuntu-latest
51+
52+
steps:
53+
- uses: actions/checkout@v4
54+
55+
- uses: actions/setup-python@v5
56+
with:
57+
python-version: "3.10"
58+
cache: pip
59+
60+
- name: Install FFmpeg
61+
run: sudo apt-get update && sudo apt-get install --yes ffmpeg
62+
63+
- name: Install minimum supported Textual
64+
run: >-
65+
python -m pip install --upgrade pip &&
66+
python -m pip install -e . -r tests/requirements.txt "textual==5.0.0" &&
67+
python -m pip check
68+
69+
- name: Run tests at the minimum Textual version
70+
run: python -m pytest -q
71+
72+
package:
73+
runs-on: ubuntu-latest
74+
75+
steps:
76+
- uses: actions/checkout@v4
77+
78+
- uses: actions/setup-python@v5
79+
with:
80+
python-version: "3.13"
81+
cache: pip
82+
83+
- name: Install build tooling and FFmpeg
84+
run: |
85+
sudo apt-get update && sudo apt-get install --yes ffmpeg
86+
python -m pip install --upgrade pip build
87+
88+
- name: Build distribution artifacts
89+
run: python -m build
90+
91+
- name: Smoke test installed wheel outside the checkout
92+
run: |
93+
python -m venv "$RUNNER_TEMP/wheel-smoke"
94+
"$RUNNER_TEMP/wheel-smoke/bin/python" -m pip install dist/*.whl
95+
cd "$RUNNER_TEMP"
96+
"$RUNNER_TEMP/wheel-smoke/bin/python" -c "import sharp_frames; from sharp_frames.ui.components.raster_preview import RasterImagePreview; print(sharp_frames.__version__)"
97+
"$RUNNER_TEMP/wheel-smoke/bin/sharp-frames" --help

‎.gitignore‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,10 @@ pip-delete-this-directory.txt
3030
htmlcov/
3131
.tox/
3232

33-
# Editor files
33+
# Editor and local agent files
3434
.vscode/
3535
.idea/
36+
.codex/
3637
*.swp
3738
*.swo
3839

‎README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,8 @@ sharp-frames photos selected --selection-method outlier-removal --outlier-sensit
128128
## Requirements
129129

130130
- Python 3.10 or higher
131-
- Dependencies installed automatically: `opencv-python`, `numpy`, `tqdm`, `textual`
131+
- Dependencies installed automatically: `opencv-python`, `numpy`, `tqdm`,
132+
`textual`, `textual-image`
132133
- FFmpeg and FFprobe (for video processing only)
133134
- FFmpeg `zscale`/`libzimg` support (for HDR-to-SDR processing only)
134135

‎pyproject.toml‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@ dependencies = [
2929
"opencv-python>=4.5.0",
3030
"numpy>=1.19.0",
3131
"tqdm>=4.64.0",
32-
"textual>=0.41.0",
32+
"textual>=5.0.0",
33+
"textual-image>=0.12.0,<0.13.0",
34+
"Pillow>=9.0.0",
3335
]
3436

3537
[project.urls]

‎sharp_frames/image_output.py‎

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
"""Shared image decoding, resizing, and encoding helpers."""
2+
3+
from pathlib import Path
4+
5+
import cv2
6+
import numpy as np
7+
from PIL import Image
8+
9+
10+
class ImageOutputError(Exception):
11+
"""Raised when an image cannot be decoded or encoded."""
12+
13+
14+
def canonical_image_format(path: str) -> str:
15+
"""Return a canonical image format name for a path."""
16+
extension = Path(path).suffix.lower().lstrip(".")
17+
aliases = {"jpeg": "jpg", "tif": "tiff"}
18+
return aliases.get(extension, extension)
19+
20+
21+
def image_needs_processing(src_path: str, dst_path: str, width: int) -> bool:
22+
"""Return whether an image needs resizing or format conversion."""
23+
return width > 0 or canonical_image_format(src_path) != canonical_image_format(
24+
dst_path
25+
)
26+
27+
28+
def resize_image(image: np.ndarray, width: int) -> np.ndarray:
29+
"""Resize an image to a width while preserving channels and aspect ratio."""
30+
height = int(image.shape[0] * (width / image.shape[1]))
31+
if height % 2 != 0:
32+
height += 1
33+
return cv2.resize(image, (width, height), interpolation=cv2.INTER_AREA)
34+
35+
36+
def transcode_image(src_path: str, dst_path: str, width: int = 0) -> None:
37+
"""Decode, optionally resize, and encode an image without losing alpha."""
38+
image = cv2.imread(src_path, cv2.IMREAD_UNCHANGED)
39+
if image is None:
40+
raise ImageOutputError(f"Failed to read image: {src_path}")
41+
image = _apply_exif_orientation(image, src_path)
42+
43+
if width > 0:
44+
image = resize_image(image, width)
45+
46+
destination_format = canonical_image_format(dst_path)
47+
image = _prepare_for_destination(image, destination_format)
48+
49+
if not cv2.imwrite(dst_path, image):
50+
raise ImageOutputError(
51+
f"Failed to encode image as {Path(dst_path).suffix}"
52+
)
53+
54+
55+
def _apply_exif_orientation(
56+
image: np.ndarray, source_path: str
57+
) -> np.ndarray:
58+
"""Apply EXIF orientation to decoded pixels before metadata is discarded."""
59+
try:
60+
with Image.open(source_path) as source:
61+
orientation = int(source.getexif().get(274, 1))
62+
except (OSError, TypeError, ValueError):
63+
orientation = 1
64+
65+
if orientation == 2:
66+
return cv2.flip(image, 1)
67+
if orientation == 3:
68+
return cv2.rotate(image, cv2.ROTATE_180)
69+
if orientation == 4:
70+
return cv2.flip(image, 0)
71+
if orientation == 5:
72+
return cv2.transpose(image)
73+
if orientation == 6:
74+
return cv2.rotate(image, cv2.ROTATE_90_CLOCKWISE)
75+
if orientation == 7:
76+
return cv2.flip(cv2.transpose(image), -1)
77+
if orientation == 8:
78+
return cv2.rotate(image, cv2.ROTATE_90_COUNTERCLOCKWISE)
79+
return image
80+
81+
82+
def _prepare_for_destination(
83+
image: np.ndarray, destination_format: str
84+
) -> np.ndarray:
85+
"""Convert channels and depth to values supported by the encoder."""
86+
if destination_format == "jpg":
87+
return _to_uint8(_flatten_alpha_on_white(image))
88+
if destination_format == "png" and image.dtype.type not in {np.uint8, np.uint16}:
89+
return _to_uint8(image)
90+
if destination_format in {"bmp", "webp"} and image.dtype.type is not np.uint8:
91+
return _to_uint8(image)
92+
return image
93+
94+
95+
def _to_uint8(image: np.ndarray) -> np.ndarray:
96+
"""Scale image data to the eight-bit range required by JPEG."""
97+
if image.dtype == np.uint8:
98+
return image
99+
if image.dtype == np.bool_:
100+
return image.astype(np.uint8) * 255
101+
if np.issubdtype(image.dtype, np.integer):
102+
info = np.iinfo(image.dtype)
103+
scaled = (
104+
(image.astype(np.float32) - float(info.min))
105+
* (255.0 / float(info.max - info.min))
106+
)
107+
return np.clip(scaled, 0, 255).round().astype(np.uint8)
108+
109+
finite = np.nan_to_num(image.astype(np.float32), nan=0.0)
110+
if finite.size and finite.min() >= 0 and finite.max() <= 1:
111+
finite = finite * 255.0
112+
return np.clip(finite, 0, 255).round().astype(np.uint8)
113+
114+
115+
def _flatten_alpha_on_white(image: np.ndarray) -> np.ndarray:
116+
"""Composite alpha-bearing images on white for JPEG output."""
117+
if image.ndim != 3 or image.shape[2] not in {2, 4}:
118+
return image
119+
120+
color = image[..., :-1]
121+
alpha = image[..., -1:]
122+
if np.issubdtype(image.dtype, np.integer):
123+
maximum = float(np.iinfo(image.dtype).max)
124+
else:
125+
maximum = 1.0
126+
127+
alpha_fraction = alpha.astype(np.float32) / maximum
128+
composited = (
129+
color.astype(np.float32) * alpha_fraction
130+
+ maximum * (1.0 - alpha_fraction)
131+
)
132+
return np.clip(composited, 0, maximum).astype(image.dtype)

‎sharp_frames/processing/colorspace.py‎

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -237,28 +237,38 @@ def _default_color_info() -> VideoColorInfo:
237237
)
238238

239239

240-
def build_colorspace_filter(color_info: VideoColorInfo) -> Optional[str]:
240+
def build_colorspace_filter(
241+
color_info: VideoColorInfo, width: int = 0
242+
) -> Optional[str]:
241243
"""
242244
Build FFmpeg filter string for color space conversion to sRGB/BT.709.
243245
244246
Args:
245247
color_info: Detected color space information
248+
width: Optional output width to apply inside HDR conversion
246249
247250
Returns:
248251
Filter string to add to -vf, or None if no conversion needed
249252
"""
253+
if color_info.color_matrix == ColorMatrix.BT2020_CL:
254+
raise RuntimeError(
255+
"BT.2020 constant-luminance video is not supported. "
256+
"Convert the source to BT.2020 non-constant-luminance or BT.709 first."
257+
)
258+
250259
if not color_info.needs_conversion:
251260
return None
252261

253262
if color_info.is_hdr:
254-
return _build_hdr_to_sdr_filter(color_info)
255-
elif color_info.is_wide_gamut_sdr:
263+
return _build_hdr_to_sdr_filter(color_info, width)
264+
if color_info.is_wide_gamut_sdr:
256265
return _build_wide_gamut_to_srgb_filter(color_info)
257-
else:
258-
return None
266+
return None
259267

260268

261-
def _build_hdr_to_sdr_filter(color_info: VideoColorInfo) -> str:
269+
def _build_hdr_to_sdr_filter(
270+
color_info: VideoColorInfo, width: int = 0
271+
) -> str:
262272
"""
263273
Build HDR to SDR tone mapping filter.
264274
@@ -282,22 +292,26 @@ def _build_hdr_to_sdr_filter(color_info: VideoColorInfo) -> str:
282292
primaries_in = "bt2020" # Default to BT.2020 for HDR
283293

284294
# Determine input matrix
285-
if color_info.color_matrix in [ColorMatrix.BT2020_NCL, ColorMatrix.BT2020_CL]:
286-
matrix_in = "bt2020nc"
287-
else:
288-
matrix_in = "bt2020nc" # Default for HDR
295+
matrix_in = "bt2020nc"
296+
resize_options = (
297+
f":w={width}:h=-2:f=lanczos" if width > 0 else ""
298+
)
289299

290300
if is_zscale_available():
301+
# 4:2:0 requires even dimensions; preserve explicit odd widths with 4:4:4.
302+
output_pixel_format = (
303+
"yuv444p" if width > 0 and width % 2 else "yuv420p"
304+
)
291305
# Full HDR to SDR pipeline with tone mapping
292306
# Explicitly specify input parameters for reliable conversion
293307
filter_chain = (
294-
f"zscale=tin={transfer_in}:min={matrix_in}:pin={primaries_in}:"
295-
f"t=linear:npl=100," # Linearize with input specs
308+
f"zscale=tin={transfer_in}:min={matrix_in}:pin={primaries_in}"
309+
f"{resize_options}:t=linear:npl=100," # Resize and linearize
296310
"format=gbrpf32le," # High precision intermediate
297311
"zscale=p=bt709," # Convert primaries to BT.709
298312
"tonemap=hable:desat=0," # Hable tone mapping (filmic)
299313
"zscale=t=bt709:m=bt709:r=tv," # Apply BT.709 transfer/matrix
300-
"format=yuv420p" # Standard output format
314+
f"format={output_pixel_format}"
301315
)
302316
else:
303317
raise RuntimeError(

‎sharp_frames/processing/frame_extractor.py‎

Lines changed: 25 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,8 @@ def __init__(self):
3737
self._active_process: Optional[subprocess.Popen] = None
3838

3939
def cancel_processing(self) -> None:
40-
"""Request cancellation and terminate an active FFmpeg process."""
40+
"""Request cancellation without blocking the caller."""
4141
self._cancellation_event.set()
42-
with self._process_lock:
43-
process = self._active_process
44-
if process is not None and process.poll() is None:
45-
self._terminate_process(process)
4642

4743
def reset_cancellation(self) -> None:
4844
"""Prepare this extractor for an explicitly requested new operation."""
@@ -381,32 +377,38 @@ def _extract_color_info_from_video_info(self, video_info: Dict[str, Any]) -> 'Vi
381377
is_hdr=False
382378
)
383379

384-
def _run_ffmpeg_extraction(self, video_path: str, output_dir: str, fps: int,
385-
output_format: str, width: int, duration: Optional[float] = None,
386-
color_info: Optional['VideoColorInfo'] = None) -> bool:
387-
"""Run FFmpeg to extract frames from video with progress monitoring and color space conversion."""
380+
@staticmethod
381+
def _build_video_filters(
382+
fps: int,
383+
width: int,
384+
color_info: Optional['VideoColorInfo'] = None,
385+
) -> List[str]:
386+
"""Build an efficient, color-correct FFmpeg filter sequence."""
388387
from .colorspace import build_colorspace_filter
389388

390-
output_pattern = os.path.join(output_dir, f"frame_%05d.{output_format}")
391-
392-
# Build video filters - order matters!
393-
vf_filters = []
389+
filters = [f"fps={fps}"]
390+
resize_consumed = False
394391

395-
# 1. Color space conversion FIRST (before any scaling)
396392
if color_info is not None:
397-
colorspace_filter = build_colorspace_filter(color_info)
393+
colorspace_filter = build_colorspace_filter(
394+
color_info,
395+
width=width if color_info.is_hdr else 0,
396+
)
398397
if colorspace_filter:
399-
vf_filters.append(colorspace_filter)
398+
filters.append(colorspace_filter)
399+
resize_consumed = color_info.is_hdr and width > 0
400400

401-
# 2. FPS filter
402-
vf_filters.append(f"fps={fps}")
401+
if width > 0 and not resize_consumed:
402+
filters.append(f"scale={width}:-1:flags=lanczos")
403403

404-
# 3. Scale filter (after color conversion)
405-
if width > 0:
406-
# Use lanczos scaling for high-quality downsampling
407-
vf_filters.append(f"scale={width}:-1:flags=lanczos")
404+
return filters
408405

409-
vf_string = ",".join(vf_filters)
406+
def _run_ffmpeg_extraction(self, video_path: str, output_dir: str, fps: int,
407+
output_format: str, width: int, duration: Optional[float] = None,
408+
color_info: Optional['VideoColorInfo'] = None) -> bool:
409+
"""Run FFmpeg to extract frames from video with progress monitoring and color space conversion."""
410+
output_pattern = os.path.join(output_dir, f"frame_%05d.{output_format}")
411+
vf_string = ",".join(self._build_video_filters(fps, width, color_info))
410412

411413
# Use proper executable name based on platform
412414
ffmpeg_executable = 'ffmpeg.exe' if os.name == 'nt' else 'ffmpeg'

0 commit comments

Comments
 (0)