blob: 285e630c1b1ad75887c9cbbec4d7e468bed624dd [file] [log] [blame]
// Compiles a fiddle and then runs the fiddle. The output of both processes is
// combined into a single JSON output.
package main
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"flag"
"fmt"
"io"
"io/ioutil"
"net/http"
"path"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
"contrib.go.opencensus.io/exporter/stackdriver"
"github.com/gorilla/mux"
"go.opencensus.io/plugin/ochttp"
"go.opencensus.io/trace"
"go.skia.org/infra/fiddlek/go/types"
"go.skia.org/infra/go/common"
"go.skia.org/infra/go/exec"
"go.skia.org/infra/go/httputils"
"go.skia.org/infra/go/sklog"
"go.skia.org/infra/go/util"
"go.skia.org/infra/go/util/limitwriter"
"golang.org/x/sync/errgroup"
)
const (
// FPS is the Frames Per Second when generating an animation.
FPS = 60
)
// flags
var (
apoptosis = flag.Duration("apoptosis", 5*time.Minute, "How long a pod should live after starting a run.")
local = flag.Bool("local", false, "Running locally if true. As opposed to in production.")
fiddleRoot = flag.String("fiddle_root", "", "Directory location where all the work is done.")
checkout = flag.String("checkout", "", "Directory where Skia is checked out.")
port = flag.String("port", ":8000", "HTTP service address (e.g., ':8000')")
)
var (
mutex sync.Mutex
currentState types.State = types.IDLE
version string
)
func setStateStart() error {
mutex.Lock()
defer mutex.Unlock()
if currentState != types.IDLE {
return fmt.Errorf("Fiddle already being run.")
}
currentState = types.WRITING
return nil
}
func setState(s types.State) {
mutex.Lock()
defer mutex.Unlock()
sklog.Info(s)
currentState = s
}
func getState() types.State {
mutex.Lock()
defer mutex.Unlock()
return currentState
}
func serializeOutput(ctx context.Context, w io.Writer, res *types.Result) {
ctx, span := trace.StartSpan(ctx, "serializeOutput")
defer span.End()
w = limitwriter.New(w, types.MAX_JSON_SIZE)
if err := json.NewEncoder(w).Encode(res); err != nil {
sklog.Errorf("Failed to encode: %s", err)
}
}
func mainHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
resp := &types.FiddlerMainResponse{
State: getState(),
Version: version,
}
if err := json.NewEncoder(w).Encode(resp); err != nil {
sklog.Warningf("Failed to write response: %s", err)
}
}
func build(ctx context.Context, cwd string, args ...string) (string, error) {
ctx, span := trace.StartSpan(ctx, "build")
defer span.End()
return exec.RunCwd(ctx, cwd, args...)
}
func runHandler(w http.ResponseWriter, r *http.Request) {
ctx, span := trace.StartSpan(r.Context(), "fiddler")
defer span.End()
span.Annotate([]trace.Attribute{
trace.Int64Attribute("num cores", int64(runtime.NumCPU())),
}, "fiddler")
defer util.Close(r.Body)
if setStateStart() != nil {
http.Error(w, "Currently running a fiddle.", http.StatusTooManyRequests)
return
}
defer setState(types.IDLE)
var request types.FiddleContext
res := &types.Result{
Compile: types.Compile{},
Execute: types.Execute{
Errors: "",
Output: types.Output{},
},
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
res.Execute.Errors = fmt.Sprintf("Invalid JSON Request: %s", err)
serializeOutput(ctx, w, res)
return
}
// Apoptosis.
_, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
select {
case <-time.Tick(*apoptosis):
sklog.Fatal("Exceeded total allowed runtime.")
case <-ctx.Done():
sklog.Info("Exited cleanly.")
}
}()
// Compile draw.cpp into 'fiddle'.
if err := ioutil.WriteFile(filepath.Join(*checkout, "tools", "fiddle", "draw.cpp"), []byte(request.Code), 0644); err != nil {
res.Execute.Errors = fmt.Sprintf("Failed to write draw.cpp: %s", err)
serializeOutput(ctx, w, res)
return
}
setState(types.COMPILING)
buildResults, err := build(ctx, *checkout, filepath.Join(*fiddleRoot, "depot_tools", "ninja"), "-C", "out/Static")
buildLogs := strings.Split(buildResults, "\n")
sklog.Info("BuildLog")
for _, s := range buildLogs {
sklog.Info(s)
}
if err != nil {
res.Compile.Errors = err.Error()
res.Compile.Output = buildResults
serializeOutput(ctx, w, res)
return
}
setState(types.RUNNING)
if request.Options.Duration == 0 {
oneStep(ctx, *checkout, res, 0.0, request.Options.Duration)
serializeOutput(ctx, w, res)
} else {
var g errgroup.Group
// If this is an animated fiddle then:
// - Create tmpdir to store PNGs.
// - Loop over the following code to generate each frame of the animation.
// - Pass the duration and frame via cmd line flags to fiddle.
// - Decode and write PNGs (CPU+GPU) to their temp location.
// - Run ffmpeg over the resulting PNG's to generate the webm files.
// - Clean up tmp file.
// - Encode resulting webm files as base64 strings and return in JSON.
numFrames := int(FPS * (request.Options.Duration))
tmpDir, err := ioutil.TempDir("", "animation")
defer util.RemoveAll(tmpDir)
if err != nil {
res.Execute.Errors = fmt.Sprintf("Failed to create tmp dir for storing animation PNGs: %s", err)
serializeOutput(ctx, w, res)
return
}
// frameCh is a channel of all the frames we want rendered, which feeds the
// pool of Go routines that will do the actual rendering.
frameCh := make(chan int)
// mutex protects glinfo.
var mutex sync.Mutex
// glinfo is the info about the GL version for the GPU.
glinfo := ""
// Start a pool of workers to do the rendering.
for i := 0; i <= 5; i++ {
g.Go(func() error {
// Each Go func should have its own 'res'.
res := &types.Result{
Compile: types.Compile{},
Execute: types.Execute{
Errors: "",
Output: types.Output{},
},
}
for frameIndex := range frameCh {
sklog.Infof("Parallel render: %d", frameIndex)
frame := float64(frameIndex) / float64(numFrames)
oneStep(ctx, *checkout, res, frame, request.Options.Duration)
// Check for errors.
if res.Execute.Errors != "" {
return fmt.Errorf("Failed to render: %s", res.Execute.Errors)
}
// Extract CPU and GPU pngs to a tmp directory.
if err := extractPNG(res.Execute.Output.Raster, res, frameIndex, "CPU", tmpDir); err != nil {
return err
}
if err := extractPNG(res.Execute.Output.Gpu, res, frameIndex, "GPU", tmpDir); err != nil {
return err
}
mutex.Lock()
if glinfo == "" {
glinfo = res.Execute.Output.GLInfo
}
mutex.Unlock()
}
return nil
})
}
// Feed all the frame indices to the channel.
for i := 0; i <= numFrames; i++ {
frameCh <- i
}
close(frameCh)
// Wait for all the work to be done.
if err := g.Wait(); err != nil {
res.Execute.Errors = fmt.Sprintf("Failed to encode video: %s", err)
serializeOutput(ctx, w, res)
return
}
// Run ffmpeg for CPU and GPU.
if err := createWebm(ctx, "CPU", tmpDir); err != nil {
res.Execute.Errors = fmt.Sprintf("Failed to encode video: %s", err)
serializeOutput(ctx, w, res)
return
}
res.Execute.Output.AnimatedRaster = encodeWebm("CPU", tmpDir, res)
if err := createWebm(ctx, "GPU", tmpDir); err != nil {
res.Execute.Errors = fmt.Sprintf("Failed to encode video: %s", err)
serializeOutput(ctx, w, res)
return
}
res.Execute.Output.AnimatedGpu = encodeWebm("GPU", tmpDir, res)
res.Execute.Output.Raster = ""
res.Execute.Output.Gpu = ""
res.Execute.Output.GLInfo = glinfo
serializeOutput(ctx, w, res)
}
}
// encodeWebm encodes the webm as base64 and adds it to the results.
func encodeWebm(prefix, tmpDir string, res *types.Result) string {
b, err := ioutil.ReadFile(path.Join(tmpDir, fmt.Sprintf("%s.webm", prefix)))
if err != nil {
res.Execute.Errors = fmt.Sprintf("Failed to read resulting video: %s", err)
return ""
}
return base64.StdEncoding.EncodeToString(b)
}
// createWebm runs ffmpeg over the images in the given dir.
func createWebm(ctx context.Context, prefix, tmpDir string) error {
ctx, span := trace.StartSpan(ctx, "createWebm-"+prefix)
defer span.End()
// ffmpeg -r $FPS -pattern_type glob -i '*.png' -c:v libvpx-vp9 -lossless 1 output.webm
name := "ffmpeg"
args := []string{
"-r", fmt.Sprintf("%d", FPS),
"-pattern_type", "glob", "-i", prefix + "*.png",
"-c:v", "libvpx-vp9",
"-lossless", "1",
fmt.Sprintf("%s.webm", prefix),
}
output := &bytes.Buffer{}
runCmd := &exec.Command{
Name: name,
Args: args,
Dir: tmpDir,
CombinedOutput: output,
}
if err := exec.Run(ctx, runCmd); err != nil {
return fmt.Errorf("ffmpeg failed %#v %q: %s", *runCmd, util.Truncate(output.String(), 100), err)
}
return nil
}
// extractPNG pulls the base64 encoded PNG out of the results and writes it to the tmpDir.
func extractPNG(b64 string, res *types.Result, i int, prefix string, tmpDir string) error {
body, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
res.Execute.Errors = fmt.Sprintf("Failed to decode frame %d of %s: %s", i, prefix, err)
return err
}
if err := ioutil.WriteFile(path.Join(tmpDir, fmt.Sprintf("%s_%05d.png", prefix, i)), body, 0600); err != nil {
res.Execute.Errors = fmt.Sprintf("Failed to write frame %d of %s as a PNG: %s", i, prefix, err)
return err
}
return nil
}
func oneStep(ctx context.Context, checkout string, res *types.Result, frame float64, duration float64) {
ctx, span := trace.StartSpan(ctx, "oneStep")
defer span.End()
name := path.Join("/usr/local/bin/fiddle_secwrap")
args := []string{path.Join(checkout, "out", "Static", "fiddle")}
args = append(args, "--duration", fmt.Sprintf("%f", duration), "--frame", fmt.Sprintf("%f", frame))
stderr := bytes.Buffer{}
stdout := bytes.Buffer{}
runCmd := &exec.Command{
Name: name,
Args: args,
Dir: *fiddleRoot,
InheritPath: true,
Env: []string{"HOME=/tmp"},
InheritEnv: true,
Stdout: &stdout,
Stderr: &stderr,
}
if err := exec.Run(ctx, runCmd); err != nil {
sklog.Errorf("Failed to run: %s", err)
res.Execute.Errors = err.Error()
}
if res.Execute.Errors != "" && stderr.String() != "" {
sklog.Errorf("Found stderr output: %q", stderr.String())
res.Execute.Errors += "\n"
}
res.Execute.Errors += stderr.String()
if err := json.Unmarshal(stdout.Bytes(), &res.Execute.Output); err != nil {
if res.Execute.Errors != "" {
res.Execute.Errors += "\n"
}
res.Execute.Errors += "Failed to decode JSON output from fiddle.\n"
res.Execute.Errors += err.Error()
res.Execute.Errors += fmt.Sprintf("\nOutput was %q", stdout.Bytes())
}
}
func main() {
common.InitWithMust(
"fiddler",
)
if *fiddleRoot == "" {
sklog.Fatalf("The --fiddle_root flag is required.")
}
if *checkout == "" {
sklog.Fatalf("The --checkout flag is required.")
}
if !*local {
exporter, err := stackdriver.NewExporter(stackdriver.Options{
BundleDelayThreshold: time.Second / 10,
BundleCountThreshold: 10})
if err != nil {
sklog.Fatal(err)
}
trace.RegisterExporter(exporter)
trace.ApplyConfig(trace.Config{DefaultSampler: trace.AlwaysSample()})
_, span := trace.StartSpan(context.Background(), "main")
defer span.End()
}
b, err := ioutil.ReadFile(filepath.Join(*checkout, "VERSION"))
if err != nil {
sklog.Fatalf("Failed to read Skia version: %s", err)
}
version = strings.TrimSpace(string(b))
r := mux.NewRouter()
r.HandleFunc("/", mainHandler)
r.Handle("/run", &ochttp.Handler{Handler: http.HandlerFunc(runHandler)}) // Just wrap the /run handler for tracing.
h := httputils.LoggingGzipRequestResponse(r)
h = httputils.Healthz(r)
sklog.Info("Ready to serve.")
srv := &http.Server{
Handler: h,
Addr: *port,
WriteTimeout: 120 * time.Second,
ReadTimeout: 120 * time.Second,
}
sklog.Fatal(srv.ListenAndServe())
}