studio

package
v0.11.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 12, 2026 License: MIT Imports: 79 Imported by: 0

Documentation

Overview

Package studio serves keryx's local web UI (spec 0011): a single-user web app bound to localhost by default (R-API-3), serving the embedded Svelte SPA plus a thin /api/v1 surface whose handlers map 1:1 to CLI workspace operations.

The lifecycle belongs to go/controls and the server to go/transport (spec 0051): the HTTP server, the git worker and the in-memory takes sweeper are three registered services rather than a hand-rolled serve loop and two bare goroutines. keryx chooses the bind address itself and hands it over, which is what makes a localhost-only dev UI expressible — an earlier evaluation of GTB's pkg/http.NewServer stalled on it binding all interfaces with TLS forced on.

Panics in the background services are contained per unit of work (a sweep tick, a git job) with superviseStart as a backstop, because controls does not recover a StartFunc: an escaping panic ends the process, and for an in-memory project the RAM worktree is the only copy of the user's work.

Index

Constants

This section is empty.

Variables

View Source
var ErrNoVisionProvider = errors.NewSentinel("keyrx.no_vision_provider", "no vision provider configured (providers.image) — set one to screen for text")

ErrNoVisionProvider is returned by the screener when no image provider is configured — a user-actionable config gap (mapped to 422), distinct from an infra error (mapped to 500).

Functions

func CertHosts added in v0.9.0

func CertHosts(host string, ips []net.IP) []string

CertHosts returns the SAN set for a bind: localhost + loopback always, the bind host when it is a specific name/IP, and every non-loopback interface IP when the bind is a wildcard (0.0.0.0 / ::) — the browser connects on a concrete LAN address, so a wildcard bind's cert must cover the machine's real IPs.

func OutcomesPath added in v0.10.0

func OutcomesPath(fs afero.Fs, toolName string) string

OutcomesPath resolves the user-scoped outcomes.yaml under keryx's config dir, beside studio.yaml (D6). Returns "" when the home dir can't be resolved, which disables persistence rather than failing.

func RegistryPath

func RegistryPath(fs afero.Fs, toolName string) string

RegistryPath resolves the user-scoped studio.yaml under keryx's config dir (~/.<tool>/), via GTB's shared resolver — beside config.yaml. Returns "" when the home dir can't be resolved (which disables persistence).

Types

type CardScreener added in v0.7.0

type CardScreener interface {
	Screen(ctx context.Context, cfg config.Reader, fs afero.Fs, dir string) ([]takescmd.SelectedScreen, error)
}

CardScreener screens a workspace's SELECTED card illustrations for accidental text and returns a per-card verdict (spec 0015 S2, over R-GEN-26). It backs the contact sheet's "screen now" action — the spending path; the cached-verdict GET path calls takescmd.SelectedScreens directly with no provider. The active project's config is passed per call (per-project providers, like the Gen seam). Faked in tests so no vision provider is called.

func NewScreener added in v0.7.0

func NewScreener() CardScreener

NewScreener builds the live text-leak screener.

type CertSource added in v0.9.0

type CertSource interface {
	ServerCert(ctx context.Context, host string, ips []net.IP) (*tls.Certificate, error)
}

CertSource yields a server certificate valid for the given host/IPs, provisioning or renewing as needed (spec 0027 §5.2). Called once at the studio TLS bind.

func Fallback added in v0.9.0

func Fallback(primary, secondary CertSource, log logger.Logger) CertSource

Fallback wraps primary with a secondary fallback (see fallbackSource).

type Chatter

type Chatter interface {
	Propose(ctx context.Context, system, user string, onToken func(string)) (reel.Storyboard, string, error)
}

Chatter proposes a revised storyboard, streaming the assistant's prose to onToken and returning the proposed board + a short summary.

func NewChatter

func NewChatter(f ClientFactory) Chatter

NewChatter builds the live Chatter over a chat-client factory; nil factory → nil Chatter (the endpoint then reports no chat provider is configured).

type ClientFactory

type ClientFactory func(ctx context.Context, systemPrompt string) (gochat.ChatClient, error)

ClientFactory builds a chat client with a system prompt (live: chatgen.Client).

type CommitStatus

type CommitStatus struct {
	Committed bool   `json:"committed"        yaml:"committed"`
	Hash      string `json:"hash,omitempty"   yaml:"hash,omitempty"`
	Reason    string `json:"reason,omitempty" yaml:"reason,omitempty"`
}

