clip-join 1.0.0 โ 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +88 -75
- package/dist/config.js +22 -2
- package/dist/core/output.js +6 -6
- package/dist/index.js +41 -1
- package/dist/ui/App.js +7 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,116 +1,129 @@
|
|
|
1
|
-
|
|
1
|
+
<h1 align="center">ClipJoin ๐ฌ</h1>
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<p align="center"><strong>Merge your videos into one file: fast, lossless, and right from your terminal.</strong></p>
|
|
4
|
+
|
|
5
|
+
<p align="center">๐ The best, easiest way to merge your clips. No editor, no re-encoding, no fuss.</p>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<a href="https://www.npmjs.com/package/clip-join"><img src="https://img.shields.io/npm/v/clip-join?logo=npm&color=green" alt="npm version"></a>
|
|
9
|
+
<a href="https://github.com/BrenoHA/clip-join"><img src="https://img.shields.io/badge/GitHub-BrenoHA%2Fclip--join-blue?logo=github" alt="GitHub"></a>
|
|
10
|
+
<a href="LICENSE"><img src="https://img.shields.io/npm/l/clip-join?color=blue" alt="license"></a>
|
|
11
|
+
</p>
|
|
4
12
|
|
|
5
13
|
> _Back from a trip with a memory card full of GoPro clips? Select them, stitch them into
|
|
6
|
-
> one file, and upload the whole adventure to YouTube in one shot, public or unlisted,
|
|
7
|
-
> your call._
|
|
14
|
+
> one file, and upload the whole adventure to YouTube in one shot, public or unlisted, your call._
|
|
8
15
|
|
|
9
|
-
|
|
16
|
+
<p align="center">
|
|
17
|
+
<img src="docs/gifs/usage-demo.gif" alt="Usage demo">
|
|
18
|
+
</p>
|
|
10
19
|
|
|
11
|
-
|
|
20
|
+
Run ClipJoin, pick the files you want, reorder them, and hit join. Simple as that.
|
|
12
21
|
|
|
13
22
|
Matching clips merge **losslessly in seconds**, with no re-encoding and no quality loss
|
|
14
23
|
(40ร1GB in seconds, not hours). Different formats? ClipJoin re-encodes them for you. No
|
|
15
24
|
flags, no config, just a clean keyboard-driven flow from folder to finished file.
|
|
16
25
|
|
|
17
|
-
##
|
|
26
|
+
## Install
|
|
18
27
|
|
|
19
|
-
|
|
20
|
-
2. **Browse** the filesystem to a folder of clips (or select individual files).
|
|
21
|
-
3. Each clip's embedded `creation_time` (falling back to file mtime) sets the true
|
|
22
|
-
capture order, which you can then reorder by hand.
|
|
23
|
-
4. **Toggle** clips in/out and rename the output, watching the live preview (clip
|
|
24
|
-
count, total duration, size, lossless-vs-re-encode).
|
|
25
|
-
5. On join, ClipJoin first tries a **lossless concat** (`ffmpeg -c copy`), which keeps full
|
|
26
|
-
quality and works when clips share a codec/resolution. If that fails (e.g. mixed formats
|
|
27
|
-
or codecs) it **re-encodes** automatically (H.264/AAC, slower but always works).
|
|
28
|
+
Install once with npm:
|
|
28
29
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
Every joined file is written to the **`output/`** folder with a timestamped default
|
|
33
|
-
name (`joined_output_YYYY-MM-DD_HHMMSS.mp4`), so repeated joins never overwrite one another.
|
|
30
|
+
```bash
|
|
31
|
+
npm install -g clip-join
|
|
32
|
+
```
|
|
34
33
|
|
|
35
|
-
|
|
34
|
+
> **ClipJoin uses [ffmpeg](#ffmpeg-required) on your `PATH`.** Install it before running `clipjoin` follow the steps bellow if you don't have it installed yet.
|
|
36
35
|
|
|
37
|
-
|
|
38
|
-
(`clip-join` works too, they're the same thing):
|
|
36
|
+
Then run it from any folder:
|
|
39
37
|
|
|
40
38
|
```bash
|
|
41
|
-
|
|
39
|
+
clipjoin # launch anywhere (clip-join works too, same command)
|
|
42
40
|
```
|
|
43
41
|
|
|
44
|
-
|
|
45
|
-
installs ClipJoin globally. Prefer to do it yourself? Any of these also work:
|
|
42
|
+
**Just trying it out?** Run it once, no install:
|
|
46
43
|
|
|
47
44
|
```bash
|
|
48
|
-
|
|
49
|
-
npx clip-join # run once without installing
|
|
45
|
+
npx clip-join
|
|
50
46
|
```
|
|
51
47
|
|
|
52
|
-
|
|
53
|
-
|
|
48
|
+
### ffmpeg (required)
|
|
49
|
+
|
|
50
|
+
ClipJoin uses **ffmpeg** (which bundles `ffprobe`). Install it with your package manager:
|
|
54
51
|
|
|
55
52
|
```bash
|
|
56
53
|
brew install ffmpeg # macOS
|
|
57
|
-
sudo apt install ffmpeg # Debian/Ubuntu
|
|
54
|
+
sudo apt install ffmpeg # Debian / Ubuntu
|
|
55
|
+
winget install ffmpeg # Windows (or: choco install ffmpeg / scoop install ffmpeg)
|
|
58
56
|
```
|
|
59
57
|
|
|
60
|
-
|
|
58
|
+
<details>
|
|
59
|
+
<summary>Optional: one-line installer</summary>
|
|
61
60
|
|
|
62
|
-
|
|
63
|
-
- **ffmpeg** (bundles `ffprobe`) on your `PATH`
|
|
61
|
+
Prefer a single command that also checks Node + ffmpeg for you and fixes your PATH?
|
|
64
62
|
|
|
65
|
-
|
|
63
|
+
```bash
|
|
64
|
+
curl -fsSL https://raw.githubusercontent.com/BrenoHA/clip-join/main/install.sh | bash
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
It just wraps `npm install -g clip-join` with a few pre-flight checks.
|
|
68
|
+
|
|
69
|
+
</details>
|
|
70
|
+
|
|
71
|
+
## Where your videos are saved
|
|
72
|
+
|
|
73
|
+
Every joined file lands in a fixed **ClipJoin** folder in your home, so it's always in the
|
|
74
|
+
same predictable place no matter which directory you run `clipjoin` from:
|
|
75
|
+
|
|
76
|
+
- **macOS** โ `~/Movies/ClipJoin`
|
|
77
|
+
- **Linux / Windows** โ `~/Videos/ClipJoin`
|
|
78
|
+
|
|
79
|
+
Files get a timestamped default name (`joined_output_YYYY-MM-DD_HHMMSS.mp4`), so repeated
|
|
80
|
+
joins never overwrite one another. Send the output somewhere else for a run with `--out`:
|
|
66
81
|
|
|
67
82
|
```bash
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
npm install
|
|
71
|
-
npm run build
|
|
83
|
+
clipjoin --out ~/Desktop # write joined videos to ~/Desktop instead
|
|
84
|
+
clipjoin ~/clips --out ~/Desktop # start in ~/clips, save to ~/Desktop
|
|
72
85
|
```
|
|
73
86
|
|
|
87
|
+
## How it works
|
|
88
|
+
|
|
89
|
+
1. A short **clips-merging animation** plays on launch while ClipJoin checks for ffmpeg.
|
|
90
|
+
2. **Browse** the filesystem to a folder of clips (or select individual files).
|
|
91
|
+
3. Each clip's embedded `creation_time` (falling back to file mtime) sets the true
|
|
92
|
+
capture order, which you can then reorder by hand.
|
|
93
|
+
4. **Toggle** clips in/out and rename the output, watching the live preview (clip
|
|
94
|
+
count, total duration, size, lossless-vs-re-encode).
|
|
95
|
+
5. On join, ClipJoin first tries a **lossless concat** (`ffmpeg -c copy`), which keeps full
|
|
96
|
+
quality and works when clips share a codec/resolution. If that fails (e.g. mixed formats
|
|
97
|
+
or codecs) it **re-encodes** automatically (H.264/AAC, slower but always works).
|
|
98
|
+
|
|
99
|
+
Optionally, add a **crossfade transition** between clips (press `t` on the arrange screen).
|
|
100
|
+
It's off by default; enabling it re-encodes the output (transitions can't be done losslessly).
|
|
101
|
+
|
|
74
102
|
## Usage
|
|
75
103
|
|
|
104
|
+
Everything is keyboard-driven.
|
|
105
|
+
|
|
76
106
|
```bash
|
|
77
|
-
clipjoin #
|
|
107
|
+
clipjoin # launch and browse from the current folder
|
|
78
108
|
clipjoin ~/clips # jump straight into a folder
|
|
109
|
+
clipjoin --out ~/Desktop # change where joined files are saved
|
|
110
|
+
clipjoin --version # print the installed version (alias: -v)
|
|
111
|
+
```
|
|
79
112
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
npm start ~/clips # jump straight into a folder
|
|
113
|
+
No need to memorize anything, every available shortcut is shown right on the
|
|
114
|
+
terminal as you go, so you always know your options at each step.
|
|
83
115
|
|
|
84
|
-
|
|
116
|
+
## Building from source
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
git clone https://github.com/BrenoHA/clip-join.git
|
|
120
|
+
cd clip-join
|
|
121
|
+
npm install
|
|
122
|
+
npm run build # then: npm start
|
|
123
|
+
npm run dev # or run straight from source (via tsx), no build step
|
|
124
|
+
```
|
|
85
125
|
|
|
86
|
-
**
|
|
87
|
-
|
|
88
|
-
| Key | Action |
|
|
89
|
-
| ---------------------------- | ---------------------------------------------------- |
|
|
90
|
-
| `โ โ` | Move the cursor |
|
|
91
|
-
| `space` / `Enter` | Open a directory, or select/deselect a video file |
|
|
92
|
-
| `โ` / `Backspace` / `Delete` | Up a directory (cursor lands on the folder you left) |
|
|
93
|
-
| `~` | Jump to your home directory |
|
|
94
|
-
| `s` | Use every video in the current folder |
|
|
95
|
-
| `c` | Continue with the files you've selected |
|
|
96
|
-
| `q` | Quit |
|
|
97
|
-
|
|
98
|
-
**Arranging & joining**
|
|
99
|
-
|
|
100
|
-
Once you've picked your clips, arrange them and watch the live preview (clip count,
|
|
101
|
-
total duration, size, and whether the join will be lossless):
|
|
102
|
-
|
|
103
|
-
| Key | Action |
|
|
104
|
-
| ----------------- | --------------------------------------------- |
|
|
105
|
-
| `โ โ` | Move the cursor |
|
|
106
|
-
| `space` / `Enter` | Toggle a clip in/out of the join |
|
|
107
|
-
| `Shift+โ โ` | Reorder the selected clip |
|
|
108
|
-
| `o` | Rename the output file (saved into `output/`) |
|
|
109
|
-
| `t` | Toggle the transition (None / Crossfade) |
|
|
110
|
-
| `j` | **โถ Start the join** |
|
|
111
|
-
| `esc` | Back to the browser |
|
|
112
|
-
| `r` | Join again (summary screen) |
|
|
113
|
-
| `q` | Quit |
|
|
126
|
+
Requires **Node.js 18+** and **ffmpeg** on your `PATH`.
|
|
114
127
|
|
|
115
128
|
## Project structure
|
|
116
129
|
|
|
@@ -165,8 +178,8 @@ conventions, and the PR process.
|
|
|
165
178
|
slightly different per-clip encoding perfectly. If playback looks off, the clips likely
|
|
166
179
|
need re-encoding (ClipJoin falls back automatically when stream copy fails).
|
|
167
180
|
- **Original files are never modified or deleted.**
|
|
168
|
-
- Output names are always confined to
|
|
169
|
-
reduced to its basename).
|
|
181
|
+
- Output names are always confined to the output folder (a path typed into the rename
|
|
182
|
+
field is reduced to its basename); use `--out <dir>` to change the folder itself.
|
|
170
183
|
|
|
171
184
|
## License
|
|
172
185
|
|
package/dist/config.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import os from "node:os";
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
export const DEFAULT_EXTS = ["mp4", "mov", "m4v", "avi", "mkv"];
|
|
3
4
|
/**
|
|
@@ -11,8 +12,27 @@ export const TRANSITIONS = [
|
|
|
11
12
|
{ id: "none", label: "None" },
|
|
12
13
|
{ id: "crossfade", label: "Crossfade", xfade: "fade" },
|
|
13
14
|
];
|
|
14
|
-
/**
|
|
15
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Default home for joined videos: a fixed, always-writable folder under the
|
|
17
|
+
* user's home so `clipjoin` behaves identically from any directory (rather than
|
|
18
|
+
* scattering `output/` folders wherever it happens to be launched).
|
|
19
|
+
* macOS โ ~/Movies/ClipJoin ยท Linux/Windows โ ~/Videos/ClipJoin
|
|
20
|
+
*/
|
|
21
|
+
export function defaultOutputDir() {
|
|
22
|
+
const parent = process.platform === "darwin" ? "Movies" : "Videos";
|
|
23
|
+
return path.join(os.homedir(), parent, "ClipJoin");
|
|
24
|
+
}
|
|
25
|
+
// The active output directory. Defaults per-platform; overridden at startup by
|
|
26
|
+
// the `--out <dir>` flag via setOutputDir().
|
|
27
|
+
let outputDir = defaultOutputDir();
|
|
28
|
+
/** Absolute directory every joined video (and its chapters file) is written to. */
|
|
29
|
+
export function getOutputDir() {
|
|
30
|
+
return outputDir;
|
|
31
|
+
}
|
|
32
|
+
/** Point output at a specific directory (from `--out`). Resolved to absolute. */
|
|
33
|
+
export function setOutputDir(dir) {
|
|
34
|
+
outputDir = path.resolve(dir);
|
|
35
|
+
}
|
|
16
36
|
export const REENCODE_CRF = 18;
|
|
17
37
|
export const REENCODE_PRESET = "veryfast";
|
|
18
38
|
/**
|
package/dist/core/output.js
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
import { promises as fs } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { getOutputDir, defaultOutputName } from "../config.js";
|
|
4
4
|
import { humanClock } from "./format.js";
|
|
5
5
|
/**
|
|
6
|
-
* Resolve a user-supplied name to an absolute path inside
|
|
7
|
-
* basename is honored, so "../evil.mp4" or an absolute path can't escape
|
|
6
|
+
* Resolve a user-supplied name to an absolute path inside the output dir. Only
|
|
7
|
+
* the basename is honored, so "../evil.mp4" or an absolute path can't escape it.
|
|
8
8
|
*/
|
|
9
9
|
export function resolveOutputPath(name) {
|
|
10
10
|
let base = path.basename(name.trim());
|
|
11
11
|
// basename() strips separators; "." / ".." would still resolve to or above
|
|
12
|
-
//
|
|
12
|
+
// the output dir, so fall back to the default for those (and for an empty name).
|
|
13
13
|
if (!base || base === "." || base === "..")
|
|
14
14
|
base = defaultOutputName();
|
|
15
|
-
return path.join(
|
|
15
|
+
return path.join(getOutputDir(), base);
|
|
16
16
|
}
|
|
17
17
|
export async function ensureOutputDir() {
|
|
18
|
-
await fs.mkdir(
|
|
18
|
+
await fs.mkdir(getOutputDir(), { recursive: true });
|
|
19
19
|
}
|
|
20
20
|
/** Build the line-per-chapter text content for the given (included) clips. */
|
|
21
21
|
function buildChaptersContent(clips) {
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,47 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
3
7
|
import { render } from "ink";
|
|
4
8
|
import { App } from "./ui/App.js";
|
|
5
|
-
|
|
9
|
+
import { setOutputDir } from "./config.js";
|
|
10
|
+
const argv = process.argv.slice(2);
|
|
11
|
+
// Expand a leading `~` ourselves: Windows shells (cmd/PowerShell) don't do it,
|
|
12
|
+
// so `clipjoin --out ~/Desktop` would otherwise create a literal "~" folder.
|
|
13
|
+
function expandTilde(p) {
|
|
14
|
+
if (p === "~")
|
|
15
|
+
return os.homedir();
|
|
16
|
+
if (p.startsWith("~/") || p.startsWith("~\\"))
|
|
17
|
+
return path.join(os.homedir(), p.slice(2));
|
|
18
|
+
return p;
|
|
19
|
+
}
|
|
20
|
+
// `--version` / `-v`: print the package version and exit before starting the UI.
|
|
21
|
+
// package.json sits one level above the built entry (dist/index.js) and is always
|
|
22
|
+
// included in the npm tarball, so this resolves for global installs and dev alike.
|
|
23
|
+
if (argv.includes("--version") || argv.includes("-v")) {
|
|
24
|
+
const pkgPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json");
|
|
25
|
+
const { version } = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
26
|
+
console.log(version);
|
|
27
|
+
process.exit(0);
|
|
28
|
+
}
|
|
29
|
+
// Minimal arg parsing: one optional positional (start folder) plus `--out <dir>`
|
|
30
|
+
// (aliases: `-o`, `--out=<dir>`) to redirect where joined videos are written.
|
|
31
|
+
let folderArg;
|
|
32
|
+
for (let i = 0; i < argv.length; i++) {
|
|
33
|
+
const arg = argv[i];
|
|
34
|
+
if (arg === "--out" || arg === "-o") {
|
|
35
|
+
const dir = argv[++i];
|
|
36
|
+
if (dir)
|
|
37
|
+
setOutputDir(expandTilde(dir));
|
|
38
|
+
}
|
|
39
|
+
else if (arg.startsWith("--out=")) {
|
|
40
|
+
setOutputDir(expandTilde(arg.slice("--out=".length)));
|
|
41
|
+
}
|
|
42
|
+
else if (!arg.startsWith("-") && folderArg === undefined) {
|
|
43
|
+
folderArg = expandTilde(arg);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
6
46
|
const { waitUntilExit } = render(_jsx(App, { initialFolder: folderArg }));
|
|
7
47
|
await waitUntilExit();
|
package/dist/ui/App.js
CHANGED
|
@@ -16,6 +16,12 @@ import { BrowseScreen } from "./screens/BrowseScreen.js";
|
|
|
16
16
|
import { EditScreen } from "./screens/EditScreen.js";
|
|
17
17
|
import { JoinScreen } from "./screens/JoinScreen.js";
|
|
18
18
|
import { SummaryScreen } from "./screens/SummaryScreen.js";
|
|
19
|
+
// Platform-appropriate way to install ffmpeg, shown when it's missing.
|
|
20
|
+
const FFMPEG_INSTALL_HINT = process.platform === "darwin"
|
|
21
|
+
? "brew install ffmpeg"
|
|
22
|
+
: process.platform === "win32"
|
|
23
|
+
? "winget install ffmpeg"
|
|
24
|
+
: "sudo apt install ffmpeg";
|
|
19
25
|
export function App({ initialFolder }) {
|
|
20
26
|
useFullscreen();
|
|
21
27
|
const { exit } = useApp();
|
|
@@ -128,7 +134,7 @@ export function App({ initialFolder }) {
|
|
|
128
134
|
let body = null;
|
|
129
135
|
switch (phase) {
|
|
130
136
|
case "missingdeps":
|
|
131
|
-
body = (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, color: theme.danger, children: "ffmpeg / ffprobe not found on your PATH." }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { children: "ClipJoin needs ffmpeg to read and join videos. Install it with:" }) }),
|
|
137
|
+
body = (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, color: theme.danger, children: "ffmpeg / ffprobe not found on your PATH." }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { children: "ClipJoin needs ffmpeg to read and join videos. Install it with:" }) }), _jsxs(Text, { color: theme.brand, children: [" ", FFMPEG_INSTALL_HINT] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: "Press q to quit, then re-run clip-join." }) })] }));
|
|
132
138
|
break;
|
|
133
139
|
case "browse":
|
|
134
140
|
body = _jsx(BrowseScreen, { onConfirm: (files) => void beginProbe(files), onQuit: exit });
|