Javascript:帧精确视频停止

Javascript: frame precise video stop

我希望能够 稳健地 在视频到达某些指定帧时停止视频,以便根据使用 Blender、Manim 制作的视频进行口头演示。. .

我知道 ,但问题是视频没有恰好停在好帧处。有时它会继续前进一帧,当我强迫它回到初始帧时,我们会看到视频倒退,这很奇怪。更糟糕的是,如果下一帧完全不同(不同的背景...),这将非常明显。

为了说明我的问题,我创建了一个演示项目 here (just click "next" and see that when the video stops, sometimes it goes backward). The full code is here

我使用的代码的重要部分是:

      var video = VideoFrame({
          id: 'video',
          frameRate: 24,
          callback: function(curr_frame) {
              // Stops the video when arriving on a frames to stop at.
              if (stopFrames.includes(curr_frame)) {
                  console.log("Automatic stop: found stop frame.");
                  pauseMyVideo();
                  // Ensure we are on the proper frame.
                  video.seekTo({frame: curr_frame});
              }
          }
      });

到目前为止,我通过在结束前停止一帧然后使用 seekTo(不确定这听起来如何)来避免这个问题,如 here 所示。但是正如您所看到的,有时在下一帧时它会“冻结”一点:我猜这是在 seekTo.

之前停止发生的时候

PS:如果你知道JS中一个可靠的方法来知道给定视频的帧数,我也很感兴趣。

关于在桌面上预先剪切视频的想法,可以使用...但我过去对此有不好的经验,特别是更改视频有时会产生一些故障。此外,使用起来可能会更复杂,这意味着视频应该手动剪切很多时间,重新编码...

EDIT 是否有任何基于 WebAssembly(与旧浏览器更兼容)或 Webcodec(更高效,但尚未广泛传播)的解决方案? Webcodec 似乎允许 pretty amazing things,但我不确定如何使用它们。我很想听听基于它们的解决方案,因为 firefox 还没有处理 webcodec。请注意,如果在此过程中没有丢失音频,那就太好了。如果我还可以根据要求显示控件,那就加分了。

编辑:我不确定正在发生什么 here (source)... But it seems to do something close to my need (using webassembly I think) since it manages to play a video in a canvas, with frame... Here is another website 使用 Webcodec 做一些接近我需要的事情。但我不确定如何使用 webcodec 可靠地同步声音和视频。

编辑:第一个问题的答案

关于视频帧,我确实选择了错误的帧率,它是 25 而不是 24。但是即使使用 25 的帧率,我仍然没有在 Firefox 和 Chromium 上得到帧精确停止.例如,这是您的演示的录音(使用 OBS)(当我使用 25 而不是 24 时,我看到的与我的相同):

一帧后,看到黄油“向后飞”(这在静止屏幕截图中可能不是很明显,但可以看到例如花朵左下翼的位置):

我可以看出三个可能的原因:第一(我认为这是最有可能的原因),我听说video.currentTime 并不总是准确报告时间,也许它可以解释为什么这里失败?为了改变当前帧,它似乎非常准确(据我所知,我可以非常可靠地前进和后退一帧),但人们报告 here that video.currentTime is computed using the audio time and not the video time in Chromium, leading to some inconsistencies (I observe similar inconsistencies in Firefox), and here 它可能会导致时间帧被发送到合成器或帧在合成器中实际打印的位置(如果它是最新的,它可以解释我们有时会有的延迟)。这也可以解释为什么 requestAnimationVideoFrame 更好,因为它还提供了当前媒体时间。

可以解释该问题的第二个原因是 setInterval 可能不够精确...但是 requestAnimationFrame 并不是真的更好(requestAnimationVideoFrame 在 Firefox 中不可用)虽然它应该每秒发射 60 次,但应该足够快了。

我能看到的第三个选项是 .pause 函数可能需要很长时间才能触发...并且在调用结束时视频还会播放另一帧。另一方面,您使用 requestAnimationVideoFrame https://mvyom.csb.app/requestFrame.html 的示例似乎工作得相当可靠,而且它使用的是 .pause!不幸的是,它只适用于 Chromium,但不适用于 Firefox。我看到您使用 metadata.mediaTime 而不是 currentTime,也许这比当前时间更精确。

最后一个选项是,如 this page 中所述,可能有一些关于 vsync 的微妙之处。它还报告说 expectedDisplayTime 在使用 requestAnimationVideoFrame 时可能有助于解决此问题。

视频的帧率为 25fps,而不是 24fps:

输入正确的值后它工作正常:demo
VideoFrame api 严重依赖您提供的 FPS。您可以离线查找视频的 FPS,并作为元数据与服务器的停止帧一起发送。


获得回调的站点videoplayer.handmadeproductions.de uses window.requestAnimationFrame()


有一个新的更好的 requestAnimationFrame 替代品。 requestVideoFrameCallback(),允许我们对视频进行 per-video-frame 操作。
同样的功能,你在OP中做的,可以这样实现:

   const callback = (now, metadata) => {
      if (startTime == 0) {
        startTime = now;
      }
      elapsed = metadata.mediaTime;
      currentFrame = metadata.presentedFrames - doneCount;

      fps = (currentFrame / elapsed).toFixed(3);
      fps = !isFinite(fps) ? 0 : fps;

      updateStats();
      if (stopFrames.includes(currentFrame)) {
        pauseMyVideo();
      } else {
        video.requestVideoFrameCallback(callback);
      }
   };
   video.requestVideoFrameCallback(callback);

