├── ui_src └── ui │ ├── component │ ├── task_tips.cljs │ ├── video_stream.cljs │ ├── video_list.cljs │ ├── msg_tips.cljs │ ├── video_thumbnail.cljs │ ├── drag_and_drop.cljs │ ├── video_dialog.cljs │ └── video_item.cljs │ ├── utils │ ├── lang.cljs │ ├── fs.cljs │ └── common.cljs │ ├── state.cljs │ ├── core.cljs │ └── ffmpeg.cljs ├── scripts ├── sync_stdout.rb ├── foreman_start.sh └── prepare_ffmpeg.sh ├── package.json ├── resources ├── assets │ ├── logo │ │ ├── logo.icns │ │ ├── logo.png │ │ └── logo.svg │ └── styles │ │ └── app.scss └── public │ └── index.html ├── electron_src └── electron │ ├── state.cljs │ ├── init.cljs │ ├── common.cljs │ ├── utils.cljs │ ├── core.cljs │ ├── menu.cljs │ ├── convert.cljs │ └── ffmpeg.cljs ├── .gitignore ├── Procfile ├── dev_src └── dev │ └── core.cljs ├── src └── tools │ └── figwheel_middleware.clj ├── README.md └── project.clj /ui_src/ui/component/task_tips.cljs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /scripts/sync_stdout.rb: -------------------------------------------------------------------------------- 1 | STDOUT.sync = true 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "grand-slam", 3 | "version": "0.1.0", 4 | "main": "resources/main.js" 5 | } 6 | -------------------------------------------------------------------------------- /resources/assets/logo/logo.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raffy2010/grand-slam/HEAD/resources/assets/logo/logo.icns -------------------------------------------------------------------------------- /resources/assets/logo/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raffy2010/grand-slam/HEAD/resources/assets/logo/logo.png -------------------------------------------------------------------------------- /ui_src/ui/utils/lang.cljs: -------------------------------------------------------------------------------- 1 | (ns ui.utils.lang) 2 | 3 | (defn js->clj-kw 4 | [data] 5 | (js->clj data :keywordize-keys true)) 6 | -------------------------------------------------------------------------------- /electron_src/electron/state.cljs: -------------------------------------------------------------------------------- 1 | (ns electron.state) 2 | 3 | (def main-window (atom nil)) 4 | (def tasks (atom (sorted-map))) 5 | -------------------------------------------------------------------------------- /scripts/foreman_start.sh: -------------------------------------------------------------------------------- 1 | file_dir=$( dirname "${BASH_SOURCE[0]}") 2 | 3 | lein clean 4 | 5 | RUBYOPT="-r $file_dir/sync_stdout.rb" foreman start 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | resources/public/img/ 3 | resources/public/css/ 4 | resources/public/js/ 5 | resources/main.js 6 | *-init.clj 7 | figwheel_server.log 8 | .cljs_rhino_repl/ 9 | .lein-repl-history 10 | .nrepl-port 11 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | electron: env LEIN_FAST_TRAMPOLINE=y lein trampoline cljsbuild auto electron-dev 2 | style: env LEIN_FAST_TRAMPOLINE=y lein trampoline sass4clj auto 3 | ui: env LEIN_FAST_TRAMPOLINE=y lein trampoline figwheel frontend-dev 4 | -------------------------------------------------------------------------------- /dev_src/dev/core.cljs: -------------------------------------------------------------------------------- 1 | (ns dev.core 2 | (:require [figwheel.client :as fw :include-macros true] 3 | [ui.core])) 4 | 5 | (fw/watch-and-reload 6 | :websocket-url "ws://localhost:3449/figwheel-ws" 7 | :jsload-callback (fn [] (print "reloaded"))) 8 | -------------------------------------------------------------------------------- /ui_src/ui/state.cljs: -------------------------------------------------------------------------------- 1 | (ns ui.state 2 | (:require [reagent.core :refer [atom]])) 3 | 4 | (defonce active-files (atom (sorted-map))) 5 | (defonce convert-option (atom {})) 6 | (defonce drag-files (atom [])) 7 | (defonce messages (atom (sorted-map))) 8 | (defonce tasks (atom (sorted-map))) 9 | -------------------------------------------------------------------------------- /ui_src/ui/utils/fs.cljs: -------------------------------------------------------------------------------- 1 | (ns ui.utils.fs) 2 | 3 | (defonce fs (js/require "fs")) 4 | 5 | (defn stat 6 | "get fs stat" 7 | ^{:pre [(string? path)]} 8 | [path cb] 9 | (.stat fs 10 | path 11 | cb)) 12 | ;#(apply cb 13 | ;(js->clj %1) 14 | ;(js->clj %2 :keywordize-keys true)))) 15 | 16 | -------------------------------------------------------------------------------- /ui_src/ui/component/video_stream.cljs: -------------------------------------------------------------------------------- 1 | (ns ui.component.video-stream) 2 | 3 | (defn stream-item 4 | "doc-string" 5 | [stream] 6 | [:div {:class "video-stream"} 7 | (for [stream-field (keys stream)] 8 | ^{:key stream-field} 9 | [:dl {:class "stream-field"} 10 | [:dt stream-field] 11 | [:dd (str (get stream stream-field))]])]) 12 | 13 | -------------------------------------------------------------------------------- /electron_src/electron/init.cljs: -------------------------------------------------------------------------------- 1 | (ns electron.init 2 | (:require-macros [cljs.core.async.macros :refer [go]]) 3 | (:require [cljs.core.async :as async :refer [! put! chan alts! timeout]] 4 | [electron.common :refer [preview-dir]] 5 | [electron.utils :refer [mkdir]])) 6 | 7 | (defn prepare-preview-dir 8 | [] 9 | (mkdir preview-dir)) 10 | -------------------------------------------------------------------------------- /ui_src/ui/component/video_list.cljs: -------------------------------------------------------------------------------- 1 | (ns ui.component.video-list 2 | (:require [ui.state :refer [active-files]] 3 | [ui.utils.common :refer [component-uid]] 4 | [ui.component.video-item :refer [video-item]])) 5 | 6 | (defn video-list 7 | [] 8 | [:div 9 | (for [file (vals @active-files)] 10 | ^{:key (:id file)} 11 | [video-item file])]) 12 | -------------------------------------------------------------------------------- /resources/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 |
3 | a video editor based on FFmpeg, Electron, ClojureScript and Material design.
7 | 8 | ## Features 9 | 10 | - [x] Video format conversion 11 | - [ ] Custom video conversion 12 | - [ ] Audio conversion 13 | - [ ] Subtitle support 14 | - [ ] Multiple system support 15 | - [ ] Auto update 16 | 17 | ## Download 18 | 19 | just download from [releases](https://github.com/raffy2010/grand-slam/releases/latest) 20 | 21 | ## Development 22 | 23 | ### Prerequisite 24 | 25 | install prebuilt Electron binary 26 | ```shell 27 | npm install electron-prebuilt -g 28 | ``` 29 | 30 | install electron-packager 31 | ```shell 32 | npm install electron-packager -g 33 | ``` 34 | 35 | install foreman 36 | ```shell 37 | gem install foreman 38 | ``` 39 | 40 | start the dev flow 41 | ```shell 42 | ./scripts/foreman_start.sh 43 | ``` 44 | 45 | open the app 46 | ```shell 47 | electron . 48 | ``` 49 | 50 | ### Release 51 | 52 | build frontend stuff 53 | ```shell 54 | lein cljsbuild once frontend-release 55 | ``` 56 | 57 | build electron stuff 58 | ```shell 59 | lein cljsbuild once electron-release 60 | ``` 61 | 62 | download the latest ffmpeg bundle 63 | ```shell 64 | sh scripts/prepare_ffmpeg.sh 65 | ``` 66 | 67 | package the app 68 | ```shell 69 | # just support mac os currently, but windows and linux versions are on the way 70 | # use mirror to handle the bad network in China, damn it! 71 | electron-packager . GrandSlam \ 72 | --download.mirror=https://npm.taobao.org/mirrors/electron/ \ 73 | --platform=darwin \ 74 | --arch=x64 \ 75 | --electron-version=x.x.x \ 76 | --logo=resources/assets/logo/logo.icns \ 77 | --overwrite \ 78 | --ignore="(electron_src|ui_src|dev_src|src|target|figwheel_server\.log|Procfile|electron-release|ui-release-out)" 79 | ``` 80 | -------------------------------------------------------------------------------- /electron_src/electron/menu.cljs: -------------------------------------------------------------------------------- 1 | (ns electron.menu 2 | (:require-macros [cljs.core.async.macros :refer [go]]) 3 | (:require [electron.state :refer [main-window]] 4 | [electron.ffmpeg :refer [probe-video respond-probe]] 5 | [cljs.core.async :as async :refer [! put! chan alts! timeout]])) 6 | 7 | (defonce electron (js/require "electron")) 8 | 9 | (defonce dialog (.-dialog electron)) 10 | 11 | (defn handle-file-select 12 | [file-list] 13 | (go 14 | (when-let [file (first file-list)] 15 | (->> @main-window 16 | .-webContents 17 | (respond-probe (js (get-menu-template)))) 57 | 58 | (defn init-menu 59 | "initialize app menu" 60 | [] 61 | (.setApplicationMenu (.-Menu electron) 62 | app-menu)) 63 | 64 | 65 | -------------------------------------------------------------------------------- /project.clj: -------------------------------------------------------------------------------- 1 | (defproject grand-slam "0.1.0" 2 | :source-paths ["src" "ui_src" "electron_src"] 3 | :description "a video editor based on FFmpeg, Electron, ClojureScript and Material design." 4 | :dependencies [[org.clojure/clojure "1.8.0"] 5 | [org.clojure/clojurescript "1.9.494"] 6 | [org.clojure/core.async "0.3.442"] 7 | [org.clojure/core.match "0.3.0-alpha4"] 8 | [figwheel "0.5.8"] 9 | [reagent "0.6.0" :exclusions [org.clojure/tools.render 10 | cljsjs/react 11 | cljsjs/react-dom]] 12 | [cljs-react-material-ui "0.2.38"] 13 | [ring/ring-core "1.5.0"] 14 | [binaryage/devtools "0.9.2"] 15 | [figwheel-sidecar "0.5.8"] 16 | [reanimated "0.5.0"] 17 | [org.slf4j/slf4j-nop "1.7.13" :scope "test"]] 18 | :plugins [[lein-cljsbuild "1.1.5"] 19 | [lein-figwheel "0.5.8"] 20 | [deraen/lein-sass4clj "0.3.1"] 21 | [lein-doo "0.1.7"] 22 | [cider/cider-nrepl "0.14.0"]] 23 | 24 | :clean-targets ^{:protect false} ["resources/main.js" 25 | "resources/public/js/ui-core.js" 26 | "resources/public/js/ui-core.js.map" 27 | "resources/public/js/ui-out" 28 | "resources/public/js/ui-release-out"] 29 | 30 | :sass {:target-path "resources/public/css" 31 | :source-paths ["resources/assets/styles"]} 32 | :cljsbuild 33 | {:builds 34 | [{:source-paths ["electron_src"] 35 | :id "electron-dev" 36 | :compiler {:output-to "resources/main.js" 37 | :output-dir "resources/public/js/electron-dev" 38 | :target :nodejs 39 | :optimizations :simple 40 | :pretty-print true 41 | :cache-analysis true}} 42 | {:source-paths ["ui_src" "dev_src"] 43 | :id "frontend-dev" 44 | :compiler {:preloads [devtools.preload] 45 | :output-to "resources/public/js/ui-core.js" 46 | :output-dir "resources/public/js/ui-out" 47 | :source-map true 48 | :asset-path "js/ui-out" 49 | :optimizations :none 50 | :cache-analysis true 51 | :main "dev.core"}} 52 | {:source-paths ["electron_src"] 53 | :id "electron-release" 54 | :compiler {:output-to "resources/main.js" 55 | :output-dir "resources/public/js/electron-release" 56 | :target :nodejs 57 | :optimizations :simple 58 | :pretty-print true 59 | :cache-analysis true}} 60 | {:source-paths ["ui_src"] 61 | :id "frontend-release" 62 | :compiler {:output-to "resources/public/js/ui-core.js" 63 | :output-dir "resources/public/js/ui-release-out" 64 | :source-map "resources/public/js/ui-core.js.map" 65 | :optimizations :simple 66 | :cache-analysis true 67 | :main "ui.core"}}]} 68 | :figwheel {:http-server-root "public" 69 | :css-dirs ["resources/public/css"] 70 | :ring-handler tools.figwheel-middleware/app 71 | :server-logfile false 72 | :server-port 3449} 73 | :profiles {:dev {:dependencies [[com.cemerick/piggieback "0.2.1"] 74 | [org.clojure/tools.nrepl "0.2.10"]] 75 | :repl-options {:nrepl-middleware [cemerick.piggieback/wrap-cljs-repl]}}}) 76 | -------------------------------------------------------------------------------- /resources/assets/styles/app.scss: -------------------------------------------------------------------------------- 1 | html, body { 2 | height: 100%; 3 | margin: 0; 4 | padding: 0; 5 | } 6 | 7 | div, p, ul, ol, li { 8 | margin: 0; 9 | padding: 0; 10 | } 11 | 12 | #app-container { 13 | height: 100%; 14 | } 15 | 16 | .ui-root { 17 | height: 100%; 18 | } 19 | 20 | .drag-zoom { 21 | display: block; 22 | height: 100%; 23 | 24 | transition: all 0.3s; 25 | 26 | &.inactive { 27 | background-color: white; 28 | } 29 | 30 | &.active { 31 | background-color: #eee; 32 | } 33 | } 34 | 35 | .empty-hints { 36 | display: flex; 37 | height: 100%; 38 | flex-flow: column nowrap; 39 | justify-content: center; 40 | align-items: center; 41 | } 42 | 43 | .video-count { 44 | padding: 16px; 45 | } 46 | 47 | .video-card { 48 | display: flex; 49 | flex-flow: row wrap; 50 | padding: 16px; 51 | position: relative; 52 | 53 | transition: all 0.3s; 54 | 55 | &.video-selected { 56 | background-color: #eee; 57 | } 58 | 59 | &.video-detailed { 60 | .video-stream-detail { 61 | display: block; 62 | } 63 | } 64 | 65 | .video-preview { 66 | width: 160px; 67 | height: 90px; 68 | position: relative; 69 | } 70 | 71 | .video-info { 72 | flex: 1 0 auto; 73 | margin-left: 16px; 74 | } 75 | 76 | .action-menu { 77 | position: absolute; 78 | top: 0; 79 | right: 12px; 80 | } 81 | 82 | .convert-progress-bar { 83 | position: absolute !important; 84 | bottom: 0; 85 | left: 0; 86 | } 87 | 88 | .video-stream-detail { 89 | display: none; 90 | width: 100%; 91 | } 92 | 93 | .action-btn-enter { 94 | opacity: 0; 95 | transform: translateX(-20px); 96 | transition: all 0.3s cubic-bezier(0.4, 0.0, 0.2, 1); 97 | position: absolute; 98 | 99 | &.action-btn-enter-active { 100 | opacity: 1; 101 | transform: translateX(0); 102 | } 103 | } 104 | 105 | .action-btn-leave { 106 | opacity: 1; 107 | transition: all 0.3s cubic-bezier(0.4, 0.0, 0.2, 1); 108 | transform: translateX(0); 109 | position: absolute; 110 | 111 | &.action-btn-leave-active { 112 | opacity: 0; 113 | transform: translateX(-20px); 114 | } 115 | } 116 | 117 | .cancel-btn-enter { 118 | opacity: 0; 119 | transform: translateX(20px); 120 | transition: all 0.3s cubic-bezier(0.4, 0.0, 0.2, 1); 121 | position: absolute; 122 | 123 | &.cancel-btn-enter-active { 124 | opacity: 1; 125 | transform: translateX(0); 126 | } 127 | } 128 | 129 | .cancel-btn-leave { 130 | position: absolute; 131 | opacity: 1; 132 | transform: translateX(0); 133 | transition: all 0.3s cubic-bezier(0.4, 0.0, 0.2, 1); 134 | 135 | &.cancel-btn-leave-active { 136 | opacity: 0; 137 | transform: translateX(20px); 138 | } 139 | } 140 | 141 | .video-stream { 142 | display: flex; 143 | flex-flow: column wrap; 144 | height: 300px; 145 | padding-top: 12px; 146 | 147 | 148 | border-bottom: #eee 1px solid; 149 | 150 | &:last-child { 151 | border-bottom: 0 none; 152 | } 153 | } 154 | 155 | .single-field { 156 | display: flex; 157 | align-items: center; 158 | margin-top: 2px; 159 | 160 | svg { 161 | width: 18px !important; 162 | height: 18px !important; 163 | margin-right: 6px; 164 | } 165 | 166 | span { 167 | font-size: 16px; 168 | } 169 | } 170 | 171 | .stream-field { 172 | flex: 0 1 auto; 173 | width: 20%; 174 | margin: 0 0 6px; 175 | 176 | dt { 177 | color: rgba(#000, 0.6); 178 | font-size: 14px; 179 | } 180 | 181 | dd { 182 | margin: 0; 183 | 184 | color: rgba(#000, 0.9); 185 | font-size: 14px; 186 | } 187 | } 188 | } 189 | 190 | -------------------------------------------------------------------------------- /resources/assets/logo/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /ui_src/ui/component/video_dialog.cljs: -------------------------------------------------------------------------------- 1 | (ns ui.component.video-dialog 2 | (:require [reagent.core :as r] 3 | [cljs-react-material-ui.reagent :as ui] 4 | [cljs-react-material-ui.icons :as ic] 5 | [cljs.core.match :refer-macros [match]] 6 | [ui.state :refer [active-files convert-option]] 7 | [ui.ffmpeg :refer [export-video extract-video-stream]])) 8 | 9 | 10 | (def video-container-list [{:name "mp4" 11 | :value "mp4"} 12 | {:name "mkv" 13 | :value "mkv"} 14 | {:name "avi" 15 | :value "avi"} 16 | {:name "mpg" 17 | :value "mpg"} 18 | {:name "ts" 19 | :value "ts"} 20 | {:name "webm" 21 | :value "webm"} 22 | {:name "ogg" 23 | :value "ogg"} 24 | {:name "flv" 25 | :value "flv"} 26 | {:name "3gp" 27 | :value "3gp"}]) 28 | 29 | (def video-quality-list [{:name "2160p 4k" 30 | :value "2160"} 31 | {:name "1440p 2k" 32 | :value "1440"} 33 | {:name "1080p" 34 | :value "1080"} 35 | {:name "720p" 36 | :value "720"} 37 | {:name "480p" 38 | :value "480"} 39 | {:name "360p" 40 | :value "360"} 41 | {:name "240p" 42 | :value "240"} 43 | {:name "144p" 44 | :value "144"}]) 45 | 46 | (defn format-quality 47 | [file] 48 | (let [video (extract-video-stream file) 49 | width (:width video) 50 | height (:height video)] 51 | (str width "x" height))) 52 | 53 | (defn toggle-convert-modal 54 | [file] 55 | (let [file-id (:id file)] 56 | (reset! convert-option {}) 57 | (swap! active-files assoc-in [file-id :convert-mode] nil))) 58 | 59 | (defn select-output-target 60 | "convert video file" 61 | [file] 62 | (export-video file)) 63 | 64 | (defn convert-actions 65 | [file] 66 | [:div [ui/flat-button {:label "Cancel" 67 | :on-click (partial toggle-convert-modal file)}] 68 | [ui/flat-button {:label "Convert" 69 | :primary true 70 | :on-click (partial select-output-target file)}]]) 71 | 72 | (defn update-convert-container 73 | "update convert video type" 74 | [event, index, value] 75 | (swap! convert-option assoc-in [:type] value)) 76 | 77 | (defn update-convert-quality 78 | "update convert video type" 79 | [event, index, value] 80 | (swap! convert-option assoc-in [:quality] value)) 81 | 82 | (defn convert-container 83 | [file] 84 | [:div 85 | [:p "Please select target type: "] 86 | [ui/drop-down-menu {:max-height 300 87 | :value (:type @convert-option) 88 | :on-change update-convert-container} 89 | (for [video-container video-container-list] 90 | ^{:key video-container} 91 | [ui/menu-item {:value (:value video-container) 92 | :label (:name video-container) 93 | :primary-text (:name video-container)}])]]) 94 | 95 | (defn convert-quality 96 | [file] 97 | [:div 98 | [:p (str "File quality: " (format-quality file))] 99 | [:p "Please select target quality:"] 100 | [ui/drop-down-menu {:max-height 300 101 | :value (:quality @convert-option) 102 | :on-change update-convert-quality} 103 | (for [video-quality video-quality-list] 104 | ^{:key video-quality} 105 | [ui/menu-item {:value (:value video-quality) 106 | :label (:name video-quality) 107 | :primary-text (:name video-quality)}])]]) 108 | 109 | (defn convert-dialog 110 | [file] 111 | [ui/dialog {:title "Convert video" 112 | :actions (r/as-element (convert-actions file)) 113 | :modal true 114 | :open (boolean (:convert-mode file))} 115 | (match (:convert-mode file) 116 | "video-type" (convert-container file) 117 | "video-quality" (convert-quality file) 118 | :else nil)]) 119 | 120 | -------------------------------------------------------------------------------- /electron_src/electron/convert.cljs: -------------------------------------------------------------------------------- 1 | (ns electron.convert 2 | (:require [cljs.core.match :refer-macros [match]])) 3 | 4 | (def container-support {:avi {:video ["h264" "mpeg1" "mpeg2" "mpeg4" "wmv" "theora" "msmpeg4v2" "vp8"] 5 | :audio ["aac" "mp3" "ac-3" "wma" "dts"]} 6 | :mp4 {:video ["h264" "mpeg1" "mpeg2" "mpeg4" "h265" "wmv" "theora" "msmpeg4v2" "vp8" "vp9"] 7 | :audio ["opus" "aac" "mp3" "ac-3" "wma" "dts" "alac" "dts-hd"]} 8 | :3gp {:video ["h264" "mpeg4"] 9 | :audio ["aac"]} 10 | :mpg {:video ["mpeg1" "mpeg2"] 11 | :audio ["mp3"]} 12 | :ts {:video ["mpeg1" "mpeg2"] 13 | :audio ["aac" "mp3" "ac3"]} 14 | :mkv {:video ["h264" "mpeg1" "mpeg2" "mpeg4" "h265" "wmv" "theora" "msmpeg4v2" "vp8" "vp9"] 15 | :audio ["opus" "vorbis" "mp3" "aac" "ac-3" "wma" "dts" "flac" "mlp" "alac" "dts-hd"]} 16 | :ogg {:video ["theora" "mpeg1" "mpeg2" "wmv"] 17 | :audio ["vorbis" "opus" "mp3" "flac"]} 18 | :webm {:video ["vp8" "vp9"] 19 | :audio ["vorbis" "opus"]} 20 | :flv {:video ["h264"] 21 | :audio ["aac" "mp3"]}}) 22 | 23 | ;;; Based on quality produced from high to low 24 | ;;; libopus > libvorbis >= libfdk_aac > aac > libmp3lame >= eac3/ac3 > libtwolame > vorbis > mp2 > wmav2/wmav1 25 | 26 | (def codec-map {:mpeg1 {:encoder "mpeg1video" 27 | :param ["-q:v" "3"]} 28 | :mpeg2 {:encoder "mpeg2video" 29 | :param ["-q:v" "3"]} 30 | :mpeg4 {:encoder "mpeg4" 31 | :param ["-q:v" "3"]} 32 | :h264 {:encoder "libx264" 33 | :param ["-preset" "medium" "-crf" "23"]} 34 | :h265 {:encoder "libx265" 35 | :param ["-preset" "medium" "-crf" "28"]} 36 | :wmv {:encoder "wmv2"} 37 | :theora {:encoder "libtheora" 38 | :param ["-q:v" "7"]} 39 | :msmpeg4v2 {:encoder "msmpeg4v2"} 40 | :vp8 {:encoder "libvpx" 41 | :param ["-crf" "10" "-b:v" "2M"]} 42 | :vp9 {:encoder "libvpx-vp9" 43 | :param ["-crf" "10" "-b:v" "2M"]} 44 | :mp3 {:encoder "libmp3lame" 45 | :param ["-q:a" "3"]} 46 | :wma {:encoder "wmav2"} 47 | :vorbis {:encoder "libvorbis" 48 | :param ["-q:a" "5"]} 49 | :opus {:encoder "libopus"} 50 | :aac {:encoder "libfdk_aac" 51 | :param ["-vbr" "3"]} 52 | :ac-3 {:encoder "ac3"} 53 | :dts {:encoder "dca"} 54 | :flac {:encoder "flac"} 55 | :mlp {:encoder "mlp"} 56 | :alac {:encoder "alac"}}) 57 | 58 | (defn col-contains? 59 | [x col] 60 | (some #(= x %) col)) 61 | 62 | (defn codec-flag 63 | [codec-type] 64 | (match codec-type 65 | :video "-c:v" 66 | :audio "-c:a" 67 | :subtitle "-c:s")) 68 | 69 | (defn get-encoder-info 70 | [field codec] 71 | (get-in codec-map [(keyword codec) field])) 72 | 73 | (def get-encoder-name (partial get-encoder-info :encoder)) 74 | (def get-encoder-param (partial get-encoder-info :param)) 75 | 76 | (defn gen-target-codec 77 | [codec-type origin-codec target] 78 | (let [flag (codec-flag codec-type) 79 | target-codecs-list (get-in container-support [(keyword target) codec-type] []) 80 | target-codec (if (col-contains? origin-codec 81 | target-codecs-list) 82 | "copy" 83 | (first target-codecs-list))] 84 | (match target-codec 85 | "copy" [flag "copy"] 86 | :else 87 | (into [] (concat [flag (get-encoder-name target-codec)] 88 | (get-encoder-param target-codec)))))) 89 | 90 | (def gen-target-video-codec (partial gen-target-codec :video)) 91 | (def gen-target-audio-codec (partial gen-target-codec :audio)) 92 | 93 | (defn get-codec 94 | "get codec from stream" 95 | [stream-type streams] 96 | (->> streams 97 | (filter #(= (:codec_type %) stream-type)) 98 | first 99 | :codec_name)) 100 | 101 | (def get-video-codec (partial get-codec "video")) 102 | (def get-audio-codec (partial get-codec "audio")) 103 | (def get-subtitle-codec (partial get-codec "subtitle")) 104 | 105 | 106 | (defn construct-convert-args 107 | [video convert-option] 108 | (let [file-path (get-in video [:format :filename]) 109 | base-args ["-y" "-stats" 110 | "-loglevel" "error" 111 | "-i" file-path]] 112 | (match [convert-option] 113 | [{:type convert-type}] 114 | (let [target-path (str (:target convert-option) "." convert-type) 115 | target-video-codec (-> video 116 | :streams 117 | get-video-codec 118 | (gen-target-video-codec convert-type)) 119 | target-audio-codec (-> video 120 | :streams 121 | get-audio-codec 122 | (gen-target-audio-codec convert-type))] 123 | (into [] (concat base-args 124 | target-video-codec 125 | target-audio-codec 126 | [target-path]))) 127 | 128 | [{:quality height}] 129 | (into [] (concat base-args 130 | ["-vf" (str "scale=-1:" height)] 131 | [(:target convert-option)]))))) 132 | 133 | 134 | -------------------------------------------------------------------------------- /ui_src/ui/ffmpeg.cljs: -------------------------------------------------------------------------------- 1 | (ns ui.ffmpeg 2 | (:require [cljs.core.match :refer-macros [match]] 3 | [ui.state :refer [active-files 4 | messages 5 | convert-option 6 | tasks]] 7 | [ui.utils.common :refer [file-uid 8 | msg-uid 9 | task-uid]] 10 | [ui.utils.lang :refer [js->clj-kw]])) 11 | 12 | (defonce path (js/require "path")) 13 | 14 | (defonce electron (js/require "electron")) 15 | (defonce app (.-app (.-remote electron))) 16 | 17 | (def preview-dir (.resolve path 18 | (.getPath app "userData") 19 | "preview")) 20 | 21 | (defonce ipcRenderer (.-ipcRenderer electron)) 22 | 23 | (defn probe 24 | [file] 25 | (.send ipcRenderer "ffmpeg-probe" file)) 26 | 27 | (defn parse-invoke-resp 28 | [resp] 29 | (.log js/console resp) 30 | (match (js->clj resp :keywordize-keys true) 31 | {:status "ok" :data data} [nil data] 32 | {:status "error" :msg err} [err])) 33 | 34 | (defn clean-file 35 | "doc-string" 36 | [file-id] 37 | (swap! active-files dissoc file-id) 38 | (.send ipcRenderer "ffmpeg-clean-preview" file-id)) 39 | 40 | 41 | (defn preview-src 42 | "gen preview image src with preview time index" 43 | [file-id index] 44 | (swap! active-files 45 | assoc-in 46 | [file-id :preview-src] 47 | (str preview-dir 48 | "/" 49 | file-id 50 | "/preview_" index ".png"))) 51 | 52 | (defn convert-video 53 | [file convert-option] 54 | (let [task-id (task-uid)] 55 | (swap! tasks assoc task-id {:id task-id 56 | :file-id (:id file)}) 57 | (.send ipcRenderer 58 | "ffmpeg-video-convert" 59 | task-id 60 | (clj->js file) 61 | (clj->js convert-option)))) 62 | 63 | (defn export-video 64 | [file] 65 | (.send ipcRenderer 66 | "ffmpeg-video-export" 67 | (clj->js file) 68 | (clj->js @convert-option))) 69 | 70 | (defn cancel-convert 71 | [pid] 72 | (.send ipcRenderer 73 | "ffmpeg-video-cancel-convert" 74 | (clj->js pid))) 75 | 76 | 77 | (defn preview 78 | [video] 79 | (.send ipcRenderer "ffmpeg-video-preview" (clj->js video))) 80 | 81 | (defn add-msg 82 | [msg-type text] 83 | (let [new-msg-id (msg-uid)] 84 | (swap! messages 85 | assoc 86 | new-msg-id 87 | {:msg-id new-msg-id 88 | :type msg-type 89 | :text text}))) 90 | 91 | (def add-success-msg (partial add-msg :success)) 92 | (def add-error-msg (partial add-msg :error)) 93 | (def add-info-msg (partial add-msg :info)) 94 | 95 | (defn extract-stream 96 | [stream-type file] 97 | (->> file 98 | :streams 99 | (filter #(->> % 100 | :codec_type 101 | keyword 102 | (= stream-type))) 103 | first)) 104 | 105 | (def extract-video-stream (partial extract-stream :video)) 106 | (def extract-audio-stream (partial extract-stream :audio)) 107 | (def extract-subtitle-stream (partial extract-stream :subtitle)) 108 | 109 | (def check-stream-type extract-stream) 110 | 111 | (def check-video-stream (partial check-stream-type :video)) 112 | (def check-audio-stream (partial check-stream-type :audio)) 113 | (def check-subtitle-stream (partial check-stream-type :subtitle)) 114 | 115 | (defn- handle-probe-result 116 | [event ret] 117 | (match (parse-invoke-resp ret) 118 | [err] (add-error-msg err) 119 | [nil data] (let [file-obj (->> data 120 | (.parse js/JSON) 121 | js->clj-kw)] 122 | (if-not (check-video-stream file-obj) 123 | (add-error-msg "current version only support file with video stream") 124 | (let [file-id (file-uid) 125 | file-with-id (assoc file-obj :id file-id)] 126 | (swap! active-files 127 | assoc file-id file-with-id) 128 | (preview file-with-id)))))) ;setup preview file 129 | 130 | (.on ipcRenderer "ffmpeg-probe-resp" handle-probe-result) 131 | 132 | (defn- handle-preview-result 133 | [event ret] 134 | (match (parse-invoke-resp ret) 135 | [err] (add-error-msg err) 136 | [nil file-id] (preview-src file-id 1))) 137 | 138 | (.on ipcRenderer "ffmpeg-video-preview-resp" handle-preview-result) 139 | 140 | (defn- handle-export-result 141 | [event ret] 142 | (match (parse-invoke-resp ret) 143 | [nil {:file file 144 | :convert-option convert-option}] 145 | (convert-video file 146 | convert-option))) 147 | 148 | (.on ipcRenderer "ffmpeg-video-export-resp" handle-export-result) 149 | 150 | (defn handle-convert-begin 151 | [event ret] 152 | (match (parse-invoke-resp ret) 153 | [err] (add-error-msg err) 154 | [nil {:file-id file-id 155 | :task-id task-id 156 | :process-data process-data}] 157 | (do 158 | (swap! tasks assoc-in [task-id :process] process-data) 159 | (swap! active-files 160 | assoc-in 161 | [file-id :convert-mode] nil)))) 162 | 163 | (defn handle-convert-progress 164 | [event ret] 165 | (match (parse-invoke-resp ret) 166 | [err] (add-error-msg err) 167 | [nil {:task-id task-id 168 | :progress progress}] 169 | (swap! tasks assoc-in [task-id :process :progress] progress))) 170 | 171 | (defn- handle-convert-result 172 | [event ret] 173 | (match (parse-invoke-resp ret) 174 | [err] (add-error-msg err) 175 | [nil {:file-id file-id 176 | :task-id task-id}] 177 | (do 178 | (swap! tasks dissoc task-id) 179 | (add-success-msg "convert complete")))) 180 | 181 | (.on ipcRenderer "ffmpeg-video-convert-begin-resp" handle-convert-begin) 182 | (.on ipcRenderer "ffmpeg-video-convert-progress-resp" handle-convert-progress) 183 | (.on ipcRenderer "ffmpeg-video-convert-finish-resp" handle-convert-result) 184 | -------------------------------------------------------------------------------- /ui_src/ui/component/video_item.cljs: -------------------------------------------------------------------------------- 1 | (ns ui.component.video-item 2 | (:require [clojure.string :refer [join split]] 3 | [cljs.core.match :refer-macros [match]] 4 | [reagent.core :as r] 5 | [cljs-react-material-ui.reagent :as ui] 6 | [cljs-react-material-ui.icons :as ic] 7 | [reanimated.core :as anim] 8 | [ui.utils.common :refer [format-file-size format-time format-bitrate]] 9 | [ui.state :refer [active-files tasks]] 10 | [ui.ffmpeg :refer [cancel-convert clean-file]] 11 | [ui.component.video-thumbnail :refer [video-thumbnail]] 12 | [ui.component.video-stream :refer [stream-item]] 13 | [ui.component.video-dialog :refer [convert-dialog]])) 14 | 15 | (defn toggle-file-select 16 | "doc-string" 17 | [file event] 18 | (let [file-id (:id file)] 19 | (swap! active-files update-in [file-id :selected] not))) 20 | 21 | (defn toggle-file-detail 22 | "doc-string" 23 | [file event] 24 | (let [file-id (:id file)] 25 | (.stopPropagation event) 26 | (swap! active-files update-in [file-id :detailed] not))) 27 | 28 | (defn toggle-convert-modal 29 | "open video conversion modal" 30 | [file event elem] 31 | (let [file-id (:id file) 32 | mode (.-type (.-props elem))] 33 | (swap! active-files assoc-in [file-id :convert-mode] mode))) 34 | 35 | (defn handle-more 36 | "handle more action with file" 37 | [file event elem] 38 | (let [file-id (:id file) 39 | action (-> elem 40 | .-props 41 | .-action)] 42 | (match action 43 | "remove" 44 | (clean-file file-id)))) 45 | 46 | (defn handle-cancel 47 | [task event] 48 | (do 49 | (.stopPropagation event) 50 | (swap! tasks dissoc (:id task)) 51 | (cancel-convert (get-in task [:process :pid])))) 52 | 53 | (defn find-task 54 | [file] 55 | (let [file-id (:id file)] 56 | (->> @tasks 57 | vals 58 | (filter #(= file-id 59 | (:file-id %))) 60 | first))) 61 | 62 | (defn cancel-btn 63 | [task] 64 | [:div {:style {:top 0 65 | :right 0}} 66 | [ui/icon-button 67 | {:on-click (partial handle-cancel task)} 68 | (ic/content-clear)]]) 69 | 70 | (defn action-btns 71 | [file] 72 | [:div {:style {:top 0 73 | :right 0 74 | :width 144}} 75 | [ui/icon-button {:class (if (:detailed file) "fold" "more") 76 | :on-click (partial toggle-file-detail file)} 77 | (if (:detailed file) 78 | (ic/navigation-expand-less) 79 | (ic/navigation-expand-more))] 80 | [ui/icon-menu {:use-layer-for-click-away true 81 | :icon-button-element (r/as-element 82 | [ui/icon-button {:on-click #(.stopPropagation %)} 83 | (ic/action-swap-horiz)]) 84 | :anchor-origin {:horizontal "right" :vertical "top"} 85 | :target-origin {:horizontal "right" :vertical "top"} 86 | :on-item-touch-tap (partial toggle-convert-modal file)} 87 | [ui/menu-item {:primary-text "type" 88 | :type "video-type"}] 89 | [ui/menu-item {:primary-text "quality" 90 | :type "video-quality"}] 91 | [ui/menu-item {:primary-text "dimension" 92 | :type "video-dimension"}] 93 | [ui/menu-item {:primary-text "preset" 94 | :type "video-preset"}] 95 | [ui/menu-item {:primary-text "custom" 96 | :type "advance"}]] 97 | [ui/icon-menu {:use-layer-for-click-away true 98 | :icon-button-element (r/as-element 99 | [ui/icon-button {:on-click #(.stopPropagation %)} 100 | (ic/navigation-more-vert)]) 101 | :anchor-origin {:horizontal "right" :vertical "top"} 102 | :target-origin {:horizontal "right" :vertical "top"} 103 | :on-item-touch-tap (partial handle-more file)} 104 | [ui/menu-item {:primary-text "remove" 105 | :action "remove"}]]]) 106 | 107 | (defn format-filename 108 | [filename] 109 | (last (split filename "/"))) 110 | 111 | (defn file-field 112 | [field value] 113 | (match field 114 | :duration [:p {:class "single-field"} 115 | [ic/action-schedule] 116 | [:span (format-time (js/parseInt value 10))]] 117 | :size [:p {:class "single-field"} 118 | [ic/notification-ondemand-video] 119 | [:span (format-file-size value)]] 120 | :bit_rate [:p {:class "single-field"} 121 | [ic/action-trending-up] 122 | [:span (str (format-bitrate value) "/s")]] 123 | :filename [:p {:class "single-field"} 124 | [ic/file-folder-open] 125 | [:span (format-filename value)]])) 126 | 127 | (defn video-item 128 | "video item card" 129 | [file] 130 | (let [task (find-task file)] 131 | [ui/card 132 | [:div {:class (str "video-card" (cond 133 | (:selected file) " video-selected" 134 | (:detailed file) " video-detailed")) 135 | :on-click (partial toggle-file-select file)} 136 | [video-thumbnail file] 137 | [:div {:class "video-info"} 138 | (for [field [:filename :size :bit_rate :duration]] 139 | ^{:key field} 140 | [:div (file-field field (get-in file [:format field]))])] 141 | (if-not (nil? task) [ui/linear-progress {:class "convert-progress-bar" 142 | :mode "determinate" 143 | :value (get-in task [:process :progress])}]) 144 | [:div {:class "action-menu"} 145 | [anim/css-transition-group 146 | {:transition-name "cancel-btn" 147 | :transition-enter-timeout 500 148 | :transition-leave-timeout 500} 149 | (if-not (nil? task) 150 | (cancel-btn task))] 151 | [anim/css-transition-group 152 | {:transition-name "action-btn" 153 | :transition-enter-timeout 500 154 | :transition-leave-timeout 500} 155 | (if (nil? task) 156 | (action-btns file))]] 157 | [:div {:class (str "video-stream-detail" 158 | (if (:more-detail file) " detail-active")) 159 | :on-click #(.stopPropagation %)} 160 | (for [stream (:streams file)] 161 | ^{:key stream} 162 | [stream-item stream])] 163 | (when-not (nil? (:convert-mode file)) 164 | [convert-dialog file])]])) 165 | 166 | -------------------------------------------------------------------------------- /electron_src/electron/ffmpeg.cljs: -------------------------------------------------------------------------------- 1 | (ns electron.ffmpeg 2 | (:require-macros [cljs.core.async.macros :refer [go go-loop]]) 3 | (:require [cljs.core.match :refer-macros [match]] 4 | [cljs.core.async :as async :refer [! put! chan alts! timeout]] 5 | [electron.common :refer [ffmpeg-bin ffprobe-bin preview-dir]] 6 | [electron.utils :refer [mkdir fs-unlink show-save-dialog]] 7 | [electron.convert :refer [construct-convert-args]])) 8 | 9 | (defonce child_process (js/require "child_process")) 10 | 11 | (defonce electron (js/require "electron")) 12 | (defonce path (js/require "path")) 13 | 14 | (defonce dialog (.-dialog electron)) 15 | 16 | (def ipcMain (.-ipcMain electron)) 17 | 18 | (defn format-invoke-resp 19 | "format ipc invoke response" 20 | [ret] 21 | (clj->js 22 | (match ret 23 | [nil data] {:status "ok" :data data} 24 | [err :guard #(not (nil? %))] {:status "error" :msg err}))) 25 | 26 | (defn success-invoke-resp 27 | "format success invoke response" 28 | [data] 29 | (format-invoke-resp [nil data])) 30 | 31 | (defn fail-invoke-resp 32 | "format failure invoke response" 33 | [err] 34 | (format-invoke-resp [err nil])) 35 | 36 | 37 | (defn spawn-process 38 | "spawn new process helper" 39 | [cmd args] 40 | (let [out (chan) 41 | output-str (atom "") 42 | err-str (atom "") 43 | newProcess (.spawn child_process 44 | (clj->js cmd) 45 | (clj->js args))] 46 | (.on (.-stderr newProcess) 47 | "data" 48 | #(swap! err-str str %)) 49 | (.on (.-stdout newProcess) 50 | "data" 51 | #(swap! output-str str %)) 52 | (.on newProcess 53 | "exit" 54 | (fn [code] 55 | (put! out (match code 56 | 0 [nil @output-str] 57 | 1 [@err-str])))) 58 | out)) 59 | 60 | (defn progress-process 61 | [cmd args] 62 | (let [out (chan) 63 | err-str (atom "") 64 | output-str (atom "") 65 | newProcess (.spawn child_process 66 | (clj->js cmd) 67 | (clj->js args))] 68 | (.on (.-stderr newProcess) 69 | "data" 70 | (fn [err-data] 71 | (do 72 | (swap! err-str str err-data) 73 | (put! out {:progress (str err-data)})))) 74 | (.on (.-stdout newProcess) 75 | "data" 76 | #(swap! output-str str %)) 77 | (.on newProcess 78 | "exit" 79 | (fn [code] 80 | (put! out {:exit (match code 81 | 0 [nil @output-str] 82 | 1 [@err-str] 83 | 255 ["cancel"])}))) 84 | [newProcess out])) 85 | 86 | (defn probe-video 87 | "probe video" 88 | [file-path] 89 | (spawn-process ffprobe-bin 90 | ["-loglevel" "error" 91 | "-of" "json" 92 | "-show_streams" "-show_format" 93 | file-path])) 94 | 95 | (defn respond-probe 96 | "respond to probe request" 97 | [ret sender] 98 | (->> ret 99 | format-invoke-resp 100 | (.send sender "ffmpeg-probe-resp"))) 101 | 102 | (defn- handle-probe 103 | "handle-probe" 104 | [event file-path] 105 | (go 106 | (-> file-path 107 | probe-video 108 | event 135 | .-sender 136 | (.send 137 | "ffmpeg-video-preview-resp" 138 | (if-not (nil? mkdir-err) 139 | (fail-invoke-resp mkdir-err) 140 | (let [[preview-err] (> ["-loglevel" "error" 141 | "-i" file-name 142 | "-r" second-rate 143 | "-vf" "scale=-1:180" 144 | "-vcodec" "png" 145 | (str dirname "/preview_%d.png")] 146 | (spawn-process ffmpeg-bin)))] 147 | (if (nil? preview-err) 148 | (success-invoke-resp file-id) 149 | (fail-invoke-resp preview-err)))))))))) 150 | 151 | (defn send-channel-back 152 | "send channel back with response dadta" 153 | [event channel data] 154 | (.send (.-sender event) 155 | channel 156 | data)) 157 | 158 | 159 | (defn convert-progress-data 160 | "generate convert task progress data" 161 | [video progress-line] 162 | (let [[_ hours minutes seconds mili-seconds] (re-find #".*time=(\d{2}):(\d{2}):(\d{2}).(\d{2}).*" progress-line) 163 | duration (-> video 164 | (get-in [:format :duration]) 165 | js/parseFloat 166 | (* 1000) 167 | (js/parseInt 10)) 168 | current (reduce (fn [ret [v factor]] 169 | (+ ret (* factor (js/parseInt v 10)))) 170 | 0 171 | (partition 2 [hours 3600000 172 | minutes 60000 173 | seconds 1000 174 | mili-seconds 1]))] 175 | (->> (/ current duration) 176 | (* 100) 177 | (.floor js/Math)))) 178 | 179 | 180 | (defn- handle-video-convert 181 | [event task-id video convert-option] 182 | (let [js-video (js->clj video :keywordize-keys true) 183 | js-convert-option (js->clj convert-option :keywordize-keys true) 184 | file-id (:id js-video) 185 | progress-notify (partial send-channel-back 186 | event 187 | "ffmpeg-video-convert-progress-resp") 188 | finish-notify (partial send-channel-back 189 | event 190 | "ffmpeg-video-convert-finish-resp") 191 | calc-progress (partial convert-progress-data js-video) 192 | convert-args (construct-convert-args js-video 193 | js-convert-option) 194 | [process out] (progress-process ffmpeg-bin convert-args)] 195 | (->> {:file-id file-id 196 | :task-id task-id 197 | :process-data {:pid (.-pid process) 198 | :progress 0}} 199 | success-invoke-resp 200 | (send-channel-back event "ffmpeg-video-convert-begin-resp")) 201 | (go-loop [] 202 | (let [ret (> progress-data 207 | calc-progress 208 | (assoc {:task-id task-id} :progress) 209 | success-invoke-resp 210 | progress-notify) 211 | (recur)) 212 | 213 | [{:exit exit-data}] 214 | (match exit-data 215 | ["cancel"] (fs-unlink (str (:target js-convert-option) "." (:type js-convert-option))) 216 | [convert-err] (->> convert-err 217 | fail-invoke-resp 218 | finish-notify) 219 | [nil _] (->> {:file-id file-id 220 | :task-id task-id} 221 | success-invoke-resp 222 | finish-notify))))))) 223 | 224 | (defn handle-video-export 225 | [event file convert-option] 226 | (go 227 | (let [[ret] (> {:file file 231 | :convert-option convert-option} 232 | success-invoke-resp 233 | (.send (.-sender event) "ffmpeg-video-export-resp")))))) 234 | 235 | (defn handle-video-cancel-convert 236 | [event pid] 237 | (.kill js/process pid)) 238 | 239 | (.on ipcMain "ffmpeg-probe" handle-probe) 240 | (.on ipcMain "ffmpeg-video-preview" handle-video-preview) 241 | (.on ipcMain "ffmpeg-video-convert" handle-video-convert) 242 | (.on ipcMain "ffmpeg-video-export" handle-video-export) 243 | (.on ipcMain "ffmpeg-video-cancel-convert" handle-video-cancel-convert) 244 | 245 | (defn handle-clean-preview 246 | [event file-id] 247 | (spawn-process "rm" 248 | ["-rf" (.resolve path 249 | preview-dir 250 | file-id)])) 251 | 252 | (.on ipcMain "ffmpeg-clean-preview" handle-clean-preview) 253 | --------------------------------------------------------------------------------