CommitStatus reports a commit-on-save attempt (on the Save response).

type ConnectionChecker added in v0.8.0

type ConnectionChecker interface {
	Check(ctx context.Context, cfg, accounts *config.Store) ([]refresh.Status, error)
}

ConnectionChecker probes each enabled platform's token via a dry-run refresh and returns per-platform status. Injected so studio tests fake it (no network, no token access).

func NewConnectionChecker added in v0.8.0

func NewConnectionChecker() ConnectionChecker

NewConnectionChecker builds the live connection checker.

type Cost

type Cost = spend.Cost

Cost is the money cue for a paid generation (spec 0013 §5, D2).

type Deps

type Deps struct {
	Log logger.Logger
	// FS is the project filesystem; ReelRoot is the active reel-workspace root
	// resolved from config (reelcmd.Root) — the studio API reuses internal/workspace
	// over these, the same core the CLI uses (spec 0011 §5).
	FS       afero.Fs
	ReelRoot string
	// ProjectDir is the startup project directory (the cwd); RegistryPath is the
	// user-scoped studio.yaml. Both drive the project switcher (spec 0011 §2.2);
	// empty values disable persistence/switching (e.g. in unit tests).
	ProjectDir   string
	RegistryPath string
	// OutcomesPath is the user-scoped outcomes.yaml, beside studio.yaml — the last
	// git outcome per workspace, so "why is this reel not pushed?" survives the
	// request and a restart (spec 0052 D6). Empty disables the record.
	OutcomesPath string
	// Config backs theme/palette resolution for storyboard validation (nil → the
	// structural rules only, palette-role checks skipped — like `storyboard draft`).
	Config *config.Store
	// Accounts is the user-scoped credentials store (~/.keryx/accounts.yaml). The
	// publish path rotates tokens — TikTok on every post — and without a separate
	// destination those writes route to the highest-precedence writable layer,
	// which inside a project is the committable .keryx.yaml (spec 0042 §3.4).
	// nil → credentials resolve from Config, the pre-split behaviour.
	Accounts *config.Store
	// UserThemes is the user theme library (~/.keryx/themes.yaml), the destination
	// for promoting a project theme. nil → the studio reports promotion
	// unavailable rather than silently doing nothing.
	UserThemes *config.Store
	// ConfigDir is the user config directory (~/.keryx), used to resolve the theme
	// library for the no-project fallback catalog.
	ConfigDir string
	// Chat proposes storyboard revisions for the chat endpoint (nil → the endpoint
	// reports no provider configured). Injectable so the endpoint tests with a fake.
	Chat Chatter
	// Summarise drafts the reel's source brief from the linked post (0029 §9
	// note 29; nil → the endpoint reports no provider configured). Injectable so
	// the endpoint tests with a fake — no LLM spend.
	Summarise Summarizer
	// Compose drafts per-platform social copy for the AI-compose endpoint (R-SOC-4;
	// nil → the endpoint reports no provider configured). Injectable so the endpoint
	// tests with a fake — no LLM spend.
	Compose SocialComposer
	// Post runs the approved-gated post-now fan-out (R-UI-27, the irreversible
	// action; nil → the endpoint reports posting unavailable). Injectable so tests
	// fake it — no real network posting in CI.
	Post Poster
	// Git performs commit-on-save, push, and clone (R-GIT-2/3); nil → a no-op.
	// Injectable so studio tests don't need a real repo.
	Git GitOps
	// Gen produces card/cover/portrait media (R-UI-29/3; spec 0013); nil → a no-op
	// that reports generation unavailable. Injectable so studio tests fake it (no
	// provider, no spend, no network).
	Gen Generator
	// Render renders the workspace to an mp4 (R-UI-8/11; spec 0017); nil → a no-op
	// that reports rendering unavailable. Local-only (shells out to ffmpeg).
	// Injectable so studio tests fake it (no ffmpeg, no exec).
	Render Renderer
	// Screen runs the contact sheet's on-demand text-leak screen of the selected
	// card illustrations (R-GEN-26 surfaced in S2); nil → the "screen now" endpoint
	// reports screening unavailable. Injectable so studio tests fake it (no vision
	// provider, no spend). The cached-verdict GET path needs no seam — it is a pure
	// ledger read.
	Screen CardScreener
	// Quota reports the voice account's character quota for the quota widget (spec
	// 0016); nil → the endpoint reports quota unavailable. Injectable so studio tests
	// fake it (no provider, no network).
	Quota QuotaReporter
	// ConnCheck live-probes platform token health for the Connections "check now"
	// action (S5); nil → the endpoint reports the check unavailable. Injectable so
	// studio tests fake it (no network, no token access).
	ConnCheck ConnectionChecker
	// Storage resolves the configured object store for the Commit & push media
	// sync (spec 0039); nil → the package registry (objectstore.Default.Resolve).
	// Injectable so studio tests fake the store — no bucket, no network.
	Storage StorageResolver
	// CertSource yields the studio's TLS certificate on a non-loopback bind (spec
	// 0027, R-TLS-1/3). Non-nil → the studio serves HTTPS and sets a Secure session
	// cookie; nil → the exposure gate falls back to http (a documented downgrade, kept
	// for tests). The command wires a trusted local-CA source with a self-signed
	// fallback, so a real exposed bind is always HTTPS.
	CertSource CertSource
	// Analyzer runs first-use avatar analysis (spec 0034 D3) — nil resolves the
	// configured image provider at request time. Injectable so tests fake the vision.
	Analyzer avatar.Describer
	// NoAuth disables the exposure token gate on a non-loopback bind (the `--no-auth`
	// flag). The studio still serves HTTPS, but anyone who can reach the address has
	// full access — a deliberate, logged opt-out for trusted-network dev/testing.
	NoAuth bool
}

