pax_global_header00006660000000000000000000000064151434351360014517gustar00rootroot0000000000000052 comment=f03a3a686c7304588dd434322c73506531e53595 djfun-audio-visualizer-python-f03a3a6/000077500000000000000000000000001514343513600200435ustar00rootroot00000000000000djfun-audio-visualizer-python-f03a3a6/.github/000077500000000000000000000000001514343513600214035ustar00rootroot00000000000000djfun-audio-visualizer-python-f03a3a6/.github/workflows/000077500000000000000000000000001514343513600234405ustar00rootroot00000000000000djfun-audio-visualizer-python-f03a3a6/.github/workflows/python-app.yml000066400000000000000000000023201514343513600262570ustar00rootroot00000000000000# This workflow will install Python dependencies and run tests with a single version of Python # # For more information see: # https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python # # For information on using pytest-qt in GitHub Actions see: # https://pytest-qt.readthedocs.io/en/latest/troubleshooting.html#github-actions-azure-pipelines-travis-ci-and-gitlab-ci-cd name: Python application on: push: branches: [ "master" ] pull_request: branches: [ "master" ] permissions: contents: read jobs: build: runs-on: ubuntu-latest env: DISPLAY: ':99.0' steps: - uses: actions/checkout@v3 - name: Set up Python 3.12 uses: actions/setup-python@v3 with: python-version: "3.12" - name: Install system dependencies run: | sudo apt install -y ffmpeg libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 x11-utils libgl1 libegl1 libdbus-1-3 libxcb-cursor0 fonts-noto-core - name: Install Python dependencies run: | pip install . pytest pytest-qt pytest-xvfb - name: Test with pytest run: | python3 -m pytest -vdjfun-audio-visualizer-python-f03a3a6/.gitignore000066400000000000000000000005241514343513600220340ustar00rootroot00000000000000__pycache__ *.py[cod] *.egg-info .pytest_cache build/ dist/ env/ prof/ .venv/ .env/ .vscode/ tests/data/config/log/ tests/data/config/presets/ tests/data/config/settings.ini tests/data/config/autosave.avp *.mkv *.mp4 *.wav *.mp3 *.aif *.ac3 *.zip *.tar *.tar.* *.exe ffmpeg *.bak *~ *.goutput* *.kate-swp *.code-workspace .coverage htmlcov/djfun-audio-visualizer-python-f03a3a6/AUTHORS000066400000000000000000000002061514343513600211110ustar00rootroot00000000000000Martin Kaistra , Brianna Rainey , DH4, HunterwolfAT, rikai, Aeliton Silva djfun-audio-visualizer-python-f03a3a6/LICENSE000066400000000000000000000021761514343513600210560ustar00rootroot00000000000000================== audio-visualizer-python is licensed under the MIT License ================== Copyright (c) 2015 Martin Kaistra Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.djfun-audio-visualizer-python-f03a3a6/MANIFEST.in000066400000000000000000000002701514343513600216000ustar00rootroot00000000000000include src/components/*.ui include src/gui/*.ui include src/gui/background.png include src/encoder-options.json global-exclude src/components/__template__.ui global-exclude *.py[cod] djfun-audio-visualizer-python-f03a3a6/README.md000066400000000000000000000164341514343513600213320ustar00rootroot00000000000000# Audio Visualizer Python **We need a good name that is not as generic as "audio-visualizer-python"!** This is a little GUI tool which creates an audio visualization video from an input audio file. Different components can be added and layered to change the resulting video and add images, videos, gradients, text, etc. Encoding options can be changed with a variety of different output containers. The program works on **Linux**, **macOS**, and **Windows**. If you encounter problems running it or have other bug reports or features that you wish to see implemented, please fork the project and submit a pull request and/or file an [issue](https://github.com/djfun/audio-visualizer-python/issues) on this project. ## Screenshots & Videos [![Screenshot of AVP running on Windows](/screenshot.png?raw=true)](https://tassaron.com/img/avp/screenshot-v2.0.0.png) ### A video created by this app - **[YouTube: A day in spring](https://www.youtube.com/watch?v=-M3jR1NuJHM)** 🎥 ### Video demonstration of the app features - [YouTube: Audio Visualizer Python v2.0.0 demonstration](https://www.youtube.com/watch?v=EVt2ckQs1Yg) 🎥 ## Installation on Linux ### System dependencies - Install FFmpeg: - On Ubuntu: `sudo apt install ffmpeg` - On Arch: `sudo pacman -S ffmpeg` - If using X11 (Ubuntu 24.04 default): - `sudo apt install libxcb-cursor0` ### Using pipx - **This is a good method if you just want to use the program** - Install `pipx` tool if you don't have it: - On Ubuntu: `sudo apt install pipx` - On Arch: `sudo pacman -S python-pipx` - Run `pipx ensurepath` then close and reopen the terminal - Install latest stable version: `pipx install audio-visualizer-python` - Run this program with `avp` or `python -m avp` from terminal ### Using a Python virtual environment - **This is a good method if you want to edit the code** - Make a virtual environment: `python -m venv .venv` - Activate it: `source .venv/bin/activate` - Install uv: `pip install uv` - Install this program: `uv sync` in this directory - Run program with `avp` or `python -m avp` ## Installation on Windows - Install Python from the Windows Store - Add Python to your system PATH (it should ask during the installation process) - [PATH]() is where your computer looks for programs - Download and install [FFmpeg](https://www.ffmpeg.org/download.html). Use the GPL-licensed static builds. - Add FFmpeg to the system PATH as well (program will then work anywhere) - Alternatively, copy ffmpeg.exe into the folder that you want to run the program within - Open command prompt and run `pip install audio-visualizer-python` - Now run `avp` or `python -m avp` from a command prompt window to start the app ## Installation on macOS - Install Homebrew - If you don't have it already, install Homebrew (the macOS package manager) by running this in your terminal: ```bash /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" ``` - Install System Dependencies - Update your packages and install FFmpeg: ```bash brew update && brew upgrade brew install ffmpeg ``` - Install Python 3 - You can use a standard Python installation or Miniconda. To install Python via Homebrew: ```bash brew install python ``` - Install and Run - Install the latest stable version of audio-visualizer-python using pip: ```bash pip3 install audio-visualizer-python ``` - Now run the program from your terminal: ```bash avp ``` or ```bash python3 -m avp ``` ## [Keyboard Shortcuts](https://github.com/djfun/audio-visualizer-python/wiki/Keyboard-Shortcuts) | Key Combo | Effect | | ------------------------ | --------------------------------------------- | | Ctrl+S | Save Current Project | | Ctrl+A | Save Project As... | | Ctrl+O | Open Project | | Ctrl+N | New Project (prompts to save current project) | | Ctrl+Z | Undo | | Ctrl+Shift+Z _or_ Ctrl+Y | Redo | | Ctrl+T _or_ Insert | Add Component | | Ctrl+R _or_ Delete | Remove Component | | Ctrl+Space | Focus Component List | | Ctrl+Shift+S | Save Component Preset | | Ctrl+Shift+C | Remove Preset from Component | | Ctrl+Up | Move Selected Component Up | | Ctrl+Down | Move Selected Component Down | | Ctrl+Home | Move Selected Component to Top | | Ctrl+End | Move Selected Component to Bottom | | Ctrl+Shift+U | Open Undo History | | Ctrl+Shift+F | Show FFmpeg Command | ## Using Commandline Interface Projects can be created with the GUI then loaded from the commandline for easy automation of video production. Some components have commandline options for extra customization, and you can save "presets" with settings to load if the commandline option doesn't exist. ### Example Command - Create a video with a grey "classic visualizer", background image, and text: - `avp -c 0 image path=src/tests/data/test.jpg -c 1 classic color=180,180,180 -c 2 text "title=Episode 371" -i src/tests/data/test.ogg -o output.mp4` - [See more about commandline mode in the wiki!](https://github.com/djfun/audio-visualizer-python/wiki/Commandline-Mode) ## Developer Information ### Dependencies - Python 3.12 or higher - FFmpeg 4.4.1 or higher - PyQt6 6.10.2 - Pillow 12.1.0 - NumPy 2.4.1 ### Running Automatic Tests Run unit and integration tests with `pytest`. * First you will need to install with `pip install pytest pytest-qt` * You may omit the slowest test with `pytest -k "not commandline_export"` ### Installing from TestPyPI Because some dependencies (namely numpy) are not always on TestPyPI, you must specify when installing that these dependencies should come from the real PyPI. * `pip install -i https://test.pypi.org/simple/ audio-visualizer-python==x.x.x --extra-index-url https://pypi.org/simple numpy` ### Getting Faster Export Times - [Pillow-SIMD](https://github.com/uploadcare/pillow-simd) may be used as a drop-in replacement for Pillow if you desire faster video export times, but it must be compiled from source. For help installing dependencies to compile Pillow-SIMD, see the [Pillow installation guide](https://pillow.readthedocs.io/en/stable/installation/building-from-source.html). ### Developing a New Component - Information for developing a component is in our wiki: [How a Component Works](https://github.com/djfun/audio-visualizer-python/wiki/How-a-Component-Works) - File an issue on GitHub if you need help fitting your visualizer into our component system; we would be happy to collaborate ## License Source code of audio-visualizer-python is licensed under the MIT license. Some dependencies of this application are under the GPL license. When packaged with these dependencies, audio-visualizer-python may also be under the terms of this GPL license. djfun-audio-visualizer-python-f03a3a6/avp.desktop000077500000000000000000000003361514343513600222310ustar00rootroot00000000000000[Desktop Entry] Name=Audio Visualizer Python #Exec=/usr/bin/env python3 -m avp #Exec=$HOME/.local/bin/avp Exec=$(which avp) Icon= Terminal=false Type=Application StartupNotify=true Categories=AudioVideo;AudioVideoEditing; djfun-audio-visualizer-python-f03a3a6/pyproject.toml000066400000000000000000000024131514343513600227570ustar00rootroot00000000000000[build-system] requires = ["uv_build>=0.9.23,<0.10.0"] build-backend = "uv_build" [project] name = "audio-visualizer-python" description = "Create audio visualization videos from a GUI or commandline" readme = "README.md" version = "2.2.4" requires-python = ">= 3.12" license = "MIT" classifiers=[ "Development Status :: 5 - Production/Stable", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3 :: Only", "Intended Audience :: End Users/Desktop", "Topic :: Multimedia :: Video", ] keywords = [ "visualizer", "visualization", "fft", "ffmpeg", "podcast", ] authors = [ {name = "Martin Kaistra", email = "admin@djfun.de"}, {name = "Brianna Rainey", email = "brianna@tassaron.com"}, {name = "DH4", email = "wayne@bitstorm.pw"}, {name = "HunterwolfAT", email = "hunterwolf0815@gmail.com"}, {name = "rikai"}, ] maintainers = [ {name = "Brianna Rainey", email = "brianna@tassaron.com"}, ] dependencies = [ "numpy>=2.4.1", "pillow>=12.1.0", "pyqt6>=6.10.2", ] [dependency-groups] dev = [ "pytest", "pytest-qt", ] [tool.uv.build-backend] module-name = "avp" [external] dependencies = ["pkg:generic/ffmpeg"] [project.urls] repository="https://github.com/djfun/audio-visualizer-python" [project.scripts] avp = "avp.cli:main" djfun-audio-visualizer-python-f03a3a6/requirements.txt000066400000000000000000000001171514343513600233260ustar00rootroot00000000000000numpy==2.4.1 pillow==12.1.0 PyQt6==6.10.2 PyQt6-Qt6==6.10.1 PyQt6_sip==13.11.0 djfun-audio-visualizer-python-f03a3a6/screenshot.png000066400000000000000000000532721514343513600227370ustar00rootroot00000000000000PNG  IHDR76 PLTE *.2&)-049H^49>16;6;@^befimrvy">BF[\_ $%& % jmp534mqt=DCB CGLQPPLPSmmm `__񦥥IJL\'.Q;7:u^;Q{{{ɈM- 1i16B1'+%7xa301F/=m0Xocef0!TWZg9=N+1R8;Д@+/ܝ_(SaH>0:[j"Hο|@- +e%)2fv=0T+ҒV9tؼC %N'18O(,?9(_ϯpA}N<#H/= ?MdE8:b2۞H/K-@@4̭W8QcOS[% Ȉ"B}ru-Fe0ܫCqy0'{ӬcNJe߄sSn=$m.!ɛoUpGƟve}[qorb[/2^TJMvwEV+XGx:ҭDb q`ڴXggMbu cVŠj|c:\6z@fX2uVL%b?zevDSv3P{ [ҔCi9ۥ,矺q~Zf]zizyƀ׼(gcᶂaƞyhÁa᪏_SuIDATxOhqGA r CCIsY] F 'lA= .;쐧tyY3<D,KP]`<ړX"Ҟ};*vLըch(VKH{FVV `51tHbdUJo(鸬`Ew!FMc IHm@3-@MHm @@N/wgotZoykiյiKzJy Cc$-* >)#R-?>҄2qX8]Ňsy[r|z>#LӸ EZ娉Q_m⛕)MU0`xBh&`%3$dHp f&- GT(%HeH@KHeH UB$H A@ ͟`wkļ V {55 jb:.%螑`>ň[`6j g$%8CE: %' $@Ր@T $@Ր@T $@Ր@T $@Ր@T V hA,H0KÁҝ *ZOfH jH jH  ,ls8 J&K\'}B@d Ksprmޖ 2;wskޖM /RG9\ppsBR $L~З$ ,m\*Ƹpb ՟}eF?r o 0xOA]:ԓxfmΑ :`DohIP!T,<OAtTYۻx]=y^GPooF6 OOyjjjBϚ_0KV  s ϴ,* [r"$&38 V )ྯx~9p4Ng?rٯ-41 `@R8MlzňNo Z-R;y& 5H^O@U"ࡁT  u h|YX@Z:@ց~0~ h@ @@Z:@ցT٪Œs`e36!k.K@7u'rC#`q@$sءKvʿ|L %sF|$i.fC}ύ fk1B[ /.Oj;O_is1N/g#6yPsn߾kѳ)/L0P+O. H=?{Q@6ƇSvaTsǟ 0^t|`xkʃ,H@4'ƫ6"=,!R{\, g'r&Ex%/a/WL@W1uE\1&%`oמ2|Ti՜jѕX*7͂yymM5kOfUML"͛O09Wo/Be9u+a]+ LسwXhhUpb}ŽQ%{SG'GkFÒ>D@ҕ@03`1 vvl7Hi7we!>?$' =,O\ڝXRbߜK_ `fB*$A@Jw u h@ @@Z:@ց u h@ @@ZPu >R QUM(@uM(@MH :cI\T@*WGS3g/M? ɾ$TV"OC`c@U!GcPi \@4@ Q뻛PىP D뿛X1ˁc@ QUMY'c D;:@/f%b p?dYR"a\y_L["&9H\# HHk$$5 pF@\# HHk$$54}+o%}0H@   R=N?fԢVj%d? h9.`H@`^Tl̎duZj&m3- Q ye85ٓܝzB I\b.g-n+,j# (ƹ ̛'$uLzuiI@yL@sPf $<K% 'δ$`Kh6x(fkLG vlqj{DSA|H8$2I'1b*څ A.u(\(*ҍх`+EJ}XDܙ$5>3R19^]F@?F&"Kȧ z"-AF@_ȢHR@ z*GlҍmF=@@ SЧ$XG>Mu" ٖS]* ]zn`[enF@p}*@ &Ad-` 2 )[&#@'?P5B2RF gɧ ~*h#>㐒?he2Dw}¡@')m2R#aC*(LF4" jӟ 4B$#@7TZ Ha uLOB_nE@ %o+dDU"o(3 EDA&| u1b|˒ Q*g!tS[-Sv50_Dn-Cy|ˊc}" Q*$Pz&!X1Nᖾa}G@ڶmiᆜFci̽Ơ @ө4@ %NPDQ%"Q0VT K3{#o^V4=h3-Ahߠ)1)EC 3B1+IziU1\ddc-KBƍ>2l ȢiU(R9}WT}Tӏi 3B1BIhyab%Tc&qQG ~ <VIS@ 74 (F``=Sw7ݥ`.59X - ]X}{)̻͖JG=6V'v:ˋd27e0U^(8pŔW 䈨̊( 9 =+!hh8@`Ra+"NJ +JDPeoHPPo YC,YDg%4g>b>B U0/l>9k5^ljZm=pxPWuRq:9P5DF3P--z~G6$ E,v8@ xP#o[nfN.ҟB|9ݧBV6xvvs`}f`m ;܃=/n3##oc7!0?wl0o޼2F d4޺s-|Wtӧ2"GF_N̟FITh=?_t(gUB|4+e2԰nLSS|{}/j{q/_ k'''&^r}hw;(9t:+C )k5mִEA둃#5̷A6~>߿znܸ}ӕ+gϺ'wlnCX$7w#MhTW&1?4cf ,lhM)Z ]ą]BZ(Bͦ4Bӆ)EAib.*HEmK+}sΜsܙrgr$~`*;e'Hac8n`#n>^V H#OWnԴ\_@gg/8z䟚9zzrrΝ;_!C3::00]"M=@R, r! AUì%Ͳo@)P9&@>#]w{sZu '~m|:ggK€P' b :Vr+llA@L&I}1˻|p)!S7#B?_rO:E{cǔv_pĉog}C9>c}G)}Or#4Px, g$c?uqӳ-g2JF@qD )C(_hS@/W18ǿ{6@7X \ݥ_XR^@/H,YC%7v<;$~1^9r\%;Sp k!o\3~P%F܅iuv۬X/  e+ا{ Pn4nN?s6T8p`߾XOv;̼,Ot!{x!t|47?O<7B`|zIQ 'Aa]##|8 ;6l`{}P7&uX"L鉉FVDW-9`$uxOhMJZK"!de\rp+wwA r.]#UZڑoO[]k}Q  T;OPQN/ B@u(¨b 7o66"ӗ[ZcNuD3H&1ppZ 䠺NH\!wm핯߼޲9C*e arsi@DRV3 0cG H {ٶ=p5BMLdGi=y#W>ܵ5!QO=O0)aEA%zYbVr6Pi-8R$iմ떓# z5ʏ;e2*As ZcϏ~)|ۡr#۷ggXW􌠬A ӊI/Dq\$mtbqpFA (G@?^4!|Td#,^, &hK ş(I> 'TGQ@ʉȠpyh<faw}UHe p=2;;|E&OSN)< T/ɹ SYXlpN1/**j䨇2&U9X2/׌W J\ X yMxRh+d~fE d3:e4 &/f9I^${noG湙沁wEl@ g-UpI~һg`N.vJ9r(Z\P4A\ݭf#ځY+a<րW5f{XPFɇMr|}}Qo3˄ !jvm;D˱W02N&[zW d(pLߌ4 92Njs@߱'c ٤]$īrT$Պ$ Y+= M (x+[w5c>7`qNp)uj{rߡ[%lq(Y@9W`W^"/Aynj9㕇r9#H}J;?4OaGcLAbS4BEFd+BR.Ϗ,\\]sMP*ycyMX]F bs#-Dz._De'SN\|:mc)6;1ˡYʉCtKivS* 2$?CXP&A`Kf`&)f t9p FD'0> ܕAzsu`7UY7krkyӰ)>y}g)=6p|k[vI=9۱Bku ͒f};aYx2Y+,$3iO Wngo`[ ;ܑrSvue[ q 8.˧M\Ӛ[VF=x᳋E3Cch5vM]b ;[XxϲJUF,ي+n}dg/^2-/LZ`\ н*_"7`f˵_~C[ &`S)0u|!`#2΁'s9!ӄ&9+԰MrjSW x\ &cnm rU n> G% pQ~05%YOW>!G?`aeU)6뒓Z]f]Ҟd% q% حU,<<.˸v9lfтE]5ɭK7<'Ȱy+g2%Lp">)\K@6I_fG kr阝E#On=a .Wl8Sw-$;`l0yBkѢ•d'DN('4ʁB'S3^V-brr>?}2d$hO,Wp-r'_}5,8 nAP5|K'8ppFi0Vu.@,D~ rᣐWN&g;Z,mr?u?죳JN,YG]fN+eEOTVx$Occo:i5zv0'һV,? 5qb XK,('s☍=E_44J+^s2Z\eApU*b :!Y>drmi{<څ_ޫV hxkp~ C"$0MBu =sJ=F4lhPJS^:<}2QfM,YIx9pu2Em;:]y% ;ڒ $ʟc=IbR ߟ*:MPϠA3f5 2(5A A.*g1D0Ƞ3$!`7Tg7dC% K /pt9ur NQt&)0/#(K, 42hXƀb%^SAV$6I) H5| 7 1hT#fPA#_T ̀o$ s䵂2Zٓg\f\()ixCZb ! =7K@^8ի|tց4EX;2K@̣_]j@aa#)64*/LVr[ 漉줟Fs$R-<(ѫHUm `=>A1X8I$;l=7 Xy"C  T8Vn5{,DέFE@L"htEvn5y,` @[M% jX !m"C"ى~wuݕ/ `^&`5yJNڽ?M$鿼ߐ$).O;ߋwF[uvxҺ]cxV ҊT?祿-v(+ 4)f7n(-%IcK&J4e!Ә,t "霈>OŒ֗2СY/n_=lϯ7j:of!so;<9V@@9⏠ޅ{p$>H)qgy:}0Ϲ6;Cεys-6»4:/ܼy%l9]K^\IyRm7-6rUB6B ɩz'[ >\_TDR} 9D}P*3~Oӎ3R!PH׿991~@~=4)&(1@nTzi/nd"50 IVx/>sS<@HD~XB#8u[)r OgYT^pe?`C m5q+5ER$ȥŗCk-(̑"b`l'vۯ/ J|/(+;*B#( zq% j.K X 1YpRثA HrQ@q~I\EB..Nѱ~GXH %;b)E W%-I pb JKRAHA ) r(R!UeFuBc(`U}E"Q~.rXB6sAcH"!D^C @y ) RA5kHא"9'EAq $!+B Rv ) HA A N赼H^}F.)8uaAQֈ@tF(59q UCͭH QJiyH2R)ĨW^:39Q'4f[ǐ!=!]r+I %)P_:;^W.FfK [9prW[ K1+/ԬtGx}p,R`:*G(z̾JB$]w+?X.zk`ZU¯看|OJ/&3(3ikiW};p/:+!Rrۏ'B`@FߧlDӗ`̐xtpKNJCدOU7J6-M')nXge hYi^w*e ,|2;^ѵUo}Tޒjv_VR@v"7SnQ>X7ۡP&a>?UvNxԍ ŹbH, 1D^ jh : VuhV;缱׻¬M/ Nhc_ J,. }zQ+|3NhG0l;uփ ~`jYr]pC$pڞc/I<.`t {סO^r-o}Oӏ:P!刢Z$Y }F@Yp20S( ~[ŒB+ ?/Vߪ+>) mP.a/ dXUL b_v@@D 6` `.P@ȡ)j^`&lћ1YDn,@^Ip뽁k J]CZr)? "*Dܙc|cOS@gM4vdE~z"#|'B@WPWj]Q0Щˁ$3+rR"&FX9pˁe?km9y)*?{%O`8GӑEJP'+uZws>*qP.( zJ Хvȹwx5ü'^AS,4 zJj0*wǠQq"z`- E#yAQ޼ /{Dpwbl $pPV0fZ+t,74F 4# C`>L҈|A,zE@"ɧ}v6o~~߭~w~S Ra5MHED`ӱH˄~S'4DyUNN0RJ)RHx@hcʿ81R)@ːHMC 4M HO )?)@cĭn+2(3$fͣgRPQsTxMRRv3 hRA 4iH)@ӐR!MC 8"q@ H:J]ߏqZ 03d00H@g^̛uK H x?f}NL}Hƫ'H>:&*)%ȇ $J=僮2tQCH) 5dG @ )@!R@ٚ|RU٬8YpG@}H"L{8")pc[G em4lJŏ^qCU(/ő~&( f+Ua[#_FK.[{|#TQ@G0Z: d8C~/(~*RP1bxNEPN% h.(( ;L VPENJT`ArNQ͂JKJTTar.fȇUg{ /`p"pOaTҀ<<Q`('Os1 m ȷlOL2@d؄HAT@0xp m`kp3xpT50ŢJYxp.&.j(rLA 3DhCY?avbm52W׫oAc{Y kAú'4 c!@*5 FܛѠG: фM* <8T`ZpscejYV9*{o#(V ‡>j :H)og ,m&ȪfIzgxb;45!`RIYڻbث1 arv@ kj(yg~3 \i* T -~6RTԷ7+vT[,^s"{']P-MmD)A䨥`Yo׆F 7D@"A֕ -ʇv`{Jf۩(s١  T_Yi,83E>Xk~x-uV@0<|6x5(@3ûߎ} ]n UX7?~x&f7pT K9˕Ai"G-\A J@ /H ,7YTGche[sv" *@ cARơ({)&R6ⶂ["E"^RH/&WLt9XRbU^>P9ӦϰV(17٬߻ 3v薆N̻2 y$s}_̛YpDc,H1͖Fˁc/;w)܏sW*3axdFW{뭛9]T4TJx­Un R I𦀨dh1WDBg =x:1ڴ(|Ph {DZTb+9zS0xe6/̮=DwM}㹓A TeU V@#) aa.K>'YgaR@R) nB&Owz_rWqtFPg`YY,; ) YTɒ^gQF+CHI 'ߖ.k[>D/H b H) 5l)аZ@ `-C )@ː֎ kuK 2\ ) hRA 4iH)@ӐR!N 8 `6f`4aR@13u2d%H@@jb43d}:V$T{p< `0  (Tp1Lsj/ 8MV=hBDTX+B}q-:|>XR{)t}(F}ޛDoZݒx~t=76/wn막b4dA)!f}xt@E)^Cq`D* t@EɠjGTU$L逆f ` P('ICypj TA_UߺR^^)  w+!0Z(Ss+;9OOʀ٪u&mW?g0C&NmLj> F+步y+~A7[,XYր i7R@ZG_/ɣ~&|d#(w+`Hȍ[_eg{*/抺=fW a*:!J7'}7dFV|FKkNvv@Ö{#7:iŴm?^( Śn#z,f}A8xJaVdH+`޶;-4SvىPW 0]KsIkhz ht"ڧ;KvLpe6̕OG0ivuW#q'0ާ!5Hd{lYE+a,lCRN ? 8;^Bc}hG替^BAFr5w#/-u Yᛸ$(D;0vRqia,>q^0buCb$M\[|ׄJK'üPM-d'B@i[4qG+CanE& '`M&or9f8U$ )hhTF (JEC) t{3TA5PZ3e:I݁ςIq5. QOh :р1W81OtS|2$iG9Z{`\IXr'LvX.,2k f8(v '8J WRY@vvd|!|TNT # +MUo&&RYPda&Ppg]U)Ͻ6^F __kPѶ6($~+ CBȤU]~yU"fRKT V;f| mm-vE.x AV_EJ)n vX6;IYév@tD$Wj;p~0Fr;nwf5ρ.Dd;?pOW/w@m^ V$(wgsq 7^]q "Hf BP@tusf~cG   yD|qDC thFT@ȗQ*RLq5xN; (T00t@C` ` 8!Li&J=N)` ` H4Q # P_v HY30#K0Shx>A 2D]:DHYK"`mE. +Rn4qDHYK"` 8萲X>P:>vRkK60aVFm%f}d0]XgaS)pa `0iLgS)pa `0iNQ&F3b( S275"@A 0 jb1( S2ɇ~Z%NQ bHT0?ǀbPTL#0(|@0L  S0(|u` ` PT&NmVZpII`qS@pbLʘGxݖ0QXjT)) 7[OQ|00?,* vܴ;DE|' cАhs;w{=v}ɳ QăÝ0 .U pljj+G{7ĎۺW Q\TCh374,>qd|_ZV'r 0\a)X0ȣJJx9_vOGQoQ*/u"T5t( v<qc^\ cB^>U01':eǃ)0tp<0(KsBV,Ye'* ĥԆ mX3Eyyy-=6YՑWg>9*aγ&.Z06;baw"& ?޾G+'8}7G[+:(@1wiJTB@Z[HT) R@ȧTG^,4r. *aowu|+ÜdKp<_@P=䱋dX^‘ښ"YA~SCv|^fvt!pHىA,['\UpAG^  BWvrJ$ڣIC-T}8HP.J8찺] @/6W+zmBO9tMNXZB]$Q &W47ؚ*?tW#o`q +(߁n*Z8|m}*pDʽ\] )߫ int: """Returns an exit code (0 for success)""" proj = None mode = "GUI" # Determine whether we're in GUI or commandline mode if len(sys.argv) > 2: mode = "commandline" elif len(sys.argv) == 2: if sys.argv[1].startswith("-"): mode = "commandline" else: # remove unsafe punctuation characters such as \/?*&^%$# if sys.argv[1].endswith(".avp"): # remove file extension sys.argv[1] = sys.argv[1][:-4] sys.argv[1] = re.sub(f"[{re.escape(string.punctuation)}]", "", sys.argv[1]) # opening a project file with gui proj = sys.argv[1] # Create Qt Application app = QApplication(sys.argv) app.setApplicationName("audio-visualizer") screen = app.primaryScreen() if screen is None: dpi = None else: dpi = screen.physicalDotsPerInchX() # Launch program if mode == "commandline": from .command import Command main = Command() mode = main.parseArgs() log.debug("Finished creating command object") log.info(f"QApplication Platform: {QApplication.platformName()}") log.info(f"Detected screen DPI: {dpi}") # Both branches here may occur in one execution: # Commandline parsing could change mode back to GUI if mode == "GUI": from avp.gui.mainwindow import MainWindow mainWindow = MainWindow(proj, dpi) log.debug("Finished creating MainWindow") mainWindow.raise_() return app.exec() if __name__ == "__main__": sys.exit(main()) djfun-audio-visualizer-python-f03a3a6/src/avp/command.py000066400000000000000000000252071514343513600234160ustar00rootroot00000000000000""" When using commandline mode, this module's object handles interpreting the arguments and giving them to Core, which tracks the main program state. Then it immediately exports a video. """ from PyQt6 import QtCore import argparse import os import sys import time import glob import signal import shutil import logging from . import __version__ from .core import Core log = logging.getLogger("AVP.Commandline") class Command(QtCore.QObject): """ This replaces the GUI MainWindow when in commandline mode. """ createVideo = QtCore.pyqtSignal() def __init__(self): super().__init__() self.core = Core() Core.mode = "commandline" self.dataDir = self.core.dataDir self.canceled = False self.settings = Core.settings # ctrl-c stops the export thread signal.signal(signal.SIGINT, self.stopVideo) def parseArgs(self): parser = argparse.ArgumentParser( prog="avp" if os.path.basename(sys.argv[0]) == "__main__.py" else None, description="Create a visualization for an audio file", epilog="EXAMPLE COMMAND: avp myvideotemplate " "-i ~/Music/song.mp3 -o ~/video.mp4 " "-c 0 image path=~/Pictures/thisWeeksPicture.jpg " '-c 1 video "preset=My Logo" -c 2 vis layout=classic', ) parser.add_argument( "--version", "-V", action="version", version=f"%(prog)s {__version__}" ) # input/output automatic-export commands parser.add_argument("-i", "--input", metavar="SOUND", help="input audio file") parser.add_argument( "-o", "--output", metavar="OUTPUT", help="output video file" ) parser.add_argument( "--export-project", action="store_true", help="use input and output files from project file if -i or -o is missing", ) # mutually exclusive debug options debugCommands = parser.add_mutually_exclusive_group() debugCommands.add_argument( "--log", action="store_true", help="copy and shorten recent log files into ~/avp_log.txt", ) debugCommands.add_argument( "--verbose", "-v", action="store_true", help="send log messages and ffmpeg output to stdout, and create more verbose log files (good to use before --log)", ) # project/GUI options parser.add_argument( "projpath", metavar="path-to-project", help="open a project file (.avp)", nargs="?", ) parser.add_argument( "-c", "--comp", metavar=("LAYER", "ARG"), help="first arg must be component NAME to insert at LAYER." '"help" for information about possible args for a component.', nargs="*", action="append", ) parser.add_argument( "--no-preview", action="store_true", help="disable live preview during export", ) args = parser.parse_args() if args.verbose: Core.stdoutLogLvl = logging.DEBUG Core.makeLogger(deleteOldLogs=False, fileLogLvl=logging.DEBUG) if args.log: self.createLogFile() quit(0) if args.projpath: projPath = args.projpath if not os.path.dirname(projPath): projPath = os.path.join(self.settings.value("projectDir"), projPath) if not projPath.endswith(".avp"): projPath += ".avp" self.core.openProject(self, projPath) self.core.selectedComponents = list(reversed(self.core.selectedComponents)) self.core.componentListChanged() if args.comp: for comp in args.comp: pos = comp[0] name = comp[1] compargs = comp[2:] try: pos = int(pos) except ValueError: print(pos, "is not a layer number.") quit(1) realName = self.parseCompName(name) if not realName: print(name, "is not a valid component name.") quit(1) modI = self.core.moduleIndexFor(realName) i = self.core.insertComponent(pos, modI, self) if i is None: print(name, "could not be initialized.") quit(1) for arg in compargs: self.core.selectedComponents[i].command(arg) if args.export_project and args.projpath: errcode, data = self.core.parseAvFile(projPath) input_ = None output = None for key, value in data["WindowFields"]: if "outputFile" in key: output = value if output and not os.path.dirname(value): output = os.path.join(os.path.expanduser("~"), output) if "audioFile" in key: input_ = value # use input/output from project file, overwritten by -i and -o if (not input_ and not args.input) or (not output and not args.output): parser.print_help() quit(1) self.createAudioVisualization( input_ if not args.input else args.input, output if not args.output else args.output, ) return "commandline" elif args.input and args.output: self.createAudioVisualization(args.input, args.output) return "commandline" elif args.no_preview: Core.previewEnabled = False elif ( args.projpath is None and "help" not in sys.argv and "--verbose" not in sys.argv and "-v" not in sys.argv and "--log" not in sys.argv ): parser.print_help() quit(1) return "GUI" def createAudioVisualization(self, input, output): if not self.core.selectedComponents: print("No components selected. Adding a default visualizer.") time.sleep(1) self.core.insertComponent(0, 0, self) self.core.selectedComponents = list(reversed(self.core.selectedComponents)) self.core.componentListChanged() self.worker = self.core.newVideoWorker(self, input, output) # quit(0) after video is created self.worker.videoCreated.connect(self.videoCreated) self.lastProgressUpdate = time.time() self.worker.progressBarSetText.connect(self.progressBarSetText) self.createVideo.emit() def stopVideo(self, *args): self.worker.error = True self.worker.cancelExport() self.worker.cancel() @QtCore.pyqtSlot(str) def progressBarSetText(self, value): if "Export " in value or time.time() - self.lastProgressUpdate < 0.1: # Don't duplicate completion/failure messages or send too many messages return if not value.endswith("%"): # Show most messages very often print(value) elif log.getEffectiveLevel() > logging.INFO: # if ffmpeg isn't printing export progress for us, # then overwrite previous message with the next one # if this text is our main export progress print(f"{value}\r", end="") self.lastProgressUpdate = time.time() @QtCore.pyqtSlot() def videoCreated(self): self.quit(0) def quit(self, code): print() quit(code) def showMessage(self, **kwargs): print(kwargs["msg"]) if "detail" in kwargs: print(kwargs["detail"]) @QtCore.pyqtSlot(str, str) def videoThreadError(self, msg, detail): print(msg) print(detail) quit(1) def drawPreview(self, *args): pass def parseCompName(self, name): """Deduces a proper component name out of a commandline arg""" if name.title() in self.core.compNames: return name.title() for compName in self.core.compNames: if name.capitalize() in compName: return compName for altName, moduleIndex in self.core.altCompNames: if name.title() in altName: return self.core.compNames[moduleIndex] compFileNames = [ os.path.splitext(os.path.basename(mod.__file__))[0] for mod in self.core.modules ] for i, compFileName in enumerate(compFileNames): if name.lower() in compFileName: return self.core.compNames[i] return None def createLogFile(self): # Choose a numbered location to put the output file logNumber = 0 def getFilename(): """Get a numbered filename for the final log file""" nonlocal logNumber name = os.path.join(os.path.expanduser("~"), "avp_log") while True: possibleName = f"{name}{logNumber:0>2}.txt" if os.path.exists(possibleName) and logNumber < 100: logNumber += 1 continue break return possibleName # Copy latest debug log to chosen log file location filename = getFilename() if logNumber == 100: print("Log file could not be created (too many exist).") return try: shutil.copy(os.path.join(Core.logDir, "avp_debug.log"), filename) with open(filename, "a") as f: f.write(f"{'='*60} debug log ends {'='*60}\n") except FileNotFoundError: print("No debug log was found. Run `avp --verbose` before `avp --log`.") with open(filename, "w") as f: f.write(f"{'='*60} no debug log {'='*60}\n") def concatenateLogs(logPattern): nonlocal filename renderLogs = glob.glob(os.path.join(Core.logDir, logPattern)) with open(filename, "a") as fw: for renderLog in renderLogs: with open(renderLog, "r") as fr: fw.write(f"{'='*60} {os.path.basename(renderLog)} {'='*60}\n") logContents = fr.readlines() fw.write("".join(logContents[:5])) fw.write("...trimmed...\n") fw.write("".join(logContents[-10:])) fw.write(f"{'='*60} {os.path.basename(renderLog)} {'='*60}\n") concatenateLogs("render_*.log") concatenateLogs("preview_*.log") print(f"Log file created at {filename}") djfun-audio-visualizer-python-f03a3a6/src/avp/components/000077500000000000000000000000001514343513600236055ustar00rootroot00000000000000djfun-audio-visualizer-python-f03a3a6/src/avp/components/__init__.py000066400000000000000000000000011514343513600257050ustar00rootroot00000000000000 djfun-audio-visualizer-python-f03a3a6/src/avp/components/__template__.ui000066400000000000000000000054141514343513600265570ustar00rootroot00000000000000 Form 0 0 586 197 Form Qt::Horizontal 40 20 Qt::Horizontal 40 20 Qt::Horizontal 40 20 Qt::Horizontal 40 20 Qt::Horizontal 40 20 Qt::Vertical 20 40 djfun-audio-visualizer-python-f03a3a6/src/avp/components/classic.py000066400000000000000000000154611514343513600256070ustar00rootroot00000000000000import numpy from PIL import Image, ImageDraw from ..libcomponent import BaseComponent from ..toolkit.frame import BlankFrame, FloodFrame from ..toolkit.visualizer import createSpectrumArray class Component(BaseComponent): name = "Classic Visualizer" version = "1.2.0" def names(*args): return ["Original"] def properties(self): props = ["pcm"] if self.invert: props.append("composite") return props def widget(self, *args): self.scale = 20 self.bars = 63 self.y = 0 super().widget(*args) self.page.comboBox_visLayout.addItem("Classic") self.page.comboBox_visLayout.addItem("Split") self.page.comboBox_visLayout.addItem("Bottom") self.page.comboBox_visLayout.addItem("Top") self.page.comboBox_visLayout.setCurrentIndex(0) self.trackWidgets( { "visColor": self.page.lineEdit_visColor, "layout": self.page.comboBox_visLayout, "scale": self.page.spinBox_scale, "y": self.page.spinBox_y, "smooth": self.page.spinBox_sensitivity, "bars": self.page.spinBox_bars, "invert": self.page.checkBox_invert, }, colorWidgets={ "visColor": self.page.pushButton_visColor, }, relativeWidgets=[ "y", ], ) def previewRender(self, frame=None): spectrum = numpy.fromfunction( lambda x: float(self.scale) / 2500 * (x - 128) ** 2, (255,), dtype="int16", ) return self.drawBars( self.width, self.height, spectrum, self.visColor, self.layout, frame, ) def preFrameRender(self, **kwargs): super().preFrameRender(**kwargs) smoothConstantDown = 0.08 if not self.smooth else self.smooth / 15 smoothConstantUp = 0.8 if not self.smooth else self.smooth / 15 self.spectrumArray = createSpectrumArray( self, self.completeAudioArray, self.sampleSize, smoothConstantDown, smoothConstantUp, self.scale, self.progressBarUpdate, self.progressBarSetText, ) def frameRender(self, frameNo, frame=None): arrayNo = frameNo * self.sampleSize return self.drawBars( self.width, self.height, self.spectrumArray[arrayNo], self.visColor, self.layout, frame, ) def drawBars(self, width, height, spectrum, color, layout, frame): bigYCoord = height - height / 8 smallYCoord = height / 1200 bigXCoord = width / (self.bars + 1) middleXCoord = bigXCoord / 2 smallXCoord = bigXCoord / 4 imTop = BlankFrame(width, height) draw = ImageDraw.Draw(imTop) r, g, b = color color2 = (r, g, b, 125) for i in range(self.bars): # draw outline behind rectangles if not inverted if frame is None: x0 = middleXCoord + i * bigXCoord y0 = bigYCoord + smallXCoord x1 = middleXCoord + i * bigXCoord + bigXCoord y1 = ( bigYCoord + smallXCoord - spectrum[i * 4] * smallYCoord - middleXCoord ) selection = ( x0, y0 if y0 < y1 else y1, x1 if x1 > x0 else x0, y1 if y0 < y1 else y0, ) draw.rectangle( selection, fill=color2, ) x0 = middleXCoord + smallXCoord + i * bigXCoord y0 = bigYCoord x1 = middleXCoord + smallXCoord + i * bigXCoord + middleXCoord y1 = bigYCoord - spectrum[i * 4] * smallYCoord selection = ( x0, y0 if y0 < y1 else y1, x1 if x1 > x0 else x0, y1 if y0 < y1 else y0, ) # fill rectangle if not inverted draw.rectangle( selection, fill=color if frame is None else (0, 0, 0, 0), outline=color, width=int(x1 - x0), ) imBottom = imTop.transpose(Image.Transpose.FLIP_TOP_BOTTOM) im = BlankFrame(width, height) if layout == 0: # Classic y = self.y - int(height / 100 * 43) im.paste(imTop, (0, y), mask=imTop) y = self.y + int(height / 100 * 43) im.paste(imBottom, (0, y), mask=imBottom) if layout == 1: # Split y = self.y + int(height / 100 * 10) im.paste(imTop, (0, y), mask=imTop) y = self.y - int(height / 100 * 10) im.paste(imBottom, (0, y), mask=imBottom) if layout == 2: # Bottom y = self.y + int(height / 100 * 10) im.paste(imTop, (0, y), mask=imTop) if layout == 3: # Top y = self.y - int(height / 100 * 10) im.paste(imBottom, (0, y), mask=imBottom) if frame is None: return im f = FloodFrame(width, height, color) f.paste(frame, (0, 0), mask=im) return f def command(self, arg): if "=" in arg: key, arg = arg.split("=", 1) try: if key == "color": self.page.lineEdit_visColor.setText(arg) return elif key == "layout": if arg == "classic": self.page.comboBox_visLayout.setCurrentIndex(0) elif arg == "split": self.page.comboBox_visLayout.setCurrentIndex(1) elif arg == "bottom": self.page.comboBox_visLayout.setCurrentIndex(2) elif arg == "top": self.page.comboBox_visLayout.setCurrentIndex(3) return elif key == "scale": arg = int(arg) self.page.spinBox_scale.setValue(arg) return elif key == "y": arg = int(arg) self.page.spinBox_y.setValue(arg) return except ValueError: print("You must enter a number.") quit(1) super().command(arg) def commandHelp(self): print("Give a layout name:\n layout=[classic/split/bottom/top]") print("Specify a color:\n color=255,255,255") print("Visualizer scale (20 is default):\n scale=number") print("Y position:\n y=number") djfun-audio-visualizer-python-f03a3a6/src/avp/components/classic.ui000066400000000000000000000155761514343513600256030ustar00rootroot00000000000000 Form 0 0 586 178 180 0 Form 4 0 0 Layout Qt::Orientation::Horizontal QSizePolicy::Policy::Fixed 5 20 Color 32 32 32 32 Qt::Orientation::Horizontal QSizePolicy::Policy::Fixed 5 20 Y QAbstractSpinBox::ButtonSymbols::UpDownArrows -5000 5000 10 0 Qt::Orientation::Horizontal 40 20 4 Scale QAbstractSpinBox::ButtonSymbols::PlusMinus 1 20 Sensitivity 5 Qt::Orientation::Horizontal QSizePolicy::Policy::Expanding 40 20 QLayout::SizeConstraint::SetDefaultConstraint 4 Bars 63 64 63 Invert Qt::Orientation::Horizontal 40 20 Qt::Orientation::Vertical 20 40 djfun-audio-visualizer-python-f03a3a6/src/avp/components/color.py000066400000000000000000000140741514343513600253030ustar00rootroot00000000000000from PyQt6 import QtGui import logging from ..libcomponent import BaseComponent from ..toolkit.frame import BlankFrame, FloodFrame, FramePainter log = logging.getLogger("AVP.Components.Color") class Component(BaseComponent): name = "Color" version = "1.0.0" def widget(self, *args): self.x = 0 self.y = 0 super().widget(*args) # disable color #2 until non-default 'fill' option gets changed self.page.lineEdit_color2.setDisabled(True) self.page.pushButton_color2.setDisabled(True) self.page.spinBox_width.setValue(int(self.settings.value("outputWidth"))) self.page.spinBox_height.setValue(int(self.settings.value("outputHeight"))) self.fillLabels = [ "Solid", "Linear Gradient", "Radial Gradient", ] for label in self.fillLabels: self.page.comboBox_fill.addItem(label) self.page.comboBox_fill.setCurrentIndex(0) self.trackWidgets( { "x": self.page.spinBox_x, "y": self.page.spinBox_y, "sizeWidth": self.page.spinBox_width, "sizeHeight": self.page.spinBox_height, "trans": self.page.checkBox_trans, "spread": self.page.comboBox_spread, "stretch": self.page.checkBox_stretch, "RG_start": self.page.spinBox_radialGradient_start, "LG_start": self.page.spinBox_linearGradient_start, "RG_end": self.page.spinBox_radialGradient_end, "LG_end": self.page.spinBox_linearGradient_end, "RG_centre": self.page.spinBox_radialGradient_spread, "fillType": self.page.comboBox_fill, "color1": self.page.lineEdit_color1, "color2": self.page.lineEdit_color2, }, presetNames={ "sizeWidth": "width", "sizeHeight": "height", }, colorWidgets={ "color1": self.page.pushButton_color1, "color2": self.page.pushButton_color2, }, relativeWidgets=[ "x", "y", "sizeWidth", "sizeHeight", "LG_start", "LG_end", "RG_start", "RG_end", "RG_centre", ], ) def update(self): fillType = self.page.comboBox_fill.currentIndex() if fillType == 0: self.page.lineEdit_color2.setEnabled(False) self.page.pushButton_color2.setEnabled(False) self.page.checkBox_trans.setEnabled(False) self.page.checkBox_stretch.setEnabled(False) self.page.comboBox_spread.setEnabled(False) else: self.page.lineEdit_color2.setEnabled(True) self.page.pushButton_color2.setEnabled(True) self.page.checkBox_trans.setEnabled(True) self.page.checkBox_stretch.setEnabled(True) self.page.comboBox_spread.setEnabled(True) if self.page.checkBox_trans.isChecked(): self.page.lineEdit_color2.setEnabled(False) self.page.pushButton_color2.setEnabled(False) self.page.fillWidget.setCurrentIndex(fillType) def previewRender(self): return self.drawFrame(self.width, self.height) def properties(self): return ["static"] def frameRender(self, frameNo): log.debug("Color component is drawing frame #%s", frameNo) return self.drawFrame(self.width, self.height) def drawFrame(self, width, height): r, g, b = self.color1 shapeSize = (self.sizeWidth, self.sizeHeight) # in default state, skip all this logic and return a plain fill if ( self.fillType == 0 and shapeSize == (width, height) and self.x == 0 and self.y == 0 ): return FloodFrame(width, height, (r, g, b, 255)) # Return a solid image at x, y if self.fillType == 0: frame = BlankFrame(width, height) image = FloodFrame(self.sizeWidth, self.sizeHeight, (r, g, b, 255)) frame.paste(image, box=(self.x, self.y)) return frame # Now fills that require using Qt... elif self.fillType > 0: image = FramePainter(width, height) if self.stretch: w = width h = height else: w = self.sizeWidth h = self.sizeWidth if self.fillType == 1: # Linear Gradient brush = QtGui.QLinearGradient( float(self.LG_start), float(self.LG_start), float(self.LG_end + width / 3), float(self.LG_end), ) elif self.fillType == 2: # Radial Gradient brush = QtGui.QRadialGradient( float(self.RG_start), float(self.RG_end), float(w), float(h), float(self.RG_centre), ) spread = QtGui.QGradient.Spread.PadSpread if self.spread == 1: spread = QtGui.QGradient.Spread.ReflectSpread elif self.spread == 2: spread = QtGui.QGradient.Spread.RepeatSpread brush.setSpread(spread) brush.setColorAt(0.0, QtGui.QColor(*self.color1)) if self.trans: brush.setColorAt(1.0, QtGui.QColor(0, 0, 0, 0)) elif self.fillType == 1 and self.stretch: brush.setColorAt(0.2, QtGui.QColor(*self.color2)) else: brush.setColorAt(1.0, QtGui.QColor(*self.color2)) image.setBrush(brush) image.drawRect(self.x, self.y, self.sizeWidth, self.sizeHeight) return image.finalize() def commandHelp(self): print("Specify a color:\n color=255,255,255") def command(self, arg): if "=" in arg: key, arg = arg.split("=", 1) if key == "color": self.page.lineEdit_color1.setText(arg) return super().command(arg) djfun-audio-visualizer-python-f03a3a6/src/avp/components/color.ui000066400000000000000000000511151514343513600252650ustar00rootroot00000000000000 Form 0 0 586 197 Form 4 0 0 31 0 Color #1 32 32 32 32 0 0 1 0 12 Qt::Orientation::Horizontal QSizePolicy::Policy::Fixed 5 20 0 0 31 0 Color #2 32 32 End color of gradient. Disabled if fill is solid. 32 32 0 0 1 0 133,133,133 12 0 0 0 Width Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft|Qt::AlignmentFlag::AlignVCenter 0 0 80 16777215 0 0 0 19200 0 0 0 Height 0 0 80 16777215 10800 Qt::Orientation::Horizontal QSizePolicy::Policy::Fixed 5 20 0 0 X 0 0 80 16777215 0 0 -10000 10000 0 0 0 Y 0 0 80 16777215 -10000 10000 0 0 0 Fill 0 0 -1 QComboBox::SizeAdjustPolicy::AdjustToContentsOnFirstShow 0 0 Transparent 0 0 Stretch Pad Reflect Repeat Qt::Orientation::Horizontal QSizePolicy::Policy::Minimum 40 20 0 0 0 2 -1 0 561 34 0 0 Start -10000 10000 10 0 0 End Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter -10000 10000 10 Qt::Orientation::Horizontal 40 20 -1 -1 561 34 0 0 Start Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter -10000 10000 10 0 0 End Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter -10000 10000 10 0 0 Centre Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter QAbstractSpinBox::ButtonSymbols::PlusMinus -10000 10000 3 Qt::Orientation::Horizontal 40 20 djfun-audio-visualizer-python-f03a3a6/src/avp/components/image.py000066400000000000000000000157731514343513600252560ustar00rootroot00000000000000from PIL import Image, ImageOps, ImageEnhance from PyQt6 import QtWidgets import os from ..libcomponent import BaseComponent from ..toolkit.frame import BlankFrame, addShadow from ..toolkit.visualizer import createSpectrumArray class Component(BaseComponent): name = "Image" version = "2.1.0" def widget(self, *args): super().widget(*args) # cache a modified image object in case we are rendering beyond frame 1 self.existingImage = None self.page.pushButton_image.clicked.connect(self.pickImage) self.page.comboBox_resizeMode.addItem("Scale") self.page.comboBox_resizeMode.addItem("Cover") self.page.comboBox_resizeMode.addItem("Stretch") self.page.comboBox_resizeMode.setCurrentIndex(0) self.trackWidgets( { "imagePath": self.page.lineEdit_image, "scale": self.page.spinBox_scale, "rotate": self.page.spinBox_rotate, "color": self.page.spinBox_color, "xPosition": self.page.spinBox_x, "yPosition": self.page.spinBox_y, "resizeMode": self.page.comboBox_resizeMode, "mirror": self.page.checkBox_mirror, "respondToAudio": self.page.checkBox_respondToAudio, "sensitivity": self.page.spinBox_sensitivity, "shadow": self.page.checkBox_shadow, }, presetNames={ "imagePath": "image", "xPosition": "x", "yPosition": "y", }, relativeWidgets=["xPosition", "yPosition", "scale"], ) def update(self): self.page.spinBox_sensitivity.setEnabled( self.page.checkBox_respondToAudio.isChecked() ) self.page.spinBox_scale.setEnabled( self.page.comboBox_resizeMode.currentIndex() == 0 ) def previewRender(self): return self.drawFrame(self.width, self.height, None) def properties(self): props = ["pcm" if self.respondToAudio else "static"] if not os.path.exists(self.imagePath): props.append("error") return props def error(self): if not self.imagePath: return "There is no image selected." if not os.path.exists(self.imagePath): return "The image selected does not exist!" def preFrameRender(self, **kwargs): super().preFrameRender(**kwargs) if not self.respondToAudio: return # Trigger creation of new base image self.existingImage = None self.spectrumArray = createSpectrumArray( self, self.completeAudioArray, self.sampleSize, 0.08, 0.8, self.sensitivity, self.progressBarUpdate, self.progressBarSetText, ) def frameRender(self, frameNo): return self.drawFrame( self.width, self.height, ( None if not self.respondToAudio else self.spectrumArray[frameNo * self.sampleSize] ), ) def drawFrame(self, width, height, dynamicScale): frame = BlankFrame(width, height) if self.imagePath and os.path.exists(self.imagePath): if dynamicScale is not None and self.existingImage: image = self.existingImage else: image = Image.open(self.imagePath) # Modify static image appearance if self.color != 100: image = ImageEnhance.Color(image).enhance(float(self.color / 100)) if self.mirror: image = image.transpose(Image.Transpose.FLIP_LEFT_RIGHT) if self.resizeMode == 1: # Cover image = ImageOps.fit( image, (width, height), Image.Resampling.LANCZOS ) elif self.resizeMode == 2: # Stretch image = image.resize((width, height), Image.Resampling.LANCZOS) elif self.scale != 100: # Scale newHeight = int((image.height / 100) * self.scale) newWidth = int((image.width / 100) * self.scale) image = image.resize( (newWidth, newHeight), Image.Resampling.LANCZOS ) self.existingImage = image # Respond to audio resolutionFactor = height / 1080 shadX = int(resolutionFactor * 1) shadY = int(resolutionFactor * -1) shadBlur = resolutionFactor * 3.50 scale = 0 if dynamicScale is not None: scale = dynamicScale[36 * 4] / 4 shadX += int((scale / 4) * resolutionFactor) shadY += int((scale / 2) * resolutionFactor) shadBlur += (scale / 8) * resolutionFactor image = ImageOps.contain( image, ( image.width + int(scale / 2), image.height + int(scale / 2), ), Image.Resampling.LANCZOS, ) # Paste image at correct position frame.paste( image, box=( self.xPosition - (0 if not self.respondToAudio else int(scale / 2)), self.yPosition - (0 if not self.respondToAudio else int(scale / 2)), ), ) if self.rotate != 0: frame = frame.rotate(self.rotate) if self.shadow: frame = addShadow(frame, shadBlur, shadX, shadY) return frame def postFrameRender(self): self.existingImage = None def pickImage(self): imgDir = self.settings.value("componentDir", os.path.expanduser("~")) filename, _ = QtWidgets.QFileDialog.getOpenFileName( self.page, "Choose Image", imgDir, "Image Files (%s)" % " ".join(self.core.imageFormats), ) if filename: self.settings.setValue("componentDir", os.path.dirname(filename)) self.mergeUndo = False self.page.lineEdit_image.setText(filename) self.mergeUndo = True def command(self, arg): def fail(): print("Not a supported image format") quit(1) if "=" in arg: key, arg = arg.split("=", 1) if key == "path" and os.path.exists(arg): if f"*{os.path.splitext(arg)[1]}" not in self.core.imageFormats: fail() try: Image.open(arg) self.page.lineEdit_image.setText(arg) self.page.comboBox_resizeMode.setCurrentIndex(2) return except OSError as e: fail() super().command(arg) def commandHelp(self): print("Load an image:\n path=/filepath/to/image.png") djfun-audio-visualizer-python-f03a3a6/src/avp/components/image.ui000066400000000000000000000310241514343513600252260ustar00rootroot00000000000000 Form 0 0 586 197 Form 4 0 0 31 0 Image 1 0 0 0 1 0 32 32 ... 32 32 Qt::Orientation::Horizontal QSizePolicy::Policy::Fixed 5 20 0 0 X 0 0 80 16777215 -10000 10000 0 0 Y 0 0 80 16777215 0 0 -1000 1000 0 0 0 Resize Qt::Orientation::Horizontal 40 20 Qt::LayoutDirection::RightToLeft Mirror 0 0 Rotate Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter QAbstractSpinBox::ButtonSymbols::UpDownArrows ° 0 359 0 0 0 Scale Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter QAbstractSpinBox::ButtonSymbols::UpDownArrows % 10 400 100 Qt::Orientation::Horizontal 40 20 Qt::LayoutDirection::RightToLeft Shadow 0 0 Color Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter QAbstractSpinBox::ButtonSymbols::UpDownArrows % 0 999 1 100 Qt::Orientation::Horizontal 40 20 Scale image in response to input audio file Qt::LayoutDirection::RightToLeft Respond to Audio true false false Sensitivity 1 20 Qt::Orientation::Vertical 20 40 djfun-audio-visualizer-python-f03a3a6/src/avp/components/life.py000066400000000000000000000541541514343513600251070ustar00rootroot00000000000000from PyQt6 import QtCore, QtWidgets from PyQt6.QtGui import QUndoCommand from PIL import Image, ImageDraw import os import math import logging from ..libcomponent import BaseComponent from ..toolkit.frame import BlankFrame, scale, addShadow from ..toolkit.visualizer import createSpectrumArray log = logging.getLogger("AVP.Component.Life") class Component(BaseComponent): name = "Conway's Game of Life" version = "2.0.1" def widget(self, *args): super().widget(*args) self.scale = 32 self.updateGridSize() # The initial grid: a "Queen Bee Shuttle" # https://conwaylife.com/wiki/Queen_bee_shuttle self.startingGrid = set( [ (3, 11), (3, 12), (4, 11), (4, 12), (8, 11), (9, 10), (9, 12), (10, 9), (10, 13), (11, 10), (11, 11), (11, 12), (12, 8), (12, 9), (12, 13), (12, 14), (23, 10), (23, 11), (24, 10), (24, 11), ] ) # Amount of 'bleed' (off-canvas coordinates) on each side of the grid self.bleedSize = 40 self.page.pushButton_pickImage.clicked.connect(self.pickImage) self.trackWidgets( { "tickRate": self.page.spinBox_tickRate, "scale": self.page.spinBox_scale, "color": self.page.lineEdit_color, "shapeType": self.page.comboBox_shapeType, "shadow": self.page.checkBox_shadow, "customImg": self.page.checkBox_customImg, "showGrid": self.page.checkBox_showGrid, "image": self.page.lineEdit_image, "kaleidoscope": self.page.checkBox_kaleidoscope, "sensitivity": self.page.spinBox_sensitivity, }, colorWidgets={ "color": self.page.pushButton_color, }, ) self.shiftButtons = ( self.page.toolButton_up, self.page.toolButton_down, self.page.toolButton_left, self.page.toolButton_right, ) def shiftFunc(i): def shift(): self.shiftGrid(i) return shift shiftFuncs = [shiftFunc(i) for i in range(len(self.shiftButtons))] for i, widget in enumerate(self.shiftButtons): widget.clicked.connect(shiftFuncs[i]) self.page.spinBox_scale.setValue(self.scale) self.page.spinBox_scale.valueChanged.connect(self.updateGridSize) def pickImage(self): imgDir = self.settings.value("componentDir", os.path.expanduser("~")) filename, _ = QtWidgets.QFileDialog.getOpenFileName( self.page, "Choose Image", imgDir, "Image Files (%s)" % " ".join(self.core.imageFormats), ) if filename: self.settings.setValue("componentDir", os.path.dirname(filename)) self.mergeUndo = False self.page.lineEdit_image.setText(filename) self.mergeUndo = True def shiftGrid(self, d): action = ShiftGrid(self, d) self.parent.undoStack.push(action) def update(self): self.updateGridSize() # Hide/show widgets depending on state of "custom image" checkbox if self.page.checkBox_customImg.isChecked(): self.page.label_color.setVisible(False) self.page.lineEdit_color.setVisible(False) self.page.pushButton_color.setVisible(False) self.page.label_shape.setVisible(False) self.page.comboBox_shapeType.setVisible(False) self.page.label_image.setVisible(True) self.page.lineEdit_image.setVisible(True) self.page.pushButton_pickImage.setVisible(True) else: self.page.label_color.setVisible(True) self.page.lineEdit_color.setVisible(True) self.page.pushButton_color.setVisible(True) self.page.label_shape.setVisible(True) self.page.comboBox_shapeType.setVisible(True) self.page.label_image.setVisible(False) self.page.lineEdit_image.setVisible(False) self.page.pushButton_pickImage.setVisible(False) # Disable audio sensitivity spinbox if not relevant if ( self.page.comboBox_shapeType.currentIndex() < 4 or self.page.checkBox_customImg.isChecked() ): self.page.spinBox_sensitivity.setEnabled(True) else: self.page.spinBox_sensitivity.setEnabled(False) # Disable arrow buttons to shift the grid if the grid is empty enabled = len(self.startingGrid) > 0 for widget in self.shiftButtons: widget.setEnabled(enabled) def previewClickEvent(self, pos, size, button): pos = ( math.ceil((pos[0] / size[0]) * self.gridWidth) - 1, math.ceil((pos[1] / size[1]) * self.gridHeight) - 1, ) action = ClickGrid(self, pos, button) self.parent.undoStack.push(action) def updateGridSize(self): w, h = self.core.resolutions[-1].split("x") self.gridWidth = int(int(w) / self.scale) self.gridHeight = int(int(h) / self.scale) self.pxWidth = math.ceil(self.width / self.gridWidth) self.pxHeight = math.ceil(self.height / self.gridHeight) def previewRender(self): image = self.drawGrid(self.startingGrid, self.color) image = self.addKaleidoscopeEffect(image) if self.shadow: image = addShadow(image, 5.00, -2, 2) image = self.addGridLines(image) return image def preFrameRender(self, *args, **kwargs): super().preFrameRender(*args, **kwargs) self.tickGrids = {0: self.startingGrid} if self.sensitivity == 0: return self.spectrumArray = createSpectrumArray( self, self.completeAudioArray, self.sampleSize, 0.08, 0.8, 20, self.progressBarUpdate, self.progressBarSetText, ) def properties(self): if self.customImg and (not self.image or not os.path.exists(self.image)): return ["error"] return ["pcm"] if self.sensitivity > 0 else [] def error(self): return "No image selected to represent life." def frameRender(self, frameNo): tick = math.floor(frameNo / self.tickRate) # Compute grid evolution on this frame if it hasn't been computed yet if tick not in self.tickGrids: self.tickGrids[tick] = self.gridForTick(tick) grid = self.tickGrids[tick] # Delete old evolution data which we shouldn't need anymore if tick - 60 in self.tickGrids: del self.tickGrids[tick - 60] # Fade difference between previous and current grid previousGrid = self.tickGrids.get(tick - 1, set()) newColor = self.color if not self.customImg: r, g, b = self.color decay = 255 / self.tickRate decayAmount = int(decay * (frameNo % self.tickRate)) decayColor = ( r, g, b, 255 - decayAmount, ) newColor = (r, g, b, min(255, decayAmount * 2)) previousGridImage = self.drawGrid( previousGrid, decayColor, ( None if (not self.customImg and self.shapeType > 3) or self.sensitivity < 1 else self.spectrumArray[frameNo * self.sampleSize] ), ) image = self.drawGrid( grid, newColor, ( None if (not self.customImg and self.shapeType > 3) or self.sensitivity < 1 else self.spectrumArray[frameNo * self.sampleSize] ), grid.intersection(previousGrid), ) if not self.customImg: image = Image.alpha_composite(previousGridImage, image) image = self.addKaleidoscopeEffect(image) if self.shadow: image = addShadow(image, 5.00, -2, 2) image = self.addGridLines(image) return image def addGridLines(self, frame): if not self.showGrid: return frame drawer = ImageDraw.Draw(frame) w, h = scale(0.05, self.width, self.height, int) for x in range(self.pxWidth, self.width, self.pxWidth): drawer.rectangle( ((x, 0), (x + w, self.height)), fill=self.color, ) for y in range(self.pxHeight, self.height, self.pxHeight): drawer.rectangle( ((0, y), (self.width, y + h)), fill=self.color, ) return frame def addKaleidoscopeEffect(self, frame): if not self.kaleidoscope: return frame flippedImage = frame.transpose(Image.Transpose.FLIP_TOP_BOTTOM) frame.paste(flippedImage, (0, 0), mask=flippedImage) flippedImage = frame.transpose(Image.Transpose.FLIP_LEFT_RIGHT) frame.paste(flippedImage, (0, 0), mask=flippedImage) return frame def drawGrid(self, grid, color, spectrumData=None, didntChange=None): frame = BlankFrame(self.width, self.height) if didntChange is None: # this set would contain cell coords that did not change # between the previous grid tick and this one didntChange = set() def drawCustomImg(): try: img = Image.open(self.image) except Exception: return img = img.resize( ( (self.pxWidth + audioMorphWidth), (self.pxHeight + audioMorphHeight), ), Image.Resampling.LANCZOS, ) frame.paste( img, box=( (drawPtX - (audioMorphWidth * 2)), (drawPtY - (audioMorphHeight * 2)), ), ) def drawShape(x, y): drawer = ImageDraw.Draw(frame) rect = ( (drawPtX - audioMorphWidth, drawPtY - audioMorphHeight), ( drawPtX + self.pxWidth + audioMorphWidth, drawPtY + self.pxHeight + audioMorphHeight, ), ) shape = self.page.comboBox_shapeType.currentText().lower() thisCellColor = color if (x, y) not in didntChange else (*color[:3], 255) # Rectangle if shape == "rectangle": drawer.rectangle(rect, fill=thisCellColor) # Elliptical elif shape == "elliptical": drawer.ellipse(rect, fill=thisCellColor) tenthX, tenthY = scale(10, self.pxWidth, self.pxHeight, int) smallerShape = ( ( drawPtX + tenthX + int(tenthX / 4) - int(audioMorphWidth / 2), drawPtY + tenthY + int(tenthY / 2) - int(audioMorphHeight / 2), ), ( drawPtX + self.pxWidth - tenthX - int(tenthX / 4) + int(audioMorphWidth / 2), drawPtY + self.pxHeight - (tenthY + int(tenthY / 2)) + int(audioMorphHeight / 2), ), ) outlineShape = ( ( drawPtX + int(tenthX / 4) - audioMorphWidth, drawPtY + int(tenthY / 2) - audioMorphHeight, ), ( drawPtX + self.pxWidth - int(tenthX / 4) + audioMorphWidth, drawPtY + self.pxHeight - int(tenthY / 2) + audioMorphHeight, ), ) # Circle if shape == "circle": drawer.ellipse(outlineShape, fill=thisCellColor) drawer.ellipse(smallerShape, fill=(0, 0, 0, 0)) # Lilypad elif shape == "lilypad": drawer.pieslice(smallerShape, 290, 250, fill=thisCellColor) # Pie elif shape == "pie": drawer.pieslice(outlineShape, 35, 320, fill=thisCellColor) hX, hY = scale(50, self.pxWidth, self.pxHeight, int) # halfline tX, tY = scale(33, self.pxWidth, self.pxHeight, int) # thirdline qX, qY = scale(20, self.pxWidth, self.pxHeight, int) # quarterline # Path if shape == "path": drawer.ellipse(rect, fill=thisCellColor) rects = { direction: False for direction in ( "up", "down", "left", "right", ) } for cell in self.nearbyCoords(x, y): if cell not in grid: continue if cell[0] == x: if cell[1] < y: rects["up"] = True if cell[1] > y: rects["down"] = True if cell[1] == y: if cell[0] < x: rects["left"] = True if cell[0] > x: rects["right"] = True for direction, rect in rects.items(): if rect: if direction == "up": sect = ( (drawPtX, drawPtY), (drawPtX + self.pxWidth, drawPtY + hY), ) elif direction == "down": sect = ( (drawPtX, drawPtY + hY), ( drawPtX + self.pxWidth, drawPtY + self.pxHeight, ), ) elif direction == "left": sect = ( (drawPtX, drawPtY), (drawPtX + hX, drawPtY + self.pxHeight), ) elif direction == "right": sect = ( (drawPtX + hX, drawPtY), ( drawPtX + self.pxWidth, drawPtY + self.pxHeight, ), ) drawer.rectangle(sect, fill=thisCellColor) # Duck elif shape == "duck": duckHead = ( (drawPtX + qX, drawPtY + qY), (drawPtX + int(qX * 3), drawPtY + int(tY * 2)), ) duckBeak = ( (drawPtX + hX, drawPtY + qY), (drawPtX + self.pxWidth + qX, drawPtY + int(qY * 3)), ) duckWing = ((drawPtX, drawPtY + hY), rect[1]) duckBody = ( (drawPtX + int(qX / 4), drawPtY + int(qY * 3)), (drawPtX + int(tX * 2), drawPtY + self.pxHeight), ) drawer.ellipse(duckBody, fill=thisCellColor) drawer.ellipse(duckHead, fill=thisCellColor) drawer.pieslice(duckWing, 130, 200, fill=thisCellColor) drawer.pieslice(duckBeak, 145, 200, fill=thisCellColor) # Peace elif shape == "peace": line = ( ( drawPtX + hX - int(tenthX / 2), drawPtY + int(tenthY / 2), ), ( drawPtX + hX + int(tenthX / 2), drawPtY + self.pxHeight - int(tenthY / 2), ), ) drawer.ellipse(outlineShape, fill=thisCellColor) drawer.ellipse(smallerShape, fill=(0, 0, 0, 0)) drawer.rectangle(line, fill=thisCellColor) def slantLine(difference): return ( (drawPtX + difference), (drawPtY + self.pxHeight - qY), ), ( (drawPtX + hX), (drawPtY + hY), ) drawer.line(slantLine(qX), fill=thisCellColor, width=tenthX) drawer.line( slantLine(self.pxWidth - qX), fill=thisCellColor, width=tenthX ) for x, y in grid: drawPtX = x * self.pxWidth if drawPtX > self.width or drawPtX + self.pxWidth < 0: continue drawPtY = y * self.pxHeight if drawPtY > self.height or drawPtY + self.pxHeight < 0: continue audioMorphWidth = ( 0 if spectrumData is None else int(spectrumData[(drawPtX % 63) * 4] / 4) ) audioMorphHeight = ( 0 if spectrumData is None else int(spectrumData[(drawPtY % 63) * 4] / 4) ) if self.customImg: drawCustomImg() else: drawShape(x, y) return frame def gridForTick(self, tick): """ Given a tick number over 0, returns a new grid (a set of tuples). This must compute the previous ticks' grids if not already computed """ if tick - 1 not in self.tickGrids: self.tickGrids[tick - 1] = self.gridForTick(tick - 1) lastGrid = self.tickGrids[tick - 1] def neighbours(x, y): return {cell for cell in self.nearbyCoords(x, y) if cell in lastGrid} newGrid = set() # Copy cells from the previous grid if they have 2 or 3 neighbouring cells # and if they are within the grid or its bleed area (off-canvas area) for x, y in lastGrid: if ( -self.bleedSize > x > self.gridWidth + self.bleedSize or -self.bleedSize > y > self.gridHeight + self.bleedSize ): continue surrounding = len(neighbours(x, y)) if surrounding == 2 or surrounding == 3: newGrid.add((x, y)) # Find positions around living cells which must be checked for reproduction potentialNewCells = { coordTup for origin in lastGrid for coordTup in list(self.nearbyCoords(*origin)) } # Check for reproduction for x, y in potentialNewCells: if (x, y) in newGrid: # Ignore non-empty cell continue surrounding = len(neighbours(x, y)) if surrounding == 3: newGrid.add((x, y)) return newGrid def savePreset(self): pr = super().savePreset() pr["GRID"] = sorted(self.startingGrid) return pr def loadPreset(self, pr, *args): self.startingGrid = set(pr["GRID"]) if self.startingGrid: for widget in self.shiftButtons: widget.setEnabled(True) super().loadPreset(pr, *args) def nearbyCoords(self, x, y): yield x + 1, y + 1 yield x + 1, y - 1 yield x - 1, y + 1 yield x - 1, y - 1 yield x, y + 1 yield x, y - 1 yield x + 1, y yield x - 1, y class ClickGrid(QUndoCommand): def __init__(self, comp, pos, button): super().__init__("click %s component #%s" % (comp.name, comp.compPos)) self.comp = comp self.pos = [pos] if button == QtCore.Qt.MouseButton.RightButton: self.button = 2 else: self.button = 1 def id(self): return self.button def mergeWith(self, other): self.pos.extend(other.pos) return True def add(self): for pos in self.pos[:]: self.comp.startingGrid.add(pos) self.comp.update(auto=True) def remove(self): for pos in self.pos[:]: self.comp.startingGrid.discard(pos) self.comp.update(auto=True) def redo(self): if self.button == 1: # Left-click self.add() elif self.button == 2: # Right-click self.remove() def undo(self): if self.button == 1: # Left-click self.remove() elif self.button == 2: # Right-click self.add() class ShiftGrid(QUndoCommand): def __init__(self, comp, direction): super().__init__("change %s component #%s" % (comp.name, comp.compPos)) self.comp = comp self.direction = direction self.distance = 1 def id(self): return self.direction def mergeWith(self, other): self.distance += other.distance return True def newGrid(self, Xchange, Ychange): return {(x + Xchange, y + Ychange) for x, y in self.comp.startingGrid} def redo(self): if self.direction == 0: newGrid = self.newGrid(0, -self.distance) elif self.direction == 1: newGrid = self.newGrid(0, self.distance) elif self.direction == 2: newGrid = self.newGrid(-self.distance, 0) elif self.direction == 3: newGrid = self.newGrid(self.distance, 0) self.comp.startingGrid = newGrid self.comp._sendUpdateSignal() def undo(self): if self.direction == 0: newGrid = self.newGrid(0, self.distance) elif self.direction == 1: newGrid = self.newGrid(0, -self.distance) elif self.direction == 2: newGrid = self.newGrid(self.distance, 0) elif self.direction == 3: newGrid = self.newGrid(-self.distance, 0) self.comp.startingGrid = newGrid self.comp._sendUpdateSignal() djfun-audio-visualizer-python-f03a3a6/src/avp/components/life.ui000066400000000000000000000340161514343513600250670ustar00rootroot00000000000000 Form 0 0 586 206 Form Simulation Speed increase number for slower animation frames per tick 10 240 60 Qt::Orientation::Horizontal 40 20 0 16777215 Grid Scale 22 128 32 Custom Image Qt::Orientation::Horizontal 40 20 Image 0 0 32 32 ... Color 0 16777215 0,0,0 0 0 32 32 false false Qt::Orientation::Horizontal 40 20 Shape Path Rectangle Elliptical Circle Lilypad Pie Duck Peace Qt::Orientation::Horizontal 40 20 Kaleidoscope true Shadow true Show Grid Qt::Orientation::Horizontal 40 20 Up Qt::ArrowType::UpArrow Down Qt::ArrowType::DownArrow Left Qt::ArrowType::LeftArrow Right Qt::ArrowType::RightArrow Qt::Orientation::Horizontal 40 20 Audio Sensitivity 40 20 <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> <html><head><meta name="qrichtext" content="1" /><meta charset="utf-8" /><style type="text/css"> p, li { white-space: pre-wrap; } hr { height: 1px; border-width: 0; } li.unchecked::marker { content: "\2610"; } li.checked::marker { content: "\2612"; } </style></head><body style=" font-family:'Noto Sans'; font-size:10pt; font-weight:400; font-style:normal;"> <p style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-family:'Ubuntu'; font-size:11pt; font-weight:600;">Click the preview window to place a cell. Right-click to remove.</span></p> <p style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-family:'Ubuntu'; font-size:11pt;">- A cell with less than 2 neighbours will die from underpopulation</span></p> <p style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-family:'Ubuntu'; font-size:11pt;">- A cell with more than 3 neighbours will die from overpopulation.</span></p> <p style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-family:'Ubuntu'; font-size:11pt;">- An empty space surrounded by 3 live cells will cause reproduction.</span></p></body></html> 80.000000000000000 Qt::TextInteractionFlag::NoTextInteraction false Qt::Orientation::Vertical 20 40 djfun-audio-visualizer-python-f03a3a6/src/avp/components/sound.py000066400000000000000000000046301514343513600253120ustar00rootroot00000000000000from PyQt6 import QtWidgets import os from ..libcomponent import BaseComponent from ..toolkit.frame import BlankFrame class Component(BaseComponent): name = "Sound" version = "1.0.0" def widget(self, *args): super().widget(*args) self.page.pushButton_sound.clicked.connect(self.pickSound) self.trackWidgets( { "sound": self.page.lineEdit_sound, "chorus": self.page.checkBox_chorus, "delay": self.page.spinBox_delay, "volume": self.page.spinBox_volume, }, commandArgs={ "sound": None, }, ) def properties(self): props = ["static", "audio"] if not os.path.exists(self.sound): props.append("error") return props def error(self): if not self.sound: return "No audio file selected." if not os.path.exists(self.sound): return "The audio file selected no longer exists!" def audio(self): params = {} if self.delay != 0.0: params["adelay"] = "=%s" % str(int(self.delay * 1000.00)) if self.chorus: params["chorus"] = "=0.5:0.9:50|60|40:0.4|0.32|0.3:0.25|0.4|0.3:2|2.3|1.3" if self.volume != 1.0: params["volume"] = "=%s:replaygain_noclip=0" % str(self.volume) return (self.sound, params) def pickSound(self): sndDir = self.settings.value("componentDir", os.path.expanduser("~")) filename, _ = QtWidgets.QFileDialog.getOpenFileName( self.page, "Choose Sound", sndDir, "Audio Files (%s)" % " ".join(self.core.audioFormats), ) if filename: self.settings.setValue("componentDir", os.path.dirname(filename)) self.mergeUndo = False self.page.lineEdit_sound.setText(filename) self.mergeUndo = True def commandHelp(self): print("Path to audio file:\n path=/filepath/to/sound.ogg") def command(self, arg): if "=" in arg: key, arg = arg.split("=", 1) if key == "path": if "*%s" % os.path.splitext(arg)[1] not in self.core.audioFormats: print("Not a supported audio format") quit(1) self.page.lineEdit_sound.setText(arg) return super().command(arg) djfun-audio-visualizer-python-f03a3a6/src/avp/components/sound.ui000066400000000000000000000106021514343513600252730ustar00rootroot00000000000000 Form 0 0 586 197 Form 4 0 0 31 0 Audio File 1 0 0 0 1 0 32 32 ... 32 32 Volume x 10.000000000000000 0.100000000000000 1.000000000000000 Qt::Horizontal 40 20 Delay s 9999999.990000000223517 0.500000000000000 Chorus Qt::Vertical 20 40 djfun-audio-visualizer-python-f03a3a6/src/avp/components/spectrum.py000066400000000000000000000321571514343513600260310ustar00rootroot00000000000000from PIL import Image import os import subprocess import logging from ..libcomponent import BaseComponent from ..toolkit.frame import BlankFrame, scale from ..toolkit import connectWidget from ..toolkit.ffmpeg import ( openPipe, closePipe, getAudioDuration, FfmpegVideo, exampleSound, ) log = logging.getLogger("AVP.Components.Spectrum") class Component(BaseComponent): name = "Spectrum" version = "1.0.1" def widget(self, *args): self.previewFrame = None super().widget(*args) self._image = BlankFrame(self.width, self.height) self.chunkSize = 4 * self.width * self.height self.changedOptions = True self.previewSize = (214, 120) self.previewPipe = None if hasattr(self.parent, "lineEdit_audioFile"): # update preview when audio file changes (if genericPreview is off) self.parent.lineEdit_audioFile.textChanged.connect(self.update) self.trackWidgets( { "filterType": self.page.comboBox_filterType, "window": self.page.comboBox_window, "mode": self.page.comboBox_mode, "amplitude": self.page.comboBox_amplitude0, "amplitude1": self.page.comboBox_amplitude1, "amplitude2": self.page.comboBox_amplitude2, "display": self.page.comboBox_display, "zoom": self.page.spinBox_zoom, "tc": self.page.spinBox_tc, "x": self.page.spinBox_x, "y": self.page.spinBox_y, "mirror": self.page.checkBox_mirror, "draw": self.page.checkBox_draw, "scale": self.page.spinBox_scale, "color": self.page.comboBox_color, "compress": self.page.checkBox_compress, "mono": self.page.checkBox_mono, "hue": self.page.spinBox_hue, }, relativeWidgets=[ "x", "y", ], ) for widget in self._trackedWidgets.values(): connectWidget(widget, lambda: self.changed()) def changed(self): self.changedOptions = True def update(self): filterType = self.page.comboBox_filterType.currentIndex() self.page.stackedWidget.setCurrentIndex(filterType) if filterType == 3: self.page.spinBox_hue.setEnabled(False) else: self.page.spinBox_hue.setEnabled(True) if filterType == 2 or filterType == 4: self.page.checkBox_mono.setEnabled(False) else: self.page.checkBox_mono.setEnabled(True) def previewRender(self): changedSize = self.updateChunksize() if ( not changedSize and not self.changedOptions and self.previewFrame is not None ): log.debug("Spectrum #%s is reusing old preview frame" % self.compPos) return self.previewFrame frame = self.getPreviewFrame() self.changedOptions = False if not frame: log.warning("Spectrum #%s failed to create a preview frame" % self.compPos) self.previewFrame = None return BlankFrame(self.width, self.height) else: self.previewFrame = frame return frame def preFrameRender(self, **kwargs): super().preFrameRender(**kwargs) if self.previewPipe is not None: self.previewPipe.wait() self.updateChunksize() w, h = scale(self.scale, self.width, self.height, str) self.video = FfmpegVideo( inputPath=self.audioFile, filter_=self.makeFfmpegFilter(), width=w, height=h, chunkSize=self.chunkSize, frameRate=int(self.settings.value("outputFrameRate")), parent=self.parent, component=self, ) def frameRender(self, frameNo): if FfmpegVideo.threadError is not None: raise FfmpegVideo.threadError return self.finalizeFrame(self.video.frame(frameNo)) def postFrameRender(self): closePipe(self.video.pipe) def getPreviewFrame(self): genericPreview = self.settings.value("pref_genericPreview") startPt = 0 if not genericPreview: inputFile = self.parent.lineEdit_audioFile.text() if not inputFile or not os.path.exists(inputFile): return duration = getAudioDuration(inputFile) if not duration: return startPt = duration / 3 command = [ self.core.FFMPEG_BIN, "-thread_queue_size", "512", "-r", str(self.settings.value("outputFrameRate")), "-ss", "{0:.3f}".format(startPt), "-i", self.core.junkStream if genericPreview else inputFile, "-f", "image2pipe", "-pix_fmt", "rgba", ] command.extend(self.makeFfmpegFilter(preview=True, startPt=startPt)) command.extend( [ "-an", "-s:v", "%sx%s" % scale(self.scale, self.width, self.height, str), "-codec:v", "rawvideo", "-", "-frames:v", "1", ] ) if self.core.logEnabled: logFilename = os.path.join( self.core.logDir, "preview_%s.log" % str(self.compPos) ) log.debug("Creating FFmpeg process (log at %s)" % logFilename) with open(logFilename, "w") as logf: logf.write(" ".join(command) + "\n\n") with open(logFilename, "a") as logf: self.previewPipe = openPipe( command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=logf, bufsize=10**8, ) else: self.previewPipe = openPipe( command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, bufsize=10**8, ) byteFrame = self.previewPipe.stdout.read(self.chunkSize) closePipe(self.previewPipe) frame = self.finalizeFrame(byteFrame) return frame def makeFfmpegFilter(self, preview=False, startPt=0): """Makes final FFmpeg filter command""" def getFilterComplexCommand(): """Inner function that creates the final, complex part of the filter command""" nonlocal self genericPreview = self.settings.value("pref_genericPreview") def getFilterComplexCommandForType(): """Determine portion of filter command that changes depending on selected type""" nonlocal self if preview: w, h = self.previewSize else: w, h = (self.width, self.height) color = self.page.comboBox_color.currentText().lower() if self.filterType == 0: # Spectrum if self.amplitude == 0: amplitude = "sqrt" elif self.amplitude == 1: amplitude = "cbrt" elif self.amplitude == 2: amplitude = "4thrt" elif self.amplitude == 3: amplitude = "5thrt" elif self.amplitude == 4: amplitude = "lin" elif self.amplitude == 5: amplitude = "log" filter_ = ( f"showspectrum=s={w}x{h}:" "slide=scroll:" f"win_func={self.page.comboBox_window.currentText()}:" f"color={color}:" f"scale={amplitude}," "colorkey=color=black:" "similarity=0.1:blend=0.5" ) elif self.filterType == 1: # Histogram if self.amplitude1 == 0: amplitude = "log" elif self.amplitude1 == 1: amplitude = "lin" if self.display == 0: display = "log" elif self.display == 1: display = "sqrt" elif self.display == 2: display = "cbrt" elif self.display == 3: display = "lin" elif self.display == 4: display = "rlog" filter_ = ( f'ahistogram=r={str(self.settings.value("outputFrameRate"))}:' f"s={w}x{h}:" "dmode=separate:" f"ascale={amplitude}:" f"scale={display}" ) elif self.filterType == 2: # Vector Scope if self.amplitude2 == 0: amplitude = "log" elif self.amplitude2 == 1: amplitude = "sqrt" elif self.amplitude2 == 2: amplitude = "cbrt" elif self.amplitude2 == 3: amplitude = "lin" m = self.page.comboBox_mode.currentText() filter_ = ( f"avectorscope=s={w}x{h}:" f'draw={"line" if self.draw else "dot"}:' f"m={m}:" f"scale={amplitude}:" f"zoom={str(self.zoom)}" ) elif self.filterType == 3: # Musical Scale filter_ = ( f'showcqt=r={str(self.settings.value("outputFrameRate"))}:' f"s={w}x{h}:" "count=30:" "text=0:" f"tc={str(self.tc)}," "colorkey=color=black:" "similarity=0.1:blend=0.5" ) elif self.filterType == 4: # Phase filter_ = ( f'aphasemeter=r={str(self.settings.value("outputFrameRate"))}:' f"s={w}x{h}:" "video=1 [atrash][vtmp1]; " "[atrash] anullsink; " "[vtmp1] colorkey=color=black:" "similarity=0.1:blend=0.5, " "crop=in_w/8:in_h:(in_w/8)*7:0 " ) return filter_ if self.filterType < 2: exampleSnd = exampleSound("freq") elif self.filterType == 2 or self.filterType == 4: exampleSnd = exampleSound("stereo") elif self.filterType == 3: exampleSnd = exampleSound("white") compression = "compand=gain=4," if self.compress else "" aformat = ( "aformat=channel_layouts=mono," if self.mono and self.filterType not in (2, 4) else "" ) filter_ = getFilterComplexCommandForType() hflip = "hflip, " if self.mirror else "" trim = ( "trim=start=%s:end=%s, " % ( "{0:.3f}".format(startPt + 12), "{0:.3f}".format(startPt + 12.5), ) if preview else "" ) scale_ = "scale=%sx%s" % scale(self.scale, self.width, self.height, str) hue = ( ", hue=h=%s:s=10" % str(self.hue) if self.hue > 0 and self.filterType != 3 else "" ) convolution = ( ", convolution=-2 -1 0 -1 1 1 0 1 2:-2 -1 0 -1 1 1 0 1 2:-2 -1 0 -1 1 1 0 1 2:-2 -1 0 -1 1 1 0 1 2" if self.filterType == 3 else "" ) return ( f"{exampleSnd if preview and genericPreview else '[0:a] '}" f"{compression}{aformat}{filter_} [v1]; " f"[v1] {hflip}{trim}{scale_}{hue}{convolution} [v]" ) return [ "-filter_complex", getFilterComplexCommand(), "-map", "[v]", ] def updateChunksize(self): width, height = scale(self.scale, self.width, self.height, int) oldChunkSize = int(self.chunkSize) self.chunkSize = 4 * width * height changed = self.chunkSize != oldChunkSize return changed def finalizeFrame(self, imageData): try: image = Image.frombytes( "RGBA", scale(self.scale, self.width, self.height, int), imageData, ) self._image = image except ValueError: image = self._image frame = BlankFrame(self.width, self.height) frame.paste(image, box=(self.x, self.y)) return frame djfun-audio-visualizer-python-f03a3a6/src/avp/components/spectrum.ui000066400000000000000000000650201514343513600260110ustar00rootroot00000000000000 Form 0 0 586 197 0 0 0 197 Form 0 0 Type Spectrum Histogram Vector Scope Musical Scale Phase Qt::Orientation::Horizontal QSizePolicy::Policy::Fixed 5 20 0 0 X 0 0 80 16777215 -10000 10000 0 0 Y 0 0 80 16777215 0 0 -10000 10000 0 Compress Mono Mirror Qt::Orientation::Horizontal 40 20 Hue 4 ° 359 0 0 Scale Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter QAbstractSpinBox::ButtonSymbols::UpDownArrows % 10 400 100 0 0 false QFrame::Shape::NoFrame QFrame::Shadow::Plain 0 0 0 561 76 QLayout::SizeConstraint::SetMaximumSize 0 QLayout::SizeConstraint::SetDefaultConstraint 0 0 31 0 Window 4 hann gauss tukey dolph cauchy parzen poisson rect bartlett hanning hamming blackman welch flattop bharris bnuttall lanczos 0 0 Amplitude 4 Square root Cubic root 4thrt 5thrt Linear Logarithmic Qt::Orientation::Horizontal QSizePolicy::Policy::MinimumExpanding 10 20 0 0 Color 4 Channel Intensity Rainbow Moreland Nebulae Fire Fiery Fruit Cool Qt::Orientation::Horizontal QSizePolicy::Policy::MinimumExpanding 10 20 -1 -1 561 36 0 0 Display Scale 4 Logarithmic Square root Cubic root Linear Reverse Log 0 0 Amplitude 4 Logarithmic Linear Qt::Orientation::Horizontal QSizePolicy::Policy::Minimum 40 20 -1 -1 585 76 Mode lissajous lissajous_xy polar 0 0 Amplitude 4 Linear Square root Cubic root Logarithmic Qt::Orientation::Horizontal 40 20 0 0 Zoom 4 1 10 Line Qt::Orientation::Horizontal 40 20 0 0 561 36 0 0 Timeclamp 4 s 3 0.002000000000000 1.000000000000000 0.010000000000000 0.017000000000000 Qt::Orientation::Horizontal 40 20 0 0 551 31 Qt::Orientation::Vertical QSizePolicy::Policy::Fixed 20 10 djfun-audio-visualizer-python-f03a3a6/src/avp/components/text.py000066400000000000000000000172441514343513600251530ustar00rootroot00000000000000from PyQt6.QtGui import QFont from PyQt6 import QtGui, QtCore import logging from ..libcomponent import BaseComponent from ..toolkit.frame import FramePainter, addShadow log = logging.getLogger("AVP.Components.Text") class Component(BaseComponent): name = "Title Text" version = "1.0.1" def widget(self, *args): super().widget(*args) self.title = "Text" self.alignment = 1 self.titleFont = QFont() self.fontSize = self.height / 13.5 self.page.comboBox_textAlign.addItem("Left") self.page.comboBox_textAlign.addItem("Middle") self.page.comboBox_textAlign.addItem("Right") self.page.comboBox_textAlign.setCurrentIndex(int(self.alignment)) self.page.spinBox_fontSize.setValue(int(self.fontSize)) self.page.pushButton_center.clicked.connect(self.centerXY) self.page.fontComboBox_titleFont.currentFontChanged.connect( self._sendUpdateSignal ) # The QFontComboBox must be connected directly to the Qt Signal # which triggers the preview to update. # This unfortunately makes changing the font into a non-undoable action. # Fix requires updating ComponentAction to handle fonts self.trackWidgets( { "textColor": self.page.lineEdit_textColor, "title": self.page.lineEdit_title, "alignment": self.page.comboBox_textAlign, "fontSize": self.page.spinBox_fontSize, "xPosition": self.page.spinBox_xTextAlign, "yPosition": self.page.spinBox_yTextAlign, "fontStyle": self.page.comboBox_fontStyle, "stroke": self.page.spinBox_stroke, "strokeColor": self.page.lineEdit_strokeColor, "shadow": self.page.checkBox_shadow, "shadX": self.page.spinBox_shadX, "shadY": self.page.spinBox_shadY, "shadBlur": self.page.spinBox_shadBlur, }, colorWidgets={ "textColor": self.page.pushButton_textColor, "strokeColor": self.page.pushButton_strokeColor, }, relativeWidgets=[ "xPosition", "yPosition", "fontSize", "stroke", "shadX", "shadY", "shadBlur", ], ) self.centerXY() def update(self): self.titleFont = self.page.fontComboBox_titleFont.currentFont() if self.page.checkBox_shadow.isChecked(): self.page.label_shadX.setHidden(False) self.page.spinBox_shadX.setHidden(False) self.page.spinBox_shadY.setHidden(False) self.page.label_shadBlur.setHidden(False) self.page.spinBox_shadBlur.setHidden(False) else: self.page.label_shadX.setHidden(True) self.page.spinBox_shadX.setHidden(True) self.page.spinBox_shadY.setHidden(True) self.page.label_shadBlur.setHidden(True) self.page.spinBox_shadBlur.setHidden(True) def centerXY(self): self.setRelativeWidget("xPosition", 0.5) self.setRelativeWidget("yPosition", 0.521) def getXY(self): """Returns true x, y after considering alignment settings""" fm = QtGui.QFontMetrics(self.titleFont) text_width = fm.boundingRect(self.title).width() x = self.pixelValForAttr("xPosition") if self.alignment == 1: # Middle offset = int(text_width / 2) elif self.alignment == 2: # Right offset = text_width else: raise ValueError(f"Alignment value {self.alignment} unknown") x -= offset return x, self.yPosition def loadPreset(self, pr, *args): super().loadPreset(pr, *args) font = QFont() font.fromString(pr["titleFont"]) self.page.fontComboBox_titleFont.setCurrentFont(font) def savePreset(self): saveValueStore = super().savePreset() saveValueStore["titleFont"] = self.titleFont.toString() return saveValueStore def previewRender(self): return self.addText(self.width, self.height) def properties(self): props = ["static"] if not self.title: props.append("error") return props def error(self): return "No text provided." def frameRender(self, frameNo): return self.addText(self.width, self.height) def addText(self, width, height): font = self.titleFont font.setPixelSize(self.fontSize) font.setStyle(QFont.Style.StyleNormal) font.setWeight(QFont.Weight.Normal) font.setCapitalization(QFont.Capitalization.MixedCase) if self.fontStyle == 1: font.setWeight(QFont.Weight.DemiBold) if self.fontStyle == 2: font.setWeight(QFont.Weight.Bold) elif self.fontStyle == 3: font.setStyle(QFont.Style.StyleItalic) elif self.fontStyle == 4: font.setWeight(QFont.Weight.Bold) font.setStyle(QFont.Style.StyleItalic) elif self.fontStyle == 5: font.setStyle(QFont.Style.StyleOblique) elif self.fontStyle == 6: font.setCapitalization(QFont.Capitalization.SmallCaps) image = FramePainter(width, height) x, y = self.getXY() log.debug("Text position translates to %s, %s", x, y) if self.stroke > 0: outliner = QtGui.QPainterPathStroker() outliner.setWidth(self.stroke) path = QtGui.QPainterPath() if self.fontStyle == 6: # PathStroker ignores smallcaps so we need this weird hack path.addText(x, y, font, self.title[0]) fm = QtGui.QFontMetrics(font) newX = x + fm.boundingRect(self.title[0]).width() strokeFont = self.page.fontComboBox_titleFont.currentFont() strokeFont.setCapitalization(QFont.Capitalization.SmallCaps) strokeFont.setPixelSize(int((self.fontSize / 7) * 5)) strokeFont.setLetterSpacing(QFont.SpacingType.PercentageSpacing, 139) path.addText(newX, y, strokeFont, self.title[1:]) else: path.addText(x, y, font, self.title) path = outliner.createStroke(path) image.setPen(QtCore.Qt.PenStyle.NoPen) image.setBrush(QtGui.QColor(*self.strokeColor)) image.drawPath(path) image.setFont(font) image.setPen(self.textColor) image.drawText(x, y, self.title) # turn QImage into Pillow frame frame = image.finalize() if self.shadow: frame = addShadow(frame, self.shadBlur / 10, self.shadX, self.shadY) return frame def commandHelp(self): print("Enter a string to use as centred white text:") print(' "title=User Error"') print("Specify a text color:\n color=255,255,255") print("Set custom x, y position:\n x=500 y=500") def command(self, arg): if "=" in arg: key, arg = arg.split("=", 1) if key == "color": self.page.lineEdit_textColor.setText(arg) return elif key == "size": self.page.spinBox_fontSize.setValue(int(arg)) return elif key == "x": self.page.spinBox_xTextAlign.setValue(int(arg)) return elif key == "y": self.page.spinBox_yTextAlign.setValue(int(arg)) return elif key == "title": self.page.lineEdit_title.setText(arg) return super().command(arg) djfun-audio-visualizer-python-f03a3a6/src/avp/components/text.ui000066400000000000000000000443131514343513600251350ustar00rootroot00000000000000 Form 0 0 586 197 Form Title 0 0 0 0 Text 0 0 Font 0 0 0 0 0 0 0 Text Layout 0 0 100 16777215 Qt::Orientation::Horizontal QSizePolicy::Policy::Fixed 5 20 0 0 Center Text Qt::Orientation::Horizontal QSizePolicy::Policy::Fixed 5 20 0 0 X 0 0 50 16777215 0 0 0 999999999 0 0 0 Y 0 0 50 16777215 999999999 0 0 16777215 16777215 Text Color 0 0 32 32 32 32 Qt::Orientation::Horizontal QSizePolicy::Policy::Fixed 5 20 0 0 Font Size 0 0 1 500 Qt::Orientation::Horizontal QSizePolicy::Policy::Fixed 5 20 0 0 Font Style Normal Semi-Bold Bold Italic Bold Italic Faux Italic Small Caps 0 0 0 16777215 Qt::FocusPolicy::NoFocus 0 0 Stroke 0 0 px 0 0 Stroke Color 0 0 0 16777215 Qt::FocusPolicy::NoFocus 0,0,0 0 0 32 32 32 32 Qt::Orientation::Horizontal QSizePolicy::Policy::MinimumExpanding 40 20 0 0 Shadow Qt::Orientation::Horizontal QSizePolicy::Policy::Preferred 40 20 0 0 Shadow Offset 0 0 -1000 1000 2 0 0 -1000 1000 -2 0 0 Shadow Blur 0 0 QAbstractSpinBox::ButtonSymbols::PlusMinus QAbstractSpinBox::CorrectionMode::CorrectToPreviousValue 1000 QAbstractSpinBox::StepType::DefaultStepType 35 10 Qt::Orientation::Vertical 20 40 djfun-audio-visualizer-python-f03a3a6/src/avp/components/video.py000066400000000000000000000202431514343513600252660ustar00rootroot00000000000000from PIL import Image from PyQt6 import QtWidgets import os import subprocess import logging from ..libcomponent import BaseComponent from ..toolkit.frame import BlankFrame, scale from ..toolkit.ffmpeg import openPipe, closePipe, testAudioStream, FfmpegVideo log = logging.getLogger("AVP.Components.Video") class Component(BaseComponent): name = "Video" version = "1.0.0" def widget(self, *args): self.videoPath = "" self.badAudio = False self.x = 0 self.y = 0 self.loopVideo = False super().widget(*args) self._image = BlankFrame(self.width, self.height) self.page.pushButton_video.clicked.connect(self.pickVideo) self.trackWidgets( { "videoPath": self.page.lineEdit_video, "loopVideo": self.page.checkBox_loop, "useAudio": self.page.checkBox_useAudio, "distort": self.page.checkBox_distort, "scale": self.page.spinBox_scale, "volume": self.page.spinBox_volume, "xPosition": self.page.spinBox_x, "yPosition": self.page.spinBox_y, }, presetNames={ "videoPath": "video", "loopVideo": "loop", "xPosition": "x", "yPosition": "y", }, relativeWidgets=[ "xPosition", "yPosition", ], ) def update(self): if self.page.checkBox_useAudio.isChecked(): self.page.label_volume.setEnabled(True) self.page.spinBox_volume.setEnabled(True) else: self.page.label_volume.setEnabled(False) self.page.spinBox_volume.setEnabled(False) def previewRender(self): self.updateChunksize() frame = self.getPreviewFrame(self.width, self.height) if not frame: return BlankFrame(self.width, self.height) else: return frame def properties(self): props = [] outputFile = None if hasattr(self.parent, "lineEdit_outputFile"): # check only happens in GUI mode outputFile = self.parent.lineEdit_outputFile.text() if not self.videoPath: self.lockError("There is no video selected.") elif not os.path.exists(self.videoPath): self.lockError("The video selected does not exist!") elif outputFile and os.path.realpath(self.videoPath) == os.path.realpath( outputFile ): self.lockError("Input and output paths match.") if self.useAudio: props.append("audio") if not testAudioStream(self.videoPath) and self.error() is None: self.lockError("Could not identify an audio stream in this video.") return props def audio(self): params = {} if self.volume != 1.0: params["volume"] = "=%s:replaygain_noclip=0" % str(self.volume) return (self.videoPath, params) def preFrameRender(self, **kwargs): super().preFrameRender(**kwargs) self.updateChunksize() self.video = ( FfmpegVideo( inputPath=self.videoPath, filter_=self.makeFfmpegFilter(), width=self.width, height=self.height, chunkSize=self.chunkSize, frameRate=int(self.settings.value("outputFrameRate")), parent=self.parent, loopVideo=self.loopVideo, component=self, ) if os.path.exists(self.videoPath) else None ) def frameRender(self, frameNo): if FfmpegVideo.threadError is not None: raise FfmpegVideo.threadError return self.finalizeFrame(self.video.frame(frameNo)) def postFrameRender(self): closePipe(self.video.pipe) def pickVideo(self): imgDir = self.settings.value("componentDir", os.path.expanduser("~")) filename, _ = QtWidgets.QFileDialog.getOpenFileName( self.page, "Choose Video", imgDir, "Video Files (%s)" % " ".join(self.core.videoFormats), ) if filename: self.settings.setValue("componentDir", os.path.dirname(filename)) self.mergeUndo = False self.page.lineEdit_video.setText(filename) self.mergeUndo = True def getPreviewFrame(self, width, height): if not self.videoPath or not os.path.exists(self.videoPath): return command = [ self.core.FFMPEG_BIN, "-thread_queue_size", "512", "-i", self.videoPath, "-f", "image2pipe", "-pix_fmt", "rgba", ] command.extend(self.makeFfmpegFilter()) command.extend( [ "-codec:v", "rawvideo", "-", "-ss", "90", "-frames:v", "1", ] ) if self.core.logEnabled: logFilename = os.path.join( self.core.logDir, "preview_%s.log" % str(self.compPos) ) log.debug("Creating ffmpeg process (log at %s)" % logFilename) with open(logFilename, "w") as logf: logf.write(" ".join(command) + "\n\n") with open(logFilename, "a") as logf: pipe = openPipe( command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=logf, bufsize=10**8, ) else: pipe = openPipe( command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, bufsize=10**8, ) byteFrame = pipe.stdout.read(self.chunkSize) closePipe(pipe) frame = self.finalizeFrame(byteFrame) return frame def makeFfmpegFilter(self): return [ "-filter_complex", "[0:v] scale=%s:%s" % scale(self.scale, self.width, self.height, str), ] def updateChunksize(self): if self.scale != 100 and not self.distort: width, height = scale(self.scale, self.width, self.height, int) else: width, height = self.width, self.height self.chunkSize = 4 * width * height def command(self, arg): if "=" in arg: key, arg = arg.split("=", 1) if key == "path" and os.path.exists(arg): if "*%s" % os.path.splitext(arg)[1] in self.core.videoFormats: self.page.lineEdit_video.setText(arg) self.page.spinBox_scale.setValue(100) self.page.checkBox_loop.setChecked(True) return else: print("Not a supported video format") quit(1) elif arg == "audio": if not self.page.lineEdit_video.text(): print("'audio' option must follow a video selection") quit(1) self.page.checkBox_useAudio.setChecked(True) return super().command(arg) def commandHelp(self): print("Load a video:\n path=/filepath/to/video.mp4") print("Using audio:\n path=/filepath/to/video.mp4 audio") def finalizeFrame(self, imageData): try: if self.distort: image = Image.frombytes("RGBA", (self.width, self.height), imageData) else: image = Image.frombytes( "RGBA", scale(self.scale, self.width, self.height, int), imageData, ) self._image = image except ValueError: # use last good frame image = self._image if self.scale != 100 or self.xPosition != 0 or self.yPosition != 0: frame = BlankFrame(self.width, self.height) frame.paste(image, box=(self.xPosition, self.yPosition)) else: frame = image return frame djfun-audio-visualizer-python-f03a3a6/src/avp/components/video.ui000066400000000000000000000206041514343513600252540ustar00rootroot00000000000000 Form 0 0 586 197 0 0 0 197 Form 0 0 31 0 Video 1 0 0 0 1 0 32 32 ... 32 32 Qt::Orientation::Horizontal QSizePolicy::Policy::Fixed 5 20 0 0 X 0 0 80 16777215 -10000 10000 0 0 Y 0 0 80 16777215 0 0 -10000 10000 0 Loop Qt::Orientation::Horizontal 40 20 Distort by scale Scale Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter QAbstractSpinBox::ButtonSymbols::UpDownArrows % 10 400 100 Use Audio Volume 0 0 x 0.000000000000000 10.000000000000000 0.100000000000000 1.000000000000000 Qt::Orientation::Horizontal 40 20 Qt::Orientation::Vertical 20 40 djfun-audio-visualizer-python-f03a3a6/src/avp/components/waveform.py000066400000000000000000000233311514343513600260070ustar00rootroot00000000000000from PIL import Image, ImageChops from PyQt6.QtGui import QColor import os import subprocess import logging from ..libcomponent import BaseComponent from ..toolkit.visualizer import createSpectrumArray from ..toolkit.frame import BlankFrame, scale from ..toolkit.ffmpeg import ( openPipe, closePipe, getAudioDuration, FfmpegVideo, exampleSound, ) log = logging.getLogger("AVP.Components.Waveform") class Component(BaseComponent): name = "Waveform" version = "2.0.0" @property def updateInterval(self): """How many frames from FFmpeg are ignored between each final frame""" return 100 - self.speed def properties(self): return [] if self.speed == 100 else ["pcm"] def widget(self, *args): super().widget(*args) self._image = BlankFrame(self.width, self.height) if hasattr(self.parent, "lineEdit_audioFile"): self.parent.lineEdit_audioFile.textChanged.connect(self.update) self.trackWidgets( { "color": self.page.lineEdit_color, "mode": self.page.comboBox_mode, "amplitude": self.page.comboBox_amplitude, "x": self.page.spinBox_x, "y": self.page.spinBox_y, "mirror": self.page.checkBox_mirror, "scale": self.page.spinBox_scale, "opacity": self.page.spinBox_opacity, "compress": self.page.checkBox_compress, "mono": self.page.checkBox_mono, "speed": self.page.spinBox_speed, }, colorWidgets={ "color": self.page.pushButton_color, }, relativeWidgets=[ "x", "y", ], ) def previewRender(self): self.updateChunksize() frame = self.getPreviewFrame(self.width, self.height) if not frame: return BlankFrame(self.width, self.height) else: return frame def preFrameRender(self, **kwargs): self._fadingImage = None self._prevImage = None self._currImage = None self._lastUpdatedFrame = 0 super().preFrameRender(**kwargs) self.updateChunksize() w, h = scale(self.scale, self.width, self.height, str) self.video = FfmpegVideo( inputPath=self.audioFile, filter_=self.makeFfmpegFilter(), width=w, height=h, chunkSize=self.chunkSize, frameRate=int(self.settings.value("outputFrameRate")), parent=self.parent, component=self, debug=True, ) if self.speed == 100: return self.spectrumArray = createSpectrumArray( self, self.completeAudioArray, self.sampleSize, 0.08, 0.8, 20, self.progressBarUpdate, self.progressBarSetText, ) def frameRender(self, frameNo): if FfmpegVideo.threadError is not None: raise FfmpegVideo.threadError newFrame = self.finalizeFrame(self.video.frame(frameNo)) if self.speed == 100: return newFrame frameDiff = 0 if frameNo == 0 else frameNo % self.updateInterval peaks = [ self.spectrumArray[frameNo * self.sampleSize][i * 4] for i in range(64) ] peakValue = 70 - (max(*peaks) - min(*peaks)) isValidPeak = ( peakValue > 27 and frameNo - self._lastUpdatedFrame > self.updateInterval / 2 ) if frameDiff == 0 or isValidPeak: self._lastUpdatedFrame = frameNo self._fadingImage = self._prevImage self._prevImage = self._image self._currImage = newFrame usualAlpha = 0.0 + (1 / self.updateInterval) * frameDiff alpha = max( 0.1 + ( 1 / max( 10, peakValue, ) ), usualAlpha, ) baseImage = self._prevImage if self._fadingImage is not None: # fade away the old previous frame from ages ago baseImage = ImageChops.blend( self._prevImage, self._fadingImage, max(0.0, 0.9 - usualAlpha) ) blendedImage = ImageChops.blend( baseImage, ImageChops.lighter(self._prevImage, newFrame), alpha, ) baseImage.paste(blendedImage, (0, 0), mask=blendedImage) return Image.alpha_composite(self._currImage, baseImage) def postFrameRender(self): closePipe(self.video.pipe) def getPreviewFrame(self, width, height): genericPreview = self.settings.value("pref_genericPreview") startPt = 0 if not genericPreview: inputFile = self.parent.lineEdit_audioFile.text() if not inputFile or not os.path.exists(inputFile): return duration = getAudioDuration(inputFile) if not duration: return startPt = duration / 3 if startPt + 3 > duration: startPt += startPt - 3 command = [ self.core.FFMPEG_BIN, "-thread_queue_size", "512", "-r", str(self.settings.value("outputFrameRate")), "-ss", "{0:.3f}".format(startPt), "-i", self.core.junkStream if genericPreview else inputFile, "-f", "image2pipe", "-pix_fmt", "rgba", ] command.extend(self.makeFfmpegFilter(preview=True, startPt=startPt)) command.extend( [ "-an", "-s:v", "%sx%s" % scale(self.scale, self.width, self.height, str), "-codec:v", "rawvideo", "-", "-frames:v", "1", ] ) if self.core.logEnabled: logFilename = os.path.join( self.core.logDir, "preview_%s.log" % str(self.compPos) ) log.debug("Creating ffmpeg log at %s", logFilename) with open(logFilename, "w") as logf: logf.write(" ".join(command) + "\n\n") with open(logFilename, "a") as logf: pipe = openPipe( command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=logf, bufsize=10**8, ) else: pipe = openPipe( command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, bufsize=10**8, ) byteFrame = pipe.stdout.read(self.chunkSize) closePipe(pipe) frame = self.finalizeFrame(byteFrame) return frame def makeFfmpegFilter(self, preview=False, startPt=0): w, h = scale(self.scale, self.width, self.height, str) if self.amplitude == 0: amplitude = "log" elif self.amplitude == 1: amplitude = "sqrt" elif self.amplitude == 2: amplitude = "cbrt" elif self.amplitude == 3: amplitude = "lin" hexcolor = QColor(*self.color).name() opacity = "{0:.1f}".format(self.opacity / 100) genericPreview = self.settings.value("pref_genericPreview") if self.mode > 1: filter_ = ( "showwaves=" f'r={str(self.settings.value("outputFrameRate"))}:' f's={self.settings.value("outputWidth")}x{self.settings.value("outputHeight")}:' f'mode={self.page.comboBox_mode.currentText().lower() if self.mode != 3 else "p2p"}:' f"colors={hexcolor}@{opacity}:scale={amplitude}" ) elif self.mode < 2: filter_ = ( f'showfreqs=s={str(self.settings.value("outputWidth"))}x{str(self.settings.value("outputHeight"))}:' f'mode={"line" if self.mode == 0 else "bar"}:' f"colors={hexcolor}@{opacity}" f":ascale={amplitude}:fscale={'log' if self.mono else 'lin'}" ) baselineHeight = int(self.height * (4 / 1080)) return [ "-filter_complex", f"{exampleSound('wave', extra='') if preview and genericPreview else '[0:a] '}" f"{'compand=gain=4,' if self.compress else ''}" f"{'aformat=channel_layouts=mono,' if self.mono and self.mode < 3 else ''}" f"{filter_}" f"{', drawbox=x=(iw-w)/2:y=(ih-h)/2:w=iw:h=%s:color=%s@%s' % (baselineHeight, hexcolor, opacity) if self.mode < 2 else ''}" f"{', hflip' if self.mirror else''}" " [v1]; " "[v1] scale=%s:%s%s [v]" % ( w, h, ", trim=duration=%s" % "{0:.3f}".format(startPt + 3) if preview else "", ), "-map", "[v]", ] def updateChunksize(self): width, height = scale(self.scale, self.width, self.height, int) self.chunkSize = 4 * width * height def finalizeFrame(self, imageData): try: image = Image.frombytes( "RGBA", scale(self.scale, self.width, self.height, int), imageData, ) self._image = image except ValueError: image = self._image if self.scale != 100 or self.x != 0 or self.y != 0: frame = BlankFrame(self.width, self.height) frame.paste(image, box=(self.x, self.y)) else: frame = image return frame djfun-audio-visualizer-python-f03a3a6/src/avp/components/waveform.ui000066400000000000000000000253221514343513600257760ustar00rootroot00000000000000 Form 0 0 586 197 0 0 0 197 Form 0 0 31 0 Mode Frequency Line Frequency Bar Cline Line Qt::Orientation::Horizontal QSizePolicy::Policy::Fixed 5 20 0 0 X 0 0 80 16777215 -10000 10000 0 0 Y 0 0 80 16777215 0 0 -10000 10000 0 Color Qt::InputMethodHint::ImhNone 0 0 32 32 false false Qt::Orientation::Horizontal 40 20 Opacity Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter QAbstractSpinBox::ButtonSymbols::UpDownArrows % 0 100 100 Scale Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter QAbstractSpinBox::ButtonSymbols::UpDownArrows % 10 400 100 Compress true Mono Mirror Qt::Orientation::Horizontal 40 20 Amplitude Logarithmic Square root Cubic root Linear Animation Speed % 10 100 10 50 Qt::Orientation::Horizontal 40 20 Qt::Orientation::Vertical 20 40 djfun-audio-visualizer-python-f03a3a6/src/avp/core.py000066400000000000000000000537271514343513600227400ustar00rootroot00000000000000""" Home to the Core class which tracks program state. Used by GUI & commandline to create a list of components and create a video thread to export. """ from PyQt6 import QtCore, QtGui, uic import sys import os import json from importlib import import_module import logging from . import toolkit appName = "Audio Visualizer Python" log = logging.getLogger("AVP.Core") class Core: """ MainWindow and Command module both use an instance of this class to store the core program state. This object tracks the components, talks to the components, handles opening/creating project files and presets, and creates the video thread to export. This class also stores constants as class variables. """ stdoutLogLvl = logging.WARNING def __init__(self): self.importComponents() self.selectedComponents = [] self.savedPresets = {} # copies of presets to detect modification self.openingProject = False def __repr__(self): return "\n=~=~=~=\n".join([repr(comp) for comp in self.selectedComponents]) def importComponents(self): def findComponents(): for f in os.listdir(Core.componentsPath): name, ext = os.path.splitext(f) if name.startswith("__"): continue elif ext == ".py": yield name log.debug("Importing component modules") self.modules = [ import_module(".components.%s" % name, __package__) for name in findComponents() ] # store canonical module names and indexes self.moduleIndexes = [i for i in range(len(self.modules))] self.compNames = [mod.Component.name for mod in self.modules] # alphabetize modules by Component name sortedModules = sorted(zip(self.compNames, self.modules)) self.compNames = [y[0] for y in sortedModules] self.modules = [y[1] for y in sortedModules] # store alternative names for modules self.altCompNames = [] for i, mod in enumerate(self.modules): if hasattr(mod.Component, "names"): for name in mod.Component.names(): self.altCompNames.append((name, i)) def componentListChanged(self): for i, component in enumerate(self.selectedComponents): component.compPos = i def insertComponent(self, compPos, component, loader): """ Creates a new component using these args: (compPos, component obj or moduleIndex, MWindow/Command obj) """ if compPos < 0 or compPos > len(self.selectedComponents): compPos = len(self.selectedComponents) if len(self.selectedComponents) > 50: return -1 if component is None: log.warning("Tried to insert non-existent component") return -1 elif type(component) is int: # create component using module index in self.modules moduleIndex = int(component) log.debug("Creating new component from module #%s", str(moduleIndex)) component = self.modules[moduleIndex].Component(moduleIndex, compPos, self) component.widget(loader) else: moduleIndex = -1 log.debug("Inserting previously-created %s component", component.name) component._error.connect(loader.videoThreadError) self.selectedComponents.insert(compPos, component) if hasattr(loader, "insertComponent"): loader.insertComponent(compPos) self.componentListChanged() self.updateComponent(compPos) return compPos def moveComponent(self, startI, endI): comp = self.selectedComponents.pop(startI) self.selectedComponents.insert(endI, comp) self.componentListChanged() return endI def removeComponent(self, i): self.selectedComponents.pop(i) self.componentListChanged() def clearComponents(self): self.selectedComponents = list() self.componentListChanged() def updateComponent(self, i): log.debug("Auto-updating %s #%s", self.selectedComponents[i], str(i)) self.selectedComponents[i].update(auto=True) def moduleIndexFor(self, compName): try: index = self.compNames.index(compName) return self.moduleIndexes[index] except ValueError: for altName, modI in self.altCompNames: if altName == compName: return self.moduleIndexes[modI] def clearPreset(self, compIndex): self.selectedComponents[compIndex].currentPreset = None def openPreset(self, filepath, compIndex, presetName): """Applies a preset to a specific component""" saveValueStore = self.getPreset(filepath) if not saveValueStore: return False comp = self.selectedComponents[compIndex] comp.loadPreset(saveValueStore, presetName) self.savedPresets[presetName] = dict(saveValueStore) return True def getPreset(self, filepath): """Returns the preset dict stored at this filepath""" if not os.path.exists(filepath): return False with open(filepath, "r") as f: for line in f: saveValueStore = toolkit.presetFromString(line.strip()) break return saveValueStore def getPresetDir(self, comp): """Get the preset subdir for a particular version of a component""" return os.path.join(Core.presetDir, comp.name, str(comp.version)) def openProject(self, loader, filepath): """loader is the object calling this method which must have its own showMessage(**kwargs) method for displaying errors. """ if not os.path.exists(filepath): loader.showMessage(msg="Project file not found.") return errcode, data = self.parseAvFile(filepath) if errcode == 0: self.openingProject = True try: if hasattr(loader, "window"): for widget, value in data["WindowFields"]: widget = eval("loader.%s" % widget) with toolkit.blockSignals(widget): toolkit.setWidgetValue(widget, value) for key, value in data["Settings"]: Core.settings.setValue(key, value) for tup in data["Components"]: name, vers, preset = tup clearThis = False modified = False # add loaded named presets to savedPresets dict if "preset" in preset and preset["preset"] is not None: nam = preset["preset"] filepath2 = os.path.join(Core.presetDir, name, str(vers), nam) origSaveValueStore = self.getPreset(filepath2) if origSaveValueStore: self.savedPresets[nam] = dict(origSaveValueStore) modified = not origSaveValueStore == preset else: # saved preset was renamed or deleted clearThis = True # create the actual component object & get its index i = self.insertComponent(-1, self.moduleIndexFor(name), loader) if i is None: loader.showMessage( msg=f"Component '{name}' didn't initialize correctly and had to be removed." ) continue if i == -1: loader.showMessage(msg="Invalid components!") break try: if "preset" in preset and preset["preset"] is not None: self.selectedComponents[i].loadPreset(preset) else: self.selectedComponents[i].loadPreset( preset, preset["preset"] ) except KeyError as e: log.warning( "%s missing value: %s" % (self.selectedComponents[i], e) ) if clearThis: self.clearPreset(i) if hasattr(loader, "updateComponentTitle"): loader.updateComponentTitle(i, modified) self.openingProject = False return True except Exception: errcode = 1 data = sys.exc_info() if errcode == 1: typ, value, tb = data if typ.__name__ == "KeyError": # probably just an old version, still loadable log.warning("Project file missing value: %s" % value) return if hasattr(loader, "createNewProject"): loader.createNewProject(prompt=False) msg = "%s: %s\n\n" % (typ.__name__, value) msg += toolkit.formatTraceback(tb) loader.showMessage( msg="Project file '%s' is corrupted." % filepath, showCancel=False, icon="Warning", detail=msg, ) self.openingProject = False return False def parseAvFile(self, filepath): """ Parses an avp (project) or avl (preset package) file. Returns dictionary with section names as the keys, each one contains a list of tuples: (compName, version, compPresetDict) """ log.debug("Parsing av file: %s", filepath) validSections = ("Components", "Settings", "WindowFields") data = {sect: [] for sect in validSections} try: with open(filepath, "r") as f: def parseLine(line): """Decides if a file line is a section header""" line = line.strip() newSection = "" if ( line.startswith("[") and line.endswith("]") and line[1:-1] in validSections ): newSection = line[1:-1] return line, newSection section = "" i = 0 for line in f: line, newSection = parseLine(line) if newSection: section = str(newSection) continue if line and section == "Components": if i == 0: lastCompName = str(line) i += 1 elif i == 1: lastCompVers = str(line) i += 1 elif i == 2: lastCompPreset = toolkit.presetFromString(line) data[section].append( (lastCompName, lastCompVers, lastCompPreset) ) i = 0 elif line and section: key, value = line.split("=", 1) data[section].append((key, value.strip())) return 0, data except Exception: return 1, sys.exc_info() def importPreset(self, filepath): errcode, data = self.parseAvFile(filepath) returnList = [] if errcode == 0: name, vers, preset = data["Components"][0] presetName = ( preset["preset"] if preset["preset"] else os.path.basename(filepath)[:-4] ) newPath = os.path.join(Core.presetDir, name, vers, presetName) if os.path.exists(newPath): return False, newPath preset["preset"] = presetName self.createPresetFile(name, vers, presetName, preset) return True, presetName elif errcode == 1: # TODO: an error message return False, "" def exportPreset(self, exportPath, compName, vers, origName): internalPath = os.path.join(Core.presetDir, compName, str(vers), origName) if not os.path.exists(internalPath): return if os.path.exists(exportPath): os.remove(exportPath) with open(internalPath, "r") as f: internalData = [line for line in f] try: saveValueStore = toolkit.presetFromString(internalData[0].strip()) self.createPresetFile(compName, vers, origName, saveValueStore, exportPath) return True except Exception: return False def createPresetFile(self, compName, vers, presetName, saveValueStore, filepath=""): """Create a preset file (.avl) at filepath using args. Or if filepath is empty, create an internal preset using args""" if not filepath: dirname = os.path.join(Core.presetDir, compName, str(vers)) if not os.path.exists(dirname): os.makedirs(dirname) filepath = os.path.join(dirname, presetName) internal = True else: if not filepath.endswith(".avl"): filepath += ".avl" internal = False with open(filepath, "w") as f: if not internal: f.write("[Components]\n") f.write("%s\n" % compName) f.write("%s\n" % str(vers)) f.write(toolkit.presetToString(saveValueStore)) def createProjectFile(self, filepath, window=None): """Create a project file (.avp) using the current program state""" log.info("Creating %s", filepath) settingsKeys = [ "componentDir", "inputDir", "outputDir", "presetDir", "projectDir", ] try: if not filepath.endswith(".avp"): filepath += ".avp" if os.path.exists(filepath): os.remove(filepath) with open(filepath, "w") as f: f.write("[Components]\n") for comp in self.selectedComponents: saveValueStore = comp.savePreset() saveValueStore["preset"] = comp.currentPreset f.write("%s\n" % str(comp)) f.write("%s\n" % str(comp.version)) f.write("%s\n" % toolkit.presetToString(saveValueStore)) f.write("\n[Settings]\n") for key in Core.settings.allKeys(): if key in settingsKeys: f.write("%s=%s\n" % (key, Core.settings.value(key))) if window: f.write("\n[WindowFields]\n") f.write( "lineEdit_audioFile=%s\n" "lineEdit_outputFile=%s\n" % ( window.lineEdit_audioFile.text(), window.lineEdit_outputFile.text(), ) ) return True except Exception: return False def newVideoWorker(self, loader, audioFile, outputPath): """loader is MainWindow or Command object which must own the thread""" from . import video_thread self.videoThread = QtCore.QThread(loader) videoWorker = video_thread.Worker( loader, audioFile, outputPath, self.selectedComponents ) videoWorker.moveToThread(self.videoThread) videoWorker.videoCreated.connect(self.stopVideoThread) self.videoThread.start() return videoWorker def stopVideoThread(self): self.videoThread.quit() self.videoThread.wait() def cancel(self): Core.canceled = True def reset(self): Core.canceled = False @classmethod def storeSettings(cls, dataDir=None): """Store settings/paths to directories as class variables""" from .__init__ import wd from .toolkit.ffmpeg import findFfmpeg cls.wd = wd dataDir = cls.getConfigPath(dataDir) # Windows: C:/Users//AppData/Local/audio-visualizer # macOS: ~/Library/Preferences/audio-visualizer # Linux: ~/.config/audio-visualizer with open(os.path.join(wd, "encoder-options.json")) as json_file: encoderOptions = json.load(json_file) # Locate FFmpeg ffmpegBin = findFfmpeg() if not ffmpegBin: print("Could not find FFmpeg") settings = { "canceled": False, "FFMPEG_BIN": ffmpegBin, "dataDir": dataDir, "settings": QtCore.QSettings( os.path.join(dataDir, "settings.ini"), QtCore.QSettings.Format.IniFormat, ), "presetDir": os.path.join(dataDir, "presets"), "componentsPath": os.path.join(wd, "components"), "junkStream": os.path.join(wd, "gui", "background.png"), "encoderOptions": encoderOptions, "resolutions": [ "1920x1080", "1280x720", "854x480", ], "logDir": os.path.join(dataDir, "log"), "logEnabled": False, "previewEnabled": True, } settings["videoFormats"] = toolkit.appendUppercase( [ "*.mp4", "*.mov", "*.mkv", "*.avi", "*.webm", "*.flv", ] ) settings["audioFormats"] = toolkit.appendUppercase( [ "*.mp3", "*.wav", "*.ogg", "*.fla", "*.flac", "*.aac", ] ) settings["imageFormats"] = toolkit.appendUppercase( [ "*.png", "*.jpg", "*.tif", "*.tiff", "*.gif", "*.bmp", "*.ico", "*.xbm", "*.xpm", ] ) # Register all settings as class variables for classvar, val in settings.items(): setattr(cls, classvar, val) cls.loadDefaultSettings() if not os.path.exists(cls.dataDir): os.makedirs(cls.dataDir) for neededDirectory in ( cls.presetDir, cls.logDir, cls.settings.value("projectDir"), ): if not os.path.exists(neededDirectory): os.mkdir(neededDirectory) cls.makeLogger(deleteOldLogs=True) @classmethod def loadDefaultSettings(cls): # settings that get saved into the ini file cls.defaultSettings = { "outputWidth": 1280, "outputHeight": 720, "outputFrameRate": 30, "outputAudioCodec": "AAC", "outputAudioBitrate": "192", "outputVideoCodec": "H264", "outputVideoBitrate": "2500", "outputVideoFormat": "yuv420p", "outputPreset": "medium", "outputFormat": "mp4", "outputContainer": "MP4", "projectDir": os.path.join(cls.dataDir, "projects"), "pref_insertCompAtTop": True, "pref_genericPreview": True, "pref_undoLimit": 10, } for parm, value in cls.defaultSettings.items(): if cls.settings.value(parm) is None: cls.settings.setValue(parm, value) # Allow manual editing of prefs. (Surprisingly necessary as Qt seems to # store True as 'true' but interprets a manually-added 'true' as str.) for key in cls.settings.allKeys(): if not key.startswith("pref_"): continue val = cls.settings.value(key) try: val = int(val) except ValueError: if val == "true": val = True elif val == "false": val = False cls.settings.setValue(key, val) @staticmethod def makeLogger(deleteOldLogs=False, fileLogLvl=None): # send critical log messages to stdout logStream = logging.StreamHandler() logStream.setLevel(Core.stdoutLogLvl) streamFormatter = logging.Formatter("<%(name)s> %(levelname)s: %(message)s") logStream.setFormatter(streamFormatter) log = logging.getLogger("AVP") log.addHandler(logStream) if fileLogLvl is not None: # write log files as well! Core.logEnabled = True logFilename = os.path.join(Core.logDir, "avp_debug.log") libLogFilename = os.path.join(Core.logDir, "global_debug.log") if deleteOldLogs: for log_ in (logFilename, libLogFilename): if os.path.exists(log_): os.remove(log_) logFile = logging.FileHandler(logFilename, delay=True) logFile.setLevel(fileLogLvl) libLogFile = logging.FileHandler(libLogFilename, delay=True) libLogFile.setLevel(fileLogLvl) fileFormatter = logging.Formatter( "[%(asctime)s] %(threadName)-10.10s %(name)-23.23s %(levelname)s: " "%(message)s" ) logFile.setFormatter(fileFormatter) libLogFile.setFormatter(fileFormatter) libLog = logging.getLogger() log.addHandler(logFile) libLog.addHandler(libLogFile) # lowest level must be explicitly set on the root Logger libLog.setLevel(0) @staticmethod def getConfigPath(dataDir=None): return ( os.path.join( QtCore.QStandardPaths.writableLocation( QtCore.QStandardPaths.StandardLocation.AppConfigLocation ), "audio-visualizer", ) if dataDir is None else dataDir ) djfun-audio-visualizer-python-f03a3a6/src/avp/encoder-options.json000066400000000000000000000062201514343513600254230ustar00rootroot00000000000000{ "containers":[ { "name": "MP4", "container": "mp4", "default-vcodec": "H264", "default-acodec": "AAC", "video-codecs": [ "H264", "H264 (nvenc)", "MPEG4" ], "audio-codecs": [ "AAC", "AC3", "MP3" ] }, { "name": "MOV", "container": "mov", "default-vcodec": "H264", "default-acodec": "AAC", "video-codecs": [ "H264", "H264 (nvenc)", "MPEG4", "XVID" ], "audio-codecs": [ "AAC", "AC3", "MP3", "PCM s16 LE" ] }, { "name": "MKV", "container": "matroska", "default-vcodec": "H264", "default-acodec": "AAC", "video-codecs": [ "H264", "H264 (nvenc)", "MPEG4", "MPEG2", "DV", "WMV" ], "audio-codecs": [ "AAC", "AC3", "MP3", "PCM s16 LE", "WMA" ] }, { "name": "AVI", "container": "avi", "default-vcodec": "H264", "default-acodec": "AAC", "video-codecs": [ "H264", "H264 (nvenc)", "MPEG4", "MPEG2", "DV", "WMV" ], "audio-codecs": [ "AAC", "AC3", "MP3", "PCM s16 LE", "WMA" ] }, { "name": "WEBM", "container": "webm", "default-vcodec": "VP9", "default-acodec": "Vorbis", "video-codecs": [ "VP9", "VP8" ], "audio-codecs": [ "Vorbis" ] }, { "name": "FLV", "container": "flv", "default-vcodec": "FLV", "default-acodec": "Vorbis", "video-codecs": [ "Sorenson (flv)", "H264", "H264 (nvenc)", "MPEG4" ], "audio-codecs": [ "MP3", "PCM s16 LE", "Vorbis" ] } ], "video-codecs":{ "H264": ["libx264"], "H264 (nvenc)": ["h264_nvenc", "nvenc_h264"], "MPEG4": ["mpeg4"], "VP9": ["libvpx-vp9"], "VP8": ["libvpx"], "XVID": ["libxvid"], "Sorenson (flv)": ["flv"], "MPEG2": ["mp2video"], "DV": ["dvvideo"], "WMV": ["wmv2"] }, "audio-codecs": { "AAC": ["libfdk_aac", "aac"], "AC3": ["ac3"], "MP3": ["libmp3lame"], "PCM s16 LE": ["pcm_s16le"], "WMA": ["wmav2"], "Vorbis": ["libvorbis"] } }djfun-audio-visualizer-python-f03a3a6/src/avp/gui/000077500000000000000000000000001514343513600222045ustar00rootroot00000000000000djfun-audio-visualizer-python-f03a3a6/src/avp/gui/__init__.py000066400000000000000000000000001514343513600243030ustar00rootroot00000000000000djfun-audio-visualizer-python-f03a3a6/src/avp/gui/actions.py000066400000000000000000000145651514343513600242310ustar00rootroot00000000000000""" QUndoCommand classes for every undoable user action performed in the MainWindow """ from PyQt6.QtGui import QUndoCommand import os import logging from copy import copy from ..core import Core log = logging.getLogger("AVP.Gui.Actions") # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ # COMPONENT ACTIONS # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ class AddComponent(QUndoCommand): def __init__(self, parent, compI, moduleI): super().__init__( "create new %s component" % parent.core.modules[moduleI].Component.name ) self.parent = parent self.moduleI = moduleI self.compI = compI self.comp = None self.valid = True def redo(self): if self.comp is None: i = self.parent.core.insertComponent(self.compI, self.moduleI, self.parent) if i != self.compI: self.valid = False if i is not None: log.error( f"Expected new component index to be {self.compI} but received {i}" ) else: # inserting previously-created component self.parent.core.insertComponent(self.compI, self.comp, self.parent) def undo(self): if not self.valid: return self.comp = self.parent.core.selectedComponents[self.compI] self.parent._removeComponent(self.compI) class RemoveComponent(QUndoCommand): def __init__(self, parent, selectedRows): super().__init__("remove component") self.parent = parent componentList = self.parent.listWidget_componentList self.selectedRows = [componentList.row(selected) for selected in selectedRows] self.components = [parent.core.selectedComponents[i] for i in self.selectedRows] def redo(self): self.parent._removeComponent(self.selectedRows[0]) def undo(self): componentList = self.parent.listWidget_componentList for index, comp in zip(self.selectedRows, self.components): self.parent.core.insertComponent(index, comp, self.parent) self.parent.drawPreview() class MoveComponent(QUndoCommand): def __init__(self, parent, row, newRow, tag): super().__init__("move component %s" % tag) self.parent = parent self.row = row self.newRow = newRow self.id_ = ord(tag[0]) def id(self): """If 2 consecutive updates have same id, Qt will call mergeWith()""" return self.id_ def mergeWith(self, other): self.newRow = other.newRow return True def do(self, rowa, rowb): componentList = self.parent.listWidget_componentList page = self.parent.pages.pop(rowa) self.parent.pages.insert(rowb, page) item = componentList.takeItem(rowa) componentList.insertItem(rowb, item) stackedWidget = self.parent.stackedWidget widget = stackedWidget.removeWidget(page) stackedWidget.insertWidget(rowb, page) componentList.setCurrentRow(rowb) stackedWidget.setCurrentIndex(rowb) self.parent.core.moveComponent(rowa, rowb) self.parent.drawPreview(True) def redo(self): self.do(self.row, self.newRow) def undo(self): self.do(self.newRow, self.row) # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ # PRESET ACTIONS # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ class ClearPreset(QUndoCommand): def __init__(self, parent, compI): super().__init__("clear preset") self.parent = parent self.compI = compI self.component = self.parent.core.selectedComponents[compI] self.store = self.component.savePreset() self.store["preset"] = self.component.currentPreset def redo(self): self.parent.core.clearPreset(self.compI) self.parent.updateComponentTitle(self.compI, False) def undo(self): self.parent.core.selectedComponents[self.compI].loadPreset(self.store) self.parent.updateComponentTitle(self.compI, self.store) class OpenPreset(QUndoCommand): def __init__(self, parent, presetName, compI): super().__init__("open %s preset" % presetName) self.parent = parent self.presetName = presetName self.compI = compI comp = self.parent.core.selectedComponents[compI] self.store = comp.savePreset() self.store["preset"] = copy(comp.currentPreset) def redo(self): self.parent._openPreset(self.presetName, self.compI) def undo(self): self.parent.core.selectedComponents[self.compI].loadPreset(self.store) self.parent.parent.updateComponentTitle(self.compI, self.store) class RenamePreset(QUndoCommand): def __init__(self, parent, path, oldName, newName): super().__init__("rename preset") self.parent = parent self.path = path self.oldName = oldName self.newName = newName def redo(self): self.parent.renamePreset(self.path, self.oldName, self.newName) def undo(self): self.parent.renamePreset(self.path, self.newName, self.oldName) class DeletePreset(QUndoCommand): def __init__(self, parent, compName, vers, presetFile): self.parent = parent self.preset = (compName, vers, presetFile) self.path = os.path.join(Core.presetDir, compName, str(vers), presetFile) self.store = self.parent.core.getPreset(self.path) self.presetName = self.store["preset"] super().__init__("delete %s preset (%s)" % (self.presetName, compName)) self.loadedPresets = [ i for i, comp in enumerate(self.parent.core.selectedComponents) if self.presetName == str(comp.currentPreset) ] def redo(self): os.remove(self.path) for i in self.loadedPresets: self.parent.core.clearPreset(i) self.parent.parent.updateComponentTitle(i, False) self.parent.findPresets() self.parent.drawPresetList() def undo(self): self.parent.createNewPreset(*self.preset, self.store) selectedComponents = self.parent.core.selectedComponents for i in self.loadedPresets: selectedComponents[i].currentPreset = self.presetName self.parent.parent.updateComponentTitle(i) self.parent.findPresets() self.parent.drawPresetList() djfun-audio-visualizer-python-f03a3a6/src/avp/gui/background.png000066400000000000000000000103571514343513600250370ustar00rootroot00000000000000PNG  IHDR8͸ pHYs.#.#x?vzTXtTitleK/LQ(H,)I-OzTXtAuthors0a[IDATxס 0EQn%}E(T]D'+hcܯ֭[nݺ```@@```@@```@@```@@```@@ pu֭[n}J֭[nݺYd?^+ۺu֭[>djdw֭[nT.'   00   00   00   00   0[{%u֭[nA@@``@@@``@@@``@@@``@@@8/3֭[nݺ7z3*ZnݺugޒxEho֭[nĒ}ܭ[nݺuS   00   00   00   00   00 po5cܯ֭[nݺu?```@@``@@``@@``@@``WXnݺu֧Ϩiݺu֭[xKV[nݺuKFqnݺu֭O5r   00    00    00    00    00 ՈΏWrZnݺu``@@``@@``@@``@@``@@\=cݺu֭[~g>u֭[n}-YWnݺu֭O,ݺu֭[> 000   000   000   000   000  V#:?^jݺu֭[{```@@```@@```@@```@@```@@ pu֭[n}J֭[nݺYd?^+ۺu֭[>djdw֭[nT.'   00   00   00   00   0[{%u֭[nA@@``@@@``@@@``@@@``@@@8/3֭[nݺ7z3*ZnݺugޒxEho֭[nĒ}ܭ[nݺuS   00   00   00   00   00 po5cܯ֭[nݺu?```@@``@@``@@``@@``WXnݺu֧Ϩiݺu֭[xKV[nݺuKFqnݺu֭O5r   00    00    00    00    00 ՈΏWrZnݺu``@@``@@``@@``@@``@@\=cݺu֭[~g>u֭[n}-YWnݺu֭O,ݺu֭[> 000   000   000   000   000  V#:?^jݺu֭[{```@@```@@```@@```@@```@@ pu֭[n}J֭[nݺYd?^+ۺu֭[>djdw֭[nT.'   00   00   00   00   0[{%u֭[nA@@``@@@``@@@``@@@``@@@8/3֭[nݺ7z3*ZnݺugޒxEho֭[nĒ}ܭ[nݺuS   00   00   00   00   00 po5cܯ֭[nݺu?```@@``@@``@@``@@``WXnݺu֧Ϩiݺu֭[xKV[nݺuKFqnݺu֭O5r   00    00    00    00    00 ՈΏWrZnݺu``@@``@@``@@``@@``@@\=cݺu֭[-PյHIENDB`djfun-audio-visualizer-python-f03a3a6/src/avp/gui/mainwindow.py000066400000000000000000001226661514343513600247470ustar00rootroot00000000000000""" When using GUI mode, this module's object (the main window) takes user input to construct a program state (stored in the Core object). This shows a preview of the video being created and allows for saving projects and exporting the video at a later time. """ from PyQt6 import QtCore, QtWidgets, uic import PyQt6.QtWidgets as QtWidgets from PyQt6.QtGui import QShortcut from PIL import Image from queue import Queue import sys import os import signal import filecmp import time import logging from textwrap import wrap from ..__init__ import __version__ from ..core import Core, appName from .undostack import UndoStack from . import preview_thread from .preview_win import PreviewWindow from .presetmanager import PresetManager from .actions import * from ..toolkit.ffmpeg import createFfmpegCommand, checkFfmpegVersion from ..toolkit import ( disableWhenEncoding, disableWhenOpeningProject, checkOutput, blockSignals, ) log = logging.getLogger("AVP.Gui.MainWindow") class MainWindow(QtWidgets.QMainWindow): """ The MainWindow wraps many Core methods in order to update the GUI accordingly. E.g., instead of self.core.openProject(), it will use self.openProject() and update the window titlebar within the wrapper. MainWindow manages the autosave feature, although Core has the primary functions for opening and creating project files. """ createVideo = QtCore.pyqtSignal() newTask = QtCore.pyqtSignal(list) # for the preview window processTask = QtCore.pyqtSignal() def __init__(self, project, dpi): super().__init__() log.debug("Main thread id: {}".format(int(QtCore.QThread.currentThreadId()))) uic.loadUi(os.path.join(Core.wd, "gui", "mainwindow.ui"), self) if dpi: self.resize( int(self.width() * (dpi / 144)), int(self.height() * (dpi / 144)), ) self.core = Core() Core.mode = "GUI" # widgets of component settings self.pages = [] self.lastAutosave = time.time() # list of previous five autosave times, used to reduce update spam self.autosaveTimes = [] self.autosaveCooldown = 0.2 self.encoding = False # Find settings created by Core object self.dataDir = Core.dataDir self.presetDir = Core.presetDir self.autosavePath = os.path.join(self.dataDir, "autosave.avp") self.settings = Core.settings # Create stack of undoable user actions self.undoStack = UndoStack(self) undoLimit = self.settings.value("pref_undoLimit") self.undoStack.setUndoLimit(undoLimit) # Create Undo Dialog - A standard QUndoView on a standard QDialog self.undoDialog = QtWidgets.QDialog(self) self.undoDialog.setWindowTitle("Undo History") undoView = QtWidgets.QUndoView(self.undoStack) layout = QtWidgets.QVBoxLayout() layout.addWidget(undoView) self.undoDialog.setLayout(layout) self.undoDialog.setMinimumWidth(int(self.width() / 2)) # Create Preset Manager self.presetManager = PresetManager(self) # Create the preview window and its thread, queues, and timers log.debug("Creating preview window") self.previewWindow = PreviewWindow( self, os.path.join(Core.wd, "gui", "background.png") ) self.verticalLayout_previewWrapper.addWidget(self.previewWindow) log.debug("Starting preview thread") self.previewQueue = Queue() self.previewThread = QtCore.QThread(self) self.previewWorker = preview_thread.Worker( self.core, self.settings, self.previewQueue ) self.previewWorker.moveToThread(self.previewThread) self.newTask.connect(self.previewWorker.createPreviewImage) self.processTask.connect(self.previewWorker.process) self.previewWorker.error.connect(self.previewWindow.threadError) self.previewWorker.imageCreated.connect(self.showPreviewImage) self.previewThread.start() self.previewThread.finished.connect( lambda: log.info("Preview thread finished.") ) timeout = 500 log.debug("Preview timer set to trigger when idle for %sms" % str(timeout)) self.timer = QtCore.QTimer(self) self.timer.timeout.connect(self.processTask.emit) self.timer.start(timeout) # Begin decorating the window and connecting events componentList = self.listWidget_componentList # Undo Feature def toggleUndoButtonEnabled(*_): """Enable/disable undo button depending on whether UndoStack contains Actions""" try: undoButton.setEnabled(self.undoStack.count()) except RuntimeError: # program is probably in midst of exiting pass style = self.pushButton_undo.style() undoButton = self.pushButton_undo undoButton.setIcon( style.standardIcon(QtWidgets.QStyle.StandardPixmap.SP_FileDialogBack) ) undoButton.clicked.connect(self.undoStack.undo) undoButton.setEnabled(False) self.undoStack.cleanChanged.connect(toggleUndoButtonEnabled) self.undoMenu = QtWidgets.QMenu() self.undoMenu.addAction(self.undoStack.createUndoAction(self)) self.undoMenu.addAction(self.undoStack.createRedoAction(self)) action = self.undoMenu.addAction("Show History...") action.triggered.connect(lambda _: self.showUndoStack()) undoButton.setMenu(self.undoMenu) # end of Undo Feature style = self.pushButton_listMoveUp.style() self.pushButton_listMoveUp.setIcon( style.standardIcon(QtWidgets.QStyle.StandardPixmap.SP_ArrowUp) ) style = self.pushButton_listMoveDown.style() self.pushButton_listMoveDown.setIcon( style.standardIcon(QtWidgets.QStyle.StandardPixmap.SP_ArrowDown) ) style = self.pushButton_removeComponent.style() self.pushButton_removeComponent.setIcon( style.standardIcon(QtWidgets.QStyle.StandardPixmap.SP_DialogDiscardButton) ) if sys.platform == "darwin": log.debug("Darwin detected: showing progress label below progress bar") self.progressBar_createVideo.setTextVisible(False) else: self.progressLabel.setHidden(True) self.toolButton_selectAudioFile.clicked.connect(self.openInputFileDialog) self.toolButton_selectOutputFile.clicked.connect(self.openOutputFileDialog) def changedField(): self.autosave() self.updateWindowTitle() self.lineEdit_audioFile.textChanged.connect(changedField) self.lineEdit_outputFile.textChanged.connect(changedField) self.progressBar_createVideo.setValue(0) self.pushButton_createVideo.clicked.connect(self.createAudioVisualization) self.pushButton_Cancel.clicked.connect(self.stopVideo) for i, container in enumerate(Core.encoderOptions["containers"]): self.comboBox_videoContainer.addItem(container["name"]) if container["name"] == self.settings.value("outputContainer"): selectedContainer = i self.comboBox_videoContainer.setCurrentIndex(selectedContainer) self.comboBox_videoContainer.currentIndexChanged.connect(self.updateCodecs) self.updateCodecs() for i in range(self.comboBox_videoCodec.count()): codec = self.comboBox_videoCodec.itemText(i) if codec == self.settings.value("outputVideoCodec"): self.comboBox_videoCodec.setCurrentIndex(i) for i in range(self.comboBox_audioCodec.count()): codec = self.comboBox_audioCodec.itemText(i) if codec == self.settings.value("outputAudioCodec"): self.comboBox_audioCodec.setCurrentIndex(i) self.comboBox_videoCodec.currentIndexChanged.connect(self.updateCodecSettings) self.comboBox_audioCodec.currentIndexChanged.connect(self.updateCodecSettings) vBitrate = int(self.settings.value("outputVideoBitrate")) aBitrate = int(self.settings.value("outputAudioBitrate")) self.spinBox_vBitrate.setValue(vBitrate) self.spinBox_aBitrate.setValue(aBitrate) self.spinBox_vBitrate.valueChanged.connect(self.updateCodecSettings) self.spinBox_aBitrate.valueChanged.connect(self.updateCodecSettings) # Make component buttons self.compMenu = QtWidgets.QMenu() for i, comp in enumerate(self.core.modules): action = self.compMenu.addAction(comp.Component.name) action.triggered.connect(lambda _, item=i: self.addComponent(0, item)) self.pushButton_addComponent.setMenu(self.compMenu) componentList.dropEvent = self.dragComponent componentList.itemSelectionChanged.connect(self.changeComponentWidget) componentList.itemSelectionChanged.connect( self.presetManager.clearPresetListSelection ) self.pushButton_removeComponent.clicked.connect(lambda: self.removeComponent()) componentList.setContextMenuPolicy( QtCore.Qt.ContextMenuPolicy.CustomContextMenu ) componentList.customContextMenuRequested.connect(self.componentContextMenu) currentRes = ( str(self.settings.value("outputWidth")) + "x" + str(self.settings.value("outputHeight")) ) for i, res in enumerate(Core.resolutions): self.comboBox_resolution.addItem(res) if res == currentRes: currentRes = i self.comboBox_resolution.setCurrentIndex(currentRes) self.comboBox_resolution.currentIndexChanged.connect( self.updateResolution ) self.pushButton_listMoveUp.clicked.connect(lambda: self.moveComponent(-1)) self.pushButton_listMoveDown.clicked.connect(lambda: self.moveComponent(1)) # Configure the Projects Menu self.projectMenu = QtWidgets.QMenu() self.menuButton_newProject = self.projectMenu.addAction("New Project") self.menuButton_newProject.triggered.connect(lambda: self.createNewProject()) self.menuButton_openProject = self.projectMenu.addAction("Open Project") self.menuButton_openProject.triggered.connect( lambda: self.openOpenProjectDialog() ) action = self.projectMenu.addAction("Save Project") action.triggered.connect(self.saveCurrentProject) action = self.projectMenu.addAction("Save Project As") action.triggered.connect(self.openSaveProjectDialog) self.pushButton_projects.setMenu(self.projectMenu) # Configure the Presets Button self.pushButton_presets.clicked.connect(self.openPresetManager) self.updateWindowTitle() log.debug("Showing main window") self.show() if project and project != self.autosavePath: if not project.endswith(".avp"): project += ".avp" # open a project from the commandline if not os.path.dirname(project): project = os.path.join(self.settings.value("projectDir"), project) self.currentProject = project self.settings.setValue("currentProject", project) if os.path.exists(self.autosavePath): os.remove(self.autosavePath) else: # open the last currentProject from settings self.currentProject = self.settings.value("currentProject") # delete autosave if it's identical to this project if self.autosaveExists(identical=True): os.remove(self.autosavePath) if self.currentProject and os.path.exists(self.autosavePath): ch = self.showMessage( msg="Restore unsaved changes in project '%s'?" % os.path.basename(self.currentProject)[:-4], showCancel=True, ) if ch: self.saveProjectChanges() else: os.remove(self.autosavePath) self.openProject(self.currentProject, prompt=False) self.drawPreview(True) log.info("Pillow version %s", Image.__version__) log.info( "PyQt version %s (Qt version %s)", QtCore.PYQT_VERSION_STR, QtCore.QT_VERSION_STR, ) # verify Ffmpeg version if not self.core.FFMPEG_BIN: self.showMessage( msg="FFmpeg could not be found. This is a critical error. " "Install FFmpeg, or download it and place the program executable " "in the same folder as this program.", icon="Critical", ) else: if not self.settings.value("ffmpegMsgShown"): ffmpegGoodVersion, ffmpegVersionNum = checkFfmpegVersion() if not ffmpegGoodVersion: self.showMessage( msg="The version of FFmpeg ({ffmpegVersionNum}) is " "not recognized. Some features may not work as expected." ) self.settings.setValue("ffmpegMsgShown", True) # Hotkeys for projects QShortcut("Ctrl+S", self, self.saveCurrentProject) QShortcut("Ctrl+A", self, self.openSaveProjectDialog) QShortcut("Ctrl+O", self, self.openOpenProjectDialog) QShortcut("Ctrl+N", self, self.createNewProject) # Hotkeys for undo/redo QShortcut("Ctrl+Z", self, self.undoStack.undo) QShortcut("Ctrl+Y", self, self.undoStack.redo) QShortcut("Ctrl+Shift+Z", self, self.undoStack.redo) # Hotkeys for component list for inskey in ("Ctrl+T", QtCore.Qt.Key.Key_Insert): QShortcut( inskey, self, activated=lambda: self.pushButton_addComponent.click(), ) for delkey in ("Ctrl+R", QtCore.Qt.Key.Key_Delete): QShortcut(delkey, self.listWidget_componentList, self.removeComponent) QShortcut( "Ctrl+Space", self, activated=lambda: self.listWidget_componentList.setFocus(), ) QShortcut("Ctrl+Shift+S", self, self.presetManager.openSavePresetDialog) QShortcut("Ctrl+Shift+C", self, self.presetManager.clearPreset) QShortcut( "Ctrl+Up", self.listWidget_componentList, activated=lambda: self.moveComponent(-1), ) QShortcut( "Ctrl+Down", self.listWidget_componentList, activated=lambda: self.moveComponent(1), ) QShortcut( "Ctrl+Home", self.listWidget_componentList, activated=lambda: self.moveComponent("top"), ) QShortcut( "Ctrl+End", self.listWidget_componentList, activated=lambda: self.moveComponent("bottom"), ) QShortcut("F1", self, self.showHelpWindow) QShortcut("Ctrl+Shift+F", self, self.showFfmpegCommand) QShortcut("Ctrl+Shift+U", self, self.showUndoStack) if log.isEnabledFor(logging.DEBUG): QShortcut("Ctrl+Alt+Shift+R", self, self.drawPreview) QShortcut("Ctrl+Alt+Shift+A", self, lambda: log.debug(repr(self))) # Close MainWindow when receiving Ctrl+C from terminal signal.signal(signal.SIGINT, lambda *args: self.close()) # Add initial components if none are in the list if not self.core.selectedComponents: self.core.insertComponent( 0, self.core.moduleIndexFor("Classic Visualizer"), self ) self.core.insertComponent(1, self.core.moduleIndexFor("Color"), self) # set colors to white and black to match classic appearance of program self.core.selectedComponents[0].page.lineEdit_visColor.setText( "255,255,255" ) self.core.selectedComponents[1].page.lineEdit_color1.setText("0,0,0") self.undoStack.clear() def __repr__(self): return ( "%s\n" "\n%s\n" "#####\n" "Preview thread is %s\n" % ( super().__repr__(), ( "core not initialized" if not hasattr(self, "core") else repr(self.core) ), ( "live" if hasattr(self, "previewThread") and self.previewThread.isRunning() else "dead" ), ) ) def closeEvent(self, event): log.info("Ending the preview thread") self.timer.stop() self.previewThread.quit() self.previewThread.wait() return super().closeEvent(event) @disableWhenOpeningProject def updateWindowTitle(self): log.debug("Setting main window's title") windowTitle = appName try: if self.currentProject: windowTitle += ( " - %s" % os.path.splitext(os.path.basename(self.currentProject))[0] ) if self.autosaveExists(identical=False): windowTitle += "*" except AttributeError: pass log.verbose(f'Window title is "{windowTitle}"') self.setWindowTitle(windowTitle) @QtCore.pyqtSlot(int, dict) def updateComponentTitle(self, pos, presetStore=False): """ Sets component title to modified or unmodified when given boolean. If given a preset dict, compares it against the component to determine if it is modified. A component with no preset is always unmodified. """ if type(presetStore) is dict: name = presetStore["preset"] if name is None or name not in self.core.savedPresets: modified = False else: modified = presetStore != self.core.savedPresets[name] modified = bool(presetStore) if pos < 0: pos = len(self.core.selectedComponents) - 1 name = self.core.selectedComponents[pos].name title = str(name) if self.core.selectedComponents[pos].currentPreset: title += " - %s" % self.core.selectedComponents[pos].currentPreset if modified: title += "*" if type(presetStore) is bool: log.debug( "Forcing %s #%s's modified status to %s: %s", name, pos, modified, title, ) else: log.debug("Setting %s #%s's title: %s", name, pos, title) self.listWidget_componentList.item(pos).setText(title) def updateCodecs(self): containerWidget = self.comboBox_videoContainer vCodecWidget = self.comboBox_videoCodec aCodecWidget = self.comboBox_audioCodec index = containerWidget.currentIndex() name = containerWidget.itemText(index) self.settings.setValue("outputContainer", name) vCodecWidget.clear() aCodecWidget.clear() for container in Core.encoderOptions["containers"]: if container["name"] == name: for vCodec in container["video-codecs"]: vCodecWidget.addItem(vCodec) for aCodec in container["audio-codecs"]: aCodecWidget.addItem(aCodec) def updateCodecSettings(self): """Updates settings.ini to match encoder option widgets""" vCodecWidget = self.comboBox_videoCodec vBitrateWidget = self.spinBox_vBitrate aBitrateWidget = self.spinBox_aBitrate aCodecWidget = self.comboBox_audioCodec currentVideoCodec = vCodecWidget.currentIndex() currentVideoCodec = vCodecWidget.itemText(currentVideoCodec) currentVideoBitrate = vBitrateWidget.value() currentAudioCodec = aCodecWidget.currentIndex() currentAudioCodec = aCodecWidget.itemText(currentAudioCodec) currentAudioBitrate = aBitrateWidget.value() self.settings.setValue("outputVideoCodec", currentVideoCodec) self.settings.setValue("outputAudioCodec", currentAudioCodec) self.settings.setValue("outputVideoBitrate", currentVideoBitrate) self.settings.setValue("outputAudioBitrate", currentAudioBitrate) @disableWhenOpeningProject def autosave(self, force=False): if not self.currentProject: if os.path.exists(self.autosavePath): os.remove(self.autosavePath) elif force or time.time() - self.lastAutosave >= self.autosaveCooldown: self.core.createProjectFile(self.autosavePath, self) self.lastAutosave = time.time() if len(self.autosaveTimes) >= 5: # Do some math to reduce autosave spam. This gives a smooth # curve up to 5 seconds cooldown and maintains that for 30 secs # if a component is continuously updated timeDiff = self.lastAutosave - self.autosaveTimes.pop() if not force and timeDiff >= 1.0 and timeDiff <= 10.0: if self.autosaveCooldown / 4.0 < 0.5: self.autosaveCooldown += 1.0 self.autosaveCooldown = (5.0 * (self.autosaveCooldown / 5.0)) + ( self.autosaveCooldown / 5.0 ) * 2 elif force or timeDiff >= self.autosaveCooldown * 5: self.autosaveCooldown = 0.2 self.autosaveTimes.insert(0, self.lastAutosave) else: log.debug("Autosave rejected by cooldown") def autosaveExists(self, identical=True): """Determines if creating the autosave should be blocked.""" try: if ( self.currentProject and os.path.exists(self.autosavePath) and filecmp.cmp(self.autosavePath, self.currentProject) == identical ): log.debug( "Autosave found %s to be identical" % "not" if not identical else "" ) return True except FileNotFoundError: log.error("Project file couldn't be located: %s", self.currentProject) return identical return False def saveProjectChanges(self): """Overwrites project file with autosave file""" try: os.remove(self.currentProject) os.rename(self.autosavePath, self.currentProject) return True except (FileNotFoundError, IsADirectoryError) as e: self.showMessage(msg="Project file couldn't be saved.", detail=str(e)) return False def openInputFileDialog(self): inputDir = self.settings.value("inputDir", os.path.expanduser("~")) fileName, _ = QtWidgets.QFileDialog.getOpenFileName( self, "Open Audio File", inputDir, "Audio Files (%s)" % " ".join(Core.audioFormats), ) if fileName: self.settings.setValue("inputDir", os.path.dirname(fileName)) self.lineEdit_audioFile.setText(fileName) def openOutputFileDialog(self): outputDir = self.settings.value("outputDir", os.path.expanduser("~")) fileName, _ = QtWidgets.QFileDialog.getSaveFileName( self, "Set Output Video File", outputDir, "Video Files (%s);; All Files (*)" % " ".join(Core.videoFormats), ) if fileName: self.settings.setValue("outputDir", os.path.dirname(fileName)) self.lineEdit_outputFile.setText(fileName) def stopVideo(self): log.info("Export cancelled") self.videoWorker.cancel() self.canceled = True def createAudioVisualization(self): # create output video if mandatory settings are filled in audioFile = self.lineEdit_audioFile.text() outputPath = self.lineEdit_outputFile.text() if audioFile and outputPath and self.core.selectedComponents: if not os.path.dirname(outputPath): outputPath = os.path.join(os.path.expanduser("~"), outputPath) if outputPath and os.path.isdir(outputPath): self.showMessage( msg="Chosen filename matches a directory, which " "cannot be overwritten. Please choose a different " "filename or move the directory.", icon="Warning", ) return else: if not audioFile or not outputPath: self.showMessage( msg="You must select an audio file and output filename." ) elif not self.core.selectedComponents: self.showMessage(msg="Not enough components.") return self.canceled = False self.progressBarUpdated(-1) self.videoWorker = self.core.newVideoWorker(self, audioFile, outputPath) self.videoWorker.progressBarUpdate.connect(self.progressBarUpdated) self.videoWorker.progressBarSetText.connect(self.progressBarSetText) self.videoWorker.imageCreated.connect(self.showPreviewImage) self.videoWorker.encoding.connect(self.changeEncodingStatus) self.createVideo.emit() @QtCore.pyqtSlot(str, str) def videoThreadError(self, msg, detail): try: self.stopVideo() except AttributeError as e: if "videoWorker" not in str(e): raise self.showMessage( msg=msg, detail=detail, icon="Critical", ) log.info("%s", repr(self)) def changeEncodingStatus(self, status): self.encoding = status if status: # Disable many widgets when starting to export self.pushButton_createVideo.setEnabled(False) self.pushButton_Cancel.setEnabled(True) self.comboBox_resolution.setEnabled(False) self.stackedWidget.setEnabled(False) self.tab_encoderSettings.setEnabled(False) self.label_audioFile.setEnabled(False) self.toolButton_selectAudioFile.setEnabled(False) self.label_outputFile.setEnabled(False) self.toolButton_selectOutputFile.setEnabled(False) self.lineEdit_audioFile.setEnabled(False) self.lineEdit_outputFile.setEnabled(False) self.listWidget_componentList.setEnabled(False) self.pushButton_addComponent.setEnabled(False) self.pushButton_removeComponent.setEnabled(False) self.pushButton_listMoveDown.setEnabled(False) self.pushButton_listMoveUp.setEnabled(False) self.pushButton_undo.setEnabled(False) self.menuButton_newProject.setEnabled(False) self.menuButton_openProject.setEnabled(False) # Close undo history dialog if open self.undoDialog.close() # Show label under progress bar on macOS if sys.platform == "darwin": self.progressLabel.setHidden(False) else: self.pushButton_createVideo.setEnabled(True) self.pushButton_Cancel.setEnabled(False) self.comboBox_resolution.setEnabled(True) self.stackedWidget.setEnabled(True) self.tab_encoderSettings.setEnabled(True) self.label_audioFile.setEnabled(True) self.toolButton_selectAudioFile.setEnabled(True) self.lineEdit_audioFile.setEnabled(True) self.label_outputFile.setEnabled(True) self.toolButton_selectOutputFile.setEnabled(True) self.lineEdit_outputFile.setEnabled(True) self.pushButton_addComponent.setEnabled(True) self.pushButton_removeComponent.setEnabled(True) self.pushButton_listMoveDown.setEnabled(True) self.pushButton_listMoveUp.setEnabled(True) self.pushButton_undo.setEnabled(True) self.menuButton_newProject.setEnabled(True) self.menuButton_openProject.setEnabled(True) self.listWidget_componentList.setEnabled(True) self.progressLabel.setHidden(True) self.drawPreview(True) @QtCore.pyqtSlot(int) def progressBarUpdated(self, value): self.progressBar_createVideo.setValue(value) @QtCore.pyqtSlot(str) def progressBarSetText(self, value): if sys.platform == "darwin": self.progressLabel.setText(value) else: self.progressBar_createVideo.setFormat(value) if log.getEffectiveLevel() > logging.INFO: # if ffmpeg is quiet, print progress ourselves if any( [ value.startswith("Export C"), value.startswith("Analyzing"), value.startswith("Loading"), ] ): # Don't duplicate completion/failure messages or send too many messages return elif not value.startswith("Exporting"): print(value) else: # overwrite previous message with next one # if the text is our main export progress print(f"\r{value}", end="") def updateResolution(self): resIndex = int(self.comboBox_resolution.currentIndex()) res = Core.resolutions[resIndex].split("x") changed = res[0] != self.settings.value("outputWidth") self.settings.setValue("outputWidth", res[0]) self.settings.setValue("outputHeight", res[1]) if changed: for i in range(len(self.core.selectedComponents)): self.core.updateComponent(i) def drawPreview(self, force=False, **kwargs): """Use autosave keyword arg to force saving or not saving if needed""" self.newTask.emit(self.core.selectedComponents) # self.processTask.emit() if force or "autosave" in kwargs: if force or kwargs["autosave"]: self.autosave(True) else: self.autosave() self.updateWindowTitle() @QtCore.pyqtSlot("QImage") def showPreviewImage(self, image): self.previewWindow.changePixmap(image) @disableWhenEncoding def showUndoStack(self): self.undoDialog.show() def showHelpWindow(self): self.showMessage(msg=f"{appName} v{__version__}") def showFfmpegCommand(self): command = createFfmpegCommand( self.lineEdit_audioFile.text(), self.lineEdit_outputFile.text(), self.core.selectedComponents, ) command = " ".join(command) log.info(f"FFmpeg command: {command}") lines = wrap(command, 49) self.showMessage(msg=f"Current FFmpeg command:\n\n{' '.join(lines)}") def addComponent(self, compPos, moduleIndex): """Creates an undoable action that adds a new component.""" action = AddComponent(self, compPos, moduleIndex) self.undoStack.push(action) def insertComponent(self, index): """Triggered by Core to finish initializing a new component.""" if not hasattr(self.core.selectedComponents[index], "page"): log.error("Component failed to initialize") return componentList = self.listWidget_componentList stackedWidget = self.stackedWidget componentList.insertItem(index, self.core.selectedComponents[index].name) componentList.setCurrentRow(index) # connect to signal that adds an asterisk when modified self.core.selectedComponents[index].modified.connect(self.updateComponentTitle) self.pages.insert(index, self.core.selectedComponents[index].page) stackedWidget.insertWidget(index, self.pages[index]) stackedWidget.setCurrentIndex(index) return index def removeComponent(self): componentList = self.listWidget_componentList selected = componentList.selectedItems() if selected: action = RemoveComponent(self, selected) self.undoStack.push(action) def _removeComponent(self, index): stackedWidget = self.stackedWidget componentList = self.listWidget_componentList stackedWidget.removeWidget(self.pages[index]) componentList.takeItem(index) self.core.removeComponent(index) self.pages.pop(index) self.changeComponentWidget() self.drawPreview() @disableWhenEncoding def moveComponent(self, change): """Moves a component relatively from its current position""" componentList = self.listWidget_componentList tag = change if change == "top": change = -componentList.currentRow() elif change == "bottom": change = len(componentList) - componentList.currentRow() - 1 else: tag = "down" if change == 1 else "up" row = componentList.currentRow() newRow = row + change if newRow > -1 and newRow < componentList.count(): action = MoveComponent(self, row, newRow, tag) self.undoStack.push(action) def getComponentListMousePos(self, position): """ Given a QPos, returns the component index under the mouse cursor or -1 if no component is there. """ componentList = self.listWidget_componentList if hasattr(position, "toPointF"): position = position.toPointF() position = position.toPoint() modelIndexes = [ componentList.model().index(i) for i in range(componentList.count()) ] rects = [componentList.visualRect(modelIndex) for modelIndex in modelIndexes] mousePos = [rect.contains(position) for rect in rects] if not any(mousePos): # Not clicking a component mousePos = -1 else: mousePos = mousePos.index(True) log.debug("Click component list row %s" % mousePos) return mousePos @disableWhenEncoding def dragComponent(self, event): """Used as Qt drop event for the component listwidget""" componentList = self.listWidget_componentList mousePos = self.getComponentListMousePos(event.position()) if mousePos > -1: change = (componentList.currentRow() - mousePos) * -1 else: change = componentList.count() - componentList.currentRow() - 1 self.moveComponent(change) def changeComponentWidget(self): selected = self.listWidget_componentList.selectedItems() if selected: index = self.listWidget_componentList.row(selected[0]) self.stackedWidget.setCurrentIndex(index) def openPresetManager(self): """Preset manager for importing, exporting, renaming, deleting""" self.presetManager.show_() def clear(self): """Get a blank slate""" self.core.clearComponents() self.listWidget_componentList.clear() for widget in self.pages: self.stackedWidget.removeWidget(widget) self.pages = [] for field in (self.lineEdit_audioFile, self.lineEdit_outputFile): with blockSignals(field): field.setText("") self.progressBarUpdated(0) self.progressBarSetText("") self.undoStack.clear() @disableWhenEncoding def createNewProject(self, prompt=True): if prompt: ch = self.openSaveChangesDialog("starting a new project") if ch is None: return self.clear() self.currentProject = None self.settings.setValue("currentProject", None) self.drawPreview(True) def saveCurrentProject(self): if self.currentProject: self.core.createProjectFile(self.currentProject, self) try: os.remove(self.autosavePath) except FileNotFoundError: pass self.updateWindowTitle() else: self.openSaveProjectDialog() def openSaveChangesDialog(self, phrase): success = True ch = True if self.autosaveExists(identical=False): ch = self.showMessage( msg="You have unsaved changes in project '%s'. " "Save before %s?" % (os.path.basename(self.currentProject)[:-4], phrase), showDiscard=True, ) if ch: success = self.saveProjectChanges() if ch is not None and success and os.path.exists(self.autosavePath): os.remove(self.autosavePath) return success and ch def openSaveProjectDialog(self): filename, _ = QtWidgets.QFileDialog.getSaveFileName( self, "Create Project File", self.settings.value("projectDir"), "Project Files (*.avp)", ) if not filename: return if not filename.endswith(".avp"): filename += ".avp" self.settings.setValue("projectDir", os.path.dirname(filename)) self.settings.setValue("currentProject", filename) self.currentProject = filename self.core.createProjectFile(filename, self) self.updateWindowTitle() @disableWhenEncoding def openOpenProjectDialog(self): filename, _ = QtWidgets.QFileDialog.getOpenFileName( self, "Open Project File", self.settings.value("projectDir"), "Project Files (*.avp)", ) self.openProject(filename) def openProject(self, filepath, prompt=True): if ( not filepath or not os.path.exists(filepath) or not filepath.endswith(".avp") ): return # ask to save any changes that are about to get deleted if prompt: ch = self.openSaveChangesDialog("opening another project") if ch is None: return self.clear() self.currentProject = filepath self.settings.setValue("currentProject", filepath) self.settings.setValue("projectDir", os.path.dirname(filepath)) # actually load the project using core method self.core.openProject(self, filepath) self.drawPreview(autosave=False) self.updateWindowTitle() def showMessage(self, **kwargs): parent = kwargs["parent"] if "parent" in kwargs else self msg = QtWidgets.QMessageBox(parent) msg.setWindowTitle(appName) msg.setModal(True) msg.setText(kwargs["msg"]) msg.setIcon( eval("QtWidgets.QMessageBox.Icon.%s" % kwargs["icon"]) if "icon" in kwargs else QtWidgets.QMessageBox.Icon.Information ) msg.setDetailedText(kwargs["detail"] if "detail" in kwargs else None) if "showDiscard" in kwargs and kwargs["showDiscard"]: msg.setStandardButtons( QtWidgets.QMessageBox.StandardButton.Save | QtWidgets.QMessageBox.StandardButton.Discard | QtWidgets.QMessageBox.StandardButton.Cancel ) elif "showCancel" in kwargs and kwargs["showCancel"]: msg.setStandardButtons( QtWidgets.QMessageBox.StandardButton.Ok | QtWidgets.QMessageBox.StandardButton.Cancel ) else: msg.setStandardButtons(QtWidgets.QMessageBox.StandardButton.Ok) ch = msg.exec() if ch == 1024 or ch == 2048: # OK or Save return True elif ch > 8000000: # Discard return False # Cancel return None @disableWhenEncoding def componentContextMenu(self, QPos): """Appears when right-clicking the component list""" componentList = self.listWidget_componentList self.menu = QtWidgets.QMenu() parentPosition = componentList.mapToGlobal(QtCore.QPoint(0, 0)) index = self.getComponentListMousePos(QPos) if index > -1: # Show preset menu if clicking a component self.presetManager.findPresets() menuItem = self.menu.addAction("Save Preset") menuItem.triggered.connect(self.presetManager.openSavePresetDialog) # submenu for opening presets try: presets = self.presetManager.presets[ str(self.core.selectedComponents[index]) ] self.presetSubmenu = QtWidgets.QMenu("Open Preset") self.menu.addMenu(self.presetSubmenu) for version, presetName in presets: menuItem = self.presetSubmenu.addAction(presetName) menuItem.triggered.connect( lambda _, presetName=presetName: self.presetManager.openPreset( presetName ) ) except KeyError: pass if self.core.selectedComponents[index].currentPreset: menuItem = self.menu.addAction("Clear Preset") menuItem.triggered.connect(self.presetManager.clearPreset) self.menu.addSeparator() # "Add Component" submenu self.submenu = QtWidgets.QMenu("Add") self.menu.addMenu(self.submenu) insertCompAtTop = self.settings.value("pref_insertCompAtTop") for i, comp in enumerate(self.core.modules): menuItem = self.submenu.addAction(comp.Component.name) menuItem.triggered.connect( lambda _, item=i: self.addComponent( 0 if insertCompAtTop else index, item ) ) self.menu.move(parentPosition + QPos) self.menu.show() djfun-audio-visualizer-python-f03a3a6/src/avp/gui/mainwindow.ui000066400000000000000000000647041514343513600247320ustar00rootroot00000000000000 MainWindow 0 0 1008 575 0 0 0 0 Qt::StrongFocus MainWindow 0 0 false 9 0 Qt::Vertical QSizePolicy::MinimumExpanding 0 360 QLayout::SetDefaultConstraint 0 Qt::Horizontal QSizePolicy::MinimumExpanding 420 0 QLayout::SetMinimumSize 3 QLayout::SetMinimumSize 3 QLayout::SetMinimumSize Undo Qt::Horizontal QSizePolicy::Fixed 140 20 Projects Presets Qt::Horizontal QSizePolicy::Minimum 20 2 0 0 0 0 16777215 16777215 true QFrame::StyledPanel QFrame::Sunken 1 true true false QAbstractItemView::InternalMove Qt::MoveAction Add Remove Up Down 4 2 QLayout::SetFixedSize 4 0 0 0 500 0 16777215 180 QTabWidget::North QTabWidget::Rounded 0 Export Video 10 0 0 0 85 0 80 16777215 80 0 Audio File Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter 0 0 0 28 16777215 28 0 0 0 28 16777215 28 ... 0 0 85 0 0 0 Output File Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter 0 0 0 28 16777215 28 0 28 16777215 28 ... 0 0 0 24 Qt::Horizontal QSizePolicy::Minimum 10 20 Create Video false Cancel true Qt::AlignCenter -1 progressLabel Encoder Settings 10 0 0 85 0 Container 150 0 Qt::Horizontal QSizePolicy::Minimum 5 5 0 0 Resolution 0 0 0 0 0 0 85 0 Video Codec 150 0 Qt::Horizontal QSizePolicy::Fixed 5 5 0 0 Video Bitrate (Kbps) 99999 0 0 85 0 Audio Codec 150 0 Qt::Horizontal QSizePolicy::Fixed 5 10 0 0 Audio Bitrate (Kbps) 9999 QLayout::SetDefaultConstraint Qt::Horizontal QSizePolicy::MinimumExpanding 500 0 0 0 0 180 16777215 180 -1 djfun-audio-visualizer-python-f03a3a6/src/avp/gui/presetmanager.py000066400000000000000000000307331514343513600254210ustar00rootroot00000000000000""" Preset manager object handles all interactions with presets, including the context menu accessed from MainWindow. """ from PyQt6 import QtCore, QtWidgets, uic import string import os import logging from ..toolkit import badName from ..core import Core, appName from .actions import * log = logging.getLogger("AVP.Gui.PresetManager") class PresetManager(QtWidgets.QDialog): def __init__(self, parent): super().__init__() uic.loadUi(os.path.join(Core.wd, "gui", "presetmanager.ui"), self) self.parent = parent self.core = parent.core self.settings = parent.settings self.presetDir = parent.presetDir if not self.settings.value("presetDir"): self.settings.setValue("presetDir", os.path.join(parent.dataDir, "presets")) self.findPresets() # window self.lastFilter = "*" self.presetRows = [] # list of (comp, vers, name) tuples self.setWindowFlags(QtCore.Qt.WindowType.WindowStaysOnTopHint) # connect button signals self.pushButton_delete.clicked.connect(self.openDeletePresetDialog) self.pushButton_rename.clicked.connect(self.openRenamePresetDialog) self.pushButton_import.clicked.connect(self.openImportDialog) self.pushButton_export.clicked.connect(self.openExportDialog) self.pushButton_close.clicked.connect(self.close) # create filter box and preset list self.drawFilterList() self.comboBox_filter.currentIndexChanged.connect( lambda: self.drawPresetList( self.comboBox_filter.currentText(), self.lineEdit_search.text() ) ) # make auto-completion for search bar self.autocomplete = QtCore.QStringListModel() completer = QtWidgets.QCompleter() completer.setModel(self.autocomplete) self.lineEdit_search.setCompleter(completer) self.lineEdit_search.textChanged.connect( lambda: self.drawPresetList( self.comboBox_filter.currentText(), self.lineEdit_search.text() ) ) self.drawPresetList("*") def show_(self): """Open a new preset manager window from the mainwindow""" self.findPresets() self.drawFilterList() self.drawPresetList("*") self.show() def findPresets(self): log.debug("Searching %s for presets", self.presetDir) parseList = [] for dirpath, dirnames, filenames in os.walk(self.presetDir): # anything without a subdirectory must be a preset folder if dirnames: continue for preset in filenames: compName = os.path.basename(os.path.dirname(dirpath)) if compName not in self.core.compNames: continue compVers = os.path.basename(dirpath) try: parseList.append((compName, int(compVers), preset)) except ValueError: continue self.presets = { compName: [ (vers, preset) for name, vers, preset in parseList if name == compName ] for compName, _, __ in parseList } def drawPresetList(self, compFilter=None, presetFilter=""): self.listWidget_presets.clear() if compFilter: self.lastFilter = str(compFilter) else: compFilter = str(self.lastFilter) self.presetRows = [] presetNames = [] for component, presets in self.presets.items(): if compFilter != "*" and component != compFilter: continue for vers, preset in presets: if not presetFilter or presetFilter in preset: self.listWidget_presets.addItem("%s: %s" % (component, preset)) self.presetRows.append((component, vers, preset)) if preset not in presetNames: presetNames.append(preset) self.autocomplete.setStringList(presetNames) def drawFilterList(self): self.comboBox_filter.clear() self.comboBox_filter.addItem("*") for component in self.presets: self.comboBox_filter.addItem(component) def clearPreset(self, compI=None): """Functions on mainwindow level from the context menu""" compI = self.parent.listWidget_componentList.currentRow() action = ClearPreset(self.parent, compI) self.parent.undoStack.push(action) def openSavePresetDialog(self): """Functions on mainwindow level from the context menu""" selectedComponents = self.core.selectedComponents componentList = self.parent.listWidget_componentList if componentList.currentRow() == -1: return while True: index = componentList.currentRow() currentPreset = selectedComponents[index].currentPreset newName, OK = QtWidgets.QInputDialog.getText( self.parent, appName, "New Preset Name:", QtWidgets.QLineEdit.EchoMode.Normal, currentPreset, ) if OK: if badName(newName): self.warnMessage(self.parent) continue if newName: if index != -1: selectedComponents[index].currentPreset = newName saveValueStore = selectedComponents[index].savePreset() saveValueStore["preset"] = newName componentName = str(selectedComponents[index]).strip() vers = selectedComponents[index].version self.createNewPreset( componentName, vers, newName, saveValueStore, window=self.parent, ) self.findPresets() self.drawPresetList() self.openPreset(newName, index) break def createNewPreset(self, compName, vers, filename, saveValueStore, **kwargs): path = os.path.join(self.presetDir, compName, str(vers), filename) if self.presetExists(path, **kwargs): return self.core.createPresetFile(compName, vers, filename, saveValueStore) def presetExists(self, path, **kwargs): if os.path.exists(path): window = kwargs.get("window", self) ch = self.parent.showMessage( msg="%s already exists! Overwrite it?" % os.path.basename(path), showCancel=True, icon="Warning", parent=window, ) if not ch: # user clicked cancel return True return False def openPreset(self, presetName, compPos=None): componentList = self.parent.listWidget_componentList index = compPos if compPos is not None else componentList.currentRow() if index == -1: return action = OpenPreset(self, presetName, index) self.parent.undoStack.push(action) def _openPreset(self, presetName, index): selectedComponents = self.core.selectedComponents componentName = selectedComponents[index].name.strip() version = selectedComponents[index].version dirname = os.path.join(self.presetDir, componentName, str(version)) filepath = os.path.join(dirname, presetName) self.core.openPreset(filepath, index, presetName) self.parent.updateComponentTitle(index) self.parent.drawPreview() def openDeletePresetDialog(self): row = self.getPresetRow() if row == -1: return comp, vers, name = self.presetRows[row] ch = self.parent.showMessage( msg="Really delete %s?" % name, showCancel=True, icon="Warning", parent=self, ) if not ch: return self.deletePreset(comp, vers, name) def deletePreset(self, comp, vers, name): action = DeletePreset(self, comp, vers, name) self.parent.undoStack.push(action) def warnMessage(self, window=None): self.parent.showMessage( msg="Preset names must contain only letters, " "numbers, and spaces.", parent=window if window else self, ) def getPresetRow(self): row = self.listWidget_presets.currentRow() if row > -1: return row # check if component selected in MainWindow has preset loaded componentList = self.parent.listWidget_componentList compIndex = componentList.currentRow() if compIndex == -1: return compIndex preset = self.core.selectedComponents[compIndex].currentPreset if preset is None: return -1 else: rowTuple = ( self.core.selectedComponents[compIndex].name, self.core.selectedComponents[compIndex].version, preset, ) for i, tup in enumerate(self.presetRows): if rowTuple == tup: index = i break else: return -1 return index def openRenamePresetDialog(self): presetList = self.listWidget_presets index = self.getPresetRow() if index == -1: return while True: newName, OK = QtWidgets.QInputDialog.getText( self, "Preset Manager", "Rename Preset:", QtWidgets.QLineEdit.EchoMode.Normal, self.presetRows[index][2], ) if OK: if badName(newName): self.warnMessage() continue if newName: comp, vers, oldName = self.presetRows[index] path = os.path.join(self.presetDir, comp, str(vers)) newPath = os.path.join(path, newName) if self.presetExists(newPath): return action = RenamePreset(self, path, oldName, newName) self.parent.undoStack.push(action) break def renamePreset(self, path, oldName, newName): oldPath = os.path.join(path, oldName) newPath = os.path.join(path, newName) if os.path.exists(newPath): os.remove(newPath) os.rename(oldPath, newPath) self.findPresets() self.drawPresetList() path = os.path.dirname(newPath) for i, comp in enumerate(self.core.selectedComponents): if self.core.getPresetDir(comp) == path and comp.currentPreset == oldName: self.core.openPreset(newPath, i, newName) self.parent.updateComponentTitle(i, False) self.parent.drawPreview() def openImportDialog(self): filename, _ = QtWidgets.QFileDialog.getOpenFileName( self, "Import Preset File", self.settings.value("presetDir"), "Preset Files (*.avl)", ) if filename: # get installed path & ask user to overwrite if needed path = "" while True: if path: if self.presetExists(path): break else: if os.path.exists(path): os.remove(path) success, path = self.core.importPreset(filename) if success: break self.findPresets() self.drawPresetList() self.settings.setValue("presetDir", os.path.dirname(filename)) def openExportDialog(self): index = self.getPresetRow() if index == -1: return filename, _ = QtWidgets.QFileDialog.getSaveFileName( self, "Export Preset", self.settings.value("presetDir"), "Preset Files (*.avl)", ) if filename: comp, vers, name = self.presetRows[index] if not self.core.exportPreset(filename, comp, vers, name): self.parent.showMessage( msg="Couldn't export %s." % filename, parent=self ) self.settings.setValue("presetDir", os.path.dirname(filename)) def clearPresetListSelection(self): self.listWidget_presets.setCurrentRow(-1) djfun-audio-visualizer-python-f03a3a6/src/avp/gui/presetmanager.ui000066400000000000000000000076721514343513600254140ustar00rootroot00000000000000 presetmanager Qt::NonModal true 0 0 497 377 Preset Manager Filter by name 200 0 0 0 true QLayout::SetMinimumSize Import Export Qt::Horizontal 40 20 true Rename Delete <html><head/><body><p><span style=" font-size:10pt; font-style:italic;">Right-click components in the main window to create presets</span></p></body></html> Qt::Horizontal 40 20 Close djfun-audio-visualizer-python-f03a3a6/src/avp/gui/preview_thread.py000066400000000000000000000066461514343513600256020ustar00rootroot00000000000000""" Thread that runs to create QImages for MainWindow's preview label. Processes a queue of component lists. """ from PyQt6 import QtCore, QtGui, uic from PyQt6.QtCore import pyqtSignal, pyqtSlot from PIL import Image from PIL.ImageQt import ImageQt from queue import Queue, Empty import os import logging from ..toolkit.frame import Checkerboard from ..toolkit import disableWhenOpeningProject log = logging.getLogger("AVP.Gui.PreviewThread") class Worker(QtCore.QObject): imageCreated = pyqtSignal(QtGui.QImage) error = pyqtSignal(str) def __init__(self, core, settings, queue): super().__init__() self.core = core self.settings = settings width = int(self.settings.value("outputWidth")) height = int(self.settings.value("outputHeight")) self.queue = queue self.background = Checkerboard(width, height) @disableWhenOpeningProject @pyqtSlot(list) def createPreviewImage(self, components): dic = { "components": components, } self.queue.put(dic) log.debug("Preview thread id: {}".format(int(QtCore.QThread.currentThreadId()))) @pyqtSlot() def process(self): try: nextPreviewInformation = self.queue.get(block=False) while self.queue.qsize() >= 2: try: self.queue.get(block=False) except Empty: continue width = int(self.settings.value("outputWidth")) height = int(self.settings.value("outputHeight")) if self.background.width != width or self.background.height != height: self.background = Checkerboard(width, height) frame = self.background.copy() log.info("Creating new preview frame") components = nextPreviewInformation["components"] for component in reversed(components): try: component.lockSize(width, height) if "composite" in component.properties(): newFrame = component.previewRender(frame) else: newFrame = component.previewRender() component.unlockSize() frame = Image.alpha_composite(frame, newFrame) except (AttributeError, ValueError) as e: errMsg = ( "Bad frame returned by %s's preview renderer. " "%s. New frame %s." % ( str(component), str(e).capitalize(), ( "is None" if newFrame is None else "size was %s*%s; should be %s*%s" % (newFrame.width, newFrame.height, width, height) ), ) ) log.critical(errMsg) self.error.emit(errMsg) break except RuntimeError as e: log.error(str(e)) else: # We must store a reference to this QImage # or else Qt will garbage-collect it on the C++ side self.frame = ImageQt(frame) self.imageCreated.emit(QtGui.QImage(self.frame)) except Empty: True djfun-audio-visualizer-python-f03a3a6/src/avp/gui/preview_win.py000066400000000000000000000036321514343513600251200ustar00rootroot00000000000000from PyQt6 import QtCore, QtGui, QtWidgets import logging log = logging.getLogger("AVP.Gui.PreviewWindow") class PreviewWindow(QtWidgets.QLabel): """ Paints the preview QLabel in MainWindow and maintains the aspect ratio when the window is resized. """ def __init__(self, parent, img): super().__init__() self.parent = parent # FIXME # self.setFrameStyle(QtWidgets.QFrame.StyledPanel) self.pixmap = QtGui.QPixmap(img) def paintEvent(self, event): size = self.size() painter = QtGui.QPainter(self) point = QtCore.QPoint(0, 0) scaledPix = self.pixmap.scaled( size, QtCore.Qt.AspectRatioMode.KeepAspectRatio, transformMode=QtCore.Qt.TransformationMode.SmoothTransformation, ) # start painting the label from left upper corner point.setX(int((size.width() - scaledPix.width()) / 2)) point.setY(int((size.height() - scaledPix.height()) / 2)) painter.drawPixmap(point, scaledPix) def changePixmap(self, img): self.pixmap = QtGui.QPixmap(img) self.repaint() def mousePressEvent(self, event): if self.parent.encoding: return i = self.parent.listWidget_componentList.currentRow() if i >= 0: component = self.parent.core.selectedComponents[i] if not hasattr(component, "previewClickEvent"): return qpoint = event.position().toPoint() pos = (qpoint.x(), qpoint.y()) size = (self.width(), self.height()) butt = event.button() log.info("Click event for #%s: %s button %s" % (i, pos, butt)) component.previewClickEvent(pos, size, butt) @QtCore.pyqtSlot(str) def threadError(self, msg): self.parent.showMessage(msg=msg, icon="Critical", parent=self) log.info("%", repr(self.parent)) djfun-audio-visualizer-python-f03a3a6/src/avp/gui/undostack.py000066400000000000000000000006111514343513600245470ustar00rootroot00000000000000from PyQt6.QtGui import QUndoStack from ..toolkit.common import disableWhenEncoding class UndoStack(QUndoStack): @property def encoding(self): return self.parent().encoding @disableWhenEncoding def undo(self, *args, **kwargs): super().undo(*args, **kwargs) @disableWhenEncoding def redo(self, *args, **kwargs): super().redo(*args, **kwargs) djfun-audio-visualizer-python-f03a3a6/src/avp/libcomponent/000077500000000000000000000000001514343513600241115ustar00rootroot00000000000000djfun-audio-visualizer-python-f03a3a6/src/avp/libcomponent/__init__.py000066400000000000000000000002041514343513600262160ustar00rootroot00000000000000from .component import Component as BaseComponent from .exceptions import ComponentError __all__ = [BaseComponent, ComponentError] djfun-audio-visualizer-python-f03a3a6/src/avp/libcomponent/actions.py000066400000000000000000000070471514343513600261330ustar00rootroot00000000000000""" QUndoCommand class for generic undoable user actions performed to a BaseComponent See `../life.py` for an example of a component that uses a custom QUndoCommand """ from PyQt6.QtGui import QUndoCommand from copy import copy import logging log = logging.getLogger("AVP.ComponentHandler") class ComponentUpdate(QUndoCommand): """Command object for making a component action undoable""" def __init__(self, parent, oldWidgetVals, modifiedVals): super().__init__("change %s component #%s" % (parent.name, parent.compPos)) self.undone = False self.res = (int(parent.width), int(parent.height)) self.parent = parent self.oldWidgetVals = { attr: ( copy(val) if attr not in self.parent._relativeWidgets else self.parent.floatValForAttr(attr, val, axis=self.res) ) for attr, val in oldWidgetVals.items() if attr in modifiedVals } self.modifiedVals = { attr: ( val if attr not in self.parent._relativeWidgets else self.parent.floatValForAttr(attr, val, axis=self.res) ) for attr, val in modifiedVals.items() } # Because relative widgets change themselves every update based on # their previous value, we must store ALL their values in case of undo self.relativeWidgetValsAfterUndo = { attr: copy(getattr(self.parent, attr)) for attr in self.parent._relativeWidgets } # Determine if this update is mergeable self.id_ = -1 if self.parent.mergeUndo: if len(self.modifiedVals) == 1: attr, val = self.modifiedVals.popitem() self.id_ = sum([ord(letter) for letter in attr[-14:]]) self.modifiedVals[attr] = val return log.warning( "%s component settings changed at once. (%s)", len(self.modifiedVals), repr(self.modifiedVals), ) def id(self): """If 2 consecutive updates have same id, Qt will call mergeWith()""" return self.id_ def mergeWith(self, other): self.modifiedVals.update(other.modifiedVals) return True def setWidgetValues(self, attrDict): """ Mask the component's usual method to handle our relative widgets in case the resolution has changed. """ newAttrDict = { attr: ( val if attr not in self.parent._relativeWidgets else self.parent.pixelValForAttr(attr, val) ) for attr, val in attrDict.items() } self.parent.setWidgetValues(newAttrDict) def redo(self): if self.undone: log.info("Redoing component update") self.parent.oldAttrs = self.relativeWidgetValsAfterUndo self.setWidgetValues(self.modifiedVals) self.parent.update(auto=True) self.parent.oldAttrs = None if not self.undone: self.relativeWidgetValsAfterRedo = { attr: copy(getattr(self.parent, attr)) for attr in self.parent._relativeWidgets } self.parent._sendUpdateSignal() def undo(self): log.info("Undoing component update") self.undone = True self.parent.oldAttrs = self.relativeWidgetValsAfterRedo self.setWidgetValues(self.oldWidgetVals) self.parent.update(auto=True) self.parent.oldAttrs = None djfun-audio-visualizer-python-f03a3a6/src/avp/libcomponent/component.py000066400000000000000000000514041514343513600264710ustar00rootroot00000000000000""" Base classes for components to import. Read comments for some documentation on making a valid component. """ from PyQt6 import uic, QtCore, QtWidgets from PyQt6.QtGui import QColor import os import math import logging from copy import copy from .metaclass import ComponentMetaclass from .actions import ComponentUpdate from .exceptions import ComponentError from ..toolkit.frame import BlankFrame from ..toolkit import ( getWidgetValue, setWidgetValue, rgbFromString, randomColor, blockSignals, ) log = logging.getLogger("AVP.BaseComponent") class Component(QtCore.QObject, metaclass=ComponentMetaclass): """ The base class for components to inherit. """ name = "Component" # ui = 'name_Of_Non_Default_Ui_File' version = "1.0.0" # The major version (before the first dot) is used to determine # preset compatibility; the rest is ignored so it can be non-numeric. modified = QtCore.pyqtSignal(int, dict) _error = QtCore.pyqtSignal(str, str) def __init__(self, moduleIndex, compPos, core): super().__init__() self.moduleIndex = moduleIndex self.compPos = compPos self.core = core # STATUS VARIABLES self.currentPreset = None self._allWidgets = {} self._trackedWidgets = {} self._presetNames = {} self._commandArgs = {} self._colorWidgets = {} self._colorFuncs = {} self._relativeWidgets = {} # Pixel values stored as floats self._relativeValues = {} # Maximum values of spinBoxes at 1080p (Core.resolutions[0]) self._relativeMaximums = {} # LOCKING VARIABLES self.openingPreset = False self.mergeUndo = True self._lockedProperties = None self._lockedError = None self._lockedSize = None # If set to a dict, values are used as basis to update relative widgets self.oldAttrs = None # Stop lengthy processes in response to this variable self.canceled = False def __str__(self): return self.__class__.name def __repr__(self): import pprint try: preset = self.savePreset() except Exception as e: preset = "%s occurred while saving preset" % str(e) return "Component(module %s, pos %s) (%s)\n" "Name: %s v%s\nPreset: %s" % ( self.moduleIndex, self.compPos, object.__repr__(self), self.__class__.name, str(self.__class__.version), pprint.pformat(preset), ) # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ # Render Methods # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ def previewRender(self): image = BlankFrame(self.width, self.height) return image def preFrameRender(self, **kwargs): """ Must call super() when subclassing Triggered only before a video is exported (video_thread.py) self.audioFile = filepath to the main input audio file self.completeAudioArray = a list of audio samples self.sampleSize = number of audio samples per video frame self.progressBarUpdate = signal to set progress bar number self.progressBarSetText = signal to set progress bar text Use the latter two signals to update the MainWindow if needed for a long initialization procedure (i.e., for a visualizer) """ for key, value in kwargs.items(): setattr(self, key, value) def frameRender(self, frameNo): audioArrayIndex = frameNo * self.sampleSize image = BlankFrame(self.width, self.height) return image def postFrameRender(self): pass # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ # Properties # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ def properties(self): """ Return a list of properties with certain meanings: `static`: non-animated `audio`: has extra sound to add `error`: bad configuration `pcm`: request raw audio data `composite`: request frame to draw on """ return [] def error(self): """ Return a string containing an error message, or None for a default. Or tuple of two strings for a message with details. Alternatively use lockError(msgString) within properties() to skip this method entirely. """ return def audio(self): """ Return audio to mix into master as a tuple with two elements: The first element can be: - A string (path to audio file), - Or an object that returns audio data through a pipe The second element must be a dictionary of ffmpeg filters/options to apply to the input stream. See the filter docs for ideas: https://ffmpeg.org/ffmpeg-filters.html """ # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ # Idle Methods # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ def widget(self, parent): """ Call super().widget(*args) to create the component widget which also auto-connects any common widgets (e.g., checkBoxes) to self.update(). Then in a subclass connect special actions (e.g., pushButtons to select a file) and initialize """ self.parent = parent self.settings = parent.settings log.verbose( "Creating UI for %s #%s's widget", self.__class__.name, self.compPos, ) self.page = self.loadUi(self.__class__.ui) # Find all normal widgets which will be connected after subclass method self._allWidgets = { "lineEdit": self.page.findChildren(QtWidgets.QLineEdit), "checkBox": self.page.findChildren(QtWidgets.QCheckBox), "spinBox": self.page.findChildren(QtWidgets.QSpinBox), "comboBox": self.page.findChildren(QtWidgets.QComboBox), } self._allWidgets["spinBox"].extend( self.page.findChildren(QtWidgets.QDoubleSpinBox) ) def update(self): """ Starting point for a component update. A subclass should override this method, and the base class will then magically insert a call to either _autoUpdate() or _userUpdate() at the end. """ def loadPreset(self, presetDict, presetName=None): """ Subclasses should take (presetDict, *args) as args. Must use super().loadPreset(presetDict, *args) first, then update self.page widgets using the preset dict. """ self.currentPreset = ( presetName if presetName is not None else presetDict["preset"] ) for attr, widget in self._trackedWidgets.items(): key = attr if attr not in self._presetNames else self._presetNames[attr] try: val = presetDict[key] except KeyError as e: log.info( "%s missing value %s. Outdated preset?", self.currentPreset, str(e), ) val = getattr(self, key) if attr in self._colorWidgets: widget.setText("%s,%s,%s" % val) btnStyle = ( "QPushButton { background-color : %s; outline: none; }" % QColor(*val).name() ) self._colorWidgets[attr].setStyleSheet(btnStyle) elif attr in self._relativeWidgets: self._relativeValues[attr] = val pixelVal = self.pixelValForAttr(attr, val) setWidgetValue(widget, pixelVal) else: setWidgetValue(widget, val) def savePreset(self): saveValueStore = {} for attr, widget in self._trackedWidgets.items(): presetAttrName = ( attr if attr not in self._presetNames else self._presetNames[attr] ) if attr in self._relativeWidgets: try: val = self._relativeValues[attr] except AttributeError: val = self.floatValForAttr(attr) else: val = getattr(self, attr) saveValueStore[presetAttrName] = val return saveValueStore def commandHelp(self): """Help text as string for this component's commandline arguments""" def command(self, arg=""): """ Configure a component using an arg from the commandline. This is never called if global args like 'preset=' are found in the arg. So simply check for any non-global args in your component and call super().command() at the end to get a Help message. """ print( self.__class__.name, "Usage:\n" "Open a preset for this component:\n" ' "preset=Preset Name"', ) self.commandHelp() quit(0) # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ # "Private" Methods # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ def _preUpdate(self): """Happens before subclass update()""" for attr in self._relativeWidgets: self.updateRelativeWidget(attr) def _userUpdate(self): """Happens after subclass update() for an undoable update by user.""" oldWidgetVals = { attr: copy(getattr(self, attr)) for attr in self._trackedWidgets } newWidgetVals = { attr: ( getWidgetValue(widget) if attr not in self._colorWidgets else rgbFromString(widget.text()) ) for attr, widget in self._trackedWidgets.items() } modifiedWidgets = { attr: val for attr, val in newWidgetVals.items() if val != oldWidgetVals[attr] } if modifiedWidgets: action = ComponentUpdate(self, oldWidgetVals, modifiedWidgets) self.parent.undoStack.push(action) def _autoUpdate(self): """Happens after subclass update() for an internal component update.""" newWidgetVals = { attr: getWidgetValue(widget) for attr, widget in self._trackedWidgets.items() } self.setAttrs(newWidgetVals) self._sendUpdateSignal() def setAttrs(self, attrDict): """ Sets attrs (linked to trackedWidgets) in this component to the values in the attrDict. Mutates certain widget values if needed """ for attr, val in attrDict.items(): if attr in self._colorWidgets: # Color Widgets must have a tuple & have a button to update if type(val) is tuple: rgbTuple = val else: rgbTuple = rgbFromString(val) btnStyle = ( "QPushButton { background-color : %s; outline: none; }" % QColor(*rgbTuple).name() ) self._colorWidgets[attr].setStyleSheet(btnStyle) setattr(self, attr, rgbTuple) else: # Normal tracked widget setattr(self, attr, val) log.verbose("Setting %s self.%s to %s" % (self.__class__.name, attr, val)) def setWidgetValues(self, attrDict): """ Sets widgets defined by keys in trackedWidgets in this preset to the values in the attrDict. """ affectedWidgets = [self._trackedWidgets[attr] for attr in attrDict] with blockSignals(affectedWidgets): for attr, val in attrDict.items(): widget = self._trackedWidgets[attr] if attr in self._colorWidgets: val = "%s,%s,%s" % val setWidgetValue(widget, val) def _sendUpdateSignal(self): if not self.core.openingProject: self.parent.drawPreview() saveValueStore = self.savePreset() saveValueStore["preset"] = self.currentPreset self.modified.emit(self.compPos, saveValueStore) def trackWidgets(self, trackDict, **kwargs): """ Name widgets to track in update(), savePreset(), loadPreset(), and command(). Requires a dict of attr names as keys, widgets as values Optional args: 'presetNames': preset variable names to replace attr names 'commandArgs': arg keywords that differ from attr names 'colorWidgets': identify attr as RGB tuple & update button CSS 'relativeWidgets': change value proportionally to resolution NOTE: Any kwarg key set to None will selectively disable tracking. """ self._trackedWidgets = trackDict for kwarg in kwargs: try: if kwarg in ( "presetNames", "commandArgs", "colorWidgets", "relativeWidgets", ): setattr(self, "_{}".format(kwarg), kwargs[kwarg]) else: raise ComponentError(self, "Nonsensical keywords to trackWidgets.") except ComponentError: continue if kwarg == "colorWidgets": def makeColorFunc(attr): def pickColor_(): self.mergeUndo = False self.pickColor( self._trackedWidgets[attr], self._colorWidgets[attr], ) self.mergeUndo = True return pickColor_ self._colorFuncs = {attr: makeColorFunc(attr) for attr in kwargs[kwarg]} for attr, func in self._colorFuncs.items(): colorText = self._trackedWidgets[attr].text() if colorText == "": rndColor = randomColor() self._trackedWidgets[attr].setText(str(rndColor)[1:-1]) self._colorWidgets[attr].clicked.connect(func) self._colorWidgets[attr].setStyleSheet( "QPushButton {" "background-color : %s; outline: none; }" % QColor( *rgbFromString(colorText) if colorText else rndColor ).name() ) if kwarg == "relativeWidgets": # store maximum values of spinBoxes to be scaled appropriately for attr in kwargs[kwarg]: self._relativeMaximums[attr] = self._trackedWidgets[attr].maximum() self.updateRelativeWidgetMaximum(attr) setattr(self, attr, getWidgetValue(self._trackedWidgets[attr])) self._preUpdate() self._autoUpdate() def pickColor(self, textWidget, button): """Use color picker to get color input from the user.""" dialog = QtWidgets.QColorDialog() # TODO alpha channel is not actually shown in most color picker widgets? dialog.setOption( QtWidgets.QColorDialog.ColorDialogOption.ShowAlphaChannel, True ) color = dialog.getColor() if color.isValid(): RGBstring = "%s,%s,%s" % ( str(color.red()), str(color.green()), str(color.blue()), ) btnStyle = ( "QPushButton{background-color: %s; outline: none;}" % color.name() ) textWidget.setText(RGBstring) button.setStyleSheet(btnStyle) def lockProperties(self, propList): self._lockedProperties = propList def lockError(self, msg): self._lockedError = msg def lockSize(self, w, h): self._lockedSize = (w, h) def unlockProperties(self): self._lockedProperties = None def unlockError(self): self._lockedError = None def unlockSize(self): self._lockedSize = None def loadUi(self, filename): """Load a Qt Designer ui file to use for this component's widget""" return uic.loadUi(os.path.join(self.core.componentsPath, filename)) @property def width(self): if self._lockedSize is None: return int(self.settings.value("outputWidth")) else: return self._lockedSize[0] @property def height(self): if self._lockedSize is None: return int(self.settings.value("outputHeight")) else: return self._lockedSize[1] def cancel(self): """Stop any lengthy process in response to this variable.""" self.canceled = True def reset(self): self.canceled = False self.unlockProperties() self.unlockError() def relativeWidgetAxis(func): def relativeWidgetAxis(self, attr, *args, **kwargs): hasVerticalWords = ( lambda attr: "height" in attr.lower() or "ypos" in attr.lower() or attr == "y" ) if "axis" not in kwargs: axis = self.width if hasVerticalWords(attr): axis = self.height kwargs["axis"] = axis if "axis" in kwargs and type(kwargs["axis"]) is tuple: axis = kwargs["axis"][0] if hasVerticalWords(attr): axis = kwargs["axis"][1] kwargs["axis"] = axis return func(self, attr, *args, **kwargs) return relativeWidgetAxis @relativeWidgetAxis def pixelValForAttr(self, attr, val=None, **kwargs): if val is None: val = self._relativeValues[attr] if val > 50.0: log.warning( "%s #%s attempted to set %s to dangerously high number %s", self.__class__.name, self.compPos, attr, val, ) val = 50.0 result = math.ceil(kwargs["axis"] * val) log.verbose( "Converting %s: f%s to px%s using axis %s", attr, val, result, kwargs["axis"], ) return result @relativeWidgetAxis def floatValForAttr(self, attr, val=None, **kwargs): if val is None: val = self._trackedWidgets[attr].value() return val / kwargs["axis"] def setRelativeWidget(self, attr, floatVal): """Set a relative widget using a float""" pixelVal = self.pixelValForAttr(attr, floatVal) with blockSignals(self._trackedWidgets[attr]): self._trackedWidgets[attr].setValue(pixelVal) self.update(auto=True) def getOldAttr(self, attr): """ Returns previous state of this attr. Used to determine whether a relative widget must be updated. Required because undoing/redoing can make determining the 'previous' value tricky. """ if self.oldAttrs is not None: return self.oldAttrs[attr] else: try: return getattr(self, attr) except AttributeError: log.error("Using visible values instead of oldAttrs") return self._trackedWidgets[attr].value() def updateRelativeWidget(self, attr): """Called by _preUpdate() for each relativeWidget before each update""" oldUserValue = self.getOldAttr(attr) newUserValue = self._trackedWidgets[attr].value() newRelativeVal = self.floatValForAttr(attr, newUserValue) if attr in self._relativeValues: oldRelativeVal = self._relativeValues[attr] if oldUserValue == newUserValue and oldRelativeVal != newRelativeVal: # Float changed without pixel value changing, which # means the pixel value needs to be updated # TODO QDoubleSpinBox doesn't work with relativeWidgets because of this log.debug( "Updating %s #%s's relative widget: %s", self.__class__.name, self.compPos, attr, ) with blockSignals(self._trackedWidgets[attr]): self.updateRelativeWidgetMaximum(attr) pixelVal = self.pixelValForAttr(attr, oldRelativeVal) self._trackedWidgets[attr].setValue(pixelVal) if attr not in self._relativeValues or oldUserValue != newUserValue: self._relativeValues[attr] = newRelativeVal def updateRelativeWidgetMaximum(self, attr): maxRes = int(self.core.resolutions[0].split("x")[0]) newMaximumValue = self.width * (self._relativeMaximums[attr] / maxRes) self._trackedWidgets[attr].setMaximum(int(newMaximumValue)) djfun-audio-visualizer-python-f03a3a6/src/avp/libcomponent/exceptions.py000066400000000000000000000037041514343513600266500ustar00rootroot00000000000000import time import sys import logging from ..toolkit import formatTraceback log = logging.getLogger("AVP.ComponentHandler") class ComponentError(RuntimeError): """Gives the MainWindow a traceback to display, and cancels the export.""" prevErrors = [] lastTime = time.time() def __init__(self, caller, name, msg=None): if msg is None and sys.exc_info()[0] is not None: msg = str(sys.exc_info()[1]) else: msg = "Unknown error." log.error("ComponentError by %s's %s: %s" % (caller.name, name, msg)) # Don't create multiple windows for quickly repeated messages if len(ComponentError.prevErrors) > 1: ComponentError.prevErrors.pop() ComponentError.prevErrors.insert(0, name) curTime = time.time() if ( name in ComponentError.prevErrors[1:] and curTime - ComponentError.lastTime < 1.0 ): return ComponentError.lastTime = time.time() if sys.exc_info()[0] is not None: string = "%s component (#%s): %s encountered %s %s: %s" % ( caller.__class__.name, str(caller.compPos), name, ( "an" if any( [ sys.exc_info()[0].__name__.startswith(vowel) for vowel in ("A", "I", "U", "O", "E") ] ) else "a" ), sys.exc_info()[0].__name__, str(sys.exc_info()[1]), ) detail = formatTraceback(sys.exc_info()[2]) else: string = name detail = "Attributes:\n%s" % ( "\n".join([m for m in dir(caller) if not m.startswith("_")]) ) super().__init__(string) caller.lockError(string) caller._error.emit(string, detail) djfun-audio-visualizer-python-f03a3a6/src/avp/libcomponent/metaclass.py000066400000000000000000000207501514343513600264430ustar00rootroot00000000000000import os import logging from PyQt6 import QtCore from .exceptions import ComponentError from ..toolkit import connectWidget from ..toolkit.frame import BlankFrame log = logging.getLogger("AVP.ComponentHandler") class ComponentMetaclass(type(QtCore.QObject)): """ Checks the validity of each Component class and mutates some attrs. E.g., takes only major version from version string & decorates methods """ def initializationWrapper(func): def initializationWrapper(self, *args, **kwargs): try: return func(self, *args, **kwargs) except Exception: try: raise ComponentError(self, "initialization process") except ComponentError: return return initializationWrapper def renderWrapper(func): def renderWrapper(self, *args, **kwargs): try: log.verbose( "### %s #%s renders a preview frame ###", self.__class__.name, str(self.compPos), ) return func(self, *args, **kwargs) except Exception as e: try: if e.__class__.__name__.startswith("Component"): raise else: raise ComponentError(self, "renderer") except ComponentError: return BlankFrame() return renderWrapper def commandWrapper(func): """Intercepts the command() method to check for global args""" def commandWrapper(self, arg): if arg.startswith("preset="): _, preset = arg.split("=", 1) path = os.path.join(self.core.getPresetDir(self), preset) if not os.path.exists(path): print('Couldn\'t locate preset "%s"' % preset) quit(1) else: print('Opening "%s" preset on layer %s' % (preset, self.compPos)) self.core.openPreset(path, self.compPos, preset) # Don't call the component's command() method return else: return func(self, arg) return commandWrapper def propertiesWrapper(func): """Intercepts the usual properties if the properties are locked.""" def propertiesWrapper(self): if self._lockedProperties is not None: return self._lockedProperties else: try: return func(self) except Exception: try: raise ComponentError(self, "properties") except ComponentError: return [] return propertiesWrapper def errorWrapper(func): """Intercepts the usual error message if it is locked.""" def errorWrapper(self): if self._lockedError is not None: return self._lockedError else: return func(self) return errorWrapper def loadPresetWrapper(func): """Wraps loadPreset to handle the self.openingPreset boolean""" class openingPreset: def __init__(self, comp): self.comp = comp def __enter__(self): self.comp.openingPreset = True def __exit__(self, *args): self.comp.openingPreset = False def presetWrapper(self, *args): with openingPreset(self): try: return func(self, *args) except Exception: try: raise ComponentError(self, "preset loader") except ComponentError: return return presetWrapper def updateWrapper(func): """ Calls _preUpdate before every subclass update(). Afterwards, for non-user updates, calls _autoUpdate(). For undoable updates triggered by the user, calls _userUpdate() """ class wrap: def __init__(self, comp, auto): self.comp = comp self.auto = auto def __enter__(self): self.comp._preUpdate() def __exit__(self, *args): if ( self.auto or self.comp.openingPreset or not hasattr(self.comp.parent, "undoStack") ): log.verbose("Automatic update") self.comp._autoUpdate() else: log.verbose("User update") self.comp._userUpdate() def updateWrapper(self, **kwargs): auto = kwargs["auto"] if "auto" in kwargs else False with wrap(self, auto): try: return func(self) except Exception: try: raise ComponentError(self, "update method") except ComponentError: return return updateWrapper def widgetWrapper(func): """Connects all widgets to update method after the subclass's method""" class wrap: def __init__(self, comp): self.comp = comp def __enter__(self): pass def __exit__(self, *args): for widgetList in self.comp._allWidgets.values(): for widget in widgetList: log.verbose("Connecting %s", str(widget.__class__.__name__)) connectWidget(widget, self.comp.update) def widgetWrapper(self, *args, **kwargs): auto = kwargs["auto"] if "auto" in kwargs else False with wrap(self): try: return func(self, *args, **kwargs) except Exception: try: raise ComponentError(self, "widget creation") except ComponentError: return return widgetWrapper def __new__(cls, name, parents, attrs): if "ui" not in attrs: # Use module name as ui filename by default attrs["ui"] = ( "%s.ui" % os.path.splitext(attrs["__module__"].split(".")[-1])[0] ) decorate = ( "names", # Class methods "error", "audio", "properties", # Properties "preFrameRender", "previewRender", "loadPreset", "command", "update", "widget", ) # Auto-decorate methods for key in decorate: if key not in attrs: continue if key in ("names"): attrs[key] = classmethod(attrs[key]) elif key in ("audio"): attrs[key] = property(attrs[key]) elif key == "command": attrs[key] = cls.commandWrapper(attrs[key]) elif key == "previewRender": attrs[key] = cls.renderWrapper(attrs[key]) elif key == "preFrameRender": attrs[key] = cls.initializationWrapper(attrs[key]) elif key == "properties": attrs[key] = cls.propertiesWrapper(attrs[key]) elif key == "error": attrs[key] = cls.errorWrapper(attrs[key]) elif key == "loadPreset": attrs[key] = cls.loadPresetWrapper(attrs[key]) elif key == "update": attrs[key] = cls.updateWrapper(attrs[key]) elif key == "widget" and parents[0] != QtCore.QObject: attrs[key] = cls.widgetWrapper(attrs[key]) # Turn version string into a number try: if "version" not in attrs: log.error( "No version attribute in %s. Defaulting to 1", attrs["name"], ) attrs["version"] = 1 else: attrs["version"] = int(attrs["version"].split(".")[0]) except ValueError: log.critical( "%s component has an invalid version string:\n%s", attrs["name"], str(attrs["version"]), ) except KeyError: log.critical("%s component has no version string.", attrs["name"]) else: return super().__new__(cls, name, parents, attrs) quit(1) djfun-audio-visualizer-python-f03a3a6/src/avp/toolkit/000077500000000000000000000000001514343513600231055ustar00rootroot00000000000000djfun-audio-visualizer-python-f03a3a6/src/avp/toolkit/__init__.py000066400000000000000000000000261514343513600252140ustar00rootroot00000000000000from .common import * djfun-audio-visualizer-python-f03a3a6/src/avp/toolkit/common.py000066400000000000000000000134701514343513600247540ustar00rootroot00000000000000""" Common functions """ from PyQt6 import QtWidgets import string import random import sys import subprocess import logging from copy import copy from collections import OrderedDict log = logging.getLogger("AVP.Toolkit.Common") class blockSignals: """ Context manager to temporarily block list of QtWidgets from updating, and guarantee restoring the previous state afterwards. """ def __init__(self, widgets): if type(widgets) is dict: self.widgets = concatDictVals(widgets) else: self.widgets = widgets if hasattr(widgets, "__iter__") else [widgets] def __enter__(self): log.verbose( "Blocking signals for %s", ", ".join([str(w.__class__.__name__) for w in self.widgets]), ) self.oldStates = [w.signalsBlocked() for w in self.widgets] for w in self.widgets: w.blockSignals(True) def __exit__(self, *args): log.verbose("Resetting blockSignals to %s", str(bool(sum(self.oldStates)))) for w, state in zip(self.widgets, self.oldStates): w.blockSignals(state) def concatDictVals(d): """Concatenates all values in given dict into one list.""" key, value = d.popitem() d[key] = value final = copy(value) if type(final) is not list: final = [final] final.extend([val for val in d.values()]) else: value.extend([item for val in d.values() for item in val]) return final def badName(name): """Returns whether a name contains non-alphanumeric chars""" return any([letter in string.punctuation for letter in name]) def alphabetizeDict(dictionary): """Alphabetizes a dict into OrderedDict""" return OrderedDict(sorted(dictionary.items(), key=lambda t: t[0])) def presetToString(dictionary): """Returns string repr of a preset""" return repr(alphabetizeDict(dictionary)) def presetFromString(string): """Turns a string repr of OrderedDict into a regular dict""" return dict(eval(string)) def appendUppercase(lst): for form, i in zip(lst, range(len(lst))): lst.append(form.upper()) return lst def pipeWrapper(func): """A decorator to insert proper kwargs into Popen objects.""" def pipeWrapper(commandList, **kwargs): if sys.platform == "win32": # Stop CMD window from appearing on Windows startupinfo = subprocess.STARTUPINFO() startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW kwargs["startupinfo"] = startupinfo if "bufsize" not in kwargs: kwargs["bufsize"] = 10**8 if "stdin" not in kwargs: kwargs["stdin"] = subprocess.DEVNULL return func(commandList, **kwargs) return pipeWrapper @pipeWrapper def checkOutput(commandList, **kwargs): return subprocess.check_output(commandList, **kwargs) def disableWhenEncoding(func): def decorator(self, *args, **kwargs): if self.encoding: return else: return func(self, *args, **kwargs) return decorator def disableWhenOpeningProject(func): def decorator(self, *args, **kwargs): if self.core.openingProject: return else: return func(self, *args, **kwargs) return decorator def rgbFromString(string): """Turns an RGB string like "255, 255, 255" into a tuple""" try: tup = tuple([int(i) for i in string.split(",")]) if len(tup) != 3: raise ValueError for i in tup: if i > 255 or i < 0: raise ValueError return tup except Exception as e: log.warning( "Could not parse '%s' as a color (encountered %s).", string, type(e).__name__, ) return (255, 255, 255) def formatTraceback(tb=None): import traceback if tb is None: import sys tb = sys.exc_info()[2] return "Traceback:\n%s" % "\n".join(traceback.format_tb(tb)) def connectWidget(widget, func): unsupportedWidgets = ["QtWidgets.QFontComboBox"] if type(widget) == QtWidgets.QLineEdit: widget.textChanged.connect(func) elif type(widget) == QtWidgets.QSpinBox or type(widget) == QtWidgets.QDoubleSpinBox: widget.valueChanged.connect(func) elif type(widget) == QtWidgets.QCheckBox: widget.stateChanged.connect(func) elif type(widget) == QtWidgets.QComboBox: widget.currentIndexChanged.connect(func) elif type(widget) in unsupportedWidgets: log.info( "Could not connect %s using connectWidget()", str(widget.__class__.__name__) ) else: log.warning("Failed to connect %s ", str(widget.__class__.__name__)) return False return True def setWidgetValue(widget, val): """Generic setValue method for use with any typical QtWidget""" log.verbose("Setting %s to %s" % (str(widget.__class__.__name__), val)) if type(widget) == QtWidgets.QLineEdit: widget.setText(val) elif type(widget) == QtWidgets.QSpinBox or type(widget) == QtWidgets.QDoubleSpinBox: widget.setValue(val) elif type(widget) == QtWidgets.QCheckBox: widget.setChecked(val) elif type(widget) == QtWidgets.QComboBox: widget.setCurrentIndex(val) else: log.warning("Failed to set %s ", str(widget.__class__.__name__)) return False return True def getWidgetValue(widget): if type(widget) == QtWidgets.QLineEdit: return widget.text() elif type(widget) == QtWidgets.QSpinBox or type(widget) == QtWidgets.QDoubleSpinBox: return widget.value() elif type(widget) == QtWidgets.QCheckBox: return widget.isChecked() elif type(widget) == QtWidgets.QComboBox: return widget.currentIndex() def randomColor(): return (random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)) djfun-audio-visualizer-python-f03a3a6/src/avp/toolkit/ffmpeg.py000066400000000000000000000406461514343513600247350ustar00rootroot00000000000000""" Tools for using ffmpeg """ import numpy import sys import os import subprocess import threading import signal from queue import PriorityQueue import logging from ..core import Core from .common import checkOutput, pipeWrapper log = logging.getLogger("AVP.Toolkit.Ffmpeg") class FfmpegVideo: """Opens an input pipe to ffmpeg and stores a buffer of raw video frames.""" # error from the thread used to fill the buffer threadError = None def __init__(self, **kwargs): mandatoryArgs = [ "inputPath", "filter_", "width", "height", "frameRate", # frames per second "chunkSize", # number of bytes in one frame "parent", # mainwindow object "component", # component object ] for arg in mandatoryArgs: setattr(self, arg, kwargs[arg]) self.frameNo = -1 self.currentFrame = "None" self.map_ = None if "loopVideo" in kwargs and kwargs["loopVideo"]: self.loopValue = "-1" else: self.loopValue = "0" if "filter_" in kwargs: if kwargs["filter_"][0] != "-filter_complex": kwargs["filter_"].insert(0, "-filter_complex") else: kwargs["filter_"] = None self.command = [ Core.FFMPEG_BIN, "-thread_queue_size", "512", "-r", str(self.frameRate), "-stream_loop", str(self.loopValue), "-i", self.inputPath, "-f", "image2pipe", "-pix_fmt", "rgba", ] if type(kwargs["filter_"]) is list: self.command.extend(kwargs["filter_"]) self.command.extend( [ "-codec:v", "rawvideo", "-", ] ) self.frameBuffer = PriorityQueue() self.frameBuffer.maxsize = self.frameRate self.finishedFrames = {} self.thread = threading.Thread( target=self.fillBuffer, name="FFmpeg Frame-Fetcher" ) self.thread.daemon = True self.thread.start() def frame(self, num): while True: if num in self.finishedFrames: image = self.finishedFrames.pop(num) return image i, image = self.frameBuffer.get() self.finishedFrames[i] = image self.frameBuffer.task_done() def fillBuffer(self): from ..libcomponent import ComponentError if Core.logEnabled: logFilename = os.path.join( Core.logDir, "render_%s.log" % str(self.component.compPos) ) log.debug("Creating ffmpeg process (log at %s)", logFilename) with open(logFilename, "w") as logf: logf.write(" ".join(self.command) + "\n\n") with open(logFilename, "a") as logf: self.pipe = openPipe( self.command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=logf, bufsize=10**8, ) else: self.pipe = openPipe( self.command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, bufsize=10**8, ) while True: if self.parent.canceled: break self.frameNo += 1 # If we run out of frames, use the last good frame and loop. try: if len(self.currentFrame) == 0: self.frameBuffer.put((self.frameNo - 1, self.lastFrame)) continue except AttributeError: FfmpegVideo.threadError = ComponentError( self.component, "video", "Video seemed playable but wasn't.", ) break try: self.currentFrame = self.pipe.stdout.read(self.chunkSize) except ValueError as e: if str(e) == "PyMemoryView_FromBuffer(): info->buf must not be NULL": log.debug( "Ignored 'info->buf must not be NULL' error from FFmpeg pipe" ) return else: FfmpegVideo.threadError = ComponentError(self.component, "video") if len(self.currentFrame) != 0: self.frameBuffer.put((self.frameNo, self.currentFrame)) self.lastFrame = self.currentFrame @pipeWrapper def openPipe(commandList, **kwargs): return subprocess.Popen(commandList, **kwargs) def closePipe(pipe): pipe.stdout.close() pipe.send_signal(signal.SIGTERM) def findFfmpeg(): if sys.platform == "win32": bin = "ffmpeg.exe" else: bin = "ffmpeg" if getattr(sys, "frozen", False): # The application is frozen bin = os.path.join(Core.wd, bin) with open(os.devnull, "w") as f: try: checkOutput([bin, "-version"], stderr=f) except (subprocess.CalledProcessError, FileNotFoundError): bin = "" return bin def createFfmpegCommand( inputFile, outputFile, components, duration=-1, logLevel="info" ): """ Constructs the major ffmpeg command used to export the video """ if duration == -1: duration = getAudioDuration(inputFile) safeDuration = "{0:.3f}".format(duration - 0.05) # used by filters duration = "{0:.3f}".format(duration + 0.1) # used by input sources # Test if user has libfdk_aac encoders = checkOutput("%s -encoders -hide_banner" % Core.FFMPEG_BIN, shell=True) encoders = encoders.decode("utf-8") acodec = Core.settings.value("outputAudioCodec") options = Core.encoderOptions containerName = Core.settings.value("outputContainer") vcodec = Core.settings.value("outputVideoCodec") vbitrate = str(Core.settings.value("outputVideoBitrate")) + "k" acodec = Core.settings.value("outputAudioCodec") abitrate = str(Core.settings.value("outputAudioBitrate")) + "k" for cont in options["containers"]: if cont["name"] == containerName: container = cont["container"] break vencoders = options["video-codecs"][vcodec] aencoders = options["audio-codecs"][acodec] def error(): nonlocal encoders, encoder log.critical( "Selected encoder (%s) is not supported by Ffmpeg. The supported encoders are: %s", encoder, encoders, ) return [] for encoder in vencoders: if encoder in encoders: vencoder = encoder break else: return error() for encoder in aencoders: if encoder in encoders: aencoder = encoder break else: return error() ffmpegCommand = [ Core.FFMPEG_BIN, "-loglevel", logLevel, "-thread_queue_size", "512", "-y", # overwrite the output file if it already exists. # INPUT VIDEO "-f", "rawvideo", "-vcodec", "rawvideo", "-s", f'{Core.settings.value("outputWidth")}x{Core.settings.value("outputHeight")}', "-pix_fmt", "rgba", "-r", str(Core.settings.value("outputFrameRate")), "-t", duration, "-an", # the video input has no sound "-i", "-", # the video input comes from a pipe # INPUT SOUND "-t", duration, "-i", inputFile, ] extraAudio = [comp.audio for comp in components if "audio" in comp.properties()] segment = createAudioFilterCommand(extraAudio, safeDuration) ffmpegCommand.extend(segment) # Map audio from the filters or the single audio input, and map video from the pipe ffmpegCommand.extend( [ "-map", "0:v", "-map", "[a]" if segment else "1:a", ] ) ffmpegCommand.extend( [ # OUTPUT "-vcodec", vencoder, "-acodec", aencoder, "-b:v", vbitrate, "-b:a", abitrate, "-pix_fmt", Core.settings.value("outputVideoFormat"), "-preset", Core.settings.value("outputPreset"), "-f", container, ] ) if acodec == "aac": ffmpegCommand.append("-strict") ffmpegCommand.append("-2") ffmpegCommand.append(outputFile) return ffmpegCommand def createAudioFilterCommand(extraAudio, duration): """Add extra inputs and any needed filters to the main ffmpeg command.""" # NOTE: Global filters are currently hard-coded here for debugging use globalFilters = 0 # increase to add global filters if not extraAudio and not globalFilters: return [] ffmpegCommand = [] # Add -i options for extra input files extraFilters = {} for streamNo, params in enumerate(reversed(extraAudio)): extraInputFile, params = params ffmpegCommand.extend( [ "-t", duration, # Tell ffmpeg about shorter clips (seemingly not needed) # streamDuration = getAudioDuration(extraInputFile) # if streamDuration and streamDuration > float(safeDuration) # else "{0:.3f}".format(streamDuration), "-i", extraInputFile, ] ) # Construct dataset of extra filters we'll need to add later for ffmpegFilter in params: if streamNo + 2 not in extraFilters: extraFilters[streamNo + 2] = [] extraFilters[streamNo + 2].append((ffmpegFilter, params[ffmpegFilter])) # Start creating avfilters! Popen-style, so don't use semicolons; extraFilterCommand = [] if globalFilters <= 0: # Dictionary of last-used tmp labels for a given stream number tmpInputs = {streamNo: -1 for streamNo in extraFilters} else: # Insert blank entries for global filters into extraFilters # so the per-stream filters know what input to source later for streamNo in range(len(extraAudio), 0, -1): if streamNo + 1 not in extraFilters: extraFilters[streamNo + 1] = [] # Also filter the primary audio track extraFilters[1] = [] tmpInputs = {streamNo: globalFilters - 1 for streamNo in extraFilters} # Add the global filters! # NOTE: list length must = globalFilters, currently hardcoded if tmpInputs: extraFilterCommand.extend( [ "[%s:a] ashowinfo [%stmp0]" % (str(streamNo), str(streamNo)) for streamNo in tmpInputs ] ) # Now add the per-stream filters! for streamNo, paramList in extraFilters.items(): for param in paramList: source = ( "[%s:a]" % str(streamNo) if tmpInputs[streamNo] == -1 else "[%stmp%s]" % (str(streamNo), str(tmpInputs[streamNo])) ) tmpInputs[streamNo] = tmpInputs[streamNo] + 1 extraFilterCommand.append( "%s %s%s [%stmp%s]" % ( source, param[0], param[1], str(streamNo), str(tmpInputs[streamNo]), ) ) # Join all the filters together and combine into 1 stream extraFilterCommand = "; ".join(extraFilterCommand) + "; " if tmpInputs else "" ffmpegCommand.extend( [ "-filter_complex", extraFilterCommand + "%s amix=inputs=%s:duration=first [a]" % ( "".join( [ ( "[%stmp%s]" % (str(i), tmpInputs[i]) if i in extraFilters else "[%s:a]" % str(i) ) for i in range(1, len(extraAudio) + 2) ] ), str(len(extraAudio) + 1), ), ] ) return ffmpegCommand def testAudioStream(filename): """Test if an audio stream definitely exists""" audioTestCommand = [ Core.FFMPEG_BIN, "-i", filename, "-vn", "-f", "null", "-", ] try: checkOutput(audioTestCommand, stderr=subprocess.DEVNULL) except subprocess.CalledProcessError: return False else: return True def getAudioDuration(filename): """Try to get duration of audio file as float, or False if not possible""" command = [Core.FFMPEG_BIN, "-i", filename] try: fileInfo = checkOutput(command, stderr=subprocess.STDOUT) except subprocess.CalledProcessError as ex: fileInfo = ex.output except (FileNotFoundError, PermissionError): # ffmpeg is possibly not installed return False try: info = fileInfo.decode("utf-8").split("\n") except UnicodeDecodeError as e: log.error("Unicode error:", str(e)) return False for line in info: if "Duration" in line: d = line.split(",")[0] d = d.split(" ")[3] d = d.split(":") duration = float(d[0]) * 3600 + float(d[1]) * 60 + float(d[2]) break else: # String not found in output return False return duration def readAudioFile(filename, videoWorker): """ Creates the completeAudioArray given to components and used to draw the classic visualizer. """ duration = getAudioDuration(filename) if not duration: log.error(f"Audio file {filename} doesn't exist or unreadable.") return command = [ Core.FFMPEG_BIN, "-i", filename, "-f", "s16le", "-acodec", "pcm_s16le", "-ar", "44100", # ouput will have 44100 Hz "-ac", "1", # mono (set to '2' for stereo) "-", ] in_pipe = openPipe( command, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, bufsize=10**8, ) completeAudioArray = numpy.empty(0, dtype="int16") progress = 0 lastPercent = None while True: if Core.canceled: return # read 2 seconds of audio progress += 4 raw_audio = in_pipe.stdout.read(88200 * 4) if len(raw_audio) == 0: break audio_array = numpy.frombuffer(raw_audio, dtype="int16") completeAudioArray = numpy.append(completeAudioArray, audio_array) percent = int(100 * (progress / duration)) if percent >= 100: percent = 100 if lastPercent != percent: string = "Loading audio file: " + str(percent) + "%" videoWorker.progressBarSetText.emit(string) videoWorker.progressBarUpdate.emit(percent) lastPercent = percent in_pipe.kill() in_pipe.wait() # add 0s the end completeAudioArrayCopy = numpy.zeros(len(completeAudioArray) + 44100, dtype="int16") completeAudioArrayCopy[: len(completeAudioArray)] = completeAudioArray completeAudioArray = completeAudioArrayCopy return (completeAudioArray, duration) def exampleSound(style="white", extra="apulsator=offset_l=0.35:offset_r=0.67"): """Help generate an example sound for use in creating a preview""" if style == "white": src = "-2+random(0)" elif style == "freq": src = "sin(1000*t*PI*t)" elif style == "wave": src = "sin(random(0)*2*PI*t)*tan(random(0)*2*PI*t)" elif style == "stereo": src = "0.1*sin(2*PI*(360-2.5/2)*t) | 0.1*sin(2*PI*(360+2.5/2)*t)" return "aevalsrc='%s', %s%s" % (src, extra, ", " if extra else "") def checkFfmpegVersion(): try: with open(os.devnull, "w") as f: ffmpegVers = checkOutput([Core.FFMPEG_BIN, "-version"], stderr=f) ffmpegVers = str(ffmpegVers).split()[2].split(".", 1)[0] if ffmpegVers.startswith("n"): ffmpegVers = ffmpegVers[1:] versionNum = int(ffmpegVers) goodVersion = versionNum > 3 except Exception: versionNum = -1 goodVersion = False return goodVersion, versionNum djfun-audio-visualizer-python-f03a3a6/src/avp/toolkit/frame.py000066400000000000000000000061471514343513600245610ustar00rootroot00000000000000""" Common tools for drawing compatible frames in a Component's frameRender() """ from PyQt6 import QtGui from PIL import Image, ImageEnhance, ImageChops, ImageFilter from PIL.ImageQt import ImageQt from PyQt6 import QtCore import sys import os import math import logging from .. import core log = logging.getLogger("AVP.Toolkit.Frame") class FramePainter(QtGui.QPainter): """ A QPainter for a blank frame, which can be converted into a Pillow image with finalize() """ def __init__(self, width, height): image = BlankFrame(width, height) log.debug("Creating QImage from PIL image object") self.image = ImageQt(image) super().__init__(self.image) def setPen(self, penStyle): if type(penStyle) is tuple: super().setPen(QtGui.QColor(*penStyle)) else: super().setPen(penStyle) def finalize(self): log.verbose("Finalizing FramePainter") buffer = QtCore.QBuffer() buffer.open(QtCore.QBuffer.OpenModeFlag.ReadWrite) self.image.save(buffer, "PNG") import io frame = Image.open(io.BytesIO(buffer.data())) buffer.close() self.end() return frame def addShadow(frame, blurRadius, blurOffsetX, blurOffsetY): shadImg = ImageEnhance.Contrast(frame).enhance(0.0) shadImg = shadImg.filter(ImageFilter.GaussianBlur(blurRadius)) frame = shadImg.paste(frame, box=(-blurOffsetX, -blurOffsetY), mask=frame) frame = shadImg return frame def scale(scalePercent, width, height, returntype=None): width = (float(width) / 100.0) * float(scalePercent) height = (float(height) / 100.0) * float(scalePercent) if returntype == str: return (str(math.ceil(width)), str(math.ceil(height))) elif returntype == int: return (math.ceil(width), math.ceil(height)) else: return (width, height) def defaultSize(framefunc): """Makes width/height arguments optional""" def decorator(*args): if len(args) < 2: newArgs = list(args) if len(args) == 0 or len(args) == 1: height = int(core.Core.settings.value("outputHeight")) newArgs.append(height) if len(args) == 0: width = int(core.Core.settings.value("outputWidth")) newArgs.insert(0, width) args = tuple(newArgs) return framefunc(*args) return decorator def FloodFrame(width, height, RgbaTuple): return Image.new("RGBA", (width, height), RgbaTuple) @defaultSize def BlankFrame(width, height): """The base frame used by each component to start drawing.""" return FloodFrame(width, height, (0, 0, 0, 0)) @defaultSize def Checkerboard(width, height): """ A checkerboard to represent transparency to the user. """ # TODO: Would be cool to generate this image with numpy instead. log.debug("Creating new %s*%s checkerboard" % (width, height)) image = FloodFrame(1920, 1080, (0, 0, 0, 0)) image.paste(Image.open(os.path.join(core.Core.wd, "gui", "background.png")), (0, 0)) image = image.resize((width, height)) return image djfun-audio-visualizer-python-f03a3a6/src/avp/toolkit/visualizer.py000066400000000000000000000046071514343513600256630ustar00rootroot00000000000000"""Functions used to transform and manipulate audio for use by visualizers""" from copy import copy import numpy def createSpectrumArray( component, completeAudioArray, sampleSize, smoothConstantDown, smoothConstantUp, scale, progressBarUpdate, progressBarSetText, ): lastProgress = 0 lastSpectrum = None spectrumArray = {} for i in range(0, len(completeAudioArray), sampleSize): if component.canceled: break lastSpectrum = transformData( i, completeAudioArray, sampleSize, smoothConstantDown, smoothConstantUp, lastSpectrum, scale, ) spectrumArray[i] = copy(lastSpectrum) progress = int(100 * (i / len(completeAudioArray))) if progress >= 100: progress = 100 if progress == lastProgress: continue progressText = f"Analyzing audio: {str(progress)}%" progressBarSetText.emit(progressText) progressBarUpdate.emit(int(progress)) lastProgress = progress return spectrumArray def transformData( i, completeAudioArray, sampleSize, smoothConstantDown, smoothConstantUp, lastSpectrum, scale, ): if len(completeAudioArray) < (i + sampleSize): sampleSize = len(completeAudioArray) - i window = numpy.hanning(sampleSize) data = completeAudioArray[i : i + sampleSize][::1] * window paddedSampleSize = 2048 paddedData = numpy.pad(data, (0, paddedSampleSize - sampleSize), "constant") spectrum = numpy.fft.fft(paddedData) sample_rate = 44100 frequencies = numpy.fft.fftfreq(len(spectrum), 1.0 / sample_rate) y = abs(spectrum[0 : int(paddedSampleSize / 2) - 1]) # filter the noise away # y[y<80] = 0 with numpy.errstate(divide="ignore"): y = scale * numpy.log10(y) y[numpy.isinf(y)] = 0 if lastSpectrum is not None: lastSpectrum[y < lastSpectrum] = y[ y < lastSpectrum ] * smoothConstantDown + lastSpectrum[y < lastSpectrum] * ( 1 - smoothConstantDown ) lastSpectrum[y >= lastSpectrum] = y[ y >= lastSpectrum ] * smoothConstantUp + lastSpectrum[y >= lastSpectrum] * (1 - smoothConstantUp) else: lastSpectrum = y x = frequencies[0 : int(paddedSampleSize / 2) - 1] return lastSpectrum djfun-audio-visualizer-python-f03a3a6/src/avp/video_thread.py000066400000000000000000000360111514343513600244300ustar00rootroot00000000000000""" Worker thread created to export a video. It has a slot to begin export using an input file, output path, and component list. Signals are emitted to update MainWindow's progress bar, detail text, and preview. A Command object takes the place of MainWindow while in commandline mode. Export can be cancelled with cancel() """ from PyQt6 import QtCore, QtGui from PyQt6.QtCore import pyqtSignal, pyqtSlot from PIL import Image from PIL.ImageQt import ImageQt import numpy import subprocess as sp import sys import os import signal import logging from .libcomponent import ComponentError from .toolkit import formatTraceback from .toolkit.ffmpeg import ( openPipe, readAudioFile, getAudioDuration, createFfmpegCommand, ) log = logging.getLogger("AVP.VideoThread") class Worker(QtCore.QObject): imageCreated = pyqtSignal("QImage") videoCreated = pyqtSignal() progressBarUpdate = pyqtSignal(int) progressBarSetText = pyqtSignal(str) encoding = pyqtSignal(bool) def __init__(self, parent, inputFile, outputFile, components): super().__init__() self.core = parent.core self.settings = parent.settings self.modules = parent.core.modules parent.createVideo.connect(self.createVideo) self.previewEnabled = type(parent.core).previewEnabled self.components = components self.outputFile = outputFile self.inputFile = inputFile self.hertz = 44100 self.sampleSize = 1470 # 44100 / 30 = 1470 self.canceled = False self.error = False def createFfmpegCommand(self, duration): try: ffmpegCommand = createFfmpegCommand( self.inputFile, self.outputFile, self.components, duration, "info" if log.getEffectiveLevel() < logging.WARNING else "error", ) except sp.CalledProcessError as e: # FIXME video_thread should own this error signal, not components self.components[0]._error.emit( "Ffmpeg could not be found. Is it installed?", str(e) ) self.error = True return if not ffmpegCommand: # FIXME video_thread should own this error signal, not components self.components[0]._error.emit( "The FFmpeg command could not be generated.", "" ) log.critical( "Cancelling render process due to failure while generating the ffmpeg command." ) self.failExport() return return ffmpegCommand def determineAudioLength(self): """ Returns audio length which determines length of final video, or False if failure occurs """ if any( [True if "pcm" in comp.properties() else False for comp in self.components] ): self.progressBarSetText.emit("Loading audio file...") audioFileTraits = readAudioFile(self.inputFile, self) if audioFileTraits is None: self.cancelExport() return False self.completeAudioArray, duration = audioFileTraits self.audioArrayLen = len(self.completeAudioArray) else: duration = getAudioDuration(self.inputFile) self.completeAudioArray = [] self.audioArrayLen = int( ((duration * self.hertz) + self.hertz) - self.sampleSize ) return duration def preFrameRender(self): """ Initializes components that need to pre-compute stuff. Also prerenders "static" components like text and merges them if possible """ self.staticComponents = {} self.compositeComponents = set() # Call preFrameRender on each component canceledByComponent = False initText = ", ".join( [ "%s) %s" % (num, str(component)) for num, component in enumerate(reversed(self.components)) ] ) print("Loaded Components:", initText) log.info("Calling preFrameRender for %s", initText) for compNo, comp in enumerate(reversed(self.components)): try: comp.preFrameRender( audioFile=self.inputFile, completeAudioArray=self.completeAudioArray, audioArrayLen=self.audioArrayLen, sampleSize=self.sampleSize, progressBarUpdate=self.progressBarUpdate, progressBarSetText=self.progressBarSetText, ) except ComponentError: log.warning( "#%s %s encountered an error in its preFrameRender method", compNo, comp, ) compProps = comp.properties() if "error" in compProps or comp._lockedError is not None: self.cancel() self.canceled = True canceledByComponent = True compError = ( comp.error() if type(comp.error()) is tuple else (comp.error(), "") ) errMsg = ( "Component #%s (%s) encountered an error!" % (str(compNo), comp.name) if comp.error() is None else "Export cancelled by component #%s (%s): %s" % (str(compNo), comp.name, compError[0]) ) log.error(errMsg) comp._error.emit(errMsg, compError[1]) break if "static" in compProps: log.info("Saving static frame from #%s %s", compNo, comp) self.staticComponents[compNo] = comp.frameRender(0).copy() elif compNo > 0 and "composite" in compProps: self.compositeComponents.add(compNo) # Check if any errors occured log.debug("Checking if a component wishes to cancel the export...") if self.canceled: if canceledByComponent: log.error( "Export cancelled by component #%s (%s): %s", compNo, comp.name, ( "No message." if comp.error() is None else ( comp.error() if type(comp.error()) is str else comp.error()[0] ) ), ) self.cancelExport() # Merge static frames that can be merged to reduce workload def mergeConsecutiveStaticComponentFrames(self): log.info("Merging consecutive static component frames") for compNo in range(len(self.components)): if ( compNo not in self.staticComponents or compNo + 1 not in self.staticComponents ): continue self.staticComponents[compNo + 1] = Image.alpha_composite( self.staticComponents.pop(compNo), self.staticComponents[compNo + 1], ) self.staticComponents[compNo] = None mergeConsecutiveStaticComponentFrames(self) def frameRender(self, audioI): """ Renders a frame composited together from the frames returned by each component audioI is a multiple of self.sampleSize, which can be divided to determine frameNo """ def err(): self.closePipe() self.cancelExport() self.error = True msg = f"{comp.name} renderFrame({int(audioI / self.sampleSize)}) raised an exception." tb = formatTraceback() details = f"{e.__class__.__name__}: {str(e)}\n\n{tb}" log.critical(f"{msg}\n{details}") comp._error.emit(msg, details) bgI = int(audioI / self.sampleSize) frame = None for layerNo, comp in enumerate(reversed((self.components))): if self.canceled: break try: if layerNo in self.staticComponents: if self.staticComponents[layerNo] is None: # this layer was merged into a following layer continue # static component if frame is None: # bottom-most layer frame = self.staticComponents[layerNo] else: frame = Image.alpha_composite( frame, self.staticComponents[layerNo] ) elif layerNo in self.compositeComponents: # component that uses previous frame to draw frame = Image.alpha_composite(frame, comp.frameRender(bgI, frame)) else: # animated component if frame is None: # bottom-most layer frame = comp.frameRender(bgI) else: frame = Image.alpha_composite(frame, comp.frameRender(bgI)) except Exception as e: err() return frame def showPreview(self, frame): """ Receives a final frame that will be piped to FFmpeg, adds it to the MainWindow for the live preview """ # We must store a reference to this QImage # or else Qt will garbage-collect it on the C++ side self.latestPreview = ImageQt(frame) self.imageCreated.emit(QtGui.QImage(self.latestPreview)) @pyqtSlot() def createVideo(self): """ 1. Determine length of final video 2. Call preFrameRender on each component 3. Create the main FFmpeg command 4. Open the out_pipe to FFmpeg process 5. Iterate over the audio data array and call frameRender on the components to get frames 6. Close the out_pipe 7. Call postFrameRender on each component """ log.debug("Video worker received signal to createVideo") log.debug("Video thread id: {}".format(int(QtCore.QThread.currentThreadId()))) self.encoding.emit(True) self.extraAudio = [] self.width = int(self.settings.value("outputWidth")) self.height = int(self.settings.value("outputHeight")) # Set core.Core.canceled to False and call .reset() on each component self.reset() # Initialize progress bar to 0 progressBarValue = 0 self.progressBarUpdate.emit(progressBarValue) # Determine longest length of audio which will be the final video's duration log.debug("Determining length of audio...") duration = self.determineAudioLength() if not duration: return # Call preFrameRender on each component to perform initialization self.progressBarUpdate.emit(0) self.progressBarSetText.emit("Starting components...") self.preFrameRender() if self.canceled: return # Create FFmpeg command ffmpegCommand = self.createFfmpegCommand(duration) if not ffmpegCommand: return cmd = " ".join(ffmpegCommand) print("###### FFMPEG COMMAND ######\n%s" % cmd) print("############################") log.info(cmd) # Open pipe to FFmpeg log.info("Opening pipe to FFmpeg") try: self.out_pipe = openPipe( ffmpegCommand, stdin=sp.PIPE, stdout=sys.stdout, stderr=sys.stdout, ) except sp.CalledProcessError: log.critical("Out_Pipe to FFmpeg couldn't be created!", exc_info=True) raise # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ # START CREATING THE VIDEO # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ progressBarValue = 0 self.progressBarUpdate.emit(progressBarValue) # Begin piping into ffmpeg! self.progressBarSetText.emit("Exporting video...") for audioI in range(0, self.audioArrayLen, self.sampleSize): if self.canceled: break # fetch the next frame & add to the FFmpeg pipe frame = self.frameRender(audioI) # Update live preview if self.previewEnabled: self.showPreview(frame) try: self.out_pipe.stdin.write(frame.tobytes()) except Exception: break # increase progress bar value completion = (audioI / self.audioArrayLen) * 100 if progressBarValue + 1 <= completion: progressBarValue = numpy.floor(completion).astype(int) msg = "Exporting video: %s%%" % str(int(progressBarValue)) self.progressBarUpdate.emit(progressBarValue) self.progressBarSetText.emit(msg) # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ # Finished creating the video! # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ self.closePipe() for comp in reversed(self.components): comp.postFrameRender() if self.canceled: print("Export Canceled") try: os.remove(self.outputFile) except Exception: pass self.progressBarUpdate.emit(0) self.progressBarSetText.emit("Export Canceled") else: if self.error: self.failExport() else: print("\nExport Complete") self.progressBarUpdate.emit(100) self.progressBarSetText.emit("Export Complete") self.error = False self.canceled = False self.encoding.emit(False) self.videoCreated.emit() def closePipe(self): try: self.out_pipe.stdin.close() except (BrokenPipeError, OSError): log.debug("Broken pipe to FFmpeg!") if self.out_pipe.stderr is not None: log.error(self.out_pipe.stderr.read()) self.out_pipe.stderr.close() self.error = True self.out_pipe.wait() def cancelExport(self, message="Export Canceled"): self.progressBarUpdate.emit(0) self.progressBarSetText.emit(message) self.encoding.emit(False) self.videoCreated.emit() def failExport(self): self.cancelExport("Export Failed") def updateProgress(self, pStr, pVal): self.progressBarValue.emit(pVal) self.progressBarSetText.emit(pStr) def cancel(self): self.canceled = True self.core.cancel() for comp in self.components: comp.cancel() try: self.out_pipe.send_signal(signal.SIGTERM) except Exception: pass def reset(self): self.core.reset() self.canceled = False for comp in self.components: comp.reset() djfun-audio-visualizer-python-f03a3a6/tests/000077500000000000000000000000001514343513600212055ustar00rootroot00000000000000djfun-audio-visualizer-python-f03a3a6/tests/__init__.py000066400000000000000000000057341514343513600233270ustar00rootroot00000000000000import os import tempfile import numpy from avp.core import Core from avp.command import Command from avp.gui.mainwindow import MainWindow from avp.toolkit.ffmpeg import readAudioFile from pytest import fixture PYTEST_XDIST_WORKER_COUNT = os.environ.get("PYTEST_XDIST_WORKER_COUNT", 0) @fixture def settings(): """Doesn't instantiate core: just calls a static method to store `settings.ini`""" initCore() yield None @fixture def audioData(): """Fixture that gives a tuple of (completeAudioArray, duration)""" initCore() soundFile = getTestDataPath("inputfiles/test.ogg") yield readAudioFile(soundFile, MockVideoWorker()) @fixture def command(qtbot): initCore() command = Command() command.quit = lambda _: None yield command @fixture def window(qtbot): initCore() # patch out any modal dialog that might happen MainWindow.showMessage = lambda self, msg, **kwargs: print(msg) window = MainWindow(None, None) window.clear() qtbot.addWidget(window) window.settings.setValue("outputWidth", 1920) window.settings.setValue("outputHeight", 1080) yield window def getTestDataPath(filename=""): """Get path to a file in the ./data directory""" tests_dir = os.path.dirname(os.path.abspath(__file__)) return os.path.join(tests_dir, "data", filename) def initCore(): """ Initializes the Core by creating `settings.ini` Returns the temp directory path where settings.ini was created or None if multiple pytest workers are not enabled. """ try: numWorkers = int(PYTEST_XDIST_WORKER_COUNT) except ValueError: numWorkers = 0 if numWorkers > 0: # use temporary directories for multiple workers # so they don't interfere with each other configDir = tempfile.mkdtemp(prefix="avp-config-") else: # use test data path so we can easily see it after # a failed test, and help us understand the config configDir = getTestDataPath("config") unwanted = ["autosave.avp", "settings.ini"] for file in unwanted: filename = os.path.join(configDir, "autosave.avp") if os.path.exists(filename): os.remove(filename) Core.storeSettings(configDir) return configDir if numWorkers > 0 else None def preFrameRender(audioData, comp): """Prepares a component for calls to frameRender()""" comp.preFrameRender( audioFile=getTestDataPath("inputfiles/test.ogg"), completeAudioArray=audioData[0], sampleSize=1470, progressBarSetText=MockSignal(), progressBarUpdate=MockSignal(), ) class MockSignal: """Pretends to be a pyqtSignal""" def emit(self, *args): pass class MockVideoWorker: """Pretends to be a video thread worker""" progressBarSetText = MockSignal() progressBarUpdate = MockSignal() def imageDataSum(image): """Get sum of raw data of a Pillow Image object""" return numpy.asarray(image, dtype="int32").sum(dtype="int32") djfun-audio-visualizer-python-f03a3a6/tests/data/000077500000000000000000000000001514343513600221165ustar00rootroot00000000000000djfun-audio-visualizer-python-f03a3a6/tests/data/config/000077500000000000000000000000001514343513600233635ustar00rootroot00000000000000djfun-audio-visualizer-python-f03a3a6/tests/data/config/projects/000077500000000000000000000000001514343513600252145ustar00rootroot00000000000000djfun-audio-visualizer-python-f03a3a6/tests/data/config/projects/testproject.avp000066400000000000000000000012461514343513600302750ustar00rootroot00000000000000[Components] Classic Visualizer 1 OrderedDict({'bars': 63, 'layout': 0, 'preset': None, 'scale': 20, 'smooth': 0, 'visColor': (255, 255, 255), 'y': 0.0}) Color 1 OrderedDict({'LG_end': 0.0, 'LG_start': 0.0, 'RG_centre': 0.0015625, 'RG_end': 0.0, 'RG_start': 0.0, 'color1': (0, 0, 0), 'color2': (133, 133, 133), 'fillType': 0, 'height': 1.0, 'preset': None, 'spread': 0, 'stretch': False, 'trans': False, 'width': 1.0, 'x': 0.0, 'y': 0.0}) [Settings] componentDir=tests/data/inputfiles inputDir=tests/data/inputfiles presetDir=tests/data/config/presets projectDir=tests/data/config/projects [WindowFields] lineEdit_audioFile=tests/data/inputfiles/test.ogg lineEdit_outputFile= djfun-audio-visualizer-python-f03a3a6/tests/data/inputfiles/000077500000000000000000000000001514343513600243005ustar00rootroot00000000000000djfun-audio-visualizer-python-f03a3a6/tests/data/inputfiles/test.jpg000066400000000000000000001371761514343513600260000ustar00rootroot00000000000000JFIF''$ExifII*bj(1 r2iGIMP 2.10.302022:04:21 21:23:03 JFIFC    $.' ",#(7),01444'9=82<.342C  2!!22222222222222222222222222222222222222222222222222" }!1AQa"q2#BR$3br %&'()*456789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz w!1AQaq"2B #3Rbr $4%&'()*56789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz ?b'В(mYtm\psך#B6(PnW.5ʨl2KM)Ll&˅r@,ǰQWa/#4Ay^`xnGcO`~dr{9W92Fd}*?!ΌyF_Aδ (ۂd#攎OTRNҶYNjUJ.ֹ=6$\gЃxQ r:Y(B}zU/I?:ӄ:вur&\TZE@M;y~\j?_Z#'*R8?>OUsI5X$77[wzI|:sQi"8'RkI"qܑ(EmSSJ֢IAm8&ԾY+=]WRXW/x6A*VOg_9,-Xɤ.I#RX},ք|[')a2=+-jaR<6VrUʜ)oՌ_^ :LH2+N>":tAMck1/S@=\'aO4Ӥ.FWiӤ!>+^7+XOLR._Oր"SI_-m:@O/sǗeh n9o@8ʱ:@eƔxS )ioFWk؎ YH7Y1sJ7 >si_In!^?ZAm|etSEw~2Ǘo ktSo-Bw(lr=xp%3י|PV[#Ǧ@Orm>ix<}*oΏ1ߝ\޿K!1TUA5?@e_wfΒ3nX$d?@& C3vɯ@,WpPĎzXbد'1W=ܩڛǽ6K#c>*bAItz3広FO֟am(>ASSy?6U9lFwFwzj޻Z=wZ(|.UR}8k4Zd [b*a+8yFE)d `"P*ҧr'VE-O3C yGKɟyB/ZFE)h>9Lc?@WqPXF̾j?HK/lRHX(#>[5:1m߼M Zk/WƝgPIjHm>?zLj|K=)̫"uI77bN+xWe{ELN¹ hۘg"_W,|N95>i>ixRY"h6*ľp?1C11ӹ5-_#ˉN?h۰g㞰QCu敁RvPG)7lhQ˟7/p[o;?lP4q Oohe('63[/?\}7^-<%ך2eLy^_{Q$HwnFnhb bV#{W<+A 04ݯbOaړ>q1+zսIzP&ߨ-\@Xw*ƗjAe've]бPA'Hk `w@3i'ն+vqXJ:k\\y1b5 S捄Zvz“JlZN?Zd0G!giq)S /Ҵ6?gݸ7?h*i8N۱B?+˾'4-y*W|qPǘ|XOo0!U^^ <EP ((k¼k~5( kؠ8kǯyY?RʟjkM$lzHyuΡs$$:`\/^ب,aj.5'Su&C+;^ X8aC 6^Oo3#~8 flHL Mл|Ґ'8۹<ѹL~rZ&UK0l,>YH*9b26튐;M0c>olP?INփڐiboJHdP!'}ݬ}iO 1 Ⱦ架J$l%Z!2m$?4a(qv9i;@ `nCi@#9?y̻_< y1h>}ɠd 7c[w'Lu[?8zq*&noLW@Q@(( vƽf`7#MJaR($=0kǦ'YSj*? )N߻4G/ibUkA"o8I?TB!VrfWӂ >RYǗ?T: vm/+7$USEzAc_du =J7h+ZP&3ދ?۱U1k"Q}?Fϳn_>=}yX^Q)#~\a#m*{8Jw|}YҀ ?b0`Rb,<Ɨ?Ҕo2n9B3}a_gVva^DޡvydCО8<4+Oђ.vʼ=hli?;dv ck'D궛%3OxQa?=1q^]8@5[O'?>l4QE((ǩ>8zA/29V?WCc$Mne 6[CMczzTO=NWޢSA_;S<Fqhp93L9 TGL8lb~l[F獶Wb4x^O^vkG+矮i Y\\9]7@zךhz*Hev x^?ZDʹvG Jn7O+}sM;a4wEc cր=|HӾsivn;IF䯗kv~oּ"yYݤp7G,/w|r(ȠMOgm, #O_̵i/^gFEza}b\w vKohp72ȣ"=9$i~z첻X?ID8%w*i#mF䎢'֌S@d[\?ʺmO) W1kx&Z1|sꢧ=6욮l3T<r5PGUuC85zcc޾OhM6VqIS*6,F I~ :jm9fzRtQEQEQEQEQE=c޽W,G+g^[> (3`z+u(ǎKv'U 5`r2AZcJ`{̺Q)Y$r}_Ʊ+ץm*Ns\b+)hhYwc48V\xorZ =J#>N Y{8/S$JԊqS.B-)e㠫*E?4{8%R//Gb\C{ 8b${m>)EGAW+𯇛X%R-"9sQVTA{"¥I(vUм&o~O_ $ bOnƈ`ڹkLl[{}MxT1u{hno^g5,v٧$ "85 ̨uuSr*%(SI\*5=18Y?u^t 0Aq6&@G&V%+cVv^!v ICC_PROFILElcms0mntrRGB XYZ (acspAPPL-lcms desc @cprt`6wtptchad,rXYZbXYZgXYZrTRC gTRC bTRC chrm4$dmndX$dmdd|$mluc enUS$GIMP built-in sRGBmluc enUSPublic DomainXYZ -sf32 B%nXYZ o8XYZ $XYZ bparaff Y [chrmT|L&g\mluc enUSGIMPmluc enUSsRGBC(#(#!#-+(0hB 9<:CsY(UA8  ޹q( !H %:V "K1חkyfY=0HI@ :YiD(2y*A IS&M!0tlL4C6` L4h l!2h 90Ce2dѢ4hl! NbB=yR2GYqәλv2d`)4dM:6S4h lɐt!t0lhѓ%6C`Lѣ&JlNbP9z|!3] RAd!JBD 4 )HBR)R2 S ! RAd2h펖*(Ӟfy%@B\k=0LCF)@s`2Sd0 2d`)4dM:6S4hɒ!t0ly:j(PCLt7J[P @E!HZ@ @r9|1,M (w9鍀~A( gLktRN{rL(T@@Peޥ3g9`JkYk:ǵcPPR(ր(!@x7V(SםPH HP @!E}:fמu@Ps \*R*@K:f@ (Ӗ:s:ϥhe?'01 @P!`2pA"B3,V_ Qjj:F ^'~u,L^_?N4>_/q:zZ+$]#5^v-]EL.:]Ye_c_^9:NQ^TY~%//KH+H_/!zGI^#RñIx_^t:,,_L:LgQuGQuGQuYeYeYeYeYe|QV7#>\Ҏ2Qv/J:;^T z(TW}Q^WK ĕ_HIE"HR)E") ") "HR)E"HIHIHR)E"H$CH$CHR)E"HR)HIHIE"HR)^Xk‡ 5(p,QqE\TYl,͔he.((ˊ4YQeT\Q͔h*.+ڽw%)~?pPCơ‡C8^!~=_eEQQqQqQqQ}Q}}}e\T_eE^upСPC‡ 7 (~kCCC\,/J8X(pP:,:,:,:,:::-C^׉QC%#>|b9Pz=x4(x8Pcpp‡ޏ  ,nrΖtj(~/UU&YZZ/TY-_b‡ 5){8_Az8kҸ_EqcQF6T\QEQQ~Ƈ ,o‡޸4Y(ˊ6Q͕QeE(l*h*hlEEQQq^z?8X(pPCCơ‡ ߽xVlEEQQqF6Q(E+*h종TQf4Y,jpСPC‡ _/#z9>*x<,m:]Lxr~-[^nAAE  i:+nDGFK%eJۿ$1zmZ})YnOU+./lZvw$&Z$0p @P`1!?bL~ kyɼJR4ߡ/'yO9*u{*R0-?IQ}SeO7KI,-kI1,MQi%ӣ~! kkW.p@:NB֡kP@֡z}8OAŨ8zPqj?_&JR)sҔ)J\o){W?_F'MXƖxBv,o[–7x𥓋?o G/X|xRHO'7N)1!p A`0P2aq??K5xOŎ_YZ.oYL|u,kŎ1zߏcnύ-cߏ[""""#DDDDDDDDDDDDDDFn=RGͱh1<;c3:ftΙ333ft3gl?y~>;?uy=h3 ,1 !0AQaq@P`p?!!CMZ)Yu0 '+K{OwǗAsj4$zJWv"ax1%E5%-.`3>2%ؑ%ԗVK,$I|G];5mQ)6T$4%J{r f y7~a7yxM~X-4AK.#x{eOw_FFN8d6 lm-.}k Mt33XDDPW]2), 3?oHd2 d2dAģu .Shn7dٟQHDw2P'-(ډǒPMȔ(S @)2 ͔Oc}eRSLy"Dr#&d%b~c &lGS%ѓ7\tKb{,8 <|#Odt }+(T̄ bbkħ粴ZA-Þ R$V1?F¿ۡ0B8A_d&L2dɍ`ؑCdG =QwG(גvqB7cs$;pԜ74֙x~w/wn#<h}):{!u)d%*hj9w)d7k[(hl-ބI:9bIdx?o5KR{z;U=OG`)(vގ(=Jz;=z;E==)(v9)Tv TvވTviqSz;oC`BJ!IC8CTvމvDvވTvz;oCz;EOGmUq&]xIHZw aUb0J;4-a^iwbŰKSЦ-Z]VdԗԗԗԗԗԗԗԗԗԗԗԗԗԗԗԗԗԗԗԗԗԗԗԗԗԗԗԗԗԗԗԗԗV8ﭏ!od&HH$Bj$'$$K @DIDH[ ,Y"bBj$-H  ŽH[H"DD _J| < KbNIGaǭBWZ]☶1l: m buzUbئ üSBئUL gv7<$@ܑ@BY"7v Lm4DC@IdH߀gbF"wb.=1!)myжa^X)lS üSűL+í.L[BiwbŰK*e\{X#?㏈%p-L[¯\{|F]BuzUbئL+ҫM*}0D9{"[3gS~>1rzs73zLVM3S KSЦ-Z]V-C 3hAئUL.=0l8K?bH܁HD1 %2Dn@$bXHDp' NnjgbDF"H?LVpK-2:ì+ҫ4-a^Xwh[ ܁ ؁7! kb l[Fiw_L a$7H;"$H DH]DH]D @"D$HK$l%H$$Q($Q($7c†I"D0C &I @DVdzC ¼:Ŵ)a֗xUj-XWV-h[½*ж)zUa+BC 3e VJ$C%!C܆lP[ų%C!CD1Q(!,nd Q(Cؔ= ["QbCD2CF!,= KrQ$2$Uq[J[ж)zUa)lS KSЦ-Z]☶1l: Z8AbGD $D)âd; 8FT VIȂ1#D IBd Gd lGDI#D*.=4?t)xu)hSí.Ц-XWV-h[½*lSBئUL g] a^iwbŰK*Ŵ)aUb0J[ж)zUa+BC6yжa^Xwh[J;5¼:Ŵ)xubŰO<*eh\{r]~-4?w aUb0J;4-a^iwb¼: m bu}7Mlx $L@0܆&v Di KHHKĴ a#rY!$1Hi1:hDN;[_!\Ɩ9I 6ߗ[8<|W,K}Iq;PF9~n-4?I .EOn4#oqgB-9'*C<BBBBBBB8<MOayV$|_,qOɻ 1+moBkȕ/OamKn@~ gیEC ]V[ )g4J8VIu%m$ .Mؔp!w^Б= Vo"LF%3 .]bi6%KOR$hm|_| Y I$-mmdZ$>I$ i9&m eKddIi$I$Hmm$$ i$$hY$ $DCi4 Y,Tրm[$dK`$ -i$%lI$I$[H $$J(M&$6KlY$$Ii!IKm-I$K-II$lItm$@%u a[k$IiI$II'm Gu$BCmi $$|M@I)6@&pmOlK`$@$IѴHmҜXRK$IS m$e 4H0 %6HVͶma$A$H I6m ,߲[ieֳM $KK`"Iu*| $I6)$IO'[mrImw@&vI$m$iO I&[i i mK%mmm$[gI$mI$I$=K$T$ Fp$ơ"i)$I$I$I$I$I$I$I$I$I$I$I$I$I$$[n!$b$I$I$I$I$I$I$I$I$I$I$I$I$I$~{l) F )$I$I$I$I$I$I$I$I$I$I$A $I$ IR[d٤H2$I$I$I$I$I$I$I$I$I$I$@ $I$IdݿxlΉ$A$H$H IA H$IIA I$I$I$I$I @4%+&    @@$m ?&f@A A H$m2&A  AA  H $m9?&')I$ I$AA$I$ I$AI$H>&o).h    H>o}HmKIm P  AA  HmJri)I$H $IA$I $I$AI$HI$ji_ۻ     HiNGNAA @ H:Mn$0 AH$H$I H;mkpk[I$yA$ H$A$H AA$I Hi փ$x  H  @ Hm6N :&x  "V[$JԐ H+m{ K&$HHi)2@ HmjN+C$.$}qd$" `& H+mnZS&;n$A$I $A [(2q MI$I H+{m $> lmP H;jo9@@@@A `AA A$@H;MM@{h$J` $aA Hsi0ym'yI$ I$A C I$Hsۤm[h$e[l\&A Hsoom[J`H @KZI$HsoAi .B`$H $eJ@ HsI@BaI$H $II  I$HsI$Bh H @ @ HI6$$JII$H II@AI$HI6- $B A$H$H$I Hi~l/PA$ H A$H@ I@AI$Hm?ٶ[T    H$ٶtA$I$A$ H$ IA$I H$BٶoD  A  H鶒H۶#    H$Ii*$@A  A  $HHĒI-     HwHBE!A$ H$A$IH$ IA$I$HВ` m    HvВhI[m4HAA  $ HH$II-    HkFm%m'I$H $IA$I $I$AI$Hb @ %m'    HJ%6l!&H HHZIe$vm'     H\mE$vmb'I$ I$II$I$ I$AI$H|?O$vm@'  AA  HtiHK$mI&@@HhAIH $mI&    HH/II$I&$A$H$H IA H$IIA I$I$I$I$I IHI$I$I$I$I$I$I$I$I$I$I$I$@$$@@RIM$I6$I$I$I$I$I$I$I$I$I$I$@$$I@DJm$I$I$I$I$I$I$I$I$I$I$I$I$I $ I JJm$li$Y$ I$I$I$I$I$I$I$I$I$I$I$I$I$bMm$i6@$A$I$I$I$I$I$I$I$I$I$I$I$I$I$m$iH$@$I$I$I$I$I$I$I$I$I$I$I$I$I$n$$iI$I$I$I$I$I$I$I$ܒd$m$I$I$I$I$I$I$I$I$I$I$I$I$I$I$^Bd$dm$I$I$I$I$I$I$I$I$oM$dm$I$I$I$I$I$I$I$I$I$I$I$I$I$I$TlF$mI$I$I$I$I$I$I$I$P Flm I$I$I$I$I$I$I$I$UKQIF$m'$I$I$I$I$I$I$I$I |HAI $M7I$I$I$I$I$I$I$I$I$I }B I &$I' I$I$I$I$I$I$I$I$I 7 I&I7 A I$I$I$I$I$I$I$I 2 I&I$I$I$I$I$I$I$I$I$2 I%a$I$I$I$I$I$I$I$I ,2mI$Idmm&m$mmmo4Kd0I$MrmImI$IݟomJo@mpM$Km $m$ٴMI6I$I%mQ2IA&lxlem0I$[m!$I&mmmlIII6->Impm0@)$I$"I.$JܒM$$Kwߘr6k$K$2RI$mInHI$I!IiI6iSGd\JI$mI6 @I$I$1TmI$-m&i4$MK%HI$MI&%D$I%-x$m)M$K/IHm$II$o$/$I+H6ݭ$m$$ChV!0@`p 1P!A?Z!odƻnJeVVRQh[mC((( Hh[aSv Szc|4ctOAKMܩucFh0|O&DPuJu1|vaXMQ!ߕ/‘En6Ɍnt!B!1].,,eYeYeYeYeQEQEQEPɣrGCӞ!`Af9 #C.IMs,!D,GƕoX@ Rĕ vo[x^: ~(S[dhJ£!m ;$ p۟[ P??-T`Dw=~weSXN!lFƔ{VС7v+7 $(Mv+z膴 f zzzq{+[7_%%8_])J_| c~`_y ;.x3\lYOsx.xk:zxz{"PZh^ʆn]=Z=On փm.w=5%87y2BKA/&-a b ]A1v-bZዱh?- %Ah?5HgZ-Q&eCUlA 2Ļ̴+h?7AO2? D,Aփ5yփA4Z A>|^|Z+AEh>|Eh>} Zzb|d!B!B!B!B!B!B!B!B!QM0V w^WGb Qi>IO(؛ Yϡ0?&E0c5z,hJE{j6z%?[JDt4Z (}o+8Ȍn 4'6DF VF%\Iwl%Kiw)$9+7tvϥ/p-bdR7ki4DL]tkm,WҐԗ[VI'zӒ} cQP׆DtIw؇ݐd2t !$HI"55ȱ R$/@kB.-/CBljDIF>ZI$I$I$I5MdzI$OBl? /?l4v'Ko(\>sFR}6Qm"M6ж%a>F$II#R@܆BI AAAG@GDIݚ\b6F`l 6Ѷmm(F6QqhGq8#ap62%"# a|d(Z 7aRi2cD;o6O$!*JNM8AKD!V:"$ON`ҿ&YFF`4&"%!k--  I?R  (TtKrMozQ^^,|ƻH-ܞFJFx&,MT_ZoSgiSBrt"LYrƥvCEr4,L_pq/EB\u{XTѳZ>ړm UƦzp%)˶6+K1m)WytY1e%-˗-رIj込B`17t/ .$.b;w5=P@t ?Bjh,, Q xY?3v]± !??Bjh<8?Ci?a fZ@O,#g -= 5MM ؛h?;2XE#.C#bKOAĦW هAٖ6e nX S/XxA ^:?1oMr΄7/b} 1T3=88Zf杴24x)53T>jbN*4Ltib鋞'YU1Zqy7MǓqy7MǓqy7MǓqy7MǓqy7MǓqy7MǓqy7MǓqy7MǓqy.o܈^O~r_H+,FFr7 7 h7$N07 #9 MpAm.%7 #4 7 69n ZM7pHT07`l VQ'nCp/1aFFN0ed667(*gj郎74S7LSpsG\S=^zz>D؆ڈ'1. cp]D 'b%?!r=ՍV(Di^ududF#s܎n'b:m!<6#,2OD&{ՐWODODF7BPo1Rz!< X%{ C#"{wo1BXH.nOD'CyBW'E%Mm#iՑՍm!"˹ Xܡsrz!jhCV5BX7؞"::JЬOD%7#BQuGGQ# nLDX^'SLS=0sӃeniLS-0sGS>jbN*4Ltib鋞'YU1y ҊGwv$?FF#8bdeMs;N'3،XjNddeE38ĺs9Ŏ&$Nq#,Ǎq' f%֌s,`w'79c8cLNf3w'79ic8cNf2]yLh?Uԍn| B]Fߣɚ1sLtNNi}1sL4kcL]1sO8j郎74n)92jpsG\S5^Bt_: !)>JAkfʘf郞S-3sNfiэ3XB0&}Mւq΃0"SH9mlFl M6p6wFƔl,f(6؍ 3`Jidnw6)$Ѹ6iJ,Xg7l7K3hLɵdn ɤͣhJY7VfЅ,mpxՍ4ٴmJn 9|$1q#yri;`7Y6wFBDG_ K@7Zr3'&z3t1'99b0'YNi1tE9˦:LS=0sӃenin:WнBEȗ;?p>}p!Q>JI%Q8#kp8{I>9I \1 7C,hBa] r|*z`O24x)1&hfHxN F>Ie)S8(cyud&w[/e3*ߑ1psRcV=ZJ }D2#'Fa OWU* &{ @ f鋚cN:rsL4;鋚aN(cLŢJ%%,I$X2FBhJ%DQ$T(9<Lt89î)?BT(CT=v`f7L\2qӓa.)L\ 2qE\>*`$Q3gƚm7\IMВ#S5?@xD#SucPm!3̊J\#y̒HJhBIVK LpJ^6TS7LivZ`8z|Q:.EF|oBMY ]yII݅\N˜Y`i[cN9&Ŝ"$eS)?ayNZY ;'YU1o4!zH^ʜbK!#IO:ף:"z!v;AXJY#!tDDududXY$x+'ZƲWOD,Րqrzv::vF,ODFX 膲v-e4I$IRiqrH"|Pn<ɡ)VB1n\Y{:y#]p$m/$۹X3b: ;nGVN=(?yt/[ߌ 63ML?XU1sLtNNi}1sL4kcEBqÂZ16--B8%zzэ4C{cOF\-FC.FKFKFl19n:Wнov3pLc h\~]Ͼ.no!;!LB؞nҢBEQƲ\\BCi=PiHS{WGFGF7Eo!*Jeщ0A %h;"dJђ&(VЕ;21hL2V+'%rV2bh+BW!>,~MvԞK'ňhƋ o!;K\2zyV7iQ ?9z187Rm~E_*bgzpqL;i4x)53TK !v%(dZt`$%SrqE\^B-_Z$~F5 r$r g9C> p p )EN:r\$87iXgІr ;GRhjqDIw8 '0q w9l!ue;lFGsrBDr JQy Ё&y`,!7nWsJJ</W۾gFz3t1'99b0'YNi.|[郚%t+w4n(^LS5OpqLy^taLm \xrn!  FCFM f7&l1D3q )WfL97w4iѰ&1y6"m&n!.]capl1\6IbdԦn#qL3iHFȔaaۓq2bl7ɲNYLF6I$7JQcF 697 bFӃq #(f$hB6)FãhB}]*bgzpqL;ieh\S=>jf|LUaS'=.).i80=N(bhB}1ґ$CT\_Z1"[ PNIhi,CTCTKFKF2Iu[fі]k%j/-eַrV9%m< %%$ PJIh.5$W.KF#H!R--tCT#ly%(;Y{v%.9$5E^hiPCT2muZ2Z2k<ю&]e؇,$5E^hʼn; CX%N Wk2Z1D UBzfj3t1'99|T.i8ʮNi1t>*fj郎74S7Lif4xu3o4!zчd7L\2qӓa.)L\ 2qE\>*b鋚|TLnin)92jpsG\S=^BzbIEEzbT/V3i~- DKՒb([ D<4e^-dEzbXriF z^E v!hie^-cQb^ DYezId-VKՒbJ D3eEZĽYssr\BnĽY/VBЅ5bBYkeZ"-r^E+v%ܫr/V"i(hB}3ln)92m>*eh\S=>jf|LUaS'=.).i81.7C,昪?_~ ve7FB3hmi&i,f62MȩjY a[Z$ߓax27F44 &abm&DѴ6XVe7E{D%6a r!iv-%{6[qPɰ4m^ DѴv'fC -ưɰR%cqMa #~MI4HC46?_~c2Q.i80.i8ʮNi1t>*fj郎74S7L雚f4xu3o4/[ߺ04P4 VUJԼ T3*N(A;pB Vlа$g* 8i% JPN鴰!eN0PHfJn,+PH41TBJtv%yC}?M}ta} 1T3=88Z|n:8j|S78¦Nz\S7L\2qӓc.).zQeW'4W}wkb_PhCV5Ȥ˵j -bz!",cx.nm!jơ+ܞL">ȎmbXBTr:76NѡXKܞRC7%M&vqsy̒"z!*K܎l/8 XwBr͍4XLi WY舤˽7xa dǢ=Ս48Y{wr:B!Qk]N4 :Ffj3t1'99|T.i8rsLt.iS=0sWLt9*Z`L5Lt89î)?X.0z^ч8j̜If`b3cq;LHgIfN'b3bz^N'9`FXs'y8 FY8LԲĺ،Xp'{O9eMs;N'3،XkNd FYq8D;кs'e8̜5;t2N=v3ln)92m>.4x)53T>jbN*4Ltib鋞'YU1Uktk?ĤDtcx.ڛJRƫ o"iˋ\f%'"U6xaNBY?b:1;Os\ ''"6ݡ'|cY.ڛJR BvB77+\nҢ7e5Ln=Pm2 ocyf\7em17wІpo!3LBDёђwQsJ4ty]+( ?̔fS2qӓaNbi,'4Ob3S5ytCbS=0sӃenin:Wҳ>E_~ chChoCڱ7&6xiYB n,lxxb7wfЦo ʹ͡wFm1q#yDi;`Jios%$7m7#hxŌ1ЭobNY%4ڲ7hMҔm ,f(6؍EmYrɴm I&6Gғ-Ota} 1T9274n)9pqLn)S4V2sT4Ltib鋞'YU1ktK?sN;#ԡ?ohxxOADŽ`FbµpXۣORMdi$$IF6$I$L!W\#tF7DnD(V gw"(jb}mXu{(K䕝R p]$))VwnI7J_0rx]±ہܒynM8qaP"7DVmΦ7&˗ȔN䰯8rm&mݵfZ,LTF"J/%1WQ%;MMP&9ݒM(YB3EK'ku>~:گI&k).db˟, Bc F?s$I$I$I$I$I$I$I$I$I$I$I$%8'Oo(j2,]^я=܉f\q)ҮIx7.~ťؖ)\w\ye >cJeɶP92,VMp%&ݤ[j?!CO!.<$$$"zNяsyx7 yx,!CЇBYs"ISUC}w?)aO!U!9=:X(#{z܋$'D"~?HO'D"~?HO'D"~?HO'D"~?HO'D!Y )IƐKdɨ4u%iYj&5/]am-ȳMKKji'RX9d T,%/F,BtUx/B+m[Am8~-ŗ?YFoKt]9^w?Ys*GRGt`;3p~N8?'p~N8?'?'[G䶏m?$-?$-?%~Kh-[G䶏m?%~Kh-[G䶏m G䶏m?%~HZ?$- G䅣B!h~HZ?$-'2cPAA 'wF7nqS-$H"D$HԔѸnuF7QJ&Obdɓ&LQNDк=+qE"A +R  "AA{H T- AA݊ Qj$AA읦 zS-VyzO%{"\{2Ԃ ,6בr]-OE˲_D &Eia0s%]^}Fmu:'Q) Ŏ aA>Ϫz ]j֍NupGW|rѕhOU69ٰFv;=2z]@ޏ{sB8s9s9sАUA6q HEiȤݣ$h r GR TI) 4d!RH!RH!R!b) *2,2,2밳:0C KMXckZiJ)RJ) YdAF!R!r)BCVmIJ26]c,|Iͯ󕠭-n'U'7 E׺7˼(K[AA_JfO~ ]OW$կȍOy]]fGL!qIoDP؃30\|9t/-#G/c4wW167O>8f/H-n gz%D={q.霩2>n*!G2:,VmՂ$B OI,]9{t/>侶mpJ/ggx.x{+ň!3RQ=v})]G @Z}°'/ ' 0P{7[5ਤ`v*2Ew{s^sg^Яvk84Xv-鼴7٣O|}_{\-Ї<y&?<} Pbon޺~\ Fxn0yz!NT׻OְJ#_sWuJ`R͍ܵ_(!jv~(IXU~2:3ۻaGN^7\},8!<,A8oz}nydzCE7xf%dﱔ PXTyNhC/X pX0$A Gs[q!bb]Tv]5iw3z?F[2Փ3xX®J8&tWo}8٭ásN~Zmґ Vs&9FvN7Q>e:>4c`Q ie4 { Q8I, Nky{[$ǗJ㲋L[w z+NN>`!})WfA6fzS TҰZ״ZeT_-{.jBU$ؑKRpe2ǩDkwj9EiF;dy$If]?0,]^:j?NAP!=3g.,+j?N}oI>:8 =D\zvin5BpU^V?|ߒ>D4S7 NW>͢NPMZ:v:u) n.ޝX`]NLhnX#Šj׽ ̽7}cQph]ol17K]= N2]R|4$ܣ8}zHߴwߥI,Ρ(&M*sY,bQt޻\71|]qsc0X) hchW<Q$4K D!?|PyfP*{:{7zëh0&7_vr<~ )Ms09-3=ar5uG)ͥ 1 (G|r/`q>PԷ ڎ .$V={7]}`c0J"DF[a\([KDe2ANo`.s<P1*èZwVfgp-R1cZdaE;=n*.XWsFя3U/n;]7 x {Uxx4}Lt;ND"Ortt=&(' !H̙_h??Ճ͆:n?˄_Hҍ+qE\|[ʊѤ'_>8cibBd/ GeZQ6S]/jJa2Oɡir"iG$ک=oڇ]t@3(51r?NpoYWZ G3>y<բ\TmD}5M7>ǿ~(4UߚconANHؿ+ u*vĨ}*"1$wD͡g[r6[I=Bah&W=TX԰Le lE ь,( ,3ALvrHPSoduX[< %"<2pX{=n>Ns&(9;d@TRe9o9bW95A9;9Y0 H>>R bEMSjUouwwi#ta+9B@ Mfo/𻜥9tw9`:/aiq#r9 %EA^3<} O@|*O0h6D"^ם@Ia%\-D@0hߢ'?-*XvqDFY4r_Q&`]gu|LD`϶=2 kY7|ݷX*mv"xL0bҠkh#VѰObp=KhH3]Hm#Q2el>A VzjïcDp)aPvَE.jö{I:\5Y ^!<F(m]=[L˚Cd`gs똒ˢ2iE+OAKʁJ!͡T7cRTHABZq{_$ | uz^tP u[V\F&TbePy{_Al;ez|'dy["z̷O֙u7FPWk~&;zgJ%M1ҭ A4nJi IUq﹌ؾ֨¦ځe~7% OggS;g+).ޘY W1{Ė7x {P˻q h_¬U_F=u]uaY}߃\:=pDW&\1{OȈ@Tz/uwU fBw[qtq=si͟ܧHiXFҳK=R^;hW@dJ4:R{7$Xv|} ԝ87$e\ŹNέz,ݕD׆j8tǡSbM/Zƴ׬wZEU2]Sh1 D!ش4cCE?㦤V1{ ZYpWR[=z4 ?`wB?`wHD3]e9EĈYO%ہBh-pc * @JGD"tZ *x̃˟rϗ#lӷCbz?ba{=D<03ƷD"ɇF Pj0CCjP $r#j; I^yh``'OxBH֍uZ!A 3 PX#0l+KtÐe@>n^ejօV{(Cq#:b-* -\[{:m !Oc$ `!IPHXCX6Dhx*H&0B% hh^T/ PXP7APXDrv4pQ e*TD2J:5.^gZH;nBmm /Q=@(W-SlD4'581C/?oN-Y@#DP pean(C*C X"@"e#H aD^eƒڟr%1jK/e>6ZR("j%f  pנP•G\CdGhwb @AfDx^J/P+ .m`RYC[1*|ơ̧e|=H߅dJ>ҰU뒝X$ *HrCxKu(  G(L2aAZK,O*!Ud@$;R E* ^2VtM{i0rQb٣IMF, RfnD `. WACa5C8x^H͝öEP7@L_2;!Pc@D*,D^,g?e!ޱ{F߮D[r}X ðdEeaL dA L@ Burͳΐ!R8grL OD&7\ IOD*TU&aJKг?ݚ&{Xeԉí #G AL7"rc[(a'apN5jP= _鹈!_Y"]_ J_ʂrڸb?1A EDX Ƨ,$-/,أ6.-SVuPݳ@pIXư%ġ %R"0]2h6\\*lQbW MaȃfNH mPC,G0d.Tʂb"^Wqq5& cš\D X~Y,-.|:KŃVi"=rCVp}0+$Tld HD I] h* H> ) ;r;ha.@3 BO"H.QXTc,:X"?XKZNz~-I+mzD\If&E\S*ʉV pGmewG `pr0Dn: r" ڔ=ա"G,!{!@CQxJ04!K$MRGhjԾp5^ ']dqP+@f~3]i#,?tH#{dj7$S!\*Xax0ƕe$4Q N\ѹot hqgҎAhx.!mx.‚ I9rrrm-dxilkEnbEcEkܮն5M,T aOggSJ;Fu($,-{{%",.,z+**+')^nGXi.z3mJr͏'T|k xƅSN6dA$EcOIgyi6-)PNț8tt8P̈́HWx,p`Pw⪡^{n-|: ײj V w+48thiD { ^Y>Xr=x6lBG94 "c4ˤe09ISJIA9EEdL}6G-e&sB})( h)UI:rY I!]Pp 8C^ n[,꣬|ߗ:v\j'hްg mVi Nj "2eY1r256RȜfL!,"jŎXP(SʁCTw  Ў(%Am#uD9lKg>joujkgՃCnpFDlaqLJ11&tiZ-GHD}kwd r,0VAC4 *.@ڌ^-ov Av{;[/^=hsx^33-!ghB4ED)\+-JuA*5"`E/|DXz``-\.sp(mf&!VH lT>#K+~_>Q%p"\e,BQM.N$_́ ͟4 :8454 x6K -r1 4;V\5D|sZpRWi<6MQ,"ބN7R eǒ|+x.R&^}rEaIDD6aUKleGKMM¤)e.JIjÀ1@@Xm-zoߙn8mҐv:UrP$\Ds@ AuDQb!Rlcu\۪#@ebj3#V/ \gwHZn?IHzkj'3ùtɽ׵'Y4Jte]Q4F&T,ПDX<-/iT'~/\ >Zkt;|#kۚl8y"C&ƺID Mnf0AZ:lD\iii&ɝGJ`~ s޶isU7I;Dztjt@o$f.3򱃑~=Vg"ӸPJ;~]NEӑbJȐ)]TFuXES- d(u^Q)bt&蟴:al_+9`i!\٥}b (^ތ]q5Ӿ`/V6e|%@gQax>+,VRVN1&"L4@K0t1^Wty|wC m7Ic z,g`S7($DIdi:e@\i`39W L5bn=_4_J@>:n;r=wCʒf׾يwuFJEQ" X@Rr1SPY'd,9ZPY YތswTm<;'~ܖ7X ~va YPD"DDaIʳ Y$0lB$8d@Π_េ3+Rdخ7!תT"$L.@% mदdH P,)x͎T+yݺv@ IEF޼?ە7La/hh ]JBZjM!,Y1#F'f$IP2wTcs7[s;@k&Ըb%Mms[B`R%BA|Ka\ZZ; sd4 ɑݨ A@ũzZ{G>uЈߜNnd==[vf\9jq17ByC!I^Zvw ޔ59sե+ NV^$Ǣ ZX[$$KPM&(Q IP`H C*=)hgZcwpRˆxK4j`IW[|M8`(~4د2|ɢC/Y^eN[iKsZO&J|I'[!L)hf!dd6#Bh5 =j89tEuǨ; *-cX9o E. |e_ȁ}z-òU^ x`AcUD4@ũ"&&0ՌN!@Wu{.<@j GEy`Cұ!ͣ0.^ /V+!cUL pM) 0ʖx MHD$'ʼnT *]F@rh#& '7}ϿkpU#26,J͘ތsW{7`%xsu%g3=+>+Y0J"!DVQ:͢a4ƛ[% )%"%-Ȇjc%!R3 @ &Puf@@@P)dh^ I,YLDO%:hxudvqQh-eȬQ:nkQ}Y|-\}>(hRЦȱ1/ )US2z~`l0]{ʹ\l 7y7i][sa,t]01XRLND Eh,A -4vy@ͨ}2B 0 S "s3mKLFH0*pYωRI25ڽCPJDqޅ "^~-uq_9  Qf ODIDh#2 06CD]: :թ{"&-`!մQplYD-;RD+鈑ZH<NJmg^~3BH!ϰŬ-d> 4o& aR&M [=h1"~vZ+Gxz-lz$R%JBBEda>*K6$)DdXR5Tʼd ].@jH%W PTsMiE:Q\Ioq,@UPAdZtԁ,+ܖ&I`HT[<8 Gy~:I]L_7\cpg* .$F";)-l;_}KzHS"a צGh}(5\JZ\XוLpB;4 .uFvi^FtH3dQ^NB7ƣ*oʈ|F #_RDc=7BǽpO:Ho (ӒB +ڌ$јN/1lny]Y6[b!D~E{z+}&9#0>tVLۖeBnՑAէ޴4;j] \dJe^- J yY&d49&c EEI4:▎1WquJ7N*dDZ.h/Dx?1*EPϵbWoW\f׃~bU8q;rcXDDM8%IR4Ɉ(ÖmXѝڷPM>%!A珠7o/Qep5(!$ ؾ;~ތ˞M&vYPg$6˪{ m8-X@@ҎED4R2q9y ef ]Ti줭cx Pz.}ک,Xq~x12S@]_g7Lc{-X0x@@%`0'o pA<i$!6]v9V 81)XߒƪYwݠ ,\ZH9&H޼61@g^Ͻ qwbxK>X pR$@UUaP,#ALq-OI~ @QزO(op\DXVެ GIV~ߛba' ǂ PX"B1\AA Jl1(r25'Tc/%p;ѴcHA9R0 @D$\om[J)H 7!o ;d ڭ?6i*Nq(,qlGi) !(FႩzzL㏐! " 2"dodtssŪ6*znh) dc^;ZCUD$(WAO#>Sba\W+ZcL"d((Gn"҈(s.fmdak[ @@fB@ 2Mu{34#_`PIڸzvbK\moi##=) ր1A p,BQQPXP)T f9 CA%h0c<#^u&u .lsc>)Am* 0nktb p^@ݘ6C/fX -?WP7K)޸VЩ|zVa"!Kd8٢PI:VPo0heFtß`sAmQ]" Mi6vJCNvV(1~ٯD >>\lE YoVDzWX/Jg9a6rDb\jvF5 {EWߐ(ۉ(+.x@ul >LZ\`dlѱߌu-_Y2c7Lnt0XH%! 0A#& Bq9\Q.DX@}A񾃥NTqex@=V,}.Xڎ vy)f:N,u\Pˉ ض Ȁiv0isl)erq7?ًb eeAHdQYpѷ;7.ȗlQ j ϩF6ڸleXu"#2Q@ 5=ܻ׽\{ZQS5(``O=90\Zo{A  51K9W_8\s˱MRq̄fqį!(Y\nqm%P&B&o`HYdI 8T!ѰZ짂RK\X+Y"EE%%!4v\n細d㓊;ZUD(>kuOlހ.6\_[>-!EHa`UHD ô_/ :=<S }DP))pR5b24mvca@-A,~PKAw'yT[ hvk^ibQ3إCF%&$, 3YШ  @J70( I\P*;1β H68 v0G!v"\5BWJ`$~9:o-ݾ{bs}v`Xp+v.@]%Þg4zP-q aBf%dQDShE &@vo1oFLCLp_5bqh: PM_ ,X,^Zۿrؾȱ[R[GӀld5LPC d ~4eEia톍7{2/J\([4G,ƪi$*ɑ8{rX"u~XI`ޛ@w.uUi? ۋ㙂~\u|M81B(K 8Y f28i0Y]"13 7 $t peDtJ@AsX3gь@H1"LC PGA8@F"qq!ɘV5CSj,9{ER*E%u-?MSFP.M삝(鞊]GL lϙ)B$(+ֱS,DVݯl=-@6ìfC I("D"%[HL& e\FlTZ:s7=k ;JK ?'VHtD ۷ĊPBXמo!fǫi7'^>״H-Y*p+8شH-X*|;!laOD2K( ɉ@TSy1`fNI2r3Tpݠ/,mz-4yLQ2X_BRxMAuՀȡr`#2WFm"ܐ/ˑ\ZOggS@-;);)uq{z{y!$!""$&%r'('(&(~sL})1~307i2PB =n_f%L,!H2fH鐔LوV$n(A+ϳQmvJ( aG~9i%iS~ ,*=!˅Sn{TLjMc)q:M_qlG71b,GF,BEA* l%FŸڴAĀ,nP]qJ1hu;^;s&E *@*!5V,@JpJތs_V )繯|n{^\@8L|0UH#SXc䂦ch^RZLöG#XY6 $ c$c`1^^BFk[Pefr7fJx0 !@$Aˆ0"'@S;32CT ,{SE 5RN JBn0 Cv Ǧ5P`i;zcKOt89#$b*bv.t$Ą4Pq$^O |&ro1Dp8S @F ZЕ@F\q H@<%$X>|ҵr]nX^ovjZp#ul\ddc‚HJ 9 fCW@A3g H\hh:X`q#sE.eEq BS@^]oz'Gh6;#睺כuzF'Gfs<;|6 Ɉ%Ä "Y$ `%\썴IXpD[@Ӄ%D0,\1 oZճˮ{7=uLl =Č l-A ,@AhTK$72(>uD1.7#xX۰Sk'r0, s[޼7 9yƗTXVm93kP^odhbA&1)lqMIj0<0q7 S,.wVDmd*#<ۙLۙ @v"D4'e/l \"P"H;r,שd{v{j e( PQ1㸘_`i\!!Bd0 '/*]Qd&=qMCFd)vt a4ld*T]̟?kQ"I@J\ٺo[0Wme뺍C/\yS)@sBT]qua3BRZ{3pVplٛ1es juQ~vJ uɈ$"HD2 DQ,#jI/4<(,Lޏ?! Veo]ڜߛ7j+/܂T\JS1`/f(޴e]^-v\*빎nqq) 3UB"DIF|QЎ/"0ᅥܖlAR /pz;@Yp} R3 6=Nڮc򔺾,78j] ),L n0Nl!(dCI$%"arrIW̫kI%!]TRxѡ棛`IE/y"rxu&v@ vS.PTfvw ( N/܏B h\ʻ\cmin pevCM'Q>i󎅤7.\WC֤(2!=`VJ4_L?L _5dc3.EhK``~QaBZR8Uc pk-a$׌M K,K`w[wE,ġ KS]d}'! E"@07gqa ZTѺ-@IN8ꋌSol8Ejv@3lv#:H,$${O:j ^l]y yĮ}XXYi;1eo LIpF"&F(%bIύXՔSs½HdY*6"$Hk:XDn. Ѭq2SY  #bw&(k{U\'֯$7Ylj i ,naz(x*#4! !ő * hQADf㘅1*! qׁkXBvf]*"\wUY#{6#ADL[\^X?Q֯/-"$.F~}ޔ <7*[XL̥20Jm u(:Z,`;hT!5dw`|\7s X@JHy"<ԏEeJ Gc>mDx $ 6( :QU?H4 1T2U 4E;^ Qِv zxt $6,M c;<{wݛvt0KF>d H(Q;$i603hŠY<5E_2?[Wn łdQ@tY˦J.i5}\]fl{8,BM {L[$f_17ZhH74K0ML[]_i]@,јIC72)",I z.3n%!zxݽ7oT < $x4 "\c_/Fޜ~=4cBHnuh揍obgP1B  % $+4"/@ZYT:0Q8ҧ.s-m6Gg7`P 5iDH7ތ׆0Sx-M?r\KaR@f=D  %S!!KȭDD!DQi1pnӪ6:N(r&#NFux"`E]h{B=#T".|:lѾ95@^޴kv 'TcxY?g7ÿ֘aN~f^)# H-&R"YVF$L)k! C#=k5 qbslsp)LK8̟ HbJ$$W >\"wŭ~w /c '^> 6vɣD9D0$H``Thy)T*y@rR&D<| 5M WddtBn+р#/n>\ug=??~rxAʄ)JVJ$G@rTDE 7_P5I1-ݜdF P[sJ7$TG R=A- 5cn!OggS;O؈-tu "" $$),$y((&(+*}x%$*(د0Sc.o[z,_k&IDB@0) I`L4!DT:@pñ@' 0v<`ɗ1( !;4 L.f8\e41 R25ӛc5BZ_~;iQYE8WN`y?+z)u sHL{ߋ4a: ^ d*Dݯ/~}%6@%FG l)Toaoz $buMKFa&w Dᯯ:}l2e2@Y0 >! LUzkjYD(PBL1v`I\ D [^0P_CZYCVС P,D폿 o%0R])=NaB2TuA v6:h C-FeT]lN^^ZGL_7n|22X5g {{dHk~5$$y VoY V˼G>3`(#!A<*bJ lsW?WxM`TaXpb0XկWdoxLVk`APڎktfMF ތ5k޴+/݉wzCBX)/$,V7~8z HG>A@D'CQh@S p iD 5W G( L5iQM`q&M ,gj ?(ދ޴Xn;Ջn7sez{>x-G9AȄ (!Rh )Nb&B >+E lFr6toy8%@/B浌JV0{޴Q ٿuvaE ?s!k&pԧdVt 6g sRThC:pxb g1~HdC, (ߌ ?4[L[qdXjNC K?\|+D߲q.J:; irk. BkrD˩X6x2L"]9߹m㢢-:;8еXO^1=t~(]B`vNj'J9Gh4JRH@"HDsa`ed Y\Ԡu4 _ʺ}7.9=-13X$4h2KnJqVO ؍%q!%D\˰Aފ? |-2-poAQ J!Ja"Q8 @Y tNJN r3Ŝ}^:Yz!wX]2҆@X_7;ca[Ԋ#2 ʽ&S~ .ߊNo R\,w>XQ ŒH1Pŕ |, /ɕ>r dth%lȽƶ,Vdȝ7e^W \Jɸs$~ʽHow{Şy?^M0|w᳒M )9ESK Y'c:6)}?KbXn$0HH!D|]##J|R۝Pb}(.,T,>ug&dkMwN\&`9˒ =\ JRDQM7JD [ :񉀗~ˤQa"3so^X4zqVI>"RAI LTIӘX!mj\tЎi̜DIL-Ң,dRQG䒂 C :)KX[CCi⣚d*(" 6FB諙  R"xU?~N@,Ԫ"mҌ%)z$*_Q`UH2k Q炎:3Q`9@0:լRÁYZӺf?i۩LkA51R-!EKqS,,c&Yiaql(}t~g\r2<<;vIG.gi&2 Ynj.soOaOb`$(6"OY*،y#aC6إ Ԉ,T/+CS5.Qь5 T3no>} ,@vFZѐX$ Aak8 6~opoJ D` 6\V@tYVdjfun-audio-visualizer-python-f03a3a6/tests/data/inputfiles/test.png000066400000000000000000000003341514343513600257650ustar00rootroot00000000000000PNG  IHDROPLTEIDATx@מ0}vIENDB`djfun-audio-visualizer-python-f03a3a6/tests/test_commandline_export.py000066400000000000000000000021231514343513600265030ustar00rootroot00000000000000import sys import os import tempfile from . import command, getTestDataPath, MockSignal from pytestqt import qtbot def test_commandline_classic_export(qtbot, command): """Run Qt event loop and create a video in the system /tmp or /temp""" soundFile = getTestDataPath("inputfiles/test.ogg") outputDir = tempfile.mkdtemp(prefix="avp-export-") outputFilename = os.path.join(outputDir, "output.mp4") sys.argv = [ "", "-c", "0", "classic", "color=255,255,255", "-i", soundFile, "-o", outputFilename, ] command.parseArgs() # Command object now has a video_thread Worker which is exporting the video with qtbot.waitSignal(command.worker.videoCreated, timeout=10000): """ Wait until videoCreated is emitted by the video_thread Worker or until 10 second timeout has passed """ print(f"Test Video created at {outputFilename}") assert os.path.exists(outputFilename) # output video should be at least 200kb assert os.path.getsize(outputFilename) > 200000 djfun-audio-visualizer-python-f03a3a6/tests/test_commandline_parser.py000066400000000000000000000026241514343513600264640ustar00rootroot00000000000000import sys import pytest from pytestqt import qtbot from . import command def test_commandline_help(qtbot, command): sys.argv = ["", "--help"] with pytest.raises(SystemExit): command.parseArgs() def test_commandline_help_if_bad_args(qtbot, command): sys.argv = ["", "--junk"] with pytest.raises(SystemExit): command.parseArgs() def test_commandline_launches_gui_if_verbose(qtbot, command): sys.argv = ["", "--verbose"] mode = command.parseArgs() assert mode == "GUI" def test_commandline_launches_gui_if_verbose_with_project(qtbot, command): sys.argv = ["", "test", "--verbose"] mode = command.parseArgs() assert mode == "GUI" def test_commandline_tries_to_export(qtbot, command): didCallFunction = False def captureFunction(*args): nonlocal didCallFunction didCallFunction = True sys.argv = ["", "-c", "0", "classic", "-i", "_", "-o", "_"] command.createAudioVisualization = captureFunction command.parseArgs() assert didCallFunction def test_commandline_parses_classic_by_alias(qtbot, command): assert command.parseCompName("original") == "Classic Visualizer" def test_commandline_parses_conway_by_short_name(qtbot, command): assert command.parseCompName("conway") == "Conway's Game of Life" def test_commandline_parses_image_by_name(qtbot, command): assert command.parseCompName("image") == "Image" djfun-audio-visualizer-python-f03a3a6/tests/test_comp_classic.py000066400000000000000000000065151514343513600252640ustar00rootroot00000000000000from avp.toolkit.visualizer import transformData from pytestqt import qtbot from pytest import fixture, mark from . import audioData, command, imageDataSum, preFrameRender sampleSize = 1470 # 44100 / 30 = 1470 def createSpectrumArray(audioData): """Creates enough `spectrumArray` for one call to Component.drawBars()""" spectrumArray = {0: transformData(0, audioData[0], sampleSize, 0.08, 0.8, None, 20)} for i in range(sampleSize, len(audioData[0]), sampleSize): spectrumArray[i] = transformData( i, audioData[0], sampleSize, 0.08, 0.8, spectrumArray[i - sampleSize].copy(), 20, ) return spectrumArray @fixture def coreWithClassicComp(qtbot, command): """Fixture providing a Command object with Classic Visualizer component added""" command.core.insertComponent( 0, command.core.moduleIndexFor("Classic Visualizer"), command ) yield command.core def test_comp_classic_added(coreWithClassicComp): """Add Classic Visualizer to core""" assert len(coreWithClassicComp.selectedComponents) == 1 def test_comp_classic_removed(coreWithClassicComp): """Remove Classic Visualizer from core""" coreWithClassicComp.removeComponent(0) assert len(coreWithClassicComp.selectedComponents) == 0 @mark.parametrize("layout", (0, 1, 2, 3)) def test_comp_classic_drawBars(coreWithClassicComp, audioData, layout): """Call drawBars after creating audio spectrum data manually.""" spectrumArray = createSpectrumArray(audioData) comp = coreWithClassicComp.selectedComponents[0] image = comp.drawBars( 1920, 1080, spectrumArray[sampleSize * 4], (0, 0, 0), layout, None ) imageSize = 37872316 assert imageDataSum(image) == imageSize if layout < 2 else imageSize / 2 def test_comp_classic_drawBars_using_preFrameRender(coreWithClassicComp, audioData): """Call drawBars after creating audio spectrum data using preFrameRender.""" comp = coreWithClassicComp.selectedComponents[0] preFrameRender(audioData, comp) image = comp.drawBars( 1920, 1080, coreWithClassicComp.selectedComponents[0].spectrumArray[sampleSize * 4], (0, 0, 0), 0, None, ) assert imageDataSum(image) == 37872316 def test_comp_classic_command_layout(coreWithClassicComp): comp = coreWithClassicComp.selectedComponents[0] comp.command("layout=top") assert comp.layout == 3 def test_comp_classic_command_color(coreWithClassicComp): comp = coreWithClassicComp.selectedComponents[0] comp.command("color=111,111,111") assert comp.visColor == (111, 111, 111) def test_comp_classic_command_preset(coreWithClassicComp): comp = coreWithClassicComp.selectedComponents[0] saveValueStore = comp.savePreset() saveValueStore["preset"] = "testPreset" coreWithClassicComp.createPresetFile( comp.name, comp.version, "testPreset", saveValueStore ) comp.command("preset=testPreset") assert comp.currentPreset == "testPreset" def test_comp_classic_loadPreset(coreWithClassicComp): comp = coreWithClassicComp.selectedComponents[0] comp.scale = 99 saveValueStore = comp.savePreset() saveValueStore["preset"] = "testPreset" comp.scale = 20 comp.loadPreset(saveValueStore, "testPreset") assert comp.scale == 99 djfun-audio-visualizer-python-f03a3a6/tests/test_comp_color.py000066400000000000000000000021211514343513600247460ustar00rootroot00000000000000from avp.command import Command from pytestqt import qtbot from pytest import fixture from . import imageDataSum, command @fixture def coreWithColorComp(qtbot, command): """Fixture providing a Command object with Color component added""" command.settings.setValue("outputHeight", 1080) command.settings.setValue("outputWidth", 1920) command.core.insertComponent(0, command.core.moduleIndexFor("Color"), command) yield command.core def test_comp_color_set_color(coreWithColorComp): """Set imagePath of Image component""" comp = coreWithColorComp.selectedComponents[0] comp.page.lineEdit_color1.setText("111,111,111") image = comp.previewRender() assert imageDataSum(image) == 1219276800 def test_comp_color_gradient(coreWithColorComp): """Test changing fill type to a gradient""" comp = coreWithColorComp.selectedComponents[0] comp.page.comboBox_fill.setCurrentIndex(1) comp.page.lineEdit_color1.setText("0,0,0") comp.page.lineEdit_color2.setText("255,255,255") image = comp.previewRender() assert imageDataSum(image) == 1849285965 djfun-audio-visualizer-python-f03a3a6/tests/test_comp_image.py000066400000000000000000000041021514343513600247130ustar00rootroot00000000000000import os from avp.command import Command from pytestqt import qtbot from pytest import fixture from . import audioData, command, MockSignal, imageDataSum, getTestDataPath sampleSize = 1470 # 44100 / 30 = 1470 testFile = "inputfiles/test.jpg" @fixture def coreWithImageComp(qtbot, command): """Fixture providing a Command object with Image component added""" command.settings.setValue("outputHeight", 1080) command.settings.setValue("outputWidth", 1920) command.core.insertComponent(0, command.core.moduleIndexFor("Image"), command) yield command.core def test_comp_image_set_path(coreWithImageComp): "Set imagePath of Image component" comp = coreWithImageComp.selectedComponents[0] comp.imagePath = getTestDataPath(testFile) image = comp.previewRender() assert imageDataSum(image) == 463711601 def test_comp_image_scale_50_1080p(coreWithImageComp): """Image component stretches image to 50% at 1080p""" comp = coreWithImageComp.selectedComponents[0] comp.imagePath = getTestDataPath(testFile) image = comp.previewRender() sum = imageDataSum(image) comp.page.spinBox_scale.setValue(50) assert imageDataSum(comp.previewRender()) - sum / 4 < 2000 def test_comp_image_scale_50_720p(coreWithImageComp): """Image component stretches image to 50% at 720p""" comp = coreWithImageComp.selectedComponents[0] comp.imagePath = getTestDataPath(testFile) comp.page.spinBox_scale.setValue(50) image = comp.previewRender() sum = imageDataSum(image) comp.parent.settings.setValue("outputHeight", 720) comp.parent.settings.setValue("outputWidth", 1280) newImage = comp.previewRender() assert image.width == 1920 assert newImage.width == 1280 assert imageDataSum(comp.previewRender()) == sum def test_comp_image_command_path(coreWithImageComp): """Image component accepts commandline argument: `path=test.jpg`""" imgPath = os.path.realpath(getTestDataPath(testFile)) comp = coreWithImageComp.selectedComponents[0] comp.command(f"path={imgPath}") assert comp.imagePath == imgPath djfun-audio-visualizer-python-f03a3a6/tests/test_comp_life.py000066400000000000000000000013161514343513600245540ustar00rootroot00000000000000from avp.command import Command from pytestqt import qtbot from pytest import fixture from . import imageDataSum, command @fixture def coreWithLifeComp(qtbot, command): """Fixture providing a Command object with Waveform component added""" command.settings.setValue("outputHeight", 1080) command.settings.setValue("outputWidth", 1920) command.core.insertComponent( 0, command.core.moduleIndexFor("Conway's Game of Life"), command ) yield command.core def test_comp_life_previewRender(coreWithLifeComp): comp = coreWithLifeComp.selectedComponents[0] comp.page.lineEdit_color.setText("111,111,111") image = comp.previewRender() assert imageDataSum(image) == 339785512 djfun-audio-visualizer-python-f03a3a6/tests/test_comp_spectrum.py000066400000000000000000000017001514343513600254740ustar00rootroot00000000000000from avp.command import Command from pytestqt import qtbot from pytest import fixture from . import ( imageDataSum, command, preFrameRender, audioData, ) @fixture def coreWithSpectrumComp(qtbot, command): """Fixture providing a Command object with Spectrum component added""" command.settings.setValue("outputHeight", 1080) command.settings.setValue("outputWidth", 1920) command.core.insertComponent(0, command.core.moduleIndexFor("Spectrum"), command) yield command.core def test_comp_spectrum_previewRender(coreWithSpectrumComp): comp = coreWithSpectrumComp.selectedComponents[0] image = comp.previewRender() assert imageDataSum(image) == 71992628 def test_comp_spectrum_renderFrame(coreWithSpectrumComp, audioData): comp = coreWithSpectrumComp.selectedComponents[0] preFrameRender(audioData, comp) image = comp.frameRender(0) comp.postFrameRender() assert imageDataSum(image) == 117 djfun-audio-visualizer-python-f03a3a6/tests/test_comp_text.py000066400000000000000000000030021514343513600246130ustar00rootroot00000000000000from avp.command import Command from PyQt6.QtGui import QFont from pytestqt import qtbot from pytest import fixture, mark from . import audioData, command, MockSignal, imageDataSum @fixture def coreWithTextComp(qtbot, command): """Fixture providing a Command object with Title Text component added""" command.core.insertComponent(0, command.core.moduleIndexFor("Title Text"), command) yield command.core def setTextSettings(comp): comp.page.spinBox_fontSize.setValue(40) comp.page.checkBox_shadow.setChecked(True) comp.page.spinBox_shadBlur.setValue(0) comp.page.spinBox_shadX.setValue(2) comp.page.spinBox_shadY.setValue(-2) comp.page.fontComboBox_titleFont.setCurrentFont(QFont("Noto Sans")) comp.page.lineEdit_textColor.setText("255,255,255") @mark.parametrize( "width, height", ((1920, 1080), (1280, 720)), ) def test_comp_text_renderFrame(coreWithTextComp, width, height): """Call renderFrame of Title Text component added to Command object.""" comp = coreWithTextComp.selectedComponents[0] comp.parent.settings.setValue("outputWidth", width) comp.parent.settings.setValue("outputHeight", height) setTextSettings(comp) comp.centerXY() image = comp.frameRender(0) assert comp.titleFont.family() == "Noto Sans" assert comp.xPosition == width / 2 assert image.width == width assert comp.fontSize == 40 assert comp.shadX == 2 assert comp.shadY == -2 assert comp.shadBlur == 0 assert imageDataSum(image) == 727403 or 738586 djfun-audio-visualizer-python-f03a3a6/tests/test_comp_waveform.py000066400000000000000000000026671514343513600254750ustar00rootroot00000000000000from pytestqt import qtbot from pytest import fixture from avp.toolkit.ffmpeg import checkFfmpegVersion from . import command, imageDataSum, audioData, preFrameRender @fixture def coreWithWaveformComp(qtbot, command): """Fixture providing a Command object with Waveform component added""" command.settings.setValue("outputWidth", 1920) command.settings.setValue("outputHeight", 1080) command.core.insertComponent(0, command.core.moduleIndexFor("Waveform"), command) yield command.core def test_comp_waveform_setColor(coreWithWaveformComp): comp = coreWithWaveformComp.selectedComponents[0] comp.page.lineEdit_color.setText("255,255,255") assert comp.color == (255, 255, 255) def test_comp_waveform_previewRender(coreWithWaveformComp): comp = coreWithWaveformComp.selectedComponents[0] comp.page.lineEdit_color.setText("255,255,255") _, version = checkFfmpegVersion() if version > 6: # FFmpeg 8 has different colors from 6 # TODO check version 7 assert imageDataSum(comp.previewRender()) == 36114120 else: assert imageDataSum(comp.previewRender()) == 37210620 def test_comp_waveform_renderFrame(coreWithWaveformComp, audioData): comp = coreWithWaveformComp.selectedComponents[0] comp.page.lineEdit_color.setText("255,255,255") preFrameRender(audioData, comp) image = comp.frameRender(0) comp.postFrameRender() assert imageDataSum(image) == 8331360 djfun-audio-visualizer-python-f03a3a6/tests/test_core_init.py000066400000000000000000000013341514343513600245720ustar00rootroot00000000000000import os from avp.core import Core from . import getTestDataPath, settings def test_component_names(settings): core = Core() assert core.compNames == [ "Classic Visualizer", "Color", "Conway's Game of Life", "Image", "Sound", "Spectrum", "Title Text", "Video", "Waveform", ] def test_moduleindex(settings): core = Core() assert core.moduleIndexFor("Classic Visualizer") == 0 def test_configPath_default(): configPath = Core.getConfigPath(None) assert os.path.basename(configPath) == "audio-visualizer" def test_configPath_nonstandard(): assert Core.getConfigPath(getTestDataPath("config")) == getTestDataPath("config") djfun-audio-visualizer-python-f03a3a6/tests/test_mainwindow_comp_actions.py000066400000000000000000000047511514343513600275370ustar00rootroot00000000000000"""Tests of MainWindow undoing certain ComponentActions (changes to component settings)""" from pytest import fixture from pytestqt import qtbot from avp.gui.mainwindow import MainWindow from . import getTestDataPath, window def test_undo_classic_visualizer_sensitivity(window, qtbot): """Undo Classic Visualizer component sensitivity setting should undo multiple merged actions.""" window.core.insertComponent( 0, window.core.moduleIndexFor("Classic Visualizer"), window ) comp = window.core.selectedComponents[0] comp.imagePath = getTestDataPath("inputfiles/test.jpg") for i in range(1, 100): comp.page.spinBox_scale.setValue(i) assert comp.scale == 99 window.undoStack.undo() assert comp.scale == 20 def test_undo_image_scale(window, qtbot): """Undo Image component scale setting should undo multiple merged actions.""" window.core.insertComponent(0, window.core.moduleIndexFor("Image"), window) comp = window.core.selectedComponents[0] comp.imagePath = getTestDataPath("inputfiles/test.jpg") comp.page.spinBox_scale.setValue(100) for i in range(10, 401): comp.page.spinBox_scale.setValue(i) assert comp.scale == 400 window.undoStack.undo() assert comp.scale == 10 window.undoStack.undo() assert comp.scale == 100 def test_undo_image_resizeMode(window, qtbot): window.core.insertComponent(0, window.core.moduleIndexFor("Image"), window) comp = window.core.selectedComponents[0] comp.page.comboBox_resizeMode.setCurrentIndex(1) assert not comp.page.spinBox_scale.isEnabled() window.undoStack.undo() assert comp.page.spinBox_scale.isEnabled() def test_undo_title_text_merged(window, qtbot): """Undoing title text change should undo all recent changes.""" window.core.insertComponent(0, window.core.moduleIndexFor("Title Text"), window) comp = window.core.selectedComponents[0] comp.page.lineEdit_title.setText("avp") comp.page.lineEdit_title.setText("test") window.undoStack.undo() assert comp.title == "Text" def test_undo_title_text_not_merged(window, qtbot): """Undoing title text change should undo up to previous different action""" window.core.insertComponent(0, window.core.moduleIndexFor("Title Text"), window) comp = window.core.selectedComponents[0] comp.page.lineEdit_title.setText("avp") comp.page.spinBox_xTextAlign.setValue(0) comp.page.lineEdit_title.setText("test") window.undoStack.undo() assert comp.title == "avp" djfun-audio-visualizer-python-f03a3a6/tests/test_mainwindow_list_actions.py000066400000000000000000000033641514343513600275530ustar00rootroot00000000000000"""Tests of `actions.py` - MainWindow component list manipulation via undoable actions""" from PyQt6 import QtCore import os from pytest import fixture from pytestqt import qtbot from . import getTestDataPath, window def test_mainwindow_addComponent(qtbot, window): window.compMenu.actions()[0].trigger() assert len(window.core.selectedComponents) == 1 def test_mainwindow_removeComponent(qtbot, window): window.compMenu.actions()[0].trigger() # add component window.pushButton_removeComponent.click() # remove it assert len(window.core.selectedComponents) == 0 def test_mainwindow_moveComponent(qtbot, window): # add first two components from menu window.compMenu.actions()[0].trigger() window.compMenu.actions()[1].trigger() comp0 = window.core.selectedComponents[0].ui window.pushButton_listMoveDown.click() # check if 0 is now 1 assert window.core.selectedComponents[1].ui == comp0 def test_mainwindow_addComponent_undo(qtbot, window): window.compMenu.actions()[0].trigger() window.undoStack.undo() assert len(window.core.selectedComponents) == 0 def test_mainwindow_removeComponent_undo(qtbot, window): window.compMenu.actions()[0].trigger() # add component window.pushButton_removeComponent.click() # remove it window.undoStack.undo() assert len(window.core.selectedComponents) == 1 def test_mainwindow_moveComponent_undo(qtbot, window): # add first two components from menu window.compMenu.actions()[0].trigger() window.compMenu.actions()[1].trigger() comp0 = window.core.selectedComponents[0].ui window.pushButton_listMoveDown.click() window.undoStack.undo() # check if 0 is still 0 after undo assert window.core.selectedComponents[1].ui != comp0 djfun-audio-visualizer-python-f03a3a6/tests/test_mainwindow_projects.py000066400000000000000000000035151514343513600267070ustar00rootroot00000000000000from PyQt6 import QtCore import os from pytest import fixture from pytestqt import qtbot from avp.gui.mainwindow import MainWindow from . import getTestDataPath, window, settings def test_mainwindow_init_with_project(qtbot, settings): window = MainWindow(getTestDataPath("config/projects/testproject.avp"), None) qtbot.addWidget(window) assert window.core.selectedComponents[0].name == "Classic Visualizer" assert window.core.selectedComponents[1].name == "Color" def test_mainwindow_clear(qtbot, window): """MainWindow.clear() gives us a clean slate""" assert len(window.core.selectedComponents) == 0 def test_mainwindow_presetDir_in_tests(qtbot, window): """`presetDir` is the filepath on which "Import Preset" file picker opens""" assert os.path.basename(window.core.settings.value("presetDir")) == "presets" def test_mainwindow_openProject(qtbot, window): """Open testproject.avp using MainWindow.openProject()""" window.openProject(getTestDataPath("config/projects/testproject.avp"), prompt=False) assert len(window.core.selectedComponents) == 2 def test_mainwindow_newProject_without_unsaved_changes(qtbot, window): """Starting new project without unsaved changes""" didCallFunction = False def captureFunction(*args, **kwargs): nonlocal didCallFunction didCallFunction = True window.createNewProject(prompt=False) assert not didCallFunction assert len(window.core.selectedComponents) == 0 def test_mainwindow_newProject_with_unsaved_changes(qtbot, window): """Starting new project with unsaved changes""" didCallFunction = False def captureFunction(*args, **kwargs): nonlocal didCallFunction didCallFunction = True window.openSaveChangesDialog = captureFunction window.createNewProject(prompt=True) assert didCallFunction djfun-audio-visualizer-python-f03a3a6/tests/test_toolkit_common.py000066400000000000000000000022641514343513600256570ustar00rootroot00000000000000from pytest import fixture from pytestqt import qtbot from avp.command import Command from avp.toolkit import blockSignals, rgbFromString from . import command @fixture def gotWarning(): """Check if a function called log.warning""" import avp.toolkit.common as tk warning = False def gotWarning(): nonlocal warning return warning class log: def warning(self, *args): nonlocal warning warning = True oldLog = tk.log tk.log = log() try: yield gotWarning finally: tk.log = oldLog def test_blockSignals(qtbot, command): command.core.insertComponent(0, 0, command) comp = command.core.selectedComponents[0] assert comp.page.spinBox_scale.signalsBlocked() == False with blockSignals(comp.page.spinBox_scale): assert comp.page.spinBox_scale.signalsBlocked() == True assert comp.page.spinBox_scale.signalsBlocked() == False def test_rgbFromString(gotWarning): assert rgbFromString("255,255,255") == (255, 255, 255) assert not gotWarning() def test_rgbFromString_error(gotWarning): assert rgbFromString("255,255,256") == (255, 255, 255) assert gotWarning() djfun-audio-visualizer-python-f03a3a6/tests/test_toolkit_ffmpeg.py000066400000000000000000000025521514343513600256330ustar00rootroot00000000000000import pytest from avp.toolkit.ffmpeg import createFfmpegCommand from . import audioData, getTestDataPath, command def test_readAudioFile_data(audioData): assert len(audioData[0]) == 218453 def test_readAudioFile_duration(audioData): assert audioData[1] == 3.95 @pytest.mark.parametrize("width, height", ((1920, 1080), (1280, 720))) def test_createFfmpegCommand(command, width, height): command.settings.setValue("outputWidth", width) command.settings.setValue("outputHeight", height) ffmpegCmd = createFfmpegCommand("test.ogg", "/tmp", command.core.selectedComponents) assert ffmpegCmd == [ "ffmpeg", "-loglevel", "info", "-thread_queue_size", "512", "-y", "-f", "rawvideo", "-vcodec", "rawvideo", "-s", "%sx%s" % (width, height), "-pix_fmt", "rgba", "-r", "30", "-t", "0.100", "-an", "-i", "-", "-t", "0.100", "-i", "test.ogg", "-map", "0:v", "-map", "1:a", "-vcodec", "libx264", "-acodec", "aac", "-b:v", "2500k", "-b:a", "192k", "-pix_fmt", "yuv420p", "-preset", "medium", "-f", "mp4", "/tmp", ] djfun-audio-visualizer-python-f03a3a6/tests/test_toolkit_frame.py000066400000000000000000000006711514343513600254610ustar00rootroot00000000000000import numpy from avp.toolkit.frame import BlankFrame, FloodFrame def test_blank_frame(): """BlankFrame creates a frame of all zeros""" assert numpy.asarray(BlankFrame(1920, 1080), dtype="int32").sum() == 0 def test_flood_frame(): """FloodFrame given (1, 1, 1, 1) creates a frame of sum 1920 * 1080 * 4""" assert numpy.asarray(FloodFrame(1920, 1080, (1, 1, 1, 1)), dtype="int32").sum() == ( 1920 * 1080 * 4 ) djfun-audio-visualizer-python-f03a3a6/tests/test_version.py000066400000000000000000000005201514343513600243000ustar00rootroot00000000000000from avp import __version__ from tomllib import load import os def test_version_number_matches(): with open( os.path.join( os.path.dirname(os.path.realpath(__file__)), "..", "pyproject.toml" ), "rb", ) as fp: config = load(fp) assert config["project"]["version"] == __version__ djfun-audio-visualizer-python-f03a3a6/uv.lock000066400000000000000000001470011514343513600213520ustar00rootroot00000000000000version = 1 revision = 3 requires-python = ">=3.12" [[package]] name = "audio-visualizer-python" version = "2.2.4" source = { editable = "." } dependencies = [ { name = "numpy" }, { name = "pillow" }, { name = "pyqt6" }, ] [package.dev-dependencies] dev = [ { name = "pytest" }, { name = "pytest-qt" }, ] [package.metadata] requires-dist = [ { name = "numpy", specifier = ">=2.4.1" }, { name = "pillow", specifier = ">=12.1.0" }, { name = "pyqt6", specifier = ">=6.10.2" }, ] [package.metadata.requires-dev] dev = [ { name = "pytest" }, { name = "pytest-qt" }, ] [[package]] name = "colorama" version = "0.4.6" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] [[package]] name = "iniconfig" version = "2.3.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] [[package]] name = "numpy" version = "2.4.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/24/62/ae72ff66c0f1fd959925b4c11f8c2dea61f47f6acaea75a08512cdfe3fed/numpy-2.4.1.tar.gz", hash = "sha256:a1ceafc5042451a858231588a104093474c6a5c57dcc724841f5c888d237d690", size = 20721320, upload-time = "2026-01-10T06:44:59.619Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/78/7f/ec53e32bf10c813604edf07a3682616bd931d026fcde7b6d13195dfb684a/numpy-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d3703409aac693fa82c0aee023a1ae06a6e9d065dba10f5e8e80f642f1e9d0a2", size = 16656888, upload-time = "2026-01-10T06:42:40.913Z" }, { url = "https://files.pythonhosted.org/packages/b8/e0/1f9585d7dae8f14864e948fd7fa86c6cb72dee2676ca2748e63b1c5acfe0/numpy-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7211b95ca365519d3596a1d8688a95874cc94219d417504d9ecb2df99fa7bfa8", size = 12373956, upload-time = "2026-01-10T06:42:43.091Z" }, { url = "https://files.pythonhosted.org/packages/8e/43/9762e88909ff2326f5e7536fa8cb3c49fb03a7d92705f23e6e7f553d9cb3/numpy-2.4.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:5adf01965456a664fc727ed69cc71848f28d063217c63e1a0e200a118d5eec9a", size = 5202567, upload-time = "2026-01-10T06:42:45.107Z" }, { url = "https://files.pythonhosted.org/packages/4b/ee/34b7930eb61e79feb4478800a4b95b46566969d837546aa7c034c742ef98/numpy-2.4.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:26f0bcd9c79a00e339565b303badc74d3ea2bd6d52191eeca5f95936cad107d0", size = 6549459, upload-time = "2026-01-10T06:42:48.152Z" }, { url = "https://files.pythonhosted.org/packages/79/e3/5f115fae982565771be994867c89bcd8d7208dbfe9469185497d70de5ddf/numpy-2.4.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0093e85df2960d7e4049664b26afc58b03236e967fb942354deef3208857a04c", size = 14404859, upload-time = "2026-01-10T06:42:49.947Z" }, { url = "https://files.pythonhosted.org/packages/d9/7d/9c8a781c88933725445a859cac5d01b5871588a15969ee6aeb618ba99eee/numpy-2.4.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ad270f438cbdd402c364980317fb6b117d9ec5e226fff5b4148dd9aa9fc6e02", size = 16371419, upload-time = "2026-01-10T06:42:52.409Z" }, { url = "https://files.pythonhosted.org/packages/a6/d2/8aa084818554543f17cf4162c42f162acbd3bb42688aefdba6628a859f77/numpy-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:297c72b1b98100c2e8f873d5d35fb551fce7040ade83d67dd51d38c8d42a2162", size = 16182131, upload-time = "2026-01-10T06:42:54.694Z" }, { url = "https://files.pythonhosted.org/packages/60/db/0425216684297c58a8df35f3284ef56ec4a043e6d283f8a59c53562caf1b/numpy-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cf6470d91d34bf669f61d515499859fa7a4c2f7c36434afb70e82df7217933f9", size = 18295342, upload-time = "2026-01-10T06:42:56.991Z" }, { url = "https://files.pythonhosted.org/packages/31/4c/14cb9d86240bd8c386c881bafbe43f001284b7cce3bc01623ac9475da163/numpy-2.4.1-cp312-cp312-win32.whl", hash = "sha256:b6bcf39112e956594b3331316d90c90c90fb961e39696bda97b89462f5f3943f", size = 5959015, upload-time = "2026-01-10T06:42:59.631Z" }, { url = "https://files.pythonhosted.org/packages/51/cf/52a703dbeb0c65807540d29699fef5fda073434ff61846a564d5c296420f/numpy-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:e1a27bb1b2dee45a2a53f5ca6ff2d1a7f135287883a1689e930d44d1ff296c87", size = 12310730, upload-time = "2026-01-10T06:43:01.627Z" }, { url = "https://files.pythonhosted.org/packages/69/80/a828b2d0ade5e74a9fe0f4e0a17c30fdc26232ad2bc8c9f8b3197cf7cf18/numpy-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:0e6e8f9d9ecf95399982019c01223dc130542960a12edfa8edd1122dfa66a8a8", size = 10312166, upload-time = "2026-01-10T06:43:03.673Z" }, { url = "https://files.pythonhosted.org/packages/04/68/732d4b7811c00775f3bd522a21e8dd5a23f77eb11acdeb663e4a4ebf0ef4/numpy-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d797454e37570cfd61143b73b8debd623c3c0952959adb817dd310a483d58a1b", size = 16652495, upload-time = "2026-01-10T06:43:06.283Z" }, { url = "https://files.pythonhosted.org/packages/20/ca/857722353421a27f1465652b2c66813eeeccea9d76d5f7b74b99f298e60e/numpy-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82c55962006156aeef1629b953fd359064aa47e4d82cfc8e67f0918f7da3344f", size = 12368657, upload-time = "2026-01-10T06:43:09.094Z" }, { url = "https://files.pythonhosted.org/packages/81/0d/2377c917513449cc6240031a79d30eb9a163d32a91e79e0da47c43f2c0c8/numpy-2.4.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:71abbea030f2cfc3092a0ff9f8c8fdefdc5e0bf7d9d9c99663538bb0ecdac0b9", size = 5197256, upload-time = "2026-01-10T06:43:13.634Z" }, { url = "https://files.pythonhosted.org/packages/17/39/569452228de3f5de9064ac75137082c6214be1f5c532016549a7923ab4b5/numpy-2.4.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:5b55aa56165b17aaf15520beb9cbd33c9039810e0d9643dd4379e44294c7303e", size = 6545212, upload-time = "2026-01-10T06:43:15.661Z" }, { url = "https://files.pythonhosted.org/packages/8c/a4/77333f4d1e4dac4395385482557aeecf4826e6ff517e32ca48e1dafbe42a/numpy-2.4.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0faba4a331195bfa96f93dd9dfaa10b2c7aa8cda3a02b7fd635e588fe821bf5", size = 14402871, upload-time = "2026-01-10T06:43:17.324Z" }, { url = "https://files.pythonhosted.org/packages/ba/87/d341e519956273b39d8d47969dd1eaa1af740615394fe67d06f1efa68773/numpy-2.4.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e3087f53e2b4428766b54932644d148613c5a595150533ae7f00dab2f319a8", size = 16359305, upload-time = "2026-01-10T06:43:19.376Z" }, { url = "https://files.pythonhosted.org/packages/32/91/789132c6666288eaa20ae8066bb99eba1939362e8f1a534949a215246e97/numpy-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:49e792ec351315e16da54b543db06ca8a86985ab682602d90c60ef4ff4db2a9c", size = 16181909, upload-time = "2026-01-10T06:43:21.808Z" }, { url = "https://files.pythonhosted.org/packages/cf/b8/090b8bd27b82a844bb22ff8fdf7935cb1980b48d6e439ae116f53cdc2143/numpy-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:79e9e06c4c2379db47f3f6fc7a8652e7498251789bf8ff5bd43bf478ef314ca2", size = 18284380, upload-time = "2026-01-10T06:43:23.957Z" }, { url = "https://files.pythonhosted.org/packages/67/78/722b62bd31842ff029412271556a1a27a98f45359dea78b1548a3a9996aa/numpy-2.4.1-cp313-cp313-win32.whl", hash = "sha256:3d1a100e48cb266090a031397863ff8a30050ceefd798f686ff92c67a486753d", size = 5957089, upload-time = "2026-01-10T06:43:27.535Z" }, { url = "https://files.pythonhosted.org/packages/da/a6/cf32198b0b6e18d4fbfa9a21a992a7fca535b9bb2b0cdd217d4a3445b5ca/numpy-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:92a0e65272fd60bfa0d9278e0484c2f52fe03b97aedc02b357f33fe752c52ffb", size = 12307230, upload-time = "2026-01-10T06:43:29.298Z" }, { url = "https://files.pythonhosted.org/packages/44/6c/534d692bfb7d0afe30611320c5fb713659dcb5104d7cc182aff2aea092f5/numpy-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:20d4649c773f66cc2fc36f663e091f57c3b7655f936a4c681b4250855d1da8f5", size = 10313125, upload-time = "2026-01-10T06:43:31.782Z" }, { url = "https://files.pythonhosted.org/packages/da/a1/354583ac5c4caa566de6ddfbc42744409b515039e085fab6e0ff942e0df5/numpy-2.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f93bc6892fe7b0663e5ffa83b61aab510aacffd58c16e012bb9352d489d90cb7", size = 12496156, upload-time = "2026-01-10T06:43:34.237Z" }, { url = "https://files.pythonhosted.org/packages/51/b0/42807c6e8cce58c00127b1dc24d365305189991f2a7917aa694a109c8d7d/numpy-2.4.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:178de8f87948163d98a4c9ab5bee4ce6519ca918926ec8df195af582de28544d", size = 5324663, upload-time = "2026-01-10T06:43:36.211Z" }, { url = "https://files.pythonhosted.org/packages/fe/55/7a621694010d92375ed82f312b2f28017694ed784775269115323e37f5e2/numpy-2.4.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:98b35775e03ab7f868908b524fc0a84d38932d8daf7b7e1c3c3a1b6c7a2c9f15", size = 6645224, upload-time = "2026-01-10T06:43:37.884Z" }, { url = "https://files.pythonhosted.org/packages/50/96/9fa8635ed9d7c847d87e30c834f7109fac5e88549d79ef3324ab5c20919f/numpy-2.4.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:941c2a93313d030f219f3a71fd3d91a728b82979a5e8034eb2e60d394a2b83f9", size = 14462352, upload-time = "2026-01-10T06:43:39.479Z" }, { url = "https://files.pythonhosted.org/packages/03/d1/8cf62d8bb2062da4fb82dd5d49e47c923f9c0738032f054e0a75342faba7/numpy-2.4.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:529050522e983e00a6c1c6b67411083630de8b57f65e853d7b03d9281b8694d2", size = 16407279, upload-time = "2026-01-10T06:43:41.93Z" }, { url = "https://files.pythonhosted.org/packages/86/1c/95c86e17c6b0b31ce6ef219da00f71113b220bcb14938c8d9a05cee0ff53/numpy-2.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2302dc0224c1cbc49bb94f7064f3f923a971bfae45c33870dcbff63a2a550505", size = 16248316, upload-time = "2026-01-10T06:43:44.121Z" }, { url = "https://files.pythonhosted.org/packages/30/b4/e7f5ff8697274c9d0fa82398b6a372a27e5cef069b37df6355ccb1f1db1a/numpy-2.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:9171a42fcad32dcf3fa86f0a4faa5e9f8facefdb276f54b8b390d90447cff4e2", size = 18329884, upload-time = "2026-01-10T06:43:46.613Z" }, { url = "https://files.pythonhosted.org/packages/37/a4/b073f3e9d77f9aec8debe8ca7f9f6a09e888ad1ba7488f0c3b36a94c03ac/numpy-2.4.1-cp313-cp313t-win32.whl", hash = "sha256:382ad67d99ef49024f11d1ce5dcb5ad8432446e4246a4b014418ba3a1175a1f4", size = 6081138, upload-time = "2026-01-10T06:43:48.854Z" }, { url = "https://files.pythonhosted.org/packages/16/16/af42337b53844e67752a092481ab869c0523bc95c4e5c98e4dac4e9581ac/numpy-2.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:62fea415f83ad8fdb6c20840578e5fbaf5ddd65e0ec6c3c47eda0f69da172510", size = 12447478, upload-time = "2026-01-10T06:43:50.476Z" }, { url = "https://files.pythonhosted.org/packages/6c/f8/fa85b2eac68ec631d0b631abc448552cb17d39afd17ec53dcbcc3537681a/numpy-2.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a7870e8c5fc11aef57d6fea4b4085e537a3a60ad2cdd14322ed531fdca68d261", size = 10382981, upload-time = "2026-01-10T06:43:52.575Z" }, { url = "https://files.pythonhosted.org/packages/1b/a7/ef08d25698e0e4b4efbad8d55251d20fe2a15f6d9aa7c9b30cd03c165e6f/numpy-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3869ea1ee1a1edc16c29bbe3a2f2a4e515cc3a44d43903ad41e0cacdbaf733dc", size = 16652046, upload-time = "2026-01-10T06:43:54.797Z" }, { url = "https://files.pythonhosted.org/packages/8f/39/e378b3e3ca13477e5ac70293ec027c438d1927f18637e396fe90b1addd72/numpy-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e867df947d427cdd7a60e3e271729090b0f0df80f5f10ab7dd436f40811699c3", size = 12378858, upload-time = "2026-01-10T06:43:57.099Z" }, { url = "https://files.pythonhosted.org/packages/c3/74/7ec6154f0006910ed1fdbb7591cf4432307033102b8a22041599935f8969/numpy-2.4.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e3bd2cb07841166420d2fa7146c96ce00cb3410664cbc1a6be028e456c4ee220", size = 5207417, upload-time = "2026-01-10T06:43:59.037Z" }, { url = "https://files.pythonhosted.org/packages/f7/b7/053ac11820d84e42f8feea5cb81cc4fcd1091499b45b1ed8c7415b1bf831/numpy-2.4.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:f0a90aba7d521e6954670550e561a4cb925713bd944445dbe9e729b71f6cabee", size = 6542643, upload-time = "2026-01-10T06:44:01.852Z" }, { url = "https://files.pythonhosted.org/packages/c0/c4/2e7908915c0e32ca636b92e4e4a3bdec4cb1e7eb0f8aedf1ed3c68a0d8cd/numpy-2.4.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d558123217a83b2d1ba316b986e9248a1ed1971ad495963d555ccd75dcb1556", size = 14418963, upload-time = "2026-01-10T06:44:04.047Z" }, { url = "https://files.pythonhosted.org/packages/eb/c0/3ed5083d94e7ffd7c404e54619c088e11f2e1939a9544f5397f4adb1b8ba/numpy-2.4.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f44de05659b67d20499cbc96d49f2650769afcb398b79b324bb6e297bfe3844", size = 16363811, upload-time = "2026-01-10T06:44:06.207Z" }, { url = "https://files.pythonhosted.org/packages/0e/68/42b66f1852bf525050a67315a4fb94586ab7e9eaa541b1bef530fab0c5dd/numpy-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:69e7419c9012c4aaf695109564e3387f1259f001b4326dfa55907b098af082d3", size = 16197643, upload-time = "2026-01-10T06:44:08.33Z" }, { url = "https://files.pythonhosted.org/packages/d2/40/e8714fc933d85f82c6bfc7b998a0649ad9769a32f3494ba86598aaf18a48/numpy-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ffd257026eb1b34352e749d7cc1678b5eeec3e329ad8c9965a797e08ccba205", size = 18289601, upload-time = "2026-01-10T06:44:10.841Z" }, { url = "https://files.pythonhosted.org/packages/80/9a/0d44b468cad50315127e884802351723daca7cf1c98d102929468c81d439/numpy-2.4.1-cp314-cp314-win32.whl", hash = "sha256:727c6c3275ddefa0dc078524a85e064c057b4f4e71ca5ca29a19163c607be745", size = 6005722, upload-time = "2026-01-10T06:44:13.332Z" }, { url = "https://files.pythonhosted.org/packages/7e/bb/c6513edcce5a831810e2dddc0d3452ce84d208af92405a0c2e58fd8e7881/numpy-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:7d5d7999df434a038d75a748275cd6c0094b0ecdb0837342b332a82defc4dc4d", size = 12438590, upload-time = "2026-01-10T06:44:15.006Z" }, { url = "https://files.pythonhosted.org/packages/e9/da/a598d5cb260780cf4d255102deba35c1d072dc028c4547832f45dd3323a8/numpy-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:ce9ce141a505053b3c7bce3216071f3bf5c182b8b28930f14cd24d43932cd2df", size = 10596180, upload-time = "2026-01-10T06:44:17.386Z" }, { url = "https://files.pythonhosted.org/packages/de/bc/ea3f2c96fcb382311827231f911723aeff596364eb6e1b6d1d91128aa29b/numpy-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e53170557d37ae404bf8d542ca5b7c629d6efa1117dac6a83e394142ea0a43f", size = 12498774, upload-time = "2026-01-10T06:44:19.467Z" }, { url = "https://files.pythonhosted.org/packages/aa/ab/ef9d939fe4a812648c7a712610b2ca6140b0853c5efea361301006c02ae5/numpy-2.4.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:a73044b752f5d34d4232f25f18160a1cc418ea4507f5f11e299d8ac36875f8a0", size = 5327274, upload-time = "2026-01-10T06:44:23.189Z" }, { url = "https://files.pythonhosted.org/packages/bd/31/d381368e2a95c3b08b8cf7faac6004849e960f4a042d920337f71cef0cae/numpy-2.4.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:fb1461c99de4d040666ca0444057b06541e5642f800b71c56e6ea92d6a853a0c", size = 6648306, upload-time = "2026-01-10T06:44:25.012Z" }, { url = "https://files.pythonhosted.org/packages/c8/e5/0989b44ade47430be6323d05c23207636d67d7362a1796ccbccac6773dd2/numpy-2.4.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423797bdab2eeefbe608d7c1ec7b2b4fd3c58d51460f1ee26c7500a1d9c9ee93", size = 14464653, upload-time = "2026-01-10T06:44:26.706Z" }, { url = "https://files.pythonhosted.org/packages/10/a7/cfbe475c35371cae1358e61f20c5f075badc18c4797ab4354140e1d283cf/numpy-2.4.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:52b5f61bdb323b566b528899cc7db2ba5d1015bda7ea811a8bcf3c89c331fa42", size = 16405144, upload-time = "2026-01-10T06:44:29.378Z" }, { url = "https://files.pythonhosted.org/packages/f8/a3/0c63fe66b534888fa5177cc7cef061541064dbe2b4b60dcc60ffaf0d2157/numpy-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42d7dd5fa36d16d52a84f821eb96031836fd405ee6955dd732f2023724d0aa01", size = 16247425, upload-time = "2026-01-10T06:44:31.721Z" }, { url = "https://files.pythonhosted.org/packages/6b/2b/55d980cfa2c93bd40ff4c290bf824d792bd41d2fe3487b07707559071760/numpy-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7b6b5e28bbd47b7532698e5db2fe1db693d84b58c254e4389d99a27bb9b8f6b", size = 18330053, upload-time = "2026-01-10T06:44:34.617Z" }, { url = "https://files.pythonhosted.org/packages/23/12/8b5fc6b9c487a09a7957188e0943c9ff08432c65e34567cabc1623b03a51/numpy-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:5de60946f14ebe15e713a6f22850c2372fa72f4ff9a432ab44aa90edcadaa65a", size = 6152482, upload-time = "2026-01-10T06:44:36.798Z" }, { url = "https://files.pythonhosted.org/packages/00/a5/9f8ca5856b8940492fc24fbe13c1bc34d65ddf4079097cf9e53164d094e1/numpy-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:8f085da926c0d491ffff3096f91078cc97ea67e7e6b65e490bc8dcda65663be2", size = 12627117, upload-time = "2026-01-10T06:44:38.828Z" }, { url = "https://files.pythonhosted.org/packages/ad/0d/eca3d962f9eef265f01a8e0d20085c6dd1f443cbffc11b6dede81fd82356/numpy-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6436cffb4f2bf26c974344439439c95e152c9a527013f26b3577be6c2ca64295", size = 10667121, upload-time = "2026-01-10T06:44:41.644Z" }, ] [[package]] name = "packaging" version = "26.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] [[package]] name = "pillow" version = "12.1.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d0/02/d52c733a2452ef1ffcc123b68e6606d07276b0e358db70eabad7e40042b7/pillow-12.1.0.tar.gz", hash = "sha256:5c5ae0a06e9ea030ab786b0251b32c7e4ce10e58d983c0d5c56029455180b5b9", size = 46977283, upload-time = "2026-01-02T09:13:29.892Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/20/31/dc53fe21a2f2996e1b7d92bf671cdb157079385183ef7c1ae08b485db510/pillow-12.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a332ac4ccb84b6dde65dbace8431f3af08874bf9770719d32a635c4ef411b18b", size = 5262642, upload-time = "2026-01-02T09:11:10.138Z" }, { url = "https://files.pythonhosted.org/packages/ab/c1/10e45ac9cc79419cedf5121b42dcca5a50ad2b601fa080f58c22fb27626e/pillow-12.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:907bfa8a9cb790748a9aa4513e37c88c59660da3bcfffbd24a7d9e6abf224551", size = 4657464, upload-time = "2026-01-02T09:11:12.319Z" }, { url = "https://files.pythonhosted.org/packages/ad/26/7b82c0ab7ef40ebede7a97c72d473bda5950f609f8e0c77b04af574a0ddb/pillow-12.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:efdc140e7b63b8f739d09a99033aa430accce485ff78e6d311973a67b6bf3208", size = 6234878, upload-time = "2026-01-02T09:11:14.096Z" }, { url = "https://files.pythonhosted.org/packages/76/25/27abc9792615b5e886ca9411ba6637b675f1b77af3104710ac7353fe5605/pillow-12.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bef9768cab184e7ae6e559c032e95ba8d07b3023c289f79a2bd36e8bf85605a5", size = 8044868, upload-time = "2026-01-02T09:11:15.903Z" }, { url = "https://files.pythonhosted.org/packages/0a/ea/f200a4c36d836100e7bc738fc48cd963d3ba6372ebc8298a889e0cfc3359/pillow-12.1.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:742aea052cf5ab5034a53c3846165bc3ce88d7c38e954120db0ab867ca242661", size = 6349468, upload-time = "2026-01-02T09:11:17.631Z" }, { url = "https://files.pythonhosted.org/packages/11/8f/48d0b77ab2200374c66d344459b8958c86693be99526450e7aee714e03e4/pillow-12.1.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a6dfc2af5b082b635af6e08e0d1f9f1c4e04d17d4e2ca0ef96131e85eda6eb17", size = 7041518, upload-time = "2026-01-02T09:11:19.389Z" }, { url = "https://files.pythonhosted.org/packages/1d/23/c281182eb986b5d31f0a76d2a2c8cd41722d6fb8ed07521e802f9bba52de/pillow-12.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:609e89d9f90b581c8d16358c9087df76024cf058fa693dd3e1e1620823f39670", size = 6462829, upload-time = "2026-01-02T09:11:21.28Z" }, { url = "https://files.pythonhosted.org/packages/25/ef/7018273e0faac099d7b00982abdcc39142ae6f3bd9ceb06de09779c4a9d6/pillow-12.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:43b4899cfd091a9693a1278c4982f3e50f7fb7cff5153b05174b4afc9593b616", size = 7166756, upload-time = "2026-01-02T09:11:23.559Z" }, { url = "https://files.pythonhosted.org/packages/8f/c8/993d4b7ab2e341fe02ceef9576afcf5830cdec640be2ac5bee1820d693d4/pillow-12.1.0-cp312-cp312-win32.whl", hash = "sha256:aa0c9cc0b82b14766a99fbe6084409972266e82f459821cd26997a488a7261a7", size = 6328770, upload-time = "2026-01-02T09:11:25.661Z" }, { url = "https://files.pythonhosted.org/packages/a7/87/90b358775a3f02765d87655237229ba64a997b87efa8ccaca7dd3e36e7a7/pillow-12.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:d70534cea9e7966169ad29a903b99fc507e932069a881d0965a1a84bb57f6c6d", size = 7033406, upload-time = "2026-01-02T09:11:27.474Z" }, { url = "https://files.pythonhosted.org/packages/5d/cf/881b457eccacac9e5b2ddd97d5071fb6d668307c57cbf4e3b5278e06e536/pillow-12.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:65b80c1ee7e14a87d6a068dd3b0aea268ffcabfe0498d38661b00c5b4b22e74c", size = 2452612, upload-time = "2026-01-02T09:11:29.309Z" }, { url = "https://files.pythonhosted.org/packages/dd/c7/2530a4aa28248623e9d7f27316b42e27c32ec410f695929696f2e0e4a778/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7b5dd7cbae20285cdb597b10eb5a2c13aa9de6cde9bb64a3c1317427b1db1ae1", size = 4062543, upload-time = "2026-01-02T09:11:31.566Z" }, { url = "https://files.pythonhosted.org/packages/8f/1f/40b8eae823dc1519b87d53c30ed9ef085506b05281d313031755c1705f73/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:29a4cef9cb672363926f0470afc516dbf7305a14d8c54f7abbb5c199cd8f8179", size = 4138373, upload-time = "2026-01-02T09:11:33.367Z" }, { url = "https://files.pythonhosted.org/packages/d4/77/6fa60634cf06e52139fd0e89e5bbf055e8166c691c42fb162818b7fda31d/pillow-12.1.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:681088909d7e8fa9e31b9799aaa59ba5234c58e5e4f1951b4c4d1082a2e980e0", size = 3601241, upload-time = "2026-01-02T09:11:35.011Z" }, { url = "https://files.pythonhosted.org/packages/4f/bf/28ab865de622e14b747f0cd7877510848252d950e43002e224fb1c9ababf/pillow-12.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:983976c2ab753166dc66d36af6e8ec15bb511e4a25856e2227e5f7e00a160587", size = 5262410, upload-time = "2026-01-02T09:11:36.682Z" }, { url = "https://files.pythonhosted.org/packages/1c/34/583420a1b55e715937a85bd48c5c0991598247a1fd2eb5423188e765ea02/pillow-12.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:db44d5c160a90df2d24a24760bbd37607d53da0b34fb546c4c232af7192298ac", size = 4657312, upload-time = "2026-01-02T09:11:38.535Z" }, { url = "https://files.pythonhosted.org/packages/1d/fd/f5a0896839762885b3376ff04878f86ab2b097c2f9a9cdccf4eda8ba8dc0/pillow-12.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6b7a9d1db5dad90e2991645874f708e87d9a3c370c243c2d7684d28f7e133e6b", size = 6232605, upload-time = "2026-01-02T09:11:40.602Z" }, { url = "https://files.pythonhosted.org/packages/98/aa/938a09d127ac1e70e6ed467bd03834350b33ef646b31edb7452d5de43792/pillow-12.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6258f3260986990ba2fa8a874f8b6e808cf5abb51a94015ca3dc3c68aa4f30ea", size = 8041617, upload-time = "2026-01-02T09:11:42.721Z" }, { url = "https://files.pythonhosted.org/packages/17/e8/538b24cb426ac0186e03f80f78bc8dc7246c667f58b540bdd57c71c9f79d/pillow-12.1.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e115c15e3bc727b1ca3e641a909f77f8ca72a64fff150f666fcc85e57701c26c", size = 6346509, upload-time = "2026-01-02T09:11:44.955Z" }, { url = "https://files.pythonhosted.org/packages/01/9a/632e58ec89a32738cabfd9ec418f0e9898a2b4719afc581f07c04a05e3c9/pillow-12.1.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6741e6f3074a35e47c77b23a4e4f2d90db3ed905cb1c5e6e0d49bff2045632bc", size = 7038117, upload-time = "2026-01-02T09:11:46.736Z" }, { url = "https://files.pythonhosted.org/packages/c7/a2/d40308cf86eada842ca1f3ffa45d0ca0df7e4ab33c83f81e73f5eaed136d/pillow-12.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:935b9d1aed48fcfb3f838caac506f38e29621b44ccc4f8a64d575cb1b2a88644", size = 6460151, upload-time = "2026-01-02T09:11:48.625Z" }, { url = "https://files.pythonhosted.org/packages/f1/88/f5b058ad6453a085c5266660a1417bdad590199da1b32fb4efcff9d33b05/pillow-12.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5fee4c04aad8932da9f8f710af2c1a15a83582cfb884152a9caa79d4efcdbf9c", size = 7164534, upload-time = "2026-01-02T09:11:50.445Z" }, { url = "https://files.pythonhosted.org/packages/19/ce/c17334caea1db789163b5d855a5735e47995b0b5dc8745e9a3605d5f24c0/pillow-12.1.0-cp313-cp313-win32.whl", hash = "sha256:a786bf667724d84aa29b5db1c61b7bfdde380202aaca12c3461afd6b71743171", size = 6332551, upload-time = "2026-01-02T09:11:52.234Z" }, { url = "https://files.pythonhosted.org/packages/e5/07/74a9d941fa45c90a0d9465098fe1ec85de3e2afbdc15cc4766622d516056/pillow-12.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:461f9dfdafa394c59cd6d818bdfdbab4028b83b02caadaff0ffd433faf4c9a7a", size = 7040087, upload-time = "2026-01-02T09:11:54.822Z" }, { url = "https://files.pythonhosted.org/packages/88/09/c99950c075a0e9053d8e880595926302575bc742b1b47fe1bbcc8d388d50/pillow-12.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:9212d6b86917a2300669511ed094a9406888362e085f2431a7da985a6b124f45", size = 2452470, upload-time = "2026-01-02T09:11:56.522Z" }, { url = "https://files.pythonhosted.org/packages/b5/ba/970b7d85ba01f348dee4d65412476321d40ee04dcb51cd3735b9dc94eb58/pillow-12.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:00162e9ca6d22b7c3ee8e61faa3c3253cd19b6a37f126cad04f2f88b306f557d", size = 5264816, upload-time = "2026-01-02T09:11:58.227Z" }, { url = "https://files.pythonhosted.org/packages/10/60/650f2fb55fdba7a510d836202aa52f0baac633e50ab1cf18415d332188fb/pillow-12.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7d6daa89a00b58c37cb1747ec9fb7ac3bc5ffd5949f5888657dfddde6d1312e0", size = 4660472, upload-time = "2026-01-02T09:12:00.798Z" }, { url = "https://files.pythonhosted.org/packages/2b/c0/5273a99478956a099d533c4f46cbaa19fd69d606624f4334b85e50987a08/pillow-12.1.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2479c7f02f9d505682dc47df8c0ea1fc5e264c4d1629a5d63fe3e2334b89554", size = 6268974, upload-time = "2026-01-02T09:12:02.572Z" }, { url = "https://files.pythonhosted.org/packages/b4/26/0bf714bc2e73d5267887d47931d53c4ceeceea6978148ed2ab2a4e6463c4/pillow-12.1.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f188d580bd870cda1e15183790d1cc2fa78f666e76077d103edf048eed9c356e", size = 8073070, upload-time = "2026-01-02T09:12:04.75Z" }, { url = "https://files.pythonhosted.org/packages/43/cf/1ea826200de111a9d65724c54f927f3111dc5ae297f294b370a670c17786/pillow-12.1.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fde7ec5538ab5095cc02df38ee99b0443ff0e1c847a045554cf5f9af1f4aa82", size = 6380176, upload-time = "2026-01-02T09:12:06.626Z" }, { url = "https://files.pythonhosted.org/packages/03/e0/7938dd2b2013373fd85d96e0f38d62b7a5a262af21ac274250c7ca7847c9/pillow-12.1.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ed07dca4a8464bada6139ab38f5382f83e5f111698caf3191cb8dbf27d908b4", size = 7067061, upload-time = "2026-01-02T09:12:08.624Z" }, { url = "https://files.pythonhosted.org/packages/86/ad/a2aa97d37272a929a98437a8c0ac37b3cf012f4f8721e1bd5154699b2518/pillow-12.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f45bd71d1fa5e5749587613037b172e0b3b23159d1c00ef2fc920da6f470e6f0", size = 6491824, upload-time = "2026-01-02T09:12:10.488Z" }, { url = "https://files.pythonhosted.org/packages/a4/44/80e46611b288d51b115826f136fb3465653c28f491068a72d3da49b54cd4/pillow-12.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:277518bf4fe74aa91489e1b20577473b19ee70fb97c374aa50830b279f25841b", size = 7190911, upload-time = "2026-01-02T09:12:12.772Z" }, { url = "https://files.pythonhosted.org/packages/86/77/eacc62356b4cf81abe99ff9dbc7402750044aed02cfd6a503f7c6fc11f3e/pillow-12.1.0-cp313-cp313t-win32.whl", hash = "sha256:7315f9137087c4e0ee73a761b163fc9aa3b19f5f606a7fc08d83fd3e4379af65", size = 6336445, upload-time = "2026-01-02T09:12:14.775Z" }, { url = "https://files.pythonhosted.org/packages/e7/3c/57d81d0b74d218706dafccb87a87ea44262c43eef98eb3b164fd000e0491/pillow-12.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0ddedfaa8b5f0b4ffbc2fa87b556dc59f6bb4ecb14a53b33f9189713ae8053c0", size = 7045354, upload-time = "2026-01-02T09:12:16.599Z" }, { url = "https://files.pythonhosted.org/packages/ac/82/8b9b97bba2e3576a340f93b044a3a3a09841170ab4c1eb0d5c93469fd32f/pillow-12.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:80941e6d573197a0c28f394753de529bb436b1ca990ed6e765cf42426abc39f8", size = 2454547, upload-time = "2026-01-02T09:12:18.704Z" }, { url = "https://files.pythonhosted.org/packages/8c/87/bdf971d8bbcf80a348cc3bacfcb239f5882100fe80534b0ce67a784181d8/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:5cb7bc1966d031aec37ddb9dcf15c2da5b2e9f7cc3ca7c54473a20a927e1eb91", size = 4062533, upload-time = "2026-01-02T09:12:20.791Z" }, { url = "https://files.pythonhosted.org/packages/ff/4f/5eb37a681c68d605eb7034c004875c81f86ec9ef51f5be4a63eadd58859a/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:97e9993d5ed946aba26baf9c1e8cf18adbab584b99f452ee72f7ee8acb882796", size = 4138546, upload-time = "2026-01-02T09:12:23.664Z" }, { url = "https://files.pythonhosted.org/packages/11/6d/19a95acb2edbace40dcd582d077b991646b7083c41b98da4ed7555b59733/pillow-12.1.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:414b9a78e14ffeb98128863314e62c3f24b8a86081066625700b7985b3f529bd", size = 3601163, upload-time = "2026-01-02T09:12:26.338Z" }, { url = "https://files.pythonhosted.org/packages/fc/36/2b8138e51cb42e4cc39c3297713455548be855a50558c3ac2beebdc251dd/pillow-12.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e6bdb408f7c9dd2a5ff2b14a3b0bb6d4deb29fb9961e6eb3ae2031ae9a5cec13", size = 5266086, upload-time = "2026-01-02T09:12:28.782Z" }, { url = "https://files.pythonhosted.org/packages/53/4b/649056e4d22e1caa90816bf99cef0884aed607ed38075bd75f091a607a38/pillow-12.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3413c2ae377550f5487991d444428f1a8ae92784aac79caa8b1e3b89b175f77e", size = 4657344, upload-time = "2026-01-02T09:12:31.117Z" }, { url = "https://files.pythonhosted.org/packages/6c/6b/c5742cea0f1ade0cd61485dc3d81f05261fc2276f537fbdc00802de56779/pillow-12.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e5dcbe95016e88437ecf33544ba5db21ef1b8dd6e1b434a2cb2a3d605299e643", size = 6232114, upload-time = "2026-01-02T09:12:32.936Z" }, { url = "https://files.pythonhosted.org/packages/bf/8f/9f521268ce22d63991601aafd3d48d5ff7280a246a1ef62d626d67b44064/pillow-12.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d0a7735df32ccbcc98b98a1ac785cc4b19b580be1bdf0aeb5c03223220ea09d5", size = 8042708, upload-time = "2026-01-02T09:12:34.78Z" }, { url = "https://files.pythonhosted.org/packages/1a/eb/257f38542893f021502a1bbe0c2e883c90b5cff26cc33b1584a841a06d30/pillow-12.1.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c27407a2d1b96774cbc4a7594129cc027339fd800cd081e44497722ea1179de", size = 6347762, upload-time = "2026-01-02T09:12:36.748Z" }, { url = "https://files.pythonhosted.org/packages/c4/5a/8ba375025701c09b309e8d5163c5a4ce0102fa86bbf8800eb0d7ac87bc51/pillow-12.1.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15c794d74303828eaa957ff8070846d0efe8c630901a1c753fdc63850e19ecd9", size = 7039265, upload-time = "2026-01-02T09:12:39.082Z" }, { url = "https://files.pythonhosted.org/packages/cf/dc/cf5e4cdb3db533f539e88a7bbf9f190c64ab8a08a9bc7a4ccf55067872e4/pillow-12.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c990547452ee2800d8506c4150280757f88532f3de2a58e3022e9b179107862a", size = 6462341, upload-time = "2026-01-02T09:12:40.946Z" }, { url = "https://files.pythonhosted.org/packages/d0/47/0291a25ac9550677e22eda48510cfc4fa4b2ef0396448b7fbdc0a6946309/pillow-12.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b63e13dd27da389ed9475b3d28510f0f954bca0041e8e551b2a4eb1eab56a39a", size = 7165395, upload-time = "2026-01-02T09:12:42.706Z" }, { url = "https://files.pythonhosted.org/packages/4f/4c/e005a59393ec4d9416be06e6b45820403bb946a778e39ecec62f5b2b991e/pillow-12.1.0-cp314-cp314-win32.whl", hash = "sha256:1a949604f73eb07a8adab38c4fe50791f9919344398bdc8ac6b307f755fc7030", size = 6431413, upload-time = "2026-01-02T09:12:44.944Z" }, { url = "https://files.pythonhosted.org/packages/1c/af/f23697f587ac5f9095d67e31b81c95c0249cd461a9798a061ed6709b09b5/pillow-12.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f9f6a650743f0ddee5593ac9e954ba1bdbc5e150bc066586d4f26127853ab94", size = 7176779, upload-time = "2026-01-02T09:12:46.727Z" }, { url = "https://files.pythonhosted.org/packages/b3/36/6a51abf8599232f3e9afbd16d52829376a68909fe14efe29084445db4b73/pillow-12.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:808b99604f7873c800c4840f55ff389936ef1948e4e87645eaf3fccbc8477ac4", size = 2543105, upload-time = "2026-01-02T09:12:49.243Z" }, { url = "https://files.pythonhosted.org/packages/82/54/2e1dd20c8749ff225080d6ba465a0cab4387f5db0d1c5fb1439e2d99923f/pillow-12.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc11908616c8a283cf7d664f77411a5ed2a02009b0097ff8abbba5e79128ccf2", size = 5268571, upload-time = "2026-01-02T09:12:51.11Z" }, { url = "https://files.pythonhosted.org/packages/57/61/571163a5ef86ec0cf30d265ac2a70ae6fc9e28413d1dc94fa37fae6bda89/pillow-12.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:896866d2d436563fa2a43a9d72f417874f16b5545955c54a64941e87c1376c61", size = 4660426, upload-time = "2026-01-02T09:12:52.865Z" }, { url = "https://files.pythonhosted.org/packages/5e/e1/53ee5163f794aef1bf84243f755ee6897a92c708505350dd1923f4afec48/pillow-12.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8e178e3e99d3c0ea8fc64b88447f7cac8ccf058af422a6cedc690d0eadd98c51", size = 6269908, upload-time = "2026-01-02T09:12:54.884Z" }, { url = "https://files.pythonhosted.org/packages/bc/0b/b4b4106ff0ee1afa1dc599fde6ab230417f800279745124f6c50bcffed8e/pillow-12.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:079af2fb0c599c2ec144ba2c02766d1b55498e373b3ac64687e43849fbbef5bc", size = 8074733, upload-time = "2026-01-02T09:12:56.802Z" }, { url = "https://files.pythonhosted.org/packages/19/9f/80b411cbac4a732439e629a26ad3ef11907a8c7fc5377b7602f04f6fe4e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdec5e43377761c5dbca620efb69a77f6855c5a379e32ac5b158f54c84212b14", size = 6381431, upload-time = "2026-01-02T09:12:58.823Z" }, { url = "https://files.pythonhosted.org/packages/8f/b7/d65c45db463b66ecb6abc17c6ba6917a911202a07662247e1355ce1789e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:565c986f4b45c020f5421a4cea13ef294dde9509a8577f29b2fc5edc7587fff8", size = 7068529, upload-time = "2026-01-02T09:13:00.885Z" }, { url = "https://files.pythonhosted.org/packages/50/96/dfd4cd726b4a45ae6e3c669fc9e49deb2241312605d33aba50499e9d9bd1/pillow-12.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43aca0a55ce1eefc0aefa6253661cb54571857b1a7b2964bd8a1e3ef4b729924", size = 6492981, upload-time = "2026-01-02T09:13:03.314Z" }, { url = "https://files.pythonhosted.org/packages/4d/1c/b5dc52cf713ae46033359c5ca920444f18a6359ce1020dd3e9c553ea5bc6/pillow-12.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0deedf2ea233722476b3a81e8cdfbad786f7adbed5d848469fa59fe52396e4ef", size = 7191878, upload-time = "2026-01-02T09:13:05.276Z" }, { url = "https://files.pythonhosted.org/packages/53/26/c4188248bd5edaf543864fe4834aebe9c9cb4968b6f573ce014cc42d0720/pillow-12.1.0-cp314-cp314t-win32.whl", hash = "sha256:b17fbdbe01c196e7e159aacb889e091f28e61020a8abeac07b68079b6e626988", size = 6438703, upload-time = "2026-01-02T09:13:07.491Z" }, { url = "https://files.pythonhosted.org/packages/b8/0e/69ed296de8ea05cb03ee139cee600f424ca166e632567b2d66727f08c7ed/pillow-12.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27b9baecb428899db6c0de572d6d305cfaf38ca1596b5c0542a5182e3e74e8c6", size = 7182927, upload-time = "2026-01-02T09:13:09.841Z" }, { url = "https://files.pythonhosted.org/packages/fc/f5/68334c015eed9b5cff77814258717dec591ded209ab5b6fb70e2ae873d1d/pillow-12.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f61333d817698bdcdd0f9d7793e365ac3d2a21c1f1eb02b32ad6aefb8d8ea831", size = 2545104, upload-time = "2026-01-02T09:13:12.068Z" }, ] [[package]] name = "pluggy" version = "1.6.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] [[package]] name = "pygments" version = "2.19.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] [[package]] name = "pyqt6" version = "6.10.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyqt6-qt6" }, { name = "pyqt6-sip" }, ] sdist = { url = "https://files.pythonhosted.org/packages/96/03/e756f52e8b0d7bb5527baf8c46d59af0746391943bdb8655acba22ee4168/pyqt6-6.10.2.tar.gz", hash = "sha256:6c0db5d8cbb9a3e7e2b5b51d0ff3f283121fa27b864db6d2f35b663c9be5cc83", size = 1085573, upload-time = "2026-01-08T16:40:00.244Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/fb/3f/f073a980969aa485ef288eb2e3b94c223ba9c7ac9941543f19b51659b98d/pyqt6-6.10.2-cp39-abi3-macosx_10_14_universal2.whl", hash = "sha256:37ae7c1183fe4dd0c6aefd2006a35731245de1cb6f817bb9e414a3e4848dfd6d", size = 60244482, upload-time = "2026-01-08T16:38:50.837Z" }, { url = "https://files.pythonhosted.org/packages/ec/3e/9a015651ec71cea2e2f960c37edeb21623ba96a74956c0827def837f7c6b/pyqt6-6.10.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:78e1b3d5763e4cbc84485aef600e0aba5e1932fd263b716f92cd1a40dfa5e924", size = 37899440, upload-time = "2026-01-08T16:39:09.027Z" }, { url = "https://files.pythonhosted.org/packages/51/74/a88fec2b99700270ca5d7dc7d650236a4990ed6fc88e055ca0fc8a339ee3/pyqt6-6.10.2-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:bbc3af541bbecd27301bfe69fe445aa1611a9b490bd3de77306b12df632f7ec6", size = 40748467, upload-time = "2026-01-08T16:39:29.551Z" }, { url = "https://files.pythonhosted.org/packages/75/34/be7a55529607b21db00a49ca53cb07c3092d2a5a95ea19bb95cfa0346904/pyqt6-6.10.2-cp39-abi3-win_amd64.whl", hash = "sha256:bd328cb70bc382c48861cd5f0a11b2b8ae6f5692d5a2d6679ba52785dced327b", size = 26015391, upload-time = "2026-01-08T16:39:42.946Z" }, { url = "https://files.pythonhosted.org/packages/af/de/d9c88f976602b7884fec4ad54a4575d48e23e4f390e5357ea83917358846/pyqt6-6.10.2-cp39-abi3-win_arm64.whl", hash = "sha256:7901ba1df024b7ee9fdacfb2b7661aeb3749ae8b0bef65428077de3e0450eabb", size = 26208415, upload-time = "2026-01-08T16:39:57.751Z" }, ] [[package]] name = "pyqt6-qt6" version = "6.10.1" source = { registry = "https://pypi.org/simple" } wheels = [ { url = "https://files.pythonhosted.org/packages/54/1b/137184632cad83a210e7955226744a77945260ca2e75892fe36299d26ada/pyqt6_qt6-6.10.1-py3-none-macosx_10_14_x86_64.whl", hash = "sha256:4bb2798a95f624b462b70c4f185422235b714b01e55abab32af1740f147948e2", size = 68472463, upload-time = "2025-11-27T14:20:51.694Z" }, { url = "https://files.pythonhosted.org/packages/af/df/ca795ac3d04243ad63499cfedcf92d8b5f6e3585a2a26c09f34cb58c8e44/pyqt6_qt6-6.10.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0921cc522512cb40dbab673806bc1676924819550e0aec8e3f3fe6907387c5b7", size = 62296168, upload-time = "2025-11-27T14:21:21.232Z" }, { url = "https://files.pythonhosted.org/packages/f4/7e/9867361252e2a4717dba95c64a0f3a793603f4a52cb9a46abbb041e960f5/pyqt6_qt6-6.10.1-py3-none-manylinux_2_34_x86_64.whl", hash = "sha256:04069aea421703b1269c8a1bcf017e36463af284a044239a4ebda3bde0a629fb", size = 83829262, upload-time = "2025-11-27T14:22:00.399Z" }, { url = "https://files.pythonhosted.org/packages/9b/7b/18f4eb2273a92283fe4d87aa740a400eb14a4e41b8f990aaf563e9767db6/pyqt6_qt6-6.10.1-py3-none-manylinux_2_39_aarch64.whl", hash = "sha256:5b9be39e0120e32d0b42cdb844e3ae110ddadd39629c991e511902c06f155aff", size = 82877396, upload-time = "2025-11-27T14:22:36.994Z" }, { url = "https://files.pythonhosted.org/packages/53/5c/648c515d57bc82909d0597befb03bbc2f7a570f323dba3ad38629669efcb/pyqt6_qt6-6.10.1-py3-none-win_amd64.whl", hash = "sha256:df564d3dc2863b1fde22b39bea9f56ceb2a3ed7d6f0b76d3f96c2d3bc5d71516", size = 76670151, upload-time = "2025-11-27T14:23:11.172Z" }, { url = "https://files.pythonhosted.org/packages/0a/13/2d2a9c0559bfa53effea5e2c1ed7aebb430186ce0b64cfba235231a049d9/pyqt6_qt6-6.10.1-py3-none-win_arm64.whl", hash = "sha256:48282e0f99682daf4f1e220cfe9f41255e003af38f7728a30d40c76e55c89816", size = 58276316, upload-time = "2025-11-27T14:23:38.744Z" }, ] [[package]] name = "pyqt6-sip" version = "13.11.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e3/7d/d2916048e2e3960f68cb4e93907639844f7b8ff95897dcc98553776ccdfc/pyqt6_sip-13.11.0.tar.gz", hash = "sha256:d463af37738bda1856c9ef513e5620a37b7a005e9d589c986c3304db4a8a14d3", size = 92509, upload-time = "2026-01-13T16:01:32.16Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/53/a6/0e4d8fa7d6deb750bd0fdf89024e39c71fb127efb5eeedfab6830ad6679a/pyqt6_sip-13.11.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:6b3267cd93b7f4da6fdf9a6a26f3baed8faae06e5cdd76235f2acc2116c40a54", size = 112367, upload-time = "2026-01-13T16:01:09.08Z" }, { url = "https://files.pythonhosted.org/packages/66/e6/25dc20a03c46000e8b93aaf79347227926b67959283e5aab797daa7f64d8/pyqt6_sip-13.11.0-cp312-cp312-manylinux1_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c30248d9bbe54c46a78e5d549da50295ecd6584b965597f751e272f000fb8527", size = 301150, upload-time = "2026-01-13T16:01:12.385Z" }, { url = "https://files.pythonhosted.org/packages/11/9f/e850cd350aade789660cafba38c00777e686040c06b8cd0b45339b80fcba/pyqt6_sip-13.11.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c367b53a91e575ef66c1375f899713bdaf0a8b2c64b95ac226e9644854a4984", size = 323303, upload-time = "2026-01-13T16:01:10.736Z" }, { url = "https://files.pythonhosted.org/packages/77/26/5261d62108f7579407230f8c1d4dda43c18b5600ce70bf3becb2f997d5cc/pyqt6_sip-13.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:077958105c2ea2f62be2f1a7611ff8bd44cb52fb5ea8fc8c59ea949144acb7b5", size = 53461, upload-time = "2026-01-13T16:01:13.875Z" }, { url = "https://files.pythonhosted.org/packages/46/80/6c88b97eda309d6babb7292200bf51165dc06d0204d891b7bf1fb17a8ed0/pyqt6_sip-13.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:52812471619d3d3750b940d7d124cd0954107656924921ac177e098ba36362fb", size = 48650, upload-time = "2026-01-13T16:01:14.897Z" }, { url = "https://files.pythonhosted.org/packages/df/a0/46abcae4fce175a326185460a02c13ab81332bca7dd55c1e853ba6aee71e/pyqt6_sip-13.11.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:929716eebde1a64ffdb6b1715db6a22aefd5634d6df84858c7deb5e85be84fdf", size = 112353, upload-time = "2026-01-13T16:01:16.152Z" }, { url = "https://files.pythonhosted.org/packages/0e/38/27c3aa3f153fcd83a0765fedf8e44a1136f189a322bcc9c494c5b3793cd7/pyqt6_sip-13.11.0-cp313-cp313-manylinux1_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a75144e8a0bcf9d1a9069011890401748af353749f1de1b6a314b880781edf9d", size = 301497, upload-time = "2026-01-13T16:01:20.531Z" }, { url = "https://files.pythonhosted.org/packages/6f/ac/1053ffce45e4174f0a8174557b88537aa82bf96ba03c7dd208c59de36f69/pyqt6_sip-13.11.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8082b5f57ffad5dddf5efcf0ef5eaf94841395aa4e7c374c79ef24cf49b0f0ce", size = 323498, upload-time = "2026-01-13T16:01:17.859Z" }, { url = "https://files.pythonhosted.org/packages/40/d3/447b30d1f00cc50ad9e5c53b2e920068606b16857da83f8036b390c79fad/pyqt6_sip-13.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8d49b5bf3d8d36cd7db93ddc54cd09dbba96a3fd926e445ef75499b41e47b5a3", size = 53469, upload-time = "2026-01-13T16:01:21.762Z" }, { url = "https://files.pythonhosted.org/packages/92/67/77e6fafcabd01c0a11166ab7464509896f137929f82c4f2e03aea1bf41b3/pyqt6_sip-13.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:293eac1b53c66c54b03266cc30015ec77454af679043a4f188b9bb80a9656996", size = 48643, upload-time = "2026-01-13T16:01:22.669Z" }, { url = "https://files.pythonhosted.org/packages/ff/28/a5178c8e005bafbf9c0fd507f45a3eef619ab582811414a0a461ee75994f/pyqt6_sip-13.11.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:4dc9c4df24af0571423c3e85b5c008bad42ed48558eef80fbc3e5d30274c5abb", size = 112431, upload-time = "2026-01-13T16:01:23.832Z" }, { url = "https://files.pythonhosted.org/packages/13/3c/02770b02b5a05779e26bd02c202c2fd32aa38e225d01f14c06908e33738c/pyqt6_sip-13.11.0-cp314-cp314-manylinux1_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c974d5a193f32e55e746e9b63138503163ac63500dbb1fd67233d8a8d71369bd", size = 301236, upload-time = "2026-01-13T16:01:28.733Z" }, { url = "https://files.pythonhosted.org/packages/40/47/5af493a698cc520581ca1000b4ab09b8182992053ffe2478062dde5e4671/pyqt6_sip-13.11.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4284540ffccd8349763ddce3518264dde62f20556720d4061b9c895e09011ca0", size = 323919, upload-time = "2026-01-13T16:01:25.122Z" }, { url = "https://files.pythonhosted.org/packages/b7/2d/64b26e21183a7ff180105871dd5983a8da539d8768921728268dc6d0a73d/pyqt6_sip-13.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:9bd81cb351640abc803ea2fe7262b5adea28615c9b96fd103d1b6f3459937211", size = 55078, upload-time = "2026-01-13T16:01:29.853Z" }, { url = "https://files.pythonhosted.org/packages/7e/36/23f699fa8b1c3fcc312ecd12661a1df6057d92e16d4def2399b59cf7bf22/pyqt6_sip-13.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:cd95ec98f8edb15bcea832b8657809f69d758bc4151cc6fd7790c0181949e45f", size = 49465, upload-time = "2026-01-13T16:01:31.174Z" }, ] [[package]] name = "pytest" version = "9.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] [[package]] name = "pytest-qt" version = "4.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pluggy" }, { name = "pytest" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d3/61/8bdec02663c18bf5016709b909411dce04a868710477dc9b9844ffcf8dd2/pytest_qt-4.5.0.tar.gz", hash = "sha256:51620e01c488f065d2036425cbc1cbcf8a6972295105fd285321eb47e66a319f", size = 128702, upload-time = "2025-07-01T17:24:39.889Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/cc/d0/8339b888ad64a3d4e508fed8245a402b503846e1972c10ad60955883dcbb/pytest_qt-4.5.0-py3-none-any.whl", hash = "sha256:ed21ea9b861247f7d18090a26bfbda8fb51d7a8a7b6f776157426ff2ccf26eff", size = 37214, upload-time = "2025-07-01T17:24:38.226Z" }, ] [[package]] name = "typing-extensions" version = "4.15.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ]