Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,6 @@ dist-ssr
*.njsproj
*.sln
*.sw?
# Python
__pycache__/
*.py[cod]
24 changes: 24 additions & 0 deletions video_editor/README.md
Original file line number Diff line number Diff line change
@@ -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.
33 changes: 33 additions & 0 deletions video_editor/application/video_service.py
Original file line number Diff line number Diff line change
@@ -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)
25 changes: 25 additions & 0 deletions video_editor/domain/clip.py
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions video_editor/domain/timeline.py
Original file line number Diff line number Diff line change
@@ -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
55 changes: 55 additions & 0 deletions video_editor/infrastructure/moviepy_adapter.py
Original file line number Diff line number Diff line change
@@ -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)
123 changes: 123 additions & 0 deletions video_editor/ui/main.py
Original file line number Diff line number Diff line change
@@ -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_())