lopata 0.13.0 → 0.14.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/package.json +1 -1
- package/src/bindings/do-executor-inprocess.ts +19 -12
- package/src/bindings/do-executor.ts +3 -0
- package/src/bindings/do-worker-entry.ts +8 -12
- package/src/bindings/durable-object.ts +46 -27
- package/src/bindings/media.ts +320 -0
- package/src/cli/cache.ts +8 -5
- package/src/cli/context.ts +32 -29
- package/src/cli/d1.ts +11 -7
- package/src/cli/dev.ts +10 -6
- package/src/cli/hosts.ts +3 -0
- package/src/cli/kv.ts +27 -9
- package/src/cli/queues.ts +9 -3
- package/src/cli/r2.ts +24 -14
- package/src/cli/traces.ts +20 -15
- package/src/cli.ts +21 -15
- package/src/config.ts +3 -0
- package/src/d1-migrate.ts +9 -6
- package/src/env.ts +14 -1
- package/src/generation-manager.ts +10 -2
- package/src/generation.ts +5 -2
package/package.json
CHANGED
|
@@ -45,24 +45,22 @@ export class InProcessExecutor implements DOExecutor {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
async executeFetch(request: Request): Promise<Response> {
|
|
48
|
-
|
|
48
|
+
await this._state._enter()
|
|
49
49
|
try {
|
|
50
|
-
await this._state._waitForReady()
|
|
51
50
|
const fetchFn = (this._instance as unknown as Record<string, unknown>).fetch
|
|
52
51
|
if (typeof fetchFn !== 'function') {
|
|
53
52
|
throw new Error('Durable Object does not implement fetch()')
|
|
54
53
|
}
|
|
55
54
|
return await (fetchFn as (req: Request) => Promise<Response>).call(this._instance, request)
|
|
56
55
|
} finally {
|
|
57
|
-
|
|
56
|
+
this._state._exit()
|
|
58
57
|
}
|
|
59
58
|
}
|
|
60
59
|
|
|
61
60
|
async executeRpc(method: string, args: unknown[]): Promise<unknown> {
|
|
62
61
|
warnInvalidRpcArgs(args, method)
|
|
63
|
-
|
|
62
|
+
await this._state._enter()
|
|
64
63
|
try {
|
|
65
|
-
await this._state._waitForReady()
|
|
66
64
|
const val = (this._instance as unknown as Record<string, unknown>)[method]
|
|
67
65
|
if (typeof val === 'function') {
|
|
68
66
|
const result = await (val as (...a: unknown[]) => unknown).call(this._instance, ...args)
|
|
@@ -70,28 +68,26 @@ export class InProcessExecutor implements DOExecutor {
|
|
|
70
68
|
}
|
|
71
69
|
throw new Error(`"${method}" is not a method on the Durable Object`)
|
|
72
70
|
} finally {
|
|
73
|
-
|
|
71
|
+
this._state._exit()
|
|
74
72
|
}
|
|
75
73
|
}
|
|
76
74
|
|
|
77
75
|
async executeRpcGet(prop: string): Promise<unknown> {
|
|
78
|
-
|
|
76
|
+
await this._state._enter()
|
|
79
77
|
try {
|
|
80
|
-
await this._state._waitForReady()
|
|
81
78
|
const val = (this._instance as unknown as Record<string, unknown>)[prop]
|
|
82
79
|
if (typeof val === 'function') {
|
|
83
80
|
return createRpcFunctionStub(val as Function, this._instance)
|
|
84
81
|
}
|
|
85
82
|
return wrapRpcReturnValue(val, prop)
|
|
86
83
|
} finally {
|
|
87
|
-
|
|
84
|
+
this._state._exit()
|
|
88
85
|
}
|
|
89
86
|
}
|
|
90
87
|
|
|
91
88
|
async executeAlarm(retryCount: number): Promise<void> {
|
|
92
|
-
|
|
89
|
+
await this._state._enter()
|
|
93
90
|
try {
|
|
94
|
-
await this._state._waitForReady()
|
|
95
91
|
const alarmFn = (this._instance as unknown as Record<string, unknown>).alarm
|
|
96
92
|
if (typeof alarmFn === 'function') {
|
|
97
93
|
await alarmFn.call(this._instance, {
|
|
@@ -100,7 +96,7 @@ export class InProcessExecutor implements DOExecutor {
|
|
|
100
96
|
})
|
|
101
97
|
}
|
|
102
98
|
} finally {
|
|
103
|
-
|
|
99
|
+
this._state._exit()
|
|
104
100
|
}
|
|
105
101
|
}
|
|
106
102
|
|
|
@@ -120,7 +116,18 @@ export class InProcessExecutor implements DOExecutor {
|
|
|
120
116
|
return this._state._isAborted()
|
|
121
117
|
}
|
|
122
118
|
|
|
119
|
+
reloadClass(cls: new(ctx: DurableObjectStateImpl, env: unknown) => DurableObjectBase, env: unknown): void {
|
|
120
|
+
this._instance = new cls(this._state, env)
|
|
121
|
+
this._state._setInstanceResolver(() => this._instance)
|
|
122
|
+
}
|
|
123
|
+
|
|
123
124
|
async dispose(): Promise<void> {
|
|
125
|
+
// Close all accepted WebSockets so clients can reconnect to new instance
|
|
126
|
+
for (const ws of this._state.getWebSockets()) {
|
|
127
|
+
try {
|
|
128
|
+
ws.close(1012, 'Service restart')
|
|
129
|
+
} catch {}
|
|
130
|
+
}
|
|
124
131
|
if (this._containerRuntime) {
|
|
125
132
|
await this._containerRuntime.cleanup()
|
|
126
133
|
}
|
|
@@ -39,6 +39,9 @@ export interface DOExecutor {
|
|
|
39
39
|
/** Whether the instance has been aborted */
|
|
40
40
|
isAborted(): boolean
|
|
41
41
|
|
|
42
|
+
/** Hot-swap the DO class and env without disposing (preserves WebSocket connections) */
|
|
43
|
+
reloadClass?(cls: new(ctx: any, env: unknown) => DurableObjectBase, env: unknown): void
|
|
44
|
+
|
|
42
45
|
/** Kill the instance */
|
|
43
46
|
dispose(): Promise<void>
|
|
44
47
|
}
|
|
@@ -94,9 +94,8 @@ async function initWorker(workerConfig: WorkerConfig) {
|
|
|
94
94
|
async function handleCommand(cmd: DOCommand): Promise<DOResult> {
|
|
95
95
|
switch (cmd.type) {
|
|
96
96
|
case 'fetch': {
|
|
97
|
-
|
|
97
|
+
await state._enter()
|
|
98
98
|
try {
|
|
99
|
-
await state._waitForReady()
|
|
100
99
|
const fetchFn = (instance as any).fetch
|
|
101
100
|
if (typeof fetchFn !== 'function') {
|
|
102
101
|
throw new Error('Durable Object does not implement fetch()')
|
|
@@ -118,14 +117,13 @@ async function initWorker(workerConfig: WorkerConfig) {
|
|
|
118
117
|
body: resBody,
|
|
119
118
|
}
|
|
120
119
|
} finally {
|
|
121
|
-
|
|
120
|
+
state._exit()
|
|
122
121
|
}
|
|
123
122
|
}
|
|
124
123
|
|
|
125
124
|
case 'rpc-call': {
|
|
126
|
-
|
|
125
|
+
await state._enter()
|
|
127
126
|
try {
|
|
128
|
-
await state._waitForReady()
|
|
129
127
|
const val = (instance as any)[cmd.method]
|
|
130
128
|
if (typeof val !== 'function') {
|
|
131
129
|
throw new Error(`"${cmd.method}" is not a method on the Durable Object`)
|
|
@@ -133,28 +131,26 @@ async function initWorker(workerConfig: WorkerConfig) {
|
|
|
133
131
|
const result = await val.call(instance, ...cmd.args)
|
|
134
132
|
return { type: 'rpc-call', value: result }
|
|
135
133
|
} finally {
|
|
136
|
-
|
|
134
|
+
state._exit()
|
|
137
135
|
}
|
|
138
136
|
}
|
|
139
137
|
|
|
140
138
|
case 'rpc-get': {
|
|
141
|
-
|
|
139
|
+
await state._enter()
|
|
142
140
|
try {
|
|
143
|
-
await state._waitForReady()
|
|
144
141
|
const val = (instance as any)[cmd.prop]
|
|
145
142
|
if (typeof val === 'function') {
|
|
146
143
|
return { type: 'rpc-get', value: '__function__' }
|
|
147
144
|
}
|
|
148
145
|
return { type: 'rpc-get', value: val }
|
|
149
146
|
} finally {
|
|
150
|
-
|
|
147
|
+
state._exit()
|
|
151
148
|
}
|
|
152
149
|
}
|
|
153
150
|
|
|
154
151
|
case 'alarm': {
|
|
155
|
-
|
|
152
|
+
await state._enter()
|
|
156
153
|
try {
|
|
157
|
-
await state._waitForReady()
|
|
158
154
|
const alarmFn = (instance as any).alarm
|
|
159
155
|
if (typeof alarmFn === 'function') {
|
|
160
156
|
await alarmFn.call(instance, {
|
|
@@ -164,7 +160,7 @@ async function initWorker(workerConfig: WorkerConfig) {
|
|
|
164
160
|
}
|
|
165
161
|
return { type: 'alarm' }
|
|
166
162
|
} finally {
|
|
167
|
-
|
|
163
|
+
state._exit()
|
|
168
164
|
}
|
|
169
165
|
}
|
|
170
166
|
|
|
@@ -83,6 +83,24 @@ export class SqlStorageCursor implements Iterable<Record<string, unknown>> {
|
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
// --- DB-level transaction mutex ---
|
|
87
|
+
// Shared across all SqliteDurableObjectStorage instances using the same Database connection.
|
|
88
|
+
// Prevents overlapping BEGIN/COMMIT blocks from concurrent requests.
|
|
89
|
+
const dbTxnLocks = new WeakMap<Database, Promise<void>>()
|
|
90
|
+
|
|
91
|
+
async function acquireDbTxnLock(db: Database): Promise<() => void> {
|
|
92
|
+
let unlock!: () => void
|
|
93
|
+
const next = new Promise<void>((r) => {
|
|
94
|
+
unlock = r
|
|
95
|
+
})
|
|
96
|
+
const ready = dbTxnLocks.get(db) ?? Promise.resolve()
|
|
97
|
+
dbTxnLocks.set(db, next)
|
|
98
|
+
await ready
|
|
99
|
+
return () => {
|
|
100
|
+
unlock()
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
86
104
|
// --- SQL Storage API ---
|
|
87
105
|
|
|
88
106
|
export class SqlStorage {
|
|
@@ -324,14 +342,15 @@ export class SqliteDurableObjectStorage {
|
|
|
324
342
|
const stmt = this.db.query(
|
|
325
343
|
'INSERT OR REPLACE INTO do_storage (namespace, id, key, value) VALUES (?, ?, ?, ?)',
|
|
326
344
|
)
|
|
327
|
-
this.db.run('
|
|
345
|
+
this.db.run('SAVEPOINT put_batch')
|
|
328
346
|
try {
|
|
329
347
|
for (const [k, v] of Object.entries(keyOrEntries)) {
|
|
330
348
|
stmt.run(this.namespace, this.id, k, JSON.stringify(v))
|
|
331
349
|
}
|
|
332
|
-
this.db.run('
|
|
350
|
+
this.db.run('RELEASE put_batch')
|
|
333
351
|
} catch (e) {
|
|
334
|
-
this.db.run('ROLLBACK')
|
|
352
|
+
this.db.run('ROLLBACK TO put_batch')
|
|
353
|
+
this.db.run('RELEASE put_batch')
|
|
335
354
|
throw e
|
|
336
355
|
}
|
|
337
356
|
}
|
|
@@ -415,14 +434,19 @@ export class SqliteDurableObjectStorage {
|
|
|
415
434
|
}
|
|
416
435
|
|
|
417
436
|
async transaction<T>(closure: (txn: SqliteDurableObjectStorage) => Promise<T>): Promise<T> {
|
|
418
|
-
this.db
|
|
437
|
+
const unlock = await acquireDbTxnLock(this.db)
|
|
419
438
|
try {
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
439
|
+
this.db.run('BEGIN')
|
|
440
|
+
try {
|
|
441
|
+
const result = await closure(this)
|
|
442
|
+
this.db.run('COMMIT')
|
|
443
|
+
return result
|
|
444
|
+
} catch (e) {
|
|
445
|
+
this.db.run('ROLLBACK')
|
|
446
|
+
throw e
|
|
447
|
+
}
|
|
448
|
+
} finally {
|
|
449
|
+
unlock()
|
|
426
450
|
}
|
|
427
451
|
}
|
|
428
452
|
|
|
@@ -506,7 +530,6 @@ export class DurableObjectStateImpl {
|
|
|
506
530
|
private _hibernatableTimeout: number | null = null
|
|
507
531
|
private _limits: Required<DurableObjectLimits>
|
|
508
532
|
private _instanceResolver: (() => DurableObjectBase | null) | null = null
|
|
509
|
-
private _lockTail: Promise<void> = Promise.resolve()
|
|
510
533
|
private _activeRequests = 0
|
|
511
534
|
private _aborted = false
|
|
512
535
|
private _abortReason: string | undefined
|
|
@@ -545,27 +568,21 @@ export class DurableObjectStateImpl {
|
|
|
545
568
|
return this._activeRequests > 0
|
|
546
569
|
}
|
|
547
570
|
|
|
548
|
-
/** @internal
|
|
549
|
-
async
|
|
571
|
+
/** @internal Enter a request — waits for blockConcurrencyWhile, checks abort, increments counter. */
|
|
572
|
+
async _enter(): Promise<void> {
|
|
550
573
|
if (this._aborted) {
|
|
551
574
|
throw new Error(this._abortReason ?? 'Durable Object has been aborted')
|
|
552
575
|
}
|
|
553
|
-
|
|
554
|
-
const nextTail = new Promise<void>(r => {
|
|
555
|
-
unlockNext = r
|
|
556
|
-
})
|
|
557
|
-
const ready = this._lockTail
|
|
558
|
-
this._lockTail = nextTail
|
|
559
|
-
await ready
|
|
576
|
+
await this._waitForReady()
|
|
560
577
|
if (this._aborted) {
|
|
561
|
-
unlockNext!()
|
|
562
578
|
throw new Error(this._abortReason ?? 'Durable Object has been aborted')
|
|
563
579
|
}
|
|
564
580
|
this._activeRequests++
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** @internal Exit a request — decrements counter. */
|
|
584
|
+
_exit(): void {
|
|
585
|
+
this._activeRequests--
|
|
569
586
|
}
|
|
570
587
|
|
|
571
588
|
/**
|
|
@@ -776,9 +793,11 @@ export class DurableObjectNamespaceImpl {
|
|
|
776
793
|
|
|
777
794
|
/** Called after worker module is loaded to wire the actual class */
|
|
778
795
|
_setClass(cls: new(ctx: DurableObjectStateImpl, env: unknown) => DurableObjectBase, env: unknown, generationId?: number) {
|
|
779
|
-
// Dispose existing executors so next request creates new ones with the new class
|
|
780
796
|
for (const [idStr, executor] of this._executors) {
|
|
781
|
-
if (executor.activeWebSocketCount()
|
|
797
|
+
if (executor.activeWebSocketCount() > 0 && executor.reloadClass) {
|
|
798
|
+
// Hot-swap: reuse state + WebSocket connections, create new instance with new code
|
|
799
|
+
executor.reloadClass(cls, env)
|
|
800
|
+
} else {
|
|
782
801
|
executor.dispose().catch(() => {})
|
|
783
802
|
this._executors.delete(idStr)
|
|
784
803
|
this._lastActivity.delete(idStr)
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// Media Transformations binding — ffmpeg-based implementation for local dev
|
|
2
|
+
// Cloudflare's Media binding provides video/audio processing (resize, crop, frame extraction, etc.)
|
|
3
|
+
// Requires ffmpeg installed on the system. Falls back to passthrough if unavailable.
|
|
4
|
+
//
|
|
5
|
+
// Note: MP4 files often have the moov atom at the end, making them non-streamable.
|
|
6
|
+
// ffmpeg needs to seek backwards to read the moov atom, which doesn't work with stdin pipes.
|
|
7
|
+
// We use temp files for input/output to handle all container formats correctly.
|
|
8
|
+
|
|
9
|
+
import { randomUUID } from 'node:crypto'
|
|
10
|
+
import { unlink } from 'node:fs/promises'
|
|
11
|
+
import { tmpdir } from 'node:os'
|
|
12
|
+
import { join } from 'node:path'
|
|
13
|
+
|
|
14
|
+
export interface MediaTransformOptions {
|
|
15
|
+
width?: number
|
|
16
|
+
height?: number
|
|
17
|
+
fit?: 'contain' | 'cover' | 'crop' | 'scale-down'
|
|
18
|
+
trim?: { start?: string; end?: string }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface MediaOutputOptions {
|
|
22
|
+
mode: 'video' | 'frame' | 'spritesheet' | 'audio'
|
|
23
|
+
duration?: string
|
|
24
|
+
offset?: string
|
|
25
|
+
format?: string
|
|
26
|
+
fps?: number
|
|
27
|
+
columns?: number
|
|
28
|
+
rows?: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface MediaOutputResult {
|
|
32
|
+
response(): Promise<Response>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// --- Lazy ffmpeg detection ---
|
|
36
|
+
|
|
37
|
+
let _ffmpegResolved = false
|
|
38
|
+
let _ffmpegAvailable = false
|
|
39
|
+
let _ffmpegWarned = false
|
|
40
|
+
|
|
41
|
+
async function hasFfmpeg(): Promise<boolean> {
|
|
42
|
+
if (!_ffmpegResolved) {
|
|
43
|
+
try {
|
|
44
|
+
const proc = Bun.spawn(['ffmpeg', '-version'], { stdout: 'ignore', stderr: 'ignore' })
|
|
45
|
+
const code = await proc.exited
|
|
46
|
+
_ffmpegAvailable = code === 0
|
|
47
|
+
} catch {
|
|
48
|
+
_ffmpegAvailable = false
|
|
49
|
+
}
|
|
50
|
+
_ffmpegResolved = true
|
|
51
|
+
}
|
|
52
|
+
return _ffmpegAvailable
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function warnFfmpegMissing() {
|
|
56
|
+
if (!_ffmpegWarned) {
|
|
57
|
+
console.warn('[lopata] ffmpeg is not installed — media transformations will pass through unchanged. Install it: https://ffmpeg.org')
|
|
58
|
+
_ffmpegWarned = true
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// --- Content type helpers ---
|
|
63
|
+
|
|
64
|
+
const MODE_CONTENT_TYPES: Record<string, Record<string, string>> = {
|
|
65
|
+
video: { mp4: 'video/mp4', webm: 'video/webm' },
|
|
66
|
+
frame: { png: 'image/png', jpeg: 'image/jpeg', jpg: 'image/jpeg', webp: 'image/webp' },
|
|
67
|
+
spritesheet: { png: 'image/png', jpeg: 'image/jpeg', jpg: 'image/jpeg', webp: 'image/webp' },
|
|
68
|
+
audio: { m4a: 'audio/mp4', mp3: 'audio/mpeg', ogg: 'audio/ogg' },
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const MODE_DEFAULTS: Record<string, { format: string; contentType: string }> = {
|
|
72
|
+
video: { format: 'mp4', contentType: 'video/mp4' },
|
|
73
|
+
frame: { format: 'png', contentType: 'image/png' },
|
|
74
|
+
spritesheet: { format: 'png', contentType: 'image/png' },
|
|
75
|
+
audio: { format: 'm4a', contentType: 'audio/mp4' },
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function resolveContentType(mode: string, format?: string): { ffFormat: string; contentType: string } {
|
|
79
|
+
const defaults = MODE_DEFAULTS[mode] ?? MODE_DEFAULTS.video!
|
|
80
|
+
if (!format) return { ffFormat: defaults.format, contentType: defaults.contentType }
|
|
81
|
+
const ct = MODE_CONTENT_TYPES[mode]?.[format]
|
|
82
|
+
return { ffFormat: format, contentType: ct ?? defaults.contentType }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// --- ffmpeg argument builders ---
|
|
86
|
+
|
|
87
|
+
function buildScaleFilter(transforms: MediaTransformOptions[]): string | null {
|
|
88
|
+
// Use the last transform that specifies dimensions
|
|
89
|
+
for (let i = transforms.length - 1; i >= 0; i--) {
|
|
90
|
+
const t = transforms[i]!
|
|
91
|
+
if (t.width || t.height) {
|
|
92
|
+
const w = t.width ?? -2
|
|
93
|
+
const h = t.height ?? -2
|
|
94
|
+
const fit = t.fit ?? 'contain'
|
|
95
|
+
|
|
96
|
+
if (fit === 'contain' || fit === 'scale-down') {
|
|
97
|
+
// Scale to fit within dimensions, preserving aspect ratio
|
|
98
|
+
return `scale=${w}:${h}:force_original_aspect_ratio=decrease`
|
|
99
|
+
} else if (fit === 'cover' || fit === 'crop') {
|
|
100
|
+
// Scale to cover, then crop to exact dimensions
|
|
101
|
+
if (t.width && t.height) {
|
|
102
|
+
return `scale=${t.width}:${t.height}:force_original_aspect_ratio=increase,crop=${t.width}:${t.height}`
|
|
103
|
+
}
|
|
104
|
+
return `scale=${w}:${h}:force_original_aspect_ratio=increase`
|
|
105
|
+
}
|
|
106
|
+
return `scale=${w}:${h}`
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function buildTrimArgs(transforms: MediaTransformOptions[], outputOpts: MediaOutputOptions): string[] {
|
|
113
|
+
const args: string[] = []
|
|
114
|
+
|
|
115
|
+
// Seek from transform trim or output offset
|
|
116
|
+
for (const t of transforms) {
|
|
117
|
+
if (t.trim?.start) {
|
|
118
|
+
args.push('-ss', t.trim.start)
|
|
119
|
+
break
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (args.length === 0 && outputOpts.offset) {
|
|
123
|
+
args.push('-ss', outputOpts.offset)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// End time from transform trim
|
|
127
|
+
for (const t of transforms) {
|
|
128
|
+
if (t.trim?.end) {
|
|
129
|
+
args.push('-to', t.trim.end)
|
|
130
|
+
break
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Duration from output options
|
|
135
|
+
if (outputOpts.duration) {
|
|
136
|
+
args.push('-t', outputOpts.duration)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return args
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function buildVideoArgs(transforms: MediaTransformOptions[], opts: MediaOutputOptions): string[] {
|
|
143
|
+
const args: string[] = []
|
|
144
|
+
|
|
145
|
+
args.push(...buildTrimArgs(transforms, opts))
|
|
146
|
+
|
|
147
|
+
const scale = buildScaleFilter(transforms)
|
|
148
|
+
if (scale) args.push('-vf', scale)
|
|
149
|
+
|
|
150
|
+
args.push('-movflags', '+faststart')
|
|
151
|
+
|
|
152
|
+
return args
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function buildFrameArgs(transforms: MediaTransformOptions[], opts: MediaOutputOptions): string[] {
|
|
156
|
+
const args: string[] = []
|
|
157
|
+
|
|
158
|
+
if (opts.offset) args.push('-ss', opts.offset)
|
|
159
|
+
|
|
160
|
+
args.push('-frames:v', '1')
|
|
161
|
+
|
|
162
|
+
const scale = buildScaleFilter(transforms)
|
|
163
|
+
if (scale) args.push('-vf', scale)
|
|
164
|
+
|
|
165
|
+
return args
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function buildSpritesheetArgs(transforms: MediaTransformOptions[], opts: MediaOutputOptions): string[] {
|
|
169
|
+
const args: string[] = []
|
|
170
|
+
|
|
171
|
+
args.push(...buildTrimArgs(transforms, opts))
|
|
172
|
+
|
|
173
|
+
const fps = opts.fps ?? 1
|
|
174
|
+
const columns = opts.columns ?? 5
|
|
175
|
+
const rows = opts.rows ?? 4
|
|
176
|
+
|
|
177
|
+
const filters: string[] = []
|
|
178
|
+
filters.push(`fps=${fps}`)
|
|
179
|
+
|
|
180
|
+
const scale = buildScaleFilter(transforms)
|
|
181
|
+
if (scale) filters.push(scale)
|
|
182
|
+
|
|
183
|
+
filters.push(`tile=${columns}x${rows}`)
|
|
184
|
+
|
|
185
|
+
args.push('-vf', filters.join(','))
|
|
186
|
+
args.push('-frames:v', '1')
|
|
187
|
+
|
|
188
|
+
return args
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function buildAudioArgs(transforms: MediaTransformOptions[], opts: MediaOutputOptions): string[] {
|
|
192
|
+
const args: string[] = []
|
|
193
|
+
const { ffFormat } = resolveContentType('audio', opts.format)
|
|
194
|
+
|
|
195
|
+
args.push(...buildTrimArgs(transforms, opts))
|
|
196
|
+
|
|
197
|
+
args.push('-vn') // no video
|
|
198
|
+
|
|
199
|
+
// m4a extension is recognized by ffmpeg, no explicit format needed
|
|
200
|
+
const codecMap: Record<string, string[]> = {
|
|
201
|
+
ogg: ['-c:a', 'libvorbis'],
|
|
202
|
+
}
|
|
203
|
+
if (codecMap[ffFormat]) args.push(...codecMap[ffFormat]!)
|
|
204
|
+
|
|
205
|
+
return args
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// --- Passthrough (no ffmpeg) ---
|
|
209
|
+
|
|
210
|
+
async function readStream(stream: ReadableStream<Uint8Array>): Promise<Uint8Array> {
|
|
211
|
+
const reader = stream.getReader()
|
|
212
|
+
const chunks: Uint8Array[] = []
|
|
213
|
+
let totalLength = 0
|
|
214
|
+
while (true) {
|
|
215
|
+
const { done, value } = await reader.read()
|
|
216
|
+
if (done) break
|
|
217
|
+
chunks.push(value)
|
|
218
|
+
totalLength += value.byteLength
|
|
219
|
+
}
|
|
220
|
+
const result = new Uint8Array(totalLength)
|
|
221
|
+
let offset = 0
|
|
222
|
+
for (const chunk of chunks) {
|
|
223
|
+
result.set(chunk, offset)
|
|
224
|
+
offset += chunk.byteLength
|
|
225
|
+
}
|
|
226
|
+
return result
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// --- MediaTransformer ---
|
|
230
|
+
|
|
231
|
+
class MediaTransformer {
|
|
232
|
+
private streamPromise: Promise<Uint8Array>
|
|
233
|
+
private transforms: MediaTransformOptions[] = []
|
|
234
|
+
|
|
235
|
+
constructor(stream: ReadableStream<Uint8Array>) {
|
|
236
|
+
this.streamPromise = readStream(stream)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
transform(options: MediaTransformOptions): MediaTransformer {
|
|
240
|
+
this.transforms.push(options)
|
|
241
|
+
return this
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
output(opts: MediaOutputOptions): MediaOutputResult {
|
|
245
|
+
const transforms = this.transforms
|
|
246
|
+
const streamPromise = this.streamPromise
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
async response(): Promise<Response> {
|
|
250
|
+
if (!(await hasFfmpeg())) {
|
|
251
|
+
warnFfmpegMissing()
|
|
252
|
+
const buf = await streamPromise
|
|
253
|
+
return new Response(buf, {
|
|
254
|
+
headers: { 'content-type': 'application/octet-stream' },
|
|
255
|
+
})
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const { contentType } = resolveContentType(opts.mode, opts.format)
|
|
259
|
+
|
|
260
|
+
let modeArgs: string[]
|
|
261
|
+
switch (opts.mode) {
|
|
262
|
+
case 'video':
|
|
263
|
+
modeArgs = buildVideoArgs(transforms, opts)
|
|
264
|
+
break
|
|
265
|
+
case 'frame':
|
|
266
|
+
modeArgs = buildFrameArgs(transforms, opts)
|
|
267
|
+
break
|
|
268
|
+
case 'spritesheet':
|
|
269
|
+
modeArgs = buildSpritesheetArgs(transforms, opts)
|
|
270
|
+
break
|
|
271
|
+
case 'audio':
|
|
272
|
+
modeArgs = buildAudioArgs(transforms, opts)
|
|
273
|
+
break
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const inputBuf = await streamPromise
|
|
277
|
+
|
|
278
|
+
// Use temp files — MP4 moov atom is often at EOF, requiring seek (stdin pipes can't seek)
|
|
279
|
+
const { ffFormat } = resolveContentType(opts.mode, opts.format)
|
|
280
|
+
const id = randomUUID()
|
|
281
|
+
const inputPath = join(tmpdir(), `lopata-media-in-${id}`)
|
|
282
|
+
const outputPath = join(tmpdir(), `lopata-media-out-${id}.${ffFormat}`)
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
await Bun.write(inputPath, inputBuf)
|
|
286
|
+
|
|
287
|
+
const proc = Bun.spawn(
|
|
288
|
+
['ffmpeg', '-hide_banner', '-loglevel', 'error', '-y', '-i', inputPath, ...modeArgs, outputPath],
|
|
289
|
+
{ stdout: 'pipe', stderr: 'pipe' },
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
const [stderrBuf, exitCode] = await Promise.all([
|
|
293
|
+
new Response(proc.stderr).text(),
|
|
294
|
+
proc.exited,
|
|
295
|
+
])
|
|
296
|
+
|
|
297
|
+
if (exitCode !== 0) {
|
|
298
|
+
throw new Error(`[lopata] ffmpeg failed (exit ${exitCode}): ${stderrBuf.trim()}`)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const output = await Bun.file(outputPath).arrayBuffer()
|
|
302
|
+
return new Response(output, {
|
|
303
|
+
headers: { 'content-type': contentType },
|
|
304
|
+
})
|
|
305
|
+
} finally {
|
|
306
|
+
unlink(inputPath).catch(() => {})
|
|
307
|
+
unlink(outputPath).catch(() => {})
|
|
308
|
+
}
|
|
309
|
+
},
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// --- MediaBinding ---
|
|
315
|
+
|
|
316
|
+
export class MediaBinding {
|
|
317
|
+
input(stream: ReadableStream<Uint8Array>): MediaTransformer {
|
|
318
|
+
return new MediaTransformer(stream)
|
|
319
|
+
}
|
|
320
|
+
}
|
package/src/cli/cache.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import type { CliContext } from './context'
|
|
2
|
-
import {
|
|
2
|
+
import { parseArgs } from './context'
|
|
3
3
|
|
|
4
4
|
export async function run(ctx: CliContext, args: string[]) {
|
|
5
5
|
const action = args[0]
|
|
6
6
|
|
|
7
7
|
switch (action) {
|
|
8
8
|
case 'list': {
|
|
9
|
+
parseArgs(args.slice(1), {})
|
|
9
10
|
const db = ctx.db()
|
|
10
11
|
const rows = db.query<{ cache_name: string; cnt: number }, []>(
|
|
11
12
|
'SELECT cache_name, COUNT(*) as cnt FROM cache_entries GROUP BY cache_name ORDER BY cache_name',
|
|
@@ -20,12 +21,14 @@ export async function run(ctx: CliContext, args: string[]) {
|
|
|
20
21
|
break
|
|
21
22
|
}
|
|
22
23
|
case 'purge': {
|
|
23
|
-
const
|
|
24
|
+
const { values } = parseArgs(args.slice(1), {
|
|
25
|
+
name: { type: 'string' },
|
|
26
|
+
})
|
|
24
27
|
const db = ctx.db()
|
|
25
28
|
let result: { changes: number }
|
|
26
|
-
if (
|
|
27
|
-
result = db.run('DELETE FROM cache_entries WHERE cache_name = ?', [
|
|
28
|
-
console.log(`Purged ${result.changes} entries from cache "${
|
|
29
|
+
if (values.name) {
|
|
30
|
+
result = db.run('DELETE FROM cache_entries WHERE cache_name = ?', [values.name])
|
|
31
|
+
console.log(`Purged ${result.changes} entries from cache "${values.name}"`)
|
|
29
32
|
} else {
|
|
30
33
|
result = db.run('DELETE FROM cache_entries')
|
|
31
34
|
console.log(`Purged ${result.changes} cache entries (all caches)`)
|