clip-join 1.0.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BrenoHA
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.
package/README.md ADDED
@@ -0,0 +1,173 @@
1
+ # ClipJoin 🎬
2
+
3
+ **Merge your videos into one file: fast, lossless, and right from your terminal.**
4
+
5
+ > _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._
8
+
9
+ Run ClipJoin, pick the files you want, reorder them, and hit join. Simple as that.
10
+
11
+ ![Usage demo](docs/gifs/usage-demo.gif)
12
+
13
+ Matching clips merge **losslessly in seconds**, with no re-encoding and no quality loss
14
+ (40×1GB in seconds, not hours). Different formats? ClipJoin re-encodes them for you. No
15
+ flags, no config, just a clean keyboard-driven flow from folder to finished file.
16
+
17
+ ## How it works
18
+
19
+ 1. A short **clips-merging animation** plays on launch while ClipJoin checks for ffmpeg.
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
+
29
+ Optionally, add a **crossfade transition** between clips (press `t` on the arrange screen).
30
+ It's off by default; enabling it re-encodes the output (transitions can't be done losslessly).
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.
34
+
35
+ ## Install
36
+
37
+ The fastest way — one line, and you get a global `clipjoin` command
38
+ (`clip-join` works too, they're the same thing):
39
+
40
+ ```bash
41
+ curl -fsSL https://raw.githubusercontent.com/BrenoHA/clip-join/main/install.sh | bash
42
+ ```
43
+
44
+ The installer checks for Node and ffmpeg (guiding you if either is missing), then
45
+ installs ClipJoin globally. Prefer to do it yourself? Any of these also work:
46
+
47
+ ```bash
48
+ npm install -g clip-join # global `clipjoin` (or `clip-join`) command
49
+ npx clip-join # run once without installing
50
+ ```
51
+
52
+ **ffmpeg is still required** (it bundles `ffprobe`) — install it with your package
53
+ manager if you don't have it:
54
+
55
+ ```bash
56
+ brew install ffmpeg # macOS
57
+ sudo apt install ffmpeg # Debian/Ubuntu
58
+ ```
59
+
60
+ ## Requirements
61
+
62
+ - **Node.js 18+**
63
+ - **ffmpeg** (bundles `ffprobe`) on your `PATH`
64
+
65
+ ## Building from source
66
+
67
+ ```bash
68
+ git clone https://github.com/BrenoHA/clip-join.git
69
+ cd clip-join
70
+ npm install
71
+ npm run build
72
+ ```
73
+
74
+ ## Usage
75
+
76
+ ```bash
77
+ clipjoin # after installing globally
78
+ clipjoin ~/clips # jump straight into a folder
79
+
80
+ npm run dev # dev mode from source, no build step (via tsx)
81
+ npm start # from source, after `npm run build`
82
+ npm start ~/clips # jump straight into a folder
83
+
84
+ Everything is keyboard-driven.
85
+
86
+ **Browsing**
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 |
114
+
115
+ ## Project structure
116
+
117
+ The interface is built with [Ink](https://github.com/vadimdemedes/ink) (React for the
118
+ terminal), the same UI framework behind Claude Code, with the video engine kept cleanly
119
+ separate from it:
120
+
121
+ ```
122
+ src/
123
+ index.tsx # CLI entry (bin: clipjoin / clip-join)
124
+ config.ts # extensions, output dir, default name, encode settings
125
+ core/ # domain logic, no UI, unit-testable
126
+ videos · probe · join · output · format · types
127
+ ui/ # Ink presentation layer
128
+ App.tsx # phase router (splash → browse → edit → join → summary)
129
+ theme.ts
130
+ screens/ # SplashScreen, BrowseScreen, EditScreen, JoinScreen, SummaryScreen
131
+ components/ # Banner, Header, PreviewPanel, ProgressBar, KeyHints
132
+ hooks/ # useFullscreen
133
+ ```
134
+
135
+ `core/` never imports Ink, so the engine can be exercised without a terminal.
136
+
137
+ ## Testing
138
+
139
+ Unit tests cover core functionality and can be run without ffmpeg installed:
140
+
141
+ ```bash
142
+ npm test # run all unit tests
143
+ npm test -- --watch # run tests in watch mode during development
144
+ npm run test:ui # open test UI in browser
145
+ ```
146
+
147
+ Tests use [Vitest](https://vitest.dev/) and cover formatting utilities, video sorting,
148
+ codec detection, and join logic. See `src/core/*.test.ts` for examples.
149
+
150
+ ## Roadmap
151
+
152
+ - Per-clip trimming (in/out points).
153
+ - Audio normalization.
154
+
155
+ Mixed file types / codecs already work today via the automatic re-encode fallback.
156
+
157
+ ## Contributing
158
+
159
+ Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, coding
160
+ conventions, and the PR process.
161
+
162
+ ## Notes
163
+
164
+ - A successful lossless stream copy doesn't guarantee every player handles a file with
165
+ slightly different per-clip encoding perfectly. If playback looks off, the clips likely
166
+ need re-encoding (ClipJoin falls back automatically when stream copy fails).
167
+ - **Original files are never modified or deleted.**
168
+ - Output names are always confined to `output/` (a path typed into the rename field is
169
+ reduced to its basename).
170
+
171
+ ## License
172
+
173
+ [MIT](LICENSE) © BrenoHA
package/dist/config.js ADDED
@@ -0,0 +1,27 @@
1
+ import path from "node:path";
2
+ export const DEFAULT_EXTS = ["mp4", "mov", "m4v", "avi", "mkv"];
3
+ /**
4
+ * Overlap length for a transition, in seconds. ffmpeg's own xfade default is
5
+ * 1.0s; we go shorter for a subtler blend. Auto-clamped down when a clip is
6
+ * shorter than this (see core/join.ts).
7
+ */
8
+ export const TRANSITION_DURATION_SEC = 0.5;
9
+ /** The transitions offered in the UI, in cycle order, with their xfade names. */
10
+ export const TRANSITIONS = [
11
+ { id: "none", label: "None" },
12
+ { id: "crossfade", label: "Crossfade", xfade: "fade" },
13
+ ];
14
+ /** Every joined video is written here, relative to where ClipJoin is launched. */
15
+ export const OUTPUT_DIR = path.resolve(process.cwd(), "output");
16
+ export const REENCODE_CRF = 18;
17
+ export const REENCODE_PRESET = "veryfast";
18
+ /**
19
+ * Timestamped default filename, so repeated joins never silently overwrite each
20
+ * other when the user leaves the name unchanged.
21
+ */
22
+ export function defaultOutputName() {
23
+ const d = new Date();
24
+ const p = (n) => String(n).padStart(2, "0");
25
+ const stamp = `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}_${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
26
+ return `joined_output_${stamp}.mp4`;
27
+ }
@@ -0,0 +1,41 @@
1
+ export function humanSize(bytes) {
2
+ let n = bytes;
3
+ for (const unit of ["B", "KB", "MB", "GB", "TB"]) {
4
+ if (n < 1024)
5
+ return `${n.toFixed(1)}${unit}`;
6
+ n /= 1024;
7
+ }
8
+ return `${n.toFixed(1)}PB`;
9
+ }
10
+ export function humanTime(secs) {
11
+ const total = Math.max(0, Math.floor(secs));
12
+ const h = Math.floor(total / 3600);
13
+ const m = Math.floor((total % 3600) / 60);
14
+ const s = total % 60;
15
+ const pad = (x) => String(x).padStart(2, "0");
16
+ return `${pad(h)}:${pad(m)}:${pad(s)}`;
17
+ }
18
+ /** Compact clock: MM:SS, promoting to HH:MM:SS only when there are hours. */
19
+ export function humanClock(secs) {
20
+ const t = humanTime(secs);
21
+ return t.startsWith("00:") ? t.slice(3) : t;
22
+ }
23
+ const MONTHS = [
24
+ "Jan", "Feb", "Mar", "Apr", "May", "Jun",
25
+ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
26
+ ];
27
+ /**
28
+ * Finder-style modified date in local time, e.g. "Jul 16, 2026, 7:07 PM".
29
+ * Used for the browse screen's Date Modified column.
30
+ */
31
+ export function humanDate(d) {
32
+ const month = MONTHS[d.getMonth()];
33
+ const day = d.getDate();
34
+ const year = d.getFullYear();
35
+ const min = String(d.getMinutes()).padStart(2, "0");
36
+ const ampm = d.getHours() >= 12 ? "PM" : "AM";
37
+ let hour = d.getHours() % 12;
38
+ if (hour === 0)
39
+ hour = 12;
40
+ return `${month} ${day}, ${year}, ${hour}:${min} ${ampm}`;
41
+ }
@@ -0,0 +1,173 @@
1
+ import { spawn } from "node:child_process";
2
+ import { promises as fs } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { REENCODE_CRF, REENCODE_PRESET, TRANSITION_DURATION_SEC, TRANSITIONS } from "../config.js";
6
+ import { ensureOutputDir } from "./output.js";
7
+ /**
8
+ * ffmpeg's concat demuxer treats backslash as an escape character, so a raw
9
+ * Windows path (`C:\Users\me\clip.mp4`) gets mangled. Forward slashes work on
10
+ * every platform (Windows ffmpeg accepts them too), so normalize on Windows.
11
+ * Pure and platform-parameterized so it can be unit-tested off-Windows.
12
+ */
13
+ export function normalizeConcatPath(p, isWindows = process.platform === "win32") {
14
+ return isWindows ? p.replace(/\\/g, "/") : p;
15
+ }
16
+ async function buildConcatFile(paths) {
17
+ const tmp = path.join(os.tmpdir(), `clipjoin-${Date.now()}-${process.pid}.txt`);
18
+ const body = paths
19
+ .map((p) => `file '${normalizeConcatPath(path.resolve(p)).replace(/'/g, "'\\''")}'`)
20
+ .join("\n");
21
+ await fs.writeFile(tmp, body + "\n", "utf8");
22
+ return tmp;
23
+ }
24
+ function runFfmpeg(args, totalDuration, mode, onProgress) {
25
+ return new Promise((resolve) => {
26
+ const proc = spawn("ffmpeg", [...args, "-progress", "pipe:1", "-nostats"], {
27
+ windowsHide: true, // don't flash a console window / steal focus on Windows
28
+ });
29
+ let stderr = "";
30
+ let stdoutBuf = "";
31
+ proc.stdout.on("data", (chunk) => {
32
+ stdoutBuf += chunk.toString();
33
+ let idx;
34
+ while ((idx = stdoutBuf.indexOf("\n")) !== -1) {
35
+ const line = stdoutBuf.slice(0, idx).trim();
36
+ stdoutBuf = stdoutBuf.slice(idx + 1);
37
+ // out_time_us (newer) / out_time_ms (older) are both microseconds.
38
+ const m = line.match(/^out_time_(?:us|ms)=(\d+)/);
39
+ if (m && totalDuration > 0) {
40
+ const fraction = Math.min(1, Math.max(0, Number(m[1]) / 1_000_000 / totalDuration));
41
+ onProgress?.({ fraction, mode });
42
+ }
43
+ }
44
+ });
45
+ proc.stderr.on("data", (chunk) => (stderr += chunk.toString()));
46
+ proc.on("error", () => resolve({ code: 1, stderr: stderr || "failed to spawn ffmpeg" }));
47
+ proc.on("close", (code) => resolve({ code: code ?? 1, stderr }));
48
+ });
49
+ }
50
+ /**
51
+ * Safe overlap length for a transition: the configured default, but never as
52
+ * long as the shortest clip (xfade would run off the end of a short clip).
53
+ */
54
+ export function resolveTransitionDuration(clips) {
55
+ const shortest = Math.min(...clips.map((c) => c.durationSec));
56
+ return Math.max(0.1, Math.min(TRANSITION_DURATION_SEC, shortest - 0.05));
57
+ }
58
+ /**
59
+ * Build the ffmpeg args for a transition join: every clip is its own `-i`
60
+ * input, blended with an xfade (video) / acrossfade (audio) filtergraph. This
61
+ * always re-encodes. Pure/synchronous so it can be unit-tested without ffmpeg.
62
+ *
63
+ * `d` is the per-boundary overlap in seconds (see resolveTransitionDuration).
64
+ */
65
+ export function buildTransitionArgs(clips, output, transition, d, enc) {
66
+ const n = clips.length;
67
+ const xfade = TRANSITIONS.find((t) => t.id === transition)?.xfade ?? "fade";
68
+ const withAudio = clips.every((c) => c.hasAudio);
69
+ // Normalize to the first clip's geometry/framerate so every xfade pair matches.
70
+ const w = clips[0].width || 1920;
71
+ const h = clips[0].height || 1080;
72
+ const fps = clips[0].fps || 30;
73
+ const parts = [];
74
+ // Video: normalize each input, then chain xfade across the boundaries.
75
+ for (let i = 0; i < n; i++) {
76
+ parts.push(`[${i}:v]fps=${fps},scale=${w}:${h}:force_original_aspect_ratio=decrease,` +
77
+ `pad=${w}:${h}:(ow-iw)/2:(oh-ih)/2,setsar=1,format=yuv420p,settb=AVTB[v${i}]`);
78
+ }
79
+ let vPrev = "v0";
80
+ let sumBefore = clips[0].durationSec;
81
+ for (let k = 1; k < n; k++) {
82
+ const out = k === n - 1 ? "vout" : `vx${k}`;
83
+ const offset = sumBefore - k * d;
84
+ parts.push(`[${vPrev}][v${k}]xfade=transition=${xfade}:duration=${d}:offset=${offset.toFixed(3)}[${out}]`);
85
+ vPrev = out;
86
+ sumBefore += clips[k].durationSec;
87
+ }
88
+ // Audio: crossfade the tail of each clip into the next (skipped if any clip
89
+ // has no audio track — acrossfade would fail).
90
+ if (withAudio) {
91
+ for (let i = 0; i < n; i++) {
92
+ parts.push(`[${i}:a]aresample=async=1,aformat=sample_fmts=fltp:channel_layouts=stereo[a${i}]`);
93
+ }
94
+ let aPrev = "a0";
95
+ for (let k = 1; k < n; k++) {
96
+ const out = k === n - 1 ? "aout" : `ax${k}`;
97
+ parts.push(`[${aPrev}][a${k}]acrossfade=d=${d}[${out}]`);
98
+ aPrev = out;
99
+ }
100
+ }
101
+ const args = ["-y"];
102
+ for (const c of clips)
103
+ args.push("-i", c.path);
104
+ args.push("-filter_complex", parts.join(";"));
105
+ args.push("-map", "[vout]");
106
+ if (withAudio)
107
+ args.push("-map", "[aout]");
108
+ // Force 4:2:0 on the encoder: xfade's output format is flexible, so without
109
+ // this ffmpeg may up-convert to yuv444p, which QuickTime refuses to play.
110
+ args.push("-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", String(enc.crf), "-preset", enc.preset);
111
+ if (withAudio)
112
+ args.push("-c:a", "aac", "-b:a", "192k");
113
+ args.push(output);
114
+ const totalDuration = clips.reduce((sum, c) => sum + c.durationSec, 0) - (n - 1) * d;
115
+ return { args, totalDuration };
116
+ }
117
+ /** Join the included clips, trying a lossless stream copy before re-encoding. */
118
+ export async function runJoin(opts) {
119
+ const { output, forceReencode = false, crf = REENCODE_CRF, preset = REENCODE_PRESET, transition = "none", onProgress, } = opts;
120
+ const included = opts.clips.filter((c) => c.included);
121
+ if (included.length === 0)
122
+ throw new Error("No clips selected to join.");
123
+ await ensureOutputDir();
124
+ const started = Date.now();
125
+ // Transition path: filtergraph, always re-encodes. Needs at least two clips
126
+ // (a single clip has no boundary to blend), so fall through to concat otherwise.
127
+ if (transition !== "none" && included.length >= 2) {
128
+ const d = resolveTransitionDuration(included);
129
+ const { args, totalDuration } = buildTransitionArgs(included, output, transition, d, {
130
+ crf,
131
+ preset,
132
+ });
133
+ onProgress?.({ fraction: 0, mode: "reencode" });
134
+ const { code, stderr } = await runFfmpeg(args, totalDuration, "reencode", onProgress);
135
+ if (code !== 0)
136
+ throw new Error(`ffmpeg failed:\n${stderr.split("\n").slice(-15).join("\n")}`);
137
+ return finalize(output, "reencode", totalDuration, started);
138
+ }
139
+ const totalDuration = included.reduce((sum, c) => sum + c.durationSec, 0);
140
+ const concatFile = await buildConcatFile(included.map((c) => c.path));
141
+ try {
142
+ if (!forceReencode) {
143
+ const args = ["-y", "-f", "concat", "-safe", "0", "-i", concatFile, "-c", "copy", output];
144
+ onProgress?.({ fraction: 0, mode: "lossless" });
145
+ const { code } = await runFfmpeg(args, totalDuration, "lossless", onProgress);
146
+ if (code === 0)
147
+ return finalize(output, "lossless", totalDuration, started);
148
+ }
149
+ const args = [
150
+ "-y", "-f", "concat", "-safe", "0", "-i", concatFile,
151
+ "-c:v", "libx264", "-crf", String(crf), "-preset", preset,
152
+ "-c:a", "aac", "-b:a", "192k", output,
153
+ ];
154
+ onProgress?.({ fraction: 0, mode: "reencode" });
155
+ const { code, stderr } = await runFfmpeg(args, totalDuration, "reencode", onProgress);
156
+ if (code !== 0)
157
+ throw new Error(`ffmpeg failed:\n${stderr.split("\n").slice(-15).join("\n")}`);
158
+ return finalize(output, "reencode", totalDuration, started);
159
+ }
160
+ finally {
161
+ await fs.rm(concatFile, { force: true });
162
+ }
163
+ }
164
+ async function finalize(output, mode, durationSec, started) {
165
+ const stat = await fs.stat(output).catch(() => null);
166
+ return {
167
+ mode,
168
+ outputPath: path.resolve(output),
169
+ sizeBytes: stat?.size ?? 0,
170
+ durationSec,
171
+ elapsedMs: Date.now() - started,
172
+ };
173
+ }
@@ -0,0 +1,43 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import { OUTPUT_DIR, defaultOutputName } from "../config.js";
4
+ import { humanClock } from "./format.js";
5
+ /**
6
+ * Resolve a user-supplied name to an absolute path inside OUTPUT_DIR. Only the
7
+ * basename is honored, so "../evil.mp4" or an absolute path can't escape output/.
8
+ */
9
+ export function resolveOutputPath(name) {
10
+ let base = path.basename(name.trim());
11
+ // basename() strips separators; "." / ".." would still resolve to or above
12
+ // OUTPUT_DIR, so fall back to the default for those (and for an empty name).
13
+ if (!base || base === "." || base === "..")
14
+ base = defaultOutputName();
15
+ return path.join(OUTPUT_DIR, base);
16
+ }
17
+ export async function ensureOutputDir() {
18
+ await fs.mkdir(OUTPUT_DIR, { recursive: true });
19
+ }
20
+ /** Build the line-per-chapter text content for the given (included) clips. */
21
+ function buildChaptersContent(clips) {
22
+ let cumulative = 0;
23
+ const lines = [];
24
+ for (const clip of clips) {
25
+ const name = path.parse(clip.name).name;
26
+ lines.push(`${humanClock(cumulative)} ${name}`);
27
+ cumulative += clip.durationSec;
28
+ }
29
+ return lines.join("\n") + "\n";
30
+ }
31
+ /**
32
+ * Write a chapters .txt file alongside the video output.
33
+ * Returns the generated file path, or an empty string when there are no clips.
34
+ */
35
+ export async function writeChaptersFile(clips, videoOutputPath) {
36
+ if (clips.length === 0)
37
+ return "";
38
+ const parsed = path.parse(videoOutputPath);
39
+ const chaptersPath = path.join(parsed.dir, `${parsed.name}_chapters.txt`);
40
+ const content = buildChaptersContent(clips);
41
+ await fs.writeFile(chaptersPath, content, "utf-8");
42
+ return chaptersPath;
43
+ }
@@ -0,0 +1,115 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { promises as fs } from "node:fs";
4
+ import path from "node:path";
5
+ const execFileAsync = promisify(execFile);
6
+ // windowsHide stops a console window from flashing up (and stealing terminal
7
+ // focus) on every ffprobe spawn — on Windows that makes the UI look frozen.
8
+ const EXEC_OPTS = { windowsHide: true };
9
+ async function toolExists(tool) {
10
+ try {
11
+ await execFileAsync(tool, ["-version"], EXEC_OPTS);
12
+ return true;
13
+ }
14
+ catch {
15
+ return false;
16
+ }
17
+ }
18
+ export async function checkDeps() {
19
+ const [ffmpeg, ffprobe] = await Promise.all([toolExists("ffmpeg"), toolExists("ffprobe")]);
20
+ return { ffmpeg, ffprobe, ok: ffmpeg && ffprobe };
21
+ }
22
+ async function ffprobeJson(file) {
23
+ try {
24
+ const { stdout } = await execFileAsync("ffprobe", [
25
+ "-v", "error", "-print_format", "json", "-show_format", "-show_streams", file,
26
+ ], EXEC_OPTS);
27
+ return JSON.parse(stdout);
28
+ }
29
+ catch {
30
+ return {};
31
+ }
32
+ }
33
+ function creationTimeFrom(probe, fallback) {
34
+ const ct = probe?.format?.tags?.creation_time;
35
+ if (typeof ct === "string") {
36
+ const parsed = new Date(ct);
37
+ if (!Number.isNaN(parsed.getTime()))
38
+ return parsed;
39
+ }
40
+ return fallback;
41
+ }
42
+ function getDuration(probe) {
43
+ const d = parseFloat(probe?.format?.duration);
44
+ return Number.isFinite(d) ? d : 0;
45
+ }
46
+ function videoStream(probe) {
47
+ return (probe?.streams ?? []).find((s) => s.codec_type === "video");
48
+ }
49
+ function videoSignature(probe) {
50
+ const v = videoStream(probe);
51
+ if (!v)
52
+ return "unknown";
53
+ return `${v.codec_name ?? "?"} ${v.width ?? "?"}x${v.height ?? "?"}`;
54
+ }
55
+ /** Parse ffprobe's "num/den" (or plain number) r_frame_rate into fps. */
56
+ function parseFps(raw) {
57
+ if (typeof raw !== "string")
58
+ return 0;
59
+ const [num, den] = raw.split("/");
60
+ const n = Number(num);
61
+ const d = den === undefined ? 1 : Number(den);
62
+ if (!Number.isFinite(n) || !Number.isFinite(d) || d === 0)
63
+ return 0;
64
+ return n / d;
65
+ }
66
+ function hasAudioStream(probe) {
67
+ return (probe?.streams ?? []).some((s) => s.codec_type === "audio");
68
+ }
69
+ /** How many ffprobe processes to run at once — bounded so we don't fork-bomb. */
70
+ const PROBE_CONCURRENCY = 4;
71
+ async function probeOne(file) {
72
+ const abs = path.resolve(file);
73
+ // A single locked/inaccessible file must not sink the whole probe (which
74
+ // would leave the UI stuck on the spinner, never reaching Arrange).
75
+ const [probe, stat] = await Promise.all([
76
+ ffprobeJson(abs),
77
+ fs.stat(abs).catch(() => null),
78
+ ]);
79
+ const v = videoStream(probe);
80
+ const mtime = stat?.mtime ?? new Date(0);
81
+ return {
82
+ id: abs,
83
+ path: abs,
84
+ name: path.basename(abs),
85
+ durationSec: getDuration(probe),
86
+ sizeBytes: stat?.size ?? 0,
87
+ creationTime: creationTimeFrom(probe, mtime),
88
+ mtime,
89
+ videoSignature: videoSignature(probe),
90
+ width: Number(v?.width) || 0,
91
+ height: Number(v?.height) || 0,
92
+ fps: parseFps(v?.r_frame_rate),
93
+ hasAudio: hasAudioStream(probe),
94
+ included: true,
95
+ };
96
+ }
97
+ export async function probeClips(files, onProgress) {
98
+ // Probe in parallel with a small pool: sequential spawns are painfully slow
99
+ // on Windows (process creation + AV scans per launch look like a freeze).
100
+ const clips = new Array(files.length);
101
+ let next = 0;
102
+ let done = 0;
103
+ async function worker() {
104
+ while (true) {
105
+ const i = next++;
106
+ if (i >= files.length)
107
+ return;
108
+ clips[i] = await probeOne(files[i]);
109
+ onProgress?.(++done, files.length);
110
+ }
111
+ }
112
+ const workers = Array.from({ length: Math.min(PROBE_CONCURRENCY, files.length) }, () => worker());
113
+ await Promise.all(workers);
114
+ return clips;
115
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,42 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import { DEFAULT_EXTS } from "../config.js";
4
+ export async function findVideos(folder, exts = DEFAULT_EXTS, recursive = false) {
5
+ const wanted = new Set(exts.map((e) => e.toLowerCase().replace(/^\./, "")));
6
+ const matches = [];
7
+ async function walk(dir, descend) {
8
+ const entries = await fs.readdir(dir, { withFileTypes: true });
9
+ for (const entry of entries) {
10
+ const full = path.join(dir, entry.name);
11
+ if (entry.isDirectory()) {
12
+ if (descend)
13
+ await walk(full, true);
14
+ }
15
+ else if (entry.isFile()) {
16
+ const ext = entry.name.split(".").pop()?.toLowerCase() ?? "";
17
+ if (wanted.has(ext))
18
+ matches.push(full);
19
+ }
20
+ }
21
+ }
22
+ await walk(folder, recursive);
23
+ return matches;
24
+ }
25
+ export function sortClips(clips, by) {
26
+ const copy = [...clips];
27
+ if (by === "date") {
28
+ copy.sort((a, b) => a.creationTime.getTime() - b.creationTime.getTime());
29
+ }
30
+ else {
31
+ copy.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
32
+ }
33
+ return copy;
34
+ }
35
+ /** True when every included clip shares one signature, so a stream copy can work. */
36
+ export function canLossless(clips) {
37
+ const included = clips.filter((c) => c.included);
38
+ if (included.length === 0)
39
+ return false;
40
+ const first = included[0].videoSignature;
41
+ return included.every((c) => c.videoSignature === first);
42
+ }
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { render } from "ink";
4
+ import { App } from "./ui/App.js";
5
+ const folderArg = process.argv[2];
6
+ const { waitUntilExit } = render(_jsx(App, { initialFolder: folderArg }));
7
+ await waitUntilExit();