Skip to content

Commit 2df6608

Browse files
committed
Add passthrough preprocessing and file metadata in GUI
1 parent 58d5fa9 commit 2df6608

5 files changed

Lines changed: 173 additions & 36 deletions

File tree

tests/test_gui_gpu.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
from __future__ import annotations
22

33
import json
4+
import tempfile
45
import unittest
56
from pathlib import Path
67
from unittest.mock import Mock, patch
78

8-
from videomerge.gui import _build_merge_command, _detect_gui_ffmpeg_encoders, _windows_notification_script
9+
from videomerge.gui import _build_merge_command, _detect_gui_ffmpeg_encoders, _serialize_files, _windows_notification_script
10+
from videomerge.models import Orientation, VideoFile
911

1012

1113
class GuiGpuTests(unittest.TestCase):
@@ -45,6 +47,17 @@ def test_gui_command_writes_selected_file_list(self) -> None:
4547

4648
self.assertEqual(selected, ["/tmp/in/a.mp4", "/tmp/in/b.mp4"])
4749

50+
def test_serialize_files_includes_filesystem_time_and_size(self) -> None:
51+
with tempfile.TemporaryDirectory() as temp_dir:
52+
path = Path(temp_dir) / "clip.mp4"
53+
path.write_bytes(b"x" * 1536)
54+
serialized = _serialize_files([_video(path)])[0]
55+
56+
self.assertIn("modified_time", serialized)
57+
self.assertIn("file_time", serialized)
58+
self.assertEqual(serialized["file_size"], "1.5 KB")
59+
self.assertEqual(serialized["file_size_bytes"], 1536)
60+
4861
def test_windows_notification_script_uses_notify_icon_and_sound(self) -> None:
4962
script = _windows_notification_script("A&B's", "done", True)
5063

@@ -63,6 +76,30 @@ def test_macos_gui_encoder_detection_retries_until_videotoolbox_is_seen(self) ->
6376
self.assertIn("h264_videotoolbox", encoders)
6477
self.assertEqual(detect.call_count, 2)
6578

79+
def _video(path: Path) -> VideoFile:
80+
return VideoFile(
81+
path=path,
82+
container="mp4",
83+
video_codec="h264",
84+
audio_codec="aac",
85+
width=1280,
86+
height=720,
87+
display_width=1280,
88+
display_height=720,
89+
aspect_ratio="1280:720",
90+
frame_rate="30/1",
91+
frame_rate_float=30.0,
92+
pixel_format="yuv420p",
93+
duration=10.0,
94+
has_audio=True,
95+
orientation=Orientation.landscape,
96+
rotation=0,
97+
video_bitrate=2_000_000,
98+
audio_bitrate=128_000,
99+
audio_sample_rate=48000,
100+
audio_channels=2,
101+
)
102+
66103

67104
if __name__ == "__main__":
68105
unittest.main()

tests/test_transcode_rotation.py

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
build_preprocess_segments,
1212
build_video_filter,
1313
can_concat_originals,
14+
choose_passthrough_signature,
1415
choose_audio_action,
1516
choose_audio_target,
1617
choose_video_action,
@@ -207,7 +208,7 @@ def test_group_rejects_original_copy_when_concat_signature_differs(self) -> None
207208
)
208209
)
209210

210-
def test_preprocess_group_uses_safe_segments_when_any_file_needs_normalization(self) -> None:
211+
def test_preprocess_group_passthroughs_dominant_ready_files_when_some_need_normalization(self) -> None:
211212
captured_commands = []
212213
first = _plain_video()
213214
second = _plain_video().__class__(**{**_plain_video().__dict__, "path": Path("second.mp4"), "frame_rate": "30000/1001", "frame_rate_float": 29.97})
@@ -235,9 +236,9 @@ def fake_run_command(args, logger, dry_run=False): # type: ignore[no-untyped-de
235236
owner.cleanup()
236237