这是 demo 的样子。
API 适用于基于铬的浏览器,如 Chrome、Edge、Brave 等


有一个JS库,可以从视频二进制文件中找到帧率,名称为mediainfo.js

看看这是否对您有帮助。如果对你有用,我会在后面展开。

可以通过以下方式在线测试:https://www.w3schools.com/tags/tryit.asp?filename=tryhtml5_video

  • 它将计算 MP4 文件中的总帧数。
  • 它将估计当前帧(随着视频播放)。

如果对您的问题的解决方案有用,请告诉我,我会将其扩展以进行反向播放等,并处理在特定帧停止(当设置到“停止点”框中时)。

<!DOCTYPE html>
<html>
<body>

<h1 style="position: absolute; top: 10px; left: 10px" > Demo // Stop Video at Specific Frame(s) : </h1>
<br>

<div style="z-index: 1; overflow:hidden; position: absolute; top: 60px; left: 10px; font-family: OpenSans; font-size: 14px">
<p> <b> Choose an .MP4 video file... </b> </p>
<input type="file" id="choose_media" accept=".mov, .mp4" />
</div>

<video id="myVideo" width="640" height="480" controls muted playsinline 
style="position: absolute; top: 80px; left: 10px" >
<source src="vc_timecode3.mp4" type="video/mp4">
</video>

<div id="cont_texts" style="position: absolute; top: 80px; left: 700px" >

<span> Current Time : </span> <span id="txt_curr_time"> 00:00:00 </span> 
<br><br>
<span> Estimated Frame Num : </span> <span id="txt_est_frame"> 0 </span> 
<br><br>
<span> Total Frames (video) : </span> <span id="txt_total_frame"> -999 </span> 
<br><br>

<span onclick="check_points()" > Stopping Points Array : </span> <input type="text" id="stopPointsArray" value="" > 

</div>

</body>


<script>


////////////////////////////////

//# VARS
var myVideo = document.getElementById( 'myVideo' );
var video_duration;

var h; var m; var s;
var h_time; var m_time; var s_time;

var vid_curr_time = document.getElementById( 'txt_curr_time' );
var vid_est_frame = document.getElementById( 'txt_est_frame' );
var vid_total_frame = document.getElementById( 'txt_total_frame' );

var reader; //to get bytes from file into Array
var bytes_MP4; //updated as Array

//# MP4 related vars
var got_FPS = false; var video_FPS = -1; 
var temp_Pos = 0;  var sampleCount = -1;
var temp_int_1, temp_int_2 = -1;

                    
var array_points = document.getElementById("stopPointsArray");
array_points.addEventListener('change', check_points );

//# EVENTS
document.getElementById('choose_media').addEventListener('change', onFileSelected, false);

myVideo.addEventListener("timeupdate", video_timeUpdate);           
myVideo.addEventListener("play", handle_Media_Events );
myVideo.addEventListener("pause", handle_Media_Events );
myVideo.addEventListener("ended", handle_Media_Events );

//# LET'S GO...
        
function onFileSelected ( evt )
{
    file = evt.target.files[0];
    path = (window.URL || window.webkitURL).createObjectURL(file);
    
    reader = new FileReader();
    reader.readAsArrayBuffer(file);
    
    
    reader.onloadend = function(evt) 
    {
        //alert( " file is selected ... " );
        
        if (evt.target.readyState == FileReader.DONE) 
        {
            bytes_MP4 = new Uint8Array( evt.target.result );
            get_MP4_info( bytes_MP4 );
            
            //# use bytes Array as video tag source
            let path_MP4 = (window.URL || window.webkitURL).createObjectURL(new Blob([bytes_MP4], { type: 'video/mp4' })); //'image/png' //'application/octet-stream'
            myVideo.src = path_MP4;
            myVideo.load();
            
            video_duration = myVideo.duration;
            txt_total_frame.innerHTML =( sampleCount);
            //alert("video FPS : " + video_FPS);
        }
        
    }
    
}

function check_points (e)
{
    alert( "Array Points are : " + e.target.value );
}

function handle_Media_Events()
{
    if ( event.type == "ended" )
    { 
        myVideo.currentTime = 0; myVideo.play(); myVideo.pause(); myVideo.play();
    }
    
    //{ myVideo.currentTime = 8; btn_ctrl.src = "ico_vc_play.png"; vid_isPlaying = false; bool_isPlaying = true; }
    
    if ( event.type == "play" )
    {
        if ( myVideo.nodeName == "VIDEO" )
        {

        }
    
    }
    
    else if ( event.type == "pause" )
    {
        
        
    }
    
    else if ( event.type == "seeked" )
    {
        
        
    }
    
}

function video_timeUpdate()
{
    vid_curr_time.innerHTML = ( convertTime ( myVideo.currentTime ) );
    
    vid_est_frame.innerHTML = Math.round ( video_FPS * myVideo.currentTime );
    
}

