Vinlic / WebVideoCreator

๐ŸŒˆ A framework for rendering web animations into videos. It's implemented based on Node.js + Puppeteer + Chrome + FFmpeg, utilizing the latest browser APIs.
Apache License 2.0
115 stars 29 forks source link

Output video too fast #13

Closed ikequan closed 9 months ago

ikequan commented 10 months ago

First of all thank you for this an amazing project, I saw your comment recommending your project to another user and decided to give it and try. Your render is faster than more of the projects out there so I decided to switch to your project. Everything works fine but the out video seems to like it been fast forward

this my js code using fabric js(for adding two box and a video to canvas) and Anime.js for animating the fabric js objects :

`document.addEventListener("DOMContentLoaded", function () { fabric.CustomVideo = fabric.util.createClass(fabric.Image, { type: "customvideo", cropRect: null,

initialize: function (video, options) {
  const defaultOpts = {
    lockRotation: true,
    objectCaching: false,
    cacheProperties: ["time"],
  };
  options = options || {};

  this.callSuper(
    "initialize",
    video,
    Object.assign({}, defaultOpts, options)
  );
},
_draw: function (video, ctx, w, h) {
  const c = this.cropRect;
  const d = {
    x: -this.width / 2,
    y: -this.height / 2,
    w: this.width,
    h: this.height,
  };
  if (c) {
    ctx.drawImage(video, c.x, c.y, c.w, c.h, d.x, d.y, d.w, d.h);
  } else {
    ctx.drawImage(video, d.x, d.y, d.w, d.h);
  }
},

_render: function (ctx) {
  this._draw(this.getElement(), ctx);
},

});

class RenderEngine { constructor() { this.canvas = new fabric.Canvas("canvas"); this.backgroundColor = "#00000"; this.canvas.backgroundColor = this.backgroundColor; let context = this; this.playing = false; this.duration = 40000; // 40 seconds this.startTime = null; this.elapsedTime = 0; this.traceObjects = []; this.videos = []; this.animationTimeLine = anime.timeline(); this.animations = []; this.currentKeyFrame = 0; this.currentTime = 0; this.fps = 60; this.v = document.querySelector("#video-1");

  this.redBox = new fabric.Rect({
    id: this.getUid(),
    left: -50,
    top: 150,
    width: 50,
    height: 50,
    selectable: true,
    fill: "red",
    timeFrame: {
      start: 5000,
      end: 15000,
    },
  });

  this.blueBox = new fabric.Rect({
    id: this.getUid(),
    left: -50,
    top: 200,
    width: 50,
    selectable: true,
    height: 50,
    fill: "blue",
    timeFrame: {
      start: 7000,
      end: 20000,
    },
  });

  this.canvas.add(this.redBox, this.blueBox);

  this.traceObjects.push(this.redBox);
  this.traceObjects.push(this.blueBox);

  this.addAnimation({
    id: this.getUid(),
    type: "slideIn",
    targetId: this.redBox.id ?? "",
    duration: 500,
    left: 200,
    properties: {},
  });

  this.addAnimation({
    id: this.getUid(),
    type: "slideIn",
    targetId: this.blueBox.id ?? "",
    duration: 500,
    left: 200,
    properties: {},
  });
}

updateVideoElements() {
  this.traceObjects
    .filter((element) => element.type === "video")
    .forEach((element) => {
      const video = this.videos.find(
        (video) => video.id === element.properties.elementId
      );
      if (element.type == "video") {
        const videoTime =
          (this.getCurrentTime() - element.timeFrame.start);
        video.currentTime = videoTime;
      }
    });
}

addVideo() {
  let video = document.createElement("video");
  video.src = "/f3ea9a1d-935d-4eb4-a508-a94d47e77f02-final.mp4";
  video.onloadedmetadata = () => {
    const id = this.getUid();
    video.setAttribute("id", id);

    video.muted = false;
    this.videoOject = new fabric.CustomVideo(video, {
      type: "video",
      id: id,
      left: 200,
      top: 0,
      width: video.videoWidth,
      height: video.videoHeight,
      cropRect: null,
      timeFrame: {
        start: 0,
        end: (video.duration * 1000).toFixed(),
      },
      properties: {
        elementId: id,
      },
    });

    this.canvas.add(this.videoOject);
    this.traceObjects.push(this.videoOject);
    this.videos.push(video);

    this.addAnimation({
      id: this.getUid(),
      type: "slideIn",
      targetId: this.videoOject.id ?? "",
      duration: 500,
      left: 400,
      properties: {},
    });
    this.updateVideoElements();
  };
}

addAnimation(animation) {
  this.animations = [...this.animations, animation];
  this.refreshAnimations();
}

getUid() {
  return Math.random().toString(36).substring(2, 9);
}

togglePlay() {
  console.log(this.animations);
  this.playing = !this.playing;

  if (this.playing) {
    this.startTime = performance.now() - this.elapsedTime;
    this.play();
  } else {
    this.pause();
  }

  this.updateVideoElements();
  this.refreshAnimations();
}

play() {
  this.playFrame();
}

pause() {
  this.elapsedTime = this.getCurrentTime();
}

updateTimeTo(newTime) {
  this.animationTimeLine.seek(newTime);
  if (this.canvas) {
    this.canvas.backgroundColor = this.backgroundColor;
  }
  this.traceObjects.forEach((e) => {
    const isInside =
      e.timeFrame.start <= newTime && newTime <= e.timeFrame.end;

    e.visible = isInside;
    if (e.type == "video") {
      const videoEl = this.videos.find(
        (video) => video.id === e.properties.elementId
      );

      if (isInside) {
        videoEl.play();
      } else {
        videoEl.pause();
      }
    }
  });
}

refreshAnimations() {
  anime.remove(this.animationTimeLine);
  this.animationTimeLine = anime.timeline({
    duration: this.duration,
    autoplay: false,
    loop: false,
  });
  for (let i = 0; i < this.animations.length; i++) {
    const animation = this.animations[i];
    const traceObject = this.traceObjects.find(
      (object) => object.id === animation.targetId
    );

    switch (animation.type) {
      case "slideIn": {
        this.animationTimeLine.add(
          {
            easing: "linear",
            duration: animation.duration,
            targets: traceObject,
            left: animation.left,
          },
          traceObject.timeFrame.start
        );
        break;
      }
    }
  }
}

playFrame() {
  if (this.playing) {
    const currentTime = this.getCurrentTime();
    this.updateTimeTo(currentTime);
    // Continue animation
    if (currentTime < this.duration) {
      requestAnimationFrame(() => this.playFrame());
    } else {
      this.togglePlay(); // Animation complete, toggle play state
    }

    // Render canvas
    this.canvas.renderAll();
  }
}

handleSeekbarInput() {
  this.playing = false; // Pause the animation when manually adjusting the seekbar
  this.elapsedTime = this.seekbar.value;
  this.updateTimeDisplay(this.seekbar.value);
  this.updateTimeTo(this.seekbar.value);
  this.updateVideoElements();
  this.canvas.renderAll();
}

updateTimeDisplay(currentTime) {
  const minutes = Math.floor(currentTime / 60000);
  const seconds = ((currentTime % 60000) / 1000).toFixed(2);
  this.currentTimeDisplay.textContent = `${minutes}:${seconds}`;
}

getCurrentTime() {
  return performance.now() - this.startTime;
}

setCurrentTime(newTime) {
  this.startTime = newTime;
}

} const _RenderEngine = new RenderEngine(); _RenderEngine.addVideo(); _RenderEngine.togglePlay(); }); `

