UNPKG

@remotion/studio

Version:

APIs for interacting with the Remotion Studio

269 lines (268 loc) 10.4 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.TimelineVideoInfo = void 0; const jsx_runtime_1 = require("react/jsx-runtime"); const react_1 = require("react"); const remotion_1 = require("remotion"); const extract_frames_1 = require("../../helpers/extract-frames"); const frame_database_1 = require("../../helpers/frame-database"); const resize_video_frame_1 = require("../../helpers/resize-video-frame"); const timeline_layout_1 = require("../../helpers/timeline-layout"); const HEIGHT = (0, timeline_layout_1.getTimelineLayerHeight)('video') - 2; const containerStyle = { height: HEIGHT, width: '100%', backgroundColor: 'rgba(0, 0, 0, 0.3)', display: 'flex', borderTopLeftRadius: 2, borderBottomLeftRadius: 2, fontSize: 10, fontFamily: 'Arial, Helvetica', }; const WEBCODECS_TIMESCALE = 1000000; const MAX_TIME_DEVIATION = WEBCODECS_TIMESCALE * 0.05; const getDurationOfOneFrame = ({ visualizationWidth, aspectRatio, segmentDuration, }) => { const framesFitInWidthUnrounded = visualizationWidth / (HEIGHT * aspectRatio); return (segmentDuration / framesFitInWidthUnrounded) * WEBCODECS_TIMESCALE; }; const fixRounding = (value) => { if (value % 1 >= 0.49999999) { return Math.ceil(value); } return Math.floor(value); }; const calculateTimestampSlots = ({ visualizationWidth, fromSeconds, segmentDuration, aspectRatio, }) => { const framesFitInWidthUnrounded = visualizationWidth / (HEIGHT * aspectRatio); const framesFitInWidth = Math.ceil(framesFitInWidthUnrounded); const durationOfOneFrame = getDurationOfOneFrame({ visualizationWidth, aspectRatio, segmentDuration, }); const timestampTargets = []; for (let i = 0; i < framesFitInWidth + 1; i++) { const target = fromSeconds * WEBCODECS_TIMESCALE + durationOfOneFrame * (i + 0.5); const snappedToDuration = (Math.round(fixRounding(target / durationOfOneFrame)) - 1) * durationOfOneFrame; timestampTargets.push(snappedToDuration); } return timestampTargets; }; const ensureSlots = ({ filledSlots, visualizationWidth, fromSeconds, toSeconds, aspectRatio, }) => { const segmentDuration = toSeconds - fromSeconds; const timestampTargets = calculateTimestampSlots({ visualizationWidth, fromSeconds, segmentDuration, aspectRatio, }); for (const timestamp of timestampTargets) { if (!filledSlots.has(timestamp)) { filledSlots.set(timestamp, undefined); } } }; const drawSlot = ({ frame, ctx, filledSlots, visualizationWidth, timestamp, segmentDuration, fromSeconds, }) => { const durationOfOneFrame = getDurationOfOneFrame({ visualizationWidth, aspectRatio: frame.displayWidth / frame.displayHeight, segmentDuration, }); const relativeTimestamp = timestamp - fromSeconds * WEBCODECS_TIMESCALE; const frameIndex = relativeTimestamp / durationOfOneFrame; const left = Math.floor((frameIndex * frame.displayWidth) / window.devicePixelRatio); // round to avoid antialiasing ctx.drawImage(frame, left, 0, frame.displayWidth / window.devicePixelRatio, frame.displayHeight / window.devicePixelRatio); filledSlots.set(timestamp, frame.timestamp); }; const fillWithCachedFrames = ({ ctx, visualizationWidth, filledSlots, src, segmentDuration, fromSeconds, }) => { const keys = Array.from(frame_database_1.frameDatabase.keys()).filter((k) => k.startsWith(src)); const targets = Array.from(filledSlots.keys()); for (const timestamp of targets) { let bestKey; let bestDistance = Infinity; for (const key of keys) { const distance = Math.abs((0, frame_database_1.getTimestampFromFrameDatabaseKey)(key) - timestamp); if (distance < bestDistance) { bestDistance = distance; bestKey = key; } } if (!bestKey) { continue; } const frame = frame_database_1.frameDatabase.get(bestKey); if (!frame) { continue; } const alreadyFilled = filledSlots.get(timestamp); // Don't fill if a closer frame was already drawn if (alreadyFilled && Math.abs(alreadyFilled - timestamp) <= Math.abs(frame.frame.timestamp - timestamp)) { continue; } frame.lastUsed = Date.now(); drawSlot({ ctx, frame: frame.frame, filledSlots, visualizationWidth, timestamp, segmentDuration, fromSeconds, }); } }; const fillFrameWhereItFits = ({ frame, filledSlots, ctx, visualizationWidth, segmentDuration, fromSeconds, }) => { const slots = Array.from(filledSlots.keys()); for (let i = 0; i < slots.length; i++) { const slot = slots[i]; if (Math.abs(slot - frame.timestamp) > MAX_TIME_DEVIATION) { continue; } const filled = filledSlots.get(slot); // Don't fill if a better timestamp was already filled if (filled && Math.abs(filled - slot) <= Math.abs(filled - frame.timestamp)) { continue; } drawSlot({ ctx, frame, filledSlots, visualizationWidth, timestamp: slot, segmentDuration, fromSeconds, }); } }; const TimelineVideoInfo = ({ src, visualizationWidth, startFrom, durationInFrames }) => { const { fps } = (0, remotion_1.useVideoConfig)(); const ref = (0, react_1.useRef)(null); const [error, setError] = (0, react_1.useState)(null); const aspectRatio = (0, react_1.useRef)((0, frame_database_1.getAspectRatioFromCache)(src)); (0, react_1.useEffect)(() => { if (error) { return; } const { current } = ref; if (!current) { return; } const controller = new AbortController(); const canvas = document.createElement('canvas'); canvas.width = visualizationWidth; canvas.height = HEIGHT; const ctx = canvas.getContext('2d'); if (!ctx) { return; } current.appendChild(canvas); // desired-timestamp -> filled-timestamp const filledSlots = new Map(); const fromSeconds = startFrom / fps; const toSeconds = (startFrom + durationInFrames) / fps; if (aspectRatio.current !== null) { ensureSlots({ filledSlots, visualizationWidth, fromSeconds, toSeconds, aspectRatio: aspectRatio.current, }); fillWithCachedFrames({ ctx, visualizationWidth, filledSlots, src, segmentDuration: toSeconds - fromSeconds, fromSeconds, }); const unfilled = Array.from(filledSlots.keys()).filter((timestamp) => !filledSlots.get(timestamp)); // Don't extract frames if all slots are filled if (unfilled.length === 0) { return () => { current.removeChild(canvas); (0, frame_database_1.clearOldFrames)(); }; } } (0, frame_database_1.clearOldFrames)(); (0, extract_frames_1.extractFrames)({ timestampsInSeconds: ({ track, }) => { aspectRatio.current = track.width / track.height; frame_database_1.aspectRatioCache.set(src, aspectRatio.current); ensureSlots({ filledSlots, fromSeconds, toSeconds, visualizationWidth, aspectRatio: aspectRatio.current, }); return Array.from(filledSlots.keys()).map((timestamp) => timestamp / WEBCODECS_TIMESCALE); }, src, onFrame: (frame) => { const scale = (HEIGHT / frame.displayHeight) * window.devicePixelRatio; const transformed = (0, resize_video_frame_1.resizeVideoFrame)({ frame, scale, }); if (transformed !== frame) { frame.close(); } const databaseKey = (0, frame_database_1.makeFrameDatabaseKey)(src, transformed.timestamp); const existingFrame = frame_database_1.frameDatabase.get(databaseKey); if (existingFrame) { existingFrame.frame.close(); } frame_database_1.frameDatabase.set(databaseKey, { frame: transformed, lastUsed: Date.now(), }); if (aspectRatio.current === null) { throw new Error('Aspect ratio is not set'); } ensureSlots({ filledSlots, fromSeconds, toSeconds, visualizationWidth, aspectRatio: aspectRatio.current, }); fillFrameWhereItFits({ ctx, filledSlots, visualizationWidth, frame: transformed, segmentDuration: toSeconds - fromSeconds, fromSeconds, }); }, signal: controller.signal, }) .then(() => { fillWithCachedFrames({ ctx, visualizationWidth, filledSlots, src, segmentDuration: toSeconds - fromSeconds, fromSeconds, }); }) .catch((e) => { setError(e); }) .finally(() => { (0, frame_database_1.clearOldFrames)(); }); return () => { controller.abort(); current.removeChild(canvas); }; }, [durationInFrames, error, fps, src, startFrom, visualizationWidth]); return (0, jsx_runtime_1.jsx)("div", { ref: ref, style: containerStyle }); }; exports.TimelineVideoInfo = TimelineVideoInfo;