237238
self.assertEqual(len(outputs), 2)
238-
self.assertNotEqual(outputs[0], first.path)
239+
self.assertEqual(outputs[0], first.path)
239240
self.assertNotEqual(outputs[1], second.path)
240-
self.assertEqual(len(captured_commands), 2)
241+
self.assertEqual(len(captured_commands), 1)
241242
self.assertTrue(all("-vf" in command for command in captured_commands))
242243

243244
def test_safe_segmentation_preserves_order_and_batches_ready_runs(self) -> None:
@@ -258,11 +259,26 @@ def test_safe_segmentation_preserves_order_and_batches_ready_runs(self) -> None:
258259
self.assertEqual([segment.files for segment in segments], [[first, second], [third], [fourth]])
259260
self.assertEqual([segment.copy_compatible for segment in segments], [True, False, True])
260261

261-
def test_preprocess_group_batches_consecutive_ready_files_before_normalization(self) -> None:
262+
def test_choose_passthrough_signature_uses_most_common_ready_signature(self) -> None:
263+
first = _plain_video()
264+
second = _plain_video().__class__(**{**_plain_video().__dict__, "path": Path("second.mp4")})
265+
third = _plain_video().__class__(**{**_plain_video().__dict__, "path": Path("third.mp4"), "frame_rate": "60/2", "frame_rate_float": 30.0})
266+
audio_target = AudioTarget("aac", "aac", 48000, 2, "128k")
267+
segments = build_preprocess_segments(
268+
[first, second, third],
269+
Canvas(1280, 720),
270+
30.0,
271+
CodecPlan("h264", "aac", "libx264", "aac"),
272+
audio_target,
273+
)
274+
275+
self.assertEqual(choose_passthrough_signature(segments), choose_passthrough_signature([segments[0]]))
276+
277+
def test_preprocess_group_normalizes_non_dominant_ready_signature(self) -> None:
262278
captured_commands = []
263279
first = _plain_video()
264280
second = _plain_video().__class__(**{**_plain_video().__dict__, "path": Path("second.mp4")})
265-
third = _plain_video().__class__(**{**_plain_video().__dict__, "path": Path("third.mp4"), "frame_rate": "30000/1001", "frame_rate_float": 29.97})
281+
third = _plain_video().__class__(**{**_plain_video().__dict__, "path": Path("third.mp4"), "frame_rate": "60/2", "frame_rate_float": 30.0})
266282

267283
def fake_run_command(args, logger, dry_run=False): # type: ignore[no-untyped-def]
268284
captured_commands.append(list(args))
@@ -288,9 +304,10 @@ def fake_run_command(args, logger, dry_run=False): # type: ignore[no-untyped-de
288304

289305
concat_commands = [command for command in captured_commands if "-f" in command and "concat" in command]
290306
transcode_commands = [command for command in captured_commands if "-vf" in command]
291-
self.assertEqual(len(outputs), 2)
292-
self.assertEqual(len(concat_commands), 1)
293-
self.assertEqual(len(transcode_commands), 2)
307+
self.assertEqual(outputs[0:2], [first.path, second.path])
308+
self.assertEqual(len(outputs), 3)
309+
self.assertEqual(len(concat_commands), 0)
310+
self.assertEqual(len(transcode_commands), 1)
294311

295312

296313
if __name__ == "__main__":

videomerge/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
__all__ = ["__version__"]
22

3-
__version__ = "0.3.2"
3+
__version__ = "0.3.3"

