1919from pathlib import Path
2020
2121from PySide6 .QtCore import Qt , QThread , Signal , QUrl , QRectF , QPointF , QSizeF , QTimer
22- from PySide6 .QtGui import QColor , QPen , QBrush , QPainter , QAction
22+ from PySide6 .QtGui import QColor , QPen , QBrush , QPainter , QAction , QPixmap
2323from PySide6 .QtWidgets import (
2424 QApplication , QMainWindow , QWidget , QVBoxLayout , QHBoxLayout , QGridLayout ,
2525 QLineEdit , QPushButton , QLabel , QFileDialog , QSlider , QComboBox , QCheckBox ,
@@ -423,6 +423,69 @@ def run(self):
423423 self .failed .emit (str (e ))
424424
425425
426+ class WaveformWorker (QThread ):
427+ """Render the clip's audio waveform to a PNG via ffmpeg showwavespic."""
428+ done = Signal (str )
429+
430+ def __init__ (self , src , out ):
431+ super ().__init__ ()
432+ self .src , self .out = src , out
433+
434+ def run (self ):
435+ try :
436+ run_subprocess ([FFMPEG , "-y" , "-i" , str (self .src ), "-filter_complex" ,
437+ f"showwavespic=s=1200x80:colors={ ACCENT } " ,
438+ "-frames:v" , "1" , str (self .out )], timeout = 60 )
439+ if Path (self .out ).exists ():
440+ self .done .emit (str (self .out ))
441+ except Exception :
442+ pass
443+
444+
445+ class WaveformBar (QWidget ):
446+ """Waveform strip with a playhead and dimmed out-of-trim regions; click to seek."""
447+ seek_to = Signal (float )
448+
449+ def __init__ (self ):
450+ super ().__init__ ()
451+ self .setFixedHeight (64 )
452+ self .setObjectName ("wave" )
453+ self ._pix = None
454+ self ._frac = 0.0
455+ self ._in = 0.0
456+ self ._out = 1.0
457+
458+ def set_image (self , path ):
459+ self ._pix = QPixmap (path )
460+ self .update ()
461+
462+ def set_position (self , frac ):
463+ self ._frac = max (0.0 , min (1.0 , frac ))
464+ self .update ()
465+
466+ def set_region (self , in_frac , out_frac ):
467+ self ._in , self ._out = in_frac , out_frac
468+ self .update ()
469+
470+ def paintEvent (self , event ):
471+ p = QPainter (self )
472+ w , h = self .width (), self .height ()
473+ if self ._pix and not self ._pix .isNull ():
474+ p .drawPixmap (self .rect (), self ._pix )
475+ p .setPen (Qt .NoPen )
476+ p .setBrush (QColor (0 , 0 , 0 , 150 ))
477+ p .drawRect (QRectF (0 , 0 , self ._in * w , h ))
478+ p .drawRect (QRectF (self ._out * w , 0 , w - self ._out * w , h ))
479+ pen = QPen (QColor (ACCENT )); pen .setWidth (2 )
480+ p .setPen (pen )
481+ x = self ._frac * w
482+ p .drawLine (QPointF (x , 0 ), QPointF (x , h ))
483+
484+ def mousePressEvent (self , event ):
485+ if self .width ():
486+ self .seek_to .emit (max (0.0 , min (1.0 , event .position ().x () / self .width ())))
487+
488+
426489# ──────────────────────────────────────────────────────────────────────
427490# Welcome overlay (first run)
428491# ──────────────────────────────────────────────────────────────────────
@@ -491,6 +554,9 @@ def __init__(self):
491554 self .time_lbl = QLabel ("0:00 / 0:00" ); self .time_lbl .setObjectName ("mono" )
492555 tr .addWidget (self .play_btn ); tr .addWidget (self .seek , 1 ); tr .addWidget (self .time_lbl )
493556 left .addLayout (tr )
557+ self .wave = WaveformBar ()
558+ self .wave .seek_to .connect (lambda f : self .player .setPosition (int (f * self .duration * 1000 )))
559+ left .addWidget (self .wave )
494560 root .addLayout (left , 1 )
495561
496562 # right: controls panel
@@ -657,6 +723,11 @@ def _loaded(self, path):
657723 self .set_ratio ("Original" )
658724 self ._update_trim_lbl ()
659725 self .status .setText (f"{ Path (path ).name } · { w } ×{ h } · native preview (no proxy)" )
726+ # render the waveform strip in the background
727+ self .wave .set_image ("" )
728+ self .wf = WaveformWorker (path , Path (path ).parent / "wave.png" )
729+ self .wf .done .connect (self .wave .set_image )
730+ self .wf .start ()
660731
661732 # --- playback ---
662733 def toggle_play (self ):
@@ -672,8 +743,29 @@ def on_seek(self, v):
672743 def on_position (self , ms ):
673744 if self .duration and not self .seek .isSliderDown ():
674745 self .seek .setValue (int (ms / 1000 / self .duration * 1000 ))
746+ if self .duration :
747+ self .wave .set_position (ms / 1000 / self .duration )
675748 self .time_lbl .setText (f"{ self ._fmt (ms / 1000 )} / { self ._fmt (self .duration )} " )
676749
750+ def keyPressEvent (self , event ):
751+ if self .url_input .hasFocus ():
752+ return super ().keyPressEvent (event )
753+ k = event .key ()
754+ if k == Qt .Key_Space :
755+ self .toggle_play ()
756+ elif k == Qt .Key_I :
757+ self .set_in ()
758+ elif k == Qt .Key_O :
759+ self .set_out ()
760+ elif k == Qt .Key_Left :
761+ self .player .setPosition (max (0 , self .player .position () - 1000 ))
762+ elif k == Qt .Key_Right :
763+ self .player .setPosition (self .player .position () + 1000 )
764+ elif k == Qt .Key_Home :
765+ self .player .setPosition (0 )
766+ else :
767+ return super ().keyPressEvent (event )
768+
677769 def on_player_duration (self , ms ):
678770 if ms > 0 and self .duration <= 0 :
679771 self .duration = ms / 1000
@@ -708,6 +800,8 @@ def set_out(self):
708800 def _update_trim_lbl (self ):
709801 self .trim_lbl .setText (
710802 f"In { self ._fmt (self .trim_in )} · Out { self ._fmt (self .trim_out )} · { self ._fmt (self .trim_out - self .trim_in )} " )
803+ if self .duration :
804+ self .wave .set_region (self .trim_in / self .duration , self .trim_out / self .duration )
711805
712806 # --- export ---
713807 def on_export (self ):
@@ -776,6 +870,7 @@ def _fmt(secs):
776870#welcomeTitle {{ font-size: 27px; font-weight: 800; color: #ffffff; }}
777871#welcomeSub {{ color: #c7ccd6; font-size: 14px; }}
778872#welcomeSteps {{ color: #9aa0ab; font-size: 14px; }}
873+ #wave {{ background: #0f1014; border-radius: 6px; }}
779874"""
780875
781876
0 commit comments