WebVideoCreator ` import WebVideoCreator, { VIDEO_ENCODER, logger } from "web-video-creator";

const wvc = new WebVideoCreator();

// Configure WVC wvc.config({
compatibleRenderingMode: true,
});

// Create a single-scene video const video = wvc.createSingleVideo({ url: "http://127.0.0.1:5500/fabric-anim-export.html",

width: 1920,
browserUseGPU: true,                // Enable GPU acceleration if available
// Video height
height: 1080,
// Video frame rate
fps: 30,
// Video duration
duration: 40000,
// Output path for the video
outputPath: "./test.mp4",
// Display progress bar in the command line
showProgress: true,
consoleLog: true,  

});

// Listen for the completion event video.once("completed", result => { logger.success(Render Completed!!!\nvideo duration: ${Math.floor(result.duration / 1000)}s\ntakes: ${Math.floor(result.takes / 1000)}s\nRTF: ${result.rtf}) });

// Start rendering video.start(); `

sample videos: My browser

https://github.com/Vinlic/WebVideoCreator/assets/86013106/8923f21a-bc10-45dd-b463-d151a6a901a2

WebVideoCreator output

https://github.com/Vinlic/WebVideoCreator/assets/86013106/134795ee-656c-4bac-babe-4964b6b94eef

Vinlic commented 10 months ago

