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 +21 -0
- package/README.md +173 -0
- package/dist/config.js +27 -0
- package/dist/core/format.js +41 -0
- package/dist/core/join.js +173 -0
- package/dist/core/output.js +43 -0
- package/dist/core/probe.js +115 -0
- package/dist/core/types.js +1 -0
- package/dist/core/videos.js +42 -0
- package/dist/index.js +7 -0
- package/dist/ui/App.js +159 -0
- package/dist/ui/components/Banner.js +22 -0
- package/dist/ui/components/Header.js +7 -0
- package/dist/ui/components/KeyHints.js +6 -0
- package/dist/ui/components/PreviewPanel.js +21 -0
- package/dist/ui/components/ProgressBar.js +8 -0
- package/dist/ui/hooks/useFullscreen.js +20 -0
- package/dist/ui/screens/BrowseScreen.js +148 -0
- package/dist/ui/screens/EditScreen.js +92 -0
- package/dist/ui/screens/JoinScreen.js +11 -0
- package/dist/ui/screens/SplashScreen.js +68 -0
- package/dist/ui/screens/SummaryScreen.js +14 -0
- package/dist/ui/theme.js +14 -0
- package/package.json +60 -0
package/dist/ui/App.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
import { Box, Text, useApp, useInput } from "ink";
|
|
4
|
+
import Spinner from "ink-spinner";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { defaultOutputName } from "../config.js";
|
|
7
|
+
import { checkDeps, probeClips } from "../core/probe.js";
|
|
8
|
+
import { findVideos, sortClips } from "../core/videos.js";
|
|
9
|
+
import { runJoin } from "../core/join.js";
|
|
10
|
+
import { resolveOutputPath, writeChaptersFile } from "../core/output.js";
|
|
11
|
+
import { useFullscreen } from "./hooks/useFullscreen.js";
|
|
12
|
+
import { theme } from "./theme.js";
|
|
13
|
+
import { Header } from "./components/Header.js";
|
|
14
|
+
import { SplashScreen } from "./screens/SplashScreen.js";
|
|
15
|
+
import { BrowseScreen } from "./screens/BrowseScreen.js";
|
|
16
|
+
import { EditScreen } from "./screens/EditScreen.js";
|
|
17
|
+
import { JoinScreen } from "./screens/JoinScreen.js";
|
|
18
|
+
import { SummaryScreen } from "./screens/SummaryScreen.js";
|
|
19
|
+
export function App({ initialFolder }) {
|
|
20
|
+
useFullscreen();
|
|
21
|
+
const { exit } = useApp();
|
|
22
|
+
const [phase, setPhase] = useState("splash");
|
|
23
|
+
const [deps, setDeps] = useState(null);
|
|
24
|
+
const [splashDone, setSplashDone] = useState(false);
|
|
25
|
+
const [clips, setClips] = useState([]);
|
|
26
|
+
const [outputName, setOutputName] = useState(defaultOutputName);
|
|
27
|
+
const [transition, setTransition] = useState("none");
|
|
28
|
+
const [generateChapters, setGenerateChapters] = useState(false);
|
|
29
|
+
const [probeDone, setProbeDone] = useState(0);
|
|
30
|
+
const [probeTotal, setProbeTotal] = useState(0);
|
|
31
|
+
const [progress, setProgress] = useState({
|
|
32
|
+
fraction: 0,
|
|
33
|
+
mode: "lossless",
|
|
34
|
+
});
|
|
35
|
+
const [result, setResult] = useState(null);
|
|
36
|
+
const [errorMsg, setErrorMsg] = useState("");
|
|
37
|
+
const lastPaint = useRef(0);
|
|
38
|
+
// Kick off the dep check immediately so it overlaps the splash animation.
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
void checkDeps().then(setDeps);
|
|
41
|
+
}, []);
|
|
42
|
+
// Route once the splash is done AND deps have resolved.
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (phase !== "splash" || !splashDone || !deps)
|
|
45
|
+
return;
|
|
46
|
+
if (!deps.ok) {
|
|
47
|
+
setPhase("missingdeps");
|
|
48
|
+
}
|
|
49
|
+
else if (initialFolder) {
|
|
50
|
+
void (async () => {
|
|
51
|
+
const files = await findVideos(path.resolve(initialFolder)).catch(() => []);
|
|
52
|
+
await beginProbe(files);
|
|
53
|
+
})();
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
setPhase("browse");
|
|
57
|
+
}
|
|
58
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
59
|
+
}, [phase, splashDone, deps]);
|
|
60
|
+
async function beginProbe(files) {
|
|
61
|
+
if (files.length === 0) {
|
|
62
|
+
setErrorMsg("No videos found in that folder.");
|
|
63
|
+
setPhase("error");
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
setProbeTotal(files.length);
|
|
67
|
+
setProbeDone(0);
|
|
68
|
+
setPhase("probing");
|
|
69
|
+
try {
|
|
70
|
+
const probed = await probeClips(files, (done) => setProbeDone(done));
|
|
71
|
+
setClips(sortClips(probed, "date"));
|
|
72
|
+
setOutputName(defaultOutputName()); // fresh timestamped name per session
|
|
73
|
+
setTransition("none");
|
|
74
|
+
setPhase("edit");
|
|
75
|
+
}
|
|
76
|
+
catch (e) {
|
|
77
|
+
// Never leave the user stranded on the spinner if probing blows up.
|
|
78
|
+
setErrorMsg(e.message || "Failed to read clip metadata.");
|
|
79
|
+
setPhase("error");
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
async function startJoin() {
|
|
83
|
+
setPhase("joining");
|
|
84
|
+
setProgress({ fraction: 0, mode: "lossless" });
|
|
85
|
+
try {
|
|
86
|
+
const res = await runJoin({
|
|
87
|
+
clips,
|
|
88
|
+
output: resolveOutputPath(outputName),
|
|
89
|
+
transition,
|
|
90
|
+
onProgress: (p) => {
|
|
91
|
+
// Throttle repaints to ~15/s to keep the bar smooth without flicker.
|
|
92
|
+
const now = Date.now();
|
|
93
|
+
if (now - lastPaint.current > 66 || p.fraction >= 1) {
|
|
94
|
+
lastPaint.current = now;
|
|
95
|
+
setProgress({ fraction: p.fraction, mode: p.mode });
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
if (generateChapters) {
|
|
100
|
+
const included = clips.filter((c) => c.included);
|
|
101
|
+
res.chaptersPath = await writeChaptersFile(included, res.outputPath);
|
|
102
|
+
}
|
|
103
|
+
setResult(res);
|
|
104
|
+
setPhase("done");
|
|
105
|
+
}
|
|
106
|
+
catch (e) {
|
|
107
|
+
setErrorMsg(e.message);
|
|
108
|
+
setPhase("error");
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
useInput((input) => input === "q" && exit(), {
|
|
112
|
+
isActive: phase === "missingdeps" || phase === "error",
|
|
113
|
+
});
|
|
114
|
+
// The splash owns the whole screen; every other phase gets the persistent
|
|
115
|
+
// ClipJoin header (the ASCII logo stays pinned once the animation is done).
|
|
116
|
+
if (phase === "splash") {
|
|
117
|
+
return _jsx(SplashScreen, { onDone: () => setSplashDone(true) });
|
|
118
|
+
}
|
|
119
|
+
const subtitles = {
|
|
120
|
+
missingdeps: "Setup needed",
|
|
121
|
+
browse: "Browse to your clips",
|
|
122
|
+
probing: "Reading clips",
|
|
123
|
+
edit: "Arrange your clips",
|
|
124
|
+
joining: "Joining",
|
|
125
|
+
done: "Done",
|
|
126
|
+
error: "Error",
|
|
127
|
+
};
|
|
128
|
+
let body = null;
|
|
129
|
+
switch (phase) {
|
|
130
|
+
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:" }) }), _jsx(Text, { color: theme.brand, children: " brew install ffmpeg" }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: "Press q to quit, then re-run clip-join." }) })] }));
|
|
132
|
+
break;
|
|
133
|
+
case "browse":
|
|
134
|
+
body = _jsx(BrowseScreen, { onConfirm: (files) => void beginProbe(files), onQuit: exit });
|
|
135
|
+
break;
|
|
136
|
+
case "probing":
|
|
137
|
+
body = _jsx(Centered, { spinner: true, text: `Reading clip metadata… ${probeDone}/${probeTotal}` });
|
|
138
|
+
break;
|
|
139
|
+
case "edit":
|
|
140
|
+
body = (_jsx(EditScreen, { clips: clips, setClips: setClips, outputName: outputName, setOutputName: setOutputName, transition: transition, setTransition: setTransition, generateChapters: generateChapters, setGenerateChapters: setGenerateChapters, onJoin: () => void startJoin(), onBack: () => setPhase("browse"), onQuit: exit }));
|
|
141
|
+
break;
|
|
142
|
+
case "joining":
|
|
143
|
+
body = _jsx(JoinScreen, { fraction: progress.fraction, mode: progress.mode });
|
|
144
|
+
break;
|
|
145
|
+
case "done":
|
|
146
|
+
body = result ? (_jsx(SummaryScreen, { result: result, onAgain: () => {
|
|
147
|
+
setResult(null);
|
|
148
|
+
setPhase("browse");
|
|
149
|
+
}, onQuit: exit })) : null;
|
|
150
|
+
break;
|
|
151
|
+
case "error":
|
|
152
|
+
body = (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, color: theme.danger, children: "Something went wrong" }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: errorMsg }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: "Press q to quit." }) })] }));
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Header, { subtitle: subtitles[phase] }), body] }));
|
|
156
|
+
}
|
|
157
|
+
function Centered({ text, spinner }) {
|
|
158
|
+
return (_jsxs(Box, { paddingX: 1, paddingY: 1, children: [spinner && (_jsxs(Text, { color: theme.success, children: [_jsx(Spinner, { type: "dots" }), " "] })), _jsx(Text, { children: text })] }));
|
|
159
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { theme } from "../theme.js";
|
|
4
|
+
// 4-row block font; each glyph's rows are equal width so words align by construction.
|
|
5
|
+
const FONT = {
|
|
6
|
+
C: ["█████", "██ ", "██ ", "█████"],
|
|
7
|
+
L: ["██ ", "██ ", "██ ", "████"],
|
|
8
|
+
I: ["██", "██", "██", "██"],
|
|
9
|
+
P: ["█████", "██ ██", "█████", "██ "],
|
|
10
|
+
J: [" ██", " ██", "██ ██", "████ "],
|
|
11
|
+
O: ["█████", "██ ██", "██ ██", "█████"],
|
|
12
|
+
N: ["██ ██", "███ ██", "██ ███", "██ ██"],
|
|
13
|
+
};
|
|
14
|
+
const ROWS = 4;
|
|
15
|
+
export function renderWord(word) {
|
|
16
|
+
const glyphs = [...word.toUpperCase()].map((ch) => FONT[ch] ?? [" ", " ", " ", " "]);
|
|
17
|
+
return Array.from({ length: ROWS }, (_, r) => glyphs.map((g) => g[r]).join(" "));
|
|
18
|
+
}
|
|
19
|
+
const LOGO = renderWord("ClipJoin");
|
|
20
|
+
export function Banner({ tagline = false, align = "left" }) {
|
|
21
|
+
return (_jsxs(Box, { flexDirection: "column", alignItems: align === "center" ? "center" : "flex-start", children: [LOGO.map((line, i) => (_jsx(Text, { color: theme.logo, bold: true, children: line }, i))), tagline && _jsx(Text, { color: theme.muted, children: "\u2726 join your clips \u2726" })] }));
|
|
22
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { Banner } from "./Banner.js";
|
|
4
|
+
import { theme } from "../theme.js";
|
|
5
|
+
export function Header({ subtitle }) {
|
|
6
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Banner, {}), subtitle ? (_jsxs(Text, { color: theme.subtitle, children: ["\n", "> ", subtitle] })) : null] }));
|
|
7
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { theme } from "../theme.js";
|
|
4
|
+
export function KeyHints({ lines }) {
|
|
5
|
+
return (_jsx(Box, { flexDirection: "column", marginTop: 1, children: lines.map((line, i) => (_jsx(Text, { color: theme.muted, children: line.map((hint, j) => (_jsxs(Text, { children: [j > 0 ? " · " : "", _jsx(Text, { color: hint.primary ? theme.success : theme.key, children: hint.keys }), " ", hint.label] }, j))) }, i))) }));
|
|
6
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { Box, Text } from "ink";
|
|
4
|
+
import TextInput from "ink-text-input";
|
|
5
|
+
import { canLossless } from "../../core/videos.js";
|
|
6
|
+
import { humanSize, humanTime } from "../../core/format.js";
|
|
7
|
+
import { TRANSITION_DURATION_SEC, TRANSITIONS } from "../../config.js";
|
|
8
|
+
import { theme } from "../theme.js";
|
|
9
|
+
export function PreviewPanel({ clips, outputPath, transition, chaptersEnabled, editingValue, onChangeEditing, onSubmitEditing, }) {
|
|
10
|
+
const included = clips.filter((c) => c.included);
|
|
11
|
+
const totalSize = included.reduce((s, c) => s + c.sizeBytes, 0);
|
|
12
|
+
const hasTransition = transition !== "none" && included.length >= 2;
|
|
13
|
+
// Crossfades overlap, so each boundary shortens the output by the overlap.
|
|
14
|
+
const overlap = hasTransition ? (included.length - 1) * TRANSITION_DURATION_SEC : 0;
|
|
15
|
+
const totalDuration = Math.max(0, included.reduce((s, c) => s + c.durationSec, 0) - overlap);
|
|
16
|
+
const transitionLabel = TRANSITIONS.find((t) => t.id === transition)?.label ?? "None";
|
|
17
|
+
const lossless = canLossless(clips) && !hasTransition;
|
|
18
|
+
const folder = path.basename(path.dirname(outputPath));
|
|
19
|
+
const filename = path.basename(outputPath);
|
|
20
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.brand, paddingX: 1, marginLeft: 1, flexGrow: 1, children: [_jsx(Text, { color: theme.muted, bold: true, children: "Preview" }), _jsxs(Text, { children: [included.length, "/", clips.length, " clips"] }), _jsxs(Text, { children: ["\u23F1 ", humanTime(totalDuration)] }), _jsxs(Text, { children: ["\uD83D\uDCBE ", humanSize(totalSize)] }), _jsxs(Text, { color: hasTransition ? undefined : theme.muted, children: [hasTransition ? "🎬 " : "", "Transition: ", transitionLabel] }), _jsxs(Text, { color: chaptersEnabled ? undefined : theme.muted, children: [chaptersEnabled ? "📄 " : "", "Chapters: ", chaptersEnabled ? "ON" : "OFF"] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: theme.muted, children: ["Output \u2192 ", folder, "/"] }), editingValue !== null ? (_jsx(TextInput, { value: editingValue, onChange: onChangeEditing, onSubmit: onSubmitEditing })) : (_jsx(Text, { color: theme.warn, children: filename }))] }), _jsx(Box, { marginTop: 1, children: lossless ? (_jsx(Text, { color: theme.success, children: "\u2713 lossless (fast)" })) : (_jsxs(Text, { color: theme.warn, children: ["\u27F3 will re-encode", hasTransition ? " (transition)" : ""] })) }), _jsx(Text, { color: theme.muted, children: hasTransition ? "takes longer to render" : " " })] }));
|
|
21
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Text } from "ink";
|
|
3
|
+
export function ProgressBar({ fraction, width = 32, color = "green" }) {
|
|
4
|
+
const clamped = Math.min(1, Math.max(0, fraction));
|
|
5
|
+
const filled = Math.round(clamped * width);
|
|
6
|
+
const bar = "█".repeat(filled) + "░".repeat(width - filled);
|
|
7
|
+
return (_jsxs(Text, { children: [_jsx(Text, { color: color, children: bar }), " ", Math.round(clamped * 100), "%"] }));
|
|
8
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { useStdout } from "ink";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
/** Run full-screen via the alternate screen buffer, restoring scrollback on exit. */
|
|
4
|
+
export function useFullscreen() {
|
|
5
|
+
const { stdout } = useStdout();
|
|
6
|
+
const [size, setSize] = useState({
|
|
7
|
+
columns: stdout.columns ?? 80,
|
|
8
|
+
rows: stdout.rows ?? 24,
|
|
9
|
+
});
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
stdout.write("\x1b[?1049h\x1b[H"); // enter alternate screen, cursor home
|
|
12
|
+
const onResize = () => setSize({ columns: stdout.columns ?? 80, rows: stdout.rows ?? 24 });
|
|
13
|
+
stdout.on("resize", onResize);
|
|
14
|
+
return () => {
|
|
15
|
+
stdout.off("resize", onResize);
|
|
16
|
+
stdout.write("\x1b[?1049l"); // leave alternate screen
|
|
17
|
+
};
|
|
18
|
+
}, [stdout]);
|
|
19
|
+
return size;
|
|
20
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { Box, Text, useInput } from "ink";
|
|
4
|
+
import { promises as fs } from "node:fs";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { DEFAULT_EXTS } from "../../config.js";
|
|
8
|
+
import { humanDate } from "../../core/format.js";
|
|
9
|
+
import { theme } from "../theme.js";
|
|
10
|
+
import { KeyHints } from "../components/KeyHints.js";
|
|
11
|
+
const VIDEO_EXTS = new Set(DEFAULT_EXTS);
|
|
12
|
+
const VISIBLE = 12;
|
|
13
|
+
/** Width of the name column so the Date Modified column lines up across rows. */
|
|
14
|
+
const NAME_W = 30;
|
|
15
|
+
function isVideoFile(name) {
|
|
16
|
+
const ext = name.split(".").pop()?.toLowerCase() ?? "";
|
|
17
|
+
return VIDEO_EXTS.has(ext);
|
|
18
|
+
}
|
|
19
|
+
export function BrowseScreen({ onConfirm, onQuit }) {
|
|
20
|
+
const [dir, setDir] = useState(process.cwd());
|
|
21
|
+
const [entries, setEntries] = useState([]);
|
|
22
|
+
const [cursor, setCursor] = useState(0);
|
|
23
|
+
const [selected, setSelected] = useState(new Set());
|
|
24
|
+
const [error, setError] = useState(null);
|
|
25
|
+
async function load(target, selectName) {
|
|
26
|
+
try {
|
|
27
|
+
const dirents = await fs.readdir(target, { withFileTypes: true });
|
|
28
|
+
const list = await Promise.all(dirents
|
|
29
|
+
.filter((d) => !d.name.startsWith("."))
|
|
30
|
+
.map(async (d) => {
|
|
31
|
+
const full = path.join(target, d.name);
|
|
32
|
+
const isVideo = d.isFile() && isVideoFile(d.name);
|
|
33
|
+
// Only selectable (video) files need a modified date, so skip the
|
|
34
|
+
// stat() for everything else and keep directory listings fast.
|
|
35
|
+
let mtime = null;
|
|
36
|
+
if (isVideo) {
|
|
37
|
+
mtime = await fs
|
|
38
|
+
.stat(full)
|
|
39
|
+
.then((s) => s.mtime)
|
|
40
|
+
.catch(() => null);
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
name: d.name,
|
|
44
|
+
path: full,
|
|
45
|
+
isDir: d.isDirectory(),
|
|
46
|
+
isVideo,
|
|
47
|
+
selected: selected.has(full),
|
|
48
|
+
mtime,
|
|
49
|
+
};
|
|
50
|
+
}));
|
|
51
|
+
list.sort((a, b) => {
|
|
52
|
+
if (a.isDir !== b.isDir)
|
|
53
|
+
return a.isDir ? -1 : 1;
|
|
54
|
+
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
|
55
|
+
});
|
|
56
|
+
setEntries(list);
|
|
57
|
+
const idx = selectName ? list.findIndex((e) => e.name === selectName) : -1;
|
|
58
|
+
setCursor(idx >= 0 ? idx : 0);
|
|
59
|
+
setDir(target);
|
|
60
|
+
setError(null);
|
|
61
|
+
}
|
|
62
|
+
catch (e) {
|
|
63
|
+
setError(e.message);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
void load(process.cwd());
|
|
68
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
69
|
+
}, []);
|
|
70
|
+
const videoCountHere = entries.filter((e) => e.isVideo).length;
|
|
71
|
+
useInput((input, key) => {
|
|
72
|
+
if (input === "q")
|
|
73
|
+
return onQuit();
|
|
74
|
+
if (key.upArrow) {
|
|
75
|
+
setCursor((c) => (c > 0 ? c - 1 : entries.length - 1));
|
|
76
|
+
}
|
|
77
|
+
else if (key.downArrow) {
|
|
78
|
+
setCursor((c) => (c < entries.length - 1 ? c + 1 : 0));
|
|
79
|
+
}
|
|
80
|
+
else if (key.return || input === " ") {
|
|
81
|
+
const entry = entries[cursor];
|
|
82
|
+
if (!entry)
|
|
83
|
+
return;
|
|
84
|
+
if (entry.isDir) {
|
|
85
|
+
void load(entry.path);
|
|
86
|
+
}
|
|
87
|
+
else if (entry.isVideo) {
|
|
88
|
+
setSelected((prev) => {
|
|
89
|
+
const next = new Set(prev);
|
|
90
|
+
if (next.has(entry.path))
|
|
91
|
+
next.delete(entry.path);
|
|
92
|
+
else
|
|
93
|
+
next.add(entry.path);
|
|
94
|
+
return next;
|
|
95
|
+
});
|
|
96
|
+
setEntries((prev) => prev.map((e, i) => (i === cursor ? { ...e, selected: !e.selected } : e)));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
else if (key.leftArrow || key.backspace || key.delete) {
|
|
100
|
+
const parent = path.dirname(dir);
|
|
101
|
+
// Coming back up, land the cursor on the folder we just left.
|
|
102
|
+
if (parent !== dir)
|
|
103
|
+
void load(parent, path.basename(dir));
|
|
104
|
+
}
|
|
105
|
+
else if (input === "~") {
|
|
106
|
+
void load(os.homedir());
|
|
107
|
+
}
|
|
108
|
+
else if (input === "s") {
|
|
109
|
+
const all = entries.filter((e) => e.isVideo).map((e) => e.path);
|
|
110
|
+
if (all.length > 0)
|
|
111
|
+
onConfirm(all);
|
|
112
|
+
}
|
|
113
|
+
else if (input === "c") {
|
|
114
|
+
if (selected.size > 0)
|
|
115
|
+
onConfirm([...selected]);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
const start = Math.max(0, Math.min(cursor - Math.floor(VISIBLE / 2), Math.max(0, entries.length - VISIBLE)));
|
|
119
|
+
const window = entries.slice(start, start + VISIBLE);
|
|
120
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsxs(Text, { color: theme.muted, children: ["\uD83D\uDCC1 ", dir] }), _jsx(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.muted, paddingX: 1, marginY: 1, children: error ? (_jsx(Text, { color: theme.danger, children: error })) : window.length === 0 ? (_jsx(Text, { color: theme.muted, children: "(empty)" })) : (window.map((entry, i) => {
|
|
121
|
+
const idx = start + i;
|
|
122
|
+
const active = idx === cursor;
|
|
123
|
+
const icon = entry.isDir ? "📁" : entry.isVideo ? "🎬" : " ";
|
|
124
|
+
const check = entry.isVideo ? (entry.selected ? "[x]" : "[ ]") : " ";
|
|
125
|
+
const label = `${entry.name}${entry.isDir ? "/" : ""}`;
|
|
126
|
+
// Video rows pad the name so their Date Modified column aligns;
|
|
127
|
+
// other rows just show the (untruncated) name with no date.
|
|
128
|
+
const name = entry.isVideo
|
|
129
|
+
? label.length > NAME_W
|
|
130
|
+
? label.slice(0, NAME_W - 1) + "…"
|
|
131
|
+
: label.padEnd(NAME_W)
|
|
132
|
+
: label;
|
|
133
|
+
const modified = entry.isVideo && entry.mtime ? ` ${humanDate(entry.mtime)}` : "";
|
|
134
|
+
return (_jsxs(Text, { color: active ? theme.brand : undefined, inverse: active, children: [active ? "▸ " : " ", check, " ", icon, " ", name, modified] }, entry.path));
|
|
135
|
+
})) }), _jsxs(Text, { color: theme.muted, children: [videoCountHere, " video(s) here \u00B7 ", selected.size, " selected"] }), _jsx(KeyHints, { lines: [
|
|
136
|
+
[
|
|
137
|
+
{ keys: "↑↓", label: "move" },
|
|
138
|
+
{ keys: "space/enter", label: "open dir / select file" },
|
|
139
|
+
{ keys: "←/⌫", label: "up" },
|
|
140
|
+
{ keys: "~", label: "home" },
|
|
141
|
+
],
|
|
142
|
+
[
|
|
143
|
+
{ keys: "s", label: "use whole folder", primary: true },
|
|
144
|
+
{ keys: "c", label: `continue with selected (${selected.size})`, primary: true },
|
|
145
|
+
{ keys: "q", label: "quit" },
|
|
146
|
+
],
|
|
147
|
+
] })] }));
|
|
148
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { Box, Text, useInput } from "ink";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { humanClock, humanDate } from "../../core/format.js";
|
|
6
|
+
import { resolveOutputPath } from "../../core/output.js";
|
|
7
|
+
import { TRANSITIONS } from "../../config.js";
|
|
8
|
+
import { theme } from "../theme.js";
|
|
9
|
+
import { PreviewPanel } from "../components/PreviewPanel.js";
|
|
10
|
+
import { KeyHints } from "../components/KeyHints.js";
|
|
11
|
+
const VISIBLE = 12;
|
|
12
|
+
export function EditScreen({ clips, setClips, outputName, setOutputName, transition, setTransition, generateChapters, setGenerateChapters, onJoin, onBack, onQuit, }) {
|
|
13
|
+
const [cursor, setCursor] = useState(0);
|
|
14
|
+
const [editingOutput, setEditingOutput] = useState(false);
|
|
15
|
+
const [draftOutput, setDraftOutput] = useState(outputName);
|
|
16
|
+
const move = (from, to) => {
|
|
17
|
+
if (to < 0 || to >= clips.length)
|
|
18
|
+
return;
|
|
19
|
+
const next = [...clips];
|
|
20
|
+
const [item] = next.splice(from, 1);
|
|
21
|
+
next.splice(to, 0, item);
|
|
22
|
+
setClips(next);
|
|
23
|
+
setCursor(to);
|
|
24
|
+
};
|
|
25
|
+
useInput((input, key) => {
|
|
26
|
+
if (editingOutput)
|
|
27
|
+
return; // TextInput owns keystrokes while editing
|
|
28
|
+
if (input === "q")
|
|
29
|
+
return onQuit();
|
|
30
|
+
if (key.escape)
|
|
31
|
+
return onBack();
|
|
32
|
+
if (key.upArrow) {
|
|
33
|
+
if (key.shift)
|
|
34
|
+
move(cursor, cursor - 1);
|
|
35
|
+
else
|
|
36
|
+
setCursor((c) => (c > 0 ? c - 1 : clips.length - 1));
|
|
37
|
+
}
|
|
38
|
+
else if (key.downArrow) {
|
|
39
|
+
if (key.shift)
|
|
40
|
+
move(cursor, cursor + 1);
|
|
41
|
+
else
|
|
42
|
+
setCursor((c) => (c < clips.length - 1 ? c + 1 : 0));
|
|
43
|
+
}
|
|
44
|
+
else if (input === " " || key.return) {
|
|
45
|
+
setClips(clips.map((c, i) => (i === cursor ? { ...c, included: !c.included } : c)));
|
|
46
|
+
}
|
|
47
|
+
else if (input === "o") {
|
|
48
|
+
setDraftOutput(outputName);
|
|
49
|
+
setEditingOutput(true);
|
|
50
|
+
}
|
|
51
|
+
else if (input === "t") {
|
|
52
|
+
const idx = TRANSITIONS.findIndex((t) => t.id === transition);
|
|
53
|
+
setTransition(TRANSITIONS[(idx + 1) % TRANSITIONS.length].id);
|
|
54
|
+
}
|
|
55
|
+
else if (input === "c") {
|
|
56
|
+
setGenerateChapters(!generateChapters);
|
|
57
|
+
}
|
|
58
|
+
else if (input === "j") {
|
|
59
|
+
// j = Join. (Enter toggles the clip; it never starts the join.)
|
|
60
|
+
if (clips.some((c) => c.included))
|
|
61
|
+
onJoin();
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
const included = clips.filter((c) => c.included);
|
|
65
|
+
const transitionLabel = TRANSITIONS.find((t) => t.id === transition)?.label ?? "None";
|
|
66
|
+
const start = Math.max(0, Math.min(cursor - Math.floor(VISIBLE / 2), Math.max(0, clips.length - VISIBLE)));
|
|
67
|
+
const window = clips.slice(start, start + VISIBLE);
|
|
68
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { children: [_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.muted, paddingX: 1, width: "65%", children: [_jsxs(Text, { color: theme.muted, bold: true, children: ["Clips (", clips.length, ")"] }), window.map((clip, i) => {
|
|
69
|
+
const idx = start + i;
|
|
70
|
+
const active = idx === cursor;
|
|
71
|
+
const num = String(idx + 1).padStart(2, " ");
|
|
72
|
+
return (_jsxs(Text, { color: active ? theme.brand : clip.included ? undefined : theme.muted, inverse: active, children: [active ? "▸" : " ", " ", num, ". ", clip.included ? "[x]" : "[ ]", " ", clip.name.padEnd(16).slice(0, 16), " ", humanClock(clip.durationSec), " ", humanDate(clip.mtime)] }, clip.id));
|
|
73
|
+
})] }), _jsx(PreviewPanel, { clips: clips, outputPath: resolveOutputPath(outputName), transition: transition, chaptersEnabled: generateChapters, editingValue: editingOutput ? draftOutput : null, onChangeEditing: setDraftOutput, onSubmitEditing: (val) => {
|
|
74
|
+
const base = path.basename(val.trim());
|
|
75
|
+
setOutputName(base || outputName);
|
|
76
|
+
setEditingOutput(false);
|
|
77
|
+
} })] }), _jsx(KeyHints, { lines: [
|
|
78
|
+
[
|
|
79
|
+
{ keys: "↑↓", label: "move" },
|
|
80
|
+
{ keys: "space/enter", label: "toggle" },
|
|
81
|
+
{ keys: "Shift+↑↓", label: "reorder" },
|
|
82
|
+
{ keys: "o", label: "rename output" },
|
|
83
|
+
{ keys: "t", label: `transition: ${transitionLabel}` },
|
|
84
|
+
{ keys: "c", label: `chapters: ${generateChapters ? "ON" : "OFF"}` },
|
|
85
|
+
],
|
|
86
|
+
[
|
|
87
|
+
{ keys: "j", label: `▶ JOIN${included.length === 0 ? " (select a clip first)" : ""}`, primary: true },
|
|
88
|
+
{ keys: "esc", label: "back" },
|
|
89
|
+
{ keys: "q", label: "quit" },
|
|
90
|
+
],
|
|
91
|
+
] })] }));
|
|
92
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import Spinner from "ink-spinner";
|
|
4
|
+
import { theme } from "../theme.js";
|
|
5
|
+
import { ProgressBar } from "../components/ProgressBar.js";
|
|
6
|
+
export function JoinScreen({ fraction, mode }) {
|
|
7
|
+
const label = mode === "lossless" ? "Joining clips…" : "Re-encoding clips…";
|
|
8
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { color: theme.success, children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" ", label] })] }), _jsx(ProgressBar, { fraction: fraction, color: mode === "lossless" ? theme.success : theme.warn }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: mode === "lossless"
|
|
9
|
+
? "Lossless stream copy — no re-encoding."
|
|
10
|
+
: "Re-encoding to H.264/AAC…" }) })] }));
|
|
11
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
import { Box, Text, useInput } from "ink";
|
|
4
|
+
import { Banner } from "../components/Banner.js";
|
|
5
|
+
import { theme } from "../theme.js";
|
|
6
|
+
const TRACK = 34;
|
|
7
|
+
const CLIP = "▐████▌";
|
|
8
|
+
const MERGED = "▐██████████▌";
|
|
9
|
+
const CLIP_ROWS = 4; // clip height, matched to the logo so the snap flows into it
|
|
10
|
+
const STEPS = 8;
|
|
11
|
+
const FRAME_MS = 90;
|
|
12
|
+
const CENTER = Math.floor(TRACK / 2);
|
|
13
|
+
// One row of the two clips sliding inward toward the center for a given step.
|
|
14
|
+
function approachRow(step) {
|
|
15
|
+
const track = Array.from({ length: TRACK }, () => " ");
|
|
16
|
+
const lx = Math.round((step / STEPS) * (CENTER - CLIP.length));
|
|
17
|
+
const rx = TRACK - CLIP.length - lx;
|
|
18
|
+
for (let i = 0; i < CLIP.length; i++) {
|
|
19
|
+
track[lx + i] = CLIP[i];
|
|
20
|
+
track[rx + i] = CLIP[i];
|
|
21
|
+
}
|
|
22
|
+
return track.join("");
|
|
23
|
+
}
|
|
24
|
+
function mergedRow() {
|
|
25
|
+
const pad = Math.floor((TRACK - MERGED.length) / 2);
|
|
26
|
+
return " ".repeat(pad) + MERGED;
|
|
27
|
+
}
|
|
28
|
+
// Stack a row into a CLIP_ROWS-tall block.
|
|
29
|
+
function block(row) {
|
|
30
|
+
return Array.from({ length: CLIP_ROWS }, () => row);
|
|
31
|
+
}
|
|
32
|
+
// Frame milestones: approach (0..STEPS) → snap → banner reveal → brief hold.
|
|
33
|
+
const SNAP = STEPS + 1;
|
|
34
|
+
const REVEAL = STEPS + 2;
|
|
35
|
+
const END = STEPS + 5;
|
|
36
|
+
/** Clips-merging intro that reveals the ClipJoin wordmark. Any key skips. */
|
|
37
|
+
export function SplashScreen({ onDone }) {
|
|
38
|
+
const [frame, setFrame] = useState(0);
|
|
39
|
+
const onDoneRef = useRef(onDone);
|
|
40
|
+
onDoneRef.current = onDone;
|
|
41
|
+
const finishedRef = useRef(false);
|
|
42
|
+
const timerRef = useRef();
|
|
43
|
+
const finish = () => {
|
|
44
|
+
if (finishedRef.current)
|
|
45
|
+
return;
|
|
46
|
+
finishedRef.current = true;
|
|
47
|
+
if (timerRef.current)
|
|
48
|
+
clearInterval(timerRef.current);
|
|
49
|
+
onDoneRef.current();
|
|
50
|
+
};
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
timerRef.current = setInterval(() => setFrame((f) => f + 1), FRAME_MS);
|
|
53
|
+
return () => {
|
|
54
|
+
if (timerRef.current)
|
|
55
|
+
clearInterval(timerRef.current);
|
|
56
|
+
};
|
|
57
|
+
}, []);
|
|
58
|
+
// Complete once we reach the end — in an effect, never inside a setState updater.
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (frame >= END)
|
|
61
|
+
finish();
|
|
62
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
63
|
+
}, [frame]);
|
|
64
|
+
useInput(() => finish()); // any key skips
|
|
65
|
+
const showBanner = frame >= REVEAL;
|
|
66
|
+
const rows = frame >= SNAP ? block(mergedRow()) : block(approachRow(frame));
|
|
67
|
+
return (_jsx(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: showBanner ? (_jsx(Banner, {})) : (_jsxs(Box, { flexDirection: "column", children: [rows.map((row, i) => (_jsx(Text, { color: theme.logo, children: row }, i))), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: frame >= SNAP ? "✦ joining" : "clip + clip" }) })] })) }));
|
|
68
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
import { humanSize, humanTime } from "../../core/format.js";
|
|
4
|
+
import { theme } from "../theme.js";
|
|
5
|
+
import { KeyHints } from "../components/KeyHints.js";
|
|
6
|
+
export function SummaryScreen({ result, onAgain, onQuit }) {
|
|
7
|
+
useInput((input) => {
|
|
8
|
+
if (input === "r")
|
|
9
|
+
onAgain();
|
|
10
|
+
else if (input === "q")
|
|
11
|
+
onQuit();
|
|
12
|
+
});
|
|
13
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.success, paddingX: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: "Output: " }), _jsx(Text, { color: theme.warn, children: result.outputPath })] }), _jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: "Duration: " }), humanTime(result.durationSec)] }), _jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: "Size: " }), humanSize(result.sizeBytes)] }), _jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: "Mode: " }), result.mode === "lossless" ? (_jsx(Text, { color: theme.success, children: "lossless (stream copy)" })) : (_jsx(Text, { color: theme.warn, children: "re-encoded (H.264/AAC)" }))] }), _jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: "Elapsed: " }), (result.elapsedMs / 1000).toFixed(1), "s"] }), result.chaptersPath && (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: "Chapters: " }), _jsx(Text, { color: theme.warn, children: result.chaptersPath })] }))] }), _jsx(KeyHints, { lines: [[{ keys: "r", label: "join more", primary: true }, { keys: "q", label: "quit" }]] })] }));
|
|
14
|
+
}
|
package/dist/ui/theme.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Single source of truth for ClipJoin's terminal colors. */
|
|
2
|
+
export const theme = {
|
|
3
|
+
brand: "cyan",
|
|
4
|
+
/** Calm, warm yellowish tone for the persistent ClipJoin logo. */
|
|
5
|
+
logo: "#E4C88A",
|
|
6
|
+
accent: "magenta",
|
|
7
|
+
muted: "gray",
|
|
8
|
+
/** Soft steel-blue for the phase subtitle — a gentle step up from plain gray. */
|
|
9
|
+
subtitle: "#8FB3C7",
|
|
10
|
+
success: "green",
|
|
11
|
+
warn: "yellow",
|
|
12
|
+
danger: "red",
|
|
13
|
+
key: "white",
|
|
14
|
+
};
|