@gjsify/video 0.1.15 → 0.3.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.
@@ -1,92 +1,130 @@
1
- // VideoBridge GTK container for GJS Gtk.Box wrapping Gtk.Picture + gtk4paintablesink.
2
- // Bridges HTMLVideoElement to GStreamer video rendering via Gdk.Paintable.
1
+ // VideoBridge GTK container that bridges HTMLVideoElement to GStreamer video.
2
+ // Gtk.Box Gtk.Overlay (Gtk.Picture + optional control bar).
3
+ // Controls float over the video (valign=END), start hidden, reveal on mouse
4
+ // motion, and auto-hide after 2s of inactivity like browser video players.
3
5
  //
4
- // Reference: refs/showtime/showtime/play.py (gtk4paintablesink + optional glsinkbin)
5
- // Pattern follows packages/dom/canvas2d/src/canvas-drawing-area.ts (Canvas2DBridge)
6
+ // Reference: refs/showtime/showtime/play.py (gtk4paintablesink + glsinkbin).
7
+ // Pattern follows packages/dom/canvas2d/src/canvas-drawing-area.ts.
6
8
 
7
9
  import GObject from 'gi://GObject';
8
10
  import GLib from 'gi://GLib?version=2.0';
9
11
  import Gtk from 'gi://Gtk?version=4.0';
10
- import { HTMLVideoElement } from '@gjsify/dom-elements';
12
+ import type Gst from 'gi://Gst?version=1.0';
11
13
  import { attachEventControllers } from '@gjsify/event-bridge';
12
14
  import { Event } from '@gjsify/dom-events';
13
15
  import { BridgeEnvironment } from '@gjsify/bridge-types';
14
16
  import type { BridgeWindowHost } from '@gjsify/bridge-types';
15
17
 
18
+ import { HTMLVideoElement } from '@gjsify/dom-elements';
16
19
  import { buildMediaStreamPipeline, buildUriPipeline } from './pipeline-builder.js';
17
- import { Gst } from './gst-init.js';
20
+ import { Gst as GstRuntime } from './gst-init.js';
18
21
 
19
22
  type VideoReadyCallback = (video: globalThis.HTMLVideoElement) => void;
20
23
 
24
+ type GstSourceTrack = MediaStreamTrack & {
25
+ _gstSource?: unknown;
26
+ _gstPipeline?: unknown;
27
+ _gstTee?: unknown;
28
+ };
29
+
30
+ const PLAY_ICON = 'media-playback-start-symbolic';
31
+ const PAUSE_ICON = 'media-playback-pause-symbolic';
32
+ const AUTO_HIDE_SECONDS = 2;
33
+ const POSITION_TICK_MS = 200;
34
+
35
+ function formatTime(seconds: number): string {
36
+ if (!isFinite(seconds) || isNaN(seconds)) return '--:--';
37
+ const m = Math.floor(seconds / 60);
38
+ const s = Math.floor(seconds % 60);
39
+ return `${m}:${s.toString().padStart(2, '0')}`;
40
+ }
41
+
21
42
  /**
22
- * A `Gtk.Box` subclass that hosts a `Gtk.Picture` for video rendering:
23
- * - Creates an `HTMLVideoElement` on construction
24
- * - Renders video via GStreamer `gtk4paintablesink` `Gdk.Paintable` `Gtk.Picture`
25
- * - Supports `video.srcObject = mediaStream` (from getUserMedia/WebRTC)
26
- * - Supports `video.src = "file:///..."` (URI playback via playbin)
27
- * - Fires `onReady()` callbacks with the HTMLVideoElement
28
- * - `installGlobals()` sets `globalThis.HTMLVideoElement`
43
+ * A `Gtk.Box` subclass that hosts a `Gtk.Picture` for video rendering.
44
+ *
45
+ * - Owns an `HTMLVideoElement` whose DOM API is wired to GStreamer
46
+ * - Renders video via `gtk4paintablesink` `Gdk.Paintable` → `Gtk.Picture`
47
+ * - Supports `video.srcObject = mediaStream` (getUserMedia / WebRTC)
48
+ * - Supports `video.src = 'file://…'` or HTTP URL (URI playback via playbin)
49
+ * - `onReady(cb)` fires with the HTMLVideoElement when the widget realizes
50
+ * - `showControls(true)` appends a play/pause + seek + time + volume bar
29
51
  *
30
- * Usage:
31
52
  * ```ts
32
53
  * const bridge = new VideoBridge();
33
- * bridge.installGlobals();
34
- * bridge.onReady((video) => {
35
- * video.srcObject = mediaStream; // from getUserMedia
36
- * });
54
+ * bridge.showControls(true);
55
+ * bridge.onReady((video) => { video.src = 'https://example.com/video.mp4'; });
37
56
  * window.set_child(bridge);
38
57
  * ```
39
58
  */