Deps are the studio server's collaborators. It grows as slices land.

type Generator

type Generator interface {
	// Available reports whether generation can proceed (an image provider is
	// configured); a non-nil error is the actionable reason for a 503. It does not
	// guarantee a working API key — a missing key surfaces as a failed job with the
	// provider's own message.
	Available() error
	// Generate writes the request's candidate takes into its slot on fs (the active
	// worktree, possibly an in-memory remote), resolving the provider + theme against
	// cfg (the active project's config), and returns the written take paths.
	Generate(ctx context.Context, cfg *config.Store, fs afero.Fs, req gencmd.Request) ([]string, error)
}

Generator produces candidate media (card / cover / portrait / VO / music) for a workspace — one method for every kind (the request's Target selects what to make). It is side-effecting and PAID; callers surface cost (Cost) before invoking it and never auto-fire it. Generate takes the ACTIVE PROJECT's config so per-project provider/theme overrides (spec 0014) reach generation — not the global props.

func NewGenerator

func NewGenerator(p *props.Props) Generator

NewGenerator builds the live Generator over the generation cores. It holds the base props (FS, logger, …); the per-call cfg overrides the config so generation resolves per-project.

type GitOps

type GitOps interface {
	Commit(ctx context.Context, projectDir, relPath, msg string) CommitStatus
	Push(ctx context.Context, projectDir string) PushStatus
	Clone(ctx context.Context, remote, branch, targetDir string) error
	// OpenInMemory clones a remote into RAM and returns the held repo (the
	// in-memory backend). The caller holds it for the active project's session.
	OpenInMemory(ctx context.Context, remote, branch string) (MemRepo, error)
	// DirtySlugs reports which workspaces under reelRootRel (project-relative)
	// carry uncommitted changes — the rail's "remember to commit" indicator.
	// Best-effort: any failure (not a repo, status error) returns nil.
	DirtySlugs(ctx context.Context, projectDir, reelRootRel string) map[string]bool
}

GitOps is the studio's git operations seam. Injected via Deps so the studio tests don't need a real repo (the git behaviour itself is tested in internal/gitrepo).

func NewGitOps

func NewGitOps(p *props.Props) GitOps

NewGitOps builds the live GitOps over internal/gitrepo (GTB vcs/repo). It needs props for vcs/repo construction (forge auth resolution).

It holds NO context. Every operation takes its own, because the right scope differs per caller and a stored one silently imposed the wrong answer on all of them: work queued on the git worker must run under a context that OUTLIVES shutdown (spec 0051 §5) so the drain can finish what it accepted, while a clone driven straight from a request should follow that request. A single server-lifetime context could express neither.

type GitOutcome added in v0.10.0

type GitOutcome struct {
	At     time.Time     `json:"at"               yaml:"at"`
	Commit *CommitStatus `json:"commit,omitempty" yaml:"commit,omitempty"`
	Push   *PushStatus   `json:"push,omitempty"   yaml:"push,omitempty"`
}

GitOutcome is the last git result for one workspace, plus when it happened. Commit and Push mirror what the synchronous response carries (D7), so the record and the response tell the same story in the same shape.

type Job

type Job struct {
	ID        string   `json:"id"`
	Kind      string   `json:"kind"`
	Card      int      `json:"card,omitempty"`
	State     JobState `json:"state"`
	Takes     []string `json:"takes,omitempty"`
	Results   []string `json:"results,omitempty"` // post jobs: per-platform result lines
	Output    string   `json:"output,omitempty"`  // render jobs: the produced mp4 (workspace-relative)
	Cost      Cost     `json:"cost"`
	Error     string   `json:"error,omitempty"`
	ElapsedMS int64    `json:"elapsed_ms"`
	EtaMS     int64    `json:"eta_ms"`
	Percent   float64  `json:"percent,omitempty"` // render jobs: live 0..100 when the backend streams it
	// contains filtered or unexported fields
}

Job is one async generation, polled by GET .../jobs/{id}. Takes are the produced take *filenames* (tier-1 pointers, not the bytes). Cost is the up-front estimate while running and the actual on completion — which may be lower when the provider cache serves an unchanged prompt (R-GLOBAL-9).

type JobState

type JobState string

JobState is the lifecycle of a generation job.

const (
	JobQueued  JobState = "queued"
	JobRunning JobState = "running"
	JobDone    JobState = "done"
	JobFailed  JobState = "failed"
)

type MediaPullStatus added in v0.9.0

type MediaPullStatus struct {
	mediastore.PullReport
	Skipped string `json:"skipped,omitempty"`
	Error   string `json:"error,omitempty"`
}

MediaPullStatus reports the open-time hydrate (mirrors MediaPushStatus).

type MediaPushStatus added in v0.9.0

type MediaPushStatus struct {
	mediastore.Report
	// Skipped names why no sync was attempted (unconfigured storage, no active
	// project) — an expected state, not a fault.
	Skipped string `json:"skipped,omitempty"`
	// Error is a real sync failure. The git commit still proceeds — media.lock
	// simply stays at its last-pushed pins.
	Error string `json:"error,omitempty"`
}

MediaPushStatus reports the Commit & push media sync (on the commit response).

type MemRepo

type MemRepo interface {
	WorkFS() afero.Fs
	Commit(ctx context.Context, relPath, msg string) CommitStatus
	Push(ctx context.Context) PushStatus
	// DirtySlugs mirrors GitOps.DirtySlugs over the held worktree.
	DirtySlugs(reelRootRel string) map[string]bool
}

MemRepo is a held in-memory repository: its worktree (the only copy) exposed as an afero.Fs, with commit + push on that same worktree.

type Options

type Options struct {
	Host string
	Port int
}

Options configure the bind. Host defaults to localhost; Port 0 picks a free port.

type Poster added in v0.5.0

type Poster interface {
	Post(ctx context.Context, fs afero.Fs, stores publish.Stores, root, slug string, platforms []string) ([]postcmd.Result, error)
}

Poster posts a reel's approved platforms — all of them, or the named subset — against the active project. Per-platform outcomes are carried in the results (a refused/failed platform is a `failed` result, not a hard error); only a systemic failure (e.g. the workspace can't be read) returns an error.

