Metal: inline video playback (Fase H2)

AVFoundation decodes into BGRA pixel buffers (AVPlayerItemVideoOutput)
that CVMetalTextureCache wraps as Metal textures with zero copies; the
compositor draws the newest frame as a textured quad over the static
texture on every present, scissor-clipped to the window interior so a
half-scrolled video does not bleed over the mode line.

Presents are driven by a 30fps Lisp timer (mtl-video-tick): Emacs's
event loop starves the CADisplayLink while idle (it stalled after two
ticks), and Lisp timers are what fire reliably; this also matches how
image-animate drives GIFs.  The timer re-anchors the video to a buffer
marker each tick (mtl-video-move), so it follows scrolling and window
changes, and parks it off-screen when not visible.

Lisp API: mtl-video-open/close/pause/move/tick (mtlfns.m) and the
user-facing mtl-video-insert/mtl-video-stop in mtl.el, which insert a
pixel-sized placeholder space and track it.

MtlVideoPlayer goes through property setters everywhere: this file is
compiled without ARC, and raw ivar assignment autoreleased the AVPlayer
graph under us (crash: unrecognized selector on a reused dictionary).

Links AVFoundation/CoreMedia/CoreVideo (configure.ac libs_nsgui and
MTL_LIBS).
This commit is contained in:
Andros Fenollosa 2026-06-03 23:03:31 +02:00
parent b81683d822
commit e19ee5fb2b
5 changed files with 522 additions and 20 deletions

View file

@ -2934,7 +2934,9 @@ if test "${with_mtl}" = yes; then
AC_LANG_POP([Objective C])
if test "${HAVE_MTL}" = yes; then
MTL_OBJC_OBJ="mtlterm.o mtlfns.o"
MTL_LIBS="-framework Metal -framework QuartzCore -framework CoreText"
dnl AVFoundation/CoreMedia/CoreVideo: inline video (AVPlayerItemVideoOutput
dnl decoded straight into Metal textures via CVMetalTextureCache).
MTL_LIBS="-framework Metal -framework QuartzCore -framework CoreText -framework AVFoundation -framework CoreMedia -framework CoreVideo"
dnl Metal piggybacks on NS infrastructure; keep window_system=nextstep
dnl so that term_header=nsterm.h and all NS macros remain available.
fi
@ -7492,7 +7494,10 @@ case "$opsys" in
fi
fi
if test "$HAVE_MTL" = "yes"; then
libs_nsgui="$libs_nsgui -framework Metal -framework CoreText"
dnl AVFoundation/CoreMedia/CoreVideo: inline video (Fase H2).
libs_nsgui="$libs_nsgui -framework Metal -framework CoreText \
-framework AVFoundation -framework CoreMedia \
-framework CoreVideo"
fi
else
libs_nsgui=

View file

