Initial commit
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
# Käkisalmi proto
|
||||||
|
|
||||||
|
GOOS=linux GOARCH=amd64 go build -o booth_linux booth_pipeline.go
|
||||||
@@ -0,0 +1,510 @@
|
|||||||
|
// Museum Story Booth — Video Processing Pipeline (Go)
|
||||||
|
// =====================================================
|
||||||
|
// Produces a panning photo-reel composite from a raw booth recording.
|
||||||
|
//
|
||||||
|
// Build
|
||||||
|
// -----
|
||||||
|
// go mod init booth
|
||||||
|
// go get github.com/fsnotify/fsnotify
|
||||||
|
// go build -o booth booth_pipeline.go
|
||||||
|
//
|
||||||
|
// Usage
|
||||||
|
// -----
|
||||||
|
// ./booth process recording.mp4 ./output --speaker "Jane Smith"
|
||||||
|
// ./booth watch ./watch_folder ./output --title "Community Voices"
|
||||||
|
//
|
||||||
|
// Dependencies
|
||||||
|
// ------------
|
||||||
|
// ffmpeg / ffprobe must be on PATH
|
||||||
|
// github.com/fsnotify/fsnotify (only needed for 'watch' command)
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fsnotify/fsnotify"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Configuration ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const (
|
||||||
|
frameInterval = 5 // Extract one key frame every N seconds
|
||||||
|
panelWidth = 320 // Each filmstrip panel width in px
|
||||||
|
panelHeight = 240 // Each filmstrip panel height in px
|
||||||
|
border = 8 // White border around each panel in px
|
||||||
|
panelGap = 10 // Horizontal gap between panels in px
|
||||||
|
|
||||||
|
outputWidth = 1920
|
||||||
|
outputHeight = 1080
|
||||||
|
outputFPS = 25
|
||||||
|
|
||||||
|
panSpeed = 55 // Pixels/second the reel scrolls
|
||||||
|
subjectScale = 0.65 // Subject height as fraction of outputHeight
|
||||||
|
|
||||||
|
grainStrength = 16
|
||||||
|
vignetteAngle = "PI/4"
|
||||||
|
|
||||||
|
defaultExhibit = "Stories from the Community"
|
||||||
|
fontBold = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
|
||||||
|
fontRegular = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
|
||||||
|
)
|
||||||
|
|
||||||
|
// videoExtensions that trigger the watch pipeline
|
||||||
|
var videoExtensions = map[string]bool{
|
||||||
|
".mp4": true, ".mov": true, ".mxf": true, ".avi": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Metadata ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type Metadata struct {
|
||||||
|
ContentID string `json:"content_id"`
|
||||||
|
Exhibit string `json:"exhibit"`
|
||||||
|
Speaker string `json:"speaker"`
|
||||||
|
DateRecorded string `json:"date_recorded"`
|
||||||
|
DurationSeconds float64 `json:"duration_seconds"`
|
||||||
|
PipelineVersion string `json:"pipeline_version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// run executes a command, streaming its stderr to our log. Fatal on error.
|
||||||
|
func run(label string, name string, args ...string) {
|
||||||
|
if label != "" {
|
||||||
|
log.Printf(" %s", label)
|
||||||
|
}
|
||||||
|
preview := name + " " + strings.Join(args, " ")
|
||||||
|
if len(preview) > 120 {
|
||||||
|
preview = preview[:120] + "…"
|
||||||
|
}
|
||||||
|
log.Printf(" $ %s", preview)
|
||||||
|
|
||||||
|
cmd := exec.Command(name, args...)
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
log.Fatalf("command failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeFormat returns the "format" section from ffprobe as a map.
|
||||||
|
func probeFormat(path string) map[string]interface{} {
|
||||||
|
out, err := exec.Command(
|
||||||
|
"ffprobe", "-v", "quiet", "-print_format", "json",
|
||||||
|
"-show_format", "-show_streams", path,
|
||||||
|
).Output()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("ffprobe failed: %v", err)
|
||||||
|
}
|
||||||
|
var result map[string]interface{}
|
||||||
|
if err := json.Unmarshal(out, &result); err != nil {
|
||||||
|
log.Fatalf("ffprobe JSON parse: %v", err)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func getDuration(path string) float64 {
|
||||||
|
info := probeFormat(path)
|
||||||
|
format := info["format"].(map[string]interface{})
|
||||||
|
d, _ := strconv.ParseFloat(format["duration"].(string), 64)
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func getVideoSize(path string) (int, int) {
|
||||||
|
info := probeFormat(path)
|
||||||
|
streams := info["streams"].([]interface{})
|
||||||
|
for _, s := range streams {
|
||||||
|
stream := s.(map[string]interface{})
|
||||||
|
if stream["codec_type"] == "video" {
|
||||||
|
w := int(stream["width"].(float64))
|
||||||
|
h := int(stream["height"].(float64))
|
||||||
|
return w, h
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Fatalf("no video stream in %s", path)
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// escDrawtext escapes characters special to ffmpeg's drawtext filter.
|
||||||
|
func escDrawtext(s string) string {
|
||||||
|
s = strings.ReplaceAll(s, `\`, `\\`)
|
||||||
|
s = strings.ReplaceAll(s, `'`, `\'`)
|
||||||
|
s = strings.ReplaceAll(s, `:`, `\:`)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// glob returns sorted matches, fatal if the pattern errors.
|
||||||
|
func glob(pattern string) []string {
|
||||||
|
matches, err := filepath.Glob(pattern)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("glob %s: %v", pattern, err)
|
||||||
|
}
|
||||||
|
sort.Strings(matches)
|
||||||
|
return matches
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stage 1: Extract key frames ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
func extractFrames(inputPath, framesDir string) []string {
|
||||||
|
log.Println("\n[1/5] Extracting key frames...")
|
||||||
|
|
||||||
|
if err := os.MkdirAll(framesDir, 0755); err != nil {
|
||||||
|
log.Fatalf("mkdir frames: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
vf := fmt.Sprintf(
|
||||||
|
"fps=1/%d,scale=%d:%d:force_original_aspect_ratio=increase,crop=%d:%d",
|
||||||
|
frameInterval,
|
||||||
|
panelWidth, panelHeight,
|
||||||
|
panelWidth, panelHeight,
|
||||||
|
)
|
||||||
|
run("", "ffmpeg", "-y", "-i", inputPath,
|
||||||
|
"-vf", vf, "-q:v", "2",
|
||||||
|
filepath.Join(framesDir, "frame_%04d.jpg"),
|
||||||
|
)
|
||||||
|
|
||||||
|
frames := glob(filepath.Join(framesDir, "frame_*.jpg"))
|
||||||
|
log.Printf(" → %d frames extracted", len(frames))
|
||||||
|
return frames
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stage 2: Build filmstrip image ────────────────────────────────────────────
|
||||||
|
|
||||||
|
func buildFilmstrip(frames []string, tmpDir string) (stripPath string, stripW, stripH int) {
|
||||||
|
log.Println("\n[2/5] Building filmstrip image...")
|
||||||
|
|
||||||
|
if len(frames) == 0 {
|
||||||
|
log.Fatal("no frames to stitch — check your input video")
|
||||||
|
}
|
||||||
|
|
||||||
|
n := len(frames)
|
||||||
|
panelW := panelWidth + border*2
|
||||||
|
panelH := panelHeight + border*2
|
||||||
|
stripW = n*panelW + (n-1)*panelGap
|
||||||
|
stripH = panelH
|
||||||
|
stripPath = filepath.Join(tmpDir, "filmstrip.png")
|
||||||
|
|
||||||
|
// Build the ffmpeg command inputs and filter_complex string
|
||||||
|
args := []string{"-y"}
|
||||||
|
for _, f := range frames {
|
||||||
|
args = append(args, "-i", f)
|
||||||
|
}
|
||||||
|
|
||||||
|
sepia := "colorchannelmixer=" +
|
||||||
|
"rr=0.393:rg=0.769:rb=0.189:" +
|
||||||
|
"gr=0.349:gg=0.686:gb=0.168:" +
|
||||||
|
"br=0.272:bg=0.534:bb=0.131"
|
||||||
|
|
||||||
|
var filterParts []string
|
||||||
|
|
||||||
|
// Per-frame: scale → white border pad → sepia
|
||||||
|
for i := range frames {
|
||||||
|
filterParts = append(filterParts, fmt.Sprintf(
|
||||||
|
"[%d:v]scale=%d:%d:force_original_aspect_ratio=increase,crop=%d:%d,pad=%d:%d:%d:%d:color=white,%s[p%d]",
|
||||||
|
i,
|
||||||
|
panelWidth, panelHeight,
|
||||||
|
panelWidth, panelHeight,
|
||||||
|
panelW, panelH, border, border,
|
||||||
|
sepia, i,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add right-side gap to each panel (except last), then hstack
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
extra := 0
|
||||||
|
if i < n-1 {
|
||||||
|
extra = panelGap
|
||||||
|
}
|
||||||
|
filterParts = append(filterParts, fmt.Sprintf(
|
||||||
|
"[p%d]pad=%d:%d:0:0:color=0x1a1a1a[g%d]",
|
||||||
|
i, panelW+extra, panelH, i,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
var stackInputs strings.Builder
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
fmt.Fprintf(&stackInputs, "[g%d]", i)
|
||||||
|
}
|
||||||
|
filterParts = append(filterParts,
|
||||||
|
fmt.Sprintf("%shstack=inputs=%d[strip]", stackInputs.String(), n),
|
||||||
|
)
|
||||||
|
|
||||||
|
args = append(args,
|
||||||
|
"-filter_complex", strings.Join(filterParts, ";"),
|
||||||
|
"-map", "[strip]",
|
||||||
|
"-frames:v", "1",
|
||||||
|
stripPath,
|
||||||
|
)
|
||||||
|
|
||||||
|
run("Stitching panels with hstack…", "ffmpeg", args...)
|
||||||
|
return stripPath, stripW, stripH
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stage 3: Composite ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func composite(
|
||||||
|
inputPath, stripPath string,
|
||||||
|
stripW, stripH int,
|
||||||
|
outputPath string,
|
||||||
|
meta map[string]string,
|
||||||
|
) {
|
||||||
|
log.Println("\n[3/5] Compositing panning reel + subject video...")
|
||||||
|
|
||||||
|
duration := getDuration(inputPath)
|
||||||
|
srcW, srcH := getVideoSize(inputPath)
|
||||||
|
|
||||||
|
// Subject dimensions, centred
|
||||||
|
subjH := int(float64(outputHeight) * subjectScale)
|
||||||
|
subjW := int(float64(subjH) * float64(srcW) / float64(srcH))
|
||||||
|
subjX := (outputWidth - subjW) / 2
|
||||||
|
subjY := (outputHeight - subjH) / 2
|
||||||
|
|
||||||
|
// Tile copies to cover full pan travel
|
||||||
|
totalTravel := int(duration*panSpeed) + outputWidth
|
||||||
|
tileCopies := totalTravel/stripW + 2
|
||||||
|
if tileCopies < 2 {
|
||||||
|
tileCopies = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
stripY := (outputHeight - stripH) / 2
|
||||||
|
panX := fmt.Sprintf("-(t*%d)", panSpeed)
|
||||||
|
|
||||||
|
// Drawtext for title card
|
||||||
|
title := escDrawtext(meta["title"])
|
||||||
|
date := escDrawtext(meta["date"])
|
||||||
|
speaker := escDrawtext(meta["speaker"])
|
||||||
|
|
||||||
|
drawtext := fmt.Sprintf(
|
||||||
|
"drawtext=fontfile=%s:text='%s':fontcolor=white:fontsize=28:alpha=0.85:x=(w-text_w)/2:y=h-70:shadowcolor=black:shadowx=1:shadowy=1,"+
|
||||||
|
"drawtext=fontfile=%s:text='%s':fontcolor=0xdddddd:fontsize=20:alpha=0.7:x=(w-text_w)/2:y=h-36:shadowcolor=black:shadowx=1:shadowy=1",
|
||||||
|
fontBold, title,
|
||||||
|
fontRegular, date,
|
||||||
|
)
|
||||||
|
if speaker != "" {
|
||||||
|
drawtext += fmt.Sprintf(
|
||||||
|
",drawtext=fontfile=%s:text='%s':fontcolor=0xffd580:fontsize=22:alpha=0.90:x=(w-text_w)/2:y=30:shadowcolor=black:shadowx=1:shadowy=1",
|
||||||
|
fontRegular, speaker,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
filterComplex := strings.Join([]string{
|
||||||
|
fmt.Sprintf("[1:v]tile=%dx1[strip_tiled]", tileCopies),
|
||||||
|
fmt.Sprintf("[strip_tiled]crop=%d:%d:'%s':0[reel]", outputWidth, stripH, panX),
|
||||||
|
fmt.Sprintf("color=c=0x111111:s=%dx%d:r=%d[bg]", outputWidth, outputHeight, outputFPS),
|
||||||
|
fmt.Sprintf("[bg][reel]overlay=0:%d[bg_reel]", stripY),
|
||||||
|
fmt.Sprintf("[0:v]scale=%d:%d[subject]", subjW, subjH),
|
||||||
|
fmt.Sprintf("[bg_reel][subject]overlay=%d:%d[with_subject]", subjX, subjY),
|
||||||
|
fmt.Sprintf("[with_subject]noise=alls=%d:allf=t+u[grainy]", grainStrength),
|
||||||
|
fmt.Sprintf("[grainy]vignette=angle=%s:mode=forward[vignetted]", vignetteAngle),
|
||||||
|
fmt.Sprintf("[vignetted]%s[out]", drawtext),
|
||||||
|
}, ";")
|
||||||
|
|
||||||
|
run("Rendering composite (this may take a while)…",
|
||||||
|
"ffmpeg", "-y",
|
||||||
|
"-i", inputPath,
|
||||||
|
"-i", stripPath,
|
||||||
|
"-filter_complex", filterComplex,
|
||||||
|
"-map", "[out]",
|
||||||
|
"-map", "0:a",
|
||||||
|
"-c:v", "libx264", "-preset", "fast", "-crf", "18",
|
||||||
|
"-c:a", "aac", "-b:a", "192k",
|
||||||
|
"-t", strconv.FormatFloat(duration, 'f', 3, 64),
|
||||||
|
"-r", strconv.Itoa(outputFPS),
|
||||||
|
outputPath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stage 4: Archive copy ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func archiveCopy(displayPath, archivePath string) {
|
||||||
|
log.Println("\n[4/5] Writing archive copy (ProRes HQ)...")
|
||||||
|
run("",
|
||||||
|
"ffmpeg", "-y",
|
||||||
|
"-i", displayPath,
|
||||||
|
"-c:v", "prores_ks", "-profile:v", "3",
|
||||||
|
"-c:a", "pcm_s16le",
|
||||||
|
archivePath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stage 5: Sidecar metadata ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func writeSidecar(outputPath string, meta map[string]string, duration float64) {
|
||||||
|
log.Println("\n[5/5] Writing sidecar metadata...")
|
||||||
|
|
||||||
|
stem := strings.TrimSuffix(filepath.Base(outputPath), filepath.Ext(outputPath))
|
||||||
|
exhibit := meta["title"]
|
||||||
|
if exhibit == "" {
|
||||||
|
exhibit = defaultExhibit
|
||||||
|
}
|
||||||
|
|
||||||
|
data := Metadata{
|
||||||
|
ContentID: stem,
|
||||||
|
Exhibit: exhibit,
|
||||||
|
Speaker: meta["speaker"],
|
||||||
|
DateRecorded: meta["date"],
|
||||||
|
DurationSeconds: math_round(duration, 2),
|
||||||
|
PipelineVersion: "1.0",
|
||||||
|
}
|
||||||
|
|
||||||
|
sidecarPath := strings.TrimSuffix(outputPath, filepath.Ext(outputPath)) + ".json"
|
||||||
|
b, _ := json.MarshalIndent(data, "", " ")
|
||||||
|
if err := os.WriteFile(sidecarPath, b, 0644); err != nil {
|
||||||
|
log.Fatalf("write sidecar: %v", err)
|
||||||
|
}
|
||||||
|
log.Printf(" → %s", sidecarPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// math_round rounds f to decimalPlaces (avoids importing math just for this).
|
||||||
|
func math_round(f float64, places int) float64 {
|
||||||
|
pow := 1.0
|
||||||
|
for i := 0; i < places; i++ {
|
||||||
|
pow *= 10
|
||||||
|
}
|
||||||
|
return float64(int(f*pow+0.5)) / pow
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Orchestrator ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func process(inputPath, outputDir string, meta map[string]string) {
|
||||||
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||||
|
log.Fatalf("mkdir output: %v", err)
|
||||||
|
}
|
||||||
|
if meta == nil {
|
||||||
|
meta = map[string]string{}
|
||||||
|
}
|
||||||
|
if meta["title"] == "" {
|
||||||
|
meta["title"] = defaultExhibit
|
||||||
|
}
|
||||||
|
if meta["date"] == "" {
|
||||||
|
meta["date"] = time.Now().Format("January 02, 2006")
|
||||||
|
}
|
||||||
|
|
||||||
|
stem := strings.TrimSuffix(filepath.Base(inputPath), filepath.Ext(inputPath))
|
||||||
|
displayOut := filepath.Join(outputDir, stem+"_display.mp4")
|
||||||
|
archiveOut := filepath.Join(outputDir, stem+"_archive.mov")
|
||||||
|
|
||||||
|
log.Printf("\n%s", strings.Repeat("=", 60))
|
||||||
|
log.Printf(" Processing: %s", filepath.Base(inputPath))
|
||||||
|
log.Printf("%s", strings.Repeat("=", 60))
|
||||||
|
|
||||||
|
// Use a temp dir for intermediate files; cleaned up automatically
|
||||||
|
tmpDir, err := os.MkdirTemp("", "booth-*")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
frames := extractFrames(inputPath, filepath.Join(tmpDir, "frames"))
|
||||||
|
stripPath, stripW, stripH := buildFilmstrip(frames, tmpDir)
|
||||||
|
composite(inputPath, stripPath, stripW, stripH, displayOut, meta)
|
||||||
|
writeSidecar(displayOut, meta, getDuration(inputPath))
|
||||||
|
archiveCopy(displayOut, archiveOut)
|
||||||
|
|
||||||
|
log.Printf("\n✓ Display copy → %s", displayOut)
|
||||||
|
log.Printf("✓ Archive copy → %s", archiveOut)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Watchdog ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func watch(watchDir, outputDir, defaultTitle string) {
|
||||||
|
watcher, err := fsnotify.NewWatcher()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("fsnotify: %v", err)
|
||||||
|
}
|
||||||
|
defer watcher.Close()
|
||||||
|
|
||||||
|
if err := watcher.Add(watchDir); err != nil {
|
||||||
|
log.Fatalf("watch dir: %v", err)
|
||||||
|
}
|
||||||
|
log.Printf("Watching %q for new recordings… (Ctrl+C to stop)", watchDir)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case event, ok := <-watcher.Events:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if event.Op&fsnotify.Create == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ext := strings.ToLower(filepath.Ext(event.Name))
|
||||||
|
if !videoExtensions[ext] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait a moment for the camera software to finish writing
|
||||||
|
time.Sleep(4 * time.Second)
|
||||||
|
|
||||||
|
log.Printf("\n★ New recording detected: %s", filepath.Base(event.Name))
|
||||||
|
go func(path string) {
|
||||||
|
process(path, outputDir, map[string]string{
|
||||||
|
"title": defaultTitle,
|
||||||
|
"date": time.Now().Format("January 02, 2006"),
|
||||||
|
})
|
||||||
|
}(event.Name)
|
||||||
|
|
||||||
|
case err, ok := <-watcher.Errors:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("watcher error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CLI ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
log.SetFlags(0) // cleaner output without timestamps
|
||||||
|
|
||||||
|
processCmd := flag.NewFlagSet("process", flag.ExitOnError)
|
||||||
|
pTitle := processCmd.String("title", defaultExhibit, "Exhibit name")
|
||||||
|
pSpeaker := processCmd.String("speaker", "", "Speaker name (optional)")
|
||||||
|
|
||||||
|
watchCmd := flag.NewFlagSet("watch", flag.ExitOnError)
|
||||||
|
wTitle := watchCmd.String("title", defaultExhibit, "Exhibit name")
|
||||||
|
|
||||||
|
if len(os.Args) < 2 {
|
||||||
|
fmt.Println("Usage:")
|
||||||
|
fmt.Println(" booth process <input> <output_dir> [--title ...] [--speaker ...]")
|
||||||
|
fmt.Println(" booth watch <watch_dir> <output_dir> [--title ...]")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch os.Args[1] {
|
||||||
|
|
||||||
|
case "process":
|
||||||
|
processCmd.Parse(os.Args[4:])
|
||||||
|
if len(os.Args) < 4 {
|
||||||
|
fmt.Println("Usage: booth process <input> <output_dir>")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
process(os.Args[2], os.Args[3], map[string]string{
|
||||||
|
"title": *pTitle,
|
||||||
|
"speaker": *pSpeaker,
|
||||||
|
"date": time.Now().Format("January 02, 2006"),
|
||||||
|
})
|
||||||
|
|
||||||
|
case "watch":
|
||||||
|
watchCmd.Parse(os.Args[4:])
|
||||||
|
if len(os.Args) < 4 {
|
||||||
|
fmt.Println("Usage: booth watch <watch_dir> <output_dir>")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
watch(os.Args[2], os.Args[3], *wTitle)
|
||||||
|
|
||||||
|
default:
|
||||||
|
fmt.Printf("Unknown command %q. Use 'process' or 'watch'.\n", os.Args[1])
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
module booth
|
||||||
|
|
||||||
|
go 1.21
|
||||||
|
|
||||||
|
require github.com/fsnotify/fsnotify v1.7.0
|
||||||
|
|
||||||
|
require golang.org/x/sys v0.15.0 // indirect
|
||||||
Reference in New Issue
Block a user