40
59
  export const VideoBridge = GObject.registerClass(
41
60
  { GTypeName: 'GjsifyVideoBridge' },
42
61
  class VideoBridge extends Gtk.Box {
62
+ // GObject.registerClass produces an anonymous class, so TS requires that fields
63
+ // referenced from the InstanceType alias be non-private. Prefixed with `_` as the
64
+ // convention for "implementation detail, not meant for external use".
65
+ _overlay: Gtk.Overlay;
43
66
  _picture: Gtk.Picture;
44
67
  _video: HTMLVideoElement;
45
- _pipeline: any | null = null; // Gst.Pipeline
46
- _readyCallbacks: VideoReadyCallback[] = [];
47
- _resizeCallbacks: ((w: number, h: number) => void)[] = [];
48
68
  _environment: BridgeEnvironment;
49
69
  _timeOrigin: number = GLib.get_monotonic_time();
70
+ _pipeline: Gst.Pipeline | null = null;
71
+ // Bus associated with _pipeline; stored so `_destroyPipeline` can
72
+ // disconnect the handlers + `remove_signal_watch` before the pipeline
73
+ // is nulled. Without cleanup, changing `video.src` repeatedly
74
+ // accumulates handler connections on each pipeline's bus.
75
+ _pipelineBus: Gst.Bus | null = null;
76
+ _pipelineBusHandlers: number[] = [];
77
+ _readyCallbacks: VideoReadyCallback[] = [];
78
+ _resizeCallbacks: ((w: number, h: number) => void)[] = [];
50
79
  _ready = false;
51
80
 
81
+ // Control bar + its per-tick change-detection state (null when
82
+ // showControls(false) or never called). Keeping _lastSeekValue /
83
+ // _lastTimeText on the same object lets them live and die with the
84
+ // controls; no separate reset needed.
85
+ _controls: {
86
+ bar: Gtk.Box;
87
+ playBtn: Gtk.Button;
88
+ seekAdj: Gtk.Adjustment;
89
+ seekScale: Gtk.Scale;
90
+ timeLabel: Gtk.Label;
91
+ volumeBtn: Gtk.VolumeButton;
92
+ lastSeekValue: number;
93
+ lastTimeText: string;
94
+ } | null = null;
95
+ _positionTimerId: number | null = null;
96
+ // change-value on seekScale fires on user interaction only; the guard prevents
97
+ // programmatic set_value from bouncing through the signal on some compositors.
98
+ _updatingFromTimer = false;
99
+ // Auto-hide: a single re-armed GLib source. Mouse motion re-starts the
100
+ // 2s timer by removing and re-adding, so we never pile up pending sources.
101
+ _hideTimerId: number | null = null;
102
+
52
103
  constructor(params?: Partial<Gtk.Box.ConstructorProps>) {
53
- super({
54
- ...params,
55
- orientation: Gtk.Orientation.VERTICAL,
56
- });
104
+ super({ ...params, orientation: Gtk.Orientation.VERTICAL });
105
+
106
+ this._overlay = new Gtk.Overlay({ hexpand: true, vexpand: true });
107
+ this.append(this._overlay);
57
108
 
58
- this._picture = new Gtk.Picture();
59
- this._picture.set_hexpand(true);
60
- this._picture.set_vexpand(true);
61
- this.append(this._picture);
109
+ this._picture = new Gtk.Picture({ hexpand: true, vexpand: true });
110
+ this._overlay.set_child(this._picture);
62
111
 
63
- // Create the DOM element
64
112
  this._video = new HTMLVideoElement();
65
113
 
66
- // Set up the bridge environment
67
114
  const host: BridgeWindowHost = {
68
115
  performanceNow: () => (GLib.get_monotonic_time() - this._timeOrigin) / 1000,
69
116
  getWidth: () => this.get_allocated_width(),
70
117
  getHeight: () => this.get_allocated_height(),
71
- getDevicePixelRatio: () => {
72
- const display = this.get_display();
73
- const surface = this.get_native()?.get_surface();
74
- if (surface) return surface.get_scale_factor();
75
- if (display) return (display as any).get_scale?.() ?? 1;
76
- return 1;
77
- },
118
+ getDevicePixelRatio: () => this.get_native()?.get_surface()?.get_scale_factor() ?? 1,
78
119
  };
79
120
  this._environment = new BridgeEnvironment(host);
80
121
  this._environment.document.body.appendChild(this._video);
81
122
 
82
- // Bridge GTK events → DOM events on the video element
83
123
  attachEventControllers(this, () => this._video);
84
124
 
85
- // Listen for srcObject / src changes on the HTMLVideoElement
86
125
  this._video.addEventListener('srcobjectchange', () => this._onSrcObjectChange());
87
126
  this._video.addEventListener('srcchange', () => this._onSrcChange());
88
127
 
89
- // Fire ready callbacks once the widget is realized
90
128
  this.connect('realize', () => {
91
129
  if (this._ready) return;
92
130
  this._ready = true;
@@ -96,52 +134,32 @@ export const VideoBridge = GObject.registerClass(
96
134
  this._readyCallbacks = [];
97
135
  });
98
136
 
99
- // Handle resize — Gtk.Box has no 'resize' signal (unlike DrawingArea/GLArea).
100
- // Use notify on allocation width/height instead.
101
137
  let lastWidth = 0;
102
138
  let lastHeight = 0;
103
139
  const checkResize = () => {
104
140
  const width = this.get_allocated_width();
105
141
  const height = this.get_allocated_height();
106
- if (width !== lastWidth || height !== lastHeight) {
107
- lastWidth = width;
108
- lastHeight = height;
109
- this._video.dispatchEvent(new Event('resize'));
110
- for (const cb of this._resizeCallbacks) {
111
- cb(width, height);
112
- }
113
- }
142
+ if (width === lastWidth && height === lastHeight) return;
143
+ lastWidth = width;
144
+ lastHeight = height;
145
+ this._video.dispatchEvent(new Event('resize'));
146
+ for (const cb of this._resizeCallbacks) cb(width, height);
114
147
  };
115
148
  this.connect('notify::width-request', checkResize);
116
149
  this.connect('notify::height-request', checkResize);
117
- // Also check after the widget is mapped and sized
118
150
  this.connect('map', checkResize);
119
151
 
120
- // Cleanup on unrealize
121
152
  this.connect('unrealize', () => {
122
153
  this._destroyPipeline();
154
+ this._stopPositionTimer();
155
+ this._resizeCallbacks = [];
123
156
  });
124
157
  }
125
158
 
126
- /** The HTMLVideoElement backing this bridge. */
127
- get element(): HTMLVideoElement {
128
- return this._video;
129
- }
130
-
131
- /** Alias for element — matches browser naming. */
132
- get videoElement(): HTMLVideoElement {
133
- return this._video;
134
- }
135
-
136
- /** The isolated browser environment for this bridge. */
137
- get environment(): BridgeEnvironment {
138
- return this._environment;
139
- }
159
+ get element(): HTMLVideoElement { return this._video; }
160
+ get videoElement(): HTMLVideoElement { return this._video; }
161
+ get environment(): BridgeEnvironment { return this._environment; }
140
162
 
141
- /**
142
- * Register a callback to be invoked once the video element is ready.
143
- * If already ready, the callback fires synchronously.
144
- */
145
163
  onReady(cb: VideoReadyCallback): void {
146
164
  if (this._ready) {
147
165
  cb(this._video as unknown as globalThis.HTMLVideoElement);
@@ -150,34 +168,160 @@ export const VideoBridge = GObject.registerClass(
150
168
  this._readyCallbacks.push(cb);
151
169
  }
152
170
 
153
- /** Register a callback invoked whenever the widget is resized. */
154
171
  onResize(cb: (width: number, height: number) => void): void {
155
172
  this._resizeCallbacks.push(cb);
156
173
  }
157
174
 
158
- /** Sets browser globals for video support. */
159
175
  installGlobals(): void {
160
- (globalThis as any).HTMLVideoElement = HTMLVideoElement;
176
+ (globalThis as { HTMLVideoElement?: unknown }).HTMLVideoElement = HTMLVideoElement;
161
177
 
162
- const timeOrigin = this._timeOrigin;
163
- if (typeof (globalThis as any).performance === 'undefined') {
164
- (globalThis as any).performance = {
178
+ if (typeof (globalThis as { performance?: unknown }).performance === 'undefined') {
179
+ const timeOrigin = this._timeOrigin;
180
+ (globalThis as { performance?: unknown }).performance = {
165
181
  now: () => (GLib.get_monotonic_time() - timeOrigin) / 1000,
166
182
  timeOrigin: Date.now(),
167
183
  };
168
184
  }
169
185
  }
170
186
 
171
- /** @internal Handle srcObject change (MediaStream) */
187
+ /**
188
+ * Show or hide the built-in play/pause + seek + time + volume control bar.
189
+ * Controls auto-hide after 2 seconds of mouse inactivity.
190
+ */
191
+ showControls(show = true): void {
192
+ if (show && !this._controls) {
193
+ this._controls = this._buildControlBar();
194
+ const { bar } = this._controls;
195
+ bar.set_halign(Gtk.Align.FILL);
196
+ bar.set_valign(Gtk.Align.END);
197
+ bar.set_visible(false);
198
+ this._overlay.add_overlay(bar);
199
+ this._startPositionTimer();
200
+ this._setupAutoHideMotion(bar);
201
+ } else if (!show && this._controls) {
202
+ this._overlay.remove_overlay(this._controls.bar);
203
+ this._controls = null;
204
+ this._stopPositionTimer();
205
+ if (this._hideTimerId !== null) {
206
+ GLib.Source.remove(this._hideTimerId);
207
+ this._hideTimerId = null;
208
+ }
209
+ }
210
+ }
211
+
212
+ _setupAutoHideMotion(controlBar: Gtk.Box): void {
213
+ for (const widget of [this, controlBar] as const) {
214
+ const motion = new Gtk.EventControllerMotion();
215
+ motion.connect('motion', () => this._revealControls());
216
+ motion.connect('enter', () => this._revealControls());
217
+ widget.add_controller(motion);
218
+ }
219
+ }
220
+
221
+ _revealControls(): void {
222
+ if (!this._controls) return;
223
+ this._controls.bar.set_visible(true);
224
+ if (this._hideTimerId !== null) GLib.Source.remove(this._hideTimerId);
225
+ this._hideTimerId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, AUTO_HIDE_SECONDS, () => {
226
+ this._hideTimerId = null;
227
+ this._controls?.bar.set_visible(false);
228
+ return GLib.SOURCE_REMOVE;
229
+ });
230
+ }
231
+
232
+ _buildControlBar(): NonNullable<VideoBridge['_controls']> {
233
+ const bar = new Gtk.Box({
234
+ orientation: Gtk.Orientation.HORIZONTAL,
235
+ spacing: 6,
236
+ margin_start: 6,
237
+ margin_end: 6,
238
+ margin_top: 4,
239
+ margin_bottom: 4,
240
+ });
241
+ // OSD class: semi-transparent dark background for legibility over video.
242
+ bar.add_css_class('osd');
243
+
244
+ const playBtn = new Gtk.Button({ icon_name: PAUSE_ICON });
245
+ playBtn.connect('clicked', () => {
246
+ if (this._video.paused) {
247
+ this._video.play();
248
+ playBtn.set_icon_name(PAUSE_ICON);
249
+ } else {
250
+ this._video.pause();
251
+ playBtn.set_icon_name(PLAY_ICON);
252
+ }
253
+ });
254
+ bar.append(playBtn);
255
+
256
+ const seekAdj = new Gtk.Adjustment({ lower: 0, upper: 1, step_increment: 1, page_increment: 10 });
257
+ const seekScale = Gtk.Scale.new(Gtk.Orientation.HORIZONTAL, seekAdj);
258
+ seekScale.set_hexpand(true);
259
+ seekScale.set_draw_value(false);
260
+ seekScale.connect('change-value', (_scale, _scroll, value) => {
261
+ if (!this._updatingFromTimer && isFinite(value)) {
262
+ this._video.currentTime = value;
263
+ }
264
+ return false;
265
+ });
266
+ bar.append(seekScale);
267
+
268
+ const timeLabel = new Gtk.Label({ label: '--:-- / --:--', use_markup: false });
269
+ bar.append(timeLabel);
270
+
271
+ const volumeBtn = new Gtk.VolumeButton({ value: 1.0 });
272
+ volumeBtn.connect('value-changed', (_btn, value) => { this._video.volume = value; });
273
+ bar.append(volumeBtn);
274
+
275
+ return { bar, playBtn, seekAdj, seekScale, timeLabel, volumeBtn, lastSeekValue: NaN, lastTimeText: '' };
276
+ }
277
+
278
+ _startPositionTimer(): void {
279
+ if (this._positionTimerId !== null) return;
280
+ this._positionTimerId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, POSITION_TICK_MS, () => {
281
+ const controls = this._controls;
282
+ if (!controls) return GLib.SOURCE_REMOVE;
283
+
284
+ const cur = this._video.currentTime;
285
+ const dur = this._video.duration;
286
+
287
+ if (isFinite(dur) && dur > 0) {
288
+ if (controls.seekAdj.upper !== dur) controls.seekAdj.upper = dur;
289
+ if (cur !== controls.lastSeekValue) {
290
+ this._updatingFromTimer = true;
291
+ controls.seekAdj.set_value(cur);
292
+ this._updatingFromTimer = false;
293
+ controls.lastSeekValue = cur;
294
+ }
295
+ }
296
+
297
+ const text = `${formatTime(cur)} / ${formatTime(dur)}`;
298
+ if (text !== controls.lastTimeText) {
299
+ controls.timeLabel.set_label(text);
300
+ controls.lastTimeText = text;
301
+ }
302
+
303
+ const icon = this._video.paused ? PLAY_ICON : PAUSE_ICON;
304
+ if (controls.playBtn.get_icon_name() !== icon) controls.playBtn.set_icon_name(icon);
305
+
306
+ return GLib.SOURCE_CONTINUE;
307
+ });
308
+ }
309
+
310
+ _stopPositionTimer(): void {
311
+ if (this._positionTimerId !== null) {
312
+ GLib.Source.remove(this._positionTimerId);
313
+ this._positionTimerId = null;
314
+ }
315
+ }
316
+
172
317
  _onSrcObjectChange(): void {
173
318
  this._destroyPipeline();
174
319
 
175
320
  const stream = this._video.srcObject;
176
321
  if (!stream) return;
177
322
 
178
- // Find the first video track with a GStreamer source
179
- const videoTracks = stream.getVideoTracks?.() ?? [];
180
- const track = videoTracks.find((t: any) => t._gstSource != null);
323
+ const tracks = stream.getVideoTracks?.() as GstSourceTrack[] | undefined ?? [];
324
+ const track = tracks.find((t) => t._gstSource != null);
181
325
  if (!track?._gstSource) {
182
326
  console.warn('VideoBridge: MediaStream has no video track with GStreamer source');
183
327
  return;
@@ -185,57 +329,77 @@ export const VideoBridge = GObject.registerClass(
185
329
 
186
330
  try {
187
331
  const { pipeline, paintable, tee } = buildMediaStreamPipeline(track._gstSource, track._gstPipeline);
188
- this._pipeline = pipeline;
189
- this._picture.set_paintable(paintable);
190
-
191
- // Update the track's pipeline and tee references so that other
192
- // consumers (e.g. RTCPeerConnection.addTrack) can find the source
193
- // in this pipeline and request tee branches without cross-pipeline issues.
332
+ this._attachPipeline(pipeline, paintable);
194
333
  track._gstPipeline = pipeline;
195
334
  track._gstTee = tee;
196
-
197
- pipeline.set_state(Gst.State.PLAYING);
198
-
199
- this._video.readyState = 4; // HAVE_ENOUGH_DATA
200
- this._video.paused = false;
201
- this._video.dispatchEvent(new Event('loadedmetadata'));
202
- this._video.dispatchEvent(new Event('canplay'));
203
- this._video.dispatchEvent(new Event('playing'));
204
335
  } catch (err) {
205
336
  console.error('VideoBridge: Failed to build MediaStream pipeline:', err);
206
337
  }
207
338
  }
208
339
 
209
- /** @internal Handle src change (URI) */
210
340
  _onSrcChange(): void {
211
341
  this._destroyPipeline();
212
-
213
- const src = this._video.src;
214
- if (!src) return;
342
+ if (!this._video.src) return;
215
343
 
216
344
  try {
217
- const { pipeline, paintable } = buildUriPipeline(src);
218
- this._pipeline = pipeline;
219
- this._picture.set_paintable(paintable);
220
- pipeline.set_state(Gst.State.PLAYING);
221
-
222
- this._video.readyState = 4;
223
- this._video.paused = false;
224
- this._video.dispatchEvent(new Event('loadedmetadata'));
225
- this._video.dispatchEvent(new Event('canplay'));
226
- this._video.dispatchEvent(new Event('playing'));
345
+ const { pipeline, paintable } = buildUriPipeline(this._video.src);
346
+ this._attachPipeline(pipeline, paintable);
227
347
  } catch (err) {
228
348
  console.error('VideoBridge: Failed to build URI pipeline:', err);
229
349
  }
230
350
  }
231
351
 
232
- /** @internal Tear down the GStreamer pipeline */
352
+ _attachPipeline(pipeline: Gst.Pipeline, paintable: Parameters<Gtk.Picture['set_paintable']>[0]): void {
353
+ this._pipeline = pipeline;
354
+ this._video._pipeline = pipeline;
355
+ this._picture.set_paintable(paintable);
356
+
357
+ // Bus watch surfaces pipeline errors and warnings (missing decoder,
358
+ // http source failure, missing plugin, etc.). Without this, playbin
359
+ // can fail to preroll and sit silently in READY forever. Handler
360
+ // ids + bus are stashed on the instance so `_destroyPipeline` can
361
+ // disconnect them — otherwise each pipeline swap (new video.src)
362
+ // leaks a set of signal handlers on the freed bus.
363
+ const bus = pipeline.get_bus();
364
+ if (bus) {
365
+ bus.add_signal_watch();
366
+ this._pipelineBus = bus;
367
+ this._pipelineBusHandlers = [
368
+ bus.connect('message::error', (_b, msg) => {
369
+ const [err, debug] = msg.parse_error();
370
+ console.error(`VideoBridge pipeline error: ${err?.message ?? 'unknown'} (${debug ?? ''})`);
371
+ this._video.dispatchEvent(new Event('error'));
372
+ }),
373
+ bus.connect('message::warning', (_b, msg) => {
374
+ const [err, debug] = msg.parse_warning();
375
+ console.warn(`VideoBridge pipeline warning: ${err?.message ?? 'unknown'} (${debug ?? ''})`);
376
+ }),
377
+ ];
378
+ }
379
+
380
+ const ret = pipeline.set_state(GstRuntime.State.PLAYING);
381
+ if (ret === GstRuntime.StateChangeReturn.FAILURE) {
382
+ console.error('VideoBridge: pipeline state change to PLAYING failed');
383
+ }
384
+ this._video.readyState = 4;
385
+ this._video.dispatchEvent(new Event('loadedmetadata'));
386
+ this._video.dispatchEvent(new Event('canplay'));
387
+ this._video.dispatchEvent(new Event('playing'));
388
+ }
389
+
233
390
  _destroyPipeline(): void {
391
+ if (this._pipelineBus) {
392
+ for (const id of this._pipelineBusHandlers) {
393
+ try { this._pipelineBus.disconnect(id); } catch { /* ignore */ }
394
+ }
395
+ try { this._pipelineBus.remove_signal_watch(); } catch { /* ignore */ }
396
+ this._pipelineBus = null;
397
+ this._pipelineBusHandlers = [];
398
+ }
234
399
  if (this._pipeline) {
235
- try {
236
- this._pipeline.set_state(Gst.State.NULL);
237
- } catch { /* ignore */ }
400
+ try { this._pipeline.set_state(GstRuntime.State.NULL); } catch { /* ignore */ }
238
401
  this._pipeline = null;
402
+ this._video._pipeline = null;
239
403
  }
240
404
  this._picture.set_paintable(null);
241
405
  }