func NewPoster added in v0.5.0

func NewPoster() Poster

NewPoster builds the live poster — the real, irreversible publish path.

type PushStatus

type PushStatus struct {
	Pushed bool   `json:"pushed"           yaml:"pushed"`
	Reason string `json:"reason,omitempty" yaml:"reason,omitempty"`
}

PushStatus reports a push attempt (on the Save response, remote projects).

type QuotaReporter added in v0.8.0

type QuotaReporter interface {
	Quota(ctx context.Context, cfg config.Reader) (provider.Quota, error)
}

QuotaReporter reports the voice account's character quota for the studio quota widget (spec 0016). The active project's config is passed per call (per-project provider choice, like the other seams). Faked in tests so no network is hit.

func NewQuotaReporter added in v0.8.0

func NewQuotaReporter() QuotaReporter

NewQuotaReporter builds the live quota reporter.

type RenderOpts

type RenderOpts struct {
	Silent bool
	Theme  string
	Out    string // absolute output mp4 path
	// OnProgress (optional) receives live render progress — set by the job runner to
	// feed the job's percent; nil for a plain render (spec 0026).
	OnProgress func(provider.Progress)
}

RenderOpts is a render request: a silent draft (storyboard timing, no audio) or a full reel (VO-driven timing + bed), under a theme, written to Out.

type RenderResult

