video2pptx.py ダウンロード/コピー

video2pptx.py をダウンロード

video2pptx.py
video2pptx.py
  1#!/usr/bin/env python3
  2"""
  3概要:
  4    MP4等の動画から定期的または操作後の安定画面のスクリーンショットを作成し、PowerPointに貼り付けます。
  5詳細説明:
  6    ffmpegおよびffprobeがシステムのPATHに存在し、Pillowとpython-pptxがインストールされている必要があります。
  7"""
  8
  9from __future__ import annotations
 10
 11import argparse
 12import json
 13import math
 14import shutil
 15import subprocess
 16import sys
 17import tempfile
 18from pathlib import Path
 19
 20from PIL import Image, ImageChops, ImageStat
 21from pptx import Presentation
 22from pptx.enum.text import PP_ALIGN
 23from pptx.util import Inches, Pt
 24from pptx.dml.color import RGBColor
 25
 26
 27SLIDE_W, SLIDE_H = Inches(13.333), Inches(7.5)  # 16:9
 28MARGIN_X = Inches(0.45)
 29HEADER_H = Inches(0.58)
 30FOOTER_H = Inches(0.25)
 31
 32
 33def require_command(name: str) -> None:
 34    """
 35    概要:
 36        コマンドがシステムに存在するか確認します。
 37    引数:
 38        :param name: 確認するコマンド名
 39        :type name: str
 40    戻り値:
 41        :returns: なし
 42        :rtype: None
 43    例外:
 44        :raises RuntimeError: コマンドが見つからない場合
 45    """
 46    if shutil.which(name) is None:
 47        raise RuntimeError(f"'{name}' が見つかりません。ffmpeg をインストールし、PATH を確認してください。")
 48
 49
 50def run(command: list[str]) -> str:
 51    """
 52    概要:
 53        外部コマンドを実行し標準出力を取得します。
 54    引数:
 55        :param command: 実行するコマンドのリスト
 56        :type command: list[str]
 57    戻り値:
 58        :returns: コマンドの標準出力文字列
 59        :rtype: str
 60    """
 61    result = subprocess.run(command, check=True, text=True, capture_output=True)
 62    return result.stdout
 63
 64
 65def video_duration(video: Path) -> float:
 66    """
 67    概要:
 68        動画の長さを秒単位で取得します。
 69    引数:
 70        :param video: 動画ファイルのパス
 71        :type video: Path
 72    戻り値:
 73        :returns: 動画の長さ
 74        :rtype: float
 75    """
 76    text = run([
 77        "ffprobe", "-v", "error", "-show_entries", "format=duration",
 78        "-of", "json", str(video),
 79    ])
 80    return float(json.loads(text)["format"]["duration"])
 81
 82
 83def timestamp(seconds: float) -> str:
 84    """
 85    概要:
 86        秒数を HH:MM:SS 形式の文字列に変換します。
 87    引数:
 88        :param seconds: 秒数
 89        :type seconds: float
 90    戻り値:
 91        :returns: フォーマットされた時間文字列
 92        :rtype: str
 93    """
 94    total = max(0, round(seconds))
 95    return f"{total // 3600:02d}:{(total % 3600) // 60:02d}:{total % 60:02d}"
 96
 97
 98def extract_frame(video: Path, at: float, destination: Path, max_width: int) -> None:
 99    """
100    概要:
101        動画から指定時刻のフレームを抽出し画像として保存します。
102    引数:
103        :param video: 動画ファイルのパス
104        :type video: Path
105        :param at: 抽出する時刻
106        :type at: float
107        :param destination: 保存先ファイルのパス
108        :type destination: Path
109        :param max_width: 画像の最大幅
110        :type max_width: int
111    戻り値:
112        :returns: なし
113        :rtype: None
114    """
115    # -ss before -i provides fast, sufficiently accurate seeking for screen recordings.
116    run([
117        "ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-ss", f"{at:.3f}",
118        "-i", str(video), "-frames:v", "1", "-vf", f"scale={max_width}:-2",
119        "-q:v", "2", str(destination),
120    ])
121
122
123def extract_analysis_frames(video: Path, destination: Path, start: float, end: float, sample_fps: float) -> list[Path]:
124    """
125    概要:
126        動き解析用に使用する低解像度フレームを抽出します。
127    引数:
128        :param video: 動画ファイルのパス
129        :type video: Path
130        :param destination: 保存先ディレクトリのパス
131        :type destination: Path
132        :param start: 抽出開始時刻
133        :type start: float
134        :param end: 抽出終了時刻
135        :type end: float
136        :param sample_fps: 抽出のサンプリングfps
137        :type sample_fps: float
138    戻り値:
139        :returns: 抽出された画像パスのリスト
140        :rtype: list[Path]
141    """
142    destination.mkdir(parents=True, exist_ok=True)
143    run([
144        "ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-ss", f"{start:.3f}",
145        "-to", f"{end:.3f}", "-i", str(video), "-vf", f"fps={sample_fps},scale=360:-2",
146        "-q:v", "6", str(destination / "analysis_%05d.jpg"),
147    ])
148    return sorted(destination.glob("analysis_*.jpg"))
149
150
151def mean_frame_difference(before: Path, after: Path) -> float:
152    """
153    概要:
154        2つの画像間の平均絶対グレースケール差分をフルスケールのパーセントとして計算します。
155    引数:
156        :param before: 比較元画像ファイルのパス
157        :type before: Path
158        :param after: 比較先画像ファイルのパス
159        :type after: Path
160    戻り値:
161        :returns: 差分のパーセンテージ
162        :rtype: float
163    """
164    with Image.open(before) as a, Image.open(after) as b:
165        a = a.convert("L")
166        b = b.convert("L")
167        diff = ImageChops.difference(a, b)
168        return ImageStat.Stat(diff).mean[0] * 100.0 / 255.0
169
170
171def motion_times(video: Path, temp_dir: Path, start: float, end: float, args: argparse.Namespace) -> list[float]:
172    """
173    概要:
174        低解像度フレームの変化から操作後の安定画面の時刻リストを検出します。
175    引数:
176        :param video: 動画ファイルのパス
177        :type video: Path
178        :param temp_dir: 作業用ディレクトリのパス
179        :type temp_dir: Path
180        :param start: 検出開始時刻
181        :type start: float
182        :param end: 検出終了時刻
183        :type end: float
184        :param args: コマンドライン引数を格納したNamespace
185        :type args: argparse.Namespace
186    戻り値:
187        :returns: 検出された時刻のリスト
188        :rtype: list[float]
189    """
190    paths = extract_analysis_frames(video, temp_dir / "analysis", start, end, args.sample_fps)
191    if len(paths) < 2:
192        return [start]
193
194    selected = [start] if args.include_first else []
195    last_selected = selected[-1] if selected else -float("inf")
196    motion_seen = False
197    stable_since: float | None = None
198    for i in range(1, len(paths)):
199        now = start + i / args.sample_fps
200        score = mean_frame_difference(paths[i - 1], paths[i])
201        if score >= args.motion_threshold:
202            motion_seen = True
203            stable_since = None
204            continue
205        if not motion_seen or score > args.stable_threshold:
206            stable_since = None
207            continue
208        if stable_since is None:
209            stable_since = now
210            continue
211        if now - stable_since >= args.settle_seconds and now - last_selected >= args.min_interval:
212            selected.append(now)
213            last_selected = now
214            motion_seen = False
215            stable_since = None
216    return selected
217
218
219def add_textbox(slide, text: str, left, top, width, height, size: int, color, bold=False, align=None):
220    """
221    概要:
222        スライドにテキストボックスを追加します。
223    引数:
224        :param slide: テキストボックスを追加するスライドオブジェクト
225        :type slide: pptx.slide.Slide
226        :param text: 表示するテキスト
227        :type text: str
228        :param left: 左端の位置
229        :type left: int または float
230        :param top: 上端の位置
231        :type top: int または float
232        :param width: 幅
233        :type width: int または float
234        :param height: 高さ
235        :type height: int または float
236        :param size: フォントサイズ
237        :type size: int
238        :param color: RGBカラーのタプル
239        :type color: tuple
240        :param bold: 太字にするかどうか
241        :type bold: bool
242        :param align: テキストの配置
243        :type align: PP_ALIGN
244    戻り値:
245        :returns: 作成されたテキストボックスのシェイプオブジェクト
246        :rtype: pptx.shapes.autoshape.Shape
247    """
248    shape = slide.shapes.add_textbox(left, top, width, height)
249    tf = shape.text_frame
250    tf.clear()
251    p = tf.paragraphs[0]
252    p.text = text
253    p.font.name = "Yu Gothic"
254    p.font.size = Pt(size)
255    p.font.bold = bold
256    p.font.color.rgb = RGBColor(*color)
257    if align is not None:
258        p.alignment = align
259    return shape
260
261
262def add_image_fit(slide, image_path: Path, left, top, width, height) -> None:
263    """
264    概要:
265        指定された領域に収まるように画像をスライドに追加します。
266    引数:
267        :param slide: 画像を追加するスライドオブジェクト
268        :type slide: pptx.slide.Slide
269        :param image_path: 追加する画像ファイルのパス
270        :type image_path: Path
271        :param left: 領域の左端位置
272        :type left: int または float
273        :param top: 領域の上端位置
274        :type top: int または float
275        :param width: 領域の幅
276        :type width: int または float
277        :param height: 領域の高さ
278        :type height: int または float
279    戻り値:
280        :returns: なし
281        :rtype: None
282    """
283    with Image.open(image_path) as im:
284        ratio = im.width / im.height
285    box_ratio = width / height
286    if ratio >= box_ratio:
287        image_w, image_h = width, width / ratio
288        image_left, image_top = left, top + (height - image_h) / 2
289    else:
290        image_h, image_w = height, height * ratio
291        image_left, image_top = left + (width - image_w) / 2, top
292    slide.shapes.add_picture(str(image_path), image_left, image_top, width=image_w, height=image_h)
293
294
295def make_slide(prs: Presentation, entries: list[tuple[float, Path]], page: int) -> None:
296    """
297    概要:
298        抽出した画像と時刻を配置したスライドを作成します。
299    引数:
300        :param prs: プレゼンテーションオブジェクト
301        :type prs: Presentation
302        :param entries: 時刻と画像パスのタプルのリスト
303        :type entries: list
304        :param page: スライド番号
305        :type page: int
306    戻り値:
307        :returns: なし
308        :rtype: None
309    """
310    slide = prs.slides.add_slide(prs.slide_layouts[6])
311    slide.background.fill.solid()
312    slide.background.fill.fore_color.rgb = RGBColor(248, 250, 252)
313    start, end = entries[0][0], entries[-1][0]
314    heading = f"Excel操作画面  {timestamp(start)}" if len(entries) == 1 else f"Excel操作画面  {timestamp(start)} – {timestamp(end)}"
315    add_textbox(slide, heading, MARGIN_X, Inches(0.12), Inches(9.5), Inches(0.35), 24, (15, 23, 42), bold=True)
316    add_textbox(slide, f"{page:02d}", Inches(12.3), Inches(0.16), Inches(0.55), Inches(0.28), 14, (100, 116, 139), align=PP_ALIGN.RIGHT)
317
318    n = len(entries)
319    cols = 1 if n == 1 else 2
320    rows = math.ceil(n / cols)
321    gap = Inches(0.18)
322    area_top = HEADER_H + Inches(0.08)
323    area_h = SLIDE_H - area_top - FOOTER_H - Inches(0.08)
324    cell_w = (SLIDE_W - 2 * MARGIN_X - gap * (cols - 1)) / cols
325    cell_h = (area_h - gap * (rows - 1)) / rows
326    for i, (at, path) in enumerate(entries):
327        col, row = i % cols, i // cols
328        x, y = MARGIN_X + col * (cell_w + gap), area_top + row * (cell_h + gap)
329        add_image_fit(slide, path, x, y, cell_w, cell_h - Inches(0.25))
330        add_textbox(slide, timestamp(at), x, y + cell_h - Inches(0.22), cell_w, Inches(0.18), 12, (71, 85, 105), align=PP_ALIGN.CENTER)
331
332
333def parse_args() -> argparse.Namespace:
334    """
335    概要:
336        コマンドライン引数をパースします。
337    引数:
338        なし
339    戻り値:
340        :returns: パース結果を格納したNamespaceオブジェクト
341        :rtype: argparse.Namespace
342    """
343    p = argparse.ArgumentParser(description="MP4等の動画から定期的または操作後の安定画面のSSを作成し、PowerPointに貼り付けます。")
344    p.add_argument("input", type=Path, help="入力動画(MP4など)")
345    p.add_argument("-o", "--output", type=Path, help="出力PPTX(省略時: 入力名_SS.pptx)")
346    p.add_argument("--mode", choices=("interval", "motion", "both"), default="interval", help="抽出方法(既定: interval)")
347    p.add_argument("--interval", type=float, default=10.0, help="定期抽出の間隔(秒、既定: 10)")
348    p.add_argument("--start", type=float, default=0.0, help="抽出開始時刻(秒、既定: 0)")
349    p.add_argument("--end", type=float, help="抽出終了時刻(秒、省略時: 動画末尾)")
350    p.add_argument("--per-slide", type=int, choices=(1, 2, 4), default=1, help="1スライド当たりの画像数(既定: 1)")
351    p.add_argument("--image-width", type=int, default=1600, help="抽出画像の最大幅px(既定: 1600)")
352    p.add_argument("--keep-images", action="store_true", help="抽出したJPEGを <出力名>_frames に残す")
353    motion = p.add_argument_group("motion mode options")
354    motion.add_argument("--sample-fps", type=float, default=2.0, help="動き判定のサンプリングfps(既定: 2)")
355    motion.add_argument("--motion-threshold", type=float, default=0.45, help="操作開始とみなす平均画素差(%%、既定: 0.45)")
356    motion.add_argument("--stable-threshold", type=float, default=0.12, help="静止とみなす平均画素差(%%、既定: 0.12)")
357    motion.add_argument("--settle-seconds", type=float, default=0.8, help="操作後に待つ安定時間(秒、既定: 0.8)")
358    motion.add_argument("--min-interval", type=float, default=3.0, help="motion SSの最小時間間隔(秒、既定: 3)")
359    motion.add_argument("--include-first", action=argparse.BooleanOptionalAction, default=True, help="開始画面もmotion SSに含める(既定: true)")
360    p.add_argument("--pause", action="store_true", help="Pause before terminate")
361    return p.parse_args()
362
363
364def main() -> None:
365    """
366    概要:
367        動画からスクリーンショットを抽出してPowerPoint資料を作成するメイン処理です。
368    引数:
369        なし
370    戻り値:
371        :returns: なし
372        :rtype: None
373    """
374    args = parse_args()
375    require_command("ffmpeg")
376    require_command("ffprobe")
377    video = args.input.resolve()
378    if not video.is_file():
379        raise FileNotFoundError(f"動画が見つかりません: {video}")
380    if args.interval <= 0 or args.sample_fps <= 0:
381        raise ValueError("--interval と --sample-fps は正の値にしてください。")
382    duration = video_duration(video)
383    start, end = args.start, duration if args.end is None else min(args.end, duration)
384    if start < 0 or start >= end:
385        raise ValueError(f"範囲が不正です: start={start}, end={end:.3f}")
386    output = args.output or video.with_name(f"{video.stem}_SS.pptx")
387    output = output.resolve()
388    temp_dir = Path(tempfile.mkdtemp(prefix="video_to_pptx_"))
389    try:
390        interval_times: list[float] = []
391        if args.mode in ("interval", "both"):
392            t = start
393            while t < end - 0.05:
394                interval_times.append(t)
395                t += args.interval
396        detected_times = motion_times(video, temp_dir, start, end, args) if args.mode in ("motion", "both") else []
397        # Merge interval and motion candidates; 0.1 s removes duplicates without erasing nearby distinct steps.
398        times = sorted({round(t, 1) for t in interval_times + detected_times})
399        if not times:
400            times = [start]
401        print(f"抽出モード: {args.mode}  候補SS: {len(times)} 枚")
402        frames = []
403        for i, t in enumerate(times, 1):
404            frame = temp_dir / f"frame_{i:03d}_{timestamp(t).replace(':', '-')}.jpg"
405            extract_frame(video, t, frame, args.image_width)
406            frames.append((t, frame))
407            print(f"[{i}/{len(times)}] {timestamp(t)}")
408        prs = Presentation()
409        prs.slide_width, prs.slide_height = SLIDE_W, SLIDE_H
410        for page, offset in enumerate(range(0, len(frames), args.per_slide), 1):
411            make_slide(prs, frames[offset:offset + args.per_slide], page)
412        output.parent.mkdir(parents=True, exist_ok=True)
413        prs.save(output)
414        print(f"作成完了: {output}")
415        print(f"抽出枚数: {len(frames)}, スライド数: {len(prs.slides)}")
416        if args.keep_images:
417            frame_dir = output.with_name(f"{output.stem}_frames")
418            frame_dir.mkdir(exist_ok=True)
419            for _, frame in frames:
420                shutil.copy2(frame, frame_dir / frame.name)
421            print(f"抽出画像: {frame_dir}")
422    finally:
423        shutil.rmtree(temp_dir, ignore_errors=True)
424
425    if args.pause:
426        input("\nPress ENTER to terminate>>\n")
427        
428
429if __name__ == "__main__":
430    try:
431        main()
432    except (RuntimeError, ValueError, FileNotFoundError, subprocess.CalledProcessError) as exc:
433        print(f"エラー: {exc}", file=sys.stderr)
434        sys.exit(1)