first commit
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
.venv/
|
||||
manifest/
|
||||
@@ -0,0 +1,28 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Modules
|
||||
|
||||
This is a small, flat Python video workflow. `home_video_phase1.py` scans source videos and creates daily JSON manifests and LosslessCut `.llc` projects. `review_videos.py` provides the Tkinter manifest reviewer and title editor. `build_project.py` generates OpenShot project files from a folder of clips. `make_video.py` assembles selected clips with FFmpeg; its input paths and encoder are configured in the script. `README.md` documents the workflows. `vids/` contains sample media links and a generated title SVG; `out/` contains generated manifests and editing projects. Keep generated videos, projects, and local environments (such as `.venv/`) out of source changes unless they are intentional examples.
|
||||
|
||||
## Build, Run & Development
|
||||
|
||||
There is no package build step. Use Python 3.9+ and install FFmpeg so `ffprobe` and `ffmpeg` are available on `PATH`.
|
||||
|
||||
- `py home_video_phase1.py "D:\Home Videos" "D:\Video Days" --timezone America/New_York` scans recursively and writes per-day outputs. Keep the output outside the input folder.
|
||||
- `py -m pip install -r requirements-review.txt` installs the optional VLC binding; `py review_videos.py "D:\Video Days\2024-07-11.json"` opens a manifest for review.
|
||||
- `py build_project.py "D:\Video Days" "Family Memories"` creates OpenShot projects from supported videos directly in the given folder.
|
||||
- `py make_video.py` runs the configured render pipeline; update `INPUT_FILES`, `OUTPUT_FILE`, and `ENCODER` in the script for the intended job first.
|
||||
- `py -m compileall home_video_phase1.py review_videos.py build_project.py make_video.py` checks Python syntax.
|
||||
|
||||
## Style & Testing
|
||||
|
||||
Use four spaces for indentation, standard-library imports, `snake_case` for functions and variables, and uppercase names for configuration constants. Keep CLI options explicit and preserve existing media-handling behavior. No formatter, linter, or automated test suite is configured. For changes, run the relevant command on a small disposable sample set and inspect the generated manifest/project; never use irreplaceable source footage as a test fixture.
|
||||
|
||||
## Commits & Pull Requests
|
||||
|
||||
This checkout has no Git history to establish a commit convention. Use a short imperative subject (for example, `Preserve timezone offsets in manifests`). Pull requests should explain the user-visible change, list the script or sample workflow used to validate it, and include a small before/after example when output structure changes. Link a related issue when one exists.
|
||||
|
||||
## Media & Configuration
|
||||
|
||||
Treat source video paths as user data. Confirm generated outputs go to the requested destination, avoid committing personal footage or rendered files, and document external requirements such as FFmpeg, LosslessCut, and OpenShot when changing a workflow.
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# Home video organizer: phase 1
|
||||
|
||||
This Python command scans a folder of videos recursively. It groups clips by filming date, excludes clips shorter than 5 seconds from the output, makes a LosslessCut `.llc` project for every clip longer than 3 minutes, and writes one chronologically sorted JSON manifest per day. Each manifest has an editable `title` string and a `videos` array. Source videos are never deleted or modified. A clip exactly 5 seconds long is included; a clip exactly 180 seconds long remains a video entry.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.9 or newer
|
||||
- FFmpeg's `ffprobe` on your PATH
|
||||
- LosslessCut to review/edit the generated `.llc` projects
|
||||
- On Windows, if IANA timezones are unavailable: `py -m pip install tzdata`
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
python home_video_phase1.py "/path/to/all-videos" "/path/to/day-output" --timezone America/New_York
|
||||
```
|
||||
|
||||
On Windows:
|
||||
|
||||
```powershell
|
||||
py home_video_phase1.py "D:\Home Videos" "D:\Video Days" --timezone America/New_York
|
||||
```
|
||||
|
||||
For one date, add `--date 2024-07-11`. The output directory must be outside the input directory. If the video has neither a recognized embedded recording date nor a date in its filename, the script skips it with a message. Use `--fallback mtime` if you want such files assigned by filesystem modification time (which can change when copying files).
|
||||
|
||||
## Date and ordering
|
||||
|
||||
The command uses the file's embedded QuickTime `com.apple.quicktime.creationdate` or `creation_time` when available, then a `YYYYMMDD[_HHMMSS]` or `YYYY-MM-DD[_HH-MM-SS]` style filename. Date-only filenames are placed at midnight, with the path used to break ties. Embedded UTC dates are converted to `--timezone` before grouping and sorting. Choose the timezone where the videos were filmed. If the cameras have wrong clocks, correct their metadata or names before running the command.
|
||||
|
||||
## Output
|
||||
|
||||
For example, `2024-07-11.json` contains a title and an array of absolute paths:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "",
|
||||
"videos": [
|
||||
"/path/to/day-output/projects/2024-07-11/long-3f7a4c5d12.llc",
|
||||
"/path/to/all-videos/shorter.mp4"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The generated `.llc` project contains the absolute source path and one selected segment covering the whole video. Review long videos in LosslessCut to mark cuts. Set `title` when you have chosen a rendered-video title; rerunning the command preserves that string while updating `videos`. Existing array-format manifests are upgraded on the next run with an empty title. Files that fail probing or date detection are reported and cause a nonzero exit code; already successful days are still written.
|
||||
|
||||
## Review titles
|
||||
|
||||
Install the lightweight Python VLC binding into the Python environment used for the tool:
|
||||
|
||||
```powershell
|
||||
py -m pip install -r requirements-review.txt
|
||||
```
|
||||
|
||||
Open a daily manifest in the Tkinter reviewer:
|
||||
|
||||
```powershell
|
||||
py review_videos.py "D:\Video Days\2024-07-11.json"
|
||||
```
|
||||
|
||||
The reviewer uses the portable VLC installation at `C:\PortableApps\VLCPortable3` by default. Pass `--vlc-dir` or set `VLC_DIR` if it is elsewhere. VLC and Python must have matching architectures (both 64-bit, for example). It plays the source video for each entry, including the `mediaFileName` referenced by `.llc` projects; it does not apply LosslessCut edits. Use `j`/`l` to seek back/forward 10 seconds, Left/Right to change videos, and Space to pause or resume. Playback automatically starts the next manifest entry when a video ends. The title saves automatically to the manifest. Click the title box to edit; click elsewhere in the reviewer to return keyboard control to playback. Legacy array manifests are accepted and converted to the object format when saved.
|
||||
|
||||
## Render titled daily videos
|
||||
|
||||
After filling in a manifest title, render all titled daily manifests in `out/`:
|
||||
|
||||
```powershell
|
||||
py render_daily_videos.py
|
||||
```
|
||||
|
||||
The script saves one MP4 per manifest under `out\rendered` by default. Pass a different manifest folder as the first argument (for example, `py render_daily_videos.py "D:\Video Days"`) and choose another destination with `--output-dir`. Untitled manifests are skipped. Existing output files are preserved unless `--overwrite` is supplied.
|
||||
|
||||
Videos are processed in manifest order. Raw video paths use the full clip; `.llc` entries use their `mediaFileName` and the time ranges in `cutSegments` (a project with no ranges uses the full clip). Before rendering, the script probes every source, uses the highest frame rate it finds, and chooses the largest source resolution by pixel area (ties go to the wider source). It accounts for 90-degree rotation metadata. Other clips are scaled and padded to fit that output canvas. The renderer crossfades video and audio over 1.5 seconds and fades the video and audio in and out over 1.5 seconds. The trimmed manifest title starts fading in at 3 seconds, stays fully visible for 5 seconds, then fades out; each title fade lasts 0.75 seconds. At the bottom right, it shows the manifest date (formatted like `December 25, 2025`) above the title, with both lines left aligned and no text box. The title uses `C:\Windows\Fonts\MVBOLI.TTF` (MV Boli). Timing, font, fallback frame rate (30 fps if the sources report none), and encoder settings are constants near the top of `render_daily_videos.py`.
|
||||
|
||||
FFmpeg and `ffprobe` must be available on `PATH`; FFmpeg must include the `drawtext` filter. Rendering tries NVIDIA's `h264_nvenc` encoder first and falls back to `libx264` if NVENC is unavailable or fails. NVENC accelerates video encoding; the crossfade, scaling, and title filters run through FFmpeg's CPU filters.
|
||||
|
||||
An SVG can be used as a designed title template, but this FFmpeg build has no SVG decoder, so the SVG would first need to be rasterized to an image for each manifest. For the current two-line date/title overlay, `drawtext` keeps the workflow simpler. An SVG becomes useful if the title needs more elaborate graphics or decorative layout.
|
||||
|
||||
**Compatibility note:** `.llc` files use LosslessCut's version 2 project structure (`mediaFileName`, `cutSegments`). Check one generated project in your installed LosslessCut version before working through a large collection.
|
||||
@@ -0,0 +1,422 @@
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import uuid
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from collections import defaultdict
|
||||
|
||||
# --- CONFIGURATION ---
|
||||
CROSSFADE_SEC = 1.5
|
||||
FADE_IN_SEC = 1.5
|
||||
FADE_OUT_SEC = 1.5
|
||||
MIN_CLIP_DURATION = 5.0 # Skip clips under 5 seconds
|
||||
|
||||
TITLE_START_SEC = 5.0
|
||||
TITLE_HOLD_SEC = 5.0
|
||||
TITLE_FADE_SEC = 1.5
|
||||
|
||||
|
||||
def get_video_info(file_path):
|
||||
"""Extracts duration, resolution, frame rate, stream metadata, and creation date using ffprobe."""
|
||||
cmd = [
|
||||
"ffprobe", "-v", "quiet",
|
||||
"-print_format", "json",
|
||||
"-show_format", "-show_streams",
|
||||
file_path
|
||||
]
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||
data = json.loads(result.stdout)
|
||||
|
||||
duration = float(data['format']['duration'])
|
||||
|
||||
creation_str = data['format'].get('tags', {}).get('creation_time')
|
||||
if creation_str:
|
||||
dt = datetime.fromisoformat(creation_str.replace('Z', '+00:00'))
|
||||
else:
|
||||
dt = datetime.fromtimestamp(os.path.getmtime(file_path))
|
||||
|
||||
streams = data.get('streams', [])
|
||||
v_stream = next((s for s in streams if s.get('codec_type') == 'video'), {})
|
||||
a_stream = next((s for s in streams if s.get('codec_type') == 'audio'), {})
|
||||
|
||||
width = int(v_stream.get('width', 1920))
|
||||
height = int(v_stream.get('height', 1080))
|
||||
|
||||
fps_str = v_stream.get('r_frame_rate', '30/1')
|
||||
num, den = map(int, fps_str.split('/'))
|
||||
fps_val = num / den if den != 0 else 30.0
|
||||
|
||||
return {
|
||||
"duration": duration,
|
||||
"dt": dt,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"fps_val": fps_val,
|
||||
"vcodec": v_stream.get('codec_name', 'h264'),
|
||||
"acodec": a_stream.get('codec_name', 'aac'),
|
||||
"video_stream_index": v_stream.get('index', 0),
|
||||
"audio_stream_index": a_stream.get('index', 1)
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"Error reading {file_path}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def normalize_fps(fps_val):
|
||||
"""Normalizes Variable Frame Rates (VFR) to standard project rates."""
|
||||
if fps_val > 45.0:
|
||||
return {"num": 60, "den": 1, "val": 60.0}
|
||||
elif fps_val > 27.0:
|
||||
return {"num": 30, "den": 1, "val": 30.0}
|
||||
elif fps_val > 22.0:
|
||||
return {"num": 24, "den": 1, "val": 24.0}
|
||||
else:
|
||||
return {"num": int(round(fps_val)), "den": 1, "val": float(round(fps_val))}
|
||||
|
||||
|
||||
def generate_title_svg(output_path, event_name, date_str):
|
||||
"""Generates an SVG lower-third title card using hex escapes for clean copying."""
|
||||
svg_content = f"""\x3csvg xmlns="http://www.w3.org/2000/svg" width="1920" height="1080" viewBox="0 0 1920 1080"\x3e
|
||||
\x3cstyle\x3e
|
||||
.title {{ font-family: 'Arial', sans-serif; font-size: 52px; fill: white; font-weight: bold; text-anchor: middle; }}
|
||||
.subtitle {{ font-family: 'Arial', sans-serif; font-size: 34px; fill: #E0E0E0; text-anchor: middle; }}
|
||||
\x3c/style\x3e
|
||||
\x3cfilter id="shadow"\x3e
|
||||
\x3cfeDropShadow dx="2" dy="2" stdDeviation="3" flood-color="black" flood-opacity="0.8"/\x3e
|
||||
\x3c/filter\x3e
|
||||
\x3cg filter="url(#shadow)"\x3e
|
||||
\x3ctext x="960" y="920" class="title"\x3e{event_name}\x3c/text\x3e
|
||||
\x3ctext x="960" y="970" class="subtitle"\x3e{date_str}\x3c/text\x3e
|
||||
\x3c/g\x3e
|
||||
\x3c/svg\x3e"""
|
||||
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
f.write(svg_content)
|
||||
|
||||
|
||||
def make_keyframe(val):
|
||||
"""Generates OpenShot's standard keyframe curve structure with required handle_type."""
|
||||
return {
|
||||
"Points": [
|
||||
{
|
||||
"co": {"X": 1.0, "Y": float(val)},
|
||||
"interpolation": 0,
|
||||
"handle_type": 0,
|
||||
"handle_left": {"X": 0.5, "Y": 0.5},
|
||||
"handle_right": {"X": 0.5, "Y": 0.5}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def make_keyframe_points(points_sec, fps):
|
||||
"""Converts (seconds, alpha) tuples into valid OpenShot JSON keyframes."""
|
||||
pts = []
|
||||
for t, alpha in points_sec:
|
||||
frame = max(1, int(round(t * fps)) + 1)
|
||||
pts.append({
|
||||
"co": {"X": float(frame), "Y": float(alpha)},
|
||||
"interpolation": 0,
|
||||
"handle_type": 0,
|
||||
"handle_left": {"X": 0.5, "Y": 0.5},
|
||||
"handle_right": {"X": 0.5, "Y": 0.5}
|
||||
})
|
||||
return {"Points": pts}
|
||||
|
||||
|
||||
def create_openshot_clip(file_id, file_reader, position, start, end, layer=1, title=""):
|
||||
"""Creates a fully compliant OpenShot clip object matching native 3.x schema."""
|
||||
clip_duration = float(end - start)
|
||||
|
||||
return {
|
||||
"id": str(uuid.uuid4()),
|
||||
"file_id": file_id,
|
||||
"title": title,
|
||||
"position": position,
|
||||
"start": start,
|
||||
"end": end,
|
||||
"duration": clip_duration,
|
||||
"layer": layer,
|
||||
"reader": file_reader,
|
||||
|
||||
# --- Hierarchy & Orientation Attributes ---
|
||||
"parentObjectId": "",
|
||||
"reader_orientation_mode": "reader",
|
||||
|
||||
# --- Mandatory Keyframe Property Dictionaries ---
|
||||
"location_x": make_keyframe(0.0),
|
||||
"location_y": make_keyframe(0.0),
|
||||
"scale_x": make_keyframe(1.0),
|
||||
"scale_y": make_keyframe(1.0),
|
||||
"scale": 1, # Best Fit
|
||||
"rotation": make_keyframe(0.0),
|
||||
"shear_x": make_keyframe(0.0),
|
||||
"shear_y": make_keyframe(0.0),
|
||||
"origin_x": make_keyframe(0.5),
|
||||
"origin_y": make_keyframe(0.5),
|
||||
"corner_radius": make_keyframe(0.0),
|
||||
"margin": make_keyframe(0.0),
|
||||
"alpha": make_keyframe(1.0),
|
||||
"volume": make_keyframe(1.0),
|
||||
"time": make_keyframe(1.0),
|
||||
|
||||
# --- Perspective Keyframes ---
|
||||
"perspective_c1_x": make_keyframe(-0.5),
|
||||
"perspective_c1_y": make_keyframe(-0.5),
|
||||
"perspective_c2_x": make_keyframe(0.5),
|
||||
"perspective_c2_y": make_keyframe(-0.5),
|
||||
"perspective_c3_x": make_keyframe(-0.5),
|
||||
"perspective_c3_y": make_keyframe(0.5),
|
||||
"perspective_c4_x": make_keyframe(0.5),
|
||||
"perspective_c4_y": make_keyframe(0.5),
|
||||
|
||||
# --- Audio, Channel & Display Defaults ---
|
||||
"channel_filter": make_keyframe(-1.0),
|
||||
"channel_mapping": make_keyframe(-1.0),
|
||||
"has_audio": make_keyframe(-1.0), # -1.0 default sentinel for enabled stream
|
||||
"has_video": make_keyframe(-1.0), # -1.0 default sentinel for enabled stream
|
||||
"waveform": False,
|
||||
"waveform_mode": 1,
|
||||
"wave_color": {
|
||||
"red": make_keyframe(0.0),
|
||||
"green": make_keyframe(123.0),
|
||||
"blue": make_keyframe(255.0),
|
||||
"alpha": make_keyframe(255.0)
|
||||
},
|
||||
"anchor": 0,
|
||||
"gravity": 4, # Center alignment
|
||||
"composite": 0,
|
||||
"mixing": 0,
|
||||
"display": 0
|
||||
}
|
||||
|
||||
|
||||
def create_osp_project(date_str, event_name, video_clips, output_dir):
|
||||
"""Assembles OpenShot project JSON with standard schema and normalized FPS."""
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
max_raw_fps = max(c["fps_val"] for c in video_clips)
|
||||
fps_info = normalize_fps(max_raw_fps)
|
||||
master_fps = fps_info["val"]
|
||||
|
||||
max_width = max(c["width"] for c in video_clips)
|
||||
max_height = max(c["height"] for c in video_clips)
|
||||
|
||||
# Generate Title SVG
|
||||
title_svg_path = os.path.abspath(os.path.join(output_dir, f"title_{date_str}.svg"))
|
||||
formatted_date = datetime.strptime(date_str, "%Y-%m-%d").strftime("%B %d, %Y")
|
||||
generate_title_svg(title_svg_path, event_name, formatted_date)
|
||||
|
||||
osp_files = []
|
||||
osp_clips = []
|
||||
|
||||
# 1. Title File & Reader Setup
|
||||
title_file_id = str(uuid.uuid4())
|
||||
title_dur = TITLE_FADE_SEC + TITLE_HOLD_SEC + TITLE_FADE_SEC
|
||||
title_video_length = int(round(title_dur * master_fps))
|
||||
|
||||
title_reader = {
|
||||
"id": title_file_id,
|
||||
"path": title_svg_path,
|
||||
"type": "QtImageReader",
|
||||
"duration": title_dur,
|
||||
"video_length": title_video_length,
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"has_audio": False,
|
||||
"has_video": True,
|
||||
"fps": {"num": fps_info["num"], "den": fps_info["den"]}
|
||||
}
|
||||
|
||||
osp_files.append({
|
||||
"id": title_file_id,
|
||||
"path": title_svg_path,
|
||||
"media_type": "image",
|
||||
"duration": title_dur,
|
||||
"video_length": title_video_length,
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"fps": {"num": fps_info["num"], "den": fps_info["den"]},
|
||||
"has_audio": False,
|
||||
"has_video": True
|
||||
})
|
||||
|
||||
# Title Clip Creation
|
||||
title_clip = create_openshot_clip(
|
||||
file_id=title_file_id,
|
||||
file_reader=title_reader,
|
||||
position=TITLE_START_SEC,
|
||||
start=0.0,
|
||||
end=title_dur,
|
||||
layer=4,
|
||||
title=f"title_{date_str}.svg"
|
||||
)
|
||||
title_clip["alpha"] = make_keyframe_points([
|
||||
(0.0, 0.0),
|
||||
(TITLE_FADE_SEC, 1.0),
|
||||
(TITLE_FADE_SEC + TITLE_HOLD_SEC, 1.0),
|
||||
(title_dur, 0.0)
|
||||
], master_fps)
|
||||
osp_clips.append(title_clip)
|
||||
|
||||
# 2. Video Files & Clips Creation
|
||||
current_pos = 0.0
|
||||
num_clips = len(video_clips)
|
||||
|
||||
for idx, c in enumerate(video_clips):
|
||||
file_id = str(uuid.uuid4())
|
||||
dur = c["duration"]
|
||||
file_path = os.path.abspath(c["path"])
|
||||
video_length = int(round(dur * master_fps))
|
||||
file_size = os.path.getsize(file_path) if os.path.exists(file_path) else 0
|
||||
|
||||
# Reader Object attached to the Clip
|
||||
video_reader = {
|
||||
"id": file_id,
|
||||
"path": file_path,
|
||||
"type": "FFmpegReader",
|
||||
"duration": dur,
|
||||
"video_length": video_length,
|
||||
"width": c["width"],
|
||||
"height": c["height"],
|
||||
"video_stream_index": c.get("video_stream_index", 0),
|
||||
"audio_stream_index": c.get("audio_stream_index", 1),
|
||||
"vcodec": c.get("vcodec", "h264"),
|
||||
"acodec": c.get("acodec", "aac"),
|
||||
"fps": {"num": fps_info["num"], "den": fps_info["den"]},
|
||||
"sample_rate": 44100,
|
||||
"channels": 2,
|
||||
"channel_layout": 3,
|
||||
"has_audio": True,
|
||||
"has_video": True,
|
||||
"pixel_format": 12,
|
||||
"pixel_ratio": {"num": 1, "den": 1},
|
||||
"display_ratio": {"num": 16, "den": 9}
|
||||
}
|
||||
|
||||
# Flattened top-level File Object in osp_files
|
||||
osp_files.append({
|
||||
"id": file_id,
|
||||
"path": file_path,
|
||||
"media_type": "video",
|
||||
"duration": dur,
|
||||
"file_size": file_size,
|
||||
"width": c["width"],
|
||||
"height": c["height"],
|
||||
"video_length": video_length,
|
||||
"video_stream_index": c.get("video_stream_index", 0),
|
||||
"audio_stream_index": c.get("audio_stream_index", 1),
|
||||
"vcodec": c.get("vcodec", "h264"),
|
||||
"acodec": c.get("acodec", "aac"),
|
||||
"fps": {"num": fps_info["num"], "den": fps_info["den"]},
|
||||
"sample_rate": 44100,
|
||||
"channels": 2,
|
||||
"channel_layout": 3,
|
||||
"has_audio": True,
|
||||
"has_video": True,
|
||||
"pixel_format": 12,
|
||||
"pixel_ratio": {"num": 1, "den": 1},
|
||||
"display_ratio": {"num": 16, "den": 9}
|
||||
})
|
||||
|
||||
layer = 2 if (idx % 2 == 0) else 3
|
||||
|
||||
alpha_pts = []
|
||||
if idx == 0:
|
||||
alpha_pts.extend([(0.0, 0.0), (FADE_IN_SEC, 1.0)])
|
||||
else:
|
||||
alpha_pts.extend([(0.0, 0.0), (CROSSFADE_SEC, 1.0)])
|
||||
|
||||
if idx == num_clips - 1:
|
||||
alpha_pts.extend([(dur - FADE_OUT_SEC, 1.0), (dur, 0.0)])
|
||||
else:
|
||||
alpha_pts.extend([(dur - CROSSFADE_SEC, 1.0), (dur, 0.0)])
|
||||
|
||||
video_clip = create_openshot_clip(
|
||||
file_id=file_id,
|
||||
file_reader=video_reader,
|
||||
position=current_pos,
|
||||
start=0.0,
|
||||
end=dur,
|
||||
layer=layer,
|
||||
title=os.path.basename(file_path)
|
||||
)
|
||||
video_clip["alpha"] = make_keyframe_points(alpha_pts, master_fps)
|
||||
osp_clips.append(video_clip)
|
||||
|
||||
current_pos += (dur - CROSSFADE_SEC)
|
||||
|
||||
# Validated OpenShot Project Schema
|
||||
osp_data = {
|
||||
"width": max_width,
|
||||
"height": max_height,
|
||||
"fps": {"num": fps_info["num"], "den": fps_info["den"]},
|
||||
"display_ratio": {"num": 16, "den": 9},
|
||||
"pixel_ratio": {"num": 1, "den": 1},
|
||||
"sample_rate": 44100,
|
||||
"channels": 2,
|
||||
"channel_layout": 3,
|
||||
"settings": {},
|
||||
"profile": "Custom",
|
||||
"files": osp_files,
|
||||
"clips": osp_clips,
|
||||
"effects": [],
|
||||
"markers": [],
|
||||
"layers": [
|
||||
{"id": "L1", "layer": 1, "label": "Track 1", "number": 1},
|
||||
{"id": "L2", "layer": 2, "label": "Track 2", "number": 2},
|
||||
{"id": "L3", "layer": 3, "label": "Track 3", "number": 3},
|
||||
{"id": "L4", "layer": 4, "label": "Track 4", "number": 4},
|
||||
{"id": "L5", "layer": 5, "label": "Track 5", "number": 5}
|
||||
],
|
||||
"history": {"undo": [], "redo": []},
|
||||
"scale": 1.0,
|
||||
"tick_method": "FRAME",
|
||||
"version": {
|
||||
"openshot-qt": "3.1.1",
|
||||
"libopenshot": "0.3.2"
|
||||
}
|
||||
}
|
||||
|
||||
osp_filename = os.path.join(output_dir, f"{date_str}_{event_name.replace(' ', '_')}.osp")
|
||||
with open(osp_filename, "w", encoding="utf-8") as f:
|
||||
json.dump(osp_data, f, indent=2)
|
||||
|
||||
print(f"Project created: {osp_filename} ({max_width}x{max_height} @ {fps_info['num']}/{fps_info['den']} fps)")
|
||||
|
||||
|
||||
def process_folder(input_folder, event_name="Home Video"):
|
||||
valid_exts = ('.mp4', '.mov', '.m4v', '.mkv', '.avi')
|
||||
videos_by_day = defaultdict(list)
|
||||
|
||||
for fname in sorted(os.listdir(input_folder)):
|
||||
if fname.lower().endswith(valid_exts):
|
||||
fpath = os.path.join(input_folder, fname)
|
||||
info = get_video_info(fpath)
|
||||
|
||||
if not info:
|
||||
continue
|
||||
if info["duration"] < MIN_CLIP_DURATION:
|
||||
print(f"Skipping short clip (< {MIN_CLIP_DURATION}s): {fname}")
|
||||
continue
|
||||
|
||||
info["path"] = fpath
|
||||
day_key = info["dt"].strftime("%Y-%m-%d")
|
||||
videos_by_day[day_key].append(info)
|
||||
|
||||
for day_key, clips in videos_by_day.items():
|
||||
if clips:
|
||||
print(f"\nProcessing {len(clips)} clip(s) for date: {day_key}")
|
||||
create_osp_project(day_key, event_name, clips, output_dir=input_folder)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python build_openshot_project.py [Folder Path] [Event Title]")
|
||||
sys.exit(1)
|
||||
|
||||
folder_path = sys.argv[1]
|
||||
title = sys.argv[2] if len(sys.argv) > 2 else "Family Memories"
|
||||
process_folder(folder_path, title)
|
||||
Binary file not shown.
@@ -0,0 +1,230 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build chronological, per-day editing manifests for home videos."""
|
||||
|
||||
import argparse
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
VIDEO_EXTENSIONS = {".mp4", ".mov", ".m4v", ".mkv", ".avi", ".webm", ".mts", ".m2ts", ".3gp"}
|
||||
NAME_DATE = re.compile(r"(?<!\d)(20\d{2})[-_.]?([01]\d)[-_.]?([0-3]\d)(?:[T _.-]?([0-2]\d)[-_.]?([0-5]\d)[-_.]?([0-5]\d))?(?!\d)")
|
||||
|
||||
|
||||
def probe(path):
|
||||
result = subprocess.run(
|
||||
["ffprobe", "-v", "error", "-show_format", "-show_streams", "-of", "json", str(path)],
|
||||
capture_output=True, text=True, check=True,
|
||||
)
|
||||
data = json.loads(result.stdout)
|
||||
if not any(stream.get("codec_type") == "video" for stream in data.get("streams", [])):
|
||||
raise ValueError("no video stream")
|
||||
durations = [data.get("format", {}).get("duration")]
|
||||
durations += [s.get("duration") for s in data.get("streams", []) if s.get("codec_type") == "video"]
|
||||
duration = next((float(x) for x in durations if x is not None and float(x) > 0), None)
|
||||
if duration is None:
|
||||
raise ValueError("no positive duration")
|
||||
return data, duration
|
||||
|
||||
|
||||
def recorded_at(path, data, local_zone, fallback):
|
||||
# QuickTime metadata is typically UTC. An embedded offset takes precedence.
|
||||
tags = [data.get("format", {}).get("tags", {})]
|
||||
tags += [s.get("tags", {}) for s in data.get("streams", [])]
|
||||
for key in ("com.apple.quicktime.creationdate", "creation_time", "date"):
|
||||
for group in tags:
|
||||
value = next((v for k, v in group.items() if k.lower() == key), None)
|
||||
if not value:
|
||||
continue
|
||||
try:
|
||||
stamp = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
if stamp.tzinfo is None:
|
||||
stamp = stamp.replace(tzinfo=local_zone if key != "creation_time" else timezone.utc)
|
||||
return stamp.astimezone(local_zone), "metadata"
|
||||
except ValueError:
|
||||
pass
|
||||
match = NAME_DATE.search(path.stem)
|
||||
if match:
|
||||
try:
|
||||
y, m, d, h, minute, second = match.groups()
|
||||
return datetime(int(y), int(m), int(d), int(h or 0), int(minute or 0), int(second or 0), tzinfo=local_zone), "filename"
|
||||
except ValueError:
|
||||
pass
|
||||
if fallback == "mtime":
|
||||
return datetime.fromtimestamp(path.stat().st_mtime, local_zone), "mtime"
|
||||
raise ValueError("no recording timestamp; add a dated filename or use --fallback mtime")
|
||||
|
||||
|
||||
def write_json(path, value):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def existing_manifest_title(path):
|
||||
"""Return a title already set in a daily manifest, if one exists."""
|
||||
try:
|
||||
manifest = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return ""
|
||||
if isinstance(manifest, dict) and isinstance(manifest.get("title"), str):
|
||||
return manifest["title"]
|
||||
return ""
|
||||
|
||||
|
||||
def normalized_path(value):
|
||||
return os.path.normcase(os.path.abspath(str(value)))
|
||||
|
||||
|
||||
def find_existing_project(project_root, source):
|
||||
"""Find a project from an earlier layout, preferring a direct path match."""
|
||||
wanted = normalized_path(source)
|
||||
for candidate in project_root.rglob("*.llc"):
|
||||
try:
|
||||
data = json.loads(candidate.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
media_name = data.get("mediaFileName", "")
|
||||
if media_name and normalized_path(media_name) == wanted:
|
||||
return candidate, data
|
||||
metadata_path = candidate.parent / "source.json"
|
||||
try:
|
||||
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
metadata = {}
|
||||
if metadata.get("source_path") and normalized_path(metadata["source_path"]) == wanted:
|
||||
return candidate, data
|
||||
return None, None
|
||||
|
||||
|
||||
def remove_old_project_artifacts(old_project, source, project_root):
|
||||
if old_project is None or not old_project.exists():
|
||||
return
|
||||
old_dir = old_project.parent
|
||||
alias = old_dir / source.name
|
||||
if alias.exists():
|
||||
try:
|
||||
if alias.samefile(source):
|
||||
alias.unlink()
|
||||
print(f"Removed obsolete hard-link entry: {alias}")
|
||||
except OSError as exc:
|
||||
print(f"Warning: could not remove old media link {alias}: {exc}", file=sys.stderr)
|
||||
metadata_path = old_dir / "source.json"
|
||||
try:
|
||||
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
|
||||
if metadata.get("source_path") and normalized_path(metadata["source_path"]) == normalized_path(source):
|
||||
metadata_path.unlink()
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
old_project.unlink(missing_ok=True)
|
||||
current = old_dir
|
||||
while current != project_root:
|
||||
try:
|
||||
current.rmdir()
|
||||
except OSError:
|
||||
break
|
||||
current = current.parent
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("input", type=Path, help="folder containing videos (searched recursively)")
|
||||
parser.add_argument("output", type=Path, help="folder for daily JSON manifests and .llc files")
|
||||
parser.add_argument("--timezone", default="UTC", help="IANA filming timezone, e.g. America/New_York (default: UTC)")
|
||||
parser.add_argument("--fallback", choices=("skip", "mtime"), default="skip", help="date source when metadata and filename lack a date")
|
||||
parser.add_argument("--date", help="only produce one filming day, YYYY-MM-DD")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
local_zone = ZoneInfo(args.timezone)
|
||||
except ZoneInfoNotFoundError:
|
||||
parser.error("unknown timezone; install tzdata if your system lacks zone information")
|
||||
if args.date:
|
||||
try:
|
||||
datetime.strptime(args.date, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
parser.error("--date must be YYYY-MM-DD")
|
||||
source = args.input.expanduser().resolve()
|
||||
dest = args.output.expanduser().resolve()
|
||||
if not source.is_dir():
|
||||
parser.error("input must be an existing directory")
|
||||
if source == dest or source in dest.parents:
|
||||
parser.error("output must be outside input so generated media is not rescanned")
|
||||
|
||||
days = defaultdict(list)
|
||||
excluded = 0
|
||||
failed = 0
|
||||
for path in sorted(source.rglob("*")):
|
||||
if not path.is_file() or path.suffix.lower() not in VIDEO_EXTENSIONS:
|
||||
continue
|
||||
try:
|
||||
data, duration = probe(path)
|
||||
stamp, date_source = recorded_at(path, data, local_zone, args.fallback)
|
||||
day = stamp.date().isoformat()
|
||||
if args.date and day != args.date:
|
||||
continue
|
||||
if duration < 5:
|
||||
excluded += 1
|
||||
print(f"Excluded (<5s): {path}")
|
||||
continue
|
||||
days[day].append((stamp, path.resolve(), duration, date_source))
|
||||
except (subprocess.CalledProcessError, json.JSONDecodeError, ValueError, OSError) as exc:
|
||||
failed += 1
|
||||
print(f"Skipped {path}: {exc}", file=sys.stderr)
|
||||
|
||||
project_root = dest
|
||||
for day, videos in sorted(days.items()):
|
||||
entries = []
|
||||
for stamp, path, duration, date_source in sorted(videos, key=lambda row: (row[0], str(row[1]))):
|
||||
if duration > 120:
|
||||
base_name = f"{day}__{path.stem}"
|
||||
old_project, existing = find_existing_project(project_root, path)
|
||||
suffix = 1
|
||||
while True:
|
||||
numbered_name = base_name if suffix == 1 else f"{base_name}__{suffix}"
|
||||
llc = project_root / f"{numbered_name}.llc"
|
||||
if not llc.exists():
|
||||
break
|
||||
try:
|
||||
current = json.loads(llc.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
current = {}
|
||||
if current.get("mediaFileName") and normalized_path(current["mediaFileName"]) == normalized_path(path):
|
||||
old_project, existing = llc, current
|
||||
break
|
||||
suffix += 1
|
||||
try:
|
||||
if existing is None:
|
||||
existing = {"version": 2, "mediaFileName": str(path),
|
||||
"cutSegments": [{"start": 0, "end": duration, "name": "", "selected": True}]}
|
||||
|
||||
# This LosslessCut build supports an absolute mediaFileName.
|
||||
existing["version"] = 2
|
||||
existing["mediaFileName"] = str(path)
|
||||
segments = existing.setdefault("cutSegments", [])
|
||||
for segment in segments:
|
||||
segment.setdefault("name", "")
|
||||
segment.setdefault("selected", True)
|
||||
write_json(llc, existing)
|
||||
if old_project != llc:
|
||||
remove_old_project_artifacts(old_project, path, project_root)
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise ValueError(f"cannot reuse existing project {llc}: {exc}") from exc
|
||||
target = llc
|
||||
else:
|
||||
target = path
|
||||
entries.append(str(target))
|
||||
if date_source == "mtime":
|
||||
print(f"Warning: filesystem modified time used for {path}", file=sys.stderr)
|
||||
manifest = dest / f"{day}.json"
|
||||
write_json(manifest, {"title": existing_manifest_title(manifest), "videos": entries})
|
||||
print(f"{manifest}: {len(entries)} videos")
|
||||
print(f"Finished: {len(days)} days, {excluded} short clips excluded, {failed} files skipped")
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import re
|
||||
|
||||
# --- CONFIGURATION ---
|
||||
VIDS_DIR = "./vids" # Subfolder containing video and .llc files
|
||||
XFADE_DUR = 1.5 # Duration of crossfades between segments/videos (seconds)
|
||||
FADE_IN_OUT_DUR = 1.5 # Duration of global Fade In (start) and Fade Out (end)
|
||||
OUTPUT_FILE = "final_edited_video.mp4"
|
||||
|
||||
# Video Encoder Options: "h264_nvenc" (NVIDIA GPU), "h264_qsv" (Intel), "h264_amf" (AMD), "libx264" (CPU)
|
||||
ENCODER = "hevc_nvenc"
|
||||
|
||||
# Explicit input pairs (Targeting ./vids)
|
||||
INPUT_FILES = [
|
||||
(
|
||||
os.path.join(VIDS_DIR, "PXL_20260201_163136402.TS.mp4"),
|
||||
os.path.join(VIDS_DIR, "PXL_20260201_163136402.TS-proj.llc")
|
||||
),
|
||||
(
|
||||
os.path.join(VIDS_DIR, "PXL_20260201_163651197.TS.mp4"),
|
||||
os.path.join(VIDS_DIR, "PXL_20260201_163651197.TS-proj.llc")
|
||||
),
|
||||
(
|
||||
os.path.join(VIDS_DIR, "PXL_20260201_163930934.TS.mp4"),
|
||||
os.path.join(VIDS_DIR, "PXL_20260201_163930934.TS-proj.llc")
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def get_video_duration(video_path):
|
||||
"""Uses ffprobe to retrieve the exact duration of an input video file."""
|
||||
cmd = [
|
||||
"ffprobe", "-v", "quiet",
|
||||
"-print_format", "json",
|
||||
"-show_format", video_path
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||
data = json.loads(result.stdout)
|
||||
return float(data["format"]["duration"])
|
||||
|
||||
|
||||
def parse_llc_content(raw_text):
|
||||
"""Converts a JavaScript object literal into valid JSON syntax."""
|
||||
# 1. Convert single-quoted strings to double-quoted strings
|
||||
cleaned = re.sub(r"'([^'\\]*(?:\\.[^'\\]*)*)'", r'"\1"', raw_text)
|
||||
|
||||
# 2. Enclose unquoted keys in double quotes (e.g., version: -> "version":)
|
||||
cleaned = re.sub(r'([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*:', r'\1"\2":', cleaned)
|
||||
|
||||
# 3. Remove trailing commas before closing braces or brackets
|
||||
cleaned = re.sub(r',\s*([}\]])', r'\1', cleaned)
|
||||
|
||||
return json.loads(cleaned)
|
||||
|
||||
def get_kept_segments(llc_path, total_duration):
|
||||
"""
|
||||
Parses LosslessCut .llc project files (supports strict JSON and JS object literals).
|
||||
Assumes cutSegments represent discarded regions (cutouts to remove).
|
||||
Returns a list of kept tuples: [(kept_start, kept_end), ...]
|
||||
"""
|
||||
if not os.path.exists(llc_path):
|
||||
print(f"Warning: {llc_path} not found. Keeping full video.")
|
||||
return [(0.0, total_duration)]
|
||||
|
||||
with open(llc_path, "r", encoding="utf-8") as f:
|
||||
content = f.read().strip()
|
||||
|
||||
if not content:
|
||||
print(f"Warning: {llc_path} is empty. Keeping full video.")
|
||||
return [(0.0, total_duration)]
|
||||
|
||||
try:
|
||||
data = parse_llc_content(content)
|
||||
except Exception as e:
|
||||
print(f"⚠️ Could not parse '{llc_path}': {e}. Keeping full video.")
|
||||
return [(0.0, total_duration)]
|
||||
|
||||
cut_segments = data.get("cutSegments", []) if isinstance(data, dict) else []
|
||||
if not cut_segments:
|
||||
return [(0.0, total_duration)]
|
||||
|
||||
# Sort discarded cutouts by start time
|
||||
cut_segments = sorted(cut_segments, key=lambda x: float(x["start"]))
|
||||
|
||||
kept = []
|
||||
current_time = 0.0
|
||||
|
||||
for seg in cut_segments:
|
||||
cut_start = float(seg["start"])
|
||||
cut_end = float(seg["end"])
|
||||
|
||||
# If there is video content before the cut, keep it
|
||||
if cut_start > current_time:
|
||||
kept.append((current_time, cut_start))
|
||||
|
||||
current_time = max(current_time, cut_end)
|
||||
|
||||
# Keep remaining video content after the final cut
|
||||
if current_time < total_duration:
|
||||
kept.append((current_time, total_duration))
|
||||
|
||||
return kept
|
||||
|
||||
def run_pipeline():
|
||||
cmd = ["ffmpeg", "-y"]
|
||||
|
||||
# Verify input files exist
|
||||
for video_path, llc_path in INPUT_FILES:
|
||||
if not os.path.exists(video_path):
|
||||
print(f"Error: Could not find video file at '{video_path}'")
|
||||
return
|
||||
cmd.extend(["-i", video_path])
|
||||
|
||||
# Extract and collect kept segments across all videos
|
||||
all_segments = []
|
||||
for file_idx, (video_path, llc_path) in enumerate(INPUT_FILES):
|
||||
duration = get_video_duration(video_path)
|
||||
kept = get_kept_segments(llc_path, duration)
|
||||
|
||||
for start, end in kept:
|
||||
seg_dur = end - start
|
||||
if seg_dur > XFADE_DUR: # Segment must be longer than xfade duration
|
||||
all_segments.append({
|
||||
"file_idx": file_idx,
|
||||
"start": start,
|
||||
"end": end,
|
||||
"duration": seg_dur
|
||||
})
|
||||
|
||||
if not all_segments:
|
||||
print("Error: No valid kept segments found.")
|
||||
return
|
||||
|
||||
print(f"Extracted {len(all_segments)} kept segment(s) from input files in '{VIDS_DIR}'.")
|
||||
|
||||
filter_chains = []
|
||||
|
||||
# 1. Create trim filters for each kept segment
|
||||
for i, seg in enumerate(all_segments):
|
||||
f_idx = seg["file_idx"]
|
||||
s, e = seg["start"], seg["end"]
|
||||
filter_chains.append(f"[{f_idx}:v]trim=start={s:.3f}:end={e:.3f},setpts=PTS-STARTPTS[v{i}];")
|
||||
filter_chains.append(f"[{f_idx}:a]atrim=start={s:.3f}:end={e:.3f},asetpts=PTS-STARTPTS[a{i}];")
|
||||
|
||||
# 2. Chain crossfades sequentially across all segments
|
||||
current_v = "v0"
|
||||
current_a = "a0"
|
||||
cumulative_dur = all_segments[0]["duration"]
|
||||
|
||||
for i in range(1, len(all_segments)):
|
||||
next_v, next_a = f"v{i}", f"a{i}"
|
||||
offset = cumulative_dur - XFADE_DUR
|
||||
out_v, out_a = f"vc{i}", f"ac{i}"
|
||||
|
||||
filter_chains.append(
|
||||
f"[{current_v}][{next_v}]xfade=transition=fade:duration={XFADE_DUR}:offset={offset:.3f}[{out_v}];"
|
||||
)
|
||||
filter_chains.append(
|
||||
f"[{current_a}][{next_a}]acrossfade=d={XFADE_DUR}[{out_a}];"
|
||||
)
|
||||
|
||||
current_v, current_a = out_v, out_a
|
||||
cumulative_dur += all_segments[i]["duration"] - XFADE_DUR
|
||||
|
||||
# 3. Apply global Fade In at start and Fade Out at end
|
||||
final_v, final_a = "outv", "outa"
|
||||
fade_out_start = max(0.0, cumulative_dur - FADE_IN_OUT_DUR)
|
||||
|
||||
filter_chains.append(
|
||||
f"[{current_v}]fade=t=in:st=0:d={FADE_IN_OUT_DUR},fade=t=out:st={fade_out_start:.3f}:d={FADE_IN_OUT_DUR}[{final_v}];"
|
||||
)
|
||||
filter_chains.append(
|
||||
f"[{current_a}]afade=t=in:st=0:d={FADE_IN_OUT_DUR},afade=t=out:st={fade_out_start:.3f}:d={FADE_IN_OUT_DUR}[{final_a}]"
|
||||
)
|
||||
|
||||
# 4. Execute FFmpeg Command
|
||||
filtergraph = " ".join(filter_chains)
|
||||
cmd.extend([
|
||||
"-filter_complex", filtergraph,
|
||||
"-map", f"[{final_v}]",
|
||||
"-map", f"[{final_a}]",
|
||||
|
||||
# --- Video Settings ---
|
||||
"-c:v", ENCODER,
|
||||
"-tag:v", "hvc1", # Ensures standard MP4 HEVC metadata playback
|
||||
"-b:v", "20M", # Targets Pixel 7a ~20 Mbps bitrate
|
||||
"-maxrate", "25M",
|
||||
"-bufsize", "30M",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-color_primaries", "bt709", # Maintains Pixel 7a BT.709 color accuracy
|
||||
"-color_trc", "bt709",
|
||||
"-colorspace", "bt709",
|
||||
"-preset", "p4" if "nvenc" in ENCODER else "fast",
|
||||
|
||||
# --- Audio Settings ---
|
||||
"-c:a", "aac",
|
||||
"-ar", "48000", # Matches 48 kHz source audio sampling
|
||||
"-b:a", "192k", # Matches 192 kbps audio bitrate
|
||||
|
||||
OUTPUT_FILE
|
||||
])
|
||||
|
||||
print(f"Estimated final output duration: {cumulative_dur:.2f} seconds.")
|
||||
print("Running FFmpeg pipeline...")
|
||||
subprocess.run(cmd, check=True)
|
||||
print(f"Done! Saved output to: {OUTPUT_FILE}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_pipeline()
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"title": "Rolling Down the Hill",
|
||||
"videos": [
|
||||
"D:\\Python Utilities\\HomeVideo\\vids\\PXL_20260201_163136402.TS.mp4",
|
||||
"D:\\Python Utilities\\HomeVideo\\vids\\PXL_20260201_163651197.TS.mp4",
|
||||
"D:\\Python Utilities\\HomeVideo\\vids\\PXL_20260201_163930934.TS.mp4"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": 2,
|
||||
"mediaFileName": "D:\\Python Utilities\\HomeVideo\\vids\\PXL_20260201_163136402.TS.mp4",
|
||||
"cutSegments": [
|
||||
{
|
||||
"start": 0,
|
||||
"end": 12.3533,
|
||||
"name": "",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": 2,
|
||||
"mediaFileName": "D:\\Python Utilities\\HomeVideo\\vids\\PXL_20260201_163651197.TS.mp4",
|
||||
"cutSegments": [
|
||||
{
|
||||
"start": 0,
|
||||
"end": 38.599589,
|
||||
"name": "",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": 2,
|
||||
"mediaFileName": "D:\\Python Utilities\\HomeVideo\\vids\\PXL_20260201_163930934.TS.mp4",
|
||||
"cutSegments": [
|
||||
{
|
||||
"start": 0,
|
||||
"end": 13.826289,
|
||||
"name": "",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,662 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render titled daily video manifests into crossfaded MP4 videos."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from fractions import Fraction
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# --- CONFIGURATION ---
|
||||
XFADE_DURATION = 1.5 # Crossfade duration between every pair of segments (seconds)
|
||||
FADE_IN_OUT_DURATION = 1.5 # Fade duration at the beginning and end (seconds)
|
||||
TITLE_START = 3.0 # Time at which the title starts appearing (seconds)
|
||||
TITLE_DURATION = 5.0 # Full-opacity hold time between the title fades (seconds)
|
||||
TITLE_FADE_DURATION = 0.75 # Title fade-in and fade-out duration (seconds)
|
||||
DEFAULT_OUTPUT_FPS = Fraction(30, 1)
|
||||
TITLE_FONT_SIZE = 50
|
||||
TITLE_FONT_FAMILY = "MV Boli"
|
||||
TITLE_FONT_FILE = r"C:\Windows\Fonts\MVBOLI.TTF"
|
||||
TITLE_MARGIN_RIGHT = 72
|
||||
TITLE_MARGIN_BOTTOM = 60
|
||||
TITLE_LINE_SPACING = 10
|
||||
TITLE_RENDERING_PADDING_SPACES = 2 # Extra invisible line width for italic glyph overhang
|
||||
PREFER_HARDWARE_ENCODING = True
|
||||
HARDWARE_ENCODER = "h264_nvenc"
|
||||
HARDWARE_PRESET = "p4"
|
||||
HARDWARE_CQ = "18"
|
||||
SOFTWARE_ENCODER = "libx264"
|
||||
SOFTWARE_PRESET = "medium"
|
||||
SOFTWARE_CRF = "18"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Segment:
|
||||
input_index: int
|
||||
start: float
|
||||
end: float
|
||||
|
||||
@property
|
||||
def duration(self):
|
||||
return self.end - self.start
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VideoProfile:
|
||||
width: int
|
||||
height: int
|
||||
fps: Optional[Fraction]
|
||||
|
||||
|
||||
def parse_frame_rate(stream):
|
||||
for key in ("avg_frame_rate", "r_frame_rate"):
|
||||
value = stream.get(key)
|
||||
if not value:
|
||||
continue
|
||||
try:
|
||||
frame_rate = Fraction(value)
|
||||
except (ValueError, ZeroDivisionError):
|
||||
continue
|
||||
if frame_rate > 0:
|
||||
return frame_rate
|
||||
return None
|
||||
|
||||
|
||||
def probe_media(path, ffprobe):
|
||||
result = subprocess.run(
|
||||
[ffprobe, "-v", "error", "-show_format", "-show_streams", "-of", "json", str(path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
data = json.loads(result.stdout)
|
||||
video_stream = next(
|
||||
(
|
||||
stream for stream in data.get("streams", [])
|
||||
if stream.get("codec_type") == "video"
|
||||
and not stream.get("disposition", {}).get("attached_pic", 0)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if video_stream is None:
|
||||
raise ValueError("no video stream")
|
||||
|
||||
duration_values = [data.get("format", {}).get("duration")]
|
||||
duration_values.append(video_stream.get("duration"))
|
||||
duration = None
|
||||
for value in duration_values:
|
||||
try:
|
||||
candidate = float(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if math.isfinite(candidate) and candidate > 0:
|
||||
duration = candidate
|
||||
break
|
||||
if duration is None:
|
||||
raise ValueError("no positive duration")
|
||||
has_audio = any(stream.get("codec_type") == "audio" for stream in data.get("streams", []))
|
||||
|
||||
try:
|
||||
width = int(video_stream["width"])
|
||||
height = int(video_stream["height"])
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise ValueError("video stream has no valid resolution") from exc
|
||||
if width <= 0 or height <= 0:
|
||||
raise ValueError("video stream has no valid resolution")
|
||||
|
||||
rotation = video_stream.get("tags", {}).get("rotate")
|
||||
for side_data in video_stream.get("side_data_list", []):
|
||||
if "rotation" in side_data:
|
||||
rotation = side_data["rotation"]
|
||||
break
|
||||
try:
|
||||
rotated_degrees = round(float(rotation or 0))
|
||||
except (TypeError, ValueError):
|
||||
rotated_degrees = 0
|
||||
if abs(rotated_degrees) % 180 == 90:
|
||||
width, height = height, width
|
||||
|
||||
return duration, has_audio, VideoProfile(width, height, parse_frame_rate(video_stream))
|
||||
|
||||
|
||||
def read_json(path):
|
||||
"""Read strict JSON and common JSON5-style LosslessCut project files."""
|
||||
raw = path.read_text(encoding="utf-8-sig")
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
# LosslessCut project files may use JSON5 syntax. Normalize comments,
|
||||
# single-quoted strings, unquoted keys, and trailing commas.
|
||||
without_comments = strip_json5_comments(raw)
|
||||
converted = convert_single_quoted_strings(without_comments)
|
||||
converted = re.sub(r"([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*:", r'\1"\2":', converted)
|
||||
converted = re.sub(r",\s*([}\]])", r"\1", converted)
|
||||
return json.loads(converted)
|
||||
|
||||
|
||||
def strip_json5_comments(value):
|
||||
result = []
|
||||
quote = None
|
||||
index = 0
|
||||
while index < len(value):
|
||||
char = value[index]
|
||||
if quote:
|
||||
result.append(char)
|
||||
if char == "\\" and index + 1 < len(value):
|
||||
index += 1
|
||||
result.append(value[index])
|
||||
elif char == quote:
|
||||
quote = None
|
||||
elif char in ("'", '"'):
|
||||
quote = char
|
||||
result.append(char)
|
||||
elif char == "/" and index + 1 < len(value) and value[index + 1] == "/":
|
||||
newline = value.find("\n", index + 2)
|
||||
if newline == -1:
|
||||
break
|
||||
result.append("\n")
|
||||
index = newline
|
||||
elif char == "/" and index + 1 < len(value) and value[index + 1] == "*":
|
||||
end = value.find("*/", index + 2)
|
||||
if end == -1:
|
||||
raise ValueError("unterminated comment in JSON5 project")
|
||||
result.extend("\n" for line in value[index:end + 2].splitlines()[1:])
|
||||
index = end + 1
|
||||
else:
|
||||
result.append(char)
|
||||
index += 1
|
||||
return "".join(result)
|
||||
|
||||
|
||||
def convert_single_quoted_strings(value):
|
||||
result = []
|
||||
index = 0
|
||||
while index < len(value):
|
||||
if value[index] == '"':
|
||||
result.append(value[index])
|
||||
index += 1
|
||||
while index < len(value):
|
||||
char = value[index]
|
||||
result.append(char)
|
||||
index += 1
|
||||
if char == "\\" and index < len(value):
|
||||
result.append(value[index])
|
||||
index += 1
|
||||
elif char == '"':
|
||||
break
|
||||
continue
|
||||
if value[index] != "'":
|
||||
result.append(value[index])
|
||||
index += 1
|
||||
continue
|
||||
result.append('"')
|
||||
index += 1
|
||||
while index < len(value):
|
||||
char = value[index]
|
||||
if char == "'":
|
||||
result.append('"')
|
||||
index += 1
|
||||
break
|
||||
if char == "\\" and index + 1 < len(value):
|
||||
following = value[index + 1]
|
||||
if following == "'":
|
||||
result.append("'")
|
||||
elif following == '"':
|
||||
result.append('\\"')
|
||||
else:
|
||||
result.extend(("\\", following))
|
||||
index += 2
|
||||
continue
|
||||
if char == '"':
|
||||
result.append('\\"')
|
||||
else:
|
||||
result.append(char)
|
||||
index += 1
|
||||
else:
|
||||
raise ValueError("unterminated single-quoted string in JSON5 project")
|
||||
return "".join(result)
|
||||
|
||||
|
||||
def resolve_manifest_item(value, manifest_path):
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError("manifest video entry must be a non-empty path string")
|
||||
|
||||
path = Path(value).expanduser()
|
||||
if not path.is_absolute():
|
||||
path = manifest_path.parent / path
|
||||
path = path.resolve()
|
||||
if path.suffix.lower() != ".llc":
|
||||
return path, None
|
||||
|
||||
project = read_json(path)
|
||||
if not isinstance(project, dict):
|
||||
raise ValueError(f"LosslessCut project is not an object: {path}")
|
||||
media_name = project.get("mediaFileName")
|
||||
if not isinstance(media_name, str) or not media_name.strip():
|
||||
raise ValueError(f"LosslessCut project has no mediaFileName: {path}")
|
||||
media_path = Path(media_name).expanduser()
|
||||
if not media_path.is_absolute():
|
||||
media_path = path.parent / media_path
|
||||
media_path = media_path.resolve()
|
||||
cut_segments = project.get("cutSegments") or []
|
||||
if not isinstance(cut_segments, list):
|
||||
raise ValueError(f"LosslessCut cutSegments must be an array: {path}")
|
||||
return media_path, cut_segments
|
||||
|
||||
|
||||
def load_manifest_segments(manifest_path, videos, ffprobe):
|
||||
input_paths = []
|
||||
input_audio = []
|
||||
input_profiles = []
|
||||
segments = []
|
||||
|
||||
for input_index, value in enumerate(videos):
|
||||
media_path, cut_segments = resolve_manifest_item(value, manifest_path)
|
||||
if not media_path.is_file():
|
||||
raise ValueError(f"video file does not exist: {media_path}")
|
||||
duration, has_audio, profile = probe_media(media_path, ffprobe)
|
||||
fps_text = f"{float(profile.fps):g} fps" if profile.fps is not None else "unknown fps"
|
||||
print(f" Probe {media_path.name}: {profile.width}x{profile.height}, {fps_text}")
|
||||
input_paths.append(media_path)
|
||||
input_audio.append(has_audio)
|
||||
input_profiles.append(profile)
|
||||
|
||||
if cut_segments is None or not cut_segments:
|
||||
segments.append(Segment(input_index, 0.0, duration))
|
||||
continue
|
||||
|
||||
# LosslessCut cutSegments are the time ranges to export. Markers without
|
||||
# an end time do not contain media and are ignored.
|
||||
for segment in cut_segments:
|
||||
if not isinstance(segment, dict) or segment.get("end") is None:
|
||||
continue
|
||||
try:
|
||||
start = float(segment.get("start", 0))
|
||||
end = float(segment["end"])
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"invalid cut segment in {value}: {segment!r}") from exc
|
||||
start = max(0.0, start)
|
||||
end = min(duration, end)
|
||||
if not math.isfinite(start) or not math.isfinite(end) or end <= start:
|
||||
continue
|
||||
segments.append(Segment(input_index, start, end))
|
||||
|
||||
if not segments:
|
||||
raise ValueError("manifest has no renderable video segments")
|
||||
if len(segments) > 1:
|
||||
for segment in segments:
|
||||
if segment.duration <= XFADE_DURATION:
|
||||
path = input_paths[segment.input_index]
|
||||
raise ValueError(
|
||||
f"segment in {path} is {segment.duration:.2f}s; every segment must be longer "
|
||||
f"than the {XFADE_DURATION:.2f}s crossfade"
|
||||
)
|
||||
maximum_resolution = max(
|
||||
input_profiles,
|
||||
key=lambda profile: (profile.width * profile.height, profile.width, profile.height),
|
||||
)
|
||||
maximum_fps = max(
|
||||
(profile.fps for profile in input_profiles if profile.fps is not None),
|
||||
default=DEFAULT_OUTPUT_FPS,
|
||||
)
|
||||
return (
|
||||
input_paths,
|
||||
input_audio,
|
||||
segments,
|
||||
maximum_resolution.width,
|
||||
maximum_resolution.height,
|
||||
maximum_fps,
|
||||
)
|
||||
|
||||
|
||||
def measure_title_widths(lines):
|
||||
"""Estimate relative line widths so separate FFmpeg lines stay left-aligned."""
|
||||
try:
|
||||
import tkinter as tk
|
||||
from tkinter import font as tkfont
|
||||
except ImportError:
|
||||
tk = None
|
||||
|
||||
if tk is not None:
|
||||
root = None
|
||||
try:
|
||||
root = tk.Tk()
|
||||
root.withdraw()
|
||||
title_font = tkfont.Font(root=root, family=TITLE_FONT_FAMILY, size=-TITLE_FONT_SIZE)
|
||||
return tuple(title_font.measure(line) for line in lines)
|
||||
except tk.TclError:
|
||||
# Keep rendering available in headless environments without Tk.
|
||||
pass
|
||||
finally:
|
||||
if root is not None:
|
||||
root.destroy()
|
||||
return tuple(math.ceil(len(line) * TITLE_FONT_SIZE * 0.62) for line in lines)
|
||||
|
||||
|
||||
def make_filter_graph(
|
||||
input_audio,
|
||||
segments,
|
||||
date_file_name,
|
||||
title_file_name,
|
||||
title_lines,
|
||||
output_width,
|
||||
output_height,
|
||||
output_fps,
|
||||
):
|
||||
width = max(2, output_width + output_width % 2)
|
||||
height = max(2, output_height + output_height % 2)
|
||||
fps = f"{output_fps.numerator}/{output_fps.denominator}"
|
||||
filters = []
|
||||
|
||||
for segment_index, segment in enumerate(segments):
|
||||
source = segment.input_index
|
||||
start = segment.start
|
||||
end = segment.end
|
||||
filters.append(
|
||||
f"[{source}:v:0]trim=start={start:.6f}:end={end:.6f},"
|
||||
f"setpts=PTS-STARTPTS,"
|
||||
f"scale={width}:{height}:force_original_aspect_ratio=decrease,"
|
||||
f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:color=black,"
|
||||
f"setsar=1,fps={fps},format=yuv420p,settb=AVTB[v{segment_index}]"
|
||||
)
|
||||
if input_audio[source]:
|
||||
filters.append(
|
||||
f"[{source}:a:0]atrim=start={start:.6f}:end={end:.6f},"
|
||||
f"asetpts=PTS-STARTPTS,aresample=48000,"
|
||||
f"aformat=sample_fmts=fltp:channel_layouts=stereo[a{segment_index}]"
|
||||
)
|
||||
else:
|
||||
filters.append(
|
||||
f"anullsrc=r=48000:cl=stereo,atrim=duration={segment.duration:.6f}[a{segment_index}]"
|
||||
)
|
||||
|
||||
current_video = "v0"
|
||||
current_audio = "a0"
|
||||
output_duration = segments[0].duration
|
||||
for segment_index in range(1, len(segments)):
|
||||
offset = output_duration - XFADE_DURATION
|
||||
out_video = f"xv{segment_index}"
|
||||
out_audio = f"xa{segment_index}"
|
||||
filters.append(
|
||||
f"[{current_video}][v{segment_index}]xfade=transition=fade:"
|
||||
f"duration={XFADE_DURATION:.3f}:offset={offset:.6f}[{out_video}]"
|
||||
)
|
||||
filters.append(
|
||||
f"[{current_audio}][a{segment_index}]acrossfade=d={XFADE_DURATION:.3f}[{out_audio}]"
|
||||
)
|
||||
current_video = out_video
|
||||
current_audio = out_audio
|
||||
output_duration += segments[segment_index].duration - XFADE_DURATION
|
||||
|
||||
fade_duration = min(FADE_IN_OUT_DURATION, output_duration / 2)
|
||||
fade_out_start = max(0.0, output_duration - fade_duration)
|
||||
filters.append(
|
||||
f"[{current_video}]fade=t=in:st=0:d={fade_duration:.6f},"
|
||||
f"fade=t=out:st={fade_out_start:.6f}:d={fade_duration:.6f}[fadedv]"
|
||||
)
|
||||
filters.append(
|
||||
f"[{current_audio}]afade=t=in:st=0:d={fade_duration:.6f},"
|
||||
f"afade=t=out:st={fade_out_start:.6f}:d={fade_duration:.6f}[fadeda]"
|
||||
)
|
||||
|
||||
title_fade = min(TITLE_FADE_DURATION, TITLE_DURATION / 2)
|
||||
title_fade_out_start = TITLE_START + title_fade + TITLE_DURATION
|
||||
title_end = title_fade_out_start + title_fade
|
||||
alpha = (
|
||||
f"if(lt(t,{TITLE_START:.3f}),0,"
|
||||
f"if(lt(t,{TITLE_START + title_fade:.3f}),(t-{TITLE_START:.3f})/{title_fade:.3f},"
|
||||
f"if(lt(t,{title_fade_out_start:.3f}),1,"
|
||||
f"if(lt(t,{title_end:.3f}),({title_end:.3f}-t)/{title_fade:.3f},0))))"
|
||||
)
|
||||
date_width, title_width = measure_title_widths(title_lines)
|
||||
padding_width = measure_title_widths((" " * TITLE_RENDERING_PADDING_SPACES,))[0]
|
||||
if title_width >= date_width:
|
||||
date_x = f"w-text_w-{TITLE_MARGIN_RIGHT + title_width - date_width}+{padding_width}"
|
||||
title_x = f"w-text_w-{TITLE_MARGIN_RIGHT}+{padding_width}"
|
||||
else:
|
||||
date_x = f"w-text_w-{TITLE_MARGIN_RIGHT}+{padding_width}"
|
||||
title_x = f"w-text_w-{TITLE_MARGIN_RIGHT + date_width - title_width}+{padding_width}"
|
||||
font_option = f"fontfile='{escape_filter_path(TITLE_FONT_FILE)}':" if TITLE_FONT_FILE else ""
|
||||
title_style = (
|
||||
f"{font_option}expansion=none:fontsize={TITLE_FONT_SIZE}:fontcolor=white:"
|
||||
"borderw=2:bordercolor=black:shadowcolor=black@0.8:shadowx=2:shadowy=2:"
|
||||
"fix_bounds=1:"
|
||||
)
|
||||
date_y = f"h-text_h-{TITLE_MARGIN_BOTTOM + TITLE_FONT_SIZE + TITLE_LINE_SPACING}"
|
||||
title_y = f"h-text_h-{TITLE_MARGIN_BOTTOM}"
|
||||
filters.append(
|
||||
f"[fadedv]drawtext=textfile={date_file_name}:{title_style}"
|
||||
f"x={date_x}:y={date_y}:alpha='{alpha}',"
|
||||
f"drawtext=textfile={title_file_name}:{title_style}"
|
||||
f"x={title_x}:y={title_y}:alpha='{alpha}'[outv]"
|
||||
)
|
||||
filters.append("[fadeda]anull[outa]")
|
||||
return ";".join(filters), output_duration
|
||||
|
||||
|
||||
def make_title_text(manifest_path, title):
|
||||
try:
|
||||
manifest_date = date.fromisoformat(manifest_path.stem)
|
||||
date_text = f"{manifest_date:%B} {manifest_date.day}, {manifest_date.year}"
|
||||
except ValueError:
|
||||
date_text = manifest_path.stem
|
||||
title_text = title.strip().replace("\r", " ").replace("\n", " ")
|
||||
return date_text, title_text
|
||||
|
||||
|
||||
def escape_filter_path(value):
|
||||
return str(value).replace("\\", "/").replace(":", "\\:").replace("'", "\\'")
|
||||
|
||||
|
||||
def ffmpeg_has_encoder(ffmpeg, encoder):
|
||||
result = subprocess.run(
|
||||
[ffmpeg, "-hide_banner", "-encoders"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return re.search(rf"\b{re.escape(encoder)}\b", result.stdout) is not None
|
||||
|
||||
|
||||
def ffmpeg_command(ffmpeg, input_paths, graph, output_path, overwrite, encoder):
|
||||
command = [ffmpeg, "-hide_banner", "-y" if overwrite else "-n"]
|
||||
for path in input_paths:
|
||||
command.extend(["-i", str(path)])
|
||||
command.extend([
|
||||
"-filter_complex", graph,
|
||||
"-map", "[outv]",
|
||||
"-map", "[outa]",
|
||||
])
|
||||
if encoder == HARDWARE_ENCODER:
|
||||
command.extend([
|
||||
"-c:v", HARDWARE_ENCODER,
|
||||
"-preset", HARDWARE_PRESET,
|
||||
"-cq", HARDWARE_CQ,
|
||||
"-b:v", "0",
|
||||
])
|
||||
else:
|
||||
command.extend([
|
||||
"-c:v", SOFTWARE_ENCODER,
|
||||
"-preset", SOFTWARE_PRESET,
|
||||
"-crf", SOFTWARE_CRF,
|
||||
])
|
||||
command.extend([
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-c:a", "aac",
|
||||
"-b:a", "192k",
|
||||
"-ar", "48000",
|
||||
"-movflags", "+faststart",
|
||||
str(output_path),
|
||||
])
|
||||
return command
|
||||
|
||||
|
||||
def render_manifest(manifest_path, output_path, ffmpeg, ffprobe, overwrite, use_hardware):
|
||||
manifest = read_json(manifest_path)
|
||||
if not isinstance(manifest, dict):
|
||||
print(f"Skip {manifest_path.name}: manifest is not an object")
|
||||
return "skipped"
|
||||
title = manifest.get("title")
|
||||
if not isinstance(title, str) or not title.strip():
|
||||
print(f"Skip {manifest_path.name}: title is blank")
|
||||
return "skipped"
|
||||
title = title.strip()
|
||||
videos = manifest.get("videos")
|
||||
if not isinstance(videos, list) or not videos:
|
||||
raise ValueError("manifest needs a non-empty 'videos' array")
|
||||
if output_path.exists() and not overwrite:
|
||||
print(f"Skip {manifest_path.name}: output already exists ({output_path}); pass --overwrite to replace it")
|
||||
return "skipped"
|
||||
|
||||
(
|
||||
input_paths,
|
||||
input_audio,
|
||||
segments,
|
||||
output_width,
|
||||
output_height,
|
||||
output_fps,
|
||||
) = load_manifest_segments(manifest_path, videos, ffprobe)
|
||||
text_files = []
|
||||
temporary_output = None
|
||||
try:
|
||||
date_text, title_text = make_title_text(manifest_path, title)
|
||||
for prefix, value in ((".video_date_", date_text), (".video_title_", title_text)):
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w",
|
||||
encoding="utf-8",
|
||||
newline="",
|
||||
prefix=prefix,
|
||||
suffix=".txt",
|
||||
dir=output_path.parent,
|
||||
delete=False,
|
||||
) as text_file:
|
||||
text_file.write(value + (" " * TITLE_RENDERING_PADDING_SPACES))
|
||||
text_files.append(Path(text_file.name))
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
prefix=".render_",
|
||||
suffix=output_path.suffix,
|
||||
dir=output_path.parent,
|
||||
delete=False,
|
||||
) as output_file:
|
||||
temporary_output = Path(output_file.name)
|
||||
temporary_output.unlink()
|
||||
|
||||
graph, duration = make_filter_graph(
|
||||
input_audio,
|
||||
segments,
|
||||
text_files[0].name,
|
||||
text_files[1].name,
|
||||
(date_text, title_text),
|
||||
output_width,
|
||||
output_height,
|
||||
output_fps,
|
||||
)
|
||||
print(
|
||||
f"Render {manifest_path.name}: {len(input_paths)} video(s), {len(segments)} segment(s), "
|
||||
f"output {output_width}x{output_height} at {float(output_fps):g} fps, ~{duration:.1f}s"
|
||||
)
|
||||
if use_hardware:
|
||||
hardware_command = ffmpeg_command(
|
||||
ffmpeg, input_paths, graph, temporary_output, overwrite, HARDWARE_ENCODER
|
||||
)
|
||||
try:
|
||||
subprocess.run(hardware_command, cwd=output_path.parent, check=True)
|
||||
except subprocess.CalledProcessError:
|
||||
temporary_output.unlink(missing_ok=True)
|
||||
print(
|
||||
f"NVENC failed for {manifest_path.name}; retrying with {SOFTWARE_ENCODER}.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
software_command = ffmpeg_command(
|
||||
ffmpeg, input_paths, graph, temporary_output, overwrite, SOFTWARE_ENCODER
|
||||
)
|
||||
subprocess.run(software_command, cwd=output_path.parent, check=True)
|
||||
else:
|
||||
software_command = ffmpeg_command(
|
||||
ffmpeg, input_paths, graph, temporary_output, overwrite, SOFTWARE_ENCODER
|
||||
)
|
||||
subprocess.run(software_command, cwd=output_path.parent, check=True)
|
||||
temporary_output.replace(output_path)
|
||||
finally:
|
||||
for text_file in text_files:
|
||||
text_file.unlink(missing_ok=True)
|
||||
if temporary_output is not None:
|
||||
temporary_output.unlink(missing_ok=True)
|
||||
print(f"Saved {output_path}")
|
||||
return "rendered"
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"manifest_dir",
|
||||
type=Path,
|
||||
nargs="?",
|
||||
default=Path("out"),
|
||||
help="folder containing daily .json manifests (default: ./out)",
|
||||
)
|
||||
parser.add_argument("--output-dir", type=Path, help="where to save MP4s (default: MANIFEST_DIR/rendered)")
|
||||
parser.add_argument("--overwrite", action="store_true", help="replace existing rendered MP4 files")
|
||||
parser.add_argument("--ffmpeg", default="ffmpeg", help="FFmpeg executable (default: ffmpeg on PATH)")
|
||||
parser.add_argument("--ffprobe", default="ffprobe", help="ffprobe executable (default: ffprobe on PATH)")
|
||||
args = parser.parse_args()
|
||||
|
||||
manifest_dir = args.manifest_dir.expanduser().resolve()
|
||||
if not manifest_dir.is_dir():
|
||||
parser.error("manifest_dir must be an existing directory")
|
||||
output_dir = (args.output_dir.expanduser().resolve() if args.output_dir else manifest_dir / "rendered")
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
ffmpeg = shutil.which(args.ffmpeg)
|
||||
if ffmpeg is None:
|
||||
candidate = Path(args.ffmpeg).expanduser()
|
||||
if not candidate.is_file():
|
||||
parser.error(f"FFmpeg executable not found: {args.ffmpeg}")
|
||||
ffmpeg = str(candidate.resolve())
|
||||
ffprobe = shutil.which(args.ffprobe)
|
||||
if ffprobe is None:
|
||||
candidate = Path(args.ffprobe).expanduser()
|
||||
if not candidate.is_file():
|
||||
parser.error(f"ffprobe executable not found: {args.ffprobe}")
|
||||
ffprobe = str(candidate.resolve())
|
||||
|
||||
use_hardware = PREFER_HARDWARE_ENCODING and ffmpeg_has_encoder(ffmpeg, HARDWARE_ENCODER)
|
||||
if use_hardware:
|
||||
print(f"Video encoder: {HARDWARE_ENCODER} (software fallback: {SOFTWARE_ENCODER})")
|
||||
else:
|
||||
print(f"Video encoder: {SOFTWARE_ENCODER} (NVENC unavailable or disabled)")
|
||||
|
||||
rendered = skipped = failed = 0
|
||||
manifests = sorted(path for path in manifest_dir.glob("*.json") if path.is_file())
|
||||
if not manifests:
|
||||
print(f"No .json manifests found in {manifest_dir}")
|
||||
return 0
|
||||
for manifest_path in manifests:
|
||||
output_path = output_dir / f"{manifest_path.stem}.mp4"
|
||||
try:
|
||||
result = render_manifest(
|
||||
manifest_path, output_path, ffmpeg, ffprobe, args.overwrite, use_hardware
|
||||
)
|
||||
if result == "rendered":
|
||||
rendered += 1
|
||||
else:
|
||||
skipped += 1
|
||||
except (OSError, subprocess.CalledProcessError, json.JSONDecodeError, ValueError) as exc:
|
||||
failed += 1
|
||||
print(f"Failed {manifest_path.name}: {exc}", file=sys.stderr)
|
||||
|
||||
print(f"Finished: {rendered} rendered, {skipped} skipped, {failed} failed")
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1 @@
|
||||
python-vlc==3.0.21203
|
||||
@@ -0,0 +1,640 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Review a daily JSON video manifest and save its title."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ctypes
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import struct
|
||||
import sys
|
||||
import tkinter as tk
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from tkinter import filedialog, messagebox, ttk
|
||||
|
||||
|
||||
DEFAULT_VLC_ROOT = Path(r"C:\PortableApps\VLCPortable3")
|
||||
TITLE_SAVE_DELAY_MS = 700
|
||||
SEEK_SECONDS = 10
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReviewItem:
|
||||
manifest_value: str
|
||||
path: Path | None
|
||||
error: str = ""
|
||||
|
||||
|
||||
class WindowsShortcutPoller:
|
||||
"""Poll review shortcuts on Tk's thread, including while VLC has focus."""
|
||||
|
||||
VK_LEFT = 0x25
|
||||
VK_RIGHT = 0x27
|
||||
VK_SPACE = 0x20
|
||||
VK_J = 0x4A
|
||||
VK_L = 0x4C
|
||||
GA_ROOT = 2
|
||||
POLL_INTERVAL_MS = 30
|
||||
SHORTCUTS = {
|
||||
VK_LEFT: "Left",
|
||||
VK_RIGHT: "Right",
|
||||
VK_SPACE: "space",
|
||||
VK_J: "j",
|
||||
VK_L: "l",
|
||||
}
|
||||
|
||||
def __init__(self, root: tk.Tk, title_entry: ttk.Entry, action):
|
||||
self.root = root
|
||||
self.title_entry = title_entry
|
||||
self.action = action
|
||||
self.after_id = None
|
||||
self.pressed = {key: False for key in self.SHORTCUTS}
|
||||
self.user32 = ctypes.WinDLL("user32", use_last_error=True)
|
||||
self.user32.GetForegroundWindow.restype = ctypes.c_void_p
|
||||
self.user32.GetAncestor.argtypes = (ctypes.c_void_p, ctypes.c_uint)
|
||||
self.user32.GetAncestor.restype = ctypes.c_void_p
|
||||
self.user32.GetAsyncKeyState.argtypes = (ctypes.c_int,)
|
||||
self.user32.GetAsyncKeyState.restype = ctypes.c_short
|
||||
|
||||
def start(self):
|
||||
self.poll()
|
||||
|
||||
def _reviewer_is_active(self) -> bool:
|
||||
foreground = self.user32.GetForegroundWindow()
|
||||
root_handle = self.root.winfo_id()
|
||||
app_window = self.user32.GetAncestor(root_handle, self.GA_ROOT) or root_handle
|
||||
return foreground == app_window
|
||||
|
||||
def _title_entry_has_focus(self) -> bool:
|
||||
focus = self.root.focus_get()
|
||||
return focus is not None and str(focus) == str(self.title_entry)
|
||||
|
||||
def poll(self):
|
||||
active = self._reviewer_is_active()
|
||||
title_has_focus = active and self._title_entry_has_focus()
|
||||
for virtual_key, shortcut in self.SHORTCUTS.items():
|
||||
is_down = bool(self.user32.GetAsyncKeyState(virtual_key) & 0x8000)
|
||||
if is_down and not self.pressed[virtual_key] and active and not title_has_focus:
|
||||
self.action(shortcut)
|
||||
self.pressed[virtual_key] = is_down
|
||||
self.after_id = self.root.after(self.POLL_INTERVAL_MS, self.poll)
|
||||
|
||||
def close(self):
|
||||
if self.after_id is not None:
|
||||
self.root.after_cancel(self.after_id)
|
||||
self.after_id = None
|
||||
|
||||
|
||||
def find_vlc_directory(root: Path) -> Path | None:
|
||||
"""Accept a VLC directory or common portable-app root layouts."""
|
||||
for candidate in (root, root / "App" / "vlc", root / "vlc"):
|
||||
if (candidate / "libvlc.dll").is_file():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def pe_architecture(path: Path) -> str | None:
|
||||
"""Return the architecture recorded in a Windows executable or DLL."""
|
||||
try:
|
||||
with path.open("rb") as source:
|
||||
if source.read(2) != b"MZ":
|
||||
return None
|
||||
source.seek(0x3C)
|
||||
header_offset = struct.unpack("<I", source.read(4))[0]
|
||||
source.seek(header_offset)
|
||||
if source.read(4) != b"PE\0\0":
|
||||
return None
|
||||
machine = struct.unpack("<H", source.read(2))[0]
|
||||
except (OSError, struct.error):
|
||||
return None
|
||||
return {0x014C: "x86", 0x8664: "x64", 0xAA64: "ARM64"}.get(machine)
|
||||
|
||||
|
||||
def python_architecture() -> str:
|
||||
machine = platform.machine().lower()
|
||||
if machine in {"arm64", "aarch64"}:
|
||||
return "ARM64"
|
||||
return "x64" if struct.calcsize("P") == 8 else "x86"
|
||||
|
||||
|
||||
def load_vlc(vlc_root: Path | None):
|
||||
"""Load python-vlc, configuring DLL and plugin lookup for portable VLC."""
|
||||
if vlc_root is None:
|
||||
configured = os.environ.get("VLC_DIR")
|
||||
vlc_root = Path(configured) if configured else DEFAULT_VLC_ROOT
|
||||
|
||||
vlc_dir = find_vlc_directory(vlc_root)
|
||||
if vlc_dir is None and vlc_root.exists():
|
||||
raise RuntimeError(f"Could not find libvlc.dll under {vlc_root}")
|
||||
|
||||
if vlc_dir is not None:
|
||||
library = vlc_dir / "libvlc.dll"
|
||||
plugins = vlc_dir / "plugins"
|
||||
if not plugins.is_dir():
|
||||
raise RuntimeError(f"Could not find VLC plugins directory: {plugins}")
|
||||
dll_arch = pe_architecture(library)
|
||||
python_arch = python_architecture()
|
||||
if dll_arch is not None and dll_arch != python_arch:
|
||||
raise RuntimeError(
|
||||
f"VLC is {dll_arch}, but this Python is {python_arch}. "
|
||||
f"Install {python_arch} VLC or run the reviewer with {dll_arch} Python."
|
||||
)
|
||||
os.environ["PYTHON_VLC_LIB_PATH"] = str(library)
|
||||
os.environ["PYTHON_VLC_MODULE_PATH"] = str(plugins)
|
||||
if hasattr(os, "add_dll_directory"):
|
||||
# Keep this handle alive for as long as libVLC can load plugins.
|
||||
dll_directory = os.add_dll_directory(str(vlc_dir))
|
||||
else:
|
||||
dll_directory = None
|
||||
try:
|
||||
ctypes.CDLL(str(library))
|
||||
except OSError as exc:
|
||||
raise RuntimeError(f"Windows could not load {library}: {exc}") from exc
|
||||
else:
|
||||
dll_directory = None
|
||||
|
||||
try:
|
||||
import vlc
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"python-vlc is missing. Install it with: py -m pip install -r requirements-review.txt"
|
||||
) from exc
|
||||
except (OSError, NotImplementedError) as exc:
|
||||
raise RuntimeError(f"Could not load VLC. Check the VLC runtime path: {exc}") from exc
|
||||
except SystemExit as exc:
|
||||
raise RuntimeError(
|
||||
"python-vlc found the configured DLL path but failed to load the VLC runtime. "
|
||||
"Check that VLC and Python have matching architectures."
|
||||
) from exc
|
||||
|
||||
return vlc, dll_directory, vlc_dir
|
||||
|
||||
|
||||
def resolve_item(value: object, manifest_path: Path) -> ReviewItem:
|
||||
if not isinstance(value, str):
|
||||
return ReviewItem(str(value), None, "Manifest entry is not a path string")
|
||||
|
||||
path = Path(value).expanduser()
|
||||
if not path.is_absolute():
|
||||
path = manifest_path.parent / path
|
||||
path = path.resolve()
|
||||
|
||||
if path.suffix.lower() == ".llc":
|
||||
try:
|
||||
project = json.loads(path.read_text(encoding="utf-8"))
|
||||
media_name = project.get("mediaFileName") if isinstance(project, dict) else None
|
||||
if not isinstance(media_name, str) or not media_name:
|
||||
raise ValueError("project has no mediaFileName")
|
||||
media_path = Path(media_name).expanduser()
|
||||
if not media_path.is_absolute():
|
||||
media_path = path.parent / media_path
|
||||
path = media_path.resolve()
|
||||
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
||||
return ReviewItem(value, None, f"Could not read LosslessCut project: {exc}")
|
||||
|
||||
return ReviewItem(value, path)
|
||||
|
||||
|
||||
class ManifestReviewer:
|
||||
def __init__(self, root: tk.Tk, vlc, vlc_dir: Path | None):
|
||||
self.root = root
|
||||
self.vlc = vlc
|
||||
self.vlc_dir = vlc_dir
|
||||
self.instance = vlc.Instance()
|
||||
if self.instance is None:
|
||||
raise RuntimeError("Could not initialize VLC. Check its plugins directory.")
|
||||
self.player = self.instance.media_player_new()
|
||||
# Let Tk own shortcuts and mouse handling for the embedded video area.
|
||||
self.player.video_set_key_input(False)
|
||||
self.player.video_set_mouse_input(False)
|
||||
self.current_media = None
|
||||
self.shortcut_poller: WindowsShortcutPoller | None = None
|
||||
self.manifest_path: Path | None = None
|
||||
self.manifest_data: dict = {}
|
||||
self.raw_videos: list = []
|
||||
self.items: list[ReviewItem] = []
|
||||
self.current_index = -1
|
||||
self.completion_handled_index: int | None = None
|
||||
self.title_after_id: str | None = None
|
||||
self.loading_title = False
|
||||
self.title_dirty = False
|
||||
self.duration_ms = 0
|
||||
self.dragging_seek = False
|
||||
|
||||
self.root.title("Home Video Reviewer")
|
||||
self.root.geometry("1120x720")
|
||||
self.root.minsize(800, 520)
|
||||
self.root.protocol("WM_DELETE_WINDOW", self.close)
|
||||
|
||||
outer = ttk.Frame(root, padding=10)
|
||||
outer.pack(fill="both", expand=True)
|
||||
|
||||
title_row = ttk.Frame(outer)
|
||||
title_row.pack(fill="x", pady=(0, 8))
|
||||
ttk.Label(title_row, text="Video title:").pack(side="left")
|
||||
self.title_var = tk.StringVar()
|
||||
self.title_entry = ttk.Entry(title_row, textvariable=self.title_var)
|
||||
self.title_entry.pack(side="left", fill="x", expand=True, padx=(8, 8))
|
||||
self.title_entry.bind("<Button-1>", self.focus_title_entry, add="+")
|
||||
ttk.Button(title_row, text="Open manifest…", command=self.open_dialog).pack(side="right", padx=(0, 6))
|
||||
ttk.Button(title_row, text="Save title", command=lambda: self.save_manifest(force=True)).pack(side="right")
|
||||
|
||||
content = ttk.Frame(outer)
|
||||
content.pack(fill="both", expand=True)
|
||||
content.columnconfigure(1, weight=1)
|
||||
content.rowconfigure(0, weight=1)
|
||||
|
||||
queue_frame = ttk.Frame(content, width=280)
|
||||
queue_frame.grid(row=0, column=0, sticky="nsw", padx=(0, 10))
|
||||
queue_frame.grid_propagate(False)
|
||||
ttk.Label(queue_frame, text="Videos").pack(anchor="w", pady=(0, 5))
|
||||
list_frame = ttk.Frame(queue_frame)
|
||||
list_frame.pack(fill="both", expand=True)
|
||||
self.video_list = tk.Listbox(list_frame, activestyle="dotbox", exportselection=False)
|
||||
self.video_list.pack(side="left", fill="both", expand=True)
|
||||
list_scroll = ttk.Scrollbar(list_frame, orient="vertical", command=self.video_list.yview)
|
||||
list_scroll.pack(side="right", fill="y")
|
||||
self.video_list.configure(yscrollcommand=list_scroll.set)
|
||||
self.video_list.bind("<<ListboxSelect>>", self.on_list_select)
|
||||
|
||||
self.video_frame = tk.Frame(content, background="black")
|
||||
self.video_frame.grid(row=0, column=1, sticky="nsew")
|
||||
|
||||
control_row = ttk.Frame(outer)
|
||||
control_row.pack(fill="x", pady=(8, 4))
|
||||
self.previous_button = ttk.Button(control_row, text="Previous", command=lambda: self.navigate(-1))
|
||||
self.previous_button.pack(side="left")
|
||||
self.play_button = ttk.Button(control_row, text="Play", command=self.toggle_play)
|
||||
self.play_button.pack(side="left", padx=(6, 0))
|
||||
self.next_button = ttk.Button(control_row, text="Next", command=lambda: self.navigate(1))
|
||||
self.next_button.pack(side="left", padx=(6, 12))
|
||||
self.time_var = tk.StringVar(value="0:00 / 0:00")
|
||||
ttk.Label(control_row, textvariable=self.time_var, width=13, anchor="center").pack(side="left")
|
||||
self.seek_var = tk.DoubleVar(value=0)
|
||||
self.seek_scale = ttk.Scale(control_row, from_=0, to=1, variable=self.seek_var, command=self.on_seek_drag)
|
||||
self.seek_scale.pack(side="left", fill="x", expand=True, padx=8)
|
||||
self.seek_scale.bind("<ButtonPress-1>", self.begin_seek)
|
||||
self.seek_scale.bind("<ButtonRelease-1>", self.finish_seek)
|
||||
|
||||
self.status_var = tk.StringVar(value="Open a daily JSON manifest to begin.")
|
||||
self.title_var.trace_add("write", self.schedule_title_save)
|
||||
ttk.Label(outer, textvariable=self.status_var, anchor="w").pack(fill="x", pady=(4, 0))
|
||||
|
||||
self.shortcut_tag = f"HomeVideoReviewerShortcuts{id(self)}"
|
||||
self.mouse_focus_tag = f"HomeVideoReviewerMouseFocus{id(self)}"
|
||||
self.root.bind_class(self.shortcut_tag, "<KeyPress>", self.on_key_press)
|
||||
self.root.bind_class(self.mouse_focus_tag, "<Button-1>", self.on_mouse_down)
|
||||
self.install_shortcut_bindings(self.root)
|
||||
self.root.after(250, self.update_playback_status)
|
||||
|
||||
def install_shortcut_bindings(self, widget):
|
||||
"""Clear stale entry focus on clicks and run shortcuts before class bindings."""
|
||||
tags = widget.bindtags()
|
||||
prefix = []
|
||||
if self.mouse_focus_tag not in tags:
|
||||
prefix.append(self.mouse_focus_tag)
|
||||
if widget is not self.title_entry:
|
||||
if self.shortcut_tag not in tags:
|
||||
prefix.append(self.shortcut_tag)
|
||||
if prefix:
|
||||
widget.bindtags((*prefix, *tags))
|
||||
for child in widget.winfo_children():
|
||||
self.install_shortcut_bindings(child)
|
||||
|
||||
def on_mouse_down(self, event):
|
||||
if event.widget is not self.title_entry:
|
||||
event.widget.focus_set()
|
||||
return None
|
||||
|
||||
def focus_title_entry(self, _event=None):
|
||||
self.root.after_idle(self.title_entry.focus_force)
|
||||
|
||||
def open_manifest(self, path: Path):
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
if isinstance(data, list):
|
||||
title = ""
|
||||
videos = data
|
||||
manifest_data = {}
|
||||
elif isinstance(data, dict):
|
||||
title = data.get("title", "")
|
||||
videos = data.get("videos", [])
|
||||
if not isinstance(title, str) or not isinstance(videos, list):
|
||||
raise ValueError("manifest needs a string 'title' and an array 'videos'")
|
||||
manifest_data = dict(data)
|
||||
else:
|
||||
raise ValueError("manifest must be a JSON object or a legacy array of paths")
|
||||
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
||||
messagebox.showerror("Cannot open manifest", str(exc), parent=self.root)
|
||||
return
|
||||
|
||||
if not self.save_manifest():
|
||||
return
|
||||
self.stop_video()
|
||||
self.manifest_path = path.resolve()
|
||||
self.manifest_data = manifest_data
|
||||
self.raw_videos = videos
|
||||
self.items = [resolve_item(value, self.manifest_path) for value in videos]
|
||||
self.current_index = -1
|
||||
self.completion_handled_index = None
|
||||
self.loading_title = True
|
||||
self.title_var.set(title)
|
||||
self.loading_title = False
|
||||
self.title_dirty = False
|
||||
self.video_list.delete(0, "end")
|
||||
for index, item in enumerate(self.items, start=1):
|
||||
name = item.path.name if item.path is not None else Path(item.manifest_value).name
|
||||
self.video_list.insert("end", f"{index:02d}. {name}")
|
||||
self.root.title(f"Home Video Reviewer — {self.manifest_path.name}")
|
||||
if self.items:
|
||||
self.select_video(0)
|
||||
self.video_list.focus_set()
|
||||
else:
|
||||
self.status_var.set("This manifest has no videos.")
|
||||
self.update_navigation_buttons()
|
||||
|
||||
def open_dialog(self):
|
||||
selected = filedialog.askopenfilename(
|
||||
parent=self.root,
|
||||
title="Open daily manifest",
|
||||
filetypes=(("JSON manifests", "*.json"), ("All files", "*.*")),
|
||||
)
|
||||
if selected:
|
||||
self.open_manifest(Path(selected))
|
||||
|
||||
def select_video(self, index: int):
|
||||
if index < 0 or index >= len(self.items):
|
||||
return
|
||||
self.current_index = index
|
||||
self.video_list.selection_clear(0, "end")
|
||||
self.video_list.selection_set(index)
|
||||
self.video_list.activate(index)
|
||||
self.video_list.see(index)
|
||||
self.play_item(self.items[index])
|
||||
self.update_navigation_buttons()
|
||||
|
||||
def on_list_select(self, _event):
|
||||
selected = self.video_list.curselection()
|
||||
if selected and selected[0] != self.current_index:
|
||||
self.select_video(selected[0])
|
||||
|
||||
def navigate(self, offset: int):
|
||||
target = self.current_index + offset
|
||||
if 0 <= target < len(self.items):
|
||||
self.select_video(target)
|
||||
|
||||
def update_navigation_buttons(self):
|
||||
self.previous_button.configure(state="normal" if self.current_index > 0 else "disabled")
|
||||
self.next_button.configure(state="normal" if self.current_index + 1 < len(self.items) else "disabled")
|
||||
|
||||
def play_item(self, item: ReviewItem):
|
||||
self.stop_video()
|
||||
self.completion_handled_index = None
|
||||
if item.path is None:
|
||||
self.status_var.set(item.error)
|
||||
self.play_button.configure(text="Play")
|
||||
return
|
||||
if not item.path.is_file():
|
||||
self.status_var.set(f"File not found: {item.path}")
|
||||
self.play_button.configure(text="Play")
|
||||
return
|
||||
|
||||
try:
|
||||
self.root.update_idletasks()
|
||||
self.attach_video_output()
|
||||
self.current_media = self.instance.media_new_path(str(item.path))
|
||||
self.player.set_media(self.current_media)
|
||||
self.player.play()
|
||||
self.play_button.configure(text="Pause")
|
||||
self.duration_ms = 0
|
||||
self.seek_scale.configure(to=1)
|
||||
self.seek_var.set(0)
|
||||
self.time_var.set("0:00 / 0:00")
|
||||
self.status_var.set(f"{self.current_index + 1} of {len(self.items)} — {item.path}")
|
||||
except (AttributeError, OSError, RuntimeError) as exc:
|
||||
self.status_var.set(f"Could not play {item.path.name}: {exc}")
|
||||
|
||||
def attach_video_output(self):
|
||||
window_id = self.video_frame.winfo_id()
|
||||
if sys.platform.startswith("win"):
|
||||
self.player.set_hwnd(window_id)
|
||||
elif sys.platform == "darwin":
|
||||
self.player.set_nsobject(window_id)
|
||||
else:
|
||||
self.player.set_xwindow(window_id)
|
||||
|
||||
def stop_video(self):
|
||||
try:
|
||||
self.player.stop()
|
||||
self.player.set_media(None)
|
||||
except (AttributeError, RuntimeError):
|
||||
pass
|
||||
self.current_media = None
|
||||
self.play_button.configure(text="Play")
|
||||
self.duration_ms = 0
|
||||
self.seek_scale.configure(to=1)
|
||||
self.seek_var.set(0)
|
||||
self.time_var.set("0:00 / 0:00")
|
||||
|
||||
def toggle_play(self):
|
||||
if self.current_index < 0 or self.items[self.current_index].path is None:
|
||||
return
|
||||
if self.player.is_playing():
|
||||
self.player.pause()
|
||||
self.play_button.configure(text="Play")
|
||||
elif self.player.get_media() is not None:
|
||||
self.completion_handled_index = None
|
||||
self.player.play()
|
||||
self.play_button.configure(text="Pause")
|
||||
|
||||
def seek_by(self, seconds: int):
|
||||
if self.player.get_media() is None:
|
||||
return
|
||||
current_ms = self.player.get_time()
|
||||
if current_ms < 0:
|
||||
current_ms = 0
|
||||
target_ms = max(0, current_ms + seconds * 1000)
|
||||
if self.duration_ms > 0:
|
||||
target_ms = min(target_ms, self.duration_ms)
|
||||
self.player.set_time(target_ms)
|
||||
|
||||
def begin_seek(self, _event):
|
||||
self.dragging_seek = True
|
||||
|
||||
def on_seek_drag(self, value):
|
||||
if self.dragging_seek and self.player.get_media() is not None:
|
||||
self.player.set_time(int(float(value) * 1000))
|
||||
|
||||
def finish_seek(self, _event):
|
||||
self.dragging_seek = False
|
||||
if self.player.get_media() is not None:
|
||||
self.player.set_time(int(self.seek_var.get() * 1000))
|
||||
|
||||
@staticmethod
|
||||
def format_time(milliseconds: int) -> str:
|
||||
seconds = max(0, milliseconds // 1000)
|
||||
return f"{seconds // 60}:{seconds % 60:02d}"
|
||||
|
||||
def update_playback_status(self):
|
||||
if self.player.get_media() is not None:
|
||||
if (
|
||||
self.player.get_state() == self.vlc.State.Ended
|
||||
and self.completion_handled_index != self.current_index
|
||||
):
|
||||
self.completion_handled_index = self.current_index
|
||||
if self.current_index + 1 < len(self.items):
|
||||
self.navigate(1)
|
||||
self.root.after(250, self.update_playback_status)
|
||||
return
|
||||
length = self.player.get_length()
|
||||
current = self.player.get_time()
|
||||
if length > 0:
|
||||
self.duration_ms = length
|
||||
self.seek_scale.configure(to=max(1, length / 1000))
|
||||
if current >= 0:
|
||||
if not self.dragging_seek:
|
||||
self.seek_var.set(current / 1000)
|
||||
self.time_var.set(f"{self.format_time(current)} / {self.format_time(self.duration_ms)}")
|
||||
self.play_button.configure(text="Pause" if self.player.is_playing() else "Play")
|
||||
self.root.after(250, self.update_playback_status)
|
||||
|
||||
def _title_entry_has_focus(self) -> bool:
|
||||
focus = self.root.focus_get()
|
||||
return focus is not None and str(focus) == str(self.title_entry)
|
||||
|
||||
def on_key_press(self, event):
|
||||
if event.keysym == "Left":
|
||||
key = "Left"
|
||||
elif event.keysym == "Right":
|
||||
key = "Right"
|
||||
elif event.keysym == "space":
|
||||
key = "space"
|
||||
else:
|
||||
key = event.char.lower()
|
||||
if self.shortcut_poller is not None:
|
||||
if event.widget is self.title_entry:
|
||||
return None
|
||||
if key in WindowsShortcutPoller.SHORTCUTS.values():
|
||||
return "break"
|
||||
return None
|
||||
if self.handle_shortcut(key):
|
||||
return "break"
|
||||
return None
|
||||
|
||||
def handle_shortcut(self, key: str) -> bool:
|
||||
if self._title_entry_has_focus():
|
||||
return False
|
||||
if key == "Left":
|
||||
self.navigate(-1)
|
||||
return True
|
||||
if key == "Right":
|
||||
self.navigate(1)
|
||||
return True
|
||||
if key == "j":
|
||||
self.seek_by(-SEEK_SECONDS)
|
||||
return True
|
||||
if key == "l":
|
||||
self.seek_by(SEEK_SECONDS)
|
||||
return True
|
||||
if key == "space":
|
||||
self.toggle_play()
|
||||
return True
|
||||
return False
|
||||
|
||||
def schedule_title_save(self, *_args):
|
||||
if self.loading_title or self.manifest_path is None:
|
||||
return
|
||||
self.title_dirty = True
|
||||
self.status_var.set("Title changed; saving…")
|
||||
if self.title_after_id is not None:
|
||||
self.root.after_cancel(self.title_after_id)
|
||||
self.title_after_id = self.root.after(TITLE_SAVE_DELAY_MS, self.save_manifest)
|
||||
|
||||
def save_manifest(self, force: bool = False):
|
||||
if self.manifest_path is None or (not self.title_dirty and not force):
|
||||
return True
|
||||
if self.title_after_id is not None:
|
||||
self.root.after_cancel(self.title_after_id)
|
||||
self.title_after_id = None
|
||||
self.manifest_data["title"] = self.title_var.get()
|
||||
self.manifest_data["videos"] = self.raw_videos
|
||||
temp_path = self.manifest_path.with_name(self.manifest_path.name + ".tmp")
|
||||
try:
|
||||
temp_path.write_text(
|
||||
json.dumps(self.manifest_data, indent=2, ensure_ascii=False) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(temp_path, self.manifest_path)
|
||||
except OSError as exc:
|
||||
self.status_var.set(f"Could not save title: {exc}")
|
||||
try:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
self.title_dirty = False
|
||||
self.status_var.set(f"Title saved to {self.manifest_path.name}")
|
||||
return True
|
||||
|
||||
def close(self):
|
||||
if not self.save_manifest():
|
||||
messagebox.showerror(
|
||||
"Title not saved",
|
||||
"The manifest could not be saved. Fix the file access problem and try closing again.",
|
||||
parent=self.root,
|
||||
)
|
||||
return
|
||||
self.stop_video()
|
||||
if self.shortcut_poller is not None:
|
||||
self.shortcut_poller.close()
|
||||
self.player.release()
|
||||
self.instance.release()
|
||||
self.root.destroy()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("manifest", nargs="?", type=Path, help="daily .json manifest to review")
|
||||
parser.add_argument(
|
||||
"--vlc-dir",
|
||||
type=Path,
|
||||
help="VLC install folder or portable app root (defaults to VLC_DIR or C:\\PortableApps\\VLCPortable3)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
vlc, dll_directory, vlc_dir = load_vlc(args.vlc_dir)
|
||||
except RuntimeError as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
root = tk.Tk()
|
||||
try:
|
||||
reviewer = ManifestReviewer(root, vlc, vlc_dir)
|
||||
except Exception as exc:
|
||||
root.destroy()
|
||||
print(f"Error initializing reviewer: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
# Keep the Windows DLL search directory alive through VLC shutdown.
|
||||
reviewer.dll_directory = dll_directory
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
reviewer.shortcut_poller = WindowsShortcutPoller(root, reviewer.title_entry, reviewer.handle_shortcut)
|
||||
reviewer.shortcut_poller.start()
|
||||
except OSError as exc:
|
||||
reviewer.shortcut_poller = None
|
||||
reviewer.status_var.set(f"Windows keyboard shortcuts unavailable: {exc}")
|
||||
if args.manifest:
|
||||
reviewer.open_manifest(args.manifest.expanduser().resolve())
|
||||
else:
|
||||
root.after(0, reviewer.open_dialog)
|
||||
root.mainloop()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1920" height="1080" viewBox="0 0 1920 1080">
|
||||
<style>
|
||||
.title { font-family: 'Arial', sans-serif; font-size: 52px; fill: white; font-weight: bold; text-anchor: middle; }
|
||||
.subtitle { font-family: 'Arial', sans-serif; font-size: 34px; fill: #E0E0E0; text-anchor: middle; }
|
||||
</style>
|
||||
<filter id="shadow">
|
||||
<feDropShadow dx="2" dy="2" stdDeviation="3" flood-color="black" flood-opacity="0.8"/>
|
||||
</filter>
|
||||
<g filter="url(#shadow)">
|
||||
<text x="960" y="920" class="title">Family Memories</text>
|
||||
<text x="960" y="970" class="subtitle">February 01, 2026</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 635 B |
Reference in New Issue
Block a user