type RenderResult struct {
	VideoPath   string
	DurationSec float64
	Cards       int
}

RenderResult is the produced reel.

type Renderer

type Renderer interface {
	// Available reports whether rendering can proceed (a renderer is resolvable).
	Available() error
	// Render renders the workspace at dir (a path in fs) to opts.Out, resolving the
	// renderer + theme against cfg. fs is the active project's filesystem (on-disk or
	// in-memory), so render is no longer local-only.
	Render(ctx context.Context, fs afero.Fs, cfg *config.Store, dir string, opts RenderOpts) (RenderResult, error)
	// BedSeconds returns the music-bed length (seconds) the reel will request — the
	// VO-driven total (probing the promoted VO via the render backend), or the default
	// when no VO sizes it / the renderer is unavailable. Used to price music generation
	// per second. Never errors — it degrades to the default so the cost cue always works.
	BedSeconds(ctx context.Context, fs afero.Fs, cfg *config.Store, dir string) float64
	// Timeline returns the reel's VO-driven pre-build timing (R-UI-31): per-card
	// on-screen duration + start offset and the total, probing promoted VO through the
	// render backend (mirrors BedSeconds), or the storyboard-`dur` fallback when no VO
	// sizes it (Plan.VODriven == false). Read-only — it renders nothing.
	Timeline(ctx context.Context, fs afero.Fs, cfg *config.Store, dir, slug string, sb reel.Storyboard) (reelcmd.Plan, error)
}

Renderer renders a workspace to an mp4. Like the Generator it takes the active project's config so the renderer + theme resolve per-project (spec 0014).

func NewRenderer

func NewRenderer(p *props.Props) Renderer

NewRenderer builds the live Renderer over the render core. It holds the base props (FS, logger); the per-call cfg overrides the config so rendering resolves per-project.

type SelfSignedSource added in v0.9.0

type SelfSignedSource struct{}

SelfSignedSource mints an in-memory self-signed certificate for the bind hosts. It always succeeds; the browser warns the cert is untrusted. This is the zero-setup fallback (0027 §5.4) — enough to serve HTTPS and set a Secure cookie even when no trusted local CA is provisioned.

func (SelfSignedSource) ServerCert added in v0.9.0

func (SelfSignedSource) ServerCert(_ context.Context, host string, ips []net.IP) (*tls.Certificate, error)

type Server

type Server struct {
	// contains filtered or unexported fields
}

Server is the studio HTTP server over a reel workspace.

func New

func New(ctx context.Context, deps Deps, opts Options) *Server

New builds the studio server: the ServeMux (SPA + /api/v1) bounded by GTB's MaxBytesMiddleware. It does not bind until Run.

func (*Server) Run

func (s *Server) Run(ctx context.Context) error

Run supervises the studio's services until ctx is cancelled, then drains.

The lifecycle belongs to go/controls and the server to go/transport rather than to a hand-rolled serve loop (spec 0051). What used to be a select over a serve goroutine and a shutdown timeout is now two registered services: the HTTP server, and the in-memory takes sweeper.

type SocialComposer added in v0.5.0

type SocialComposer interface {
	Compose(ctx context.Context, fs afero.Fs, cfg config.Reader, wsDir, platform string, ground socialcmd.Grounding) (social.Set, error)
}

SocialComposer drafts one platform's (or all platforms') social copy from the grounding (the linked article — resolved by the caller, who owns the project-rooted content source), the workspace's source/direction, and the storyboard at wsDir, saving social.json. Returns the updated set.

func NewComposer added in v0.5.0

func NewComposer(f ClientFactory) SocialComposer

NewComposer builds the live composer over a chat-client factory; nil factory → nil composer (the endpoint then reports no chat provider is configured).

type StorageResolver added in v0.9.0

type StorageResolver func(config.Reader) (objectstore.Store, error)

StorageResolver resolves the configured object store (objectstore.Registry signature). Injected via Deps so studio tests fake the store.

type Summarizer added in v0.9.0

type Summarizer interface {
	Summarise(ctx context.Context, system, user string) (string, error)
}

Summarizer produces a free-text completion — the "generate the source brief from the post" seam (0029 §9 note 29). Behind an interface so the endpoint tests with a fake (no live LLM, no spend).

func NewSummarizer added in v0.9.0

func NewSummarizer(f ClientFactory) Summarizer

NewSummarizer builds the live Summarizer over the shared chat-client factory; nil factory → nil (the endpoint reports no chat provider).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL