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)