eprec 1.11.0 → 1.13.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/app/assets/styles.css +287 -0
- package/app/client/app.tsx +11 -2
- package/app/client/editing-workspace.tsx +283 -78
- package/app/client/trim-points.tsx +1287 -0
- package/app/config/routes.ts +1 -0
- package/app/router.tsx +6 -0
- package/app/routes/index.tsx +3 -0
- package/app/routes/trim-points.tsx +51 -0
- package/app/trim-api.ts +261 -0
- package/app/trim-commands.ts +154 -0
- package/package.json +1 -1
- package/server/processing-queue.ts +441 -0
- package/src/app-server.ts +8 -0
- package/src/cli.ts +8 -11
package/app/config/routes.ts
CHANGED
package/app/router.tsx
CHANGED
|
@@ -5,6 +5,7 @@ import { Layout } from './components/layout.tsx'
|
|
|
5
5
|
import routes from './config/routes.ts'
|
|
6
6
|
import { render } from './helpers/render.ts'
|
|
7
7
|
import indexHandlers from './routes/index.tsx'
|
|
8
|
+
import trimPointsHandlers from './routes/trim-points.tsx'
|
|
8
9
|
|
|
9
10
|
const STATIC_CORS_HEADERS = {
|
|
10
11
|
'Access-Control-Allow-Origin': '*',
|
|
@@ -102,5 +103,10 @@ export function createAppRouter(rootDir: string) {
|
|
|
102
103
|
action: indexHandlers.loader,
|
|
103
104
|
})
|
|
104
105
|
|
|
106
|
+
router.map(routes.trimPoints, {
|
|
107
|
+
middleware: trimPointsHandlers.middleware,
|
|
108
|
+
action: trimPointsHandlers.loader,
|
|
109
|
+
})
|
|
110
|
+
|
|
105
111
|
return router
|
|
106
112
|
}
|
package/app/routes/index.tsx
CHANGED
|
@@ -18,6 +18,9 @@ const indexHandler = {
|
|
|
18
18
|
Review transcript-based edits, refine cut ranges, and prepare
|
|
19
19
|
exports.
|
|
20
20
|
</p>
|
|
21
|
+
<nav class="app-nav">
|
|
22
|
+
<a class="app-link" href="/trim-points">Trim points</a>
|
|
23
|
+
</nav>
|
|
21
24
|
</header>
|
|
22
25
|
<section class="app-card app-card--full">
|
|
23
26
|
<h2>Source video</h2>
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { html } from 'remix/html-template'
|
|
2
|
+
import { Layout } from '../components/layout.tsx'
|
|
3
|
+
import { render } from '../helpers/render.ts'
|
|
4
|
+
|
|
5
|
+
const trimPointsHandler = {
|
|
6
|
+
middleware: [],
|
|
7
|
+
loader() {
|
|
8
|
+
const initialVideoPath = process.env.EPREC_APP_VIDEO_PATH?.trim()
|
|
9
|
+
return render(
|
|
10
|
+
Layout({
|
|
11
|
+
title: 'Trim points - Eprec Studio',
|
|
12
|
+
appConfig: initialVideoPath ? { initialVideoPath } : undefined,
|
|
13
|
+
children: html`<main class="app-shell">
|
|
14
|
+
<header class="app-header">
|
|
15
|
+
<span class="app-kicker">Eprec Studio</span>
|
|
16
|
+
<h1 class="app-title">Trim points</h1>
|
|
17
|
+
<p class="app-subtitle">
|
|
18
|
+
Add start and stop points, generate an ffmpeg trim command, and run
|
|
19
|
+
it with live progress.
|
|
20
|
+
</p>
|
|
21
|
+
<nav class="app-nav">
|
|
22
|
+
<a class="app-link" href="/">Editing workspace</a>
|
|
23
|
+
</nav>
|
|
24
|
+
</header>
|
|
25
|
+
<section class="app-card app-card--full">
|
|
26
|
+
<h2>Video source</h2>
|
|
27
|
+
<p class="app-muted">
|
|
28
|
+
Enter a video file path once the interactive UI loads.
|
|
29
|
+
</p>
|
|
30
|
+
</section>
|
|
31
|
+
<section class="app-card app-card--full">
|
|
32
|
+
<h2>Timeline</h2>
|
|
33
|
+
<p class="app-muted">
|
|
34
|
+
Add trim ranges and drag their handles to fine-tune timestamps.
|
|
35
|
+
</p>
|
|
36
|
+
<div class="trim-track trim-track--skeleton"></div>
|
|
37
|
+
</section>
|
|
38
|
+
<section class="app-card app-card--full">
|
|
39
|
+
<h2>ffmpeg command</h2>
|
|
40
|
+
<p class="app-muted">
|
|
41
|
+
Command output and progress details appear after you load a video.
|
|
42
|
+
</p>
|
|
43
|
+
<pre class="command-preview">Loading trim preview...</pre>
|
|
44
|
+
</section>
|
|
45
|
+
</main>`,
|
|
46
|
+
}),
|
|
47
|
+
)
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export default trimPointsHandler
|
package/app/trim-api.ts
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import { mkdir } from 'node:fs/promises'
|
|
4
|
+
import {
|
|
5
|
+
buildFfmpegArgs,
|
|
6
|
+
computeOutputDuration,
|
|
7
|
+
normalizeTrimRanges,
|
|
8
|
+
type TrimRange,
|
|
9
|
+
} from './trim-commands.ts'
|
|
10
|
+
|
|
11
|
+
const TRIM_ROUTE = '/api/trim'
|
|
12
|
+
|
|
13
|
+
type TrimRequestPayload = {
|
|
14
|
+
inputPath?: string
|
|
15
|
+
outputPath?: string
|
|
16
|
+
duration?: number
|
|
17
|
+
ranges?: TrimRange[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function expandHomePath(value: string) {
|
|
21
|
+
if (!value.startsWith('~/') && !value.startsWith('~\\')) {
|
|
22
|
+
return value
|
|
23
|
+
}
|
|
24
|
+
const home = process.env.HOME?.trim()
|
|
25
|
+
if (!home) return value
|
|
26
|
+
return path.join(home, value.slice(2))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function resolveMediaPath(rawPath: string): string | null {
|
|
30
|
+
const trimmed = rawPath.trim()
|
|
31
|
+
if (!trimmed) return null
|
|
32
|
+
if (trimmed.startsWith('file://')) {
|
|
33
|
+
try {
|
|
34
|
+
return fileURLToPath(trimmed)
|
|
35
|
+
} catch {
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return path.resolve(expandHomePath(trimmed))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseRanges(ranges: unknown): TrimRange[] {
|
|
43
|
+
if (!Array.isArray(ranges)) return []
|
|
44
|
+
return ranges
|
|
45
|
+
.map((entry) => {
|
|
46
|
+
if (!entry || typeof entry !== 'object') return null
|
|
47
|
+
const candidate = entry as TrimRange
|
|
48
|
+
if (!Number.isFinite(candidate.start) || !Number.isFinite(candidate.end)) {
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
return { start: candidate.start, end: candidate.end }
|
|
52
|
+
})
|
|
53
|
+
.filter((range): range is TrimRange => Boolean(range))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function parseOutTimeValue(value: string) {
|
|
57
|
+
const parts = value.trim().split(':')
|
|
58
|
+
if (parts.length !== 3) return null
|
|
59
|
+
const [hours, minutes, seconds] = parts
|
|
60
|
+
const h = Number.parseFloat(hours)
|
|
61
|
+
const m = Number.parseFloat(minutes)
|
|
62
|
+
const s = Number.parseFloat(seconds)
|
|
63
|
+
if (!Number.isFinite(h) || !Number.isFinite(m) || !Number.isFinite(s)) {
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
return h * 3600 + m * 60 + s
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function clamp(value: number, min: number, max: number) {
|
|
70
|
+
return Math.min(Math.max(value, min), max)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function readLines(
|
|
74
|
+
stream: ReadableStream<Uint8Array> | null,
|
|
75
|
+
onLine: (line: string) => void,
|
|
76
|
+
) {
|
|
77
|
+
if (!stream) return
|
|
78
|
+
const reader = stream.pipeThrough(new TextDecoderStream()).getReader()
|
|
79
|
+
let buffer = ''
|
|
80
|
+
while (true) {
|
|
81
|
+
const { value, done } = await reader.read()
|
|
82
|
+
if (done) break
|
|
83
|
+
buffer += value
|
|
84
|
+
const lines = buffer.split('\n')
|
|
85
|
+
buffer = lines.pop() ?? ''
|
|
86
|
+
for (const line of lines) {
|
|
87
|
+
const trimmed = line.trim()
|
|
88
|
+
if (trimmed) onLine(trimmed)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const trailing = buffer.trim()
|
|
92
|
+
if (trailing) onLine(trailing)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function handleTrimRequest(request: Request): Promise<Response> {
|
|
96
|
+
const url = new URL(request.url)
|
|
97
|
+
if (url.pathname !== TRIM_ROUTE) {
|
|
98
|
+
return new Response('Not Found', { status: 404 })
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (request.method === 'OPTIONS') {
|
|
102
|
+
return new Response(null, {
|
|
103
|
+
status: 204,
|
|
104
|
+
headers: {
|
|
105
|
+
'Access-Control-Allow-Origin': '*',
|
|
106
|
+
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
|
107
|
+
'Access-Control-Allow-Headers': 'Content-Type',
|
|
108
|
+
},
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (request.method !== 'POST') {
|
|
113
|
+
return new Response('Method Not Allowed', { status: 405 })
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let payload: TrimRequestPayload
|
|
117
|
+
try {
|
|
118
|
+
payload = (await request.json()) as TrimRequestPayload
|
|
119
|
+
} catch {
|
|
120
|
+
return new Response('Invalid JSON payload.', { status: 400 })
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const inputRaw = payload.inputPath ?? ''
|
|
124
|
+
const outputRaw = payload.outputPath ?? ''
|
|
125
|
+
const duration = Number(payload.duration ?? 0)
|
|
126
|
+
if (!Number.isFinite(duration) || duration <= 0) {
|
|
127
|
+
return new Response('Invalid or missing duration.', { status: 400 })
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const inputPath = resolveMediaPath(inputRaw)
|
|
131
|
+
const outputPath = resolveMediaPath(outputRaw)
|
|
132
|
+
if (!inputPath || !outputPath) {
|
|
133
|
+
return new Response('Input and output paths are required.', {
|
|
134
|
+
status: 400,
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const ranges = normalizeTrimRanges(parseRanges(payload.ranges), duration)
|
|
139
|
+
if (ranges.length === 0) {
|
|
140
|
+
return new Response('No valid trim ranges provided.', { status: 400 })
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const outputDuration = computeOutputDuration(duration, ranges)
|
|
144
|
+
if (outputDuration <= 0) {
|
|
145
|
+
return new Response('Trim ranges remove the full video.', { status: 400 })
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const resolvedInput = path.resolve(inputPath)
|
|
149
|
+
const resolvedOutput = path.resolve(outputPath)
|
|
150
|
+
if (resolvedInput === resolvedOutput) {
|
|
151
|
+
return new Response('Output path must be different from input.', {
|
|
152
|
+
status: 400,
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const inputFile = Bun.file(resolvedInput)
|
|
157
|
+
if (!(await inputFile.exists())) {
|
|
158
|
+
return new Response('Input file not found.', { status: 404 })
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
await mkdir(path.dirname(resolvedOutput), { recursive: true })
|
|
162
|
+
|
|
163
|
+
const args = buildFfmpegArgs({
|
|
164
|
+
inputPath: resolvedInput,
|
|
165
|
+
outputPath: resolvedOutput,
|
|
166
|
+
ranges,
|
|
167
|
+
withProgress: true,
|
|
168
|
+
})
|
|
169
|
+
if (args.length === 0) {
|
|
170
|
+
return new Response('Unable to build ffmpeg command.', { status: 400 })
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const outputDurationSeconds = outputDuration
|
|
174
|
+
const encoder = new TextEncoder()
|
|
175
|
+
|
|
176
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
177
|
+
start(controller) {
|
|
178
|
+
let outTimeSeconds = 0
|
|
179
|
+
const send = (payload: Record<string, unknown>) => {
|
|
180
|
+
try {
|
|
181
|
+
controller.enqueue(
|
|
182
|
+
encoder.encode(`${JSON.stringify(payload)}\n`),
|
|
183
|
+
)
|
|
184
|
+
} catch {
|
|
185
|
+
// stream closed
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const process = Bun.spawn({
|
|
189
|
+
cmd: args,
|
|
190
|
+
stdout: 'pipe',
|
|
191
|
+
stderr: 'pipe',
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
request.signal.addEventListener('abort', () => {
|
|
195
|
+
try {
|
|
196
|
+
process.kill()
|
|
197
|
+
} catch {
|
|
198
|
+
// ignore
|
|
199
|
+
}
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
const stdoutReader = readLines(process.stdout, (line) => {
|
|
203
|
+
const [key, rawValue] = line.split('=')
|
|
204
|
+
const value = rawValue ?? ''
|
|
205
|
+
if (key === 'out_time_ms') {
|
|
206
|
+
const next = Number.parseFloat(value)
|
|
207
|
+
if (Number.isFinite(next)) outTimeSeconds = next / 1000
|
|
208
|
+
}
|
|
209
|
+
if (key === 'out_time_us') {
|
|
210
|
+
const next = Number.parseFloat(value)
|
|
211
|
+
if (Number.isFinite(next)) outTimeSeconds = next / 1000000
|
|
212
|
+
}
|
|
213
|
+
if (key === 'out_time') {
|
|
214
|
+
const parsed = parseOutTimeValue(value)
|
|
215
|
+
if (parsed !== null) outTimeSeconds = parsed
|
|
216
|
+
}
|
|
217
|
+
if (key === 'progress') {
|
|
218
|
+
const progress =
|
|
219
|
+
outputDurationSeconds > 0
|
|
220
|
+
? clamp(outTimeSeconds / outputDurationSeconds, 0, 1)
|
|
221
|
+
: 0
|
|
222
|
+
send({ type: 'progress', progress })
|
|
223
|
+
if (value === 'end') {
|
|
224
|
+
send({ type: 'progress', progress: 1 })
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
const stderrReader = readLines(process.stderr, (line) => {
|
|
230
|
+
send({ type: 'log', message: line })
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
Promise.all([stdoutReader, stderrReader, process.exited])
|
|
234
|
+
.then(([, , exitCode]) => {
|
|
235
|
+
send({
|
|
236
|
+
type: 'done',
|
|
237
|
+
success: exitCode === 0,
|
|
238
|
+
exitCode,
|
|
239
|
+
})
|
|
240
|
+
})
|
|
241
|
+
.catch((error) => {
|
|
242
|
+
send({
|
|
243
|
+
type: 'done',
|
|
244
|
+
success: false,
|
|
245
|
+
error: error instanceof Error ? error.message : String(error),
|
|
246
|
+
})
|
|
247
|
+
})
|
|
248
|
+
.finally(() => {
|
|
249
|
+
controller.close()
|
|
250
|
+
})
|
|
251
|
+
},
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
return new Response(stream, {
|
|
255
|
+
headers: {
|
|
256
|
+
'Content-Type': 'application/x-ndjson',
|
|
257
|
+
'Cache-Control': 'no-cache',
|
|
258
|
+
'Access-Control-Allow-Origin': '*',
|
|
259
|
+
},
|
|
260
|
+
})
|
|
261
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
export type TrimRange = {
|
|
2
|
+
start: number
|
|
3
|
+
end: number
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const DEFAULT_MIN_RANGE = 0.05
|
|
7
|
+
|
|
8
|
+
function clamp(value: number, min: number, max: number) {
|
|
9
|
+
return Math.min(Math.max(value, min), max)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function formatSecondsForCommand(value: number) {
|
|
13
|
+
return value.toFixed(3)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function normalizeTrimRanges(
|
|
17
|
+
ranges: TrimRange[],
|
|
18
|
+
duration: number,
|
|
19
|
+
minLength: number = DEFAULT_MIN_RANGE,
|
|
20
|
+
) {
|
|
21
|
+
if (!Number.isFinite(duration) || duration <= 0) return []
|
|
22
|
+
const normalized = ranges
|
|
23
|
+
.map((range) => {
|
|
24
|
+
const startRaw = Number.isFinite(range.start) ? range.start : 0
|
|
25
|
+
const endRaw = Number.isFinite(range.end) ? range.end : 0
|
|
26
|
+
const start = clamp(Math.min(startRaw, endRaw), 0, duration)
|
|
27
|
+
const end = clamp(Math.max(startRaw, endRaw), 0, duration)
|
|
28
|
+
if (end - start < minLength) return null
|
|
29
|
+
return { start, end }
|
|
30
|
+
})
|
|
31
|
+
.filter((range): range is TrimRange => Boolean(range))
|
|
32
|
+
.sort((a, b) => a.start - b.start)
|
|
33
|
+
|
|
34
|
+
const merged: TrimRange[] = []
|
|
35
|
+
for (const range of normalized) {
|
|
36
|
+
const last = merged[merged.length - 1]
|
|
37
|
+
if (last && range.start <= last.end + minLength) {
|
|
38
|
+
last.end = Math.max(last.end, range.end)
|
|
39
|
+
} else {
|
|
40
|
+
merged.push({ ...range })
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return merged
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function computeOutputDuration(
|
|
47
|
+
duration: number,
|
|
48
|
+
ranges: TrimRange[],
|
|
49
|
+
minLength: number = DEFAULT_MIN_RANGE,
|
|
50
|
+
) {
|
|
51
|
+
const normalized = normalizeTrimRanges(ranges, duration, minLength)
|
|
52
|
+
const removed = normalized.reduce(
|
|
53
|
+
(total, range) => total + (range.end - range.start),
|
|
54
|
+
0,
|
|
55
|
+
)
|
|
56
|
+
return Math.max(duration - removed, 0)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function buildTrimExpression(ranges: TrimRange[]) {
|
|
60
|
+
if (ranges.length === 0) return ''
|
|
61
|
+
const expressions = ranges.map(
|
|
62
|
+
(range) =>
|
|
63
|
+
`between(t,${formatSecondsForCommand(range.start)},${formatSecondsForCommand(range.end)})`,
|
|
64
|
+
)
|
|
65
|
+
return `not(${expressions.join('+')})`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function buildTrimFilters(ranges: TrimRange[]) {
|
|
69
|
+
const expression = buildTrimExpression(ranges)
|
|
70
|
+
if (!expression) {
|
|
71
|
+
return {
|
|
72
|
+
expression: '',
|
|
73
|
+
videoFilter: '',
|
|
74
|
+
audioFilter: '',
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
expression,
|
|
79
|
+
videoFilter: `select='${expression}',setpts=N/FRAME_RATE/TB`,
|
|
80
|
+
audioFilter: `aselect='${expression}',asetpts=N/SR/TB`,
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function buildFfmpegArgs(options: {
|
|
85
|
+
inputPath: string
|
|
86
|
+
outputPath: string
|
|
87
|
+
ranges: TrimRange[]
|
|
88
|
+
withProgress?: boolean
|
|
89
|
+
}) {
|
|
90
|
+
const filters = buildTrimFilters(options.ranges)
|
|
91
|
+
if (!filters.expression) return []
|
|
92
|
+
const args = [
|
|
93
|
+
'ffmpeg',
|
|
94
|
+
'-hide_banner',
|
|
95
|
+
'-y',
|
|
96
|
+
'-i',
|
|
97
|
+
options.inputPath,
|
|
98
|
+
'-vf',
|
|
99
|
+
filters.videoFilter,
|
|
100
|
+
'-af',
|
|
101
|
+
filters.audioFilter,
|
|
102
|
+
'-map',
|
|
103
|
+
'0:v',
|
|
104
|
+
'-map',
|
|
105
|
+
'0:a?',
|
|
106
|
+
'-c:v',
|
|
107
|
+
'libx264',
|
|
108
|
+
'-preset',
|
|
109
|
+
'veryfast',
|
|
110
|
+
'-crf',
|
|
111
|
+
'18',
|
|
112
|
+
'-c:a',
|
|
113
|
+
'aac',
|
|
114
|
+
'-b:a',
|
|
115
|
+
'192k',
|
|
116
|
+
'-movflags',
|
|
117
|
+
'+faststart',
|
|
118
|
+
]
|
|
119
|
+
if (options.withProgress) {
|
|
120
|
+
args.push('-progress', 'pipe:1', '-nostats')
|
|
121
|
+
}
|
|
122
|
+
args.push(options.outputPath)
|
|
123
|
+
return args
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function quoteShellArgument(value: string) {
|
|
127
|
+
const escaped = value.replace(/(["\\$`])/g, '\\$1')
|
|
128
|
+
return `"${escaped}"`
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function buildFfmpegCommandPreview(options: {
|
|
132
|
+
inputPath: string
|
|
133
|
+
outputPath: string
|
|
134
|
+
ranges: TrimRange[]
|
|
135
|
+
includeProgress?: boolean
|
|
136
|
+
}) {
|
|
137
|
+
const filters = buildTrimFilters(options.ranges)
|
|
138
|
+
if (!filters.expression) return ''
|
|
139
|
+
const lines = [
|
|
140
|
+
'ffmpeg -hide_banner -y \\',
|
|
141
|
+
` -i ${quoteShellArgument(options.inputPath)} \\`,
|
|
142
|
+
` -vf ${quoteShellArgument(filters.videoFilter)} \\`,
|
|
143
|
+
` -af ${quoteShellArgument(filters.audioFilter)} \\`,
|
|
144
|
+
' -map 0:v -map 0:a? \\',
|
|
145
|
+
' -c:v libx264 -preset veryfast -crf 18 \\',
|
|
146
|
+
' -c:a aac -b:a 192k \\',
|
|
147
|
+
' -movflags +faststart \\',
|
|
148
|
+
]
|
|
149
|
+
if (options.includeProgress) {
|
|
150
|
+
lines.push(' -progress pipe:1 -nostats \\')
|
|
151
|
+
}
|
|
152
|
+
lines.push(` ${quoteShellArgument(options.outputPath)}`)
|
|
153
|
+
return lines.join('\n')
|
|
154
|
+
}
|