videomerge/gui.py

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import threading
1616
import time
1717
import webbrowser
18+
from datetime import datetime
1819
from http import HTTPStatus
1920
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
2021
from pathlib import Path
@@ -252,7 +253,7 @@
252253
color: var(--text-muted);
253254
white-space: nowrap;
254255
}
255-
table { width: 100%; min-width: 840px; border-collapse: collapse; table-layout: fixed; }
256+
table { width: 100%; min-width: 1120px; border-collapse: collapse; table-layout: fixed; }
256257
th {
257258
position: sticky;
258259
top: 0;
@@ -546,16 +547,18 @@
546547
<table>
547548
<colgroup>
548549
<col style="width: 46px">
549-
<col style="width: 32%">
550+
<col style="width: 25%">
551+
<col style="width: 12%">
552+
<col style="width: 13%">
553+
<col style="width: 8%">
554+
<col style="width: 9%">
550555
<col style="width: 14%">
551-
<col style="width: 15%">
552-
<col style="width: 11%">
556+
<col style="width: 9%">
553557
<col style="width: 10%">
554-
<col style="width: 14%">
555558
</colgroup>
556559
<thead>
557560
<tr>
558-
<th class="select-head"></th><th data-i18n="filename">Filename</th><th data-i18n="resolution">Resolution</th><th data-i18n="codec">Codec</th><th>FPS</th><th data-i18n="duration">Dur</th><th data-i18n="status">Status</th>
561+
<th class="select-head"></th><th data-i18n="filename">Filename</th><th data-i18n="resolution">Resolution</th><th data-i18n="codec">Codec</th><th>FPS</th><th data-i18n="duration">Dur</th><th data-i18n="fileTime">Created/Modified</th><th data-i18n="fileSize">Size</th><th data-i18n="status">Status</th>
559562
</tr>
560563
</thead>
561564
<tbody id="fileRows"></tbody>
@@ -646,7 +649,7 @@
646649
const messages = {
647650
en: {
648651
ffmpegNotChecked: "! FFmpeg Not Checked", ffmpegChecking: "... Checking FFmpeg", ffmpegInstalled: "✓ FFmpeg Installed", ffmpegMissing: "! FFmpeg Missing", refreshFfmpeg: "Refresh FFmpeg check",
649-
sourceFiles: "Source Files", noFolderSelected: "No folder selected", selectFolder: "Select Folder", filename: "Filename", resolution: "Resolution", codec: "Codec", duration: "Dur", status: "Status",
652+
sourceFiles: "Source Files", noFolderSelected: "No folder selected", selectFolder: "Select Folder", filename: "Filename", resolution: "Resolution", codec: "Codec", duration: "Dur", fileTime: "Created/Modified", fileSize: "Size", status: "Status",
650653
processConsole: "Process Console", configuration: "Configuration", mergeStrategy: "Merge Strategy", outputSettings: "Output Settings", browse: "Browse",
651654
fastMerge: "Fast Merge", optimalMerge: "Optimal Merge", extremeMerge: "Extreme Merge", lossless: "Lossless", smart: "Smart", bruteForce: "Brute Force",
652655
fastDesc: "Stream copy only. Skips incompatible groups.", optimalDesc: "Groups by orientation and transcodes when needed.", extremeDesc: "Normalizes all files into one output.",
@@ -695,7 +698,7 @@
695698
},
696699
zh: {
697700
ffmpegNotChecked: "! FFmpeg 未检查", ffmpegChecking: "... 正在检查 FFmpeg", ffmpegInstalled: "✓ FFmpeg 已安装", ffmpegMissing: "! FFmpeg 缺失", refreshFfmpeg: "重新检查 FFmpeg",
698-
sourceFiles: "源文件", noFolderSelected: "未选择文件夹", selectFolder: "选择文件夹", filename: "文件名", resolution: "分辨率", codec: "编码", duration: "时长", status: "状态",
701+
sourceFiles: "源文件", noFolderSelected: "未选择文件夹", selectFolder: "选择文件夹", filename: "文件名", resolution: "分辨率", codec: "编码", duration: "时长", fileTime: "创建/修改", fileSize: "大小", status: "状态",
699702
processConsole: "处理控制台", configuration: "配置", mergeStrategy: "合并策略", outputSettings: "输出设置", browse: "浏览",
700703
fastMerge: "快速合并", optimalMerge: "智能合并", extremeMerge: "强制合并", lossless: "无损", smart: "智能", bruteForce: "强制",
701704
fastDesc: "仅使用流复制,跳过不兼容分组。", optimalDesc: "按横竖屏分组,必要时转码。", extremeDesc: "统一所有文件到一个输出。",
@@ -963,11 +966,12 @@
963966
Object.entries(by).forEach(([orientation, group]) => {
964967
const w = Math.max(...group.map(file => file.display_width));
965968
const h = Math.max(...group.map(file => file.display_height));
966-
rows.insertAdjacentHTML("beforeend", `<tr class="group-row"><td colspan="7">${escapeHtml(t("groupLabel", { orientation, size: `${w}x${h}` }))}</td></tr>`);
969+
rows.insertAdjacentHTML("beforeend", `<tr class="group-row"><td colspan="9">${escapeHtml(t("groupLabel", { orientation, size: `${w}x${h}` }))}</td></tr>`);
967970
group.forEach(file => {
968971
const cls = file.fast_ready ? "status-ok" : "status-warn";
969972
const status = file.fast_ready ? t("ready") : t("needsTranscode");
970973
const checked = state.selectedPaths.has(file.path) ? "checked" : "";
974+
const timeTitle = `Created: ${file.created_time || "-"} | Modified: ${file.modified_time || "-"} | ${file.path}`;
971975
rows.insertAdjacentHTML("beforeend", `
972976
<tr>
973977
<td class="select-cell"><input class="file-checkbox" type="checkbox" data-path="${escapeHtml(file.path)}" ${checked}></td>
@@ -976,6 +980,8 @@
976980
<td class="mono">${escapeHtml(file.video_codec)}/${escapeHtml(file.audio_codec || "none")}</td>
977981
<td class="mono">${escapeHtml(file.fps)}</td>
978982
<td class="mono">${escapeHtml(file.duration)}</td>
983+
<td class="mono" title="${escapeHtml(timeTitle)}">${escapeHtml(file.file_time || "-")}</td>
984+
<td class="mono">${escapeHtml(file.file_size || "-")}</td>
979985
<td class="${cls}">${escapeHtml(status)}</td>
980986
</tr>`);
981987
});
@@ -1547,6 +1553,7 @@ def _serialize_files(files: list[VideoFile]) -> list[dict[str, object]]:
15471553
output = []
15481554
for file in files:
15491555
fast_ready = any(file in members and len(members) > 1 for members in fast_groups.values())
1556+
stat_info = _file_stat_info(file.path)
15501557
output.append(
15511558
{
15521559
"path": str(file.path),
@@ -1559,11 +1566,50 @@ def _serialize_files(files: list[VideoFile]) -> list[dict[str, object]]:
15591566
"duration": _format_duration(file.duration),
15601567
"orientation": file.orientation.value,
15611568
"fast_ready": fast_ready,
1569+
**stat_info,
15621570
}
15631571
)
15641572
return output
15651573

15661574

1575+
def _file_stat_info(path: Path) -> dict[str, object]:
1576+
try:
1577+
stat = path.stat()
1578+
except OSError:
1579+
return {
1580+
"created_time": "",
1581+
"modified_time": "",
1582+
"file_time": "",
1583+
"file_size": "",
1584+
"file_size_bytes": 0,
1585+
}
1586+
created_timestamp = getattr(stat, "st_birthtime", None)
1587+
created_time = _format_timestamp(created_timestamp) if created_timestamp else ""
1588+
modified_time = _format_timestamp(stat.st_mtime)
1589+
return {
1590+
"created_time": created_time,
1591+
"modified_time": modified_time,
1592+
"file_time": created_time or modified_time,
1593+
"file_size": _format_file_size(stat.st_size),
1594+
"file_size_bytes": stat.st_size,
1595+
}
1596+
1597+
1598+
def _format_timestamp(timestamp: float) -> str:
1599+
return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S")
1600+
1601+
1602+
def _format_file_size(size: int) -> str:
1603+
value = float(max(size, 0))
1604+
units = ("B", "KB", "MB", "GB", "TB")
1605+
for unit in units:
1606+
if value < 1024 or unit == units[-1]:
1607+
if unit == "B":
1608+
return f"{int(value)} {unit}"
1609+
return f"{value:.1f} {unit}"
1610+
value /= 1024
1611+
1612+
15671613
def _recommended_gpu_mode(gpu_encoders: list[str]) -> str:
15681614
available = set(gpu_encoders)
15691615
system = platform.system()

0 commit comments

Comments
 (0)