From ff8b9aa09cecd381c37c991eac66534779be6ea2 Mon Sep 17 00:00:00 2001 From: IAnMove Date: Sat, 6 Sep 2025 22:03:12 +0200 Subject: [PATCH] Add clean architecture Python video editor --- .gitignore | 3 + video_editor/README.md | 24 ++++ video_editor/application/video_service.py | 33 +++++ video_editor/domain/clip.py | 25 ++++ video_editor/domain/timeline.py | 11 ++ .../infrastructure/moviepy_adapter.py | 55 ++++++++ video_editor/ui/main.py | 123 ++++++++++++++++++ 7 files changed, 274 insertions(+) create mode 100644 video_editor/README.md create mode 100644 video_editor/application/video_service.py create mode 100644 video_editor/domain/clip.py create mode 100644 video_editor/domain/timeline.py create mode 100644 video_editor/infrastructure/moviepy_adapter.py create mode 100644 video_editor/ui/main.py diff --git a/.gitignore b/.gitignore index a547bf3..f75e56a 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ dist-ssr *.njsproj *.sln *.sw? +# Python +__pycache__/ +*.py[cod] diff --git a/video_editor/README.md b/video_editor/README.md new file mode 100644 index 0000000..c647485 --- /dev/null +++ b/video_editor/README.md @@ -0,0 +1,24 @@ +# Clean Architecture Video Editor + +This sample application demonstrates a minimal video editor built with Python and PyQt5 following a simplified clean architecture. + +## Features +- Add images, videos and a single audio track. +- Apply a basic crossfade transition to images (extensible for more effects). +- Timeline slider mockup. +- Play preview and export final video. +- Choose export resolution (640x480, 1280x720, 1920x1080). + +## Usage +1. Install dependencies: + ```bash + pip install pyqt5 moviepy + ``` +2. Run the editor: + ```bash + python -m video_editor.ui.main + ``` +3. Add media through buttons, select resolution, play or export. + +## Extending +Transitions are represented by the `Transition` dataclass. New effects can be added by extending `moviepy_adapter.py` and using different transition names. diff --git a/video_editor/application/video_service.py b/video_editor/application/video_service.py new file mode 100644 index 0000000..6a817b6 --- /dev/null +++ b/video_editor/application/video_service.py @@ -0,0 +1,33 @@ +"""Application layer orchestrating timeline operations.""" +from typing import Tuple + +from ..domain.clip import Clip, ClipType, Transition +from ..domain.timeline import Timeline +from ..infrastructure.moviepy_adapter import ( + build_video_clip, + export_clip, + preview_clip, +) + + +class VideoService: + def __init__(self, timeline: Timeline | None = None): + self.timeline = timeline or Timeline() + + # Use cases + def add_image(self, path: str, duration: float = 2.0, transition: Transition | None = None) -> None: + self.timeline.clips.append(Clip(path=path, type=ClipType.IMAGE, duration=duration, transition=transition)) + + def add_video(self, path: str, transition: Transition | None = None) -> None: + self.timeline.clips.append(Clip(path=path, type=ClipType.VIDEO, transition=transition)) + + def set_audio(self, path: str) -> None: + self.timeline.audio_path = path + + def play(self, resolution: Tuple[int, int]) -> None: + clip = build_video_clip(self.timeline, resolution) + preview_clip(clip) + + def export(self, path: str, resolution: Tuple[int, int]) -> None: + clip = build_video_clip(self.timeline, resolution) + export_clip(clip, path) diff --git a/video_editor/domain/clip.py b/video_editor/domain/clip.py new file mode 100644 index 0000000..16f4226 --- /dev/null +++ b/video_editor/domain/clip.py @@ -0,0 +1,25 @@ +from dataclasses import dataclass +from enum import Enum +from typing import Optional + + +class ClipType(Enum): + """Types of supported clips.""" + IMAGE = "image" + VIDEO = "video" + + +@dataclass +class Transition: + """Represents a transition effect between clips.""" + name: str + duration: float = 1.0 + + +@dataclass +class Clip: + """Domain entity for a media clip.""" + path: str + type: ClipType + duration: float = 2.0 # Only used for images + transition: Optional[Transition] = None diff --git a/video_editor/domain/timeline.py b/video_editor/domain/timeline.py new file mode 100644 index 0000000..ace18b9 --- /dev/null +++ b/video_editor/domain/timeline.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass, field +from typing import List, Optional + +from .clip import Clip + + +@dataclass +class Timeline: + """Represents an ordered collection of clips with an optional audio track.""" + clips: List[Clip] = field(default_factory=list) + audio_path: Optional[str] = None diff --git a/video_editor/infrastructure/moviepy_adapter.py b/video_editor/infrastructure/moviepy_adapter.py new file mode 100644 index 0000000..c458700 --- /dev/null +++ b/video_editor/infrastructure/moviepy_adapter.py @@ -0,0 +1,55 @@ +"""Infrastructure functions using moviepy for video operations.""" +from __future__ import annotations + +from typing import Tuple + +from moviepy.editor import ( + AudioFileClip, + ImageClip, + VideoFileClip, + concatenate_videoclips, +) + +from ..domain.clip import Clip, ClipType +from ..domain.timeline import Timeline + + +def _to_moviepy_clip(clip: Clip, resolution: Tuple[int, int]): + if clip.type == ClipType.IMAGE: + base = ImageClip(clip.path, duration=clip.duration) + else: + base = VideoFileClip(clip.path) + return base.resize(newsize=resolution) + + +def build_video_clip(timeline: Timeline, resolution: Tuple[int, int]): + moviepy_clips = [] + for clip in timeline.clips: + c = _to_moviepy_clip(clip, resolution) + if clip.transition and clip.transition.name == "crossfade": + c = c.crossfadein(clip.transition.duration) + moviepy_clips.append((c, clip.transition)) + + if not moviepy_clips: + return None + + final, _ = moviepy_clips[0] + for next_clip, transition in moviepy_clips[1:]: + if transition and transition.name == "crossfade": + final = concatenate_videoclips([final, next_clip], padding=-transition.duration) + else: + final = concatenate_videoclips([final, next_clip]) + + if timeline.audio_path: + final = final.set_audio(AudioFileClip(timeline.audio_path)) + return final + + +def preview_clip(clip): + if clip: + clip.preview() + + +def export_clip(clip, path: str): + if clip: + clip.write_videofile(path, fps=24) diff --git a/video_editor/ui/main.py b/video_editor/ui/main.py new file mode 100644 index 0000000..3ade49e --- /dev/null +++ b/video_editor/ui/main.py @@ -0,0 +1,123 @@ +import sys +from typing import Tuple + +from PyQt5.QtWidgets import ( + QApplication, + QWidget, + QVBoxLayout, + QHBoxLayout, + QPushButton, + QFileDialog, + QListWidget, + QSlider, + QLabel, + QMessageBox, + QComboBox, +) + +from ..application.video_service import VideoService +from ..domain.clip import Transition + + +class VideoEditorUI(QWidget): + def __init__(self): + super().__init__() + self.service = VideoService() + self.setWindowTitle("Clean Video Editor") + self.resize(800, 600) + + main_layout = QVBoxLayout() + + # controls + controls = QHBoxLayout() + btn_img = QPushButton("Add Image") + btn_vid = QPushButton("Add Video") + btn_audio = QPushButton("Add Audio") + btn_play = QPushButton("Play") + btn_export = QPushButton("Export") + controls.addWidget(btn_img) + controls.addWidget(btn_vid) + controls.addWidget(btn_audio) + controls.addWidget(btn_play) + controls.addWidget(btn_export) + + btn_img.clicked.connect(self.add_image) + btn_vid.clicked.connect(self.add_video) + btn_audio.clicked.connect(self.add_audio) + btn_play.clicked.connect(self.play) + btn_export.clicked.connect(self.export) + + # list of clips + self.list_widget = QListWidget() + + # timeline slider + timeline_layout = QHBoxLayout() + timeline_layout.addWidget(QLabel("Timeline:")) + self.slider = QSlider() + self.slider.setOrientation(1) + self.slider.setMinimum(0) + self.slider.setMaximum(100) + timeline_layout.addWidget(self.slider) + + # resolution selector + res_layout = QHBoxLayout() + res_layout.addWidget(QLabel("Resolution:")) + self.res_combo = QComboBox() + self.res_combo.addItems(["640x480", "1280x720", "1920x1080"]) + res_layout.addWidget(self.res_combo) + + main_layout.addLayout(controls) + main_layout.addWidget(self.list_widget) + main_layout.addLayout(timeline_layout) + main_layout.addLayout(res_layout) + self.setLayout(main_layout) + + # helpers + def _current_resolution(self) -> Tuple[int, int]: + text = self.res_combo.currentText() + w, h = text.split("x") + return int(w), int(h) + + # slots + def add_image(self): + path, _ = QFileDialog.getOpenFileName(self, "Select image", "", "Images (*.png *.jpg *.jpeg)") + if path: + transition = Transition(name="crossfade", duration=1.0) + self.service.add_image(path, transition=transition) + self.list_widget.addItem(f"Image: {path}") + + def add_video(self): + path, _ = QFileDialog.getOpenFileName(self, "Select video", "", "Videos (*.mp4 *.mov *.avi)") + if path: + self.service.add_video(path) + self.list_widget.addItem(f"Video: {path}") + + def add_audio(self): + path, _ = QFileDialog.getOpenFileName(self, "Select audio", "", "Audio (*.mp3 *.wav)") + if path: + self.service.set_audio(path) + self.list_widget.addItem(f"Audio: {path}") + + def play(self): + res = self._current_resolution() + try: + self.service.play(res) + except Exception as exc: + QMessageBox.critical(self, "Error", str(exc)) + + def export(self): + res = self._current_resolution() + path, _ = QFileDialog.getSaveFileName(self, "Save Video", "", "Videos (*.mp4)") + if path: + try: + self.service.export(path, res) + QMessageBox.information(self, "Done", "Video exported successfully") + except Exception as exc: + QMessageBox.critical(self, "Error", str(exc)) + + +if __name__ == "__main__": + app = QApplication(sys.argv) + ui = VideoEditorUI() + ui.show() + sys.exit(app.exec_())