function convertTime ( input_Secs ) 
{
    h = Math.floor(input_Secs / 3600);
    m = Math.floor(input_Secs % 3600 / 60);
    s = Math.floor(input_Secs % 3600 % 60);

    h_time = h < 10 ? ("0" + h) : h ;
    m_time = m < 10 ? ("0" + m) : m ;
    s_time = s < 10 ? ("0" + s) : s ;
    
    if ( (h_time == 0) && ( video_duration < 3600) ) 
    { return ( m_time + ":" + s_time ); }
    else 
    { return ( h_time + ":" + m_time + ":" + s_time ); }
     
}

function get_MP4_info( input ) //# "input" is Array of Byte values
{
    //# console.log( "checking MP4 frame-rate..." );
    
    got_FPS = false;
    temp_Pos = 0; //# our position inside bytes of MP4 array
     
    let hdlr_type = "-1"; 
    
    while(true)
    {
        //# Step 1) Prepare for when metadata pieces are found  
        //# When VIDEO HANDLER Atom is found in MP4
        
        //# if STSZ ... 73 74 73 7A  
        if (input[ temp_Pos+0 ] == 0x73)
        {
            if ( ( input[temp_Pos+1] == 0x74 ) && ( input[temp_Pos+2] == 0x73 ) && ( input[temp_Pos+3] == 0x7A ) )
            {
                if ( hdlr_type == "vide" ) //# only IF it's the "video" track
                {
                    temp_Pos += 12;
                    sampleCount = ( ( input[temp_Pos+0] << 24) | (input[temp_Pos+1] << 16) | (input[temp_Pos+2] << 8) | input[temp_Pos+3] );
                    console.log( "found VIDEO sampleCount at: " + sampleCount );
                    
                    video_FPS = ( ( sampleCount * temp_int_1 ) / temp_int_2 );
                    console.log( "FPS of MP4 ### : " +  video_FPS );
                }
                
            }
            
        }
        
        //# Step 2) Find the pieces of metadata info
        //# Find other Atoms with data needed by above VIDEO HANDLER code.
        
        
        //# for MOOV and MDAT
        if (input[ temp_Pos ] == 0x6D) //0x6D
        {
            //# if MDAT ... 6D 64 61 74
            if ( ( temp_Pos[temp_Pos+1] == 0x64 ) && ( input[temp_Pos+2] == 0x61 ) && ( input[temp_Pos+3] == 0x74 ) )
            {
                temp_int = ( ( input[temp_Pos-4] << 24) | (input[temp_Pos-3] << 16) | (input[temp_Pos-2] << 8) | input[temp_Pos-1] );
                temp_Pos = (temp_int-1);
                if ( temp_Pos >= (input.length-1) ) { break; }
            }
            
            //# if MOOV ... 6D 6F 6F 76
            if ( ( input[temp_Pos+1] == 0x6F ) && ( input[temp_Pos+2] == 0x6F ) && ( input[temp_Pos+3] == 0x76 ) )
            {
                temp_int = ( ( input[temp_Pos-4] << 24) | (input[temp_Pos-3] << 16) | (input[temp_Pos-2] << 8) | input[temp_Pos-1] );
            }
            
            //# if MDHD ... 6D 64 68 64
            if ( ( input[temp_Pos+1] == 0x64 ) && ( input[temp_Pos+2] == 0x68 ) && ( input[temp_Pos+3] == 0x64 ) )
            {
                temp_Pos += 32;
                
                //# if HDLR ... 68 64 6C 72
                if (  input[temp_Pos+0] == 0x68 )
                {
                    if ( ( input[temp_Pos+1] == 0x64 ) && ( input[temp_Pos+2] == 0x6C ) && ( input[temp_Pos+3] == 0x72 ) )
                    {
                        temp_Pos += 12;
                        hdlr_type = String.fromCharCode(input[temp_Pos+0], input[temp_Pos+1], input[temp_Pos+2], input[temp_Pos+3] );
                    }
                }
            }
            
            //# if MVHD ... 6D 76 68 64
            if ( ( input[temp_Pos+1] == 0x76 ) && ( input[temp_Pos+2] == 0x68 ) && ( input[temp_Pos+3] == 0x64 ) )
            {
                temp_Pos += (12 + 4);
                
                //# get timescale
                temp_int_1 = ( ( input[temp_Pos+0] << 24) | (input[temp_Pos+1] << 16) | (input[temp_Pos+2] << 8) | input[temp_Pos+3] );
                ///console.log( "MVHD timescale at: " + temp_int_1 );
                
                //# get duration
                temp_int_2 = ( ( input[temp_Pos+4+0] << 24) | (input[temp_Pos+4+1] << 16) | (input[temp_Pos+4+2] << 8) | input[temp_Pos+4+3] );
                ///console.log( "MVHD duration at: " + temp_int_2 );
            }
            
        }
        
        if( temp_Pos >= (input.length-1) ) { break; }
        if( got_FPS == true) { break; }
        
        temp_Pos++;
    }
    
}

</script>

</html>