Compare commits
9 Commits
8f9fcc45f3
...
adventure-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e0d6aff3e | ||
|
|
9bf56cbb4e | ||
|
|
4c671fb410 | ||
|
|
0a723418ff | ||
|
|
9b20040b49 | ||
|
|
e91b423b1a | ||
|
|
dbcb459908 | ||
|
|
fceeb12ad5 | ||
|
|
74aa578a2d |
@@ -77,6 +77,25 @@ interval_minutes = 360
|
||||
# item doesn't ping everyone.
|
||||
min_stories = 3
|
||||
|
||||
# Server-side neural read-aloud (Piper, https://github.com/rhasspy/piper).
|
||||
# When enabled, the reader's "Listen" button streams real Piper voices instead
|
||||
# of the browser's robotic Web Speech voice. Signed-in only, so it needs
|
||||
# [web.auth] on too. Install the piper binary and one or more voice models
|
||||
# (<id>.onnx + <id>.onnx.json) into voices_dir first.
|
||||
[web.tts]
|
||||
enabled = false
|
||||
piper_bin = "/opt/piper/piper" # path to the piper executable
|
||||
voices_dir = "/opt/piper/voices" # dir holding <id>.onnx (+ .onnx.json) models
|
||||
default = "en_US-amy-medium" # voice id selected until the reader picks another
|
||||
# List the voices to offer, in menu order. Omit the whole [[web.tts.voices]]
|
||||
# list to auto-discover every *.onnx in voices_dir (labelled by filename).
|
||||
[[web.tts.voices]]
|
||||
id = "en_US-amy-medium"
|
||||
label = "Amy (US, female)"
|
||||
[[web.tts.voices]]
|
||||
id = "en_US-ryan-high"
|
||||
label = "Ryan (US, male, HQ)"
|
||||
|
||||
# Every enabled source MUST set direct_route to a key from [matrix.channels] above.
|
||||
# There is no automatic classification — Pete posts each story to its configured channel.
|
||||
# Optional: language = "en" drops feed items whose per-item <language> tag is
|
||||
|
||||
@@ -13,13 +13,44 @@ import (
|
||||
var envBracketRe = regexp.MustCompile(`\$\{([^}]+)\}`)
|
||||
|
||||
type Config struct {
|
||||
Matrix MatrixConfig `toml:"matrix"`
|
||||
Posting PostingConfig `toml:"posting"`
|
||||
Storage StorageConfig `toml:"storage"`
|
||||
Web WebConfig `toml:"web"`
|
||||
Sources []SourceConfig `toml:"sources"`
|
||||
Matrix MatrixConfig `toml:"matrix"`
|
||||
Posting PostingConfig `toml:"posting"`
|
||||
Storage StorageConfig `toml:"storage"`
|
||||
Web WebConfig `toml:"web"`
|
||||
Adventure AdventureConfig `toml:"adventure"`
|
||||
Sources []SourceConfig `toml:"sources"`
|
||||
}
|
||||
|
||||
// AdventureConfig wires the gogobee adventure-news seam: gogobee POSTs
|
||||
// game-event facts to Pete's ingest endpoint, Pete templates them into stories
|
||||
// on the /adventure section and posts PRIORITY beats live to Matrix. Disabled by
|
||||
// default — the endpoint 404s and no adventure channel appears until enabled.
|
||||
type AdventureConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
// IngestToken is the shared bearer secret gogobee presents on
|
||||
// POST /api/ingest/adventure. Required when enabled. Use ${ENV_VAR}.
|
||||
IngestToken string `toml:"ingest_token"`
|
||||
// Channel is the Matrix channel name (a key in [matrix.channels]) that
|
||||
// PRIORITY adventure beats post to live. If it isn't a configured Matrix
|
||||
// channel, adventure runs website-only (stories still appear on /adventure).
|
||||
Channel string `toml:"channel"`
|
||||
// DigestHour is the UTC hour [0,23] the daily bulletin digest posts. Default
|
||||
// 17. A pointer so digest_hour = 0 (midnight UTC) is distinguishable from
|
||||
// "unset" and doesn't get silently rewritten to the default.
|
||||
DigestHour *int `toml:"digest_hour"`
|
||||
}
|
||||
|
||||
// DigestHourOrDefault is the UTC hour the daily digest posts, resolving the
|
||||
// unset case. Safe on a zero-value AdventureConfig.
|
||||
func (a AdventureConfig) DigestHourOrDefault() int {
|
||||
if a.DigestHour == nil {
|
||||
return defaultDigestHour
|
||||
}
|
||||
return *a.DigestHour
|
||||
}
|
||||
|
||||
const defaultDigestHour = 17
|
||||
|
||||
// WebConfig controls the read-only HTTP interface (news.parodia.dev style).
|
||||
type WebConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
@@ -28,6 +59,7 @@ type WebConfig struct {
|
||||
BaseURL string `toml:"base_url"` // public URL (used in metadata only)
|
||||
Auth AuthConfig `toml:"auth"` // optional OIDC sign-in (Authentik)
|
||||
Push PushConfig `toml:"push"` // optional Web Push digests (VAPID)
|
||||
TTS TTSConfig `toml:"tts"` // optional server-side neural read-aloud (Piper)
|
||||
// AdminSubs is the allowlist of OIDC subjects allowed to view the
|
||||
// owner-facing source-health dashboard at /status. Empty means the page is
|
||||
// inaccessible to everyone (returns 404). Requires auth to be enabled.
|
||||
@@ -53,6 +85,29 @@ type PushConfig struct {
|
||||
MinStories int `toml:"min_stories"`
|
||||
}
|
||||
|
||||
// TTSConfig wires server-side neural read-aloud (Piper). When enabled,
|
||||
// signed-in users get the reader's "Listen" button backed by real Piper voices
|
||||
// instead of the browser's robotic Web Speech voice. Read-aloud is a signed-in
|
||||
// perk, so this does nothing unless auth is also enabled.
|
||||
type TTSConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
PiperBin string `toml:"piper_bin"` // path to the piper executable
|
||||
VoicesDir string `toml:"voices_dir"` // directory holding <id>.onnx (+ .onnx.json) models
|
||||
// Voices lists the voices to offer, in menu order. Each id is a model
|
||||
// filename stem, so id "en_US-ryan-high" maps to <voices_dir>/en_US-ryan-high.onnx.
|
||||
// Leave empty to auto-discover every *.onnx in voices_dir.
|
||||
Voices []VoiceConfig `toml:"voices"`
|
||||
// Default is the voice id selected until the reader picks another. Empty
|
||||
// falls back to the first available voice.
|
||||
Default string `toml:"default"`
|
||||
}
|
||||
|
||||
// VoiceConfig is one selectable Piper voice.
|
||||
type VoiceConfig struct {
|
||||
ID string `toml:"id"` // model filename stem, e.g. "en_US-ryan-high"
|
||||
Label string `toml:"label"` // menu label, e.g. "Ryan — US, male"
|
||||
}
|
||||
|
||||
// AuthConfig wires Pete's web UI to an OIDC provider (our Authentik instance).
|
||||
// When enabled, signed-in users get their preferences stored server-side keyed
|
||||
// by the OIDC subject; anonymous visitors keep using browser localStorage.
|
||||
@@ -204,6 +259,21 @@ func (c *Config) validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
if c.Adventure.Enabled {
|
||||
if c.Adventure.IngestToken == "" {
|
||||
return fmt.Errorf("adventure.ingest_token is required when adventure is enabled (the bearer secret gogobee presents)")
|
||||
}
|
||||
if h := c.Adventure.DigestHourOrDefault(); h < 0 || h > 23 {
|
||||
return fmt.Errorf("adventure.digest_hour must be 0-23")
|
||||
}
|
||||
if c.Adventure.Channel != "" {
|
||||
if _, ok := c.Matrix.Channels[c.Adventure.Channel]; !ok {
|
||||
slog.Warn("adventure.channel is not a configured Matrix channel — adventure runs website-only",
|
||||
"channel", c.Adventure.Channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for i, s := range c.Sources {
|
||||
if s.Name == "" {
|
||||
return fmt.Errorf("sources[%d].name is required", i)
|
||||
|
||||
@@ -85,6 +85,11 @@ func runMigrations(d *sql.DB) error {
|
||||
// else the body scraped during paywall detection) for reader mode. Stories
|
||||
// ingested before this column existed simply have NULL and fall back to lede.
|
||||
addColumnIfMissing(d, "stories", "content", "TEXT")
|
||||
// content_chars caches the character count of content so the "N min read"
|
||||
// chip never has to LENGTH() the full body on the hot listing path. Filled at
|
||||
// insert time; the backfill below populates rows that predate the column.
|
||||
addColumnIfMissing(d, "stories", "content_chars", "INTEGER NOT NULL DEFAULT 0")
|
||||
backfillContentChars(d)
|
||||
addColumnIfMissing(d, "stories", "published_at", "INTEGER")
|
||||
addColumnIfMissing(d, "post_log", "url_canonical", "TEXT")
|
||||
addColumnIfMissing(d, "post_log", "forced", "INTEGER NOT NULL DEFAULT 0")
|
||||
@@ -154,6 +159,20 @@ func exec(label, query string, args ...any) {
|
||||
}
|
||||
}
|
||||
|
||||
// backfillContentChars populates content_chars for rows carrying a body but a
|
||||
// zero count — i.e. stories ingested before the column existed. LENGTH() counts
|
||||
// characters (code points) for TEXT, matching the utf8.RuneCountInString done at
|
||||
// insert. After the first run this matches no rows (bodied stories are set,
|
||||
// bodyless ones stay 0 and are filtered by content IS NOT NULL), so it's a cheap
|
||||
// startup no-op thereafter.
|
||||
func backfillContentChars(d *sql.DB) {
|
||||
if _, err := d.Exec(
|
||||
`UPDATE stories SET content_chars = LENGTH(content)
|
||||
WHERE content_chars = 0 AND content IS NOT NULL AND content <> ''`); err != nil {
|
||||
slog.Error("backfill content_chars failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func addColumnIfMissing(d *sql.DB, table, column, columnType string) {
|
||||
q := fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", table, column, columnType)
|
||||
if _, err := d.Exec(q); err != nil {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func nowUnix() int64 {
|
||||
@@ -38,9 +39,9 @@ func InsertStory(s *Story) error {
|
||||
publishedAt = s.PublishedAt
|
||||
}
|
||||
_, err := Get().Exec(
|
||||
`INSERT INTO stories (guid, headline, lede, content, image_url, article_url, url_canonical, headline_norm, source, platforms, channel, classified, paywalled, seen_at, published_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
s.GUID, s.Headline, s.Lede, nullIfEmpty(s.Content), s.ImageURL, s.ArticleURL, nullIfEmpty(s.URLCanonical), nullIfEmpty(s.HeadlineNorm), s.Source, s.Platforms, s.Channel, classified, paywalled, s.SeenAt, publishedAt,
|
||||
`INSERT INTO stories (guid, headline, lede, content, content_chars, image_url, article_url, url_canonical, headline_norm, source, platforms, channel, classified, paywalled, seen_at, published_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
s.GUID, s.Headline, s.Lede, nullIfEmpty(s.Content), utf8.RuneCountInString(s.Content), s.ImageURL, s.ArticleURL, nullIfEmpty(s.URLCanonical), nullIfEmpty(s.HeadlineNorm), s.Source, s.Platforms, s.Channel, classified, paywalled, s.SeenAt, publishedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -131,10 +132,10 @@ func MarkClassified(guid, channel, platforms string) {
|
||||
// GetStoryByGUID returns the full story record for a GUID, or nil if not found.
|
||||
func GetStoryByGUID(guid string) (*Story, error) {
|
||||
row := Get().QueryRow(
|
||||
`SELECT guid, headline, lede, image_url, article_url, source, platforms, channel, seen_at
|
||||
`SELECT guid, headline, lede, COALESCE(content, ''), image_url, article_url, source, platforms, channel, seen_at
|
||||
FROM stories WHERE guid = ?`, guid)
|
||||
var s Story
|
||||
if err := row.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.SeenAt); err != nil {
|
||||
if err := row.Scan(&s.GUID, &s.Headline, &s.Lede, &s.Content, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.SeenAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
@@ -251,6 +252,53 @@ func GetNewestPostableStoryByChannel(channel string) (*Story, error) {
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// UnpostedAdventureSince returns adventure dispatches seen at or after `since`
|
||||
// that have never been posted to Matrix (no post_log row). PRIORITY beats post
|
||||
// live and so carry a post_log row; the ones left are exactly the BULLETINs the
|
||||
// daily digest collects. Oldest first so the digest reads chronologically.
|
||||
//
|
||||
// At most `limit` rows come back, but total is how many match in all — the digest
|
||||
// quotes it to readers, so it must count the window rather than the returned page.
|
||||
func UnpostedAdventureSince(since int64, limit int) (stories []Story, total int, err error) {
|
||||
const where = `WHERE classified = 1
|
||||
AND channel = 'adventure'
|
||||
AND seen_at >= ?
|
||||
AND guid NOT IN (SELECT guid FROM post_log)`
|
||||
|
||||
if err := Get().QueryRow(`SELECT COUNT(*) FROM stories `+where, since).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
rows, err := Get().Query(
|
||||
`SELECT guid, headline, lede, article_url, seen_at
|
||||
FROM stories `+where+`
|
||||
ORDER BY seen_at ASC
|
||||
LIMIT ?`, since, limit)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Story
|
||||
for rows.Next() {
|
||||
var s Story
|
||||
if err := rows.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ArticleURL, &s.SeenAt); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, total, rows.Err()
|
||||
}
|
||||
|
||||
// MarkAdventureDigested records each bulletin guid as posted (in a shared digest
|
||||
// event) so the next daily digest doesn't re-collect it. Idempotent per guid via
|
||||
// the post_log OR IGNORE. eventID is the digest's synthetic key (e.g.
|
||||
// "adv-digest:2026-07-11") shared by every story that went out in that digest.
|
||||
func MarkAdventureDigested(guids []string, eventID string) {
|
||||
for _, g := range guids {
|
||||
InsertPostLog(g, "adventure", eventID, "", false)
|
||||
}
|
||||
}
|
||||
|
||||
// ListClassifiedByChannel returns up to `limit` classified stories routed to a
|
||||
// real channel, newest first, with optional offset for pagination. Sentinel
|
||||
// channels (_discarded, _duplicate) are excluded.
|
||||
@@ -414,8 +462,10 @@ func TrendingStories(limit int, sinceDay int64) ([]Story, error) {
|
||||
|
||||
// StoryContentLengths returns the captured article text length (in characters)
|
||||
// for the given story ids, as a map keyed by id. Used to estimate a "N min read"
|
||||
// chip without transferring the full body: only LENGTH(content) crosses the
|
||||
// wire. Ids with no captured content are absent (treated as zero by callers).
|
||||
// chip. It reads the precomputed content_chars column rather than LENGTH()-ing
|
||||
// the full body, so the hot listing path never scans article text. Ids with no
|
||||
// captured content have content_chars = 0 and are absent from the map (treated
|
||||
// as zero by callers).
|
||||
func StoryContentLengths(ids []int64) map[int64]int {
|
||||
out := make(map[int64]int, len(ids))
|
||||
if len(ids) == 0 {
|
||||
@@ -423,8 +473,8 @@ func StoryContentLengths(ids []int64) map[int64]int {
|
||||
}
|
||||
ph, args := intInClause(ids)
|
||||
rows, err := Get().Query(
|
||||
`SELECT id, LENGTH(content) FROM stories
|
||||
WHERE id IN (`+ph+`) AND content IS NOT NULL AND content <> ''`, args...)
|
||||
`SELECT id, content_chars FROM stories
|
||||
WHERE id IN (`+ph+`) AND content_chars > 0`, args...)
|
||||
if err != nil {
|
||||
slog.Error("story content lengths query failed", "err", err)
|
||||
return out
|
||||
|
||||
@@ -7,6 +7,7 @@ CREATE TABLE IF NOT EXISTS stories (
|
||||
headline TEXT NOT NULL,
|
||||
lede TEXT,
|
||||
content TEXT,
|
||||
content_chars INTEGER NOT NULL DEFAULT 0,
|
||||
image_url TEXT,
|
||||
article_url TEXT NOT NULL,
|
||||
url_canonical TEXT,
|
||||
|
||||
402
internal/web/adventure.go
Normal file
402
internal/web/adventure.go
Normal file
@@ -0,0 +1,402 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// AdvFact is the game-event fact gogobee POSTs to Pete. It mirrors the contract
|
||||
// in pete_adventure_news_voice.md. Names are character names only (never Matrix
|
||||
// handles) and Actors is the allow-list of the only names permitted to appear in
|
||||
// rendered output.
|
||||
type AdvFact struct {
|
||||
GUID string `json:"guid"`
|
||||
EventType string `json:"event_type"`
|
||||
Tier string `json:"tier"` // "priority" | "bulletin"
|
||||
Actors []string `json:"actors"`
|
||||
Subject string `json:"subject"`
|
||||
Opponent string `json:"opponent"`
|
||||
Boss string `json:"boss"`
|
||||
Zone string `json:"zone"`
|
||||
Region string `json:"region"`
|
||||
Level int `json:"level"`
|
||||
Count int `json:"count"`
|
||||
Outcome string `json:"outcome"`
|
||||
Stakes string `json:"stakes"`
|
||||
ClassRace string `json:"class_race"`
|
||||
Milestone string `json:"milestone"`
|
||||
OccurredAt int64 `json:"occurred_at"`
|
||||
NoPush bool `json:"no_push"`
|
||||
}
|
||||
|
||||
// AdvPost is a priority adventure item to post live to Matrix. Kept minimal and
|
||||
// web-local so the web package needs no dependency on internal/poster.
|
||||
type AdvPost struct {
|
||||
GUID string
|
||||
Headline string
|
||||
Lede string
|
||||
ImageURL string
|
||||
ArticleURL string
|
||||
Source string
|
||||
Channel string
|
||||
}
|
||||
|
||||
// PriorityPoster posts a priority adventure item to Matrix immediately. main
|
||||
// adapts *poster.Queue.PostNow to this; nil in web-only/local modes.
|
||||
type PriorityPoster func(AdvPost)
|
||||
|
||||
const advSource = "Pete"
|
||||
|
||||
// advBackfillEvent is the synthetic post_log event id used to retire a no_push
|
||||
// (cold-start backfill) dispatch against the daily digest. It never went to
|
||||
// Matrix; the row exists only so the digest skips it.
|
||||
const advBackfillEvent = "adv-backfill"
|
||||
|
||||
// handleAdventureIngest receives a game-event fact from gogobee, templates it
|
||||
// into a deterministic story, publishes it to the /adventure section, and posts
|
||||
// PRIORITY beats live to Matrix. Bearer-authed; idempotent on the fact GUID.
|
||||
func (s *Server) handleAdventureIngest(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.adv.Enabled {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if !s.bearerOK(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var f AdvFact
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&f); err != nil {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if f.GUID == "" || f.EventType == "" {
|
||||
http.Error(w, "guid and event_type are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Fact-guard: any player name we render must be in the actors allow-list.
|
||||
// gogobee pre-sanitizes, but Pete never trusts the channel — this is the
|
||||
// last line before a name reaches a public page.
|
||||
if !factGuard(f) {
|
||||
slog.Warn("adventure ingest: fact-guard rejected", "guid", f.GUID, "event_type", f.EventType)
|
||||
http.Error(w, "fact-guard: subject/opponent not in actors", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
headline, lede, ok := renderAdventure(f)
|
||||
if !ok {
|
||||
http.Error(w, "unknown event_type", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Idempotent: a re-delivered fact (gogobee retry) is a no-op success.
|
||||
if storage.IsGUIDSeen(f.GUID) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("duplicate"))
|
||||
return
|
||||
}
|
||||
|
||||
// A fact with no occurred_at would otherwise be stored at the Unix epoch:
|
||||
// dated 1970 on the permalink, pinned to the bottom of the section, and
|
||||
// outside every digest window. Treat "missing" as "now".
|
||||
occurredAt := f.OccurredAt
|
||||
if occurredAt <= 0 {
|
||||
occurredAt = time.Now().Unix()
|
||||
}
|
||||
|
||||
articleURL := s.advPermalink(f.GUID)
|
||||
imageURL := advArtURL(f.EventType)
|
||||
if err := storage.InsertStory(&storage.Story{
|
||||
GUID: f.GUID,
|
||||
Headline: headline,
|
||||
Lede: lede,
|
||||
ImageURL: imageURL,
|
||||
ArticleURL: articleURL,
|
||||
Source: advSource,
|
||||
Channel: "adventure",
|
||||
Classified: true,
|
||||
SeenAt: occurredAt,
|
||||
PublishedAt: occurredAt,
|
||||
}); err != nil {
|
||||
slog.Error("adventure ingest: insert failed", "guid", f.GUID, "err", err)
|
||||
http.Error(w, "insert failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
slog.Info("adventure ingest: published", "guid", f.GUID, "event_type", f.EventType, "tier", f.Tier)
|
||||
|
||||
// NoPush (cold-start backfill) means "never goes to Matrix". Suppressing only
|
||||
// the live post isn't enough: the digest collects adventure rows that carry no
|
||||
// post_log entry, so a backfilled bulletin would still be swept into the next
|
||||
// roundup — the back-catalogue dump NoPush exists to prevent. Retire the guid
|
||||
// against the digest up front instead.
|
||||
if f.NoPush {
|
||||
storage.MarkAdventureDigested([]string{f.GUID}, advBackfillEvent)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
return
|
||||
}
|
||||
|
||||
// PRIORITY beats post live to Matrix; BULLETIN beats wait for the daily
|
||||
// digest. Website section always gets the row above regardless of tier.
|
||||
if f.Tier == "priority" && s.advPost != nil && s.adv.Channel != "" {
|
||||
// No ImageURL: the emblem is an SVG (Matrix clients often block SVG
|
||||
// media), and the link's og:image carries the preview instead.
|
||||
s.advPost(AdvPost{
|
||||
GUID: f.GUID,
|
||||
Headline: headline,
|
||||
Lede: lede,
|
||||
ArticleURL: articleURL,
|
||||
Source: advSource,
|
||||
Channel: s.adv.Channel,
|
||||
})
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
// advEventMeta maps an event_type to a short display label and emoji used by the
|
||||
// permalink page (and, later, OG art selection). Unknown types fall back to a
|
||||
// neutral dispatch label so a new gogobee event never renders blank.
|
||||
func advEventMeta(eventType string) (label, emoji string) {
|
||||
switch eventType {
|
||||
case "siege_start", "siege_win", "siege_loss":
|
||||
return "The Siege", "🏰"
|
||||
case "boss_first", "boss_kill":
|
||||
return "Boss down", "🐉"
|
||||
case "zone_first":
|
||||
return "First clear", "🗺️"
|
||||
case "zone_clear":
|
||||
return "Zone cleared", "🗺️"
|
||||
case "death":
|
||||
return "In memoriam", "🪦"
|
||||
case "arrival":
|
||||
return "New arrival", "👋"
|
||||
case "standings", "rival_result":
|
||||
return "The rival board", "⚔️"
|
||||
case "pete_duel_loss", "pete_duel_win":
|
||||
return "Pete's duels", "🤝"
|
||||
case "milestone":
|
||||
return "Milestone", "🏅"
|
||||
}
|
||||
return "Dispatch", "📣"
|
||||
}
|
||||
|
||||
// advArtURL is the card/OG image for a dispatch: a themed SVG emblem served by
|
||||
// handleAdventureArt, keyed on event_type. Local (root-relative) so it bypasses
|
||||
// the external-image thumbnailer.
|
||||
func advArtURL(eventType string) string {
|
||||
return "/adventure/art/" + eventType + ".svg"
|
||||
}
|
||||
|
||||
// handleAdventureArt renders the themed emblem for an event type — an adventure
|
||||
// gradient with the event's emoji and label. Deterministic and dependency-free
|
||||
// (no external asset), so every dispatch card has visual identity instead of the
|
||||
// blank placeholder that made the section look broken next to RSS cards.
|
||||
func (s *Server) handleAdventureArt(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.adv.Enabled {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
eventType := strings.TrimSuffix(r.PathValue("type"), ".svg")
|
||||
label, emoji := advEventMeta(eventType)
|
||||
w.Header().Set("Content-Type", "image/svg+xml; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
_, _ = fmt.Fprintf(w, advArtSVG, template.HTMLEscapeString(emoji), template.HTMLEscapeString(strings.ToUpper(label)))
|
||||
}
|
||||
|
||||
// advArtSVG is the emblem template: %s = emoji, %s = label. 1200×630 (the OG
|
||||
// card ratio) so the same image works as a link-preview image.
|
||||
const advArtSVG = `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#7c5ce8"/>
|
||||
<stop offset="1" stop-color="#5836b8"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1200" height="630" fill="url(#g)"/>
|
||||
<text x="600" y="300" font-size="260" text-anchor="middle" dominant-baseline="central">%s</text>
|
||||
<text x="600" y="500" font-size="64" font-family="Fredoka, Nunito, system-ui, sans-serif" font-weight="700" fill="#ffffff" text-anchor="middle" letter-spacing="6" opacity="0.92">%s</text>
|
||||
</svg>`
|
||||
|
||||
// advStoryPage is the per-story permalink view. It reuses the shared layout so a
|
||||
// dispatch reads like the rest of the site, with an adventure-themed hero.
|
||||
type advStoryPage struct {
|
||||
pageData
|
||||
EventLabel string
|
||||
Emoji string
|
||||
Headline string
|
||||
Body string
|
||||
Region string
|
||||
When string
|
||||
Permalink string
|
||||
}
|
||||
|
||||
// handleAdventureStory serves the server-rendered permalink for one dispatch
|
||||
// (the article_url every ingested story points at). Public and cacheable; 404s
|
||||
// when the section is disabled or the guid is unknown. Character names in the
|
||||
// stored headline/body already passed the ingest fact-guard, so nothing
|
||||
// player-controlled reaches here unchecked.
|
||||
func (s *Server) handleAdventureStory(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.adv.Enabled {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
guid := r.PathValue("guid")
|
||||
st, err := storage.GetStoryByGUID(guid)
|
||||
if err != nil || st == nil || st.Channel != "adventure" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
s.track(r, "adventure")
|
||||
|
||||
// event_type is encoded in the guid prefix (e.g. "death:<hash>:<ts>"); fall
|
||||
// back to the neutral dispatch meta when it isn't a known type.
|
||||
eventType, _, _ := strings.Cut(guid, ":")
|
||||
label, emoji := advEventMeta(eventType)
|
||||
|
||||
body := st.Content
|
||||
if strings.TrimSpace(body) == "" {
|
||||
body = st.Lede // template-only dispatches carry the write-up in the lede
|
||||
}
|
||||
|
||||
base := s.base(r)
|
||||
base.Active = "adventure"
|
||||
base.NoIndex = true // player-named page; keep out of search indexes (gap #5)
|
||||
if abs := strings.TrimRight(s.cfg.BaseURL, "/"); abs != "" {
|
||||
base.OGImage = abs + advArtURL(eventType) // emblem for link unfurls
|
||||
}
|
||||
s.render(w, "story", advStoryPage{
|
||||
pageData: base,
|
||||
EventLabel: label,
|
||||
Emoji: emoji,
|
||||
Headline: st.Headline,
|
||||
Body: body,
|
||||
Region: "", // reserved: region isn't stored on the row yet
|
||||
When: time.Unix(st.SeenAt, 0).UTC().Format("Jan 2, 2006"),
|
||||
Permalink: s.advPermalink(guid),
|
||||
})
|
||||
}
|
||||
|
||||
// bearerOK checks the Authorization: Bearer header against the configured ingest
|
||||
// token in constant time.
|
||||
func (s *Server) bearerOK(r *http.Request) bool {
|
||||
const prefix = "Bearer "
|
||||
h := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(h, prefix) || s.adv.IngestToken == "" {
|
||||
return false
|
||||
}
|
||||
got := strings.TrimPrefix(h, prefix)
|
||||
return subtle.ConstantTimeCompare([]byte(got), []byte(s.adv.IngestToken)) == 1
|
||||
}
|
||||
|
||||
// siteURL makes a root-relative path absolute against BaseURL. Links that go out
|
||||
// to Matrix need the absolute form to survive safeHref; when BaseURL isn't
|
||||
// configured the relative form is all we have, and it's still fine on-site.
|
||||
func (s *Server) siteURL(path string) string {
|
||||
return strings.TrimRight(s.cfg.BaseURL, "/") + path
|
||||
}
|
||||
|
||||
// advPermalink builds the per-story Pete permalink used as article_url (the card
|
||||
// link + Matrix link). The guid is path-escaped: it's ingest-supplied, and a
|
||||
// stray "/" or "?" would otherwise produce a link that routes somewhere else.
|
||||
func (s *Server) advPermalink(guid string) string {
|
||||
return s.siteURL("/adventure/" + url.PathEscape(guid))
|
||||
}
|
||||
|
||||
// factGuard verifies every player-name field we might render is present in the
|
||||
// actors allow-list. Boss/zone/region/milestone are game-authored content, not
|
||||
// player-controlled, so they are not guarded.
|
||||
func factGuard(f AdvFact) bool {
|
||||
allow := make(map[string]bool, len(f.Actors))
|
||||
for _, a := range f.Actors {
|
||||
if a != "" {
|
||||
allow[a] = true
|
||||
}
|
||||
}
|
||||
if f.Subject != "" && !allow[f.Subject] {
|
||||
return false
|
||||
}
|
||||
if f.Opponent != "" && !allow[f.Opponent] {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// renderAdventure returns the deterministic headline + lede for a fact. Copied
|
||||
// verbatim from the voice spec (pete_adventure_news_voice.md). Template-only —
|
||||
// no LLM — so output is safe and reproducible. ok is false for an unknown type.
|
||||
func renderAdventure(f AdvFact) (headline, lede string, ok bool) {
|
||||
atLevel := ""
|
||||
if f.Level > 0 {
|
||||
atLevel = fmt.Sprintf(", at level %d", f.Level)
|
||||
}
|
||||
switch f.EventType {
|
||||
case "siege_start":
|
||||
return fmt.Sprintf("Breaking: %s is marching on the town.", f.Boss),
|
||||
fmt.Sprintf("Folks, this is the big one — %s has camped outside the gates and the whole community's needed to turn it back. You've got %s. Let's rally.", f.Boss, f.Stakes), true
|
||||
case "siege_win":
|
||||
return fmt.Sprintf("The town holds! %s turned back.", f.Boss),
|
||||
fmt.Sprintf("What a turnout — %d defenders stood shoulder to shoulder and sent %s packing. Spoils are going out now. Proud of you all.", f.Count, f.Boss), true
|
||||
case "siege_loss":
|
||||
return fmt.Sprintf("Heavy news: %s broke through.", f.Boss),
|
||||
fmt.Sprintf("We gave it everything, but %s got past the gates and took its tribute. We'll be ready next time — heads up, everyone.", f.Boss), true
|
||||
case "boss_first":
|
||||
return fmt.Sprintf("First ever: %s brings down %s.", f.Subject, f.Boss),
|
||||
fmt.Sprintf("History in %s today — %s is the first anyone's seen clear %s. Nobody had done it before. Hats off.", f.Region, f.Subject, f.Boss), true
|
||||
case "boss_kill":
|
||||
return fmt.Sprintf("%s takes down %s again.", f.Subject, f.Boss),
|
||||
fmt.Sprintf("Another clean run in %s today. Routine for %s by now — but still worth a nod.", f.Zone, f.Subject), true
|
||||
case "zone_first", "zone_clear":
|
||||
// gogobee splits the realm's first-ever clear (zone_first, priority) from a
|
||||
// later repeat (zone_clear, bulletin); they share a lede but differ in
|
||||
// headline. Fall back to the tier for a legacy zone_first that predates the
|
||||
// split.
|
||||
if f.EventType == "zone_first" || f.Tier == "priority" {
|
||||
headline = fmt.Sprintf("%s cleared for the very first time.", f.Zone)
|
||||
} else {
|
||||
headline = fmt.Sprintf("%s clears %s.", f.Subject, f.Zone)
|
||||
}
|
||||
inRegion := ""
|
||||
if f.Region != "" {
|
||||
inRegion = " in " + f.Region
|
||||
}
|
||||
return headline, fmt.Sprintf("%s made it through %s%s%s. Nicely done.", f.Subject, f.Zone, inRegion, atLevel), true
|
||||
case "death":
|
||||
return fmt.Sprintf("We lost %s in %s.", f.Subject, f.Zone),
|
||||
fmt.Sprintf("Sad news to pass along: %s fell at level %d in %s. The graveyard's a little fuller tonight. Rest easy.", f.Subject, f.Level, f.Zone), true
|
||||
case "arrival":
|
||||
return fmt.Sprintf("Welcome to the realm, %s!", f.Subject),
|
||||
fmt.Sprintf("A new %s just walked through the gates. Say hello if you see them out there.", f.ClassRace), true
|
||||
case "standings":
|
||||
return "The rival board's been shaken up.",
|
||||
fmt.Sprintf("%s is on the move — here's where the standings sit today.", f.Subject), true
|
||||
case "rival_result":
|
||||
return fmt.Sprintf("%s settles the score with %s.", f.Subject, f.Opponent),
|
||||
fmt.Sprintf("Their duel went %s's way today, and the board reflects it. Good match, you two.", f.Subject), true
|
||||
case "pete_duel_loss":
|
||||
if f.Tier == "priority" {
|
||||
headline = fmt.Sprintf("You got me, %s.", f.Subject)
|
||||
} else {
|
||||
headline = fmt.Sprintf("%s got the better of me again.", f.Subject)
|
||||
}
|
||||
return headline, fmt.Sprintf("Credit where it's due — %s beat me fair and square. Good duel. I'll want a rematch when you're ready.", f.Subject), true
|
||||
case "pete_duel_win":
|
||||
return fmt.Sprintf("Held my ground against %s today.", f.Subject),
|
||||
fmt.Sprintf("Closer than the record will show, honestly — %s pushed me. Rematch whenever you like.", f.Subject), true
|
||||
case "milestone":
|
||||
return fmt.Sprintf("%s hits %s.", f.Subject, f.Milestone),
|
||||
fmt.Sprintf("One for the books — %s just reached %s. The long road continues.", f.Subject, f.Milestone), true
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
155
internal/web/adventure_digest.go
Normal file
155
internal/web/adventure_digest.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// The BULLETIN digest is the batched counterpart to the live PRIORITY beats.
|
||||
// Once a day (Adventure.DigestHour, UTC) Pete collects the adventure dispatches
|
||||
// that were seen since the last digest but never posted live — exactly the
|
||||
// bulletins — and posts a single warm roundup to the adventure channel. The
|
||||
// website already carries each one as its own card; the digest is the Matrix-only
|
||||
// nudge so quiet-but-real activity surfaces without one ping per event.
|
||||
|
||||
const (
|
||||
// digestWindow bounds how far back a digest looks. Wider than a day so a
|
||||
// missed run (process down over a digest hour) still sweeps up the gap;
|
||||
// re-collection is prevented by MarkAdventureDigested, not by the window.
|
||||
digestWindow = 48 * time.Hour
|
||||
// digestCap bounds a single digest. Far past the observed volume (a dormant
|
||||
// community, per the voice spec); a runaway just truncates with a log line.
|
||||
digestCap = 40
|
||||
// digestPreview is how many headlines the roundup lede lists by name before
|
||||
// collapsing the rest to "…and N more".
|
||||
digestPreview = 4
|
||||
)
|
||||
|
||||
// StartAdventureDigest launches the daily bulletin-digest loop. No-op unless the
|
||||
// section is enabled AND there's a live Matrix poster AND a channel to post to —
|
||||
// i.e. website-only and local modes never post a digest.
|
||||
func (s *Server) StartAdventureDigest(ctx context.Context) {
|
||||
if !s.adv.Enabled || s.advPost == nil || s.adv.Channel == "" {
|
||||
return
|
||||
}
|
||||
go s.runAdventureDigest(ctx)
|
||||
}
|
||||
|
||||
// runAdventureDigest sleeps until the next DigestHour, posts, then repeats every
|
||||
// 24h. Sleeping to a wall-clock hour (not a fixed ticker from boot) keeps the
|
||||
// digest at a predictable time of day across restarts.
|
||||
func (s *Server) runAdventureDigest(ctx context.Context) {
|
||||
hour := s.adv.DigestHourOrDefault()
|
||||
slog.Info("web: adventure digest scheduler started", "digest_hour_utc", hour, "channel", s.adv.Channel)
|
||||
for {
|
||||
wait := durUntilNextHour(time.Now().UTC(), hour)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(wait):
|
||||
s.postDailyDigest(time.Now().UTC())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// durUntilNextHour returns the duration from now to the next occurrence of the
|
||||
// given UTC hour. If it's exactly the hour now, it targets tomorrow so a restart
|
||||
// at the hour doesn't double-fire.
|
||||
func durUntilNextHour(now time.Time, hour int) time.Duration {
|
||||
next := time.Date(now.Year(), now.Month(), now.Day(), hour, 0, 0, 0, time.UTC)
|
||||
if !next.After(now) {
|
||||
next = next.Add(24 * time.Hour)
|
||||
}
|
||||
return next.Sub(now)
|
||||
}
|
||||
|
||||
// postDailyDigest collects the window's un-posted bulletins, posts one roundup,
|
||||
// and marks them digested so they don't recur. Silent when there's nothing new —
|
||||
// a dormant realm should stay quiet, not ship an empty digest.
|
||||
func (s *Server) postDailyDigest(now time.Time) {
|
||||
since := now.Add(-digestWindow).Unix()
|
||||
bulletins, total, err := storage.UnpostedAdventureSince(since, digestCap)
|
||||
if err != nil {
|
||||
slog.Error("adventure digest: query failed", "err", err)
|
||||
return
|
||||
}
|
||||
if len(bulletins) == 0 {
|
||||
return
|
||||
}
|
||||
if total > len(bulletins) {
|
||||
slog.Warn("adventure digest: window exceeded cap, truncating", "cap", digestCap, "total", total)
|
||||
}
|
||||
|
||||
date := now.Format("2006-01-02")
|
||||
eventID := "adv-digest:" + date
|
||||
headline, lede := buildDigest(bulletins, total)
|
||||
|
||||
s.advPost(AdvPost{
|
||||
GUID: eventID,
|
||||
Headline: headline,
|
||||
Lede: lede,
|
||||
ArticleURL: s.digestURL(date),
|
||||
Source: advSource,
|
||||
Channel: s.adv.Channel,
|
||||
})
|
||||
|
||||
guids := make([]string, len(bulletins))
|
||||
for i, b := range bulletins {
|
||||
guids[i] = b.GUID
|
||||
}
|
||||
storage.MarkAdventureDigested(guids, eventID)
|
||||
slog.Info("adventure digest: posted", "date", date, "items", len(bulletins))
|
||||
}
|
||||
|
||||
// buildDigest renders the roundup headline + lede in Pete's warm reporter voice.
|
||||
// total is how many bulletins the window actually holds, which is larger than
|
||||
// len(bulletins) when the cap truncated the fetch — the counts quoted to readers
|
||||
// have to describe the realm, not the slice. Headlines come from stored,
|
||||
// fact-guarded rows, so no player-controlled text is introduced here.
|
||||
func buildDigest(bulletins []storage.Story, total int) (headline, lede string) {
|
||||
if total == 1 {
|
||||
return "Today in the realm: one dispatch.",
|
||||
fmt.Sprintf("Quiet day out there, but one worth noting — %s Full story on the board.", trimHeadline(bulletins[0].Headline))
|
||||
}
|
||||
headline = fmt.Sprintf("Today in the realm: %d dispatches.", total)
|
||||
|
||||
shown := bulletins
|
||||
if len(shown) > digestPreview {
|
||||
shown = shown[:digestPreview]
|
||||
}
|
||||
parts := make([]string, len(shown))
|
||||
for i, b := range shown {
|
||||
parts[i] = trimHeadline(b.Headline)
|
||||
}
|
||||
list := strings.Join(parts, " ")
|
||||
more := ""
|
||||
if total > len(shown) {
|
||||
more = fmt.Sprintf(" …and %d more.", total-len(shown))
|
||||
}
|
||||
return headline, fmt.Sprintf("Here's what came across the wire today. %s%s The full board's on the site.", list, more)
|
||||
}
|
||||
|
||||
// trimHeadline ensures a headline ends with sentence punctuation so the joined
|
||||
// lede reads as prose rather than a run-on.
|
||||
func trimHeadline(h string) string {
|
||||
h = strings.TrimSpace(h)
|
||||
if h == "" {
|
||||
return ""
|
||||
}
|
||||
if last := h[len(h)-1]; last != '.' && last != '!' && last != '?' {
|
||||
h += "."
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// digestURL points the roundup at the section, with a per-day query param so the
|
||||
// canonical-URL dedup treats each day's digest as distinct (it strips only
|
||||
// tracking params, keeping ?digest=).
|
||||
func (s *Server) digestURL(date string) string {
|
||||
return s.siteURL("/adventure?digest=" + date)
|
||||
}
|
||||
313
internal/web/adventure_test.go
Normal file
313
internal/web/adventure_test.go
Normal file
@@ -0,0 +1,313 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"pete/internal/config"
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// newAdvServer builds a web server with the adventure seam enabled and a
|
||||
// capturing priority poster, backed by a fresh temp DB.
|
||||
func newAdvServer(t *testing.T, token string) (*Server, *[]AdvPost) {
|
||||
t.Helper()
|
||||
storage.Close()
|
||||
if err := storage.Init(filepath.Join(t.TempDir(), "adv.db")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { storage.Close() })
|
||||
|
||||
var posted []AdvPost
|
||||
adv := config.AdventureConfig{Enabled: true, IngestToken: token, Channel: "adventure"}
|
||||
// Mirror the production poster's side effect: queue.PostNow records a
|
||||
// post_log row, which is exactly what marks a beat "posted" and thus
|
||||
// excludes it from the bulletin digest.
|
||||
poster := func(p AdvPost) {
|
||||
posted = append(posted, p)
|
||||
storage.InsertPostLog(p.GUID, "adventure", p.GUID, "", false)
|
||||
}
|
||||
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0", BaseURL: "https://news.example"},
|
||||
nil, true, adv, poster)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s, &posted
|
||||
}
|
||||
|
||||
func postFact(t *testing.T, s *Server, token string, f AdvFact) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(f)
|
||||
req := httptest.NewRequest("POST", "/api/ingest/adventure", bytes.NewReader(body))
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
rw := httptest.NewRecorder()
|
||||
s.handleAdventureIngest(rw, req)
|
||||
return rw
|
||||
}
|
||||
|
||||
// TestAdventureIngestEndToEnd covers the seam: a priority death fact is
|
||||
// bearer-accepted, templated, stored as an adventure story, and posted live.
|
||||
func TestAdventureIngestEndToEnd(t *testing.T) {
|
||||
const token = "s3cret-token"
|
||||
s, posted := newAdvServer(t, token)
|
||||
|
||||
f := AdvFact{
|
||||
GUID: "death:abc:1000", EventType: "death", Tier: "priority",
|
||||
Actors: []string{"Brannigan"}, Subject: "Brannigan",
|
||||
Zone: "the Underforge", Level: 14, OccurredAt: 1000,
|
||||
}
|
||||
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
||||
t.Fatalf("ingest status = %d body=%s", rw.Code, rw.Body.String())
|
||||
}
|
||||
|
||||
got, err := storage.GetStoryByGUID("death:abc:1000")
|
||||
if err != nil || got == nil {
|
||||
t.Fatalf("story not stored: %v", err)
|
||||
}
|
||||
if got.Channel != "adventure" || got.Source != advSource {
|
||||
t.Errorf("channel/source = %q/%q", got.Channel, got.Source)
|
||||
}
|
||||
if got.Headline != "We lost Brannigan in the Underforge." {
|
||||
t.Errorf("headline = %q", got.Headline)
|
||||
}
|
||||
if got.ArticleURL != "https://news.example/adventure/death:abc:1000" {
|
||||
t.Errorf("article_url = %q", got.ArticleURL)
|
||||
}
|
||||
if len(*posted) != 1 || (*posted)[0].GUID != f.GUID {
|
||||
t.Fatalf("priority post not delivered: %+v", *posted)
|
||||
}
|
||||
|
||||
// Idempotent re-delivery: no error, no second post.
|
||||
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
||||
t.Fatalf("dup ingest status = %d", rw.Code)
|
||||
}
|
||||
if len(*posted) != 1 {
|
||||
t.Errorf("duplicate fact re-posted: %d posts", len(*posted))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdventurePermalink renders the per-story page the article_url points at:
|
||||
// an ingested dispatch must be fetchable at /adventure/{guid} with its headline
|
||||
// and body, and an unknown guid must 404.
|
||||
func TestAdventurePermalink(t *testing.T) {
|
||||
const token = "t"
|
||||
s, _ := newAdvServer(t, token)
|
||||
f := AdvFact{
|
||||
GUID: "death:abc:1000", EventType: "death", Tier: "priority",
|
||||
Actors: []string{"Brannigan"}, Subject: "Brannigan",
|
||||
Zone: "the Underforge", Level: 14, OccurredAt: 1000,
|
||||
}
|
||||
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
||||
t.Fatalf("ingest status = %d", rw.Code)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/adventure/death:abc:1000", nil)
|
||||
req.SetPathValue("guid", "death:abc:1000")
|
||||
rw := httptest.NewRecorder()
|
||||
s.handleAdventureStory(rw, req)
|
||||
if rw.Code != 200 {
|
||||
t.Fatalf("permalink status = %d body=%s", rw.Code, rw.Body.String())
|
||||
}
|
||||
body := rw.Body.String()
|
||||
if !bytes.Contains([]byte(body), []byte("We lost Brannigan in the Underforge.")) {
|
||||
t.Errorf("permalink missing headline; body=%s", body)
|
||||
}
|
||||
if !bytes.Contains([]byte(body), []byte("In memoriam")) {
|
||||
t.Errorf("permalink missing event label; body=%s", body)
|
||||
}
|
||||
|
||||
// Unknown guid 404s.
|
||||
req2 := httptest.NewRequest("GET", "/adventure/nope:1", nil)
|
||||
req2.SetPathValue("guid", "nope:1")
|
||||
rw2 := httptest.NewRecorder()
|
||||
s.handleAdventureStory(rw2, req2)
|
||||
if rw2.Code != 404 {
|
||||
t.Errorf("unknown guid status = %d, want 404", rw2.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDurUntilNextHour targets the next UTC occurrence of the digest hour and
|
||||
// rolls to tomorrow when it's already that hour (so a restart can't double-fire).
|
||||
func TestDurUntilNextHour(t *testing.T) {
|
||||
// 14:30 UTC, targeting 17:00 → 2h30m today.
|
||||
now := time.Date(2026, 7, 11, 14, 30, 0, 0, time.UTC)
|
||||
if got := durUntilNextHour(now, 17); got != 2*time.Hour+30*time.Minute {
|
||||
t.Errorf("before hour: got %v", got)
|
||||
}
|
||||
// 17:00 exactly → tomorrow's 17:00 (24h), not zero.
|
||||
now = time.Date(2026, 7, 11, 17, 0, 0, 0, time.UTC)
|
||||
if got := durUntilNextHour(now, 17); got != 24*time.Hour {
|
||||
t.Errorf("at hour: got %v", got)
|
||||
}
|
||||
// 20:00, targeting 17:00 → tomorrow, 21h.
|
||||
now = time.Date(2026, 7, 11, 20, 0, 0, 0, time.UTC)
|
||||
if got := durUntilNextHour(now, 17); got != 21*time.Hour {
|
||||
t.Errorf("after hour: got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdventureDigest covers the batched path: bulletins (no live post) are
|
||||
// collected into one roundup, priority beats are excluded (they already posted),
|
||||
// digested bulletins don't recur, and an empty window stays silent.
|
||||
func TestAdventureDigest(t *testing.T) {
|
||||
const token = "t"
|
||||
s, posted := newAdvServer(t, token)
|
||||
now := time.Now()
|
||||
|
||||
// Two bulletins + one priority (which posts live and must be excluded).
|
||||
postFact(t, s, token, AdvFact{GUID: "arrival:a:1", EventType: "arrival", Tier: "bulletin",
|
||||
Actors: []string{"Zapp"}, Subject: "Zapp", ClassRace: "Elf Ranger", OccurredAt: now.Unix()})
|
||||
postFact(t, s, token, AdvFact{GUID: "rival:b:2", EventType: "rival_result", Tier: "bulletin",
|
||||
Actors: []string{"Kif", "Zapp"}, Subject: "Kif", Opponent: "Zapp", Outcome: "won", OccurredAt: now.Unix()})
|
||||
postFact(t, s, token, AdvFact{GUID: "death:c:3", EventType: "death", Tier: "priority",
|
||||
Actors: []string{"Brannigan"}, Subject: "Brannigan", Zone: "the Underforge", Level: 9, OccurredAt: now.Unix()})
|
||||
if len(*posted) != 1 {
|
||||
t.Fatalf("setup: priority posts = %d, want 1", len(*posted))
|
||||
}
|
||||
|
||||
s.postDailyDigest(now.UTC())
|
||||
if len(*posted) != 2 {
|
||||
t.Fatalf("digest not posted: total posts = %d, want 2", len(*posted))
|
||||
}
|
||||
dg := (*posted)[1]
|
||||
if !strings.Contains(dg.Headline, "2 dispatches") {
|
||||
t.Errorf("digest headline = %q", dg.Headline)
|
||||
}
|
||||
if !strings.HasPrefix(dg.GUID, "adv-digest:") {
|
||||
t.Errorf("digest guid = %q", dg.GUID)
|
||||
}
|
||||
|
||||
// Re-running finds nothing new (bulletins marked digested).
|
||||
s.postDailyDigest(now.UTC())
|
||||
if len(*posted) != 2 {
|
||||
t.Errorf("digest re-posted: total = %d, want 2", len(*posted))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdventureArtAndMeta covers the visual-identity slice: the emblem endpoint
|
||||
// returns a themed SVG, ingested cards carry its local path, and the permalink
|
||||
// page is noindex with an og:image.
|
||||
// TestRenderZoneTaxonomy: a realm-first (zone_first) reads as first-ever; a
|
||||
// repeat (zone_clear) reads as a personal clear, not a mis-labeled "first".
|
||||
func TestRenderZoneTaxonomy(t *testing.T) {
|
||||
first := AdvFact{EventType: "zone_first", Tier: "priority", Subject: "Brannigan",
|
||||
Zone: "Dragon's Lair", Region: "the Underforge", Level: 14}
|
||||
hl, _, ok := renderAdventure(first)
|
||||
if !ok || !strings.Contains(hl, "very first time") {
|
||||
t.Errorf("zone_first headline = %q (ok=%v)", hl, ok)
|
||||
}
|
||||
|
||||
repeat := AdvFact{EventType: "zone_clear", Tier: "bulletin", Subject: "Brannigan",
|
||||
Zone: "Dragon's Lair", Region: "the Underforge", Level: 14}
|
||||
hl2, _, ok := renderAdventure(repeat)
|
||||
if !ok || !strings.Contains(hl2, "Brannigan clears") || strings.Contains(hl2, "first") {
|
||||
t.Errorf("zone_clear headline = %q (ok=%v)", hl2, ok)
|
||||
}
|
||||
|
||||
// The permalink label distinguishes the two, so a repeat's page isn't stamped
|
||||
// "First clear".
|
||||
if lbl, _ := advEventMeta("zone_clear"); lbl == "First clear" {
|
||||
t.Errorf("zone_clear meta label = %q, want distinct from first clear", lbl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdventureArtAndMeta(t *testing.T) {
|
||||
const token = "t"
|
||||
s, _ := newAdvServer(t, token)
|
||||
|
||||
// Emblem endpoint returns SVG with the event's emoji.
|
||||
areq := httptest.NewRequest("GET", "/adventure/art/death.svg", nil)
|
||||
areq.SetPathValue("type", "death.svg")
|
||||
arw := httptest.NewRecorder()
|
||||
s.handleAdventureArt(arw, areq)
|
||||
if arw.Code != 200 {
|
||||
t.Fatalf("art status = %d", arw.Code)
|
||||
}
|
||||
if ct := arw.Header().Get("Content-Type"); !strings.HasPrefix(ct, "image/svg+xml") {
|
||||
t.Errorf("art content-type = %q", ct)
|
||||
}
|
||||
if b := arw.Body.String(); !strings.Contains(b, "🪦") || !strings.Contains(b, "<svg") {
|
||||
t.Errorf("art body missing emblem: %s", b)
|
||||
}
|
||||
|
||||
// Ingest sets the card image to the local emblem path.
|
||||
f := AdvFact{GUID: "death:abc:1000", EventType: "death", Tier: "priority",
|
||||
Actors: []string{"Brannigan"}, Subject: "Brannigan", Zone: "the Underforge", Level: 4, OccurredAt: 1000}
|
||||
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
||||
t.Fatalf("ingest status = %d", rw.Code)
|
||||
}
|
||||
got, _ := storage.GetStoryByGUID("death:abc:1000")
|
||||
if got == nil || got.ImageURL != "/adventure/art/death.svg" {
|
||||
t.Errorf("story image = %q", got.ImageURL)
|
||||
}
|
||||
|
||||
// Permalink page is noindex with an og:image.
|
||||
preq := httptest.NewRequest("GET", "/adventure/death:abc:1000", nil)
|
||||
preq.SetPathValue("guid", "death:abc:1000")
|
||||
prw := httptest.NewRecorder()
|
||||
s.handleAdventureStory(prw, preq)
|
||||
body := prw.Body.String()
|
||||
if !strings.Contains(body, `name="robots" content="noindex"`) {
|
||||
t.Error("permalink not noindex")
|
||||
}
|
||||
if !strings.Contains(body, `property="og:image" content="https://news.example/adventure/art/death.svg"`) {
|
||||
t.Errorf("permalink missing og:image; body=%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdventureIngestBearer rejects missing/wrong tokens.
|
||||
func TestAdventureIngestBearer(t *testing.T) {
|
||||
s, _ := newAdvServer(t, "right")
|
||||
f := AdvFact{GUID: "arrival:x:1", EventType: "arrival", Tier: "bulletin", OccurredAt: 1}
|
||||
if rw := postFact(t, s, "", f); rw.Code != 401 {
|
||||
t.Errorf("no token: status = %d, want 401", rw.Code)
|
||||
}
|
||||
if rw := postFact(t, s, "wrong", f); rw.Code != 401 {
|
||||
t.Errorf("wrong token: status = %d, want 401", rw.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdventureFactGuard rejects a subject not present in the actors allow-list,
|
||||
// so a name that slipped the source can't reach a public page.
|
||||
func TestAdventureFactGuard(t *testing.T) {
|
||||
const token = "t"
|
||||
s, posted := newAdvServer(t, token)
|
||||
f := AdvFact{
|
||||
GUID: "death:evil:1", EventType: "death", Tier: "priority",
|
||||
Actors: []string{"Brannigan"}, Subject: "Kif", // Kif not in actors
|
||||
Zone: "the Underforge", Level: 3, OccurredAt: 1,
|
||||
}
|
||||
if rw := postFact(t, s, token, f); rw.Code != 400 {
|
||||
t.Errorf("fact-guard: status = %d, want 400", rw.Code)
|
||||
}
|
||||
if storage.IsGUIDSeen("death:evil:1") {
|
||||
t.Error("guarded fact was stored")
|
||||
}
|
||||
if len(*posted) != 0 {
|
||||
t.Error("guarded fact was posted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdventureDisabled 404s when the seam is off.
|
||||
func TestAdventureDisabled(t *testing.T) {
|
||||
storage.Close()
|
||||
if err := storage.Init(filepath.Join(t.TempDir(), "off.db")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { storage.Close() })
|
||||
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true, config.AdventureConfig{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := AdvFact{GUID: "x", EventType: "arrival", OccurredAt: 1}
|
||||
if rw := postFact(t, s, "anything", f); rw.Code != 404 {
|
||||
t.Errorf("disabled: status = %d, want 404", rw.Code)
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,7 @@ func TestFeeds(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0", BaseURL: "https://news.example/"}, nil, true)
|
||||
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0", BaseURL: "https://news.example/"}, nil, true, config.AdventureConfig{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -104,6 +104,9 @@ type pageData struct {
|
||||
IsAdmin bool // signed-in user is on the admin allowlist (shows /status link)
|
||||
PushEnabled bool // Web Push is configured (shows the notifications toggle to signed-in users)
|
||||
PushPublicKey string // VAPID public key handed to the client to subscribe
|
||||
TTS template.JS // JSON {enabled, default, voices:[{id,label}]} for read-aloud, or "null"
|
||||
NoIndex bool // emit <meta name="robots" content="noindex"> — used by the adventure section
|
||||
OGImage string // absolute og:image URL for link unfurls (adventure emblem); "" = none
|
||||
}
|
||||
|
||||
type channelPage struct {
|
||||
@@ -157,7 +160,7 @@ func (s *Server) base(r *http.Request) pageData {
|
||||
}
|
||||
d := pageData{
|
||||
SiteTitle: s.cfg.SiteTitle,
|
||||
Channels: channels,
|
||||
Channels: s.channels,
|
||||
Weather: currentWeather(time.Now()),
|
||||
AllSources: jsForScript(srcJSON),
|
||||
AuthEnabled: s.auth != nil,
|
||||
@@ -166,6 +169,10 @@ func (s *Server) base(r *http.Request) pageData {
|
||||
PostingEnabled: s.postingEnabled,
|
||||
PushEnabled: s.auth != nil && s.cfg.Push.Enabled,
|
||||
PushPublicKey: s.cfg.Push.VAPIDPublicKey,
|
||||
TTS: template.JS("null"),
|
||||
}
|
||||
if s.tts != nil {
|
||||
d.TTS = s.tts.clientConfig()
|
||||
}
|
||||
if s.auth != nil {
|
||||
if u := s.auth.userFromRequest(r); u != nil {
|
||||
@@ -210,8 +217,8 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
dayStart := time.Now().Add(-24 * time.Hour).Unix()
|
||||
stats := make([]channelStat, 0, len(channels))
|
||||
for _, ch := range channels {
|
||||
stats := make([]channelStat, 0, len(s.channels))
|
||||
for _, ch := range s.channels {
|
||||
total, _ := storage.CountClassifiedByChannel(ch.Slug)
|
||||
lastTs := storage.GetLastPostTime(ch.Slug)
|
||||
stat := channelStat{
|
||||
@@ -322,6 +329,9 @@ func (s *Server) handleChannel(w http.ResponseWriter, r *http.Request, ch Channe
|
||||
|
||||
base := s.base(r)
|
||||
base.Active = ch.Slug
|
||||
// The adventure section names player characters; keep it out of search
|
||||
// indexes to bound the cached-forever exposure (see plan gap #5).
|
||||
base.NoIndex = ch.Slug == "adventure"
|
||||
data := channelPage{
|
||||
pageData: base,
|
||||
Channel: ch,
|
||||
@@ -396,7 +406,7 @@ func (s *Server) handleBookmarks(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
var (
|
||||
demoVariants = []string{"rain", "petals", "jacaranda", "motes", "leaves", "clear", "clouds", "snow", "fog", "storm"}
|
||||
demoVariants = []string{"clear", "clouds", "rain", "storm", "hail", "snow", "fog", "haze", "wind", "aurora", "petals", "jacaranda", "motes", "leaves"}
|
||||
demoIntensities = []string{"light", "medium", "heavy"}
|
||||
demoPhases = []string{"day", "dawn", "dusk", "night"}
|
||||
)
|
||||
@@ -426,10 +436,12 @@ func seasonForVariant(v string) string {
|
||||
return "winter"
|
||||
case "petals", "jacaranda":
|
||||
return "spring"
|
||||
case "motes":
|
||||
case "motes", "haze":
|
||||
return "summer"
|
||||
case "leaves":
|
||||
case "leaves", "wind":
|
||||
return "autumn"
|
||||
case "hail":
|
||||
return "winter"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ func TestReaderCardDataAndArticleAPI(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true)
|
||||
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true, config.AdventureConfig{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -31,6 +31,10 @@ type Channel struct {
|
||||
}
|
||||
|
||||
// channels in display order. Add a new entry here to add a section.
|
||||
//
|
||||
// This is the full catalogue, used to resolve a story's channel slug to its
|
||||
// display metadata. It is NOT the list of live sections: an entry can be gated
|
||||
// off (adventure), so route registration and the nav read s.channels instead.
|
||||
var channels = []Channel{
|
||||
{Slug: "gaming", Title: "Gaming", Theme: "gaming", Emoji: "🎮", Blurb: "Releases, platforms, and the people making the games."},
|
||||
{Slug: "tech", Title: "Tech", Theme: "tech", Emoji: "💻", Blurb: "Industry, products, and the wires that hold it all together."},
|
||||
@@ -42,6 +46,7 @@ var channels = []Channel{
|
||||
{Slug: "kids", Title: "Kids", Theme: "kids", Emoji: "🧒", Blurb: "World news written for younger readers — short, clear, and curious."},
|
||||
{Slug: "finance", Title: "Finance", Theme: "finance", Emoji: "💰", Blurb: "Markets, money, and the business of the world. Read-only — these stories don't post to Matrix."},
|
||||
{Slug: "lego", Title: "LEGO", Theme: "lego", Emoji: "🧱", Blurb: "Sets, releases, and the wider brick-building world."},
|
||||
{Slug: "adventure", Title: "Adventure", Theme: "adventure", Emoji: "⚔️", Blurb: "Dispatches from the realm — deaths, first-clears, arrivals, and rivalries. Reporting from the field, this is Pete."},
|
||||
}
|
||||
|
||||
// SourceInfo is the trimmed view of a configured feed for the settings panel.
|
||||
@@ -58,8 +63,12 @@ type Server struct {
|
||||
srv *http.Server
|
||||
tpls map[string]*template.Template // keyed by page name (e.g. "index", "channel")
|
||||
auth *Authenticator // nil when sign-in is disabled or unavailable
|
||||
tts *ttsService // nil when server-side read-aloud is disabled
|
||||
adminSubs map[string]bool // OIDC subjects allowed to view /status
|
||||
pushHTTP *http.Client // SSRF-guarded client for Web Push delivery; built lazily by pushClient()
|
||||
adv config.AdventureConfig // gogobee adventure-news seam
|
||||
advPost PriorityPoster // posts priority adventure beats to Matrix; nil = web-only
|
||||
channels []Channel // live sections: the catalogue minus anything gated off (adventure)
|
||||
|
||||
// Daily-rotated salt for the privacy-preserving unique-visitor estimate.
|
||||
// Guarded by metricsMu; never persisted (see metrics.go).
|
||||
@@ -71,8 +80,8 @@ type Server struct {
|
||||
// New builds the server. Templates are parsed once at startup. Each page
|
||||
// gets its own template set sharing layout.html + _card.html, which avoids
|
||||
// `main` define collisions between pages.
|
||||
func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled bool) (*Server, error) {
|
||||
pages := []string{"index", "channel", "weather", "bookmarks", "for-you", "status"}
|
||||
func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled bool, adv config.AdventureConfig, advPost PriorityPoster) (*Server, error) {
|
||||
pages := []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story"}
|
||||
tpls := make(map[string]*template.Template, len(pages))
|
||||
for _, p := range pages {
|
||||
t, err := template.New("").Funcs(funcs).ParseFS(templateFS,
|
||||
@@ -98,7 +107,18 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
||||
adminSubs[sub] = true
|
||||
}
|
||||
}
|
||||
s := &Server{cfg: cfg, sources: infos, postingEnabled: postingEnabled, tpls: tpls, adminSubs: adminSubs}
|
||||
// The adventure section only exists when it's enabled: with the seam off,
|
||||
// there's no nav tab, no /adventure page and no /adventure feed, rather than
|
||||
// an empty section nobody can fill.
|
||||
live := make([]Channel, 0, len(channels))
|
||||
for _, ch := range channels {
|
||||
if ch.Slug == "adventure" && !adv.Enabled {
|
||||
continue
|
||||
}
|
||||
live = append(live, ch)
|
||||
}
|
||||
|
||||
s := &Server{cfg: cfg, sources: infos, postingEnabled: postingEnabled, tpls: tpls, adminSubs: adminSubs, adv: adv, advPost: advPost, channels: live}
|
||||
|
||||
// Optional OIDC sign-in (Authentik). Discovery is a network call; if the
|
||||
// provider is unreachable at boot we log and serve anonymously rather than
|
||||
@@ -115,6 +135,18 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
||||
}
|
||||
}
|
||||
|
||||
// Optional server-side neural read-aloud (Piper). Signed-in only, so it is
|
||||
// only useful alongside auth; newTTS logs and disables itself on any misconfig.
|
||||
if s.auth != nil {
|
||||
tts, err := newTTS(cfg.TTS)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.tts = tts
|
||||
} else if cfg.TTS.Enabled {
|
||||
slog.Warn("web: TTS enabled but auth is off; read-aloud is signed-in only, so it stays disabled")
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
staticSub, err := fs.Sub(staticFS, "static")
|
||||
@@ -134,7 +166,7 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
||||
mux.HandleFunc("GET /api/related", s.handleRelated)
|
||||
mux.HandleFunc("GET /feed.xml", func(w http.ResponseWriter, r *http.Request) { s.handleFeedXML(w, r, "") })
|
||||
mux.HandleFunc("GET /feed.json", func(w http.ResponseWriter, r *http.Request) { s.handleFeedJSON(w, r, "") })
|
||||
for _, ch := range channels {
|
||||
for _, ch := range s.channels {
|
||||
ch := ch
|
||||
mux.HandleFunc("GET /"+ch.Slug, func(w http.ResponseWriter, r *http.Request) {
|
||||
s.handleChannel(w, r, ch)
|
||||
@@ -152,6 +184,19 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
|
||||
// gogobee adventure-news ingest. Bearer-authed (not OIDC), so it lives
|
||||
// outside the sign-in block. Self-gates on adv.Enabled (404 when off).
|
||||
mux.HandleFunc("POST /api/ingest/adventure", s.handleAdventureIngest)
|
||||
|
||||
// Per-dispatch permalink (the article_url every ingested story points at).
|
||||
// Public GET; self-gates on adv.Enabled. Distinct from GET /adventure (the
|
||||
// channel listing, registered in the channels loop above).
|
||||
mux.HandleFunc("GET /adventure/{guid}", s.handleAdventureStory)
|
||||
|
||||
// Themed per-event emblem (card + og:image). A distinct path depth from
|
||||
// /adventure/{guid}, so the two patterns never overlap.
|
||||
mux.HandleFunc("GET /adventure/art/{type}", s.handleAdventureArt)
|
||||
|
||||
if s.auth != nil {
|
||||
mux.HandleFunc("GET /auth/login", s.auth.handleLogin)
|
||||
mux.HandleFunc("GET /auth/callback", s.auth.handleCallback)
|
||||
@@ -167,6 +212,9 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
||||
mux.HandleFunc("POST /api/push/subscribe", s.handlePushSubscribe)
|
||||
mux.HandleFunc("POST /api/push/unsubscribe", s.handlePushUnsubscribe)
|
||||
}
|
||||
if s.tts != nil {
|
||||
mux.HandleFunc("POST /api/tts", s.handleTTS)
|
||||
}
|
||||
}
|
||||
|
||||
s.srv = &http.Server{
|
||||
|
||||
@@ -79,6 +79,7 @@ html[data-phase="night"] {
|
||||
.bg-theme-kids { background-color: #14b8a6; }
|
||||
.bg-theme-finance { background-color: #10b981; }
|
||||
.bg-theme-lego { background-color: #d01012; }
|
||||
.bg-theme-adventure { background-color: #6d4bd8; }
|
||||
|
||||
.text-theme-gaming { color: #2d8a5a; }
|
||||
.text-theme-tech { color: #2f7fb8; }
|
||||
@@ -90,6 +91,7 @@ html[data-phase="night"] {
|
||||
.text-theme-kids { color: #0f766e; }
|
||||
.text-theme-finance { color: #059669; }
|
||||
.text-theme-lego { color: #b00d0e; }
|
||||
.text-theme-adventure { color: #5836b8; }
|
||||
|
||||
.decoration-theme-gaming { text-decoration-color: #4caf7d; }
|
||||
.decoration-theme-tech { text-decoration-color: #5aa9e6; }
|
||||
@@ -101,6 +103,7 @@ html[data-phase="night"] {
|
||||
.decoration-theme-kids { text-decoration-color: #14b8a6; }
|
||||
.decoration-theme-finance { text-decoration-color: #10b981; }
|
||||
.decoration-theme-lego { text-decoration-color: #d01012; }
|
||||
.decoration-theme-adventure { text-decoration-color: #6d4bd8; }
|
||||
|
||||
.border-theme-gaming { border-color: #4caf7d; }
|
||||
.border-theme-tech { border-color: #5aa9e6; }
|
||||
@@ -112,6 +115,7 @@ html[data-phase="night"] {
|
||||
.border-theme-kids { border-color: #14b8a6; }
|
||||
.border-theme-finance { border-color: #10b981; }
|
||||
.border-theme-lego { border-color: #d01012; }
|
||||
.border-theme-adventure { border-color: #6d4bd8; }
|
||||
|
||||
/* "Posted to Matrix" glow — soft pulsing aura in the channel's theme color. */
|
||||
.glow-theme-gaming { box-shadow: 0 0 0 2px rgba(76,175,125,0.35), 0 0 24px 4px rgba(76,175,125,0.45); animation: pete-glow-pulse 2.4s ease-in-out infinite; }
|
||||
@@ -124,6 +128,7 @@ html[data-phase="night"] {
|
||||
.glow-theme-kids { box-shadow: 0 0 0 2px rgba(20,184,166,0.35), 0 0 24px 4px rgba(20,184,166,0.45); animation: pete-glow-pulse 2.4s ease-in-out infinite; }
|
||||
.glow-theme-finance { box-shadow: 0 0 0 2px rgba(16,185,129,0.35), 0 0 24px 4px rgba(16,185,129,0.45); animation: pete-glow-pulse 2.4s ease-in-out infinite; }
|
||||
.glow-theme-lego { box-shadow: 0 0 0 2px rgba(208,16,18,0.35), 0 0 24px 4px rgba(208,16,18,0.45); animation: pete-glow-pulse 2.4s ease-in-out infinite; }
|
||||
.glow-theme-adventure { box-shadow: 0 0 0 2px rgba(109,75,216,0.35), 0 0 24px 4px rgba(109,75,216,0.45); animation: pete-glow-pulse 2.4s ease-in-out infinite; }
|
||||
|
||||
@keyframes pete-glow-pulse {
|
||||
0%, 100% { filter: brightness(1); }
|
||||
@@ -242,6 +247,7 @@ html[data-phase="night"] {
|
||||
|
||||
/* Text-options popover, anchored under the Aa button in the reader bar. */
|
||||
.pete-reader-type { position: relative; display: inline-flex; }
|
||||
.pete-reader-typemenu[hidden] { display: none; }
|
||||
.pete-reader-typemenu {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.5rem);
|
||||
@@ -282,6 +288,23 @@ html[data-phase="night"] {
|
||||
color: #1c1305;
|
||||
border-color: transparent;
|
||||
}
|
||||
.pete-reader-voicerow[hidden] { display: none; }
|
||||
.pete-reader-voice {
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
padding: 0.25rem 0.4rem;
|
||||
border-radius: 0.55rem;
|
||||
border: 1px solid rgba(20, 14, 6, 0.18);
|
||||
background: rgba(20, 14, 6, 0.06);
|
||||
color: inherit;
|
||||
max-width: 11rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
html[data-phase="night"] .pete-reader-voice {
|
||||
border-color: rgba(255, 255, 255, 0.14);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.pete-reader-scroll {
|
||||
flex: 1 1 auto;
|
||||
@@ -289,6 +312,7 @@ html[data-phase="night"] {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
border-radius: 1.75rem;
|
||||
}
|
||||
.pete-reader-scroll:focus { outline: none; }
|
||||
|
||||
.pete-reader-article {
|
||||
background: var(--card);
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -25,6 +25,8 @@
|
||||
var shareBtn = overlay.querySelector("[data-reader-share]");
|
||||
var typeBtn = overlay.querySelector("[data-reader-type]");
|
||||
var typeMenu = overlay.querySelector("[data-reader-typemenu]");
|
||||
var voiceRow = overlay.querySelector("[data-reader-voicerow]");
|
||||
var voiceSel = overlay.querySelector("[data-reader-voice]");
|
||||
var relatedEl = overlay.querySelector("[data-reader-related]");
|
||||
var relatedCache = {}; // id -> results array
|
||||
|
||||
@@ -34,10 +36,12 @@
|
||||
var SIGNED_IN = !!(window.PETE_USER);
|
||||
var bookmarkSet = Object.create(null); // id -> 1 for bookmarked stories
|
||||
|
||||
// Read-aloud is a signed-in-only perk and needs Web Speech support. Share is
|
||||
// available to everyone (native sheet where present, clipboard copy otherwise).
|
||||
var TTS_OK = SIGNED_IN && typeof window.speechSynthesis !== "undefined" &&
|
||||
typeof window.SpeechSynthesisUtterance !== "undefined";
|
||||
// Read-aloud is a signed-in-only perk, backed by server-side Piper voices
|
||||
// (window.PETE_TTS) rather than the browser's robotic Web Speech voice. Share
|
||||
// is available to everyone (native sheet where present, clipboard otherwise).
|
||||
var TTS_CFG = (window.PETE_TTS && window.PETE_TTS.enabled &&
|
||||
window.PETE_TTS.voices && window.PETE_TTS.voices.length) ? window.PETE_TTS : null;
|
||||
var TTS_OK = SIGNED_IN && !!TTS_CFG;
|
||||
|
||||
var items = []; // {id, url, headline, lede, image, time, source, chTitle, chEmoji, chTheme, posted, paywalled}
|
||||
var index = 0;
|
||||
@@ -484,12 +488,34 @@
|
||||
window.open(url, "_blank", "noopener");
|
||||
}
|
||||
|
||||
// ---- read aloud (signed-in only) ------------------------------------------
|
||||
// ---- read aloud (signed-in only, server-side Piper) -----------------------
|
||||
// Each paragraph is synthesized on demand by POSTing its text to /api/tts and
|
||||
// playing the returned WAV. We fetch the next paragraph while the current one
|
||||
// plays, so the model-load + synthesis latency is hidden behind playback.
|
||||
// speakGen is bumped on every stop/start/voice-change; any async callback that
|
||||
// fires afterwards checks it against its captured gen and no-ops if stale.
|
||||
var VOICE_KEY = "pete.reader.voice.v1";
|
||||
var speaking = false;
|
||||
var speakGen = 0; // bumped on every stop/start; stale utterance callbacks that
|
||||
// fire after speechSynthesis.cancel() check it and no-op
|
||||
var speakParas = []; // <p> elements being read, for highlight
|
||||
var speakQueue = []; // remaining {el, text} to utter
|
||||
var speakGen = 0;
|
||||
var speakParas = []; // <p> elements currently highlighted
|
||||
var speakItems = []; // [{el, text}] for the whole article
|
||||
var speakIdx = 0; // index into speakItems currently playing
|
||||
var speakReq = null; // in-flight fetch for the current paragraph (has .ctrl)
|
||||
var prefetch = null; // { idx, gen, promise, abort } for the next paragraph
|
||||
var audioEl = null; // shared <audio> element
|
||||
var currentURL = null; // object URL currently loaded into audioEl
|
||||
|
||||
// Device-local voice choice, like the type prefs (see loadType).
|
||||
var ttsVoice = "";
|
||||
(function loadVoice() {
|
||||
if (!TTS_CFG) return;
|
||||
var saved = "";
|
||||
try { saved = localStorage.getItem(VOICE_KEY) || ""; } catch (e) {}
|
||||
var ok = TTS_CFG.voices.some(function (v) { return v.id === saved; });
|
||||
ttsVoice = ok ? saved : TTS_CFG.default;
|
||||
})();
|
||||
function saveVoice() { try { localStorage.setItem(VOICE_KEY, ttsVoice); } catch (e) {} }
|
||||
|
||||
function speakingText() {
|
||||
if (!articleEl) return [];
|
||||
var out = [];
|
||||
@@ -505,30 +531,95 @@
|
||||
speakParas.forEach(function (p) { p.classList.remove("is-speaking"); });
|
||||
speakParas = [];
|
||||
}
|
||||
function stopSpeak() {
|
||||
if (!TTS_OK) return;
|
||||
speakGen++;
|
||||
try { window.speechSynthesis.cancel(); } catch (e) {}
|
||||
speaking = false;
|
||||
speakQueue = [];
|
||||
function highlight(item) {
|
||||
clearSpeakHighlight();
|
||||
if (listenBtn) { listenBtn.setAttribute("aria-pressed", "false"); listenBtn.textContent = "🔊 Listen"; }
|
||||
}
|
||||
function speakNext() {
|
||||
if (!speaking) return;
|
||||
var item = speakQueue.shift();
|
||||
if (!item) { stopSpeak(); return; }
|
||||
clearSpeakHighlight();
|
||||
if (item.el) {
|
||||
if (item && item.el) {
|
||||
item.el.classList.add("is-speaking");
|
||||
speakParas.push(item.el);
|
||||
try { item.el.scrollIntoView({ block: "center", behavior: "smooth" }); } catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
// ttsFetch kicks off synthesis of one paragraph. Returns { ctrl, promise },
|
||||
// where promise resolves to an object URL for the audio blob.
|
||||
function ttsFetch(text) {
|
||||
var ctrl = new AbortController();
|
||||
var promise = fetch("/api/tts", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ voice: ttsVoice, text: text }),
|
||||
signal: ctrl.signal,
|
||||
}).then(function (r) {
|
||||
if (!r.ok) throw new Error("tts " + r.status);
|
||||
return r.blob();
|
||||
}).then(function (b) { return URL.createObjectURL(b); });
|
||||
return { ctrl: ctrl, promise: promise };
|
||||
}
|
||||
function abortPrefetch() {
|
||||
if (!prefetch) return;
|
||||
try { prefetch.ctrl.abort(); } catch (e) {}
|
||||
// If it already resolved to a URL, reclaim it.
|
||||
prefetch.promise.then(function (u) { URL.revokeObjectURL(u); }, function () {});
|
||||
prefetch = null;
|
||||
}
|
||||
function startPrefetch(i, gen) {
|
||||
var item = speakItems[i];
|
||||
if (!item) { prefetch = null; return; }
|
||||
var req = ttsFetch(item.text);
|
||||
prefetch = { idx: i, gen: gen, ctrl: req.ctrl, promise: req.promise };
|
||||
}
|
||||
function playURL(url, gen) {
|
||||
if (!audioEl) audioEl = new Audio();
|
||||
if (currentURL) URL.revokeObjectURL(currentURL);
|
||||
currentURL = url;
|
||||
audioEl.src = url;
|
||||
audioEl.onended = function () { if (speaking && gen === speakGen) playIdx(speakIdx + 1); };
|
||||
audioEl.onerror = function () { if (speaking && gen === speakGen) playIdx(speakIdx + 1); };
|
||||
var pr = audioEl.play();
|
||||
if (pr && pr.catch) pr.catch(function () {}); // ignore autoplay/abort rejections
|
||||
}
|
||||
function playIdx(i) {
|
||||
if (!speaking) return;
|
||||
var gen = speakGen;
|
||||
var u = new SpeechSynthesisUtterance(item.text);
|
||||
u.onend = function () { if (speaking && gen === speakGen) speakNext(); };
|
||||
u.onerror = function () { if (speaking && gen === speakGen) speakNext(); };
|
||||
try { window.speechSynthesis.speak(u); } catch (e) { stopSpeak(); }
|
||||
var item = speakItems[i];
|
||||
if (!item) { stopSpeak(); return; }
|
||||
speakIdx = i;
|
||||
highlight(item);
|
||||
var urlP;
|
||||
if (prefetch && prefetch.idx === i && prefetch.gen === gen) {
|
||||
urlP = prefetch.promise;
|
||||
prefetch = null; // hand off; do not abort/revoke this one
|
||||
} else {
|
||||
abortPrefetch();
|
||||
var req = ttsFetch(item.text);
|
||||
speakReq = req;
|
||||
urlP = req.promise;
|
||||
}
|
||||
urlP.then(function (url) {
|
||||
if (!speaking || gen !== speakGen) { URL.revokeObjectURL(url); return; }
|
||||
speakReq = null;
|
||||
startPrefetch(i + 1, gen); // synthesize the next paragraph during playback
|
||||
playURL(url, gen);
|
||||
}).catch(function () {
|
||||
if (!speaking || gen !== speakGen) return;
|
||||
speakReq = null;
|
||||
playIdx(i + 1); // skip a paragraph that failed rather than stalling
|
||||
});
|
||||
}
|
||||
function teardownAudio() {
|
||||
if (speakReq) { try { speakReq.ctrl.abort(); } catch (e) {} speakReq = null; }
|
||||
abortPrefetch();
|
||||
if (audioEl) { try { audioEl.pause(); } catch (e) {} audioEl.onended = null; audioEl.onerror = null; audioEl.removeAttribute("src"); }
|
||||
if (currentURL) { URL.revokeObjectURL(currentURL); currentURL = null; }
|
||||
}
|
||||
function stopSpeak() {
|
||||
if (!TTS_OK) return;
|
||||
speakGen++;
|
||||
speaking = false;
|
||||
teardownAudio();
|
||||
speakItems = [];
|
||||
clearSpeakHighlight();
|
||||
if (listenBtn) { listenBtn.setAttribute("aria-pressed", "false"); listenBtn.textContent = "🔊 Listen"; }
|
||||
}
|
||||
function startSpeak() {
|
||||
if (!TTS_OK) return;
|
||||
@@ -536,16 +627,43 @@
|
||||
var parts = speakingText();
|
||||
if (!parts.length) { toast("Nothing to read yet"); return; }
|
||||
speakGen++;
|
||||
try { window.speechSynthesis.cancel(); } catch (e) {}
|
||||
teardownAudio();
|
||||
speaking = true;
|
||||
speakQueue = parts.slice();
|
||||
speakItems = parts;
|
||||
speakIdx = 0;
|
||||
if (listenBtn) { listenBtn.setAttribute("aria-pressed", "true"); listenBtn.textContent = "⏹ Stop"; }
|
||||
speakNext();
|
||||
playIdx(0);
|
||||
}
|
||||
function toggleSpeak() {
|
||||
if (!TTS_OK) return;
|
||||
if (speaking) stopSpeak(); else startSpeak();
|
||||
}
|
||||
// Switching voice restarts the current paragraph with the new voice so the
|
||||
// change is audible immediately, keeping our place in the article.
|
||||
function changeVoice(id) {
|
||||
if (!TTS_CFG) return;
|
||||
ttsVoice = id;
|
||||
saveVoice();
|
||||
if (voiceSel) voiceSel.value = ttsVoice;
|
||||
if (speaking) {
|
||||
speakGen++;
|
||||
teardownAudio();
|
||||
playIdx(speakIdx);
|
||||
}
|
||||
}
|
||||
function buildVoiceMenu() {
|
||||
if (!voiceSel || !TTS_CFG) return;
|
||||
voiceSel.innerHTML = "";
|
||||
TTS_CFG.voices.forEach(function (v) {
|
||||
var o = document.createElement("option");
|
||||
o.value = v.id;
|
||||
o.textContent = v.label;
|
||||
voiceSel.appendChild(o);
|
||||
});
|
||||
voiceSel.value = ttsVoice;
|
||||
voiceSel.addEventListener("change", function () { changeVoice(voiceSel.value); });
|
||||
if (voiceRow) voiceRow.hidden = false;
|
||||
}
|
||||
|
||||
// ---- open / close ---------------------------------------------------------
|
||||
function openReader() {
|
||||
@@ -556,6 +674,9 @@
|
||||
document.body.classList.add("overflow-hidden");
|
||||
applyType();
|
||||
show(firstUnread());
|
||||
// Move focus into the scroll region so Space / PageUp-Down / arrow keys
|
||||
// scroll the story, not the page behind it, without a click first.
|
||||
if (scrollEl) { try { scrollEl.focus({ preventScroll: true }); } catch (e) { scrollEl.focus(); } }
|
||||
}
|
||||
function closeReader() {
|
||||
open = false;
|
||||
@@ -587,9 +708,9 @@
|
||||
if (it) setBookmark(it.id, !isBookmarked(it.id));
|
||||
});
|
||||
|
||||
// Read-aloud: signed-in only, and only where Web Speech exists.
|
||||
// Read-aloud: signed-in only, and only when server-side TTS is configured.
|
||||
if (listenBtn) {
|
||||
if (TTS_OK) { listenBtn.style.display = ""; listenBtn.addEventListener("click", toggleSpeak); }
|
||||
if (TTS_OK) { listenBtn.style.display = ""; listenBtn.addEventListener("click", toggleSpeak); buildVoiceMenu(); }
|
||||
else listenBtn.style.display = "none";
|
||||
}
|
||||
// Share: available to everyone.
|
||||
|
||||
487
internal/web/static/js/weather-2d.js
Normal file
487
internal/web/static/js/weather-2d.js
Normal file
@@ -0,0 +1,487 @@
|
||||
// Canvas2D weather engine — the fallback when WebGL2 isn't available. This is
|
||||
// the original hand-drawn renderer wrapped as an engine factory; weather.js
|
||||
// owns the toggle, storage and the PeteWeather API and just calls
|
||||
// set/start/stop here. New GPU-only variants (hail, haze, wind, aurora) alias
|
||||
// to their nearest 2D look so the fallback never renders a blank page.
|
||||
(function () {
|
||||
window.PeteWeatherEngines = window.PeteWeatherEngines || {};
|
||||
|
||||
window.PeteWeatherEngines.canvas2d = function (canvas) {
|
||||
var ctx = canvas.getContext("2d");
|
||||
if (!ctx) return null;
|
||||
var root = document.documentElement;
|
||||
|
||||
var W = 0, H = 0, DPR = 1;
|
||||
function resize() {
|
||||
DPR = Math.min(window.devicePixelRatio || 1, 2);
|
||||
W = canvas.clientWidth || window.innerWidth;
|
||||
H = canvas.clientHeight || window.innerHeight;
|
||||
canvas.width = Math.floor(W * DPR);
|
||||
canvas.height = Math.floor(H * DPR);
|
||||
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
|
||||
}
|
||||
resize();
|
||||
window.addEventListener("resize", resize);
|
||||
|
||||
var aliases = { hail: "snow", haze: "motes", wind: "leaves", aurora: "clear" };
|
||||
|
||||
var counts = {
|
||||
rain: 160, petals: 50, jacaranda: 55, motes: 70, leaves: 38,
|
||||
snow: 150, clouds: 7, fog: 7, clear: 0, storm: 200
|
||||
};
|
||||
var mults = { light: 0.6, medium: 1.0, heavy: 1.4 };
|
||||
|
||||
function rand(a, b) { return a + Math.random() * (b - a); }
|
||||
|
||||
var variant = null; // current rendered variant
|
||||
var intensity = "heavy"; // light | medium | heavy
|
||||
var particles = [];
|
||||
var flash = 0; // lightning flash decay (storm only)
|
||||
var nextBolt = 2; // seconds until next lightning bolt (storm only)
|
||||
|
||||
function spawn(initial) {
|
||||
var p = {};
|
||||
p.x = rand(0, W);
|
||||
p.y = initial ? rand(0, H) : rand(-40, -10);
|
||||
p.z = rand(0.7, 1.3); // depth — affects size + speed + alpha
|
||||
if (variant === "rain" || variant === "storm") {
|
||||
p.vy = rand(700, 1100) * p.z;
|
||||
p.vx = -rand(60, 120);
|
||||
p.len = rand(10, 18) * p.z;
|
||||
p.alpha = rand(0.25, 0.55);
|
||||
} else if (variant === "snow") {
|
||||
p.vy = rand(35, 75) * p.z;
|
||||
p.swayAmp = rand(12, 34);
|
||||
p.swayFreq = rand(0.3, 0.8);
|
||||
p.swayPhase = rand(0, Math.PI * 2);
|
||||
p.size = rand(2, 4.6) * p.z;
|
||||
p.alpha = rand(0.65, 0.95);
|
||||
} else if (variant === "clouds") {
|
||||
// Soft puffs drifting across the upper sky.
|
||||
p.y = initial ? rand(0, H * 0.5) : rand(-H * 0.1, H * 0.45);
|
||||
p.x = initial ? rand(0, W) : -rand(120, 320);
|
||||
p.vx = rand(8, 22);
|
||||
p.size = rand(80, 170) * p.z;
|
||||
p.alpha = rand(0.32, 0.55);
|
||||
p.sprite = Math.floor(rand(0, 3));
|
||||
} else if (variant === "fog") {
|
||||
// Wide translucent bands creeping sideways.
|
||||
p.y = rand(H * 0.1, H);
|
||||
p.x = initial ? rand(0, W) : -rand(200, 500);
|
||||
p.vx = rand(6, 16);
|
||||
p.w = rand(260, 520);
|
||||
p.h = rand(70, 150);
|
||||
p.alpha = rand(0.05, 0.14);
|
||||
} else if (variant === "petals" || variant === "jacaranda") {
|
||||
p.vy = rand(60, 120);
|
||||
p.swayAmp = rand(20, 50);
|
||||
p.swayFreq = rand(0.4, 0.9);
|
||||
p.swayPhase = rand(0, Math.PI * 2);
|
||||
p.rot = rand(0, Math.PI * 2);
|
||||
p.vrot = rand(-1.2, 1.2);
|
||||
p.size = rand(8, 16) * p.z;
|
||||
p.alpha = rand(0.75, 1.0);
|
||||
if (variant === "jacaranda") {
|
||||
p.hue = rand(265, 285); // blue-violet — jacaranda uses the petal silhouette in purple
|
||||
p.sat = rand(55, 75);
|
||||
p.light = rand(70, 82);
|
||||
} else {
|
||||
p.hue = rand(335, 355); // pink (almond/cherry)
|
||||
p.sat = rand(60, 80);
|
||||
p.light = rand(78, 88);
|
||||
}
|
||||
} else if (variant === "motes") {
|
||||
p.vx = rand(-15, 25);
|
||||
p.vy = rand(8, 25);
|
||||
p.size = rand(1.2, 2.8) * p.z;
|
||||
p.alpha = rand(0.35, 0.7);
|
||||
p.twinklePhase = rand(0, Math.PI * 2);
|
||||
p.twinkleFreq = rand(0.5, 1.5);
|
||||
} else if (variant === "leaves") {
|
||||
p.vy = rand(70, 130);
|
||||
p.swayAmp = rand(30, 70);
|
||||
p.swayFreq = rand(0.3, 0.6);
|
||||
p.swayPhase = rand(0, Math.PI * 2);
|
||||
p.rot = rand(0, Math.PI * 2);
|
||||
p.vrot = rand(-2.0, 2.0);
|
||||
p.size = rand(10, 18) * p.z;
|
||||
p.alpha = rand(0.8, 1.0);
|
||||
p.hue = rand(18, 42); // orange→amber→brown
|
||||
p.light = rand(38, 55);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
var night = root.dataset.phase === "night";
|
||||
function refreshNight() { night = root.dataset.phase === "night"; }
|
||||
|
||||
function drawRain(p) {
|
||||
// Day/dawn/dusk backgrounds are warm and pale, so the rain needs a darker,
|
||||
// more saturated blue plus a slightly thicker line to stay visible. Night
|
||||
// keeps a paler tone so streaks read against the dark palette.
|
||||
if (night) {
|
||||
ctx.strokeStyle = "rgba(200,215,240," + p.alpha + ")";
|
||||
ctx.lineWidth = 1;
|
||||
} else {
|
||||
ctx.strokeStyle = "rgba(60,95,150," + Math.min(1, p.alpha + 0.25) + ")";
|
||||
ctx.lineWidth = 1.3;
|
||||
}
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(p.x, p.y);
|
||||
ctx.lineTo(p.x - p.len * 0.12, p.y + p.len);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function drawSnow(p) {
|
||||
// Soft round flake with a faint glow so it reads on light and dark palettes.
|
||||
var col = night ? "235,242,255" : "255,255,255";
|
||||
ctx.fillStyle = "rgba(" + col + "," + p.alpha + ")";
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
if (!night) {
|
||||
// A thin cool outline keeps white flakes visible against cream backgrounds.
|
||||
ctx.strokeStyle = "rgba(150,180,220," + (p.alpha * 0.5) + ")";
|
||||
ctx.lineWidth = 0.6;
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-baked cloud sprites: clustered blobs softened with a heavy blur so each
|
||||
// cloud reads as one fluffy mass. Built once per palette (day/night) onto
|
||||
// offscreen canvases, then just drifted — cheap to animate.
|
||||
var cloudSprites = null;
|
||||
var cloudSpritesNight = null;
|
||||
|
||||
// Three silhouettes for variety. Each is a list of blobs [cx, cy, r] in a
|
||||
// 0..1 box (x across, y down) with a flattish base around y≈0.66.
|
||||
var cloudShapes = [
|
||||
[[0.50, 0.50, 0.26], [0.34, 0.56, 0.20], [0.66, 0.56, 0.20], [0.42, 0.40, 0.18],
|
||||
[0.58, 0.38, 0.17], [0.22, 0.60, 0.15], [0.78, 0.60, 0.15], [0.50, 0.62, 0.22]],
|
||||
[[0.46, 0.52, 0.24], [0.30, 0.58, 0.18], [0.62, 0.50, 0.22], [0.74, 0.58, 0.16],
|
||||
[0.40, 0.40, 0.16], [0.56, 0.36, 0.15], [0.18, 0.62, 0.13], [0.84, 0.62, 0.12]],
|
||||
[[0.52, 0.50, 0.28], [0.36, 0.56, 0.19], [0.68, 0.56, 0.18], [0.48, 0.36, 0.16],
|
||||
[0.26, 0.60, 0.14], [0.74, 0.60, 0.15], [0.60, 0.62, 0.18], [0.40, 0.62, 0.18]]
|
||||
];
|
||||
|
||||
function makeCloudSprite(col, shape) {
|
||||
var c = document.createElement("canvas");
|
||||
var w = 320, h = 224;
|
||||
c.width = w; c.height = h;
|
||||
var cx = c.getContext("2d");
|
||||
cx.filter = "blur(18px)"; // the softness that turns blobs into a cloud
|
||||
cx.fillStyle = "rgba(" + col + ",1)";
|
||||
for (var i = 0; i < shape.length; i++) {
|
||||
cx.beginPath();
|
||||
cx.arc(shape[i][0] * w, shape[i][1] * h, shape[i][2] * w, 0, Math.PI * 2);
|
||||
cx.fill();
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
function ensureCloudSprites() {
|
||||
if (cloudSprites && cloudSpritesNight === night) return;
|
||||
// Day sky is warm cream so clouds need a cool slate-grey; night uses a pale
|
||||
// tone against the dark palette.
|
||||
var col = night ? "206,216,236" : "150,164,190";
|
||||
cloudSprites = cloudShapes.map(function (s) { return makeCloudSprite(col, s); });
|
||||
cloudSpritesNight = night;
|
||||
}
|
||||
|
||||
function drawCloud(p) {
|
||||
ensureCloudSprites();
|
||||
var spr = cloudSprites[p.sprite % cloudSprites.length];
|
||||
var w = p.size * 2.6;
|
||||
var h = w * (spr.height / spr.width);
|
||||
ctx.globalAlpha = p.alpha;
|
||||
ctx.drawImage(spr, p.x - w / 2, p.y - h / 2, w, h);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function drawFog(p) {
|
||||
var col = night ? "150,160,180" : "230,228,224";
|
||||
var g = ctx.createLinearGradient(p.x - p.w / 2, 0, p.x + p.w / 2, 0);
|
||||
g.addColorStop(0, "rgba(" + col + ",0)");
|
||||
g.addColorStop(0.5, "rgba(" + col + "," + p.alpha + ")");
|
||||
g.addColorStop(1, "rgba(" + col + ",0)");
|
||||
ctx.fillStyle = g;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(p.x, p.y, p.w / 2, p.h / 2, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
function drawMoon(cx, cy, mr) {
|
||||
var TAU = Math.PI * 2;
|
||||
ctx.save();
|
||||
// Clip everything to the lunar disc so shading stays inside the sphere.
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, mr, 0, TAU);
|
||||
ctx.clip();
|
||||
|
||||
// Spherical body shading — light source upper-right, terminator lower-left.
|
||||
var lx = cx + mr * 0.45, ly = cy - mr * 0.45;
|
||||
var body = ctx.createRadialGradient(lx, ly, mr * 0.1, cx, cy, mr * 1.35);
|
||||
body.addColorStop(0, "rgba(249,251,255,0.97)");
|
||||
body.addColorStop(0.55, "rgba(214,222,240,0.92)");
|
||||
body.addColorStop(1, "rgba(140,151,180,0.85)");
|
||||
ctx.fillStyle = body;
|
||||
ctx.fillRect(cx - mr, cy - mr, mr * 2, mr * 2);
|
||||
|
||||
// Limb darkening — fade the edge so the disc reads as a ball, not a coin.
|
||||
var limb = ctx.createRadialGradient(cx, cy, mr * 0.62, cx, cy, mr);
|
||||
limb.addColorStop(0, "rgba(18,24,46,0)");
|
||||
limb.addColorStop(1, "rgba(18,24,46,0.4)");
|
||||
ctx.fillStyle = limb;
|
||||
ctx.fillRect(cx - mr, cy - mr, mr * 2, mr * 2);
|
||||
ctx.restore();
|
||||
|
||||
// Soft outer halo, drawn unclipped around the disc.
|
||||
var halo = ctx.createRadialGradient(cx, cy, mr, cx, cy, mr * 2.4);
|
||||
halo.addColorStop(0, "rgba(220,230,255,0.28)");
|
||||
halo.addColorStop(1, "rgba(220,230,255,0)");
|
||||
ctx.fillStyle = halo;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, mr * 2.4, 0, TAU);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
function drawClearGlow(t) {
|
||||
// No particles — render a single calm sun (day) or moon (night) glow that
|
||||
// breathes very slowly, plus a few stars at night.
|
||||
var breathe = 0.5 + 0.5 * Math.sin(t * 0.25);
|
||||
var cx = W * 0.82, cy = H * 0.18, r = Math.min(W, H) * 0.5;
|
||||
if (night) {
|
||||
// Ambient sky glow around the moon.
|
||||
var g = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
|
||||
g.addColorStop(0, "rgba(220,230,255," + (0.12 + 0.05 * breathe) + ")");
|
||||
g.addColorStop(1, "rgba(220,230,255,0)");
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
drawMoon(cx, cy, r * 0.16);
|
||||
} else {
|
||||
var gd = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
|
||||
gd.addColorStop(0, "rgba(255,224,150," + (0.30 + 0.10 * breathe) + ")");
|
||||
gd.addColorStop(0.5, "rgba(255,214,120," + (0.10 + 0.04 * breathe) + ")");
|
||||
gd.addColorStop(1, "rgba(255,214,120,0)");
|
||||
ctx.fillStyle = gd;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
}
|
||||
}
|
||||
|
||||
// Deterministic star field for clear nights (seeded so they don't jitter).
|
||||
var stars = [];
|
||||
function buildStars() {
|
||||
stars = [];
|
||||
var n = 60;
|
||||
for (var i = 0; i < n; i++) {
|
||||
// Cheap LCG-ish spread keyed by index — stable across frames.
|
||||
var sx = ((i * 73 + 11) % 100) / 100;
|
||||
var sy = ((i * 37 + 7) % 100) / 100;
|
||||
stars.push({ x: sx, y: sy * 0.7, r: 0.6 + (i % 3) * 0.5, ph: (i % 7) });
|
||||
}
|
||||
}
|
||||
function drawStars(t) {
|
||||
for (var i = 0; i < stars.length; i++) {
|
||||
var s = stars[i];
|
||||
var tw = 0.4 + 0.6 * (0.5 + 0.5 * Math.sin(t * 0.8 + s.ph));
|
||||
ctx.fillStyle = "rgba(255,255,255," + (0.5 * tw) + ")";
|
||||
ctx.beginPath();
|
||||
ctx.arc(s.x * W, s.y * H, s.r, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
function drawPetals(p) {
|
||||
// Five-petal almond/cherry blossom viewed face-on, with a yellow center.
|
||||
var s = p.size;
|
||||
ctx.save();
|
||||
ctx.translate(p.x, p.y);
|
||||
ctx.rotate(p.rot);
|
||||
ctx.fillStyle = "hsla(" + p.hue + "," + p.sat + "%," + p.light + "%," + p.alpha + ")";
|
||||
for (var i = 0; i < 5; i++) {
|
||||
var a = (i / 5) * Math.PI * 2 - Math.PI / 2;
|
||||
var cx = Math.cos(a) * s * 0.32;
|
||||
var cy = Math.sin(a) * s * 0.32;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(cx, cy, s * 0.38, s * 0.24, a, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.fillStyle = "hsla(48,90%,68%," + p.alpha + ")";
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, s * 0.13, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawLeaf(p) {
|
||||
var s = p.size;
|
||||
ctx.save();
|
||||
ctx.translate(p.x, p.y);
|
||||
ctx.rotate(p.rot);
|
||||
var g = ctx.createLinearGradient(-s * 0.5, 0, s * 0.5, 0);
|
||||
g.addColorStop(0, "hsla(" + p.hue + ",70%," + (p.light - 10) + "%," + p.alpha + ")");
|
||||
g.addColorStop(1, "hsla(" + p.hue + ",75%," + (p.light + 10) + "%," + p.alpha + ")");
|
||||
ctx.fillStyle = g;
|
||||
// Almond/lozenge leaf — pointed at both ends.
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-s * 0.55, 0);
|
||||
ctx.quadraticCurveTo(0, -s * 0.38, s * 0.55, 0);
|
||||
ctx.quadraticCurveTo(0, s * 0.38, -s * 0.55, 0);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
// Midrib vein.
|
||||
ctx.strokeStyle = "hsla(" + p.hue + ",60%," + (p.light - 22) + "%," + (p.alpha * 0.7) + ")";
|
||||
ctx.lineWidth = 0.8;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-s * 0.5, 0);
|
||||
ctx.lineTo(s * 0.5, 0);
|
||||
ctx.stroke();
|
||||
// Small stem nub at the base.
|
||||
ctx.strokeStyle = "hsla(" + p.hue + ",55%," + (p.light - 25) + "%," + (p.alpha * 0.7) + ")";
|
||||
ctx.lineWidth = 1.0;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-s * 0.55, 0);
|
||||
ctx.lineTo(-s * 0.7, -s * 0.04);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawMote(p, t) {
|
||||
var twinkle = 0.5 + 0.5 * Math.sin(t * p.twinkleFreq + p.twinklePhase);
|
||||
// Night keeps a pale glowing mote against the dark palette. Day/dawn/dusk
|
||||
// use a deeper saturated honey so the motes stand out against warm cream
|
||||
// and peach backgrounds; alpha also gets a bump.
|
||||
var color, a;
|
||||
if (night) {
|
||||
color = "255,240,180";
|
||||
a = p.alpha * (0.45 + 0.55 * twinkle);
|
||||
} else {
|
||||
color = "130,80,180";
|
||||
a = Math.min(1, (p.alpha + 0.2) * (0.6 + 0.4 * twinkle));
|
||||
}
|
||||
// Soft glow halo.
|
||||
var g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size * 5);
|
||||
g.addColorStop(0, "rgba(" + color + "," + (a * 0.55) + ")");
|
||||
g.addColorStop(1, "rgba(" + color + ",0)");
|
||||
ctx.fillStyle = g;
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.size * 5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
// Bright core.
|
||||
ctx.fillStyle = "rgba(" + color + "," + a + ")";
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
var last = performance.now();
|
||||
var raf = 0;
|
||||
|
||||
function step(now) {
|
||||
var dt = Math.min(0.05, (now - last) / 1000);
|
||||
last = now;
|
||||
var t = now / 1000;
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
refreshNight();
|
||||
|
||||
if (variant === "clear") {
|
||||
drawClearGlow(t);
|
||||
if (night) drawStars(t);
|
||||
raf = requestAnimationFrame(step);
|
||||
return;
|
||||
}
|
||||
|
||||
// Storm: drive the lightning flash before drawing rain over it.
|
||||
if (variant === "storm") {
|
||||
nextBolt -= dt;
|
||||
if (nextBolt <= 0) {
|
||||
flash = 1;
|
||||
nextBolt = rand(2.5, 7);
|
||||
}
|
||||
if (flash > 0) {
|
||||
ctx.fillStyle = "rgba(235,240,255," + (flash * 0.5) + ")";
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
flash = Math.max(0, flash - dt * 4); // ~0.25s decay
|
||||
}
|
||||
}
|
||||
|
||||
for (var i = 0; i < particles.length; i++) {
|
||||
var p = particles[i];
|
||||
if (variant === "rain" || variant === "storm") {
|
||||
p.x += p.vx * dt;
|
||||
p.y += p.vy * dt;
|
||||
if (p.y > H + 20 || p.x < -40) {
|
||||
particles[i] = spawn(false);
|
||||
particles[i].x = rand(0, W + 60);
|
||||
continue;
|
||||
}
|
||||
drawRain(p);
|
||||
} else if (variant === "snow") {
|
||||
p.y += p.vy * dt;
|
||||
var sxOff = Math.sin(t * p.swayFreq + p.swayPhase) * p.swayAmp;
|
||||
if (p.y > H + 10) { particles[i] = spawn(false); continue; }
|
||||
var ox = p.x; p.x = ox + sxOff;
|
||||
drawSnow(p);
|
||||
p.x = ox;
|
||||
} else if (variant === "clouds") {
|
||||
p.x += p.vx * dt;
|
||||
if (p.x - p.size * 1.3 > W + 40) { particles[i] = spawn(false); continue; }
|
||||
drawCloud(p);
|
||||
} else if (variant === "fog") {
|
||||
p.x += p.vx * dt;
|
||||
if (p.x - p.w / 2 > W + 40) { particles[i] = spawn(false); continue; }
|
||||
drawFog(p);
|
||||
} else if (variant === "motes") {
|
||||
p.x += p.vx * dt;
|
||||
p.y += p.vy * dt;
|
||||
if (p.y > H + 10 || p.x < -10 || p.x > W + 10) {
|
||||
particles[i] = spawn(false);
|
||||
continue;
|
||||
}
|
||||
drawMote(p, t);
|
||||
} else {
|
||||
// Drifting variants: petals, jacaranda, leaves.
|
||||
p.y += p.vy * dt;
|
||||
p.rot += p.vrot * dt;
|
||||
var sway = Math.sin(t * p.swayFreq + p.swayPhase) * p.swayAmp;
|
||||
var origX = p.x;
|
||||
p.x = origX + sway;
|
||||
if (p.y > H + 30) {
|
||||
particles[i] = spawn(false);
|
||||
continue;
|
||||
}
|
||||
if (variant === "jacaranda" || variant === "petals") drawPetals(p);
|
||||
else drawLeaf(p);
|
||||
p.x = origX;
|
||||
}
|
||||
}
|
||||
raf = requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (raf) return;
|
||||
last = performance.now();
|
||||
raf = requestAnimationFrame(step);
|
||||
}
|
||||
function stop() {
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
raf = 0;
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
}
|
||||
|
||||
// Build the particle pool for a variant. The controller decides whether to
|
||||
// start rendering afterwards.
|
||||
function set(v, inten) {
|
||||
variant = aliases[v] || v || null;
|
||||
intensity = inten || "heavy";
|
||||
flash = 0; nextBolt = rand(1.5, 4);
|
||||
particles = [];
|
||||
if (variant === "clear") { buildStars(); }
|
||||
if (!variant) { stop(); return; }
|
||||
var N = Math.round((counts[variant] || 0) * (mults[intensity] || 1));
|
||||
for (var i = 0; i < N; i++) particles.push(spawn(true));
|
||||
}
|
||||
|
||||
return { name: "canvas2d", set: set, start: start, stop: stop };
|
||||
};
|
||||
})();
|
||||
1028
internal/web/static/js/weather-gl.js
Normal file
1028
internal/web/static/js/weather-gl.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
// Canvas-based weather rendered behind the page. Two drivers feed it:
|
||||
// Weather controller. Two drivers feed the background canvas:
|
||||
//
|
||||
// 1. The server picks a *seasonal* variant + intensity from the Lisbon-local
|
||||
// date and writes them to <html data-weather / data-intensity>. This is the
|
||||
@@ -7,15 +7,21 @@
|
||||
// forecast and calls PeteWeather.set(variant, intensity) to override the
|
||||
// background with live conditions.
|
||||
//
|
||||
// Each variant has a hand-drawn shape so silhouettes are recognizable instead
|
||||
// of a generic blob. Seasonal variants: rain, petals, jacaranda, motes, leaves.
|
||||
// Live-weather variants add: clear, clouds, snow, fog, storm.
|
||||
// Rendering is delegated to an engine: the WebGL2 one (weather-gl.js) when the
|
||||
// browser supports it — GPU sprites, shader fog/aurora, real lightning — with
|
||||
// the original Canvas2D renderer (weather-2d.js) as the fallback. This file
|
||||
// only owns the toggle button, the on/off preference and the public API.
|
||||
(function () {
|
||||
var root = document.documentElement;
|
||||
var canvas = document.getElementById("pete-weather");
|
||||
if (!canvas) return;
|
||||
var reducedMotion = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
var engines = window.PeteWeatherEngines || {};
|
||||
var engine = (engines.webgl2 && engines.webgl2(canvas)) || null;
|
||||
if (!engine && engines.canvas2d) engine = engines.canvas2d(canvas);
|
||||
if (!engine) return;
|
||||
|
||||
var STORAGE_KEY = "pete-weather-off";
|
||||
var toggleBtn = document.querySelector("[data-weather-toggle]");
|
||||
var star = document.querySelector("[data-weather-star]");
|
||||
@@ -41,504 +47,38 @@
|
||||
}
|
||||
}
|
||||
|
||||
var ctx = canvas.getContext("2d");
|
||||
var W = 0, H = 0, DPR = 1;
|
||||
function resize() {
|
||||
DPR = Math.min(window.devicePixelRatio || 1, 2);
|
||||
W = canvas.clientWidth || window.innerWidth;
|
||||
H = canvas.clientHeight || window.innerHeight;
|
||||
canvas.width = Math.floor(W * DPR);
|
||||
canvas.height = Math.floor(H * DPR);
|
||||
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
|
||||
}
|
||||
resize();
|
||||
window.addEventListener("resize", resize);
|
||||
|
||||
var counts = {
|
||||
rain: 160, petals: 50, jacaranda: 55, motes: 70, leaves: 38,
|
||||
snow: 150, clouds: 7, fog: 7, clear: 0, storm: 200
|
||||
};
|
||||
var mults = { light: 0.6, medium: 1.0, heavy: 1.4 };
|
||||
|
||||
function rand(a, b) { return a + Math.random() * (b - a); }
|
||||
|
||||
var variant = null; // current rendered variant
|
||||
var intensity = "heavy"; // light | medium | heavy
|
||||
var particles = [];
|
||||
var flash = 0; // lightning flash decay (storm only)
|
||||
var nextBolt = 2; // seconds until next lightning bolt (storm only)
|
||||
|
||||
function spawn(initial) {
|
||||
var p = {};
|
||||
p.x = rand(0, W);
|
||||
p.y = initial ? rand(0, H) : rand(-40, -10);
|
||||
p.z = rand(0.7, 1.3); // depth — affects size + speed + alpha
|
||||
if (variant === "rain" || variant === "storm") {
|
||||
p.vy = rand(700, 1100) * p.z;
|
||||
p.vx = -rand(60, 120);
|
||||
p.len = rand(10, 18) * p.z;
|
||||
p.alpha = rand(0.25, 0.55);
|
||||
} else if (variant === "snow") {
|
||||
p.vy = rand(35, 75) * p.z;
|
||||
p.swayAmp = rand(12, 34);
|
||||
p.swayFreq = rand(0.3, 0.8);
|
||||
p.swayPhase = rand(0, Math.PI * 2);
|
||||
p.size = rand(2, 4.6) * p.z;
|
||||
p.alpha = rand(0.65, 0.95);
|
||||
} else if (variant === "clouds") {
|
||||
// Soft puffs drifting across the upper sky.
|
||||
p.y = initial ? rand(0, H * 0.5) : rand(-H * 0.1, H * 0.45);
|
||||
p.x = initial ? rand(0, W) : -rand(120, 320);
|
||||
p.vx = rand(8, 22);
|
||||
p.size = rand(80, 170) * p.z;
|
||||
p.alpha = rand(0.32, 0.55);
|
||||
p.sprite = Math.floor(rand(0, 3));
|
||||
} else if (variant === "fog") {
|
||||
// Wide translucent bands creeping sideways.
|
||||
p.y = rand(H * 0.1, H);
|
||||
p.x = initial ? rand(0, W) : -rand(200, 500);
|
||||
p.vx = rand(6, 16);
|
||||
p.w = rand(260, 520);
|
||||
p.h = rand(70, 150);
|
||||
p.alpha = rand(0.05, 0.14);
|
||||
} else if (variant === "petals" || variant === "jacaranda") {
|
||||
p.vy = rand(60, 120);
|
||||
p.swayAmp = rand(20, 50);
|
||||
p.swayFreq = rand(0.4, 0.9);
|
||||
p.swayPhase = rand(0, Math.PI * 2);
|
||||
p.rot = rand(0, Math.PI * 2);
|
||||
p.vrot = rand(-1.2, 1.2);
|
||||
p.size = rand(8, 16) * p.z;
|
||||
p.alpha = rand(0.75, 1.0);
|
||||
if (variant === "jacaranda") {
|
||||
p.hue = rand(265, 285); // blue-violet — jacaranda uses the petal silhouette in purple
|
||||
p.sat = rand(55, 75);
|
||||
p.light = rand(70, 82);
|
||||
} else {
|
||||
p.hue = rand(335, 355); // pink (almond/cherry)
|
||||
p.sat = rand(60, 80);
|
||||
p.light = rand(78, 88);
|
||||
}
|
||||
} else if (variant === "motes") {
|
||||
p.vx = rand(-15, 25);
|
||||
p.vy = rand(8, 25);
|
||||
p.size = rand(1.2, 2.8) * p.z;
|
||||
p.alpha = rand(0.35, 0.7);
|
||||
p.twinklePhase = rand(0, Math.PI * 2);
|
||||
p.twinkleFreq = rand(0.5, 1.5);
|
||||
} else if (variant === "leaves") {
|
||||
p.vy = rand(70, 130);
|
||||
p.swayAmp = rand(30, 70);
|
||||
p.swayFreq = rand(0.3, 0.6);
|
||||
p.swayPhase = rand(0, Math.PI * 2);
|
||||
p.rot = rand(0, Math.PI * 2);
|
||||
p.vrot = rand(-2.0, 2.0);
|
||||
p.size = rand(10, 18) * p.z;
|
||||
p.alpha = rand(0.8, 1.0);
|
||||
p.hue = rand(18, 42); // orange→amber→brown
|
||||
p.light = rand(38, 55);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
var night = root.dataset.phase === "night";
|
||||
function refreshNight() { night = root.dataset.phase === "night"; }
|
||||
|
||||
function drawRain(p) {
|
||||
// Day/dawn/dusk backgrounds are warm and pale, so the rain needs a darker,
|
||||
// more saturated blue plus a slightly thicker line to stay visible. Night
|
||||
// keeps a paler tone so streaks read against the dark palette.
|
||||
if (night) {
|
||||
ctx.strokeStyle = "rgba(200,215,240," + p.alpha + ")";
|
||||
ctx.lineWidth = 1;
|
||||
} else {
|
||||
ctx.strokeStyle = "rgba(60,95,150," + Math.min(1, p.alpha + 0.25) + ")";
|
||||
ctx.lineWidth = 1.3;
|
||||
}
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(p.x, p.y);
|
||||
ctx.lineTo(p.x - p.len * 0.12, p.y + p.len);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function drawSnow(p) {
|
||||
// Soft round flake with a faint glow so it reads on light and dark palettes.
|
||||
var col = night ? "235,242,255" : "255,255,255";
|
||||
ctx.fillStyle = "rgba(" + col + "," + p.alpha + ")";
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
if (!night) {
|
||||
// A thin cool outline keeps white flakes visible against cream backgrounds.
|
||||
ctx.strokeStyle = "rgba(150,180,220," + (p.alpha * 0.5) + ")";
|
||||
ctx.lineWidth = 0.6;
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-baked cloud sprites: clustered blobs softened with a heavy blur so each
|
||||
// cloud reads as one fluffy mass. Built once per palette (day/night) onto
|
||||
// offscreen canvases, then just drifted — cheap to animate.
|
||||
var cloudSprites = null;
|
||||
var cloudSpritesNight = null;
|
||||
|
||||
// Three silhouettes for variety. Each is a list of blobs [cx, cy, r] in a
|
||||
// 0..1 box (x across, y down) with a flattish base around y≈0.66.
|
||||
var cloudShapes = [
|
||||
[[0.50, 0.50, 0.26], [0.34, 0.56, 0.20], [0.66, 0.56, 0.20], [0.42, 0.40, 0.18],
|
||||
[0.58, 0.38, 0.17], [0.22, 0.60, 0.15], [0.78, 0.60, 0.15], [0.50, 0.62, 0.22]],
|
||||
[[0.46, 0.52, 0.24], [0.30, 0.58, 0.18], [0.62, 0.50, 0.22], [0.74, 0.58, 0.16],
|
||||
[0.40, 0.40, 0.16], [0.56, 0.36, 0.15], [0.18, 0.62, 0.13], [0.84, 0.62, 0.12]],
|
||||
[[0.52, 0.50, 0.28], [0.36, 0.56, 0.19], [0.68, 0.56, 0.18], [0.48, 0.36, 0.16],
|
||||
[0.26, 0.60, 0.14], [0.74, 0.60, 0.15], [0.60, 0.62, 0.18], [0.40, 0.62, 0.18]]
|
||||
];
|
||||
|
||||
function makeCloudSprite(col, shape) {
|
||||
var c = document.createElement("canvas");
|
||||
var w = 320, h = 224;
|
||||
c.width = w; c.height = h;
|
||||
var cx = c.getContext("2d");
|
||||
cx.filter = "blur(18px)"; // the softness that turns blobs into a cloud
|
||||
cx.fillStyle = "rgba(" + col + ",1)";
|
||||
for (var i = 0; i < shape.length; i++) {
|
||||
cx.beginPath();
|
||||
cx.arc(shape[i][0] * w, shape[i][1] * h, shape[i][2] * w, 0, Math.PI * 2);
|
||||
cx.fill();
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
function ensureCloudSprites() {
|
||||
if (cloudSprites && cloudSpritesNight === night) return;
|
||||
// Day sky is warm cream so clouds need a cool slate-grey; night uses a pale
|
||||
// tone against the dark palette.
|
||||
var col = night ? "206,216,236" : "150,164,190";
|
||||
cloudSprites = cloudShapes.map(function (s) { return makeCloudSprite(col, s); });
|
||||
cloudSpritesNight = night;
|
||||
}
|
||||
|
||||
function drawCloud(p) {
|
||||
ensureCloudSprites();
|
||||
var spr = cloudSprites[p.sprite % cloudSprites.length];
|
||||
var w = p.size * 2.6;
|
||||
var h = w * (spr.height / spr.width);
|
||||
ctx.globalAlpha = p.alpha;
|
||||
ctx.drawImage(spr, p.x - w / 2, p.y - h / 2, w, h);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function drawFog(p) {
|
||||
var col = night ? "150,160,180" : "230,228,224";
|
||||
var g = ctx.createLinearGradient(p.x - p.w / 2, 0, p.x + p.w / 2, 0);
|
||||
g.addColorStop(0, "rgba(" + col + ",0)");
|
||||
g.addColorStop(0.5, "rgba(" + col + "," + p.alpha + ")");
|
||||
g.addColorStop(1, "rgba(" + col + ",0)");
|
||||
ctx.fillStyle = g;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(p.x, p.y, p.w / 2, p.h / 2, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
function drawMoon(cx, cy, mr) {
|
||||
var TAU = Math.PI * 2;
|
||||
ctx.save();
|
||||
// Clip everything to the lunar disc so shading stays inside the sphere.
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, mr, 0, TAU);
|
||||
ctx.clip();
|
||||
|
||||
// Spherical body shading — light source upper-right, terminator lower-left.
|
||||
var lx = cx + mr * 0.45, ly = cy - mr * 0.45;
|
||||
var body = ctx.createRadialGradient(lx, ly, mr * 0.1, cx, cy, mr * 1.35);
|
||||
body.addColorStop(0, "rgba(249,251,255,0.97)");
|
||||
body.addColorStop(0.55, "rgba(214,222,240,0.92)");
|
||||
body.addColorStop(1, "rgba(140,151,180,0.85)");
|
||||
ctx.fillStyle = body;
|
||||
ctx.fillRect(cx - mr, cy - mr, mr * 2, mr * 2);
|
||||
|
||||
// Limb darkening — fade the edge so the disc reads as a ball, not a coin.
|
||||
var limb = ctx.createRadialGradient(cx, cy, mr * 0.62, cx, cy, mr);
|
||||
limb.addColorStop(0, "rgba(18,24,46,0)");
|
||||
limb.addColorStop(1, "rgba(18,24,46,0.4)");
|
||||
ctx.fillStyle = limb;
|
||||
ctx.fillRect(cx - mr, cy - mr, mr * 2, mr * 2);
|
||||
ctx.restore();
|
||||
|
||||
// Soft outer halo, drawn unclipped around the disc.
|
||||
var halo = ctx.createRadialGradient(cx, cy, mr, cx, cy, mr * 2.4);
|
||||
halo.addColorStop(0, "rgba(220,230,255,0.28)");
|
||||
halo.addColorStop(1, "rgba(220,230,255,0)");
|
||||
ctx.fillStyle = halo;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, mr * 2.4, 0, TAU);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
function drawClearGlow(t) {
|
||||
// No particles — render a single calm sun (day) or moon (night) glow that
|
||||
// breathes very slowly, plus a few stars at night.
|
||||
var breathe = 0.5 + 0.5 * Math.sin(t * 0.25);
|
||||
var cx = W * 0.82, cy = H * 0.18, r = Math.min(W, H) * 0.5;
|
||||
if (night) {
|
||||
// Ambient sky glow around the moon.
|
||||
var g = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
|
||||
g.addColorStop(0, "rgba(220,230,255," + (0.12 + 0.05 * breathe) + ")");
|
||||
g.addColorStop(1, "rgba(220,230,255,0)");
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
drawMoon(cx, cy, r * 0.16);
|
||||
} else {
|
||||
var gd = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
|
||||
gd.addColorStop(0, "rgba(255,224,150," + (0.30 + 0.10 * breathe) + ")");
|
||||
gd.addColorStop(0.5, "rgba(255,214,120," + (0.10 + 0.04 * breathe) + ")");
|
||||
gd.addColorStop(1, "rgba(255,214,120,0)");
|
||||
ctx.fillStyle = gd;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
}
|
||||
}
|
||||
|
||||
// Deterministic star field for clear nights (seeded so they don't jitter).
|
||||
var stars = [];
|
||||
function buildStars() {
|
||||
stars = [];
|
||||
var n = 60;
|
||||
for (var i = 0; i < n; i++) {
|
||||
// Cheap LCG-ish spread keyed by index — stable across frames.
|
||||
var sx = ((i * 73 + 11) % 100) / 100;
|
||||
var sy = ((i * 37 + 7) % 100) / 100;
|
||||
stars.push({ x: sx, y: sy * 0.7, r: 0.6 + (i % 3) * 0.5, ph: (i % 7) });
|
||||
}
|
||||
}
|
||||
function drawStars(t) {
|
||||
for (var i = 0; i < stars.length; i++) {
|
||||
var s = stars[i];
|
||||
var tw = 0.4 + 0.6 * (0.5 + 0.5 * Math.sin(t * 0.8 + s.ph));
|
||||
ctx.fillStyle = "rgba(255,255,255," + (0.5 * tw) + ")";
|
||||
ctx.beginPath();
|
||||
ctx.arc(s.x * W, s.y * H, s.r, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
function drawPetals(p) {
|
||||
// Five-petal almond/cherry blossom viewed face-on, with a yellow center.
|
||||
var s = p.size;
|
||||
ctx.save();
|
||||
ctx.translate(p.x, p.y);
|
||||
ctx.rotate(p.rot);
|
||||
ctx.fillStyle = "hsla(" + p.hue + "," + p.sat + "%," + p.light + "%," + p.alpha + ")";
|
||||
for (var i = 0; i < 5; i++) {
|
||||
var a = (i / 5) * Math.PI * 2 - Math.PI / 2;
|
||||
var cx = Math.cos(a) * s * 0.32;
|
||||
var cy = Math.sin(a) * s * 0.32;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(cx, cy, s * 0.38, s * 0.24, a, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.fillStyle = "hsla(48,90%,68%," + p.alpha + ")";
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, s * 0.13, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawLeaf(p) {
|
||||
var s = p.size;
|
||||
ctx.save();
|
||||
ctx.translate(p.x, p.y);
|
||||
ctx.rotate(p.rot);
|
||||
var g = ctx.createLinearGradient(-s * 0.5, 0, s * 0.5, 0);
|
||||
g.addColorStop(0, "hsla(" + p.hue + ",70%," + (p.light - 10) + "%," + p.alpha + ")");
|
||||
g.addColorStop(1, "hsla(" + p.hue + ",75%," + (p.light + 10) + "%," + p.alpha + ")");
|
||||
ctx.fillStyle = g;
|
||||
// Almond/lozenge leaf — pointed at both ends.
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-s * 0.55, 0);
|
||||
ctx.quadraticCurveTo(0, -s * 0.38, s * 0.55, 0);
|
||||
ctx.quadraticCurveTo(0, s * 0.38, -s * 0.55, 0);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
// Midrib vein.
|
||||
ctx.strokeStyle = "hsla(" + p.hue + ",60%," + (p.light - 22) + "%," + (p.alpha * 0.7) + ")";
|
||||
ctx.lineWidth = 0.8;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-s * 0.5, 0);
|
||||
ctx.lineTo(s * 0.5, 0);
|
||||
ctx.stroke();
|
||||
// Small stem nub at the base.
|
||||
ctx.strokeStyle = "hsla(" + p.hue + ",55%," + (p.light - 25) + "%," + (p.alpha * 0.7) + ")";
|
||||
ctx.lineWidth = 1.0;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-s * 0.55, 0);
|
||||
ctx.lineTo(-s * 0.7, -s * 0.04);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawMote(p, t) {
|
||||
var twinkle = 0.5 + 0.5 * Math.sin(t * p.twinkleFreq + p.twinklePhase);
|
||||
// Night keeps a pale glowing mote against the dark palette. Day/dawn/dusk
|
||||
// use a deeper saturated honey so the motes stand out against warm cream
|
||||
// and peach backgrounds; alpha also gets a bump.
|
||||
var color, a;
|
||||
if (night) {
|
||||
color = "255,240,180";
|
||||
a = p.alpha * (0.45 + 0.55 * twinkle);
|
||||
} else {
|
||||
color = "130,80,180";
|
||||
a = Math.min(1, (p.alpha + 0.2) * (0.6 + 0.4 * twinkle));
|
||||
}
|
||||
// Soft glow halo.
|
||||
var g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size * 5);
|
||||
g.addColorStop(0, "rgba(" + color + "," + (a * 0.55) + ")");
|
||||
g.addColorStop(1, "rgba(" + color + ",0)");
|
||||
ctx.fillStyle = g;
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.size * 5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
// Bright core.
|
||||
ctx.fillStyle = "rgba(" + color + "," + a + ")";
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
var last = performance.now();
|
||||
var raf = 0;
|
||||
|
||||
function step(now) {
|
||||
var dt = Math.min(0.05, (now - last) / 1000);
|
||||
last = now;
|
||||
var t = now / 1000;
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
refreshNight();
|
||||
|
||||
if (variant === "clear") {
|
||||
drawClearGlow(t);
|
||||
if (night) drawStars(t);
|
||||
raf = requestAnimationFrame(step);
|
||||
return;
|
||||
}
|
||||
|
||||
// Storm: drive the lightning flash before drawing rain over it.
|
||||
if (variant === "storm") {
|
||||
nextBolt -= dt;
|
||||
if (nextBolt <= 0) {
|
||||
flash = 1;
|
||||
nextBolt = rand(2.5, 7);
|
||||
}
|
||||
if (flash > 0) {
|
||||
ctx.fillStyle = "rgba(235,240,255," + (flash * 0.5) + ")";
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
flash = Math.max(0, flash - dt * 4); // ~0.25s decay
|
||||
}
|
||||
}
|
||||
|
||||
for (var i = 0; i < particles.length; i++) {
|
||||
var p = particles[i];
|
||||
if (variant === "rain" || variant === "storm") {
|
||||
p.x += p.vx * dt;
|
||||
p.y += p.vy * dt;
|
||||
if (p.y > H + 20 || p.x < -40) {
|
||||
particles[i] = spawn(false);
|
||||
particles[i].x = rand(0, W + 60);
|
||||
continue;
|
||||
}
|
||||
drawRain(p);
|
||||
} else if (variant === "snow") {
|
||||
p.y += p.vy * dt;
|
||||
var sxOff = Math.sin(t * p.swayFreq + p.swayPhase) * p.swayAmp;
|
||||
if (p.y > H + 10) { particles[i] = spawn(false); continue; }
|
||||
var ox = p.x; p.x = ox + sxOff;
|
||||
drawSnow(p);
|
||||
p.x = ox;
|
||||
} else if (variant === "clouds") {
|
||||
p.x += p.vx * dt;
|
||||
if (p.x - p.size * 1.3 > W + 40) { particles[i] = spawn(false); continue; }
|
||||
drawCloud(p);
|
||||
} else if (variant === "fog") {
|
||||
p.x += p.vx * dt;
|
||||
if (p.x - p.w / 2 > W + 40) { particles[i] = spawn(false); continue; }
|
||||
drawFog(p);
|
||||
} else if (variant === "motes") {
|
||||
p.x += p.vx * dt;
|
||||
p.y += p.vy * dt;
|
||||
if (p.y > H + 10 || p.x < -10 || p.x > W + 10) {
|
||||
particles[i] = spawn(false);
|
||||
continue;
|
||||
}
|
||||
drawMote(p, t);
|
||||
} else {
|
||||
// Drifting variants: petals, jacaranda, leaves.
|
||||
p.y += p.vy * dt;
|
||||
p.rot += p.vrot * dt;
|
||||
var sway = Math.sin(t * p.swayFreq + p.swayPhase) * p.swayAmp;
|
||||
var origX = p.x;
|
||||
p.x = origX + sway;
|
||||
if (p.y > H + 30) {
|
||||
particles[i] = spawn(false);
|
||||
continue;
|
||||
}
|
||||
if (variant === "jacaranda" || variant === "petals") drawPetals(p);
|
||||
else drawLeaf(p);
|
||||
p.x = origX;
|
||||
}
|
||||
}
|
||||
raf = requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (raf) return;
|
||||
last = performance.now();
|
||||
raf = requestAnimationFrame(step);
|
||||
}
|
||||
function stop() {
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
raf = 0;
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
}
|
||||
|
||||
// Build the particle pool for a variant and (re)start rendering if enabled.
|
||||
function configure(v, inten) {
|
||||
variant = v || null;
|
||||
intensity = inten || "heavy";
|
||||
flash = 0; nextBolt = rand(1.5, 4);
|
||||
particles = [];
|
||||
if (variant === "clear") { buildStars(); }
|
||||
if (!variant) { stop(); return; }
|
||||
var N = Math.round((counts[variant] || 0) * (mults[intensity] || 1));
|
||||
for (var i = 0; i < N; i++) particles.push(spawn(true));
|
||||
if (enabled) start();
|
||||
}
|
||||
|
||||
var enabled = !userDisabled() && !reducedMotion;
|
||||
|
||||
// Public hook so weather-forecast.js can swap the seasonal default for live
|
||||
// conditions. Passing a falsy variant clears the canvas.
|
||||
// Public hook so weather-forecast.js (and the /weather demo) can swap the
|
||||
// seasonal default for other conditions. Passing a falsy variant clears the
|
||||
// canvas.
|
||||
window.PeteWeather = {
|
||||
set: function (v, inten) {
|
||||
root.dataset.weather = v || "";
|
||||
if (inten) root.dataset.intensity = inten;
|
||||
configure(v, inten || intensity);
|
||||
engine.set(v || null, inten || root.dataset.intensity || "heavy");
|
||||
if (enabled && v) engine.start();
|
||||
},
|
||||
isEnabled: function () { return enabled; }
|
||||
isEnabled: function () { return enabled; },
|
||||
renderer: function () { return engine.name; }
|
||||
};
|
||||
|
||||
syncBtn(enabled);
|
||||
configure(root.dataset.weather, root.dataset.intensity);
|
||||
engine.set(root.dataset.weather || null, root.dataset.intensity || "heavy");
|
||||
if (enabled && root.dataset.weather) engine.start();
|
||||
|
||||
if (toggleBtn) {
|
||||
toggleBtn.addEventListener("click", function () {
|
||||
enabled = !enabled;
|
||||
setDisabled(!enabled);
|
||||
syncBtn(enabled);
|
||||
if (enabled) start(); else stop();
|
||||
if (enabled) engine.start(); else engine.stop();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("visibilitychange", function () {
|
||||
if (!enabled) return;
|
||||
if (document.hidden) stop();
|
||||
else start();
|
||||
if (document.hidden) engine.stop();
|
||||
else engine.start();
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//
|
||||
// Bump CACHE_VERSION whenever the precached shell assets change; activate()
|
||||
// drops every cache that doesn't match the current version.
|
||||
var CACHE_VERSION = "v3";
|
||||
var CACHE_VERSION = "v4";
|
||||
var SHELL_CACHE = "pete-shell-" + CACHE_VERSION;
|
||||
var RUNTIME_CACHE = "pete-runtime-" + CACHE_VERSION;
|
||||
|
||||
@@ -13,6 +13,8 @@ var RUNTIME_CACHE = "pete-runtime-" + CACHE_VERSION;
|
||||
var SHELL_ASSETS = [
|
||||
"/static/css/output.css",
|
||||
"/static/js/prefs.js",
|
||||
"/static/js/weather-2d.js",
|
||||
"/static/js/weather-gl.js",
|
||||
"/static/js/weather.js",
|
||||
"/static/js/weather-forecast.js",
|
||||
"/static/js/search.js",
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
)
|
||||
|
||||
func TestStatusTemplateExecutes(t *testing.T) {
|
||||
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true)
|
||||
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true, config.AdventureConfig{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{block "title" .}}{{.SiteTitle}}{{end}}</title>
|
||||
{{if .NoIndex}}<meta name="robots" content="noindex">{{end}}
|
||||
{{if .OGImage}}<meta property="og:image" content="{{.OGImage}}"><meta name="twitter:card" content="summary_large_image">{{end}}
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@400;500;600;700&family=Nunito:wght@400;600;700&display=swap" rel="stylesheet">
|
||||
@@ -246,6 +248,10 @@
|
||||
<button type="button" data-paper="sepia">Sepia</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pete-reader-typerow pete-reader-voicerow" data-reader-voicerow hidden>
|
||||
<span class="pete-reader-typelabel">Voice</span>
|
||||
<select class="pete-reader-voice" data-reader-voice aria-label="Read-aloud voice"></select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" data-reader-bookmark class="pete-reader-btn" style="display:none" title="Bookmark (b)" aria-label="Bookmark this story" aria-pressed="false">🔖 Save</button>
|
||||
@@ -253,7 +259,7 @@
|
||||
<button type="button" data-reader-close class="pete-reader-btn" title="Close (esc)" aria-label="Close reader">esc</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pete-reader-scroll" data-reader-scroll>
|
||||
<div class="pete-reader-scroll" data-reader-scroll tabindex="-1">
|
||||
<article class="pete-reader-article" data-reader-article></article>
|
||||
<div class="pete-reader-related" data-reader-related hidden></div>
|
||||
</div>
|
||||
@@ -305,8 +311,11 @@
|
||||
window.PETE_USER = {{if .User}}{ name: {{.User.Display}}, email: {{.User.Email}} }{{else}}null{{end}};
|
||||
window.PETE_PREFS = {{.UserPrefs}};
|
||||
window.PETE_PUSH = {{if .PushEnabled}}{ enabled: true, publicKey: {{.PushPublicKey}} }{{else}}null{{end}};
|
||||
window.PETE_TTS = {{.TTS}};
|
||||
</script>
|
||||
<script src="/static/js/prefs.js" defer></script>
|
||||
<script src="/static/js/weather-2d.js" defer></script>
|
||||
<script src="/static/js/weather-gl.js" defer></script>
|
||||
<script src="/static/js/weather.js" defer></script>
|
||||
<script src="/static/js/weather-forecast.js" defer></script>
|
||||
<script src="/static/js/search.js" defer></script>
|
||||
|
||||
27
internal/web/templates/story.html
Normal file
27
internal/web/templates/story.html
Normal file
@@ -0,0 +1,27 @@
|
||||
{{define "title"}}{{.Headline}} — {{.SiteTitle}}{{end}}
|
||||
|
||||
{{define "main"}}
|
||||
<article class="mt-2 mb-10 max-w-3xl mx-auto">
|
||||
<nav class="mb-4">
|
||||
<a href="/adventure" class="inline-flex items-center gap-1.5 text-sm font-semibold text-[color:var(--ink)]/60 hover:text-[color:var(--ink)] transition">
|
||||
<span aria-hidden="true">←</span> All dispatches
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<header class="rounded-3xl bg-theme-adventure text-white p-6 sm:p-10 shadow-pete relative overflow-hidden">
|
||||
<div class="absolute -top-6 -right-6 text-[12rem] opacity-20 select-none" aria-hidden="true">{{.Emoji}}</div>
|
||||
<div class="relative">
|
||||
<p class="text-sm uppercase tracking-[0.2em] opacity-80">{{.Emoji}} {{.EventLabel}}</p>
|
||||
<h1 class="font-display text-3xl sm:text-4xl font-bold mt-2 leading-tight">{{.Headline}}</h1>
|
||||
<p class="mt-4 text-xs uppercase tracking-wider opacity-75">Reported {{.When}}{{if .Region}} · {{.Region}}{{end}}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="mt-8 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 sm:p-8 shadow-pete">
|
||||
<p class="text-lg leading-relaxed text-[color:var(--ink)]/90 whitespace-pre-line">{{.Body}}</p>
|
||||
<p class="mt-8 pt-6 border-t border-[color:var(--ink)]/10 text-sm italic text-theme-adventure font-semibold">
|
||||
Reporting from the realm, this is Pete.
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
{{end}}
|
||||
@@ -3,14 +3,21 @@
|
||||
{{define "main"}}
|
||||
<div class="space-y-8">
|
||||
<section class="rounded-3xl bg-[color:var(--card)] p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
|
||||
<h1 class="font-display text-3xl font-bold mb-1">Weather demo</h1>
|
||||
<p class="text-sm text-[color:var(--ink)]/60 mb-5">Pick a variant, intensity, and phase. The canvas reloads on each click.</p>
|
||||
<div class="flex flex-wrap items-start justify-between gap-3 mb-1">
|
||||
<h1 class="font-display text-3xl font-bold">Weather demo</h1>
|
||||
<div class="flex items-center gap-2 text-xs font-semibold">
|
||||
<span data-demo-renderer class="rounded-full border-2 border-[color:var(--ink)]/10 px-3 py-1 uppercase tracking-wider text-[color:var(--ink)]/60">renderer: …</span>
|
||||
<span data-demo-fps class="rounded-full border-2 border-[color:var(--ink)]/10 px-3 py-1 tabular-nums text-[color:var(--ink)]/60">— fps</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-[color:var(--ink)]/60 mb-5">Pick a variant, intensity, and phase. Switching is instant, no reload.</p>
|
||||
|
||||
<div class="space-y-3 text-sm">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="w-20 shrink-0 font-semibold uppercase tracking-wider text-[color:var(--ink)]/60">Variant</span>
|
||||
{{range $v := .Variants}}
|
||||
<a href="?variant={{$v}}&intensity={{$.Intensity}}&phase={{$.PhaseSel}}"
|
||||
data-demo-variant="{{$v}}"
|
||||
class="rounded-full px-3 py-1 transition border-2 border-[color:var(--ink)]/10
|
||||
{{if eq $.Variant $v}}bg-[color:var(--accent)] text-white font-semibold{{else}}hover:bg-[color:var(--ink)]/5{{end}}">{{$v}}</a>
|
||||
{{end}}
|
||||
@@ -19,6 +26,7 @@
|
||||
<span class="w-20 shrink-0 font-semibold uppercase tracking-wider text-[color:var(--ink)]/60">Intensity</span>
|
||||
{{range $i := .Intensities}}
|
||||
<a href="?variant={{$.Variant}}&intensity={{$i}}&phase={{$.PhaseSel}}"
|
||||
data-demo-intensity="{{$i}}"
|
||||
class="rounded-full px-3 py-1 transition border-2 border-[color:var(--ink)]/10
|
||||
{{if eq $.Intensity $i}}bg-[color:var(--accent)] text-white font-semibold{{else}}hover:bg-[color:var(--ink)]/5{{end}}">{{$i}}</a>
|
||||
{{end}}
|
||||
@@ -27,6 +35,7 @@
|
||||
<span class="w-20 shrink-0 font-semibold uppercase tracking-wider text-[color:var(--ink)]/60">Phase</span>
|
||||
{{range $p := .Phases}}
|
||||
<a href="?variant={{$.Variant}}&intensity={{$.Intensity}}&phase={{$p}}"
|
||||
data-demo-phase="{{$p}}"
|
||||
class="rounded-full px-3 py-1 transition border-2 border-[color:var(--ink)]/10
|
||||
{{if eq $.PhaseSel $p}}bg-[color:var(--accent)] text-white font-semibold{{else}}hover:bg-[color:var(--ink)]/5{{end}}">{{$p}}</a>
|
||||
{{end}}
|
||||
@@ -34,7 +43,8 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-5 text-xs text-[color:var(--ink)]/50">
|
||||
Current: <code class="font-semibold">{{.Weather.Season}} / {{.Variant}} / {{.Intensity}} / {{.PhaseSel}}</code>
|
||||
Current: <code data-demo-current class="font-semibold">{{.Variant}} / {{.Intensity}} / {{.PhaseSel}}</code>
|
||||
<span class="mx-1">·</span> Tip: aurora and clear night want the night phase; wind and haze are the new autumn gusts and Saharan calima.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -50,8 +60,76 @@
|
||||
</div>
|
||||
<div class="rounded-3xl bg-[color:var(--card)] p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
|
||||
<p class="font-display">Card B</p>
|
||||
<p class="text-sm text-[color:var(--ink)]/60 mt-1">Move your window taller to give the particles more room.</p>
|
||||
<p class="text-sm text-[color:var(--ink)]/60 mt-1">Make your window taller to give the particles more room.</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Instant switching: the pill links stay real URLs for no-JS visitors, but
|
||||
// with JS we swap the effect in place via PeteWeather.set and keep the URL
|
||||
// in sync with replaceState.
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
var root = document.documentElement;
|
||||
var state = {
|
||||
variant: {{.Variant}},
|
||||
intensity: {{.Intensity}},
|
||||
phase: {{.PhaseSel}}
|
||||
};
|
||||
|
||||
function markActive(attr, value) {
|
||||
document.querySelectorAll("[" + attr + "]").forEach(function (el) {
|
||||
var on = el.getAttribute(attr) === value;
|
||||
el.classList.toggle("bg-[color:var(--accent)]", on);
|
||||
el.classList.toggle("text-white", on);
|
||||
el.classList.toggle("font-semibold", on);
|
||||
});
|
||||
}
|
||||
|
||||
function apply() {
|
||||
root.dataset.phase = state.phase;
|
||||
if (window.PeteWeather) window.PeteWeather.set(state.variant, state.intensity);
|
||||
markActive("data-demo-variant", state.variant);
|
||||
markActive("data-demo-intensity", state.intensity);
|
||||
markActive("data-demo-phase", state.phase);
|
||||
var cur = document.querySelector("[data-demo-current]");
|
||||
if (cur) cur.textContent = state.variant + " / " + state.intensity + " / " + state.phase;
|
||||
var label = document.querySelector("[data-phase-label]");
|
||||
if (label) label.textContent = state.phase;
|
||||
history.replaceState(null, "", "?variant=" + state.variant + "&intensity=" + state.intensity + "&phase=" + state.phase);
|
||||
}
|
||||
|
||||
document.addEventListener("click", function (e) {
|
||||
var a = e.target.closest && e.target.closest("[data-demo-variant],[data-demo-intensity],[data-demo-phase]");
|
||||
if (!a || !window.PeteWeather) return;
|
||||
e.preventDefault();
|
||||
if (a.hasAttribute("data-demo-variant")) state.variant = a.getAttribute("data-demo-variant");
|
||||
if (a.hasAttribute("data-demo-intensity")) state.intensity = a.getAttribute("data-demo-intensity");
|
||||
if (a.hasAttribute("data-demo-phase")) state.phase = a.getAttribute("data-demo-phase");
|
||||
apply();
|
||||
});
|
||||
|
||||
var rEl = document.querySelector("[data-demo-renderer]");
|
||||
if (rEl && window.PeteWeather && window.PeteWeather.renderer) {
|
||||
rEl.textContent = "renderer: " + window.PeteWeather.renderer();
|
||||
}
|
||||
|
||||
// Lightweight FPS meter, demo page only. Counts frames on its own rAF and
|
||||
// refreshes the pill once a second.
|
||||
var fpsEl = document.querySelector("[data-demo-fps]");
|
||||
if (fpsEl) {
|
||||
var frames = 0, lastStamp = performance.now();
|
||||
function tick(now) {
|
||||
frames++;
|
||||
if (now - lastStamp >= 1000) {
|
||||
fpsEl.textContent = Math.round(frames * 1000 / (now - lastStamp)) + " fps";
|
||||
frames = 0;
|
||||
lastStamp = now;
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
@@ -67,6 +67,14 @@ func thumbURL(src string) string {
|
||||
if src == "" {
|
||||
return ""
|
||||
}
|
||||
// Root-relative sources are our own local assets (e.g. the adventure
|
||||
// emblems) — serve them directly; the thumbnailer only handles remote
|
||||
// http(s) images and would 404 a local path. "//host/x.jpg" is NOT local:
|
||||
// it's a protocol-relative remote image, and it has to keep going through
|
||||
// the guarded thumbnailer like any other third-party URL.
|
||||
if strings.HasPrefix(src, "/") && !strings.HasPrefix(src, "//") {
|
||||
return src
|
||||
}
|
||||
return "/img/" + thumbKey(src) + ".avif?u=" + url.QueryEscape(src)
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ func TestIndexTrendingAndBadges(t *testing.T) {
|
||||
storage.RecordStoryView(id)
|
||||
storage.RecordStoryView(id)
|
||||
|
||||
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true)
|
||||
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true, config.AdventureConfig{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
199
internal/web/tts.go
Normal file
199
internal/web/tts.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"pete/internal/config"
|
||||
)
|
||||
|
||||
const (
|
||||
ttsMaxTextLen = 6000 // per-request cap; a single paragraph is ~1-2k chars
|
||||
ttsTimeout = 60 * time.Second
|
||||
ttsMaxConcurrent = 3 // cap simultaneous piper processes so a burst can't pin the box
|
||||
)
|
||||
|
||||
// ttsVoice is one selectable voice, as sent to the client.
|
||||
type ttsVoice struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
// ttsService runs Piper (https://github.com/rhasspy/piper) as a subprocess to
|
||||
// synthesize read-aloud audio server-side. It holds the validated voice
|
||||
// registry and a concurrency semaphore; there is no long-lived process, each
|
||||
// request spawns a short-lived piper that loads its model, synthesizes one
|
||||
// chunk of text to a WAV on stdout, and exits (~0.5s for a paragraph).
|
||||
type ttsService struct {
|
||||
piperBin string
|
||||
voices []ttsVoice // menu order, for the client selector
|
||||
models map[string]string // voice id -> absolute .onnx path
|
||||
def string // default voice id
|
||||
sem chan struct{} // buffered to ttsMaxConcurrent
|
||||
}
|
||||
|
||||
// newTTS validates the Piper install and builds the voice registry. It returns
|
||||
// (nil, nil) when TTS is disabled. A configured voice whose model file is
|
||||
// missing is skipped with a warning rather than failing startup; if that leaves
|
||||
// no usable voices, TTS is disabled.
|
||||
func newTTS(cfg config.TTSConfig) (*ttsService, error) {
|
||||
if !cfg.Enabled {
|
||||
return nil, nil
|
||||
}
|
||||
bin := cfg.PiperBin
|
||||
if bin == "" {
|
||||
bin = "piper"
|
||||
}
|
||||
if p, err := exec.LookPath(bin); err != nil {
|
||||
slog.Error("web: TTS enabled but piper binary not found; read-aloud disabled", "piper_bin", bin, "err", err)
|
||||
return nil, nil
|
||||
} else {
|
||||
bin = p
|
||||
}
|
||||
|
||||
dir := cfg.VoicesDir
|
||||
svc := &ttsService{piperBin: bin, models: make(map[string]string)}
|
||||
|
||||
want := cfg.Voices
|
||||
if len(want) == 0 {
|
||||
// Auto-discover every *.onnx in voices_dir, labelled by filename stem.
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
slog.Error("web: TTS voices_dir unreadable; read-aloud disabled", "voices_dir", dir, "err", err)
|
||||
return nil, nil
|
||||
}
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if e.IsDir() || !strings.HasSuffix(name, ".onnx") {
|
||||
continue
|
||||
}
|
||||
id := strings.TrimSuffix(name, ".onnx")
|
||||
want = append(want, config.VoiceConfig{ID: id, Label: id})
|
||||
}
|
||||
sort.Slice(want, func(i, j int) bool { return want[i].ID < want[j].ID })
|
||||
}
|
||||
|
||||
for _, v := range want {
|
||||
if v.ID == "" {
|
||||
continue
|
||||
}
|
||||
model := filepath.Join(dir, v.ID+".onnx")
|
||||
if _, err := os.Stat(model); err != nil {
|
||||
slog.Warn("web: TTS voice model missing; skipping", "voice", v.ID, "path", model)
|
||||
continue
|
||||
}
|
||||
label := v.Label
|
||||
if label == "" {
|
||||
label = v.ID
|
||||
}
|
||||
svc.models[v.ID] = model
|
||||
svc.voices = append(svc.voices, ttsVoice{ID: v.ID, Label: label})
|
||||
}
|
||||
if len(svc.voices) == 0 {
|
||||
slog.Error("web: TTS enabled but no usable voices found; read-aloud disabled", "voices_dir", dir)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
svc.def = cfg.Default
|
||||
if _, ok := svc.models[svc.def]; !ok {
|
||||
svc.def = svc.voices[0].ID
|
||||
}
|
||||
svc.sem = make(chan struct{}, ttsMaxConcurrent)
|
||||
slog.Info("web: server-side TTS enabled", "piper", bin, "voices", len(svc.voices), "default", svc.def)
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
// clientConfig is the JSON handed to the page as window.PETE_TTS so the reader
|
||||
// can build its voice selector and know TTS is available.
|
||||
func (t *ttsService) clientConfig() template.JS {
|
||||
payload := struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Default string `json:"default"`
|
||||
Voices []ttsVoice `json:"voices"`
|
||||
}{Enabled: true, Default: t.def, Voices: t.voices}
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return template.JS("null")
|
||||
}
|
||||
return jsForScript(b)
|
||||
}
|
||||
|
||||
// handleTTS synthesizes one chunk of text to WAV audio with the requested
|
||||
// voice. It is registered only under the authenticated route group, so callers
|
||||
// are already signed in (read-aloud is a signed-in perk). The request body is
|
||||
// JSON {voice, text}; the response is audio/wav.
|
||||
func (s *Server) handleTTS(w http.ResponseWriter, r *http.Request) {
|
||||
if s.tts == nil {
|
||||
http.Error(w, "tts disabled", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if s.auth == nil || s.auth.userFromRequest(r) == nil {
|
||||
http.Error(w, "sign-in required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Voice string `json:"voice"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32*1024)).Decode(&req); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
text := strings.TrimSpace(req.Text)
|
||||
if text == "" {
|
||||
http.Error(w, "empty text", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(text) > ttsMaxTextLen {
|
||||
text = text[:ttsMaxTextLen]
|
||||
}
|
||||
model, ok := s.tts.models[req.Voice]
|
||||
if !ok {
|
||||
model = s.tts.models[s.tts.def] // unknown/blank voice -> default
|
||||
}
|
||||
|
||||
// Bound concurrent piper processes; give up if the client leaves or we wait
|
||||
// too long for a slot rather than queueing unboundedly.
|
||||
slotCtx, cancelSlot := context.WithTimeout(r.Context(), ttsTimeout)
|
||||
defer cancelSlot()
|
||||
select {
|
||||
case s.tts.sem <- struct{}{}:
|
||||
defer func() { <-s.tts.sem }()
|
||||
case <-slotCtx.Done():
|
||||
http.Error(w, "tts busy", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), ttsTimeout)
|
||||
defer cancel()
|
||||
// piper -m <model> -f - : write a full WAV to stdout. Text is fed on stdin,
|
||||
// so there is no shell and nothing user-controlled reaches the arg list
|
||||
// besides the model path, which is looked up from a fixed allowlist above.
|
||||
cmd := exec.CommandContext(ctx, s.tts.piperBin, "-m", model, "-f", "-")
|
||||
cmd.Stdin = strings.NewReader(text)
|
||||
var out, errb bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &errb
|
||||
if err := cmd.Run(); err != nil {
|
||||
slog.Error("web: piper synthesis failed", "err", err, "stderr", strings.TrimSpace(errb.String()))
|
||||
http.Error(w, "synthesis failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "audio/wav")
|
||||
w.Header().Set("Cache-Control", "private, max-age=3600")
|
||||
w.Header().Set("Content-Length", strconv.Itoa(out.Len()))
|
||||
_, _ = w.Write(out.Bytes())
|
||||
}
|
||||
35
main.go
35
main.go
@@ -248,6 +248,13 @@ func main() {
|
||||
if postingEnabled && roundRobinMode {
|
||||
channelNames := make([]string, 0, len(cfg.Matrix.Channels))
|
||||
for name := range cfg.Matrix.Channels {
|
||||
// The adventure channel drives its own posting: priority beats go
|
||||
// live at ingest, bulletins wait for the daily digest. Leaving it in
|
||||
// the rotation would let the scheduler post bulletins one-by-one
|
||||
// (they're "classified, not yet posted") and steal them from the digest.
|
||||
if cfg.Adventure.Enabled && name == cfg.Adventure.Channel {
|
||||
continue
|
||||
}
|
||||
channelNames = append(channelNames, name)
|
||||
}
|
||||
rr := scheduler.New(channelNames, cfg.Posting.RoundRobin.IntervalHours, queue)
|
||||
@@ -258,14 +265,35 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Adapter: the web ingest handler posts priority adventure beats live to
|
||||
// Matrix through the same queue everything else uses (bypasses pacing).
|
||||
// PostNow doesn't consult posting.enabled, so gate here: with posting off,
|
||||
// adventure stays website-only like every other channel (nil poster also
|
||||
// keeps the digest loop from starting).
|
||||
var advPost web.PriorityPoster
|
||||
if postingEnabled {
|
||||
advPost = func(p web.AdvPost) {
|
||||
queue.PostNow(poster.QueueItem{
|
||||
GUID: p.GUID,
|
||||
Headline: p.Headline,
|
||||
Lede: p.Lede,
|
||||
ImageURL: p.ImageURL,
|
||||
ArticleURL: p.ArticleURL,
|
||||
Source: p.Source,
|
||||
Channel: p.Channel,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Start the read-only web UI alongside the Matrix bot.
|
||||
if cfg.Web.Enabled {
|
||||
ws, err := web.New(cfg.Web, cfg.Sources, postingEnabled)
|
||||
ws, err := web.New(cfg.Web, cfg.Sources, postingEnabled, cfg.Adventure, advPost)
|
||||
if err != nil {
|
||||
slog.Error("web server init failed", "err", err)
|
||||
} else {
|
||||
go ws.Start(ctx)
|
||||
ws.StartPushSender(ctx)
|
||||
ws.StartAdventureDigest(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,8 +398,9 @@ func runLocal(cfg *config.Config) {
|
||||
poller.Start(ctx)
|
||||
slog.Info("local: pollers started")
|
||||
|
||||
// Local mode never connects to Matrix, so it's always web-only.
|
||||
ws, err := web.New(cfg.Web, cfg.Sources, false)
|
||||
// Local mode never connects to Matrix, so it's always web-only: adventure
|
||||
// stories still ingest and render, but nothing posts (nil priority poster).
|
||||
ws, err := web.New(cfg.Web, cfg.Sources, false, cfg.Adventure, nil)
|
||||
if err != nil {
|
||||
slog.Error("local: web server init failed", "err", err)
|
||||
os.Exit(1)
|
||||
|
||||
Reference in New Issue
Block a user