├── .gitignore ├── tsconfig.json ├── rollup.config.ts ├── .eslintrc.json ├── .github └── workflows │ ├── eslint.yml │ ├── tagged-release.yml │ └── pre-release.yml ├── src ├── shared.ts ├── types.ts ├── streams.ts ├── purempv.ts ├── ffmpeg.ts ├── index.ts ├── utils.ts └── cropbox.ts ├── LICENSE ├── package.json ├── README.md └── main.js /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | node_modules 3 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "esnext", 5 | "moduleResolution": "node", 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "strict": true, 9 | "skipLibCheck": true 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /rollup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "rollup"; 2 | 3 | import typescript from "@rollup/plugin-typescript"; 4 | import terser from "@rollup/plugin-terser"; 5 | 6 | const plugins = [typescript(), terser()]; 7 | 8 | export default defineConfig({ 9 | input: "src/index.ts", 10 | output: { 11 | file: "main.js", 12 | format: "cjs", 13 | }, 14 | plugins, 15 | }); 16 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es2021": true, 4 | "node": true 5 | }, 6 | "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"], 7 | "overrides": [], 8 | "parser": "@typescript-eslint/parser", 9 | "parserOptions": { 10 | "ecmaVersion": "latest", 11 | "sourceType": "module" 12 | }, 13 | "plugins": ["@typescript-eslint"], 14 | "rules": {} 15 | } 16 | -------------------------------------------------------------------------------- /.github/workflows/eslint.yml: -------------------------------------------------------------------------------- 1 | name: ESLint 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | paths: 8 | - src/** 9 | 10 | jobs: 11 | eslint: 12 | name: Run ESLint 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | 18 | - name: Install dependencies 19 | run: npm ci 20 | 21 | - name: Run ESLint on src directory 22 | run: npx eslint src 23 | -------------------------------------------------------------------------------- /src/shared.ts: -------------------------------------------------------------------------------- 1 | import PureMPV from "./purempv"; 2 | 3 | const PATH = "user-data/PureMPV"; 4 | 5 | const updateSharedData = () => { 6 | const cropbox = { 7 | w: PureMPV.cropBox.w, 8 | h: PureMPV.cropBox.h, 9 | x: PureMPV.cropBox.x, 10 | y: PureMPV.cropBox.y, 11 | }; 12 | 13 | const timestamps = { 14 | start: PureMPV.timestamps.start, 15 | end: PureMPV.timestamps.end, 16 | }; 17 | 18 | mp.set_property_native(PATH, { cropbox, timestamps }); 19 | }; 20 | 21 | export { updateSharedData }; 22 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface Options { 2 | executable: string; 3 | pure_mode: boolean; 4 | ffmpeg_params: string; 5 | input_seeking: boolean; 6 | selection: "primary" | "clipboard"; 7 | copy_utility: "detect" | "xclip" | "wl-copy" | "pbcopy"; 8 | hide_osc_on_crop: boolean; 9 | box_color: string; 10 | 11 | key_crop: string; 12 | key_preview: string; 13 | key_pure_mode: string; 14 | key_file_path: string; 15 | key_timestamp: string; 16 | key_timestamp_end: string; 17 | [id: string]: string | number | boolean; 18 | } 19 | 20 | export interface Box { 21 | constX?: number; 22 | constY?: number; 23 | w?: number; 24 | h?: number; 25 | x?: number; 26 | y?: number; 27 | color: string; 28 | isCropping: boolean; 29 | toString: () => string; 30 | } 31 | -------------------------------------------------------------------------------- /.github/workflows/tagged-release.yml: -------------------------------------------------------------------------------- 1 | name: tagged-release 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | tagged-release: 13 | name: Tagged release 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v3 18 | 19 | - name: Install dependencies 20 | run: npm ci 21 | 22 | - name: Build main.js 23 | run: npm run build 24 | 25 | - name: Generate changelog text 26 | id: changelog 27 | uses: loopwerk/tag-changelog@v1 28 | with: 29 | token: ${{ secrets.GITHUB_TOKEN }} 30 | 31 | - name: Create a release 32 | uses: softprops/action-gh-release@v1 33 | with: 34 | prerelease: false 35 | body: ${{ steps.changelog.outputs.changes }} 36 | files: main.js 37 | -------------------------------------------------------------------------------- /.github/workflows/pre-release.yml: -------------------------------------------------------------------------------- 1 | name: Pre-release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - src/** 9 | - .github/workflows/pre-release.yml 10 | 11 | permissions: 12 | contents: write 13 | 14 | jobs: 15 | build-and-release: 16 | name: Build & Release 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - uses: actions/checkout@v3 21 | 22 | - name: Install dependencies 23 | run: npm ci 24 | 25 | - name: Run ESLint on src directory 26 | run: npx eslint src 27 | 28 | - name: Build main.js 29 | run: npm run build 30 | 31 | - name: Delete previous pre-release 32 | run: gh release delete --yes --cleanup-tag bleeding-edge 33 | env: 34 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 35 | 36 | - name: Create a new pre-release 37 | uses: softprops/action-gh-release@v1 38 | with: 39 | prerelease: true 40 | tag_name: bleeding-edge 41 | body: Pre-release with the latest commits 42 | files: main.js 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 4ndrs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /src/streams.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns a list of media stream urls if the source is known 3 | */ 4 | const getStreamUrls = (path: string) => { 5 | const source = getSource(path); 6 | const openStream = mp.get_property("stream-open-filename"); 7 | 8 | if (typeof openStream !== "string") { 9 | throw new Error("Unable to retrieve the open stream"); 10 | } 11 | 12 | const streams = openStream.split(";"); 13 | 14 | switch (source) { 15 | case "youtube": { 16 | const urls = getUrls(streams, "googlevideo"); 17 | return urls; 18 | } 19 | } 20 | }; 21 | 22 | const getSource = (path: string) => { 23 | const isYoutube = path.search("youtube|youtu.be") !== -1; 24 | 25 | if (isYoutube) { 26 | return "youtube"; 27 | } 28 | }; 29 | 30 | const getUrls = (streams: string[], filter: string) => 31 | streams.reduce((accumulator, stream) => { 32 | const hasUrl = stream.match(/http[s]?:\/\/.+/); 33 | const matchesFilter = stream.search(filter) !== -1; 34 | 35 | if (matchesFilter && hasUrl) { 36 | return [...accumulator, hasUrl[0]]; 37 | } 38 | 39 | return accumulator; 40 | }, []); 41 | 42 | export { getStreamUrls }; 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "purempv", 3 | "version": "1.0.1", 4 | "private": true, 5 | "description": "Script to get the timestamps, cropping coordinates, and file path of the playing video, all within mpv", 6 | "main": "main.js", 7 | "scripts": { 8 | "test": "jest src/", 9 | "build": "rm main.js && rollup --config rollup.config.ts --configPlugin typescript", 10 | "dev": "npm run build -- --watch" 11 | }, 12 | "jest": { 13 | "preset": "ts-jest", 14 | "testEnvironment": "node" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/4ndrs/PureMPV.git" 19 | }, 20 | "author": "4ndrs ", 21 | "license": "MIT", 22 | "bugs": { 23 | "url": "https://github.com/4ndrs/PureMPV/issues" 24 | }, 25 | "homepage": "https://github.com/4ndrs/PureMPV#readme", 26 | "devDependencies": { 27 | "@rollup/plugin-terser": "^0.2.1", 28 | "@rollup/plugin-typescript": "^10.0.1", 29 | "@types/jest": "^29.5.0", 30 | "@types/mpv-script": "^0.32.2", 31 | "@typescript-eslint/eslint-plugin": "^5.48.0", 32 | "@typescript-eslint/parser": "^5.48.0", 33 | "eslint": "^8.31.0", 34 | "jest": "^29.5.0", 35 | "rollup": "^3.9.1", 36 | "ts-jest": "^29.0.5", 37 | "typescript": "^4.9.4" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/purempv.ts: -------------------------------------------------------------------------------- 1 | import type { Options, Box } from "./types"; 2 | 3 | const options: Options = { 4 | executable: "ffmpeg", 5 | pure_mode: true, 6 | ffmpeg_params: "", 7 | input_seeking: true, 8 | selection: "primary", 9 | copy_utility: "detect", 10 | hide_osc_on_crop: false, 11 | box_color: "#FF1493", 12 | 13 | key_crop: "c", 14 | key_preview: "ctrl+shift+w", 15 | key_pure_mode: "ctrl+p", 16 | key_file_path: "ctrl+w", 17 | key_timestamp: "ctrl+e", 18 | key_timestamp_end: "ctrl+shift+e", 19 | }; 20 | 21 | const getKeys = () => ({ 22 | crop: options.key_crop, 23 | preview: options.key_preview, 24 | mode: options.key_pure_mode, 25 | path: options.key_file_path, 26 | time: options.key_timestamp, 27 | timeEnd: options.key_timestamp_end, 28 | }); 29 | 30 | const setBoxColor = () => { 31 | const color = PureMPV.options.box_color.toUpperCase(); 32 | const isValidHexColor = /^#[0-9A-F]{6}$/.test(color); 33 | 34 | if (!isValidHexColor) { 35 | mp.msg.warn(`Invalid hex color: ${color}`); 36 | 37 | const deepPink = "9314FF"; // #FF1493 38 | 39 | PureMPV.cropBox.color = deepPink; 40 | 41 | return; 42 | } 43 | 44 | const rgb = color.slice(1); 45 | 46 | const red = `${rgb[0]}${rgb[1]}`; 47 | const green = `${rgb[2]}${rgb[3]}`; 48 | const blue = `${rgb[4]}${rgb[5]}`; 49 | 50 | const bgr = blue + green + red; 51 | 52 | PureMPV.cropBox.color = bgr; 53 | }; 54 | 55 | const timestamps: { start?: string; end?: string } = {}; 56 | 57 | const cropBox: Box = { 58 | isCropping: false, 59 | color: "9314FF", 60 | toString() { 61 | return typeof this.w === "number" && typeof this.h === "number" 62 | ? `${cropBox.w}:${cropBox.h}:${cropBox.x}:${cropBox.y}` 63 | : ""; 64 | }, 65 | }; 66 | 67 | const PureMPV = { options, getKeys, setBoxColor, timestamps, cropBox }; 68 | 69 | export default PureMPV; 70 | -------------------------------------------------------------------------------- /src/ffmpeg.ts: -------------------------------------------------------------------------------- 1 | import { getPath, printMessage } from "./utils"; 2 | import { getStreamUrls } from "./streams"; 3 | import { boxIsSet } from "./cropbox"; 4 | 5 | import PureMPV from "./purempv"; 6 | 7 | const preview = () => { 8 | printMessage("Processing preview"); 9 | 10 | const muteAudio = mp.get_property("mute") === "yes" ? "-an" : ""; 11 | const inputs = serializeInputs(); 12 | const cropLavfi = serializeCropBox(); 13 | 14 | const params = 15 | `${muteAudio} -map_metadata -1 -map_chapters -1 -f matroska ` + 16 | "-c:v libx264 -preset ultrafast - | mpv - --loop"; 17 | 18 | const mappings = inputs.map( 19 | (_input, index) => 20 | `-map ${index}:a?` + (cropLavfi ? "" : ` -map ${index}:v?`), 21 | ); 22 | 23 | const command = `ffmpeg -hide_banner ${inputs.join(" ")} ${mappings.join( 24 | " ", 25 | )} ${cropLavfi} ${params}`; 26 | 27 | mp.commandv("run", "bash", "-c", `(${command})`); 28 | }; 29 | 30 | const generateCommand = () => { 31 | const program = PureMPV.options.executable; 32 | const params = PureMPV.options.ffmpeg_params; 33 | const inputs = serializeInputs(); 34 | const cropLavfi = serializeCropBox(); 35 | 36 | return `${program} ${inputs.join(" ")} ${cropLavfi} ${params}`.trim(); 37 | }; 38 | 39 | const serializeTimestamps = ({ start, end }: typeof PureMPV.timestamps) => 40 | `${start ? "-ss " + start : ""}${ 41 | end ? (start ? " " : "") + "-to " + end : "" 42 | }`; 43 | 44 | const serializeInputs = (options = { subProcessMode: false }) => { 45 | // Note: in subprocess mode this function returns an array of inputs adapted 46 | // for running as subprocess's args, if it is off, each item will be pushed as 47 | // a single string with quoted input paths. The following is an example of a single item 48 | // with inputSeeking=true and subProcessMode=false: 49 | // '-ss start time -to stop time -i "input/file/path"' 50 | const inputSeeking = PureMPV.options.input_seeking; 51 | const timestamps = serializeTimestamps(PureMPV.timestamps); 52 | const path = getPath(); 53 | 54 | const isStream = path.search("^http[s]?://") !== -1; 55 | 56 | if (!timestamps && !isStream) { 57 | return options.subProcessMode ? ["-i", `${path}`] : [`-i "${path}"`]; 58 | } 59 | 60 | if (!isStream) { 61 | return options.subProcessMode 62 | ? [...timestamps.split(" "), "-i", `${path}`] 63 | : inputSeeking 64 | ? [`${timestamps} -i "${path}"`] 65 | : [`-i "${path}" ${timestamps}`]; 66 | } 67 | 68 | const urls = getStreamUrls(path); 69 | const inputs: string[] = []; 70 | 71 | if (!urls) { 72 | throw new Error( 73 | "FIX ME: Unable to parse the stream urls. Source is unknown", 74 | ); 75 | } 76 | 77 | urls.forEach((url) => { 78 | if (options.subProcessMode) { 79 | inputs.push(...timestamps.split(" "), "-i", `${url}`); 80 | } else if (inputSeeking) { 81 | inputs.push(`${timestamps} -i "${url}"`); 82 | } else { 83 | inputs.push(`-i "${url}" ${timestamps}`); 84 | } 85 | }); 86 | 87 | return inputs; 88 | }; 89 | 90 | const serializeCropBox = () => 91 | boxIsSet(PureMPV.cropBox) ? `-lavfi crop=${PureMPV.cropBox.toString()}` : ""; 92 | 93 | export { preview, generateCommand }; 94 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | printMessage, 3 | getCopyUtility, 4 | copyToSelection, 5 | getTimePosition, 6 | getPath, 7 | } from "./utils"; 8 | 9 | import { preview, generateCommand } from "./ffmpeg"; 10 | import { updateSharedData } from "./shared"; 11 | import { getCrop } from "./cropbox"; 12 | 13 | import PureMPV from "./purempv"; 14 | 15 | const loadConfig = () => { 16 | mp.options.read_options(PureMPV.options, "PureMPV"); 17 | 18 | if (PureMPV.options.copy_utility === "detect") { 19 | try { 20 | PureMPV.options.copy_utility = getCopyUtility(); 21 | } catch (error) { 22 | if (error instanceof Error) { 23 | mp.msg.error(error.message); 24 | PureMPV.options.copy_utility = "xclip"; 25 | } 26 | } 27 | } 28 | 29 | if (PureMPV.options.copy_utility === "pbcopy") { 30 | // pbcopy does not have a selection option, the clipboard is the default 31 | PureMPV.options.selection = "clipboard"; 32 | } 33 | }; 34 | 35 | const setKeybindings = () => { 36 | const keys = PureMPV.getKeys(); 37 | 38 | mp.add_key_binding(keys.path, "get-file-path", getFilePath); 39 | mp.add_key_binding(keys.time, "get-timestamp", getTimestamp); 40 | mp.add_key_binding(keys.crop, "get-crop", crop); 41 | mp.add_key_binding(keys.mode, "toggle-puremode", togglePureMode); 42 | 43 | if (PureMPV.options.pure_mode) { 44 | mp.add_key_binding(keys.preview, "generate-preview", preview); 45 | mp.add_key_binding(keys.timeEnd, "set-endtime", () => 46 | getTimestamp({ getEndTime: true }) 47 | ); 48 | } 49 | }; 50 | 51 | const crop = () => { 52 | getCrop(); 53 | 54 | if (!PureMPV.options.pure_mode && !PureMPV.cropBox.isCropping) { 55 | copyToSelection(PureMPV.cropBox.toString()); 56 | } 57 | 58 | if (!PureMPV.cropBox.isCropping) { 59 | updateSharedData(); 60 | } 61 | }; 62 | 63 | const getFilePath = () => { 64 | if (!PureMPV.options.pure_mode) { 65 | const path = getPath(); 66 | 67 | copyToSelection(path); 68 | return; 69 | } 70 | 71 | const command = generateCommand(); 72 | copyToSelection(command); 73 | }; 74 | 75 | const getTimestamp = (options?: { getEndTime: boolean }) => { 76 | const timestamp = getTimePosition(); 77 | 78 | if (options?.getEndTime && PureMPV.options.pure_mode) { 79 | PureMPV.timestamps.end = timestamp; 80 | 81 | printMessage(`Set end time: ${PureMPV.timestamps.end}`); 82 | 83 | updateSharedData(); 84 | return; 85 | } 86 | 87 | if (!PureMPV.options.pure_mode) { 88 | copyToSelection(timestamp); 89 | } else if (!PureMPV.timestamps.start) { 90 | PureMPV.timestamps.start = timestamp; 91 | 92 | printMessage(`Set start time: ${PureMPV.timestamps.start}`); 93 | 94 | updateSharedData(); 95 | } else if (!PureMPV.timestamps.end) { 96 | PureMPV.timestamps.end = timestamp; 97 | 98 | printMessage(`Set end time: ${PureMPV.timestamps.end}`); 99 | 100 | updateSharedData(); 101 | } else { 102 | delete PureMPV.timestamps.start; 103 | delete PureMPV.timestamps.end; 104 | 105 | printMessage("Times reset"); 106 | 107 | updateSharedData(); 108 | } 109 | }; 110 | 111 | const togglePureMode = () => { 112 | PureMPV.options.pure_mode = !PureMPV.options.pure_mode; 113 | 114 | let status = "Pure Mode: "; 115 | 116 | if (PureMPV.options.pure_mode) { 117 | const keys = PureMPV.getKeys(); 118 | 119 | status += "ON"; 120 | 121 | mp.add_key_binding(keys.preview, "generate-preview", preview); 122 | mp.add_key_binding(keys.timeEnd, "set-endtime", () => 123 | getTimestamp({ getEndTime: true }) 124 | ); 125 | } else { 126 | status += "OFF"; 127 | 128 | mp.remove_key_binding("generate-preview"); 129 | mp.remove_key_binding("set-endtime"); 130 | } 131 | 132 | printMessage(status); 133 | }; 134 | 135 | loadConfig(); 136 | setKeybindings(); 137 | 138 | PureMPV.setBoxColor(); 139 | 140 | updateSharedData(); 141 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import PureMPV from "./purempv"; 2 | 3 | const copyToSelection = (text: string) => { 4 | let { copy_utility: copyUtility, selection } = PureMPV.options; 5 | 6 | if ( 7 | copyUtility !== "xclip" && 8 | copyUtility !== "wl-copy" && 9 | copyUtility !== "pbcopy" 10 | ) { 11 | mp.msg.error( 12 | `ERROR: ${copyUtility} is not a known copy utility. ` + 13 | "Possible values are: xclip, wl-copy, pbcopy" 14 | ); 15 | 16 | print("INFO: setting copy utility to 'xclip'"); 17 | 18 | copyUtility = "xclip"; 19 | } 20 | 21 | if (selection != "primary" && selection != "clipboard") { 22 | mp.msg.error( 23 | `ERROR: ${selection} is not a valid selection. ` + 24 | `Possible values are: primary, clipboard` 25 | ); 26 | 27 | print("INFO: setting selection to 'primary'"); 28 | 29 | selection = "primary"; 30 | } 31 | 32 | let args: string[]; 33 | 34 | switch (copyUtility) { 35 | case "xclip": 36 | args = ["xclip", "-selection", selection]; 37 | break; 38 | case "wl-copy": 39 | args = ["wl-copy"]; 40 | 41 | if (selection === "primary") { 42 | args.push("--primary"); 43 | } 44 | 45 | break; 46 | case "pbcopy": 47 | // pbcopy does not have a selection option, the clipboard is the default 48 | args = ["pbcopy"]; 49 | break; 50 | default: 51 | return assertNever(copyUtility); 52 | } 53 | 54 | const { status } = mp.command_native({ 55 | name: "subprocess", 56 | args, 57 | stdin_data: text, 58 | detach: true, 59 | }) as { status: number }; 60 | 61 | if (status === -3) { 62 | mp.msg.error(`Received status: ${status}`); 63 | 64 | printMessage( 65 | `Error occurred during the execution of ${copyUtility}. ` + 66 | `Please verify your ${copyUtility} installation.` 67 | ); 68 | 69 | return; 70 | } 71 | 72 | printMessage(`Copied to ${selection}: ${text}`); 73 | }; 74 | 75 | const getCopyUtility = () => { 76 | const { status: xclipStatus } = mp.command_native({ 77 | name: "subprocess", 78 | args: ["xclip", "-version"], 79 | detach: true, 80 | capture_stderr: true, 81 | }) as { status: number }; 82 | 83 | if (xclipStatus !== -3) { 84 | return "xclip"; 85 | } 86 | 87 | const { status: wlCopyStatus } = mp.command_native({ 88 | name: "subprocess", 89 | args: ["wl-copy", "--version"], 90 | detach: true, 91 | capture_stdout: true, 92 | }) as { status: number }; 93 | 94 | if (wlCopyStatus !== -3) { 95 | return "wl-copy"; 96 | } 97 | 98 | const { status: pbCopyStatus } = mp.command_native({ 99 | name: "subprocess", 100 | args: ["command", "-v", "pbcopy"], 101 | detach: true, 102 | capture_stdout: true, 103 | }) as { status: number }; 104 | 105 | if (pbCopyStatus !== -3) { 106 | return "pbcopy"; 107 | } 108 | 109 | throw new Error( 110 | "No xclip/wl-clipboard/pbcopy found installed. Copying will not work." 111 | ); 112 | }; 113 | 114 | /** 115 | * Returns the current timestamp in the format HH:MM:SS 116 | */ 117 | const getTimePosition = () => { 118 | const timePos = mp.get_property_native("time-pos"); 119 | 120 | if (typeof timePos !== "number") { 121 | throw new Error("Unable to retrieve the time position"); 122 | } 123 | 124 | return new Date(timePos * 1000).toISOString().substring(11, 23); 125 | }; 126 | 127 | const getPath = () => { 128 | const path = mp.get_property("path"); 129 | 130 | if (typeof path !== "string") { 131 | throw new Error("Unable to get the path"); 132 | } 133 | 134 | return path; 135 | }; 136 | 137 | /** 138 | * Prints the message to both the OSD and the console 139 | */ 140 | const printMessage = (message: string) => { 141 | mp.osd_message(message); 142 | print(message); 143 | }; 144 | 145 | const assertNever = (value: never): never => { 146 | throw new Error(`Unexpected value: ${value}`); 147 | }; 148 | 149 | export { 150 | copyToSelection, 151 | getCopyUtility, 152 | getTimePosition, 153 | printMessage, 154 | getPath, 155 | }; 156 | -------------------------------------------------------------------------------- /src/cropbox.ts: -------------------------------------------------------------------------------- 1 | import { printMessage } from "./utils"; 2 | 3 | import PureMPV from "./purempv"; 4 | 5 | import type { Box } from "./types"; 6 | 7 | type Mouse = { x: number; y: number }; 8 | type Video = { height: number; width: number }; 9 | 10 | const { cropBox } = PureMPV; 11 | 12 | const overlay = mp.create_osd_overlay("ass-events"); 13 | 14 | const getCrop = () => { 15 | if (PureMPV.options.pure_mode && !cropBox.isCropping && boxIsSet(cropBox)) { 16 | resetCrop(); 17 | return; 18 | } 19 | 20 | generateCrop(); 21 | }; 22 | 23 | const generateCrop = () => { 24 | if (!cropBox.isCropping) { 25 | cropBox.isCropping = true; 26 | 27 | setInitialMousePosition(); 28 | 29 | mp.observe_property("mouse-pos", "native", animateBox); 30 | 31 | if (PureMPV.options.hide_osc_on_crop) { 32 | mp.command("script-message osc-visibility never"); 33 | } 34 | 35 | print("Cropping started"); 36 | } else { 37 | overlay.remove(); 38 | normalizeCrop(); 39 | 40 | cropBox.isCropping = false; 41 | 42 | mp.unobserve_property(animateBox); 43 | 44 | if (PureMPV.options.hide_osc_on_crop) { 45 | mp.command("script-message osc-visibility auto"); 46 | } 47 | 48 | print("Cropping ended"); 49 | } 50 | }; 51 | 52 | const setInitialMousePosition = () => { 53 | const mouse = getProperties("mouse"); 54 | 55 | cropBox.x = mouse.x; 56 | cropBox.y = mouse.y; 57 | cropBox.constX = mouse.x; 58 | cropBox.constY = mouse.y; 59 | }; 60 | 61 | const normalizeCrop = () => { 62 | const osd = getProperties("osd"); 63 | const video = getProperties("video"); 64 | 65 | if (!boxIsSet(cropBox)) { 66 | throw new Error("cropBox is not set"); 67 | } 68 | 69 | const { width: windowWidth, height: windowHeight } = osd; 70 | 71 | let [yBoundary, xBoundary] = [0, 0]; 72 | let ratioWidth = (windowHeight * video.width) / video.height; 73 | let ratioHeight = (windowWidth * video.height) / video.width; 74 | 75 | if (ratioWidth > windowWidth) { 76 | ratioWidth = windowWidth; 77 | yBoundary = windowHeight - ratioHeight; 78 | } else if (ratioHeight > windowHeight) { 79 | ratioHeight = windowHeight; 80 | xBoundary = windowWidth - ratioWidth; 81 | } 82 | 83 | cropBox.y -= Math.ceil(yBoundary / 2); 84 | cropBox.x -= Math.ceil(xBoundary / 2); 85 | 86 | const proportion = Math.min( 87 | video.width / ratioWidth, 88 | video.height / ratioHeight 89 | ); 90 | 91 | cropBox.w = Math.ceil(cropBox.w * proportion); 92 | cropBox.h = Math.ceil(cropBox.h * proportion); 93 | cropBox.x = Math.ceil(cropBox.x * proportion); 94 | cropBox.y = Math.ceil(cropBox.y * proportion); 95 | }; 96 | 97 | const resetCrop = () => { 98 | delete cropBox.h; 99 | delete cropBox.w; 100 | delete cropBox.y; 101 | delete cropBox.x; 102 | delete cropBox.constY; 103 | delete cropBox.constX; 104 | 105 | printMessage("Crop reset"); 106 | }; 107 | 108 | const animateBox = (_name: unknown, mouse: unknown) => { 109 | if ( 110 | !mouse || 111 | typeof mouse !== "object" || 112 | !("x" in mouse) || 113 | !("y" in mouse) || 114 | typeof mouse.x !== "number" || 115 | typeof mouse.y !== "number" 116 | ) { 117 | throw new Error( 118 | `Did not receive mouse coordinates: ${JSON.stringify(mouse)}` 119 | ); 120 | } 121 | 122 | calculateBox({ x: mouse.x, y: mouse.y }); 123 | drawBox(); 124 | }; 125 | 126 | const drawBox = () => { 127 | if (!boxIsSet(cropBox)) { 128 | throw new Error("cropbox is not set"); 129 | } 130 | 131 | const borderColor = `{\\3c&${PureMPV.cropBox.color}&}`; 132 | const fillColor = "{\\1a&FF&}"; 133 | const borderWidth = "{\\bord4}"; 134 | const positionOffset = "{\\pos(0, 0)}"; 135 | 136 | const osd = getProperties("osd"); 137 | 138 | overlay.res_y = osd.height; 139 | overlay.res_x = osd.width; 140 | 141 | const { x, y, w, h } = cropBox; 142 | 143 | const _box = 144 | `{\\p1}m ${x} ${y} l ${x + w} ${y} ${x + w} ` + 145 | `${y + h} ${x} ${y + h} {\\p0}`; 146 | 147 | const data = `${positionOffset}${borderColor}${fillColor}${borderWidth}${_box}`; 148 | 149 | overlay.data = data; 150 | overlay.update(); 151 | }; 152 | 153 | const calculateBox = (mouse: Mouse) => { 154 | if ( 155 | typeof cropBox.constX !== "number" || 156 | typeof cropBox.constY !== "number" || 157 | typeof cropBox.x !== "number" || 158 | typeof cropBox.y !== "number" 159 | ) { 160 | throw new Error( 161 | `the cropbox was not initialized: ${JSON.stringify(cropBox)}` 162 | ); 163 | } 164 | 165 | if (mouse.x < cropBox.constX) { 166 | cropBox.x = mouse.x; 167 | mouse.x = cropBox.constX; 168 | cropBox.x = Math.min(mouse.x, cropBox.x); 169 | cropBox.w = mouse.x - cropBox.x; 170 | } else { 171 | mouse.x = Math.max(mouse.x, cropBox.x); 172 | cropBox.x = Math.min(mouse.x, cropBox.x); 173 | cropBox.w = mouse.x - cropBox.x; 174 | } 175 | 176 | if (mouse.y < cropBox.constY) { 177 | cropBox.y = mouse.y; 178 | mouse.y = cropBox.constY; 179 | cropBox.y = Math.min(mouse.y, cropBox.y); 180 | cropBox.h = mouse.y - cropBox.y; 181 | } else { 182 | mouse.y = Math.max(mouse.y, cropBox.y); 183 | cropBox.y = Math.min(mouse.y, cropBox.y); 184 | cropBox.h = mouse.y - cropBox.y; 185 | } 186 | }; 187 | 188 | // Overloading 189 | type GetProperties = { 190 | (kind: "mouse"): Mouse; 191 | (kind: "video"): Video; 192 | (kind: "osd"): Video; 193 | }; 194 | 195 | const getProperties: GetProperties = (kind: "mouse" | "video" | "osd") => { 196 | if (kind === "mouse") { 197 | const mouse = mp.get_property_native("mouse-pos"); 198 | 199 | if ( 200 | !mouse || 201 | typeof mouse !== "object" || 202 | !("x" in mouse) || 203 | !("y" in mouse) || 204 | typeof mouse.x !== "number" || 205 | typeof mouse.y !== "number" 206 | ) { 207 | throw new Error("Unable to retrieve mouse properties"); 208 | } 209 | 210 | return { x: mouse.x, y: mouse.y } as Mouse & Video; 211 | } 212 | 213 | if (kind === "osd") { 214 | const osd = mp.get_osd_size(); 215 | 216 | if ( 217 | !osd || 218 | !("height" in osd) || 219 | !("width" in osd) || 220 | typeof osd.width !== "number" || 221 | typeof osd.height !== "number" 222 | ) { 223 | throw new Error("Unable to retrieve OSD size"); 224 | } 225 | 226 | return { height: osd.height, width: osd.width } as Mouse & Video; 227 | } 228 | 229 | const width = mp.get_property_native("width"); 230 | const height = mp.get_property_native("height"); 231 | 232 | if (typeof height !== "number" || typeof width !== "number") { 233 | throw new Error("Unable to retrieve video properties"); 234 | } 235 | 236 | return { width, height } as Mouse & Video; 237 | }; 238 | 239 | const boxIsSet = (box: Box): box is Required => 240 | typeof box.w === "number" && 241 | typeof box.h === "number" && 242 | typeof box.x === "number" && 243 | typeof box.constX === "number" && 244 | typeof box.constY === "number"; 245 | 246 | export { getCrop, boxIsSet }; 247 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PureMPV 2 | 3 | Script to get the timestamps, cropping coordinates, and file path of the playing video, for ffmpeg, all from within mpv. 4 | 5 | ## Installation 6 | The script currently supports Linux/macOS and depends on `xclip` or `wl-clipboard` on Linux and `pbcopy` on macOS to copy the data to the primary/clipboard selections. To install, change directory to your mpv scripts folder, and git clone this repository. An appropriate folder will be created: 7 | ```console 8 | $ cd ~/.config/mpv/scripts 9 | $ git clone https://github.com/4ndrs/PureMPV.git 10 | ``` 11 | 12 | Or if just the script is preferred without downloading the whole source code, downloading the ```main.js``` file from the repository (or one of GitHub's automatic releases) and putting it in your mpv scripts folder is enough. 13 | 14 | >[!NOTE] 15 | >The ```main.js``` in the repository does not get updated on every commit. For a bleeding-edge release of the file, [click here](https://github.com/4ndrs/PureMPV/releases/download/bleeding-edge/main.js), or if cloned, see [Building](#building). 16 | 17 | It would probably be advisable to rename the ```main.js``` file when downloaded individually to avoid conflicts with other scripts in the folder. 18 | 19 | ## Usage 20 | 21 | [usage_preview.webm](https://github.com/4ndrs/PureMPV/assets/31898900/a6ac3832-e086-4d4e-90bd-e625578296e8) 22 | 23 | 24 | 25 | The script by default registers the start and end times by pressing ctrl + e, and waits until the user presses ctrl + w to copy the data to the **primary** selection. 26 | 27 | The script can copy the following by default (PureMode): 28 | 29 | - Start & end time - ctrl + e 30 | - End time - ctrl + shift + e 31 | - Cropping coordinates - c 32 | - File path - ctrl + w 33 | 34 | To copy in this mode, triggering the file path combination is necessary. If none of the above key combinations are omitted (or cancelled), the copied string will be formatted like the following: 35 | ```console 36 | ffmpeg -ss hh:mm:ss -to hh:mm:ss -i "/path/to/file" -lavfi crop=w:h:x:y 37 | ``` 38 | When omitting key combinations, the resulting string will have the values omitted as well, for example triggering just start time, and then the file path will yield the following string: 39 | ```console 40 | ffmpeg -ss hh:mm:ss -i "/path/to/file" 41 | ``` 42 | 43 | To get just the end time the ctrl+shift+e key combination must be pressed with **PureMode** activated. 44 | 45 | The default mode, and the selection to copy to can be changed creating a configuration file under ```$HOME/.config/mpv/script-opts/``` with the name **PureMPV.conf**, and inserting the following: 46 | ```console 47 | pure_mode=no 48 | selection=clipboard 49 | ``` 50 | With the PureMode deactivated, the script will copy the resulting value of the key combination right away, without "ffmpeg -i", for example triggering ctrl + e will copy just the current timestamp, the ctrl + w will copy just the file path, and c will copy just the cropping coordinates. 51 | 52 | Cropping coordinates, and set start & end times, can be cancelled by pressing their own key combination a third time. 53 | 54 | A preview of the currently set settings can be generated pressing ctrl+shift+w in PureMode. 55 | 56 | Output seeking can be enabled inserting ```input_seeking=no``` in the configuration file. 57 | 58 | ## Cropping 59 | To crop, it is necessary to put the mouse pointer in the starting position of the crop. After that, pressing the keybinding c will start the cropping mode; position the mouse to the desired location to generate the cropping coordinates. To stop the cropping mode, press the keybinding again. The cropbox will be set if PureMode is on, and just copied if it is off. 60 | 61 | ![vivycropbox_animation](https://user-images.githubusercontent.com/31898900/185887111-207cfa6b-610f-4952-a07e-58adafe7a3f9.gif) 62 | 63 | ## Shared Data API 64 | Other scripts can access PureMPV's internal data (cropbox and timestamps), which is available using mpv's `user-data` property. It can be requested any time using `mp.get_property_native("user-data/PureMPV")`. The returned object will have the following properties: 65 | 66 | ```typescript 67 | interface PureMPVData { 68 | cropbox: { 69 | w: number | null; 70 | h: number | null; 71 | x: number | null; 72 | y: number | null; 73 | }; 74 | 75 | timestamps: { 76 | start: string | null; 77 | end: string | null; 78 | }; 79 | } 80 | ```` 81 | 82 | An example of its usage can be seen in [pwebm-helper](https://github.com/4ndrs/pwebm-helper), which uses PureMPV's data to encode video segments. 83 | 84 | ## Keybindings summary 85 | |Keybinding|Name|Action| 86 | |----------|----|------| 87 | |ctrl + p| ```toggle-puremode```| Activate/deactivate PureMode. 88 | |ctrl + w| ```get-file-path```| Copy the file path with no formatting.
**PureMode**: copy the currently set parameters formatted with ffmpeg. 89 | |ctrl + shift + w| ```generate-preview```| **PureMode**: Generate a preview of the currently set parameters. 90 | |ctrl + e| ```get-timestamp```| Copy the current time position with the format HH:MM:SS.
**PureMode**: Set the start time parameter if it is not set to the current time position, otherwise set the end time. 91 | |ctrl + shift + e| ```set-endtime```| **PureMode**: Set the end time parameter regardless of whether start time is set or not. 92 | |c| ```get-crop```| Trigger cropping mode, and copy the cropped coordinates in the format W:H:X:Y.
**PureMode**: Trigger cropping mode, and set the cropbox parameter. 93 | 94 | Keybindings can be changed using the names in this table and modifying your `input.conf`, or changing the relevant option key in the [configuration file](#configuration-file). 95 | 96 | ## Configuration file 97 | 98 | The configuration file is located in ```$HOME/.config/mpv/script-opts/PureMPV.conf```, and with it, it is possible to change the following options: 99 | |Option key|Values|Details| 100 | |----------|----|------| 101 | |pure_mode| yes
no| Specifies if PureMode will be activated when running. Default is **yes**. 102 | |executable| executable | Specifies which program to prepend to the copied string in PureMode. Default is **ffmpeg**. 103 | |ffmpeg_params| params| Specifies which params to append to the copied string. Default is **empty**. 104 | |selection| primary
clipboard| Specifies where to copy the string. Default is **primary**.1 105 | |copy_utility| detect
xclip
wl-copy
pbcopy| Specifies which utility to use to copy the string. Default is **detect**. 106 | |hide_osc_on_crop| yes
no| Specifies if the OSC (On Screen Controller) should be hidden when cropping. Default is **no**. 107 | |input_seeking| yes
no| Specifies if input seeking should be assumed when formatting the timestamps with the inputs. Default is **yes**. 108 | |box_color| hex color | Specifies the color of the cropbox represented as an #RRGGBB hexadecimal value. Default is **#FF1493**. 109 | 110 | 1. On macOS, the selection will always be `clipboard`. 111 | 112 | ##### Keybinding options 113 | |Option key|Details| 114 | |----------|------| 115 | |key_crop| default: **c** 116 | |key_preview| default: **ctrl+shift+w** 117 | |key_pure_mode| default: **ctrl+p** 118 | |key_file_path| default: **ctrl+w** 119 | |key_timestamp| default: **ctrl+e** 120 | |key_timestamp_end| default: **ctrl+shift+e** 121 | 122 | An example of the content of a configuration file could be the following: 123 | ```bash 124 | # ~/.config/mpv/script-opts/PureMPV.conf 125 | executable=ffmpeg 126 | pure_mode=yes 127 | selection=primary 128 | input_seeking=yes 129 | hide_osc_on_crop=yes 130 | ffmpeg_params=-map_metadata -1 -map_chapters -1 -f webm -row-mt 1 -speed 0 -c:v libvpx-vp9 -map 0:v -crf 10 -b:v 0 -pass 1 /dev/null -y&&\ 131 | 132 | key_crop=ctrl+c 133 | ``` 134 | 135 | 136 | ## Building 137 | 138 | For building, having npm installed is necessary. To generate the ```main.js``` file with the latest changes, proceed with the following: 139 | 140 | ```console 141 | $ npm ci 142 | $ npm run build 143 | ``` 144 | -------------------------------------------------------------------------------- /main.js: -------------------------------------------------------------------------------- 1 | "use strict";function t(t,e,o){if(o||2===arguments.length)for(var n,r=0,i=e.length;ro?(p=o,i=n-a):a>n&&(a=n,c=o-p),m.y-=Math.ceil(i/2),m.x-=Math.ceil(c/2);var s=Math.min(e.width/p,e.height/a);m.w=Math.ceil(m.w*s),m.h=Math.ceil(m.h*s),m.x=Math.ceil(m.x*s),m.y=Math.ceil(m.y*s)},f=function(){delete m.h,delete m.w,delete m.y,delete m.x,delete m.constY,delete m.constX,p("Crop reset")},h=function(t,e){if(!e||"object"!=typeof e||!("x"in e)||!("y"in e)||"number"!=typeof e.x||"number"!=typeof e.y)throw new Error("Did not receive mouse coordinates: ".concat(JSON.stringify(e)));g({x:e.x,y:e.y}),_()},_=function(){if(!x(m))throw new Error("cropbox is not set");var t="{\\3c&".concat(r.cropBox.color,"&}"),e=v("osd");u.res_y=e.height,u.res_x=e.width;var o=m.x,n=m.y,i=m.w,c=m.h,p="{\\p1}m ".concat(o," ").concat(n," l ").concat(o+i," ").concat(n," ").concat(o+i," ")+"".concat(n+c," ").concat(o," ").concat(n+c," {\\p0}"),a="".concat("{\\pos(0, 0)}").concat(t).concat("{\\1a&FF&}").concat("{\\bord4}").concat(p);u.data=a,u.update()},g=function(t){if("number"!=typeof m.constX||"number"!=typeof m.constY||"number"!=typeof m.x||"number"!=typeof m.y)throw new Error("the cropbox was not initialized: ".concat(JSON.stringify(m)));t.x