# -*- coding: utf-8 -*-
"""
:File: EuljiroWorship/core/generator/ui/contents/video_content.py
:Author: Benjamin Jaedon Choi - https://github.com/saintbenjamin
:Affiliated Church: The Eulji-ro Presbyterian Church [대한예수교장로회(통합) 을지로교회]
:Address: The Eulji-ro Presbyterian Church, 24-10, Eulji-ro 20-gil, Jung-gu, Seoul 04549, South Korea
:Telephone: +82-2-2266-3070
:E-mail: euljirochurch [at] G.M.A.I.L. (replace [at] with @ and G.M.A.I.L as you understood.)
:License: MIT License with Attribution Requirement (see LICENSE file for details); Copyright (c) 2025 The Eulji-ro Presbyterian Church.
UI content widget for editing "video"-style slides.
This module defines :class:`core.generator.ui.contents.video_content.VideoContent`, a `QWidget <https://doc.qt.io/qt-6/qwidget.html>`_ that allows users to select
a video file, preview it, and associate it with caption text for use
in video-based slides. Selected videos are copied into a local ``html/img/``
directory for reliable access by overlay HTML files.
In this slide style, the ``headline`` field is repurposed to store the
relative video path, while the ``caption`` field is used as accompanying
text displayed alongside the video.
The widget integrates with :class:`core.generator.utils.slide_input_submitter.SlideInputSubmitter` to support automatic
submission and synchronization with the parent generator window.
"""
import os
import shutil
from PySide6.QtWidgets import (
QWidget,
QVBoxLayout,
QHBoxLayout,
QLabel,
QPushButton,
QFileDialog,
QLineEdit,
)
from PySide6.QtCore import Qt, QUrl
from PySide6.QtMultimedia import QMediaPlayer, QAudioOutput
from PySide6.QtMultimediaWidgets import QVideoWidget
from core.generator.utils.slide_input_submitter import SlideInputSubmitter
[docs]
class VideoContent(QWidget):
"""
Content editor widget for "video" style slides.
This widget supports selecting a local video file, copying it into the
overlay asset directory (``./html/img``), previewing it using Qt Multimedia,
and exporting the selection as slide data.
Slide-data mapping:
- ``caption``: optional text shown alongside the media
- ``headline``: relative video path used by the overlay (e.g., ``"img/foo.mp4"``)
The preview player resolves stored relative paths against ``./html/`` so that
values produced by :meth:`copy_to_img_folder` can be previewed consistently.
Attributes:
caption (str):
Initial caption text provided at construction time.
headline (str):
Relative video path stored in slide data (e.g., ``"img/foo.mp4"``).
generator_window:
Reference to the generator main window. Used by the input submitter
to synchronize and auto-save slide edits.
player (QMediaPlayer | None):
Qt Multimedia player used for in-widget preview playback.
Initialized in :meth:`_init_player`.
audio (QAudioOutput | None):
Audio output sink for preview playback. Attached to ``player``.
video_widget (QVideoWidget | None):
Video output widget used to render preview frames.
headline_edit (QLineEdit):
Input field holding the relative video path (stored in ``headline``).
caption_edit (QLineEdit):
Input field holding the caption text.
video_button (QPushButton):
Button that opens a file picker to select a video.
preview_label (QLabel):
Label showing the selected filename or status messages
(e.g., "비디오 로딩 실패").
play_button (QPushButton):
Starts preview playback.
pause_button (QPushButton):
Pauses preview playback.
stop_button (QPushButton):
Stops preview playback and resets position.
submitter (SlideInputSubmitter):
Auto-submit helper that produces export-ready slide payloads using
:meth:`build_video_slide`.
"""
[docs]
def __init__(self, parent, generator_window, caption: str = "", headline: str = ""):
"""
Initialize the video content editor.
Args:
parent (QWidget):
Parent widget container.
generator_window:
Reference to the generator window, used for submitting slide data
and enabling auto-save behavior.
caption (str):
Initial caption text associated with the video.
headline (str):
Initial video path (stored in the ``headline`` field).
Returns:
None
"""
super().__init__(parent)
self.caption = caption
self.headline = headline # headline stores relative video path (e.g., "img/foo.mp4")
self.generator_window = generator_window
self.player = None
self.audio = None
self.video_widget = None
self.build_ui()
self._init_player()
# If we were given an initial path, try to preview it.
if self.headline:
self._load_preview_from_relative_path(self.headline)
[docs]
def build_ui(self):
"""
Construct the UI elements for editing a video slide.
Builds input fields for caption and video path, a button for selecting
a video file, and a video preview area. Registers the input fields with
:class:`core.generator.utils.slide_input_submitter.SlideInputSubmitter` to enable automatic submission.
Returns:
None
"""
layout = QVBoxLayout(self)
# Headline input (stores video path)
self.headline_label = QLabel("제목 (비디오 경로 저장)")
self.headline_edit = QLineEdit(self.headline)
# Caption input
self.caption_label = QLabel("부제")
self.caption_edit = QLineEdit(self.caption)
# Video selection button
self.video_button = QPushButton("비디오 선택")
self.video_button.clicked.connect(self.select_video)
# Preview area
self.preview_label = QLabel("선택된 비디오 없음")
self.preview_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.video_widget = QVideoWidget()
self.video_widget.setMinimumHeight(240)
# Controls
controls = QHBoxLayout()
self.play_button = QPushButton("재생")
self.pause_button = QPushButton("일시정지")
self.stop_button = QPushButton("정지")
self.play_button.clicked.connect(self.play_preview)
self.pause_button.clicked.connect(self.pause_preview)
self.stop_button.clicked.connect(self.stop_preview)
controls.addWidget(self.play_button)
controls.addWidget(self.pause_button)
controls.addWidget(self.stop_button)
layout.addWidget(self.headline_label)
layout.addWidget(self.headline_edit)
layout.addWidget(self.caption_label)
layout.addWidget(self.caption_edit)
layout.addWidget(self.video_button)
layout.addWidget(self.preview_label)
layout.addWidget(self.video_widget)
layout.addLayout(controls)
layout.addStretch()
self.setLayout(layout)
# Auto-submit support
inputs = {
"title": self.caption_edit,
"body": self.headline_edit, # body stores the relative video path
}
self.submitter = SlideInputSubmitter(inputs, self.generator_window, self.build_video_slide)
[docs]
def _init_player(self):
"""
Initialize `QMediaPlayer` for in-widget preview.
Returns:
None
"""
self.player = QMediaPlayer(self)
self.audio = QAudioOutput(self)
self.player.setAudioOutput(self.audio)
self.player.setVideoOutput(self.video_widget)
[docs]
def select_video(self):
"""
Prompt the user to select a video file and register it for slide use.
The selected video is copied into the local ``html/img`` directory and its
relative path is stored in the headline field.
Returns:
None
"""
path, _ = QFileDialog.getOpenFileName(
self,
"비디오 선택",
"",
"Videos (*.mp4 *.mov *.m4v *.webm *.mkv *.avi)",
)
if not path:
return
relative_path = self.copy_to_img_folder(path)
# Normalize separators to '/' for HTML compatibility.
self.headline = relative_path.replace("\\", "/")
self.headline_edit.setText(self.headline)
self.preview_label.setText(os.path.basename(path))
self.preview_label.setToolTip(path)
self._load_preview_from_absolute_path(path)
[docs]
def copy_to_img_folder(self, source_path: str) -> str:
"""
Copy the selected video file into the local image directory (``html/img``).
If the destination directory does not exist, it is created.
If the file already exists at the destination, it is not copied again.
Args:
source_path (str):
Absolute path to the source video file.
Returns:
str:
Relative path for overlay HTML usage, e.g. ``img/example.mp4``.
Raises:
OSError:
If the file cannot be copied or the destination directory cannot be created.
"""
overlay_root = "." # assume current working directory contains html/...
img_dir = os.path.join(overlay_root, "html", "img")
os.makedirs(img_dir, exist_ok=True)
fname = os.path.basename(source_path)
dest_path = os.path.join(img_dir, fname)
if not os.path.exists(dest_path):
shutil.copy2(source_path, dest_path)
return os.path.join("img", fname)
[docs]
def _load_preview_from_absolute_path(self, abs_path: str):
"""
Load preview video into `QMediaPlayer` from an absolute file path.
Args:
abs_path (str): Absolute file path.
Returns:
None
"""
try:
url = QUrl.fromLocalFile(abs_path)
self.player.setSource(url)
except Exception:
self.preview_label.setText("비디오 로딩 실패")
[docs]
def _load_preview_from_relative_path(self, rel_path: str):
"""
Load preview video into `QMediaPlayer` from a relative path like ``img/foo.mp4``.
This resolves it against ``./html/`` to match the copy destination.
Args:
rel_path (str): Relative path stored in slide data.
Returns:
None
"""
try:
# rel_path is like "img/foo.mp4" and real file is "./html/img/foo.mp4"
abs_path = os.path.abspath(os.path.join(".", "html", rel_path))
if os.path.exists(abs_path):
self._load_preview_from_absolute_path(abs_path)
self.preview_label.setText(os.path.basename(abs_path))
else:
self.preview_label.setText("비디오 파일 없음")
except Exception:
self.preview_label.setText("비디오 로딩 실패")
[docs]
def play_preview(self):
"""
Start playing the preview video.
Returns:
None
"""
if self.player:
self.player.play()
[docs]
def pause_preview(self):
"""
Pause the preview video.
Returns:
None
"""
if self.player:
self.player.pause()
[docs]
def stop_preview(self):
"""
Stop the preview video.
Returns:
None
"""
if self.player:
self.player.stop()
[docs]
def build_video_slide(self):
"""
Conditionally generate video slide data.
If no video path has been selected, no slide data is generated.
Returns:
dict | None:
Slide data dictionary if a video is selected; otherwise, None.
"""
data = self.get_slide_data()
if not data["headline"]:
return None
return data
[docs]
def get_slide_data(self) -> dict:
"""
Generate the slide data dictionary for a video slide.
Returns:
dict:
Dictionary representing the video slide:
- style
- caption
- headline (relative video path, e.g., ``img/foo.mp4``)
"""
return {
"style": "video",
"caption": self.caption_edit.text().strip(),
"headline": self.headline_edit.text().strip(),
}
[docs]
def set_content(self, data: dict):
"""
Restore saved video slide data into the editor UI.
Populates caption and video path fields and attempts to load preview.
Args:
data (dict):
Slide data dictionary containing caption and video path.
Returns:
None
"""
self.caption_edit.setText(data.get("caption", ""))
self.headline = data.get("headline", "")
self.headline_edit.setText(self.headline)
if self.headline:
self._load_preview_from_relative_path(self.headline)
else:
self.preview_label.setText("선택된 비디오 없음")
if self.player:
self.player.stop()
self.player.setSource(QUrl())