mirror of
https://github.com/reanimate/reanimate.git
synced 2026-09-14 09:32:22 +00:00
* Improve the safety of the playground and limit the resources it may use. * Don't send new code to server until 0.5s after the last keypress.
422 lines
14 KiB
Haskell
422 lines
14 KiB
Haskell
{-# LANGUAGE MultiWayIf #-}
|
|
{-|
|
|
Copyright : Written by David Himmelstrup
|
|
License : Unlicense
|
|
Maintainer : lemmih@gmail.com
|
|
Stability : experimental
|
|
Portability : POSIX
|
|
|
|
Internal tools for rastering SVGs and rendering videos. You are unlikely
|
|
to ever directly use the functions in this module.
|
|
|
|
-}
|
|
module Reanimate.Render
|
|
( render
|
|
, renderSvgs
|
|
, renderSnippets -- :: Animation -> IO ()
|
|
, renderLimitedFrames
|
|
, Format(..)
|
|
, Raster(..)
|
|
, Width, Height, FPS
|
|
, requireRaster -- :: Raster -> IO Raster
|
|
, selectRaster -- :: Raster -> IO Raster
|
|
, applyRaster -- :: Raster -> FilePath -> IO ()
|
|
) where
|
|
|
|
import Control.Concurrent
|
|
import Control.Exception
|
|
import Control.Monad (forM_, forever, unless, void, when)
|
|
import Data.Either
|
|
import Data.Function
|
|
import qualified Data.Text as T
|
|
import qualified Data.Text.IO as T
|
|
import Data.Time
|
|
import Graphics.SvgTree (Number (..))
|
|
import Numeric
|
|
import Reanimate.Animation
|
|
import Reanimate.Driver.Check
|
|
import Reanimate.Driver.Magick
|
|
import Reanimate.Misc
|
|
import Reanimate.Parameters
|
|
import System.Console.ANSI.Codes
|
|
import System.Exit
|
|
import System.FileLock (withTryFileLock, SharedExclusive(..), unlockFile)
|
|
import System.Directory
|
|
import System.FilePath (replaceExtension, (<.>), (</>))
|
|
import System.IO
|
|
import Text.Printf (printf)
|
|
|
|
idempotentFile :: FilePath -> IO () -> IO ()
|
|
idempotentFile path action = do
|
|
_ <- withTryFileLock lockFile Exclusive $ \lock -> do
|
|
haveFile <- doesFileExist path
|
|
unless haveFile action
|
|
unlockFile lock
|
|
_ <- try (removeFile lockFile) :: IO (Either SomeException ())
|
|
return ()
|
|
return ()
|
|
where
|
|
lockFile = path <.> "lock"
|
|
|
|
-- | Generate SVGs at 60fps and put them in a folder.
|
|
renderSvgs :: FilePath -> Int -> Bool -> Animation -> IO ()
|
|
renderSvgs folder offset _prettyPrint ani = do
|
|
print frameCount
|
|
lock <- newMVar ()
|
|
|
|
handle errHandler $ concurrentForM_ (frameOrder rate frameCount) $ \nth' -> do
|
|
let nth = (nth'+offset) `mod` frameCount
|
|
now = (duration ani / (fromIntegral frameCount - 1)) * fromIntegral nth
|
|
frame = frameAt (if frameCount <= 1 then 0 else now) ani
|
|
svg = renderSvg Nothing Nothing frame
|
|
path = folder </> show nth <.> "svg"
|
|
|
|
idempotentFile path $ writeFile path svg
|
|
withMVar lock $ \_ -> do
|
|
print nth
|
|
hFlush stdout
|
|
where
|
|
rate = 60
|
|
frameCount = round (duration ani * fromIntegral rate) :: Int
|
|
errHandler (ErrorCall msg) = do
|
|
hPutStrLn stderr msg
|
|
exitWith (ExitFailure 1)
|
|
|
|
-- | Render as many frames as possible in 2 seconds. Limited to 20 frames.
|
|
renderLimitedFrames :: FilePath -> Int -> Bool -> Int -> Animation -> IO ()
|
|
renderLimitedFrames folder offset _prettyPrint rate ani = do
|
|
now <- getCurrentTime
|
|
worker (addUTCTime timeLimit now) frameLimit (frameOrder rate frameCount)
|
|
where
|
|
timeLimit = 2
|
|
frameLimit = 20 :: Int
|
|
worker _ 0 _ = return ()
|
|
worker _ _ [] = putStrLn "Done"
|
|
worker localTimeLimit l (x:xs) = do
|
|
curTime <- getCurrentTime
|
|
if curTime > localTimeLimit
|
|
then return ()
|
|
else do
|
|
let nth = (x+offset) `mod` frameCount
|
|
now = (duration ani / (fromIntegral frameCount - 1)) * fromIntegral nth
|
|
frame = frameAt (if frameCount <= 1 then 0 else now) ani
|
|
svg = renderSvg Nothing Nothing frame
|
|
path = folder </> show nth <.> "svg"
|
|
tmpPath = path <.> "tmp"
|
|
haveFile <- doesFileExist path
|
|
if haveFile
|
|
then worker localTimeLimit l xs
|
|
else do
|
|
writeFile tmpPath svg
|
|
renameOrCopyFile tmpPath path
|
|
print nth
|
|
worker localTimeLimit (l-1) xs
|
|
frameCount = round (duration ani * fromIntegral rate) :: Int
|
|
|
|
-- XXX: Merge with 'renderSvgs'
|
|
-- | Render 10 frames and print them to stdout. Used for testing.
|
|
--
|
|
-- XXX: Not related to the snippets in the playground.
|
|
renderSnippets :: Animation -> IO ()
|
|
renderSnippets ani = forM_ [0 .. frameCount - 1] $ \nth -> do
|
|
let now = (duration ani / (fromIntegral frameCount - 1)) * fromIntegral nth
|
|
frame = frameAt now ani
|
|
svg = renderSvg Nothing Nothing frame
|
|
putStr (show nth)
|
|
T.putStrLn $ T.concat . T.lines . T.pack $ svg
|
|
where frameCount = 10 :: Integer
|
|
|
|
frameOrder :: Int -> Int -> [Int]
|
|
frameOrder fps nFrames = worker [] fps
|
|
where
|
|
worker _seen 0 = []
|
|
worker seen nthFrame = filterFrameList seen nthFrame nFrames
|
|
++ worker (nthFrame : seen) (nthFrame `div` 2)
|
|
|
|
filterFrameList :: [Int] -> Int -> Int -> [Int]
|
|
filterFrameList seen nthFrame nFrames = filter (not . isSeen)
|
|
[0, nthFrame .. nFrames - 1]
|
|
where isSeen x = any (\y -> x `mod` y == 0) seen
|
|
|
|
-- | Video formats supported by reanimate.
|
|
data Format = RenderMp4 | RenderGif | RenderWebm
|
|
deriving (Show)
|
|
|
|
mp4Arguments :: FPS -> FilePath -> FilePath -> FilePath -> [String]
|
|
mp4Arguments fps progress template target =
|
|
[ "-r"
|
|
, show fps
|
|
, "-i"
|
|
, template
|
|
, "-y"
|
|
, "-c:v"
|
|
, "libx264"
|
|
, "-vf"
|
|
, "fps=" ++ show fps
|
|
, "-preset"
|
|
, "slow"
|
|
, "-crf"
|
|
, "18"
|
|
, "-movflags"
|
|
, "+faststart"
|
|
, "-progress"
|
|
, progress
|
|
, "-pix_fmt"
|
|
, "yuv420p"
|
|
, target
|
|
]
|
|
|
|
-- gifArguments :: FPS -> FilePath -> FilePath -> FilePath -> [String]
|
|
-- gifArguments fps progress template target =
|
|
|
|
-- | Render animation to a video file with given parameters.
|
|
render
|
|
:: Animation
|
|
-> FilePath
|
|
-> Raster
|
|
-> Format
|
|
-> Width
|
|
-> Height
|
|
-> FPS
|
|
-> Bool
|
|
-> IO ()
|
|
render ani target raster format width height fps partial = do
|
|
printf "Starting render of animation: %.1f\n" (duration ani)
|
|
ffmpeg <- requireExecutable "ffmpeg"
|
|
generateFrames raster ani width height fps partial $ \template ->
|
|
withTempFile "txt" $ \progress -> do
|
|
writeFile progress ""
|
|
progressH <- openFile progress ReadMode
|
|
hSetBuffering progressH NoBuffering
|
|
allFinished <- newEmptyMVar
|
|
void $ forkIO $ do
|
|
progressPrinter "rendered" (animationFrameCount ani fps)
|
|
$ \done -> fix $ \loop -> do
|
|
eof <- hIsEOF progressH
|
|
if eof
|
|
then threadDelay 1000000 >> loop
|
|
else do
|
|
l <- try (hGetLine progressH)
|
|
case l of
|
|
Left SomeException{} -> return ()
|
|
Right str ->
|
|
case take 6 str of
|
|
"frame=" -> do
|
|
void $ swapMVar done (read (drop 6 str))
|
|
loop
|
|
_ | str == "progress=end" -> return ()
|
|
_ -> loop
|
|
putMVar allFinished ()
|
|
case format of
|
|
RenderMp4 -> runCmd ffmpeg (mp4Arguments fps progress template target)
|
|
RenderGif -> withTempFile "png" $ \palette -> do
|
|
runCmd
|
|
ffmpeg
|
|
[ "-i"
|
|
, template
|
|
, "-y"
|
|
, "-vf"
|
|
, "fps="
|
|
++ show fps
|
|
++ ",scale="
|
|
++ show width
|
|
++ ":"
|
|
++ show height
|
|
++ ":flags=lanczos,palettegen"
|
|
, "-t"
|
|
, showFFloat Nothing (duration ani) ""
|
|
, palette
|
|
]
|
|
runCmd
|
|
ffmpeg
|
|
[ "-framerate"
|
|
, show fps
|
|
, "-i"
|
|
, template
|
|
, "-y"
|
|
, "-i"
|
|
, palette
|
|
, "-progress"
|
|
, progress
|
|
, "-filter_complex"
|
|
, "fps="
|
|
++ show fps
|
|
++ ",scale="
|
|
++ show width
|
|
++ ":"
|
|
++ show height
|
|
++ ":flags=lanczos[x];[x][1:v]paletteuse"
|
|
, "-t"
|
|
, showFFloat Nothing (duration ani) ""
|
|
, target
|
|
]
|
|
RenderWebm -> runCmd
|
|
ffmpeg
|
|
[ "-r"
|
|
, show fps
|
|
, "-i"
|
|
, template
|
|
, "-y"
|
|
, "-progress"
|
|
, progress
|
|
, "-c:v"
|
|
, "libvpx-vp9"
|
|
, "-vf"
|
|
, "fps=" ++ show fps
|
|
, target
|
|
]
|
|
takeMVar allFinished
|
|
|
|
---------------------------------------------------------------------------------
|
|
-- Helpers
|
|
|
|
progressPrinter :: String -> Int -> (MVar Int -> IO ()) -> IO ()
|
|
progressPrinter typeName maxCount action = do
|
|
printf "\rFrames %s: 0/%d" typeName maxCount
|
|
putStr $ clearFromCursorToLineEndCode ++ "\r"
|
|
done <- newMVar (0 :: Int)
|
|
start <- getCurrentTime
|
|
let bgThread = forever $ do
|
|
nDone <- readMVar done
|
|
now <- getCurrentTime
|
|
let spent = diffUTCTime now start
|
|
remaining =
|
|
(spent / (fromIntegral nDone / fromIntegral maxCount)) - spent
|
|
printf "\rFrames %s: %d/%d" typeName nDone maxCount
|
|
putStr $ ", time spent: " ++ ppDiff spent
|
|
unless (nDone == 0) $ do
|
|
putStr $ ", time remaining: " ++ ppDiff remaining
|
|
putStr $ ", total time: " ++ ppDiff (remaining + spent)
|
|
putStr $ clearFromCursorToLineEndCode ++ "\r"
|
|
hFlush stdout
|
|
threadDelay 1000000
|
|
withBackgroundThread bgThread $ action done
|
|
now <- getCurrentTime
|
|
let spent = diffUTCTime now start
|
|
printf "\rFrames %s: %d/%d" typeName maxCount maxCount
|
|
putStr $ ", time spent: " ++ ppDiff spent
|
|
putStr $ clearFromCursorToLineEndCode ++ "\n"
|
|
|
|
animationFrameCount :: Animation -> FPS -> Int
|
|
animationFrameCount ani rate = round (duration ani * fromIntegral rate) :: Int
|
|
|
|
generateFrames
|
|
:: Raster -> Animation -> Width -> Height -> FPS -> Bool -> (FilePath -> IO a) -> IO a
|
|
generateFrames raster ani width_ height_ rate partial action = withTempDir $ \tmp -> do
|
|
let frameName nth = tmp </> printf nameTemplate nth
|
|
setRootDirectory tmp
|
|
progressPrinter "generated" frameCount
|
|
$ \done -> handle h $ concurrentForM_ frames $ \n -> do
|
|
writeFile (frameName n) $ renderSvg width height $ nthFrame n
|
|
modifyMVar_ done $ \nDone -> return (nDone + 1)
|
|
|
|
when (isValidRaster raster)
|
|
$ progressPrinter "rastered" frameCount
|
|
$ \done -> handle h $ concurrentForM_ frames $ \n -> do
|
|
applyRaster raster (frameName n)
|
|
modifyMVar_ done $ \nDone -> return (nDone + 1)
|
|
|
|
action (tmp </> rasterTemplate raster)
|
|
where
|
|
isValidRaster RasterNone = False
|
|
isValidRaster RasterAuto = False
|
|
isValidRaster _ = True
|
|
|
|
width = Just $ Px $ fromIntegral width_
|
|
height = Just $ Px $ fromIntegral height_
|
|
h UserInterrupt | partial = do
|
|
hPutStrLn
|
|
stderr
|
|
"\nCtrl-C detected. Trying to generate video with available frames. \
|
|
\Hit ctrl-c again to abort."
|
|
return ()
|
|
h other = throwIO other
|
|
-- frames = [0..frameCount-1]
|
|
frames = frameOrder rate frameCount
|
|
nthFrame nth = frameAt (recip (fromIntegral rate) * fromIntegral nth) ani
|
|
frameCount = animationFrameCount ani rate
|
|
nameTemplate :: String
|
|
nameTemplate = "render-%05d.svg"
|
|
|
|
withBackgroundThread :: IO () -> IO a -> IO a
|
|
withBackgroundThread t = bracket (forkIO t) killThread . const
|
|
|
|
ppDiff :: NominalDiffTime -> String
|
|
ppDiff diff | hours == 0 && mins == 0 = show secs ++ "s"
|
|
| hours == 0 = printf "%.2d:%.2d" mins secs
|
|
| otherwise = printf "%.2d:%.2d:%.2d" hours mins secs
|
|
where
|
|
(osecs, secs) = round diff `divMod` (60 :: Int)
|
|
(hours, mins) = osecs `divMod` 60
|
|
|
|
rasterTemplate :: Raster -> String
|
|
rasterTemplate RasterNone = "render-%05d.svg"
|
|
rasterTemplate RasterAuto = "render-%05d.svg"
|
|
rasterTemplate _ = "render-%05d.png"
|
|
|
|
-- | Resolve RasterNone and RasterAuto. If no valid raster can
|
|
-- be found, exit with an error message.
|
|
requireRaster :: Raster -> IO Raster
|
|
requireRaster raster = do
|
|
raster' <- selectRaster (if raster == RasterNone then RasterAuto else raster)
|
|
case raster' of
|
|
RasterNone -> do
|
|
hPutStrLn
|
|
stderr
|
|
"Raster required but none could be found. \
|
|
\Please install either inkscape, imagemagick, or rsvg-convert."
|
|
exitWith (ExitFailure 1)
|
|
_ -> pure raster'
|
|
|
|
-- | Resolve RasterNone and RasterAuto. If no valid raster can
|
|
-- be found, return RasterNone.
|
|
selectRaster :: Raster -> IO Raster
|
|
selectRaster RasterAuto = do
|
|
rsvg <- hasRSvg
|
|
ink <- hasInkscape
|
|
magick <- hasMagick
|
|
if
|
|
| isRight rsvg -> pure RasterRSvg
|
|
| isRight ink -> pure RasterInkscape
|
|
| isRight magick -> pure RasterMagick
|
|
| otherwise -> pure RasterNone
|
|
selectRaster r = pure r
|
|
|
|
-- | Convert SVG file to a PNG file with selected raster engine. If
|
|
-- raster engine is RasterAuto or RasterNone, do nothing.
|
|
applyRaster :: Raster -> FilePath -> IO ()
|
|
applyRaster RasterNone _ = return ()
|
|
applyRaster RasterAuto _ = return ()
|
|
applyRaster RasterInkscape path = runCmd
|
|
"inkscape"
|
|
[ "--without-gui"
|
|
, "--file=" ++ path
|
|
, "--export-png=" ++ replaceExtension path "png"
|
|
]
|
|
applyRaster RasterRSvg path = runCmd
|
|
"rsvg-convert"
|
|
[path, "--unlimited", "--output", replaceExtension path "png"]
|
|
applyRaster RasterMagick path =
|
|
runCmd magickCmd [path, replaceExtension path "png"]
|
|
|
|
concurrentForM_ :: [a] -> (a -> IO ()) -> IO ()
|
|
concurrentForM_ lst action = do
|
|
n <- getNumCapabilities
|
|
sem <- newQSemN n
|
|
eVar <- newEmptyMVar
|
|
forM_ lst $ \elt -> do
|
|
waitQSemN sem 1
|
|
emp <- isEmptyMVar eVar
|
|
if emp
|
|
then
|
|
void
|
|
$ forkIO
|
|
( catch (action elt) (void . tryPutMVar eVar)
|
|
`finally` signalQSemN sem 1
|
|
)
|
|
else signalQSemN sem 1
|
|
waitQSemN sem n
|
|
mbE <- tryTakeMVar eVar
|
|
case mbE of
|
|
Nothing -> return ()
|
|
Just e -> throwIO (e :: SomeException)
|