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.
@@ -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 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
@@ -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
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "eprec",
3
3
  "type": "module",
4
- "version": "1.11.0",
4
+ "version": "1.13.0",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",