@ -64,7 +64,7 @@
;; Helper functions (must be defined before defcustom :set functions use them)
(defun mtl--cursor-mode-number (mode)
"Convert cursor MODE symbol to integer for mtl-cursor-mode."
"Convert cursor MODE symbol to integer for `mtl-cursor-mode'."
(pcase mode
('block 0)
('spring 1)
@ -77,7 +77,7 @@
(_ 1)))
(defun mtl--scroll-easing-number (easing)
"Convert EASING symbol to integer for mtl-scroll-effect."
"Convert EASING symbol to integer for `mtl-scroll-effect'."
(pcase easing
('none 0)
('linear 1)
@ -171,7 +171,7 @@ The NS backend still handles events, menus, and scrollbars."
(error "mtl-enable: Metal is not available on this system"))
(let ((f (or frame (selected-frame))))
(unless (framep f)
(error "mtl-enable: argument is not a frame"))
(error "Mtl-enable: argument is not a frame"))
(mtl-enable-for-frame f)
;; Apply current configuration
(mtl-cursor-mode (mtl--cursor-mode-number mtl-cursor-animation))
@ -223,6 +223,62 @@ The NS backend still handles events, menus, and scrollbars."
nil t))))
(setopt mtl-scroll-easing easing))
;; ---------------------------------------------------------------------------
;; Inline video (Fase H2)
(defvar mtl--video-state nil
"Active inline video: (MARKER WIDTH HEIGHT TIMER FRAME), or nil.")
(defun mtl--video-sync ()
"Track the video placeholder: move/clip the GPU rect and present a frame.
Runs on a 30fps timer started by `mtl-video-insert'. Follows scrolling
and window changes; hides the video while its position is off-screen."
(when mtl--video-state
(pcase-let ((`(,marker ,w ,h ,_timer ,frame) mtl--video-state))
(if (not (and (frame-live-p frame) (marker-buffer marker)))
(mtl-video-stop)
(let* ((win (get-buffer-window (marker-buffer marker) frame))
(vis (and win (pos-visible-in-window-p marker win t))))
(if (not (and vis (listp vis)))
;; Not visible: park the rect off-screen but keep decoding.
(mtl-video-move 0 -32768 w h nil frame)
(let* ((edges (window-inside-pixel-edges win))
(x (+ (nth 0 edges) (nth 0 vis)))
(y (+ (nth 1 edges) (nth 1 vis))))
(mtl-video-move x y w h edges frame)))
(mtl-video-tick frame))))))
;;;###autoload
(defun mtl-video-insert (file width height &optional loop)
"Insert a WIDTH x HEIGHT placeholder at point and play video FILE over it.
The placeholder is a space with a pixel-sized display spec; the GPU
composites the video at its position every frame, following scrolling
\(clipped to the window interior). With LOOP non-nil, restart playback
at the end. One video per frame; a previous one is replaced."
(interactive "fVideo file: \nnWidth (px): \nnHeight (px): ")
(mtl-video-stop)
(insert (propertize " "
'display `(space :width (,width) :height (,height))
'mtl-video file))
(let ((marker (copy-marker (1- (point)))))
;; Park off-screen; the first sync tick positions it for real.
(unless (mtl-video-open file 0 -32768 width height loop)
(error "mtl-video-open failed for %s" file))
(setq mtl--video-state
(list marker width height
(run-at-time 0 0.033 #'mtl--video-sync)
(selected-frame)))))
;;;###autoload
(defun mtl-video-stop ()
"Stop and remove the inline video, canceling its sync timer."
(interactive)
(when mtl--video-state
(pcase-let ((`(,_marker ,_w ,_h ,timer ,frame) mtl--video-state))
(when (timerp timer) (cancel-timer timer))
(when (frame-live-p frame) (mtl-video-close frame)))
(setq mtl--video-state nil)))
;; ---------------------------------------------------------------------------
;; Startup integration

View file

@ -22,6 +22,7 @@
#include "fontset.h"
#include "font.h"
#include "character.h"
#include "coding.h"
#include "macfont.h"
#include "mtlterm.h"
@ -453,6 +454,117 @@ DEFUN ("mtl-animation-status", Fmtl_animation_status, Smtl_animation_status,
Initialization
----------------------------------------------------------------------- */
/* ---------------------------------------------------------------------------
Fase H2: inline video
--------------------------------------------------------------------------- */
DEFUN ("mtl-video-open", Fmtl_video_open, Smtl_video_open, 5, 7, 0,
doc: /* Play video FILE over FRAME at X, Y sized WIDTH x HEIGHT pixels.
X and Y are frame-relative logical pixels (top-left origin). The video
is decoded by AVFoundation straight into Metal textures and composited
over the frame content every present; redisplay underneath continues
normally. If LOOP is non-nil, restart playback at the end.
FRAME defaults to the selected frame. One video per frame: opening a
new one replaces the previous. Returns t on success. */)
(Lisp_Object file, Lisp_Object x, Lisp_Object y, Lisp_Object width,
Lisp_Object height, Lisp_Object loop, Lisp_Object frame)
{
if (NILP (frame)) frame = Fselected_frame ();
CHECK_LIVE_FRAME (frame);
struct frame *f = XFRAME (frame);
CHECK_STRING (file);
CHECK_FIXNUM (x); CHECK_FIXNUM (y);
CHECK_FIXNUM (width); CHECK_FIXNUM (height);
Lisp_Object expanded = Fexpand_file_name (file, Qnil);
bool ok;
block_input ();
ok = mtl_video_open (f, SSDATA (ENCODE_FILE (expanded)),
XFIXNUM (x), XFIXNUM (y),
XFIXNUM (width), XFIXNUM (height),
!NILP (loop));
unblock_input ();
return ok ? Qt : Qnil;
}
DEFUN ("mtl-video-close", Fmtl_video_close, Smtl_video_close, 0, 1, 0,
doc: /* Stop and remove the inline video on FRAME.
FRAME defaults to the selected frame. Returns t if a video was open. */)
(Lisp_Object frame)
{
if (NILP (frame)) frame = Fselected_frame ();
CHECK_LIVE_FRAME (frame);
bool ok;
block_input ();
ok = mtl_video_close (XFRAME (frame));
unblock_input ();
return ok ? Qt : Qnil;
}
DEFUN ("mtl-video-pause", Fmtl_video_pause, Smtl_video_pause, 1, 2, 0,
doc: /* Pause (PAUSED non-nil) or resume the inline video on FRAME.
FRAME defaults to the selected frame. Returns t if a video is open. */)
(Lisp_Object paused, Lisp_Object frame)
{
if (NILP (frame)) frame = Fselected_frame ();
CHECK_LIVE_FRAME (frame);
bool ok;
block_input ();
ok = mtl_video_set_paused (XFRAME (frame), !NILP (paused));
unblock_input ();
return ok ? Qt : Qnil;
}
DEFUN ("mtl-video-move", Fmtl_video_move, Smtl_video_move, 4, 6, 0,
doc: /* Move/resize the inline video on FRAME to X, Y, WIDTH, HEIGHT.
Frame-relative logical pixels. Optional CLIP is a list (LEFT TOP RIGHT
BOTTOM), also frame-relative, that confines the video to a window's
interior; nil removes clipping. FRAME defaults to the selected frame.
Returns t if a video is open. */)
(Lisp_Object x, Lisp_Object y, Lisp_Object width, Lisp_Object height,
Lisp_Object clip, Lisp_Object frame)
{
if (NILP (frame)) frame = Fselected_frame ();
CHECK_LIVE_FRAME (frame);
CHECK_FIXNUM (x); CHECK_FIXNUM (y);
CHECK_FIXNUM (width); CHECK_FIXNUM (height);
bool ok;
block_input ();
ok = mtl_video_set_rect (XFRAME (frame), XFIXNUM (x), XFIXNUM (y),
XFIXNUM (width), XFIXNUM (height));
if (ok)
{
if (CONSP (clip))
{
int cl = XFIXNUM (Fnth (make_fixnum (0), clip));
int ct = XFIXNUM (Fnth (make_fixnum (1), clip));
int cr = XFIXNUM (Fnth (make_fixnum (2), clip));
int cb = XFIXNUM (Fnth (make_fixnum (3), clip));
mtl_video_set_clip (XFRAME (frame), cl, ct, cr - cl, cb - ct);
}
else
mtl_video_set_clip (XFRAME (frame), 0, 0, 0, 0);
}
unblock_input ();
return ok ? Qt : Qnil;
}
DEFUN ("mtl-video-tick", Fmtl_video_tick, Smtl_video_tick, 0, 1, 0,
doc: /* Present a fresh frame of the inline video on FRAME.
Driven by a Lisp timer in mtl.el (Emacs's event loop starves the
CADisplayLink while idle). Returns t while a video is open, nil
otherwise (letting the timer cancel itself). */)
(Lisp_Object frame)
{
if (NILP (frame)) frame = Fselected_frame ();
if (!FRAME_LIVE_P (XFRAME (frame))) return Qnil;
bool ok;
block_input ();
ok = mtl_video_tick (XFRAME (frame));
unblock_input ();
return ok ? Qt : Qnil;
}
void
syms_of_mtlfns (void)
{
@ -472,6 +584,12 @@ syms_of_mtlfns (void)
defsubr (&Smtl_animations);
defsubr (&Smtl_capture_frame);
defsubr (&Smtl_draw_stats);
/* Fase H2: inline video */
defsubr (&Smtl_video_open);
defsubr (&Smtl_video_close);
defsubr (&Smtl_video_pause);
defsubr (&Smtl_video_move);
defsubr (&Smtl_video_tick);
}
#endif /* HAVE_MTL */

View file

@ -10,6 +10,8 @@
#import <Metal/Metal.h>
#import <QuartzCore/QuartzCore.h>
#import <CoreText/CoreText.h>
#import <AVFoundation/AVFoundation.h>
#import <CoreVideo/CoreVideo.h>
#include "dispextern.h"
#include "frame.h"
@ -140,6 +142,38 @@ typedef struct mtl_spring {
@end
/* -----------------------------------------------------------------------
MtlVideoPlayer inline video playback (Fase H2).
AVPlayer decodes; AVPlayerItemVideoOutput hands BGRA pixel buffers that
CVMetalTextureCache wraps as Metal textures with zero copies; the
compositor draws the current frame as a textured quad over the static
texture each present, and the animator's CADisplayLink keeps presents
flowing while playback is active.
----------------------------------------------------------------------- */
@interface MtlVideoPlayer : NSObject
@property (nonatomic, strong) AVPlayer *player;
@property (nonatomic, strong) AVPlayerItemVideoOutput *output;
@property (nonatomic, assign) CVMetalTextureCacheRef textureCache;
/* Keep the CoreVideo wrapper alive while its MTLTexture is in use. */
@property (nonatomic, assign) CVMetalTextureRef currentCVTexture;
@property (nonatomic, strong) id<MTLTexture> currentTexture;
@property (nonatomic, assign) NSRect rect; /* logical px */
/* Window-interior clip (logical px); NSZeroRect = no clipping. Keeps a
half-scrolled video from bleeding over the mode line. */
@property (nonatomic, assign) NSRect clipRect;
@property (nonatomic, assign) BOOL loop;
/* NSNotificationCenter block token for loop mode (retained; this file is
compiled without ARC, so raw ivar assignments would not retain). */
@property (nonatomic, strong) id endObserver;
- (instancetype)initWithURL:(NSURL *)url rect:(NSRect)rect loop:(BOOL)loop;
/* Latest decoded frame as a Metal texture (nil before the first frame). */
- (id<MTLTexture>)textureForNow;
- (BOOL)isPlaying;
- (void)shutdown;
@end
/* -----------------------------------------------------------------------
MtlFrameData per-frame Metal rendering state.
Stored as ObjC associated object on EmacsView.
@ -187,6 +221,10 @@ typedef struct mtl_spring {
@property (nonatomic, assign) BOOL cycleSawClear;
@property (nonatomic, assign) BOOL cycleSawDraw;
/* Fase H2: active inline video (one per frame for now), drawn by
compositeToScreen over the static texture. */
@property (nonatomic, strong) MtlVideoPlayer *videoPlayer;
/* Main Emacs render cycle (renders to staticTexture) */
- (void)beginFrame;
- (void)endFrame;
@ -279,6 +317,15 @@ MtlGlyphCacheEntry *mtl_cache_glyph (CTFontRef font, uint32_t codepoint);
/* Pre-rasterize printable ASCII for FRAME's default face (atlas warm-up). */
extern void mtl_warm_glyph_cache (struct frame *f);
/* Fase H2: inline video (one player per frame). */
extern bool mtl_video_open (struct frame *f, const char *path,
int x, int y, int w, int h, bool loop);
extern bool mtl_video_close (struct frame *f);
extern bool mtl_video_set_paused (struct frame *f, bool paused);
extern bool mtl_video_set_rect (struct frame *f, int x, int y, int w, int h);
extern bool mtl_video_set_clip (struct frame *f, int x, int y, int w, int h);
extern bool mtl_video_tick (struct frame *f);
extern bool mtl_render_offscreen_png (const char *path, int w, int h,
void (^draw)(id<MTLRenderCommandEncoder>));
extern bool mtl_render_text_png (const char *path);

View file

@ -850,6 +850,20 @@ easing_apply (MtlScrollEasing mode, float t)
}
}
/* Sequence tracing for present-flow debugging (MTL_LOG_SEQ=1). */
static BOOL
mtl_log_seq_p (void)
{
static int on = -1;
if (on < 0) on = getenv ("MTL_LOG_SEQ") != NULL;
return on > 0;
}
#define MTL_SEQ(fmt, ...) \
do { if (mtl_log_seq_p ()) \
fprintf (stderr, "[mtlseq %.3f] " fmt "\n", \
CACurrentMediaTime (), ##__VA_ARGS__); } while (0)
/* -----------------------------------------------------------------------
@implementation MtlAnimator
----------------------------------------------------------------------- */
@ -1025,6 +1039,11 @@ easing_apply (MtlScrollEasing mode, float t)
needsComposite = YES;
}
/* Fase H2: while a video plays, every tick presents so the compositor
samples the freshest decoded frame. */
if (fd.videoPlayer && [fd.videoPlayer isPlaying])
needsComposite = YES;
if (needsComposite || self.cursorDirty)
{
self.cursorDirty = NO;
@ -1034,6 +1053,124 @@ easing_apply (MtlScrollEasing mode, float t)
@end
/* -----------------------------------------------------------------------
@implementation MtlVideoPlayer (Fase H2: inline video)
----------------------------------------------------------------------- */
@implementation MtlVideoPlayer
/* NOTE: this file is compiled without ARC; always go through the property
setters (retain semantics), never raw ivar assignment, or the AVPlayer
graph gets autoreleased under us. */
- (instancetype)initWithURL:(NSURL *)url rect:(NSRect)rect loop:(BOOL)loop
{
self = [super init];
if (!self) return nil;
AVPlayerItem *item = [AVPlayerItem playerItemWithURL:url];
NSDictionary *attrs = @{
(id) kCVPixelBufferPixelFormatTypeKey : @(kCVPixelFormatType_32BGRA),
(id) kCVPixelBufferMetalCompatibilityKey : @YES,
};
self.output = [[[AVPlayerItemVideoOutput alloc]
initWithPixelBufferAttributes:attrs] autorelease];
[item addOutput:self.output];
self.player = [AVPlayer playerWithPlayerItem:item];
self.player.actionAtItemEnd = loop ? AVPlayerActionAtItemEndNone
: AVPlayerActionAtItemEndPause;
if (loop)
self.endObserver = [[NSNotificationCenter defaultCenter]
addObserverForName:AVPlayerItemDidPlayToEndTimeNotification
object:item
queue:[NSOperationQueue mainQueue]
usingBlock:^(NSNotification *note) {
(void) note;
[item seekToTime:kCMTimeZero completionHandler:nil];
}];
CVMetalTextureCacheRef cache = NULL;
CVMetalTextureCacheCreate (NULL, NULL, g_device, NULL, &cache);
self.textureCache = cache;
self.rect = rect;
self.clipRect = NSZeroRect;
self.loop = loop;
[self.player play];
return self;
}
- (BOOL)isPlaying
{
return self.player != nil && self.player.rate != 0.0f;
}
/* Wrap the newest decoded pixel buffer as a Metal texture (zero copy via
CVMetalTextureCache). Falls back to the previous frame's texture when the
output has nothing new, so redraws between video frames keep the picture. */
- (id<MTLTexture>)textureForNow
{
if (!self.output || !self.textureCache) return self.currentTexture;
CMTime t = [self.output itemTimeForHostTime:CACurrentMediaTime ()];
if ([self.output hasNewPixelBufferForItemTime:t])
{
CVPixelBufferRef pb = [self.output copyPixelBufferForItemTime:t
itemTimeForDisplay:NULL];
if (pb)
{
size_t w = CVPixelBufferGetWidth (pb);
size_t h = CVPixelBufferGetHeight (pb);
CVMetalTextureRef cvtex = NULL;
if (CVMetalTextureCacheCreateTextureFromImage (
NULL, self.textureCache, pb, NULL,
MTLPixelFormatBGRA8Unorm, w, h, 0, &cvtex)
== kCVReturnSuccess && cvtex)
{
/* The MTLTexture is only valid while its CV wrapper lives;
release the previous wrapper now that it is replaced. */
if (self.currentCVTexture)
CFRelease (self.currentCVTexture);
self.currentCVTexture = cvtex;
self.currentTexture = CVMetalTextureGetTexture (cvtex);
}
CVPixelBufferRelease (pb);
}
}
return self.currentTexture;
}
- (void)shutdown
{
[self.player pause];
if (self.endObserver)
{
[[NSNotificationCenter defaultCenter] removeObserver:self.endObserver];
self.endObserver = nil;
}
self.player = nil;
self.output = nil;
self.currentTexture = nil;
if (self.currentCVTexture)
{
CFRelease (self.currentCVTexture);
self.currentCVTexture = NULL;
}
if (self.textureCache)
{
CVMetalTextureCacheFlush (self.textureCache, 0);
CFRelease (self.textureCache);
self.textureCache = NULL;
}
}
- (void)dealloc
{
[self shutdown];
[super dealloc];
}
@end
/* -----------------------------------------------------------------------
@implementation MtlFrameData
----------------------------------------------------------------------- */
@ -1400,20 +1537,6 @@ easing_apply (MtlScrollEasing mode, float t)
self.needsPresent = YES;
}
/* Sequence tracing for present-flow debugging (MTL_LOG_SEQ=1). */
static BOOL
mtl_log_seq_p (void)
{
static int on = -1;
if (on < 0) on = getenv ("MTL_LOG_SEQ") != NULL;
return on > 0;
}
#define MTL_SEQ(fmt, ...) \
do { if (mtl_log_seq_p ()) \
fprintf (stderr, "[mtlseq %.3f] " fmt "\n", \
CACurrentMediaTime (), ##__VA_ARGS__); } while (0)
- (void)compositeToScreen
{
if (!self.staticTexture || !g_blit_pipeline) return;
@ -1421,7 +1544,12 @@ mtl_log_seq_p (void)
id<CAMetalDrawable> drawable = [self.metalLayer nextDrawable];
if (!drawable) return;
MTL_SEQ ("PRESENT");
MTL_SEQ ("PRESENT layer=%.0fx%.0f drawable=%lux%lu static=%lux%lu",
self.metalLayer.frame.size.width, self.metalLayer.frame.size.height,
(unsigned long) drawable.texture.width,
(unsigned long) drawable.texture.height,
(unsigned long) self.staticTexture.width,
(unsigned long) self.staticTexture.height);
self.needsPresent = NO; /* about to present whatever is in the static texture */
@ -1458,6 +1586,65 @@ mtl_log_seq_p (void)
[enc drawPrimitives:MTLPrimitiveTypeTriangle vertexStart:0 vertexCount:6];
}
/* Fase H2: inline video overlay. Drawn over the static texture so the
redisplay engine can keep treating the placeholder area as ordinary
buffer background. */
MtlVideoPlayer *vp = self.videoPlayer;
if (vp)
{
id<MTLTexture> vtex = [vp textureForNow];
if (vtex && g_image_pipeline)
{
/* Clip to the window interior so a half-scrolled video does not
bleed over the mode line or a neighboring window. */
NSRect cr = vp.clipRect;
BOOL clipped = !NSIsEmptyRect (cr);
if (clipped)
{
CGSize dsz = self.metalLayer.drawableSize;
double scx = sz.width > 0 ? dsz.width / sz.width : 1.0;
double scy = sz.height > 0 ? dsz.height / sz.height : 1.0;
long tw = (long) drawable.texture.width;
long th = (long) drawable.texture.height;
long cx0 = lround (NSMinX (cr) * scx);
long cy0 = lround (NSMinY (cr) * scy);
long cx1 = lround (NSMaxX (cr) * scx);
long cy1 = lround (NSMaxY (cr) * scy);
cx0 = MAX (0, MIN (cx0, tw)); cy0 = MAX (0, MIN (cy0, th));
cx1 = MAX (cx0, MIN (cx1, tw)); cy1 = MAX (cy0, MIN (cy1, th));
MTLScissorRect sc = { (NSUInteger) cx0, (NSUInteger) cy0,
(NSUInteger) (cx1 - cx0),
(NSUInteger) (cy1 - cy0) };
if (sc.width == 0 || sc.height == 0)
sc = (MTLScissorRect) {0, 0, 1, 1};
[enc setScissorRect:sc];
}
typedef struct { float x, y, u, v, a; } ImgVert;
NSRect vr = vp.rect;
float x0 = NSMinX (vr), y0 = NSMinY (vr);
float x1 = NSMaxX (vr), y1 = NSMaxY (vr);
ImgVert verts[6] = {
{x0,y0, 0,0,1}, {x1,y0, 1,0,1}, {x0,y1, 0,1,1},
{x1,y0, 1,0,1}, {x1,y1, 1,1,1}, {x0,y1, 0,1,1},
};
[enc setRenderPipelineState:g_image_pipeline];
[enc setVertexBytes:verts length:sizeof (verts) atIndex:0];
[enc setVertexBuffer:self.uniformBuffer offset:0 atIndex:1];
[enc setFragmentTexture:vtex atIndex:0];
[enc setFragmentSamplerState:g_sampler atIndex:0];
[enc drawPrimitives:MTLPrimitiveTypeTriangle
vertexStart:0 vertexCount:6];
if (clipped)
{
MTLScissorRect full = { 0, 0, drawable.texture.width,
drawable.texture.height };
[enc setScissorRect:full];
}
}
}
/* Animation overlay (cursor effects, trail, particles) is opt-in. When off,
the cursor lives in the static texture (drawn by mtl_draw_window_cursor),
so the compositor only blits and presents. This is what kills the stray
@ -2857,6 +3044,95 @@ mtl_flush_display (struct frame *f)
[fd compositeToScreen];
}
/* ---------------------------------------------------------------------------
Fase H2: inline video API (called from mtlfns.m).
--------------------------------------------------------------------------- */
bool
mtl_video_open (struct frame *f, const char *path, int x, int y,
int w, int h, bool loop)
{
MtlFrameData *fd = mtl_get_frame_data (f);
if (!fd) return false;
if (fd.videoPlayer)
{
[fd.videoPlayer shutdown];
fd.videoPlayer = nil;
}
NSString *ns_path = [NSString stringWithUTF8String:path];
if (!ns_path || ![[NSFileManager defaultManager] fileExistsAtPath:ns_path])
return false;
MtlVideoPlayer *vp =
[[MtlVideoPlayer alloc] initWithURL:[NSURL fileURLWithPath:ns_path]
rect:NSMakeRect (x, y, w, h)
loop:loop];
if (!vp) return false;
fd.videoPlayer = vp;
/* The animator's CADisplayLink drives presents during playback. */
[fd.animator startAnimating];
return true;
}
bool
mtl_video_close (struct frame *f)
{
MtlFrameData *fd = mtl_get_frame_data (f);
if (!fd || !fd.videoPlayer) return false;
[fd.videoPlayer shutdown];
fd.videoPlayer = nil;
if (!g_mtl_animations_enabled)
[fd.animator stopAnimating];
[fd compositeToScreen]; /* repaint without the overlay */
return true;
}
bool
mtl_video_set_paused (struct frame *f, bool paused)
{
MtlFrameData *fd = mtl_get_frame_data (f);
if (!fd || !fd.videoPlayer) return false;
if (paused)
[fd.videoPlayer.player pause];
else
[fd.videoPlayer.player play];
return true;
}
bool
mtl_video_set_rect (struct frame *f, int x, int y, int w, int h)
{
MtlFrameData *fd = mtl_get_frame_data (f);
if (!fd || !fd.videoPlayer) return false;
fd.videoPlayer.rect = NSMakeRect (x, y, w, h);
return true;
}
bool
mtl_video_set_clip (struct frame *f, int x, int y, int w, int h)
{
MtlFrameData *fd = mtl_get_frame_data (f);
if (!fd || !fd.videoPlayer) return false;
fd.videoPlayer.clipRect = NSMakeRect (x, y, w, h);
return true;
}
/* Present a fresh composite if a video is active. Called from a Lisp-level
timer (mtl.el): Emacs's event loop starves the CADisplayLink while idle,
so Lisp timers are what reliably drives playback presents. */
bool
mtl_video_tick (struct frame *f)
{
MtlFrameData *fd = mtl_get_frame_data (f);
if (!fd || !fd.videoPlayer) return false;
if ([fd.videoPlayer isPlaying] && !fd.encoder)
[fd compositeToScreen];
return true;
}
static void
mtl_update_begin (struct frame *f)
{