eprec 1.12.0 → 1.13.1

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.
@@ -2,4 +2,5 @@ import { route } from 'remix/fetch-router'
2
2
 
3
3
  export default route({
4
4
  index: '/',
5
+ trimPoints: '/trim-points',
5
6
  })
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
  }
@@ -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
19
+ run 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
@@ -0,0 +1,262 @@
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 (
49
+ !Number.isFinite(candidate.start) ||
50
+ !Number.isFinite(candidate.end)
51
+ ) {
52
+ return null
53
+ }
54
+ return { start: candidate.start, end: candidate.end }
55
+ })
56
+ .filter((range): range is TrimRange => Boolean(range))
57
+ }
58
+
59
+ function parseOutTimeValue(value: string) {
60
+ const parts = value.trim().split(':')
61
+ if (parts.length !== 3) return null
62
+ const [hours, minutes, seconds] = parts
63
+ const h = Number.parseFloat(hours)
64
+ const m = Number.parseFloat(minutes)
65
+ const s = Number.parseFloat(seconds)
66
+ if (!Number.isFinite(h) || !Number.isFinite(m) || !Number.isFinite(s)) {
67
+ return null
68
+ }
69
+ return h * 3600 + m * 60 + s
70
+ }
71
+
72
+ function clamp(value: number, min: number, max: number) {
73
+ return Math.min(Math.max(value, min), max)
74
+ }
75
+
76
+ async function readLines(
77
+ stream: ReadableStream<Uint8Array> | null,
78
+ onLine: (line: string) => void,
79
+ ) {
80
+ if (!stream) return
81
+ const reader = stream.pipeThrough(new TextDecoderStream()).getReader()
82
+ let buffer = ''
83
+ while (true) {
84
+ const { value, done } = await reader.read()
85
+ if (done) break
86
+ buffer += value
87
+ const lines = buffer.split('\n')
88
+ buffer = lines.pop() ?? ''
89
+ for (const line of lines) {
90
+ const trimmed = line.trim()
91
+ if (trimmed) onLine(trimmed)
92
+ }
93
+ }
94
+ const trailing = buffer.trim()
95
+ if (trailing) onLine(trailing)
96
+ }
97
+
98
+ export async function handleTrimRequest(request: Request): Promise<Response> {
99
+ const url = new URL(request.url)
100
+ if (url.pathname !== TRIM_ROUTE) {
101
+ return new Response('Not Found', { status: 404 })
102
+ }
103
+
104
+ if (request.method === 'OPTIONS') {
105
+ return new Response(null, {
106
+ status: 204,
107
+ headers: {
108
+ 'Access-Control-Allow-Origin': '*',
109
+ 'Access-Control-Allow-Methods': 'POST, OPTIONS',
110
+ 'Access-Control-Allow-Headers': 'Content-Type',
111
+ },
112
+ })
113
+ }
114
+
115
+ if (request.method !== 'POST') {
116
+ return new Response('Method Not Allowed', { status: 405 })
117
+ }
118
+
119
+ let payload: TrimRequestPayload
120
+ try {
121
+ payload = (await request.json()) as TrimRequestPayload
122
+ } catch {
123
+ return new Response('Invalid JSON payload.', { status: 400 })
124
+ }
125
+
126
+ const inputRaw = payload.inputPath ?? ''
127
+ const outputRaw = payload.outputPath ?? ''
128
+ const duration = Number(payload.duration ?? 0)
129
+ if (!Number.isFinite(duration) || duration <= 0) {
130
+ return new Response('Invalid or missing duration.', { status: 400 })
131
+ }
132
+
133
+ const inputPath = resolveMediaPath(inputRaw)
134
+ const outputPath = resolveMediaPath(outputRaw)
135
+ if (!inputPath || !outputPath) {
136
+ return new Response('Input and output paths are required.', {
137
+ status: 400,
138
+ })
139
+ }
140
+
141
+ const ranges = normalizeTrimRanges(parseRanges(payload.ranges), duration)
142
+ if (ranges.length === 0) {
143
+ return new Response('No valid trim ranges provided.', { status: 400 })
144
+ }
145
+
146
+ const outputDuration = computeOutputDuration(duration, ranges)
147
+ if (outputDuration <= 0) {
148
+ return new Response('Trim ranges remove the full video.', { status: 400 })
149
+ }
150
+
151
+ const resolvedInput = path.resolve(inputPath)
152
+ const resolvedOutput = path.resolve(outputPath)
153
+ if (resolvedInput === resolvedOutput) {
154
+ return new Response('Output path must be different from input.', {
155
+ status: 400,
156
+ })
157
+ }
158
+
159
+ const inputFile = Bun.file(resolvedInput)
160
+ if (!(await inputFile.exists())) {
161
+ return new Response('Input file not found.', { status: 404 })
162
+ }
163
+
164
+ await mkdir(path.dirname(resolvedOutput), { recursive: true })
165
+
166
+ const args = buildFfmpegArgs({
167
+ inputPath: resolvedInput,
168
+ outputPath: resolvedOutput,
169
+ ranges,
170
+ withProgress: true,
171
+ })
172
+ if (args.length === 0) {
173
+ return new Response('Unable to build ffmpeg command.', { status: 400 })
174
+ }
175
+
176
+ const outputDurationSeconds = outputDuration
177
+ const encoder = new TextEncoder()
178
+
179
+ const stream = new ReadableStream<Uint8Array>({
180
+ start(controller) {
181
+ let outTimeSeconds = 0
182
+ const send = (payload: Record<string, unknown>) => {
183
+ try {
184
+ controller.enqueue(encoder.encode(`${JSON.stringify(payload)}\n`))
185
+ } catch {
186
+ // stream closed
187
+ }
188
+ }
189
+ const process = Bun.spawn({
190
+ cmd: args,
191
+ stdout: 'pipe',
192
+ stderr: 'pipe',
193
+ })
194
+
195
+ request.signal.addEventListener('abort', () => {
196
+ try {
197
+ process.kill()
198
+ } catch {
199
+ // ignore
200
+ }
201
+ })
202
+
203
+ const stdoutReader = readLines(process.stdout, (line) => {
204
+ const [key, rawValue] = line.split('=')
205
+ const value = rawValue ?? ''
206
+ if (key === 'out_time_ms') {
207
+ const next = Number.parseFloat(value)
208
+ if (Number.isFinite(next)) outTimeSeconds = next / 1000
209
+ }
210
+ if (key === 'out_time_us') {
211
+ const next = Number.parseFloat(value)
212
+ if (Number.isFinite(next)) outTimeSeconds = next / 1000000
213
+ }
214
+ if (key === 'out_time') {
215
+ const parsed = parseOutTimeValue(value)
216
+ if (parsed !== null) outTimeSeconds = parsed
217
+ }
218
+ if (key === 'progress') {
219
+ const progress =
220
+ outputDurationSeconds > 0
221
+ ? clamp(outTimeSeconds / outputDurationSeconds, 0, 1)
222
+ : 0
223
+ send({ type: 'progress', progress })
224
+ if (value === 'end') {
225
+ send({ type: 'progress', progress: 1 })
226
+ }
227
+ }
228
+ })
229
+
230
+ const stderrReader = readLines(process.stderr, (line) => {
231
+ send({ type: 'log', message: line })
232
+ })
233
+
234
+ Promise.all([stdoutReader, stderrReader, process.exited])
235
+ .then(([, , exitCode]) => {
236
+ send({
237
+ type: 'done',
238
+ success: exitCode === 0,
239
+ exitCode,
240
+ })
241
+ })
242
+ .catch((error) => {
243
+ send({
244
+ type: 'done',
245
+ success: false,
246
+ error: error instanceof Error ? error.message : String(error),
247
+ })
248
+ })
249
+ .finally(() => {
250
+ controller.close()
251
+ })
252
+ },
253
+ })
254
+
255
+ return new Response(stream, {
256
+ headers: {
257
+ 'Content-Type': 'application/x-ndjson',
258
+ 'Cache-Control': 'no-cache',
259
+ 'Access-Control-Allow-Origin': '*',
260
+ },
261
+ })
262
+ }
@@ -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
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "eprec",
3
3
  "type": "module",
