akarisub 0.1.0 → 0.2.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.
@@ -28,6 +28,27 @@ import { WebGL2Renderer, isWebGL2Supported } from './webgl2-renderer'
28
28
 
29
29
  type AnyGPURenderer = WebGPURenderer | WebGL2Renderer
30
30
 
31
+ const DEFAULT_RENDER_AHEAD = 0.008
32
+ const DEFAULT_PIPELINE_LATENCY_MS = DEFAULT_RENDER_AHEAD * 1000
33
+ const DEFAULT_WEBKIT_PIPELINE_LATENCY_MS = 16
34
+
35
+ const isLikelyWebKit = (): boolean => {
36
+ if (typeof navigator === 'undefined') return false
37
+
38
+ const userAgent = navigator.userAgent || ''
39
+ const vendor = navigator.vendor || ''
40
+ const isIOSWebKit = /\b(iPhone|iPad|iPod)\b/i.test(userAgent)
41
+
42
+ if (!/AppleWebKit/i.test(userAgent)) return false
43
+ if (isIOSWebKit) return true
44
+
45
+ if (/\b(Chrome|Chromium|Edg|OPR|SamsungBrowser|Firefox)\b/i.test(userAgent)) {
46
+ return false
47
+ }
48
+
49
+ return vendor.includes('Apple')
50
+ }
51
+
31
52
  /**
32
53
  * AkariSub - JavaScript ASS/SSA Subtitle Renderer
33
54
  *
@@ -74,6 +95,9 @@ export default class AkariSub extends EventTarget {
74
95
  private _ro?: ResizeObserver
75
96
  private _worker: Worker
76
97
  private _pendingDemandTimes: Array<{ mediaTime: number; width: number; height: number }> = []
98
+ private readonly _isLikelyWebKit: boolean
99
+ private _activeDemandStartedAt: number = 0
100
+ private _smoothedDemandLatencyMs: number
77
101
 
78
102
  // Bound methods for event listeners
79
103
  private _boundResize: () => void
@@ -90,6 +114,7 @@ export default class AkariSub extends EventTarget {
90
114
  // Cached render data to reduce allocations
91
115
  private _lastRenderWidth: number = 0
92
116
  private _lastRenderHeight: number = 0
117
+ private _gpuBitmapImages: Array<{ image: ImageBitmap; x: number; y: number }> = []
93
118
 
94
119
  // Public properties
95
120
  public timeOffset: number
@@ -114,6 +139,11 @@ export default class AkariSub extends EventTarget {
114
139
  this._init = resolve
115
140
  })
116
141
 
142
+ this._isLikelyWebKit = isLikelyWebKit()
143
+ this._smoothedDemandLatencyMs = this._isLikelyWebKit
144
+ ? DEFAULT_WEBKIT_PIPELINE_LATENCY_MS
145
+ : DEFAULT_PIPELINE_LATENCY_MS
146
+
117
147
  // Run feature tests
118
148
  const test = AkariSub._test()
119
149
 
@@ -121,7 +151,8 @@ export default class AkariSub extends EventTarget {
121
151
 
122
152
  this._onCanvasFallback = options.onCanvasFallback
123
153
 
124
- const canUseGPURenderer = !options.canvas && (isWebGPUSupported() || isWebGL2Supported())
154
+ const canUseGPURenderer = !this._isLikelyWebKit && !options.canvas && (isWebGPUSupported() || isWebGL2Supported())
155
+ const shouldUseAsyncRender = typeof createImageBitmap !== 'undefined' && (options.asyncRender ?? !this._isLikelyWebKit)
125
156
 
126
157
  // Don't support offscreen rendering on custom canvases
127
158
  this._offscreenRender =
@@ -153,7 +184,7 @@ export default class AkariSub extends EventTarget {
153
184
  if (canUseGPURenderer) {
154
185
  this._initGPURenderer()
155
186
  } else if (!this._offscreenRender) {
156
- this._ctx = this._canvas.getContext('2d')
187
+ this._ctx = this._canvas.getContext('2d', { alpha: true, desynchronized: true })
157
188
  }
158
189
 
159
190
  this._canvasctrl = this._offscreenRender
@@ -167,7 +198,7 @@ export default class AkariSub extends EventTarget {
167
198
  this.prescaleFactor = options.prescaleFactor || 1.0
168
199
  this.prescaleHeightLimit = options.prescaleHeightLimit || 1080
169
200
  this.maxRenderHeight = options.maxRenderHeight || 0
170
- this.renderAhead = options.renderAhead ?? 0
201
+ this.renderAhead = options.renderAhead ?? DEFAULT_RENDER_AHEAD
171
202
 
172
203
  // Bind methods
173
204
  this._boundResize = this.resize.bind(this)
@@ -195,7 +226,8 @@ export default class AkariSub extends EventTarget {
195
226
  this._worker.postMessage({
196
227
  target: 'init',
197
228
  wasmUrl: options.wasmUrl ?? 'akarisub-worker.wasm',
198
- asyncRender: typeof createImageBitmap !== 'undefined' && (options.asyncRender ?? true),
229
+ asyncRender: shouldUseAsyncRender,
230
+ fullTrackWarmup: options.fullTrackWarmup ?? false,
199
231
  onDemandRender: this._onDemandRender,
200
232
  initialTime: (this._video?.currentTime ?? 0) + this.timeOffset,
201
233
  width: this._canvasctrl.width || 0,
@@ -327,7 +359,7 @@ export default class AkariSub extends EventTarget {
327
359
 
328
360
  this._rendererType = 'canvas2d'
329
361
  if (!this._offscreenRender && !this._ctx) {
330
- this._ctx = this._canvas.getContext('2d')
362
+ this._ctx = this._canvas.getContext('2d', { alpha: true, desynchronized: true })
331
363
  }
332
364
  this.sendMessage('setAsyncRender', { value: false })
333
365
  this._onCanvasFallback?.()
@@ -743,6 +775,8 @@ export default class AkariSub extends EventTarget {
743
775
  }
744
776
 
745
777
  private _unbusy(): void {
778
+ this._observeDemandCompletion()
779
+
746
780
  if (this._pendingDemandTimes.length > 0) {
747
781
  if (this._pendingDemandTimes.length > 1) {
748
782
  const latestDemand = this._pendingDemandTimes[this._pendingDemandTimes.length - 1]
@@ -760,6 +794,31 @@ export default class AkariSub extends EventTarget {
760
794
  this.busy = false
761
795
  }
762
796
 
797
+ private _markDemandDispatched(): void {
798
+ if (!this._onDemandRender) return
799
+ this._activeDemandStartedAt = performance.now()
800
+ }
801
+
802
+ private _observeDemandCompletion(): void {
803
+ if (!this._onDemandRender || this._activeDemandStartedAt === 0) return
804
+
805
+ const elapsed = performance.now() - this._activeDemandStartedAt
806
+ this._activeDemandStartedAt = 0
807
+
808
+ if (!Number.isFinite(elapsed) || elapsed <= 0) return
809
+
810
+ this._smoothedDemandLatencyMs = this._smoothedDemandLatencyMs <= 0
811
+ ? elapsed
812
+ : this._smoothedDemandLatencyMs * 0.75 + elapsed * 0.25
813
+ }
814
+
815
+ private _getDemandPipelineLeadSeconds(now: number, metadata: VideoFrameCallbackMetadata): number {
816
+ const expectedDisplayTime = metadata.expectedDisplayTime ?? metadata.presentationTime ?? now
817
+ const displayLeadSeconds = Math.max(0, expectedDisplayTime - now) / 1000
818
+
819
+ return Math.max(0, this._smoothedDemandLatencyMs / 1000 - displayLeadSeconds)
820
+ }
821
+
763
822
  private _enqueueDemand(metadata: { mediaTime: number; width: number; height: number }): void {
764
823
  const queue = this._pendingDemandTimes
765
824
 
@@ -782,7 +841,8 @@ export default class AkariSub extends EventTarget {
782
841
 
783
842
  // Get the video's playback rate to correctly scale time offsets
784
843
  const playbackRate = this._video?.playbackRate ?? 1
785
- const renderTime = metadata.mediaTime + this.renderAhead * playbackRate
844
+ const pipelineLeadSeconds = this._getDemandPipelineLeadSeconds(now, metadata)
845
+ const renderTime = metadata.mediaTime + (pipelineLeadSeconds + this.renderAhead) * playbackRate
786
846
 
787
847
  const demandData = {
788
848
  mediaTime: renderTime,
@@ -813,6 +873,7 @@ export default class AkariSub extends EventTarget {
813
873
  this.resize()
814
874
  }
815
875
 
876
+ this._markDemandDispatched()
816
877
  this.sendMessage('demand', { time: metadata.mediaTime + this.timeOffset })
817
878
  }
818
879
 
@@ -822,9 +883,10 @@ export default class AkariSub extends EventTarget {
822
883
  this._canvas.remove()
823
884
  this._createCanvas()
824
885
  this._canvasctrl = this._canvas
825
- this._ctx = this._canvasctrl.getContext('2d')
886
+ this._ctx = this._canvasctrl.getContext('2d', { alpha: true, desynchronized: true })
826
887
  this.sendMessage('detachOffscreen')
827
888
  this.busy = false
889
+ this._activeDemandStartedAt = 0
828
890
  this._pendingDemandTimes.length = 0
829
891
  this.resize(0, 0, 0, 0, true)
830
892
  }
@@ -878,80 +940,87 @@ export default class AkariSub extends EventTarget {
878
940
  height: number
879
941
  colorSpace: SubtitleColorSpace
880
942
  }): void {
881
- this._unbusy()
943
+ try {
944
+ const dataWidth = data.width
945
+ const dataHeight = data.height
882
946
 
883
- const dataWidth = data.width
884
- const dataHeight = data.height
947
+ if (this.debug) {
948
+ data.times.IPCTime = Date.now() - (data.times.JSRenderTime || 0)
949
+ }
885
950
 
886
- if (this.debug) {
887
- data.times.IPCTime = Date.now() - (data.times.JSRenderTime || 0)
888
- }
951
+ // Check if canvas size changed
952
+ const sizeChanged = this._canvasctrl.width !== dataWidth || this._canvasctrl.height !== dataHeight
953
+ if (sizeChanged) {
954
+ this._canvasctrl.width = dataWidth
955
+ this._canvasctrl.height = dataHeight
956
+ this._lastRenderWidth = dataWidth
957
+ this._lastRenderHeight = dataHeight
958
+
959
+ // Update GPU renderer size if canvas size changed
960
+ if (this._gpuRenderer) {
961
+ this._gpuRenderer.updateSize(dataWidth, dataHeight)
962
+ }
889
963
 
890
- // Check if canvas size changed
891
- const sizeChanged = this._canvasctrl.width !== dataWidth || this._canvasctrl.height !== dataHeight
892
- if (sizeChanged) {
893
- this._canvasctrl.width = dataWidth
894
- this._canvasctrl.height = dataHeight
895
- this._lastRenderWidth = dataWidth
896
- this._lastRenderHeight = dataHeight
964
+ this._verifyColorSpace({ subtitleColorSpace: data.colorSpace })
965
+ }
897
966
 
898
- // Update GPU renderer size if canvas size changed
967
+ // Use GPU renderer (WebGPU or WebGL2) if available
899
968
  if (this._gpuRenderer) {
900
- this._gpuRenderer.updateSize(dataWidth, dataHeight)
969
+ this._renderGPU(data)
970
+ return
901
971
  }
902
972
 
903
- this._verifyColorSpace({ subtitleColorSpace: data.colorSpace })
904
- }
905
-
906
- // Use GPU renderer (WebGPU or WebGL2) if available
907
- if (this._gpuRenderer) {
908
- this._renderGPU(data)
909
- return
910
- }
973
+ if (!this._ctx) return
911
974
 
912
- if (!this._ctx) return
975
+ const ctx = this._ctx
976
+ const images = data.images
977
+ const imageCount = images.length
913
978
 
914
- const ctx = this._ctx
915
- const images = data.images
916
- const imageCount = images.length
979
+ ctx.clearRect(0, 0, dataWidth, dataHeight)
917
980
 
918
- ctx.clearRect(0, 0, dataWidth, dataHeight)
919
-
920
- if (data.asyncRender) {
921
- for (let i = 0; i < imageCount; i++) {
922
- const image = images[i]
923
- if (image.image) {
924
- ctx.drawImage(image.image as ImageBitmap, image.x, image.y)
925
- ;(image.image as ImageBitmap).close()
981
+ if (data.asyncRender) {
982
+ for (let i = 0; i < imageCount; i++) {
983
+ const image = images[i]
984
+ if (image.image) {
985
+ ctx.drawImage(image.image as ImageBitmap, image.x, image.y)
986
+ ;(image.image as ImageBitmap).close()
987
+ }
988
+ }
989
+ } else {
990
+ const hasAlphaBug = AkariSub._hasAlphaBug ?? false
991
+
992
+ for (let i = 0; i < imageCount; i++) {
993
+ const image = images[i]
994
+ if (image.image) {
995
+ const imgW = image.w
996
+ const imgH = image.h
997
+
998
+ const rawImage = image.image
999
+ const rawData = rawImage instanceof Uint8ClampedArray
1000
+ ? rawImage
1001
+ : rawImage instanceof Uint8Array
1002
+ ? new Uint8ClampedArray(rawImage.buffer, rawImage.byteOffset, rawImage.byteLength)
1003
+ : new Uint8ClampedArray(rawImage as ArrayBuffer)
1004
+ const fixedData = fixAlpha(rawData, hasAlphaBug)
1005
+ ctx.putImageData(new ImageData(fixedData as Uint8ClampedArray<ArrayBuffer>, imgW, imgH), image.x, image.y)
1006
+ }
926
1007
  }
927
1008
  }
928
- } else {
929
- const hasAlphaBug = AkariSub._hasAlphaBug ?? false
930
1009
 
931
- for (let i = 0; i < imageCount; i++) {
932
- const image = images[i]
933
- if (image.image) {
934
- const imgW = image.w
935
- const imgH = image.h
1010
+ if (this.debug) {
1011
+ data.times.JSRenderTime = Date.now() - (data.times.JSRenderTime || 0) - (data.times.IPCTime || 0)
1012
+ let total = 0
1013
+ const count = data.times.bitmaps || imageCount
1014
+ delete data.times.bitmaps
936
1015
 
937
- const rawData = new Uint8ClampedArray(image.image as ArrayBuffer)
938
- const fixedData = fixAlpha(rawData, hasAlphaBug)
939
- ctx.putImageData(new ImageData(fixedData as Uint8ClampedArray<ArrayBuffer>, imgW, imgH), image.x, image.y)
1016
+ for (const key in data.times) {
1017
+ total += (data.times as any)[key] || 0
940
1018
  }
941
- }
942
- }
943
-
944
- if (this.debug) {
945
- data.times.JSRenderTime = Date.now() - (data.times.JSRenderTime || 0) - (data.times.IPCTime || 0)
946
- let total = 0
947
- const count = data.times.bitmaps || imageCount
948
- delete data.times.bitmaps
949
1019
 
950
- for (const key in data.times) {
951
- total += (data.times as any)[key] || 0
1020
+ console.log('Bitmaps: ' + count + ' Total: ' + (total | 0) + 'ms', data.times)
952
1021
  }
953
-
954
- console.log('Bitmaps: ' + count + ' Total: ' + (total | 0) + 'ms', data.times)
1022
+ } finally {
1023
+ this._unbusy()
955
1024
  }
956
1025
  }
957
1026
 
@@ -966,13 +1035,21 @@ export default class AkariSub extends EventTarget {
966
1035
 
967
1036
  if (data.asyncRender) {
968
1037
  // For async render mode with ImageBitmaps
969
- const bitmapImages = data.images
970
- .filter((img) => img.image instanceof ImageBitmap)
971
- .map((img) => ({
972
- image: img.image as ImageBitmap,
973
- x: img.x,
974
- y: img.y
975
- }))
1038
+ const bitmapImages = this._gpuBitmapImages
1039
+ let bitmapCount = 0
1040
+
1041
+ for (let i = 0; i < data.images.length; i++) {
1042
+ const img = data.images[i]
1043
+ if (!(img.image instanceof ImageBitmap)) continue
1044
+
1045
+ const target = bitmapImages[bitmapCount] || (bitmapImages[bitmapCount] = { image: img.image, x: 0, y: 0 })
1046
+ target.image = img.image
1047
+ target.x = img.x
1048
+ target.y = img.y
1049
+ bitmapCount++
1050
+ }
1051
+
1052
+ bitmapImages.length = bitmapCount
976
1053
 
977
1054
  renderer.renderBitmaps(bitmapImages, this._canvasctrl.width, this._canvasctrl.height)
978
1055
 
package/src/ts/types.ts CHANGED
@@ -186,6 +186,8 @@ export interface AkariSubOptions {
186
186
  onCanvasFallback?: () => void
187
187
  /** Additional time in seconds to render subtitles ahead for pipeline latency compensation (default: 0.008) */