@ikequan Thank you for your attention. Can you package the code of the page into a compressed file ๐Ÿ“ฆ and upload it? This makes it easier for me to check the problem ๐Ÿ˜„

ikequan commented 10 months ago

Ok here is the compressed files. Archive.zip

Vinlic commented 10 months ago

@ikequan Okay, I've noticed that you're manipulating the currentTime of the video in your code, which isn't actually necessary. I'll need some time to investigate these anomalies.

ikequan commented 10 months ago

Yes I am using currentTime to drive the timeline of the animejs to control when the fabricjs obbject moved in

` playFrame() { if (this.playing) { const currentTime = this.getCurrentTime(); this.updateTimeTo(currentTime); // Continue animation if (currentTime < this.duration) { requestAnimationFrame(() => this.playFrame()); } else { this.togglePlay(); } // Render canvas this.canvas.renderAll(); } }

updateTimeTo(newTime) {
  this.animationTimeLine.seek(newTime);
  if (this.canvas) {
    this.canvas.backgroundColor = this.backgroundColor;
  }
  this.traceObjects.forEach((e) => {
    const isInside =
      e.timeFrame.start <= newTime && newTime <= e.timeFrame.end;

    e.visible = isInside;
    if (e.type == "video") {
      const videoEl = this.videos.find(
        (video) => video.id === e.properties.elementId
      );

      if (isInside) {
        videoEl.play();
      } else {
        videoEl.pause();
      }
    }
  });
}`
Vinlic commented 10 months ago

@ikequan I checked these codes and there may be some modifications you need to make ๐Ÿ‘€ :

Setting currentTime for the <video> element is actually an inaccurate "seek". For the browser, it is decoded and then drawn asynchronously, so picture synchronization cannot be achieved. WVC solves this problem by replacing the <video> element added to the DOM tree with the <canvas> element, and implementing decoding and drawing through VideoDecoder to ensure picture synchronization.

addVideo() {
      let video = document.createElement("video");
      video.src = "/f3ea9a1d-935d-4eb4-a508-a94d47e77f02-final.mp4";
      video.onloadedmetadata = () => {
        const id = this.getUid();
        video.setAttribute("id", id);
        video.style.position = "absolute";
        video.style.top = 0;
        video.style.left = "300px";
        video.style.zIndex = 99;
        video.style.width = `${video.videoWidth}px`;
        video.style.height = `${video.videoHeight}px`;
        // this.videoOject = new fabric.CustomVideo(video, {
        //   type: "video",
        //   id: id,
        //   left: 200,
        //   top: 0,
        //   width: video.videoWidth,
        //   height: video.videoHeight,
        //   cropRect: null,
        //   timeFrame: {
        //     start: 0,
        //     end: (video.duration * 1000).toFixed(),
        //   },
        //   properties: {
        //     elementId: id,
        //   },
        // });

        // this.canvas.add(this.videoOject);
        document.getElementsByClassName('canvas-container')[0].append(video);
        // this.traceObjects.push(this.videoOject);
        // this.videos.push(video);

        this.addAnimation({
          id: this.getUid(),
          type: "slideIn",
          targetId: this.videoOject.id ?? "",
          duration: 500,
          left: 400,
          properties: {},
        });
        // this.updateVideoElements();
      };
    }

https://github.com/Vinlic/WebVideoCreator/assets/20235341/7fe36d54-5425-497b-9da9-c4765ca79abe

ikequan commented 10 months ago

@Vinlic, while i was waiting for your reply, I also removed the fabricjs part and added the video element directly to the dom, and it worked like you did above. In my frontend code(some like canva.com editor), I use fabricjs to add the object to editor and animate them the animejs and now frabricjs implementation of adding video is not compatible with WebVideoCreator, I think I have to build a new frontend UI to add the objects directly to the dom.

Vinlic commented 10 months ago

@ikequan This does cause some trouble. I have an idea ๐Ÿงช but I haven't verified it yet, maybe you can try it:

  1. Change the video element to canvas element and retain the attributes previously set in the video element.
  2. Add the video-capture attribute to the canvas element to let WVC recognize that it is a video that needs to be taken over. eg: <canvas src="test.mp4" video-capture></canvas>
  3. Insert this canvas element into the DOM tree and set the hidden style.
  4. Each time requestAnimationFrame is triggered, draw the contents of this canvas to your visible canvas.