From 6eab3d98550b1fdddaabc6e3f46865b8baa76d35 Mon Sep 17 00:00:00 2001 From: Richard Oliver Bray Date: Tue, 24 Feb 2026 11:41:13 +0000 Subject: [PATCH 01/12] chore: ignore .worktrees directory --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 1b739a1..d8814f3 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ test-results/ # Docs docs/ +.worktrees/ From 334664b4c1b8e441d1d8bf680b2ef702d884d309 Mon Sep 17 00:00:00 2001 From: Richard Oliver Bray Date: Tue, 24 Feb 2026 11:42:36 +0000 Subject: [PATCH 02/12] feat: add --from/--to CLI flags with HH:MM:SS validation --- src/index.ts | 35 +++++++++++++++++++++++++++++++++++ test/unit/clip-args.test.ts | 16 ++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 test/unit/clip-args.test.ts diff --git a/src/index.ts b/src/index.ts index 89642bc..02eb496 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,8 @@ interface CliOptions { verifyAuth?: boolean; browserChannel?: 'chrome' | 'chromium' | 'msedge'; browserExecutablePath?: string; + clipFrom?: string; + clipTo?: string; } interface InstallCliOptions { @@ -122,6 +124,30 @@ function parseArgs(args: string[]): CliOptions { i++; } break; + case '--from': + if (!nextArg || nextArg.startsWith('-')) { + console.error('❌ Error: --from requires a time value (e.g., --from 00:00:30)'); + process.exit(1); + } + if (!/^\d{2}:\d{2}:\d{2}$/.test(nextArg)) { + console.error(`❌ Error: --from must be in HH:MM:SS format (got: ${nextArg})`); + process.exit(1); + } + options.clipFrom = nextArg; + i++; + break; + case '--to': + if (!nextArg || nextArg.startsWith('-')) { + console.error('❌ Error: --to requires a time value (e.g., --to 00:01:30)'); + process.exit(1); + } + if (!/^\d{2}:\d{2}:\d{2}$/.test(nextArg)) { + console.error(`❌ Error: --to must be in HH:MM:SS format (got: ${nextArg})`); + process.exit(1); + } + options.clipTo = nextArg; + i++; + break; case '--version': case '-v': showVersion(); @@ -168,6 +194,8 @@ OPTIONS: --browser-channel Browser channel: chrome, chromium, or msedge (default: chromium) --browser-executable-path Path to browser executable (optional, overrides channel) --verify-auth Check authentication status (EXPERIMENTAL ALPHA) + --from Clip start time (e.g., 00:00:30) + --to Clip end time (e.g., 00:01:30) --version, -v Show version information --help, -h Show this help message @@ -191,6 +219,13 @@ BROWSER EXAMPLES: # Use custom browser executable ${commandName} --browser-executable-path /path/to/browser https://x.com/user/status/123 + +CLIP EXAMPLES: + # Download only 30s–90s of a video + ${commandName} --from 00:00:30 --to 00:01:30 https://x.com/user/status/123 + + # Download from 1 minute to end + ${commandName} --from 00:01:00 https://x.com/user/status/123 `); } diff --git a/test/unit/clip-args.test.ts b/test/unit/clip-args.test.ts new file mode 100644 index 0000000..14df44e --- /dev/null +++ b/test/unit/clip-args.test.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from 'bun:test'; + +describe('clip time format validation', () => { + const valid = /^\d{2}:\d{2}:\d{2}$/; + + it('accepts valid HH:MM:SS', () => { + expect(valid.test('00:00:30')).toBe(true); + expect(valid.test('01:23:45')).toBe(true); + }); + + it('rejects invalid formats', () => { + expect(valid.test('1:30')).toBe(false); + expect(valid.test('90')).toBe(false); + expect(valid.test('00:00:30:00')).toBe(false); + }); +}); From a1f4a93020e12908dd4dba84ea0c246e3617b21f Mon Sep 17 00:00:00 2001 From: Richard Oliver Bray Date: Tue, 24 Feb 2026 11:42:40 +0000 Subject: [PATCH 03/12] feat: pass clip args to ffmpeg for HLS downloads --- src/ffmpeg.ts | 128 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 127 insertions(+), 1 deletion(-) diff --git a/src/ffmpeg.ts b/src/ffmpeg.ts index bdd332d..81fd4ca 100644 --- a/src/ffmpeg.ts +++ b/src/ffmpeg.ts @@ -4,6 +4,8 @@ export interface DownloadHlsOptions { playlistUrl: string; outputPath: string; timeout?: number; + clipFrom?: string; + clipTo?: string; } export interface FfmpegCapabilities { @@ -19,7 +21,7 @@ export interface FfmpegCapabilityCheckResult extends FfmpegCapabilities { } export async function downloadHlsWithFfmpeg(options: DownloadHlsOptions): Promise { - const { playlistUrl, outputPath, timeout } = options; + const { playlistUrl, outputPath, timeout, clipFrom, clipTo } = options; console.log('📥 Downloading HLS video via ffmpeg...'); @@ -41,7 +43,9 @@ export async function downloadHlsWithFfmpeg(options: DownloadHlsOptions): Promis '-y', '-hide_banner', '-loglevel', 'error', + ...(clipFrom ? ['-ss', clipFrom] : []), '-i', playlistUrl, + ...(clipTo ? ['-to', clipTo] : []), '-c', 'copy', '-bsf:a', 'aac_adtstoasc', outputPath, @@ -199,6 +203,128 @@ export async function checkFfmpegCapabilities(): Promise { + const { videoUrl, outputPath, clipFrom, clipTo, timeout } = options; + + console.log('📥 Downloading video via ffmpeg...'); + + const fs = await import('node:fs'); + + if (fs.existsSync(outputPath)) { + console.log(`⚠️ File already exists, removing: ${outputPath}`); + fs.unlinkSync(outputPath); + } + + const timeoutMs = timeout ?? 120000; + const noProgressTimeoutMs = 30000; + let lastFileSize = 0; + let lastProgressTime = Date.now(); + let rejected = false; + + return new Promise((resolve, reject) => { + const args = [ + '-y', + '-hide_banner', + '-loglevel', 'error', + ...(clipFrom ? ['-ss', clipFrom] : []), + '-i', videoUrl, + ...(clipTo ? ['-to', clipTo] : []), + '-c', 'copy', + outputPath, + ]; + + const ffmpeg = spawn('ffmpeg', args); + + let stderr = ''; + + if (ffmpeg.stderr) { + ffmpeg.stderr.on('data', (data: Buffer) => { + stderr += data.toString(); + }); + } + + const spinnerChars = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + let spinnerIndex = 0; + + const spinnerInterval = setInterval(() => { + process.stdout.write(`\r${spinnerChars[spinnerIndex]} Downloading...`); + spinnerIndex = (spinnerIndex + 1) % spinnerChars.length; + }, 80); + + const pollInterval = setInterval(() => { + try { + const now = Date.now(); + if (fs.existsSync(outputPath)) { + const currentFileSize = fs.statSync(outputPath).size; + if (currentFileSize > lastFileSize) { + lastFileSize = currentFileSize; + lastProgressTime = now; + } else if (now - lastProgressTime > noProgressTimeoutMs) { + clearInterval(spinnerInterval); + clearInterval(pollInterval); + clearTimeout(timeoutHandle); + rejected = true; + ffmpeg.kill(); + process.stdout.write('\r\x1b[K'); + reject(new Error(`FFMPEG stuck: no progress for ${noProgressTimeoutMs / 1000} seconds`)); + } + } else if (now - lastProgressTime > noProgressTimeoutMs) { + clearInterval(spinnerInterval); + clearInterval(pollInterval); + clearTimeout(timeoutHandle); + rejected = true; + ffmpeg.kill(); + process.stdout.write('\r\x1b[K'); + reject(new Error(`FFMPEG stuck: no progress for ${noProgressTimeoutMs / 1000} seconds`)); + } + } catch (_err) {} + }, 2000); + + const timeoutHandle = setTimeout(() => { + clearInterval(spinnerInterval); + clearInterval(pollInterval); + rejected = true; + ffmpeg.kill(); + process.stdout.write('\r\x1b[K'); + reject(new Error(`FFMPEG download timed out after ${timeoutMs / 1000} seconds`)); + }, timeoutMs); + + ffmpeg.on('close', (code: number | null) => { + clearInterval(spinnerInterval); + clearInterval(pollInterval); + clearTimeout(timeoutHandle); + + if (code === 0 && !rejected) { + process.stdout.write('\r✅ Download completed\n'); + resolve(outputPath); + } else if (!rejected) { + process.stdout.write('\r\x1b[K'); + const error = stderr.trim() || `ffmpeg exited with code ${code ?? 'null (signal)'}`; + reject(new Error(`Failed to download: ${error}`)); + } + }); + + ffmpeg.on('error', (err: Error) => { + clearInterval(spinnerInterval); + clearInterval(pollInterval); + clearTimeout(timeoutHandle); + if (!rejected) { + rejected = true; + process.stdout.write('\r\x1b[K'); + reject(new Error(`Failed to start ffmpeg: ${err.message}`)); + } + }); + }); +} + export function hasRequiredFfmpegCapabilities(capabilities: FfmpegCapabilities): { has: boolean; missing: string[] } { const required = { protocol: 'https', From ee0a447bfa32f280a9d80d9291db909984d21538 Mon Sep 17 00:00:00 2001 From: Richard Oliver Bray Date: Tue, 24 Feb 2026 11:43:21 +0000 Subject: [PATCH 04/12] feat: wire --from/--to clip flags into download flow --- src/index.ts | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 02eb496..0323f7c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,7 @@ import { VideoExtractor } from './extractor.ts'; import { downloadVideo } from './downloader.ts'; import { ensurePlaywrightReady, runInstall } from './installer.ts'; import { generateFilename, isValidTwitterUrl, parseTweetUrl, formatBytes } from './utils.ts'; -import { downloadHlsWithFfmpeg } from './ffmpeg.ts'; +import { downloadHlsWithFfmpeg, downloadMp4WithFfmpeg } from './ffmpeg.ts'; interface CliOptions { url?: string; @@ -480,6 +480,8 @@ async function main(): Promise { await downloadHlsWithFfmpeg({ playlistUrl: result.videoUrl.url, outputPath, + clipFrom: args.clipFrom, + clipTo: args.clipTo, }); console.log(`\n✅ Video saved to: ${outputPath}\n`); } catch (error) { @@ -491,6 +493,36 @@ async function main(): Promise { return; } + const isClipping = args.clipFrom || args.clipTo; + + if (isClipping) { + const { ensureFfmpegReady: ensureFfmpegReadyForClip } = await import('./installer.ts'); + const ffmpegReady = await ensureFfmpegReadyForClip(); + if (!ffmpegReady) { + console.error('\n❌ ffmpeg is required to clip videos.'); + console.error('Please install ffmpeg:'); + console.error(' macOS: brew install ffmpeg'); + console.error(' Linux: sudo apt-get install ffmpeg'); + process.exit(1); + } + + try { + await downloadMp4WithFfmpeg({ + videoUrl: result.videoUrl.url, + outputPath, + clipFrom: args.clipFrom, + clipTo: args.clipTo, + }); + console.log(`\n✅ Video saved to: ${outputPath}\n`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + process.stdout.write('\r\x1b[K'); + console.error(`❌ Download failed: ${message}\n`); + process.exit(1); + } + return; + } + try { await downloadVideo({ url: result.videoUrl.url, From d0bce515490171c0e134fbe1d7101163d731215f Mon Sep 17 00:00:00 2001 From: Richard Oliver Bray Date: Tue, 24 Feb 2026 11:54:34 +0000 Subject: [PATCH 05/12] 0.4.4 --- package.json | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 0607f3d..5d45635 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "x-dl", - "version": "0.4.3", + "version": "0.4.4", "description": "Extract videos from X/Twitter tweets", "main": "./src/index.ts", "type": "module", @@ -24,7 +24,12 @@ "release:major": "bun version major && git push && git push --tags", "version:tags": "git tag -l --sort=-version:refname" }, - "keywords": ["twitter", "x", "video", "extractor"], + "keywords": [ + "twitter", + "x", + "video", + "extractor" + ], "author": "", "license": "MIT", "dependencies": { From 99aaaba3489595b985cd916190cbc01088b48e01 Mon Sep 17 00:00:00 2001 From: Richard Oliver Bray Date: Tue, 24 Feb 2026 13:16:51 +0000 Subject: [PATCH 06/12] feat: add --from/--to clip flags (MM:SS format, HLS slow-seek for compatibility) --- src/ffmpeg.ts | 82 +++++++++++++++++++++++++++++++++---- src/index.ts | 67 +++++++++++++++++++++--------- test/unit/clip-args.test.ts | 12 +++--- 3 files changed, 128 insertions(+), 33 deletions(-) diff --git a/src/ffmpeg.ts b/src/ffmpeg.ts index 81fd4ca..4669daa 100644 --- a/src/ffmpeg.ts +++ b/src/ffmpeg.ts @@ -4,8 +4,8 @@ export interface DownloadHlsOptions { playlistUrl: string; outputPath: string; timeout?: number; - clipFrom?: string; - clipTo?: string; + clipFromSecs?: number; + clipDurationSecs?: number; } export interface FfmpegCapabilities { @@ -21,7 +21,7 @@ export interface FfmpegCapabilityCheckResult extends FfmpegCapabilities { } export async function downloadHlsWithFfmpeg(options: DownloadHlsOptions): Promise { - const { playlistUrl, outputPath, timeout, clipFrom, clipTo } = options; + const { playlistUrl, outputPath, timeout, clipFromSecs, clipDurationSecs } = options; console.log('📥 Downloading HLS video via ffmpeg...'); @@ -39,15 +39,18 @@ export async function downloadHlsWithFfmpeg(options: DownloadHlsOptions): Promis let rejected = false; return new Promise((resolve, reject) => { + const isClipping = clipFromSecs !== undefined || clipDurationSecs !== undefined; const args = [ '-y', '-hide_banner', '-loglevel', 'error', - ...(clipFrom ? ['-ss', clipFrom] : []), '-i', playlistUrl, - ...(clipTo ? ['-to', clipTo] : []), - '-c', 'copy', - '-bsf:a', 'aac_adtstoasc', + // Use -ss after -i (slow/accurate seek) — fast seek on HLS produces empty output + ...(clipFromSecs !== undefined ? ['-ss', String(clipFromSecs)] : []), + ...(clipDurationSecs !== undefined ? ['-t', String(clipDurationSecs)] : []), + ...(isClipping + ? ['-c:v', 'libx264', '-c:a', 'aac', '-movflags', '+faststart'] + : ['-c', 'copy', '-bsf:a', 'aac_adtstoasc']), outputPath, ]; @@ -140,6 +143,69 @@ export async function downloadHlsWithFfmpeg(options: DownloadHlsOptions): Promis }); } +export interface ClipLocalFileOptions { + inputPath: string; + outputPath: string; + clipFrom?: string; + clipTo?: string; +} + +export function mmssToSeconds(mmss: string): number { + const [mm, ss] = mmss.split(':').map(Number); + return mm * 60 + ss; +} + +export async function clipLocalFile(options: ClipLocalFileOptions): Promise { + const { inputPath, outputPath, clipFrom, clipTo } = options; + + const startSecs = clipFrom ? mmssToSeconds(clipFrom) : 0; + const duration = clipTo ? mmssToSeconds(clipTo) - startSecs : undefined; + + return new Promise((resolve, reject) => { + const args = [ + '-y', + '-hide_banner', + '-loglevel', 'error', + ...(clipFrom ? ['-ss', String(startSecs)] : []), + '-i', inputPath, + ...(duration !== undefined ? ['-t', String(duration)] : []), + '-c:v', 'libx264', '-c:a', 'aac', '-movflags', '+faststart', + outputPath, + ]; + + const ffmpeg = spawn('ffmpeg', args); + let stderr = ''; + + if (ffmpeg.stderr) { + ffmpeg.stderr.on('data', (data: Buffer) => { stderr += data.toString(); }); + } + + const spinnerChars = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + let spinnerIndex = 0; + const spinnerInterval = setInterval(() => { + process.stdout.write(`\r${spinnerChars[spinnerIndex]} Clipping...`); + spinnerIndex = (spinnerIndex + 1) % spinnerChars.length; + }, 80); + + ffmpeg.on('close', (code: number | null) => { + clearInterval(spinnerInterval); + if (code === 0) { + process.stdout.write('\r✅ Clip complete\n'); + resolve(); + } else { + process.stdout.write('\r\x1b[K'); + reject(new Error(stderr.trim() || `ffmpeg exited with code ${code ?? 'null (signal)'}`)); + } + }); + + ffmpeg.on('error', (err: Error) => { + clearInterval(spinnerInterval); + process.stdout.write('\r\x1b[K'); + reject(new Error(`Failed to start ffmpeg: ${err.message}`)); + }); + }); +} + export async function checkFfmpegCapabilities(): Promise { const results: FfmpegCapabilityCheckResult = { available: false, @@ -237,7 +303,7 @@ export async function downloadMp4WithFfmpeg(options: DownloadMp4Options): Promis ...(clipFrom ? ['-ss', clipFrom] : []), '-i', videoUrl, ...(clipTo ? ['-to', clipTo] : []), - '-c', 'copy', + '-c:v', 'libx264', '-c:a', 'aac', '-movflags', '+faststart', outputPath, ]; diff --git a/src/index.ts b/src/index.ts index 0323f7c..ef759c5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,7 @@ import { VideoExtractor } from './extractor.ts'; import { downloadVideo } from './downloader.ts'; import { ensurePlaywrightReady, runInstall } from './installer.ts'; import { generateFilename, isValidTwitterUrl, parseTweetUrl, formatBytes } from './utils.ts'; -import { downloadHlsWithFfmpeg, downloadMp4WithFfmpeg } from './ffmpeg.ts'; +import { downloadHlsWithFfmpeg, clipLocalFile, mmssToSeconds } from './ffmpeg.ts'; interface CliOptions { url?: string; @@ -126,11 +126,11 @@ function parseArgs(args: string[]): CliOptions { break; case '--from': if (!nextArg || nextArg.startsWith('-')) { - console.error('❌ Error: --from requires a time value (e.g., --from 00:00:30)'); + console.error('❌ Error: --from requires a time value (e.g., --from 00:30)'); process.exit(1); } - if (!/^\d{2}:\d{2}:\d{2}$/.test(nextArg)) { - console.error(`❌ Error: --from must be in HH:MM:SS format (got: ${nextArg})`); + if (!/^\d{2}:\d{2}$/.test(nextArg)) { + console.error(`❌ Error: --from must be in MM:SS format (got: ${nextArg})`); process.exit(1); } options.clipFrom = nextArg; @@ -138,11 +138,11 @@ function parseArgs(args: string[]): CliOptions { break; case '--to': if (!nextArg || nextArg.startsWith('-')) { - console.error('❌ Error: --to requires a time value (e.g., --to 00:01:30)'); + console.error('❌ Error: --to requires a time value (e.g., --to 01:30)'); process.exit(1); } - if (!/^\d{2}:\d{2}:\d{2}$/.test(nextArg)) { - console.error(`❌ Error: --to must be in HH:MM:SS format (got: ${nextArg})`); + if (!/^\d{2}:\d{2}$/.test(nextArg)) { + console.error(`❌ Error: --to must be in MM:SS format (got: ${nextArg})`); process.exit(1); } options.clipTo = nextArg; @@ -194,8 +194,8 @@ OPTIONS: --browser-channel Browser channel: chrome, chromium, or msedge (default: chromium) --browser-executable-path Path to browser executable (optional, overrides channel) --verify-auth Check authentication status (EXPERIMENTAL ALPHA) - --from Clip start time (e.g., 00:00:30) - --to Clip end time (e.g., 00:01:30) + --from Clip start time (e.g., 00:30) + --to Clip end time (e.g., 01:30) --version, -v Show version information --help, -h Show this help message @@ -222,10 +222,10 @@ BROWSER EXAMPLES: CLIP EXAMPLES: # Download only 30s–90s of a video - ${commandName} --from 00:00:30 --to 00:01:30 https://x.com/user/status/123 + ${commandName} --from 00:30 --to 01:30 https://x.com/user/status/123 # Download from 1 minute to end - ${commandName} --from 00:01:00 https://x.com/user/status/123 + ${commandName} --from 01:00 https://x.com/user/status/123 `); } @@ -260,7 +260,8 @@ EXAMPLES: } function showVersion(): void { - console.log('0.4.3'); + const pkg = require('../package.json'); + console.log(pkg.version); process.exit(0); } @@ -460,7 +461,11 @@ async function main(): Promise { defaultExtension = result.videoUrl.format; } - const outputPath = getOutputPath(args.url, args, defaultExtension); + const basePath = getOutputPath(args.url, args, defaultExtension); + const isClipping = args.clipFrom || args.clipTo; + const outputPath = isClipping + ? path.join(path.dirname(basePath), `${path.basename(basePath, path.extname(basePath))}_clip${path.extname(basePath)}`) + : basePath; if (result.videoUrl.format === 'm3u8') { const { ensureFfmpegReady } = await import('./installer.ts'); @@ -477,11 +482,15 @@ async function main(): Promise { } try { + const fromSecs = args.clipFrom ? mmssToSeconds(args.clipFrom) : undefined; + const toSecs = args.clipTo ? mmssToSeconds(args.clipTo) : undefined; + const durationSecs = toSecs !== undefined ? toSecs - (fromSecs ?? 0) : undefined; + await downloadHlsWithFfmpeg({ playlistUrl: result.videoUrl.url, outputPath, - clipFrom: args.clipFrom, - clipTo: args.clipTo, + clipFromSecs: fromSecs, + clipDurationSecs: durationSecs, }); console.log(`\n✅ Video saved to: ${outputPath}\n`); } catch (error) { @@ -493,8 +502,6 @@ async function main(): Promise { return; } - const isClipping = args.clipFrom || args.clipTo; - if (isClipping) { const { ensureFfmpegReady: ensureFfmpegReadyForClip } = await import('./installer.ts'); const ffmpegReady = await ensureFfmpegReadyForClip(); @@ -506,19 +513,39 @@ async function main(): Promise { process.exit(1); } + // Download the full video first, then clip locally. + // ffmpeg can't access X/Twitter direct MP4 URLs (auth headers required). + const os = await import('node:os'); + const fs = await import('node:fs'); + const tmpPath = path.join(os.tmpdir(), `x-dl-tmp-${Date.now()}.mp4`); + try { - await downloadMp4WithFfmpeg({ - videoUrl: result.videoUrl.url, + await downloadVideo({ + url: result.videoUrl.url, + outputPath: tmpPath, + onProgress: (progress, downloaded, total) => { + process.stdout.write( + `\r⏳ Downloading: ${progress.toFixed(1)}% (${formatBytes(downloaded)}/${formatBytes(total)})` + ); + }, + }); + process.stdout.write('\n'); + + await clipLocalFile({ + inputPath: tmpPath, outputPath, clipFrom: args.clipFrom, clipTo: args.clipTo, }); + console.log(`\n✅ Video saved to: ${outputPath}\n`); } catch (error) { const message = error instanceof Error ? error.message : String(error); process.stdout.write('\r\x1b[K'); - console.error(`❌ Download failed: ${message}\n`); + console.error(`❌ Failed: ${message}\n`); process.exit(1); + } finally { + if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath); } return; } diff --git a/test/unit/clip-args.test.ts b/test/unit/clip-args.test.ts index 14df44e..2be36d6 100644 --- a/test/unit/clip-args.test.ts +++ b/test/unit/clip-args.test.ts @@ -1,16 +1,18 @@ import { describe, it, expect } from 'bun:test'; describe('clip time format validation', () => { - const valid = /^\d{2}:\d{2}:\d{2}$/; + const valid = /^\d{2}:\d{2}$/; - it('accepts valid HH:MM:SS', () => { - expect(valid.test('00:00:30')).toBe(true); - expect(valid.test('01:23:45')).toBe(true); + it('accepts valid MM:SS', () => { + expect(valid.test('00:30')).toBe(true); + expect(valid.test('01:30')).toBe(true); + expect(valid.test('59:59')).toBe(true); }); it('rejects invalid formats', () => { expect(valid.test('1:30')).toBe(false); expect(valid.test('90')).toBe(false); - expect(valid.test('00:00:30:00')).toBe(false); + expect(valid.test('00:00:30')).toBe(false); + expect(valid.test('1:3')).toBe(false); }); }); From 51686338a2b693361cea743cdd35af8a13ccc663 Mon Sep 17 00:00:00 2001 From: Richard Oliver Bray Date: Tue, 24 Feb 2026 13:19:55 +0000 Subject: [PATCH 07/12] docs: add --from/--to clip options to README --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.md b/README.md index 869d746..a872ff4 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,8 @@ x-dl install --with-deps | `--headed` | Show browser window for debugging | | `--profile [dir]` | Use a persistent browser profile for authenticated extraction (default: `~/.x-dl-profile`) | | `--login` | Open X in a persistent profile and wait for you to log in | +| `--from ` | Clip start time in minutes and seconds (e.g. `00:30`) | +| `--to ` | Clip end time in minutes and seconds (e.g. `01:30`) | | `--help, -h` | Show help message | **Note:** The `-o` option accepts any file extension. If you specify a path with an extension (e.g., `video.mp4`, `video.webm`), that format will be used. Otherwise, the format is auto-detected from the extracted video. @@ -198,6 +200,17 @@ x-dl --profile ~/.x-dl-profile https://x.com/user/status/123456 x-dl --timeout 60 https://x.com/user/status/123456 ``` +**Clip a video to a specific time range:** +```bash +# Download only the 30s–90s portion of a video +x-dl --from 00:30 --to 01:30 https://x.com/user/status/123456 + +# Download from 1 minute to the end +x-dl --from 01:00 https://x.com/user/status/123456 +``` + +Clipped files are saved with a `_clip` suffix, e.g. `username_123456_clip.mp4`. Both `--from` and `--to` are optional — omitting `--from` starts from the beginning, omitting `--to` runs to the end. + ## Output When extracting a video, the tool will: From 4572f0078f7b124a35c672f433be49121afa2d4d Mon Sep 17 00:00:00 2001 From: Richard Oliver Bray Date: Tue, 24 Feb 2026 14:48:58 +0000 Subject: [PATCH 08/12] fix: increase no-progress timeout to 60s to avoid false stuck errors during HLS mux --- src/ffmpeg.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ffmpeg.ts b/src/ffmpeg.ts index 4669daa..9a4c706 100644 --- a/src/ffmpeg.ts +++ b/src/ffmpeg.ts @@ -33,7 +33,7 @@ export async function downloadHlsWithFfmpeg(options: DownloadHlsOptions): Promis } const timeoutMs = timeout ?? 120000; - const noProgressTimeoutMs = 30000; + const noProgressTimeoutMs = 60000; let lastFileSize = 0; let lastProgressTime = Date.now(); let rejected = false; @@ -290,7 +290,7 @@ export async function downloadMp4WithFfmpeg(options: DownloadMp4Options): Promis } const timeoutMs = timeout ?? 120000; - const noProgressTimeoutMs = 30000; + const noProgressTimeoutMs = 60000; let lastFileSize = 0; let lastProgressTime = Date.now(); let rejected = false; From 8381376b87296b84d2645f3e0faaecb0380911b4 Mon Sep 17 00:00:00 2001 From: Richard Oliver Bray Date: Tue, 24 Feb 2026 14:50:27 +0000 Subject: [PATCH 09/12] feat: validate --to is after --from --- src/index.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/index.ts b/src/index.ts index ef759c5..3925adf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -463,6 +463,16 @@ async function main(): Promise { const basePath = getOutputPath(args.url, args, defaultExtension); const isClipping = args.clipFrom || args.clipTo; + + if (args.clipFrom && args.clipTo) { + const fromSecs = mmssToSeconds(args.clipFrom); + const toSecs = mmssToSeconds(args.clipTo); + if (toSecs <= fromSecs) { + console.error('❌ Error: --to must be after --from'); + process.exit(1); + } + } + const outputPath = isClipping ? path.join(path.dirname(basePath), `${path.basename(basePath, path.extname(basePath))}_clip${path.extname(basePath)}`) : basePath; From c477100b3f8fc70ce81ee99947246ae68644c21e Mon Sep 17 00:00:00 2001 From: Richard Oliver Bray Date: Tue, 24 Feb 2026 14:50:55 +0000 Subject: [PATCH 10/12] fix: use -t duration instead of -to in downloadMp4WithFfmpeg for correct clip semantics --- src/ffmpeg.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/ffmpeg.ts b/src/ffmpeg.ts index 9a4c706..9b0d6ff 100644 --- a/src/ffmpeg.ts +++ b/src/ffmpeg.ts @@ -295,14 +295,18 @@ export async function downloadMp4WithFfmpeg(options: DownloadMp4Options): Promis let lastProgressTime = Date.now(); let rejected = false; + const fromSecs = clipFrom ? mmssToSeconds(clipFrom) : 0; + const toSecs = clipTo ? mmssToSeconds(clipTo) : undefined; + const duration = toSecs !== undefined ? toSecs - fromSecs : undefined; + return new Promise((resolve, reject) => { const args = [ '-y', '-hide_banner', '-loglevel', 'error', - ...(clipFrom ? ['-ss', clipFrom] : []), + ...(clipFrom ? ['-ss', String(fromSecs)] : []), '-i', videoUrl, - ...(clipTo ? ['-to', clipTo] : []), + ...(duration !== undefined ? ['-t', String(duration)] : []), '-c:v', 'libx264', '-c:a', 'aac', '-movflags', '+faststart', outputPath, ]; From 0cb9543d8e9944b69178334f890852e8743809e0 Mon Sep 17 00:00:00 2001 From: Richard Oliver Bray Date: Tue, 24 Feb 2026 14:59:04 +0000 Subject: [PATCH 11/12] docs: expand README with clipping feature details and examples --- README.md | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a872ff4..5ea01ad 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,8 @@ Extract videos from X (formerly Twitter) tweets. - ✅ Extract videos from public X/Twitter tweets - ✅ Supports multiple formats (mp4, webm, gif, etc.) - ✅ Automatic format selection (highest quality) -- ✅ Download videos directly or just get of URL +- ✅ Download videos directly or just get the URL +- ✅ Clip videos to a specific time range (`--from` and `--to`) - ⚠️ Downloading videos from private tweets (experimental alpha features) - ❌ Windows support @@ -59,6 +60,11 @@ x-dl https://x.com/user/status/123456 - mp4/webm/gif files: direct download - HLS (m3u8) playlists: downloads via ffmpeg to produce mp4 - If direct download fails with 401/403 auth errors and `--profile` is used, automatically retries using authenticated Playwright requests +- **Clipping:** + - `--from` and `--to` (MM:SS format) trim videos to a specific time range + - HLS streams are clipped during download with ffmpeg re-encoding + - MP4 streams download full video, then clip locally + - Clipped files get a `_clip` suffix in the filename - **Auth:** with `--profile`, Playwright reuses cookies/session from a persistent profile directory - **ffmpeg:** checked at runtime and auto-installed when possible @@ -252,8 +258,36 @@ When extracting a video, the tool will: ✅ Video saved to: ~/Downloads/Remotion_2013626968386765291.mp4 ``` +### Example Output with Clipping + +``` +🎬 x-dl - X/Twitter Video Extractor + +🔍 Checking for Playwright (Chromium)... +✅ Playwright Chromium is ready +🔍 Checking for ffmpeg... +✅ ffmpeg is ready + +🎬 Extracting video from: https://x.com/Remotion/status/2013626968386765291 +📝 Tweet: @Remotion (ID: 2013626968386765291) +🌐 Opening tweet in browser... +✅ Page loaded +🔍 Looking for video... +✅ Video extracted: https://video.twimg.com/ext_tw_video/... +📋 Suggested filename: Remotion_2013626968386765291_clip.mp4 +📥 Downloading HLS video via ffmpeg... +⠋ Downloading HLS... +✅ HLS download completed + +✅ Video saved to: ~/Downloads/Remotion_2013626968386765291_clip.mp4 +``` + ## Limitations +- **Public tweets only**: Private or protected tweets cannot be extracted +- **Clipping requires ffmpeg**: `--from` and `--to` require ffmpeg for processing +- **Clipping time format**: Times must be in MM:SS format (e.g., `00:30`, not `0:30` or `30`) + - **Public tweets only**: Private or protected tweets cannot be extracted - **Time-limited URLs**: Video URLs may expire after some time - **Rate limiting**: X may rate-limit excessive requests From 183cac634598425740d412e285a20e50e0def69e Mon Sep 17 00:00:00 2001 From: Richard Oliver Bray Date: Thu, 26 Feb 2026 15:00:05 +0000 Subject: [PATCH 12/12] Apply suggestion from @devin-ai-integration[bot] Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/index.ts b/src/index.ts index 3925adf..0ca7c86 100644 --- a/src/index.ts +++ b/src/index.ts @@ -553,6 +553,7 @@ async function main(): Promise { const message = error instanceof Error ? error.message : String(error); process.stdout.write('\r\x1b[K'); console.error(`❌ Failed: ${message}\n`); + if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath); process.exit(1); } finally { if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);