160 lines
4.6 KiB
Go
160 lines
4.6 KiB
Go
// Art Studio development host and deterministic headless replay.
|
|
// This does not replace the engine CLI; it provides a separately lazy-loaded
|
|
// browser entrypoint for integration with the existing documentation build.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"aego/art"
|
|
)
|
|
|
|
func main() {
|
|
if err := run(); err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
func run() error {
|
|
addr := flag.String("addr", "127.0.0.1:8097", "loopback listen address")
|
|
dir := flag.String("dir", ".cache/artstudio", "built browser directory")
|
|
replay := flag.String("replay", "", "replay a JSON command fixture instead of serving")
|
|
flag.Parse()
|
|
if *replay != "" {
|
|
return runReplay(*replay)
|
|
}
|
|
host, _, err := net.SplitHostPort(*addr)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ip := net.ParseIP(host)
|
|
if ip == nil || !ip.IsLoopback() {
|
|
return fmt.Errorf("artstudio: use a loopback listen address")
|
|
}
|
|
root, err := os.Stat(*dir)
|
|
if err != nil || !root.IsDir() {
|
|
return fmt.Errorf("artstudio: run tools/artstudio/build.sh first")
|
|
}
|
|
handler := staticHandler(*dir)
|
|
listener, err := net.Listen("tcp", *addr)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer listener.Close()
|
|
fmt.Printf("Art Studio: http://%s\n", listener.Addr())
|
|
server := &http.Server{Handler: handler, ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 30 * time.Second}
|
|
return server.Serve(listener)
|
|
}
|
|
|
|
// staticHandler serves only the explicit build outputs. It has no write/upload
|
|
// endpoint, and does not expose directory listings or unrelated working files.
|
|
func staticHandler(dir string) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
host := r.Host
|
|
if h, _, err := net.SplitHostPort(host); err == nil {
|
|
host = h
|
|
}
|
|
host = strings.Trim(host, "[]")
|
|
if ip := net.ParseIP(host); host != "localhost" && (ip == nil || !ip.IsLoopback()) {
|
|
http.Error(w, "loopback host required", http.StatusForbidden)
|
|
return
|
|
}
|
|
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
|
w.Header().Set("Allow", "GET, HEAD")
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
name := strings.TrimPrefix(r.URL.Path, "/")
|
|
if name == "" {
|
|
name = "index.html"
|
|
}
|
|
switch name {
|
|
case "index.html", "studio.css", "studio.js", "recovery.js", "gpu-preview.js", "wasm_exec.js", "artstudio.wasm", "preview.wasm":
|
|
default:
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
w.Header().Set("Referrer-Policy", "no-referrer")
|
|
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'")
|
|
http.ServeFile(w, r, filepath.Join(dir, name))
|
|
})
|
|
}
|
|
|
|
type replayFile struct {
|
|
Width int `json:"width"`
|
|
Height int `json:"height"`
|
|
Mode art.Mode `json:"mode"`
|
|
Operations []art.Operation `json:"operations"`
|
|
Frame art.ID `json:"frame"`
|
|
}
|
|
|
|
func runReplay(path string) error {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
data, err := io.ReadAll(io.LimitReader(f, 4<<20+1))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(data) > 4<<20 {
|
|
return fmt.Errorf("artstudio: replay exceeds limit")
|
|
}
|
|
var v replayFile
|
|
if err = json.Unmarshal(data, &v); err != nil {
|
|
return err
|
|
}
|
|
d, err := art.New(v.Width, v.Height, v.Mode, art.Limits{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer d.Close()
|
|
h := art.NewJournal(art.Limits{})
|
|
s, err := art.NewSession(d, h)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for i, op := range v.Operations {
|
|
if _, err = s.Execute(context.Background(), op); err != nil {
|
|
return fmt.Errorf("operation %d: %w", i, err)
|
|
}
|
|
}
|
|
if v.Frame == "" {
|
|
v.Frame = s.Frame
|
|
}
|
|
im, err := d.Render(v.Frame, art.RenderOptions{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
hash := sha256.Sum256(im.Pix)
|
|
frames := make(map[art.ID]map[art.Channel]string)
|
|
for _, frame := range d.Info().Frames {
|
|
channels := make(map[art.Channel]string)
|
|
for _, channel := range []art.Channel{art.Albedo, art.Normal, art.Height, art.Emission} {
|
|
pixels, err := d.Render(frame.ID, art.RenderOptions{Channel: channel})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
sum := sha256.Sum256(pixels.Pix)
|
|
channels[channel] = hex.EncodeToString(sum[:])
|
|
}
|
|
frames[frame.ID] = channels
|
|
}
|
|
return json.NewEncoder(os.Stdout).Encode(map[string]any{"pixelSHA256": hex.EncodeToString(hash[:]), "frameSHA256": frames, "state": s.Info()})
|
|
}
|