Migrate to Chromium, unified VNC, thumbnails, autologin CDP detection

---

- Migrado base Docker de ubuntu:22.04 + Google Chrome para debian:bookworm-slim + Chromium
- Dockerfile refatorado com multi-stage build (node:22-alpine builder + debian runtime) e single RUN layer para imagem menor
- VNC unificado: removido novnc por stream, substituído por websockify global na porta 6080 com token-based routing
- Implementado sistema de thumbnails por stream via ffmpeg (captura do HLS) com endpoint GET/POST e atualização no card
- Autologin reescrito com detecção via Chrome DevTools Protocol: pula credenciais se já autenticado
- Adicionado padrão desiredState (running/stopped) persistido no JSON, restaurado via restore-streams.sh ao reiniciar container
- UI traduzida para inglês, formulário reorganizado com tooltips, seção avançada colapsável e GOP automático
- Player simplificado: modos HLS e HTML unificados, removido modo m3u8 separado
- Adicionado campo threads no ffmpeg; suporte a seccomp:unconfined no docker-compose

---
This commit is contained in:
2026-04-24 23:08:42 -03:00
parent 30b0597380
commit 1f8385e450
29 changed files with 1084 additions and 5412 deletions
+130 -92
View File
@@ -1,152 +1,190 @@
"use client"
import { useRouter } from "next/navigation"
import { useState } from "react"
import { MoreHorizontal, Play, Globe, FileVideo, Monitor, Pencil, RotateCcw, Square, Trash2, Circle } from "lucide-react"
import { useState, useEffect } from "react"
import { MoreHorizontal, Play, Globe, Monitor, Pencil, RotateCcw, Square, Trash2, Circle, Copy, Check, Video, ImageUp } from "lucide-react"
import { cn } from "@/lib/utils"
import type { Stream } from "@/types/stream"
interface Props {
stream: Stream
status?: Record<string, string>
localStatus?: string | null
cardSize?: "sm" | "md" | "lg"
onRefresh: () => void
onLocalStatus: (id: string, s: string | null) => void
}
function StatusBadge({ status }: { status?: Record<string, string> }) {
if (!status) return null
const ffmpeg = status.ffmpeg
function StatusBadge({ status, localStatus }: { status?: Record<string, string>; localStatus?: string | null }) {
const label = localStatus ?? (
status?.ffmpeg === "RUNNING" ? "running" :
status?.ffmpeg === "STARTING" ? "starting" :
status?.ffmpeg === "FATAL" ? "error" :
status?.ffmpeg === "STOPPED" ? "stopped" : "..."
)
const color =
ffmpeg === "RUNNING" ? "bg-green-500" :
ffmpeg === "STARTING" ? "bg-yellow-500" :
ffmpeg === "FATAL" ? "bg-red-500" :
"bg-zinc-500"
const label =
ffmpeg === "RUNNING" ? "running" :
ffmpeg === "STARTING" ? "starting" :
ffmpeg === "FATAL" ? "error" :
"stopped"
label === "running" ? "bg-green-500" :
label === "starting" ? "bg-yellow-500" :
label === "restarting" ? "bg-yellow-500" :
label === "stopping" ? "bg-orange-500" :
label === "error" ? "bg-red-500" : "bg-zinc-500"
return (
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Circle className={cn("w-2 h-2 fill-current", color)} />
<span className="flex items-center gap-1.5 text-xs text-muted-foreground whitespace-nowrap">
<Circle className={cn("w-2 h-2 fill-current shrink-0", color)} />
{label}
</span>
)
}
export function StreamCard({ stream, status, onRefresh }: Props) {
const router = useRouter()
const [menuOpen, setMenuOpen] = useState(false)
const [loading, setLoading] = useState<string | null>(null)
function copyToClipboard(text: string) {
if (navigator.clipboard && window.isSecureContext) {
return navigator.clipboard.writeText(text)
}
const el = document.createElement("textarea")
el.value = text
el.style.position = "fixed"
el.style.opacity = "0"
document.body.appendChild(el)
el.focus()
el.select()
document.execCommand("copy")
document.body.removeChild(el)
return Promise.resolve()
}
async function action(act: string) {
setLoading(act)
const CARD_WIDTHS = { sm: "max-w-[200px]", md: "max-w-[240px]", lg: "max-w-[300px]" }
export function StreamCard({ stream, status, localStatus, cardSize = "sm", onRefresh, onLocalStatus }: Props) {
const [menuOpen, setMenuOpen] = useState(false)
const [copied, setCopied] = useState(false)
const [thumbKey, setThumbKey] = useState(0)
const [thumbError, setThumbError] = useState(false)
const [thumbCapturing, setThumbCapturing] = useState(false)
useEffect(() => {
if (!thumbError || thumbCapturing) return
const interval = setInterval(() => setThumbKey((k) => k + 1), 15000)
return () => clearInterval(interval)
}, [thumbError, thumbCapturing])
async function action(act: string, optimisticStatus: string) {
onLocalStatus(stream.id, optimisticStatus)
setMenuOpen(false)
await fetch(`/api/streams/${stream.id}/${act}`, { method: "POST" })
setLoading(null)
onRefresh()
setTimeout(() => onLocalStatus(stream.id, null), 15000)
}
async function remove() {
if (!confirm(`Deletar stream "${stream.name}"?`)) return
if (!confirm(`Delete stream "${stream.name}"?`)) return
await fetch(`/api/streams/${stream.id}`, { method: "DELETE" })
onRefresh()
}
function openVNC() {
const host = window.location.hostname
window.open(`http://${host}:${stream.novncPort}/vnc.html`, "_blank")
const token = encodeURIComponent(`token=${stream.id}`)
window.open(`http://${window.location.hostname}:6080/vnc.html?autoconnect=true&path=websockify%3F${token}`, "_blank")
}
function copyRTMP() {
const url = `rtmp://${window.location.hostname}:1935/live/${stream.id}`
copyToClipboard(url).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
})
}
async function refreshThumb() {
setMenuOpen(false)
setThumbCapturing(true)
setThumbError(false)
await fetch(`/api/streams/${stream.id}/thumb`, { method: "POST" })
// 5s delay in backend + a few seconds for ffmpeg to process
setTimeout(() => {
setThumbKey((k) => k + 1)
setThumbCapturing(false)
}, 9000)
}
function play(mode: string) {
window.location.href = `/player/${stream.id}?mode=${mode}`
}
const playBtn = "w-full flex items-center gap-2 text-xs px-3 py-2 rounded border border-border bg-muted hover:bg-[#2a2a2a] active:bg-[#333] transition-colors cursor-pointer"
const menuItem = "w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-[#2a2a2a] active:bg-[#333] transition-colors cursor-pointer"
return (
<div className="relative rounded-lg border border-border bg-card p-4 flex flex-col gap-3">
{/* Header */}
<div className={cn("relative rounded-lg border border-border bg-card p-3 flex flex-col gap-2.5 w-full", CARD_WIDTHS[cardSize])}>
{/* Thumbnail */}
<div className="w-full aspect-video rounded overflow-hidden bg-muted flex items-center justify-center">
{thumbCapturing ? (
<span className="text-xs text-muted-foreground animate-pulse">Capturing...</span>
) : thumbError ? (
<Video className="w-5 h-5 text-muted-foreground/25" />
) : (
<img
key={thumbKey}
src={`/api/streams/${stream.id}/thumb?t=${thumbKey}`}
className="w-full h-full object-cover"
onError={() => setThumbError(true)}
onLoad={() => setThumbError(false)}
/>
)}
</div>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="font-semibold truncate">{stream.name}</p>
<p className="font-semibold text-sm truncate">{stream.name}</p>
<p className="text-xs text-muted-foreground font-mono truncate">{stream.id}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<StatusBadge status={status} />
<button
onClick={() => setMenuOpen((v) => !v)}
className="p-1 rounded hover:bg-accent transition-colors"
>
<div className="flex items-center gap-1.5 shrink-0">
<StatusBadge status={status} localStatus={localStatus} />
<button onClick={() => setMenuOpen((v) => !v)} className="p-1 rounded hover:bg-[#2a2a2a] transition-colors cursor-pointer">
<MoreHorizontal className="w-4 h-4" />
</button>
</div>
</div>
{/* URL */}
<p className="text-xs text-muted-foreground truncate" title={stream.url}>{stream.url}</p>
{/* Play buttons */}
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => router.push(`/player/${stream.id}?mode=hls`)}
className="flex items-center justify-center gap-1.5 text-xs px-2 py-1.5 rounded bg-accent hover:bg-accent/80 transition-colors"
>
<Play className="w-3 h-3" /> Play HLS
</button>
<button
onClick={() => router.push(`/player/${stream.id}?mode=html`)}
className="flex items-center justify-center gap-1.5 text-xs px-2 py-1.5 rounded bg-accent hover:bg-accent/80 transition-colors"
>
<Globe className="w-3 h-3" /> Play HTML
</button>
<button
onClick={() => router.push(`/player/${stream.id}?mode=m3u8`)}
className="flex items-center justify-center gap-1.5 text-xs px-2 py-1.5 rounded bg-accent hover:bg-accent/80 transition-colors"
>
<FileVideo className="w-3 h-3" /> Play m3u8
</button>
<button
onClick={openVNC}
className="flex items-center justify-center gap-1.5 text-xs px-2 py-1.5 rounded bg-accent hover:bg-accent/80 transition-colors"
>
<Monitor className="w-3 h-3" /> Open VNC
</button>
<div className="flex flex-col gap-1.5">
<button onClick={() => play("hls")} className={playBtn}><Play className="w-3 h-3 shrink-0" /> Play Stream</button>
<button onClick={() => play("html")} className={playBtn}><Globe className="w-3 h-3 shrink-0" /> Run HTML</button>
<button onClick={openVNC} className={playBtn}><Monitor className="w-3 h-3 shrink-0" /> Open VNC</button>
</div>
{/* Dropdown menu */}
{menuOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setMenuOpen(false)} />
<div className="absolute top-10 right-4 z-20 min-w-[160px] rounded-lg border border-border bg-card shadow-lg overflow-hidden">
<button
onClick={() => { setMenuOpen(false); router.push(`/streams/${stream.id}/edit`) }}
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-accent transition-colors"
>
<Pencil className="w-3.5 h-3.5" /> Editar
<div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />
<div className="absolute top-10 right-2 z-50 min-w-[180px] rounded-lg border border-border shadow-2xl overflow-hidden"
style={{ background: "#1c1c1c" }}>
<button onClick={() => { setMenuOpen(false); window.location.href = `/streams/${stream.id}/edit` }} className={menuItem}>
<Pencil className="w-3.5 h-3.5" /> Edit
</button>
<button
onClick={() => { setMenuOpen(false); action("restart") }}
disabled={!!loading}
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-accent transition-colors"
>
<button onClick={() => action("restart", "restarting")} className={menuItem}>
<RotateCcw className="w-3.5 h-3.5" /> Restart
</button>
{status?.ffmpeg === "RUNNING" ? (
<button
onClick={() => { setMenuOpen(false); action("stop") }}
disabled={!!loading}
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-accent transition-colors"
>
{status?.ffmpeg === "RUNNING" || localStatus === "restarting" ? (
<button onClick={() => action("stop", "stopping")} className={menuItem}>
<Square className="w-3.5 h-3.5" /> Stop
</button>
) : (
<button
onClick={() => { setMenuOpen(false); action("start") }}
disabled={!!loading}
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-accent transition-colors"
>
<button onClick={() => action("start", "starting")} className={menuItem}>
<Play className="w-3.5 h-3.5" /> Start
</button>
)}
<button onClick={() => { setMenuOpen(false); copyRTMP() }} className={menuItem}>
{copied ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
{copied ? "Copied!" : "Copy RTMP"}
</button>
<div className="border-t border-border" />
<button
onClick={() => { setMenuOpen(false); remove() }}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-destructive hover:bg-accent transition-colors"
>
<Trash2 className="w-3.5 h-3.5" /> Deletar
<button onClick={refreshThumb} disabled={thumbCapturing} className={cn(menuItem, thumbCapturing && "opacity-50")}>
<ImageUp className="w-3.5 h-3.5" />
{thumbCapturing ? "Capturing..." : "Refresh thumbnail"}
</button>
<div className="border-t border-border" />
<button onClick={() => { setMenuOpen(false); remove() }} className={cn(menuItem, "text-destructive")}>
<Trash2 className="w-3.5 h-3.5" /> Delete
</button>
</div>
</>
+173 -93
View File
@@ -2,51 +2,82 @@
import { useState } from "react"
import { useRouter } from "next/navigation"
import { ChevronDown, ChevronRight, TriangleAlert } from "lucide-react"
import { cn } from "@/lib/utils"
import { STREAM_DEFAULTS, type Stream, type StreamCreate, type StreamUpdate } from "@/types/stream"
interface Props {
initial?: Stream
}
interface Props { initial?: Stream }
const SLUG_RE = /^[a-z0-9-]+$/
const PRESETS = ["ultrafast", "superfast", "veryfast", "faster", "fast", "medium"]
const TUNES = ["stillimage", "zerolatency", "film", "animation"]
const TUNES = ["stillimage", "animation", "zerolatency", "film", "fastdecode"]
function Field({ label, error, children }: { label: string; error?: string; children: React.ReactNode }) {
const TOOLTIPS = {
id: "Unique identifier used in URLs, RTMP path (/live/{id}) and HLS path. Lowercase letters, numbers and hyphens only.",
name: "Display name shown in the interface. Does not affect the stream.",
url: "The URL Chromium will open and capture as a video stream.",
user: "Username for auto-login. Chromium will type this into the first form field after the page loads.",
pass: "Password for auto-login. Typed after the username field.",
resolution: "Virtual display (Xvfb) and Chromium window size. This is what ffmpeg captures. Format: WIDTHxHEIGHT.",
scale: "Output video resolution. Can be lower than the capture resolution to reduce bandwidth. Format: WIDTHxHEIGHT.",
fps: "Frames per second for capture and encoding. Higher values produce smoother video but increase CPU and bandwidth usage.",
bitrate: "Target video bitrate. Higher values improve quality at the cost of bandwidth. Examples: 1500k, 3000k.",
bufsize: "Encoder buffer size. Controls bitrate variance. Recommended: 2× bitrate.",
preset: "Encoding speed vs compression trade-off. Faster presets use less CPU but produce larger files at the same quality. From fastest to slowest: ultrafast → superfast → veryfast → faster → fast → medium.",
tune: "Optimizes the encoder for your content type.\n• stillimage — best for static or slow-changing content\n• animation — solid colors, UI, charts\n• zerolatency — minimizes encoding delay\n• film — natural video with grain\n• fastdecode — easier to decode on the client side",
delay: "Seconds to wait after Chromium starts before ffmpeg begins capturing. Gives the page time to fully load and render.",
gop: "Keyframe interval in frames. Recommended: 2× FPS. Affects HLS segment alignment and seek accuracy. Auto-calculated from FPS unless manually changed.",
threads: "Number of ffmpeg encoding threads. 0 = auto-detect (recommended). Increasing this can reduce latency on multi-core systems at the cost of slightly reduced compression efficiency.",
}
function Tooltip({ text }: { text: string }) {
return (
<div className="relative group inline-flex items-center">
<span className="w-3.5 h-3.5 rounded-full border border-muted-foreground/40 text-muted-foreground/70 text-[10px] flex items-center justify-center cursor-help select-none leading-none flex-shrink-0">?</span>
<div className="absolute bottom-full left-0 mb-2 z-50 hidden group-hover:block w-56 rounded bg-[#1c1c1c] border border-border px-2.5 py-2 text-xs text-muted-foreground shadow-xl pointer-events-none whitespace-pre-line">
{text}
</div>
</div>
)
}
function Field({ label, tooltip, required, error, children }: {
label: string
tooltip?: string
required?: boolean
error?: string
children: React.ReactNode
}) {
return (
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">{label}</label>
<div className="flex items-center gap-1.5">
<label className="text-sm font-medium">
{label}{required && <span className="text-destructive ml-0.5">*</span>}
</label>
{tooltip && <Tooltip text={tooltip} />}
</div>
{children}
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
)
}
const inputClass = "w-full rounded border border-border bg-muted px-3 py-2 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring transition-colors"
const selectClass = cn(inputClass, "appearance-none bg-muted text-foreground")
function Input({ className, ...props }: React.InputHTMLAttributes<HTMLInputElement>) {
return <input className={cn(inputClass, className)} {...props} />
}
function Select({ className, children, ...props }: React.SelectHTMLAttributes<HTMLSelectElement>) {
return (
<input
className={cn(
"w-full rounded border border-border bg-muted px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring transition-colors",
className
)}
{...props}
/>
<select className={cn(selectClass, className)} {...props}>
{children}
</select>
)
}
function Select({ className, ...props }: React.SelectHTMLAttributes<HTMLSelectElement>) {
return (
<select
className={cn(
"w-full rounded border border-border bg-muted px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring transition-colors",
className
)}
{...props}
/>
)
}
function normalizeScaleDisplay(s: string) { return s.replace(":", "x") }
export function StreamForm({ initial }: Props) {
const router = useRouter()
@@ -60,31 +91,58 @@ export function StreamForm({ initial }: Props) {
pass: initial?.pass ?? "",
...STREAM_DEFAULTS,
...(initial ? {
delay: initial.delay,
delay: initial.delay,
resolution: initial.resolution,
scale: initial.scale,
fps: initial.fps,
bitrate: initial.bitrate,
bufsize: initial.bufsize,
preset: initial.preset,
tune: initial.tune,
gop: initial.gop,
scale: normalizeScaleDisplay(initial.scale),
fps: initial.fps,
bitrate: initial.bitrate,
bufsize: initial.bufsize,
preset: initial.preset,
tune: initial.tune,
gop: initial.gop,
threads: initial.threads ?? 0,
} : {}),
})
const [gopManuallyEdited, setGopManuallyEdited] = useState(isEdit)
const [advancedOpen, setAdvancedOpen] = useState(false)
const [errors, setErrors] = useState<Record<string, string>>({})
const [saving, setSaving] = useState(false)
function set(key: keyof StreamCreate, value: string | number) {
setForm((f) => ({ ...f, [key]: value }))
setErrors((e) => { const n = { ...e }; delete n[key]; return n })
setErrors((e) => { const n = { ...e }; delete n[key as string]; return n })
}
function setFps(value: number) {
set("fps", value)
if (!gopManuallyEdited) set("gop", value * 2)
}
function setGop(value: number) {
setGopManuallyEdited(true)
set("gop", value)
}
function setScale(value: string) {
set("scale", value.replace(":", "x"))
if (value && !/^\d+[x:]\d+$/.test(value)) {
setErrors((e) => ({ ...e, scale: "Invalid format. Use 1280x720" }))
} else {
setErrors((e) => { const n = { ...e }; delete n.scale; return n })
}
}
function validate(): boolean {
const e: Record<string, string> = {}
if (!isEdit && !SLUG_RE.test(form.id)) e.id = "Apenas letras minúsculas, números e hífen"
if (!form.name.trim()) e.name = "Obrigatório"
if (!form.url.trim()) e.url = "Obrigatório"
if (!isEdit && (!form.id || !SLUG_RE.test(form.id))) e.id = "Lowercase letters, numbers and hyphens only"
if (!isEdit && !form.id) e.id = "Required"
if (!form.name.trim()) e.name = "Required"
if (!form.url.trim()) e.url = "Required"
if (!form.resolution.trim()) e.resolution = "Required"
if (!form.scale.trim() || !/^\d+[x:]\d+$/.test(form.scale)) e.scale = "Invalid format. Use 1280x720"
if (!form.bitrate.trim()) e.bitrate = "Required"
if (!form.bufsize.trim()) e.bufsize = "Required"
setErrors(e)
return Object.keys(e).length === 0
}
@@ -94,11 +152,10 @@ export function StreamForm({ initial }: Props) {
setSaving(true)
if (isEdit) {
const body: StreamUpdate = { ...form }
await fetch(`/api/streams/${initial!.id}`, {
fetch(`/api/streams/${initial!.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
body: JSON.stringify(form as StreamUpdate),
})
} else {
await fetch("/api/streams", {
@@ -108,105 +165,128 @@ export function StreamForm({ initial }: Props) {
})
}
setSaving(false)
router.push("/")
window.location.href = "/"
}
return (
<div className="min-h-screen flex flex-col">
<header className="border-b border-border px-6 py-4 flex items-center gap-4">
<button onClick={() => router.push("/")} className="text-sm text-muted-foreground hover:text-foreground transition-colors">
Voltar
Back
</button>
<h1 className="text-lg font-semibold">{isEdit ? `Editar${initial!.name}` : "Nova stream"}</h1>
<h1 className="text-lg font-semibold">{isEdit ? `Edit — ${initial!.name}` : "New stream"}</h1>
</header>
<main className="flex-1 p-6 max-w-2xl mx-auto w-full flex flex-col gap-8">
<main className="flex-1 p-6 max-w-2xl mx-auto w-full flex flex-col gap-6">
{/* Identificação */}
{/* Identification */}
<section className="flex flex-col gap-4">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">Identificação</h2>
<Field label="ID (slug)" error={errors.id}>
<Input
value={form.id}
disabled={isEdit}
placeholder="mapa-zabbix"
onChange={(e) => set("id", e.target.value.toLowerCase())}
/>
<h2 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Identification</h2>
<Field label="ID" tooltip={TOOLTIPS.id} required error={errors.id}>
<Input value={form.id} disabled={isEdit} placeholder="my-stream" onChange={(e) => set("id", e.target.value.toLowerCase())} />
</Field>
<Field label="Nome" error={errors.name}>
<Input value={form.name} placeholder="Mapa Zabbix" onChange={(e) => set("name", e.target.value)} />
<Field label="Name" tooltip={TOOLTIPS.name} required error={errors.name}>
<Input value={form.name} placeholder="My Stream" onChange={(e) => set("name", e.target.value)} />
</Field>
</section>
{/* Fonte */}
<hr className="border-border" />
{/* Source */}
<section className="flex flex-col gap-4">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">Fonte</h2>
<Field label="URL" error={errors.url}>
<h2 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Source</h2>
<Field label="URL" tooltip={TOOLTIPS.url} required error={errors.url}>
<Input value={form.url} placeholder="https://..." onChange={(e) => set("url", e.target.value)} />
</Field>
<div className="grid grid-cols-2 gap-4">
<Field label="Usuário (opcional)">
<Field label="Username" tooltip={TOOLTIPS.user}>
<Input value={form.user ?? ""} onChange={(e) => set("user", e.target.value)} />
</Field>
<Field label="Senha (opcional)">
<Field label="Password" tooltip={TOOLTIPS.pass}>
<Input type="password" value={form.pass ?? ""} onChange={(e) => set("pass", e.target.value)} />
</Field>
</div>
</section>
{/* FFmpeg */}
<hr className="border-border" />
{/* Stream */}
<section className="flex flex-col gap-4">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">FFmpeg / Xvfb</h2>
<h2 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Stream</h2>
<div className="grid grid-cols-2 gap-4">
<Field label="Resolução (Xvfb/Chrome)">
<Field label="Resolution" tooltip={TOOLTIPS.resolution} required error={errors.resolution}>
<Input value={form.resolution} placeholder="1920x1080" onChange={(e) => set("resolution", e.target.value)} />
</Field>
<Field label="Scale (stream output)">
<Input value={form.scale} placeholder="1280:720" onChange={(e) => set("scale", e.target.value)} />
<Field label="Scale" tooltip={TOOLTIPS.scale} required error={errors.scale}>
<Input value={form.scale} placeholder="1280x720" onChange={(e) => setScale(e.target.value)} />
</Field>
<Field label="FPS">
<Input type="number" value={form.fps} onChange={(e) => set("fps", Number(e.target.value))} />
</div>
<div className="grid grid-cols-3 gap-4">
<Field label="FPS" tooltip={TOOLTIPS.fps} required>
<Input type="number" min={1} value={form.fps} onChange={(e) => setFps(Number(e.target.value))} />
</Field>
<Field label="Delay (s)">
<Input type="number" value={form.delay} onChange={(e) => set("delay", Number(e.target.value))} />
</Field>
<Field label="Bitrate">
<Field label="Bitrate" tooltip={TOOLTIPS.bitrate} required error={errors.bitrate}>
<Input value={form.bitrate} placeholder="1500k" onChange={(e) => set("bitrate", e.target.value)} />
</Field>
<Field label="Bufsize">
<Input value={form.bufsize} placeholder="1500k" onChange={(e) => set("bufsize", e.target.value)} />
<Field label="Bufsize" tooltip={TOOLTIPS.bufsize} required error={errors.bufsize}>
<Input value={form.bufsize} placeholder="3000k" onChange={(e) => set("bufsize", e.target.value)} />
</Field>
<Field label="GOP">
<Input type="number" value={form.gop} onChange={(e) => set("gop", Number(e.target.value))} />
</Field>
<Field label="Preset">
</div>
<div className="grid grid-cols-2 gap-4">
<Field label="Preset" tooltip={TOOLTIPS.preset} required>
<Select value={form.preset} onChange={(e) => set("preset", e.target.value)}>
{PRESETS.map((p) => <option key={p}>{p}</option>)}
{PRESETS.map((p) => <option key={p} value={p} style={{ background: "#1a1a1a", color: "#ededed" }}>{p}</option>)}
</Select>
</Field>
<Field label="Tune">
<Field label="Tune" tooltip={TOOLTIPS.tune} required>
<Select value={form.tune} onChange={(e) => set("tune", e.target.value)}>
{TUNES.map((t) => <option key={t}>{t}</option>)}
{TUNES.map((t) => <option key={t} value={t} style={{ background: "#1a1a1a", color: "#ededed" }}>{t}</option>)}
</Select>
</Field>
</div>
{/* Advanced */}
<div className="flex flex-col gap-3 mt-1">
<button
type="button"
onClick={() => setAdvancedOpen((v) => !v)}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer w-fit"
>
{advancedOpen ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
Advanced settings
</button>
{advancedOpen && (
<div className="flex flex-col gap-4 rounded border border-border bg-muted/30 p-4">
<div className="flex items-start gap-2 text-xs text-yellow-500/80">
<TriangleAlert className="w-3.5 h-3.5 mt-0.5 shrink-0" />
<span>Changing these settings incorrectly may break the stream.</span>
</div>
<div className="grid grid-cols-3 gap-4">
<Field label="Boot delay (s)" tooltip={TOOLTIPS.delay}>
<Input type="number" min={0} value={form.delay} onChange={(e) => set("delay", Number(e.target.value))} />
</Field>
<Field label="GOP" tooltip={TOOLTIPS.gop}>
<Input type="number" min={1} value={form.gop} onChange={(e) => setGop(Number(e.target.value))} />
</Field>
<Field label="Threads" tooltip={TOOLTIPS.threads}>
<Input type="number" min={0} value={form.threads ?? 0} onChange={(e) => set("threads", Number(e.target.value))} />
</Field>
</div>
</div>
)}
</div>
</section>
{/* Actions */}
<div className="flex gap-3 pb-8">
<button
onClick={() => router.push("/")}
className="px-4 py-2 rounded border border-border text-sm hover:bg-accent transition-colors"
>
Cancelar
<button onClick={() => router.push("/")} className="px-4 py-2 rounded border border-border text-sm hover:bg-[#2a2a2a] active:bg-[#333] transition-colors cursor-pointer">
Cancel
</button>
<button
onClick={submit}
disabled={saving}
className="px-4 py-2 rounded bg-primary text-primary-foreground text-sm hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{saving ? "Salvando..." : isEdit ? "Salvar alterações" : "Criar stream"}
<button onClick={submit} disabled={saving} className="px-4 py-2 rounded border border-primary bg-primary text-primary-foreground text-sm hover:bg-[#2a2a2a] hover:text-foreground hover:border-border active:bg-[#333] transition-colors disabled:opacity-50 cursor-pointer">
{saving ? "Saving..." : isEdit ? "Save changes" : "Create stream"}
</button>
</div>
</main>