188
188
  renderAhead?: number
189
+ /** Pre-render early track windows after load to warm libass caches (default: false) */
190
+ fullTrackWarmup?: boolean
189
191
  }
190
192
 
191
193
  // =============================================================================
@@ -211,7 +213,7 @@ export interface RenderImage {
211
213
  y: number
212
214
  w: number
213
215
  h: number
214
- image: ImageBitmap | ArrayBuffer | number
216
+ image: ImageBitmap | ArrayBuffer | Uint8Array | Uint8ClampedArray | number
215
217
  }
216
218
 
217
219
  /** Render timing debug info */
@@ -255,6 +257,7 @@ export interface WorkerInitMessage {
255
257
  target: 'init'
256
258
  wasmUrl: string
257
259
  asyncRender: boolean
260
+ fullTrackWarmup: boolean
258
261
  onDemandRender: boolean
259
262
  initialTime: number
260
263
  width: number
@@ -363,11 +366,6 @@ export interface AkariSubModule extends EmscriptenModule {
363
366
  _akarisub_remove_style: (handle: number, index: number) => void
364
367
  _akarisub_style_override_index: (handle: number, index: number) => void
365
368
  _akarisub_disable_style_override: (handle: number) => void
366
- _akarisub_render_blend: (handle: number, time: number, force: number) => number
367
- _akarisub_render_image: (handle: number, time: number, force: number) => number
368
- _akarisub_get_changed: (handle: number) => number
369
- _akarisub_get_count: (handle: number) => number
370
- _akarisub_get_time: (handle: number) => number
371
369
  _akarisub_get_track_color_space: (handle: number) => number
372
370
  _akarisub_event_get_int: (handle: number, index: number, field: number) => number
373
371
  _akarisub_event_set_int: (handle: number, index: number, field: number, value: number) => void
@@ -377,13 +375,6 @@ export interface AkariSubModule extends EmscriptenModule {
377
375
  _akarisub_style_set_num: (handle: number, index: number, field: number, value: number) => void
378
376
  _akarisub_style_get_str: (handle: number, index: number, field: number) => number
379
377
  _akarisub_style_set_str: (handle: number, index: number, field: number, valuePtr: number) => void
380
- _akarisub_render_result_x: (resultPtr: number) => number
381
- _akarisub_render_result_y: (resultPtr: number) => number
382
- _akarisub_render_result_w: (resultPtr: number) => number
383
- _akarisub_render_result_h: (resultPtr: number) => number
384
- _akarisub_render_result_image: (resultPtr: number) => number
385
- _akarisub_render_result_next: (resultPtr: number) => number
386
- _akarisub_render_result_collect: (resultPtr: number, outPtr: number, maxItems: number) => number
387
378
  _akarisub_render_blend_collect: (handle: number, time: number, force: number, outPtr: number, maxItems: number) => number
388
379
  _akarisub_render_image_collect: (handle: number, time: number, force: number, outPtr: number, maxItems: number) => number
389
380
  FS_createPath: (parent: string, path: string, canRead: boolean, canWrite: boolean) => void
@@ -75,6 +75,10 @@ export function isWebGL2Supported(): boolean {
75
75
  }
76
76
  }
77
77
 
78
+ function isArrayBufferView(value: unknown): value is Uint8Array | Uint8ClampedArray {
79
+ return value instanceof Uint8Array || value instanceof Uint8ClampedArray
80
+ }
81
+
78
82
  function compileShader(gl: WebGL2RenderingContext, type: number, source: string): WebGLShader {
79
83
  const shader = gl.createShader(type)!
80
84
  gl.shaderSource(shader, source)
@@ -362,8 +366,9 @@ export class WebGL2Renderer {
362
366
  const imgData = img.image
363
367
  if (imgData instanceof ImageBitmap) {
364
368
  gl.texSubImage3D(gl.TEXTURE_2D_ARRAY, 0, 0, 0, count, w, h, 1, gl.RGBA, gl.UNSIGNED_BYTE, imgData)
365
- } else if (imgData instanceof ArrayBuffer) {
366
- gl.texSubImage3D(gl.TEXTURE_2D_ARRAY, 0, 0, 0, count, w, h, 1, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array(imgData))
369
+ } else if (imgData instanceof ArrayBuffer || isArrayBufferView(imgData)) {
370
+ const uploadData = imgData instanceof ArrayBuffer ? new Uint8Array(imgData) : imgData
371
+ gl.texSubImage3D(gl.TEXTURE_2D_ARRAY, 0, 0, 0, count, w, h, 1, gl.RGBA, gl.UNSIGNED_BYTE, uploadData)
367
372
  }
368
373
  const off = count << 3
369
374
  instanceData[off] = img.x
@@ -114,6 +114,14 @@ export function isWebGPUSupported(): boolean {
114
114
  return typeof navigator !== 'undefined' && 'gpu' in navigator
115
115
  }
116
116
 
117
+ function toUint8View(data: ArrayBuffer | Uint8Array | Uint8ClampedArray): Uint8Array {
118
+ if (data instanceof ArrayBuffer) {
119
+ return new Uint8Array(data)
120
+ }
121
+
122
+ return new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
123
+ }
124
+
117
125
  /**
118
126
  * High-performance WebGPU subtitle renderer for AkariSub.
119
127
  */
@@ -577,7 +585,7 @@ export class WebGPURenderer {
577
585
  { texture: textureArray, origin: [0, 0, texIndex], premultipliedAlpha: false },
578
586
  { width: w, height: h }
579
587
  )
580
- } else if (imgData instanceof ArrayBuffer) {
588
+ } else if (imgData instanceof ArrayBuffer || imgData instanceof Uint8Array || imgData instanceof Uint8ClampedArray) {
581
589
  this.uploadTextureData(texIndex, imgData, w, h, isBGRA)
582
590
  }
583
591
 
@@ -626,17 +634,17 @@ export class WebGPURenderer {
626
634
 
627
635
  private uploadTextureData(
628
636
  layerIndex: number,
629
- rgbaBuffer: ArrayBuffer,
637
+ rgbaBuffer: ArrayBuffer | Uint8Array | Uint8ClampedArray,
630
638
  width: number,
631
639
  height: number,
632
640
  swapRB: boolean
633
641
  ): void {
634
642
  const size = width * height * 4
643
+ const src = toUint8View(rgbaBuffer)
635
644
 
636
645
  if (swapRB) {
637
646
  // Use reusable conversion buffer
638
647
  const uploadData = this.ensureConversionBuffer(size)
639
- const src = new Uint8Array(rgbaBuffer)
640
648
 
641
649
  // Unrolled loop for better performance
642
650
  for (let j = 0; j < size; j += 4) {
@@ -655,7 +663,7 @@ export class WebGPURenderer {
655
663
  } else {
656
664
  this.device!.queue.writeTexture(
657
665
  { texture: this.textureArray!, origin: [0, 0, layerIndex] },
658
- rgbaBuffer,
666
+ src,
659
667
  { bytesPerRow: width * 4 },
660
668
  { width, height }
661
669
  )