4
- "version": "1.12.0",
4
+ "version": "1.13.1",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -8,6 +8,7 @@ import {
8
8
  createInquirerPrompter,
9
9
  createPathPicker,
10
10
  createStepProgressReporter,
11
+ handlePromptFailure,
11
12
  isInteractive,
12
13
  pauseActiveSpinner,
13
14
  resolveOptionalString,
@@ -373,6 +374,8 @@ export async function runEditsCli(rawArgs = hideBin(process.argv)) {
373
374
  )
374
375
  .demandCommand(1)
375
376
  .strict()
377
+ .fail(handlePromptFailure)
378
+ .exitProcess(false)
376
379
  .help()
377
380
 
378
381
  await parser.parseAsync()
@@ -63,7 +63,11 @@ const TASK_STEPS: Record<ProcessingAction, string[]> = {
63
63
  'Refining command windows',
64
64
  'Updating cut ranges',
65
65
  ],
66
- 'render-preview': ['Rendering preview clip', 'Optimizing output', 'Verifying'],
66
+ 'render-preview': [
67
+ 'Rendering preview clip',
68
+ 'Optimizing output',
69
+ 'Verifying',
70
+ ],
67
71
  'export-final': [
68
72
  'Rendering chapters',
69
73
  'Packaging exports',
@@ -102,9 +106,7 @@ function updateQueueState(mutate: () => void) {
102
106
  function updateTask(taskId: string, patch: Partial<ProcessingTask>) {
103
107
  updateQueueState(() => {
104
108
  tasks = tasks.map((task) =>
105
- task.id === taskId
106
- ? { ...task, ...patch, updatedAt: Date.now() }
107
- : task,
109
+ task.id === taskId ? { ...task, ...patch, updatedAt: Date.now() } : task,
108
110
  )
109
111
  })
110
112
  }
@@ -172,8 +174,7 @@ function markActiveDone() {
172
174
  }
173
175
 
174
176
  function buildProgress(step: number, totalSteps: number, label: string) {
175
- const percent =
176
- totalSteps > 0 ? Math.round((step / totalSteps) * 100) : 0
177
+ const percent = totalSteps > 0 ? Math.round((step / totalSteps) * 100) : 0
177
178
  return { step, totalSteps, label, percent }
178
179
  }
179
180
 
@@ -227,8 +228,7 @@ async function runTask(task: ProcessingTask) {
227
228
  if (failAtStep && index + 1 === failAtStep) {
228
229
  throw new Error('Processing failed during render.')
229
230
  }
230
- const delay =
231
- STEP_DELAY_MS + Math.round(Math.random() * STEP_JITTER_MS)
231
+ const delay = STEP_DELAY_MS + Math.round(Math.random() * STEP_JITTER_MS)
232
232
  await sleep(delay, controller.signal)
233
233
  }
234
234
  updateTask(task.id, {
package/src/app-server.ts CHANGED
@@ -4,6 +4,7 @@ import '../app/config/init-env.ts'
4
4
  import getPort from 'get-port'
5
5
  import { getEnv } from '../app/config/env.ts'
6
6
  import { createAppRouter } from '../app/router.tsx'
7
+ import { handleTrimRequest } from '../app/trim-api.ts'
7
8
  import { handleVideoRequest } from '../app/video-api.ts'
8
9
  import { createBundlingRoutes } from '../server/bundling.ts'
9
10
  import { handleProcessingQueueRequest } from '../server/processing-queue.ts'
@@ -197,6 +198,9 @@ function startServer(port: number, hostname: string) {
197
198
  if (url.pathname === '/api/video') {
198
199
  return await handleVideoRequest(request)
199
200
  }
201
+ if (url.pathname === '/api/trim') {
202
+ return await handleTrimRequest(request)
203
+ }
200
204
  if (url.pathname.startsWith('/api/processing-queue')) {
201
205
  return await handleProcessingQueueRequest(request)
202
206
  }
package/src/cli.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  import path from 'node:path'
3
- import type { Arguments, CommandBuilder, CommandHandler } from 'yargs'
3
+ import type { Argv, Arguments, CommandBuilder, CommandHandler } from 'yargs'
4
4
  import yargs from 'yargs/yargs'
5
5
  import { hideBin } from 'yargs/helpers'
6
6
  import { startAppServer } from './app-server'
@@ -28,6 +28,7 @@ import {
28
28
  createInquirerPrompter,
29
29
  createPathPicker,
30
30
  createStepProgressReporter,
31
+ handlePromptFailure,
31
32
  isInteractive,
32
33
  pauseActiveSpinner,
33
34
  resumeActiveSpinner,
@@ -249,6 +250,8 @@ async function main(rawArgs = hideBin(process.argv)) {
249
250
  )
250
251
  .demandCommand(1)
251
252
  .strict()
253
+ .fail(handlePromptFailure)
254
+ .exitProcess(false)
252
255
  .help()
253
256
 
254
257
  await parser.parseAsync()