updated tasks

This commit is contained in:
Linrador 2026-03-14 18:00:28 +01:00
parent e7a13652f5
commit 5e5b8025e8
19 changed files with 539 additions and 124 deletions

View File

@ -715,12 +715,10 @@ func buildSegmentsFromAnalyzeHits(hits []analyzeHit, duration float64) []aiSegme
for i := 1; i < len(out); i++ {
n := out[i]
const mergeGapSeconds = 15.0
sameLabel := strings.EqualFold(cur.Label, n.Label)
nearEnough := n.StartSeconds <= cur.EndSeconds+mergeGapSeconds
if sameLabel && nearEnough {
// Direkt aufeinanderfolgende Segmente mit gleichem Label immer mergen,
// unabhängig von der Lücke. Sobald ein anderes Label dazwischen liegt,
// wird automatisch nicht gemergt, weil wir nur mit dem direkten Nachfolger arbeiten.
if strings.EqualFold(cur.Label, n.Label) {
if n.StartSeconds < cur.StartSeconds {
cur.StartSeconds = n.StartSeconds
}

View File

@ -473,7 +473,13 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
cellW,
cellH,
); err != nil {
fmt.Println("⚠️ preview sprite:", err)
if sfi, statErr := os.Stat(spritePath); statErr == nil && sfi != nil && !sfi.IsDir() && sfi.Size() > 0 {
// Sprite existiert am Ende trotzdem -> Warnung unterdrücken
return
}
// Optional: nur kurze Meldung statt voller ffmpeg-Ausgabe
//fmt.Printf("⚠️ preview sprite failed for %s\n", videoPath)
return
}

View File

@ -78,14 +78,29 @@ func generatePreviewSpriteWebP(
base := strings.TrimSuffix(outPath, ext)
tmpPath := base + ".tmp" + ext
// alte temp-Datei vorsichtshalber vorher entfernen
_ = os.Remove(tmpPath)
// bei JEDEM Fehler temp wieder aufräumen
renameOK := false
defer func() {
if !renameOK {
_ = os.Remove(tmpPath)
}
}()
ffmpegPath := strings.TrimSpace(getSettings().FFmpegPath)
if ffmpegPath == "" {
ffmpegPath = "ffmpeg"
}
// robustere Filterkette
vf := fmt.Sprintf(
"fps=1/%g,scale=%d:%d:force_original_aspect_ratio=decrease:flags=lanczos,"+
"pad=%d:%d:(ow-iw)/2:(oh-ih)/2:black,tile=%dx%d:margin=0:padding=0",
"fps=1/%f,"+
"scale=%d:%d:force_original_aspect_ratio=decrease:flags=bilinear,"+
"pad=%d:%d:(ow-iw)/2:(oh-ih)/2:black,"+
"setsar=1,"+
"tile=%dx%d:margin=0:padding=0",
stepSec,
cellW, cellH,
cellW, cellH,
@ -96,18 +111,17 @@ func generatePreviewSpriteWebP(
ctx,
ffmpegPath,
"-hide_banner",
"-loglevel", "error",
"-y",
"-i", videoPath,
"-an",
"-sn",
"-threads", "1",
"-vf", vf,
"-vsync", "vfr",
"-frames:v", "1",
"-c:v", "libwebp",
"-lossless", "0",
"-compression_level", "6",
"-q:v", "80",
"-compression_level", "3",
"-q:v", "65",
"-f", "webp",
tmpPath,
)
@ -115,10 +129,17 @@ func generatePreviewSpriteWebP(
out, err := cmd.CombinedOutput()
if err != nil {
msg := strings.TrimSpace(string(out))
if msg != "" {
return fmt.Errorf("ffmpeg sprite failed: %w: %s", err, msg)
if msg == "" {
msg = "(keine ffmpeg-ausgabe)"
}
return fmt.Errorf("ffmpeg sprite failed: %w", err)
return fmt.Errorf(
"ffmpeg sprite failed for %q -> %q: %w | args=%q | output=%s",
videoPath,
tmpPath,
err,
strings.Join(cmd.Args, " "),
msg,
)
}
fi, err := os.Stat(tmpPath)
@ -126,15 +147,14 @@ func generatePreviewSpriteWebP(
return fmt.Errorf("sprite temp stat failed: %w", err)
}
if fi.IsDir() || fi.Size() <= 0 {
_ = os.Remove(tmpPath)
return fmt.Errorf("sprite temp file invalid/empty")
}
_ = os.Remove(outPath)
if err := os.Rename(tmpPath, outPath); err != nil {
_ = os.Remove(tmpPath)
return fmt.Errorf("sprite rename failed: %w", err)
}
renameOK = true
return nil
}

View File

@ -430,7 +430,7 @@ func startPreviewHLS(ctx context.Context, job *RecordJob, m3u8URL, previewDir, h
publishJobUpsert(job)
}
fmt.Printf("⚠️ preview hq ffmpeg failed: %v (%s)\n", err, st)
//fmt.Printf("⚠️ preview hq ffmpeg failed: %v (%s)\n", err, st)
}
jobsMu.Lock()

View File

@ -17,7 +17,6 @@ import (
"image/jpeg"
"image/png"
"io"
"log"
"math"
"math/rand"
"net/http"
@ -621,7 +620,6 @@ func coverBatchEnter(force bool) {
coverBatchErrors = 0
coverBatchNoThumb = 0
coverBatchDecodeErr = 0
log.Printf("[cover] BATCH START")
}
coverBatchInflight++
@ -648,20 +646,6 @@ func coverBatchLeave(outcome string, status int) {
}
coverBatchInflight--
if coverBatchInflight <= 0 {
dur := time.Since(coverBatchStarted).Round(time.Millisecond)
log.Printf(
"[cover] BATCH END total=%d miss=%d forced=%d errors=%d noThumb=%d decodeFail=%d took=%s",
coverBatchTotal,
coverBatchMiss,
coverBatchForced,
coverBatchErrors,
coverBatchNoThumb,
coverBatchDecodeErr,
dur,
)
coverBatchInflight = 0
}
}
var reModelFromStem = regexp.MustCompile(`^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}`)

View File

@ -8,12 +8,12 @@
"autoStartAddedDownloads": false,
"useChaturbateApi": true,
"useMyFreeCamsWatcher": true,
"autoDeleteSmallDownloads": false,
"autoDeleteSmallDownloadsBelowMB": 50,
"autoDeleteSmallDownloads": true,
"autoDeleteSmallDownloadsBelowMB": 200,
"lowDiskPauseBelowGB": 5,
"blurPreviews": false,
"teaserPlayback": "hover",
"teaserAudio": false,
"enableNotifications": true,
"encryptedCookies": "cjdwemq1R/rQnAhNIj9/UR8Fhm5jdcsx1Dwg+kU4J2jDOB57PrIuGJjoAJGA5GZOHqvFSQYXkvksqi2MbfCInyHcO67Hdk5p4VipPnirCaDEOn+sK0eYtT9MzoRiDF0i2InEdl3JotpHscuJEjibV1kZ5Z8DrakwI7XbcNrEa5KE0opmhLAKX38CYSuWA4laBHMcXkUdP46CiTJzrtRVjVXLkuIiesffRF47qzmWyqUVjc4mkUPv4C2X6MK0KjEZKHAMR+bKd+aWZNoh04EK0ACVf1UTfbk14MivD0x5DKuw4ERVttkNZ5646W1jqx+2xCPr1IYXjci7mEqWwkhctJdjI907vyR2Zwy5KovOiaQ8FLuJBUe51nq6zDKqOZBwSc5wHo/UCSLqAjfIGrcEcuu+1r/xNQCKw/gnkCpcM5ysd6S0ruREYYrdVn4wcA4+5TsU1bJxwESnCBZNjE/IFbc0ElBmKobFrS11dDCuCpycDxQ35np03jXAm/ysvrMqcsaWldZfqGs="
"encryptedCookies": "3EPvjFs7b4JIdKUT3G2fOZKc26YmYL283VVHmG+dCLAUe+xURUkM0rZMCrf8Ug7eyXZOreLItE09FSCZrA3afNgmHg5c648hhvYhkv/mW7J8ap4tMz1m8ahcvcfoLhrx5AqU4MWXnqz+VHHglqkfPn9aFcrgFnWbOPHJ1A3S77cs2gWR0/shqn3l8nk6HmIWqJ1TnAA6z2CYDngB27sv/NflLKoujezlWitEa8wEpEW8GDSEtPjpT7X9L24wP4TK/TnxZUovaRXDDbboebk2KeKP04C5tWhhpIfKl3/ipf9dPgHdV4jLheFyczMRZN5Z6yF5WRn3NgDbdCcoldRwqgTwv1NgLri8nJKp4SGmRpGFrbq6m7/26muyGbTzsU3tniae6iYHbYrPz0pMOBLcFPxnil4yT0Xgnph+P9EYYWJxtjUXi7nsiREjHBxqU/OSogavsOjlFqJgWBBCL705R2Fap0VjlgWtJEXKu+vAlexX873uoeFzFw9niwJlNRFKJtGMjJGYE5c="
}

View File

@ -72,6 +72,7 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
api.HandleFunc("/api/generated/coverinfo/list", generatedCoverInfoList)
// Tasks
api.HandleFunc("/api/tasks/status", tasksStatusHandler)
api.HandleFunc("/api/tasks/generate-assets", tasksGenerateAssets)
// --------------------------

View File

@ -53,6 +53,10 @@ func snapshotAssetsState() AssetsTaskState {
return st
}
func getGenerateAssetsTaskStatus() AssetsTaskState {
return snapshotAssetsState()
}
func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
@ -305,7 +309,7 @@ func runGenerateMissingAssets(ctx context.Context) {
updateAssetsState(func(st *AssetsTaskState) {
st.Error = "mindestens ein Eintrag konnte nicht vollständig analysiert werden (siehe Logs)"
})
fmt.Println("⚠️ tasks generate assets analyze:", aerr)
//fmt.Println("⚠️ tasks generate assets analyze:", aerr)
}
updateAssetsState(func(st *AssetsTaskState) {

View File

@ -48,6 +48,7 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
// doneDir auflösen
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil || strings.TrimSpace(doneAbs) == "" {
setCleanupTaskError("doneDir auflösung fehlgeschlagen")
http.Error(w, "doneDir auflösung fehlgeschlagen", http.StatusBadRequest)
return
}
@ -68,6 +69,8 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
mb = 0
}
setCleanupTaskRunning("Räume auf…")
resp := cleanupResp{}
// 1) Kleine Downloads löschen (wenn mb > 0)
@ -83,6 +86,10 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
resp.GeneratedOrphansRemoved = gcStats.Removed
resp.DeletedBytesHuman = formatBytesSI(resp.DeletedBytes)
orphansTotalRemoved := resp.GeneratedOrphansRemoved
setCleanupTaskDone(fmt.Sprintf("geprüft: %d · Orphans: %d", resp.ScannedFiles, orphansTotalRemoved))
writeJSON(w, http.StatusOK, resp)
}
@ -311,7 +318,7 @@ func runGeneratedGarbageCollector() generatedGCStats {
}
}
fmt.Printf("🧹 [gc] generated/meta checked=%d removed_orphans=%d\n", checkedMeta, removedMeta)
//fmt.Printf("🧹 [gc] generated/meta checked=%d removed_orphans=%d\n", checkedMeta, removedMeta)
stats.Checked += checkedMeta
stats.Removed += removedMeta

84
backend/tasks_status.go Normal file
View File

@ -0,0 +1,84 @@
// backend\tasks_status.go
package main
import (
"encoding/json"
"net/http"
"strings"
"sync"
)
type CleanupTaskState struct {
Running bool `json:"running"`
Text string `json:"text,omitempty"`
Error string `json:"error,omitempty"`
}
type TasksStatusResponse struct {
GenerateAssets AssetsTaskState `json:"generateAssets"`
Cleanup CleanupTaskState `json:"cleanup"`
AnyRunning bool `json:"anyRunning"`
}
var (
cleanupTaskMu sync.Mutex
cleanupTaskState CleanupTaskState
)
func snapshotCleanupTaskState() CleanupTaskState {
cleanupTaskMu.Lock()
st := cleanupTaskState
cleanupTaskMu.Unlock()
return st
}
func setCleanupTaskRunning(text string) {
cleanupTaskMu.Lock()
cleanupTaskState = CleanupTaskState{
Running: true,
Text: strings.TrimSpace(text),
Error: "",
}
cleanupTaskMu.Unlock()
}
func setCleanupTaskDone(text string) {
cleanupTaskMu.Lock()
cleanupTaskState = CleanupTaskState{
Running: false,
Text: strings.TrimSpace(text),
Error: "",
}
cleanupTaskMu.Unlock()
}
func setCleanupTaskError(errText string) {
cleanupTaskMu.Lock()
cleanupTaskState = CleanupTaskState{
Running: false,
Text: "",
Error: strings.TrimSpace(errText),
}
cleanupTaskMu.Unlock()
}
func tasksStatusHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed)
return
}
assets := getGenerateAssetsTaskStatus()
cleanup := snapshotCleanupTaskState()
resp := TasksStatusResponse{
GenerateAssets: assets,
Cleanup: cleanup,
AnyRunning: assets.Running || cleanup.Running,
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(resp)
}

View File

@ -13,7 +13,17 @@ import Downloads from './components/ui/Downloads'
import ModelsTab from './components/ui/ModelsTab'
import ProgressBar from './components/ui/ProgressBar'
import ModelDetails from './components/ui/ModelDetails'
import { SignalIcon, HeartIcon, HandThumbUpIcon, EyeIcon } from '@heroicons/react/24/solid'
import {
SignalIcon,
HeartIcon,
HandThumbUpIcon,
EyeIcon,
ArrowDownTrayIcon,
ArchiveBoxIcon,
UserGroupIcon,
Squares2X2Icon,
Cog6ToothIcon,
} from '@heroicons/react/24/solid'
import PerformanceMonitor from './components/ui/PerformanceMonitor'
import { useNotify } from './components/ui/notify'
//import { startChaturbateOnlinePolling } from './lib/chaturbateOnlinePoller'
@ -448,6 +458,7 @@ export default function App() {
const [cookies, setCookies] = useState<Record<string, string>>({})
const [cookiesLoaded, setCookiesLoaded] = useState(false)
const [selectedTab, setSelectedTab] = useState('running')
const [settingsTaskRunning, setSettingsTaskRunning] = useState(false)
const [playerJob, setPlayerJob] = useState<RecordJob | null>(null)
const [playerExpanded, setPlayerExpanded] = useState(false)
const [playerStartAtSec, setPlayerStartAtSec] = useState<number | null>(null)
@ -543,6 +554,56 @@ export default function App() {
}
}, [])
useEffect(() => {
if (!authed) return
let cancelled = false
let timer: number | null = null
const loadSettingsTaskState = async () => {
try {
const res = await fetch('/api/tasks/generate-assets', { cache: 'no-store' as any })
if (!res.ok) {
if (!cancelled) setSettingsTaskRunning(false)
return
}
const data = await res.json().catch(() => null)
if (cancelled) return
const running = Boolean(data?.running)
setSettingsTaskRunning(running)
} catch {
if (!cancelled) setSettingsTaskRunning(false)
}
}
const schedule = (ms: number) => {
if (cancelled) return
timer = window.setTimeout(async () => {
await loadSettingsTaskState()
schedule(document.hidden ? 8000 : 3000)
}, ms)
}
void loadSettingsTaskState()
schedule(3000)
const onVis = () => {
if (!document.hidden) {
void loadSettingsTaskState()
}
}
document.addEventListener('visibilitychange', onVis)
return () => {
cancelled = true
if (timer != null) window.clearTimeout(timer)
document.removeEventListener('visibilitychange', onVis)
}
}, [authed])
useEffect(() => {
void checkAuth()
}, [checkAuth])
@ -2051,11 +2112,35 @@ export default function App() {
}, [jobs, pendingWatchedRooms])
const tabs: TabItem[] = [
{ id: 'running', label: 'Laufende Downloads', count: runningTabCount },
{ id: 'finished', label: 'Abgeschlossene Downloads', count: doneCount },
{ id: 'models', label: 'Models', count: modelsCount },
{ id: 'categories', label: 'Kategorien' },
{ id: 'settings', label: 'Einstellungen' },
{
id: 'running',
label: 'Laufende Downloads',
count: runningTabCount,
icon: ArrowDownTrayIcon,
},
{
id: 'finished',
label: 'Abgeschlossene Downloads',
count: doneCount,
icon: ArchiveBoxIcon,
},
{
id: 'models',
label: 'Models',
count: modelsCount,
icon: UserGroupIcon,
},
{
id: 'categories',
label: 'Kategorien',
icon: Squares2X2Icon,
},
{
id: 'settings',
label: 'Einstellungen',
icon: Cog6ToothIcon,
spinIcon: settingsTaskRunning,
},
]
const canStart = useMemo(() => sourceUrl.trim().length > 0 && !busy, [sourceUrl, busy])
@ -3256,7 +3341,12 @@ export default function App() {
{selectedTab === 'models' ? <ModelsTab /> : null}
{selectedTab === 'categories' ? <CategoriesTab /> : null}
{selectedTab === 'settings' ? <RecorderSettings onAssetsGenerated={bumpAssets} /> : null}
{selectedTab === 'settings' ? (
<RecorderSettings
onAssetsGenerated={bumpAssets}
onTaskRunningChange={setSettingsTaskRunning}
/>
) : null}
</main>
<CookieModal

View File

@ -62,13 +62,6 @@ function modelKeyFromFilename(fileOrPath: string): string | null {
return stem ? stem.trim() : null
}
// ✅ passt zu Backend: generated/meta/<id>/preview.webp
function thumbUrlFromOutput(output: string): string | null {
const id = assetIdFromOutput(output)
if (!id) return null
return `/generated/meta/${encodeURIComponent(id)}/preview.webp`
}
async function ensureCover(category: string, thumbPath: string, modelName: string | null, refresh: boolean) {
const m = (modelName || '').trim()
const url =
@ -363,12 +356,27 @@ export default function CategoriesTab() {
// ✅ random Pick -> Thumb -> ensureCover (generiert Cover garantiert ohne 404, sofern Thumb existiert)
const pick = list[Math.floor(Math.random() * list.length)]
const thumb = thumbUrlFromOutput(pick)
if (!thumb) {
return { tag: r.tag, ok: true, status: 0, text: 'skipped (no thumb url)' }
const id = assetIdFromOutput(pick)
if (!id) {
return { tag: r.tag, ok: true, status: 0, text: 'skipped (no asset id)' }
}
// Preview aktiv generieren / aufwecken
const previewRes = await fetch(`/api/preview?id=${encodeURIComponent(id)}`, {
method: 'GET',
cache: 'no-store',
})
if (!previewRes.ok) {
return {
tag: r.tag,
ok: false,
status: previewRes.status,
text: `preview generation failed: HTTP ${previewRes.status}`,
}
}
const thumb = `/generated/meta/${encodeURIComponent(id)}/preview.webp`
const model = modelKeyFromFilename(pick)
await ensureCover(r.tag, thumb, model, true)

View File

@ -1166,15 +1166,18 @@ export default function FinishedDownloadsCardsView({
}
const mobileStackDepth = 3
const mobilePreloadDepth = mobileStackDepth + 3
// Sichtbarer Stack bleibt bei 3 Karten
const mobileVisibleStackRows = isSmall ? rows.slice(0, mobileStackDepth) : []
// ✅ 4 Karten mounten, aber nur 3 sichtbar stacken
const mobileLoadedStackRows = isSmall ? rows.slice(0, mobilePreloadDepth) : []
const mobileVisibleStackRows = mobileLoadedStackRows.slice(0, mobileStackDepth)
const mobilePreloadedRow = mobileLoadedStackRows[mobileStackDepth] ?? null
// größerer Peek-Offset für stärkeren Stack-Effekt
const stackPeekOffsetPx = 15
// weil wir nach OBEN stacken, brauchen wir oben Platz
const stackExtraTopPx = (Math.min(mobileVisibleStackRows.length, mobileStackDepth) - 1)
const stackExtraTopPx = Math.max(0, Math.min(mobileVisibleStackRows.length, mobileStackDepth) - 1)
return (
<div className="relative">
@ -1215,48 +1218,76 @@ export default function FinishedDownloadsCardsView({
const visible = mobileVisibleStackRows
const topRow = visible[0]
const backRows = visible.slice(1)
const preloadRow = mobilePreloadedRow
return (
<>
{/* Hintere Karten zuerst (absolut, dekorativ) */}
{backRows
.map((j, backIdx) => {
const idx = backIdx + 1 // 1,2...
const { k, cardInner } = renderCardItem(j, {
forceStill: true,
disableInline: true,
disablePreviewHover: true,
isDecorative: true,
forceLoadStill: true,
blur: true,
preloadTeaserWhenStill: true,
})
const depth = idx
const y = -(depth * stackPeekOffsetPx)
const scale = 1 - depth * 0.03
const opacity = 1 - depth * 0.14
return (
<div
key={k}
className="absolute inset-x-0 top-0 pointer-events-none will-change-[transform,opacity] transition-[transform,opacity] duration-220 ease-out motion-reduce:transition-none"
style={{
zIndex: 20 - depth,
transform: `translateY(${y}px) scale(${scale}) translateZ(0)`,
opacity,
transformOrigin: 'top center',
}}
aria-hidden="true"
>
<div className="relative">
{cardInner}
<div className="absolute inset-0 rounded-lg bg-white/8 dark:bg-black/8" />
</div>
</div>
)
{/* Hintere Karten zuerst (absolut, dekorativ) */}
{backRows
.map((j, backIdx) => {
const idx = backIdx + 1 // 1,2...
const { k, cardInner } = renderCardItem(j, {
forceStill: true,
disableInline: true,
disablePreviewHover: true,
isDecorative: true,
forceLoadStill: true,
blur: true,
preloadTeaserWhenStill: true,
})
.reverse()}
const depth = idx
const y = -(depth * stackPeekOffsetPx)
const scale = 1 - depth * 0.03
const opacity = 1 - depth * 0.14
return (
<div
key={k}
className="absolute inset-x-0 top-0 pointer-events-none will-change-[transform,opacity] transition-[transform,opacity] duration-220 ease-out motion-reduce:transition-none"
style={{
zIndex: 20 - depth,
transform: `translateY(${y}px) scale(${scale}) translateZ(0)`,
opacity,
transformOrigin: 'top center',
}}
aria-hidden="true"
>
<div className="relative">
{cardInner}
<div className="absolute inset-0 rounded-lg bg-white/8 dark:bg-black/8" />
</div>
</div>
)
})
.reverse()}
{preloadRow ? (() => {
const { k, cardInner } = renderCardItem(preloadRow, {
forceStill: true,
disableInline: true,
disablePreviewHover: true,
isDecorative: true,
forceLoadStill: true,
preloadTeaserWhenStill: true,
})
return (
<div
key={`${k}__preload`}
className="absolute inset-x-0 top-0 pointer-events-none"
style={{
zIndex: 1,
opacity: 0,
transform: `translateY(${-(mobileStackDepth * stackPeekOffsetPx)}px) scale(${1 - mobileStackDepth * 0.03}) translateZ(0)`,
transformOrigin: 'top center',
}}
aria-hidden="true"
>
{cardInner}
</div>
)
})() : null}
{/* Oberste Karte im Flow -> bestimmt die echte Höhe */}
{topRow ? (() => {

View File

@ -8,13 +8,17 @@ import { applyInlineVideoPolicy, DEFAULT_INLINE_MUTED } from './videoPolicy'
export default function LiveVideo({
src,
muted = DEFAULT_INLINE_MUTED,
volume = 1,
className,
roomStatus,
onVolumeChange,
}: {
src: string
muted?: boolean
volume?: number
className?: string
roomStatus?: string
onVolumeChange?: (volume: number, muted: boolean) => void
}) {
const ref = useRef<HTMLVideoElement>(null)
const [broken, setBroken] = useState(false)
@ -62,6 +66,8 @@ export default function LiveVideo({
setBrokenReason(null)
applyInlineVideoPolicy(video, { muted })
video.volume = Math.max(0, Math.min(1, volume))
video.muted = muted || volume <= 0
const hardReset = () => {
try {
@ -135,7 +141,28 @@ export default function LiveVideo({
video.removeEventListener('error', onError)
hardReset()
}
}, [src, muted, roomStatus])
}, [src, roomStatus])
useEffect(() => {
const video = ref.current
if (!video) return
const safeVolume = Math.max(0, Math.min(1, volume))
video.volume = safeVolume
video.muted = muted || safeVolume <= 0
}, [volume, muted])
useEffect(() => {
const video = ref.current
if (!video || !onVolumeChange) return
const emit = () => {
onVolumeChange(video.volume, video.muted)
}
video.addEventListener('volumechange', emit)
return () => video.removeEventListener('volumechange', emit)
}, [onVolumeChange])
if (broken) {
return (
@ -159,7 +186,6 @@ export default function LiveVideo({
onClick={() => {
const v = ref.current
if (v) {
v.muted = false
v.play().catch(() => {})
}
}}

View File

@ -4,7 +4,11 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import HoverPopover from './HoverPopover'
import LiveVideo from './LiveVideo'
import { XMarkIcon } from '@heroicons/react/24/outline'
import {
XMarkIcon,
SpeakerXMarkIcon,
SpeakerWaveIcon,
} from '@heroicons/react/24/outline'
type Props = {
jobId: string
@ -68,6 +72,8 @@ export default function ModelPreview({
const enteredViewOnce = useRef(false)
const [pageVisible, setPageVisible] = useState(true)
const [popupMuted, setPopupMuted] = useState(true)
const [popupVolume, setPopupVolume] = useState(1)
const toMs = (v: any): number => {
if (typeof v === 'number' && Number.isFinite(v)) return v
@ -276,18 +282,51 @@ export default function ModelPreview({
<div className="absolute inset-0">
<LiveVideo
src={hq}
muted={false}
muted={popupMuted}
volume={popupVolume}
roomStatus={roomStatus}
onVolumeChange={(nextVolume, nextMuted) => {
setPopupVolume(nextVolume)
setPopupMuted(nextMuted)
}}
className="w-full h-full object-contain object-bottom relative z-0"
/>
{showLiveBadge ? (
<div className="absolute left-2 top-2 inline-flex items-center gap-1.5 rounded-full bg-red-600/90 px-2 py-1 text-[11px] font-semibold text-white shadow-sm">
<div className="absolute left-2 top-2 z-[20] inline-flex items-center gap-1.5 rounded-full bg-red-600/90 px-2 py-1 text-[11px] font-semibold text-white shadow-sm">
<span className="inline-block size-1.5 rounded-full bg-white animate-pulse" />
Live
</div>
) : null}
<div className="absolute right-2 bottom-2 z-[60]">
<button
type="button"
className="pointer-events-auto inline-flex items-center justify-center rounded-full bg-black/65 px-2.5 py-1.5 text-white shadow-sm ring-1 ring-white/10 hover:bg-black/75 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white/70"
title={popupMuted ? 'Ton an' : 'Stumm'}
aria-label={popupMuted ? 'Ton an' : 'Stumm'}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
if (popupMuted) {
setPopupMuted(false)
if (popupVolume <= 0) setPopupVolume(1)
} else {
setPopupMuted(true)
}
}}
>
<span className="text-[13px] leading-none">
{popupMuted ? (
<SpeakerXMarkIcon className="h-4 w-4" />
) : (
<SpeakerWaveIcon className="h-4 w-4" />
)}
</span>
</button>
</div>
<button
type="button"
className="absolute right-2 top-2 z-20 inline-flex items-center justify-center rounded-md p-1.5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white/70 bg-white/75 text-gray-900 ring-1 ring-black/10 hover:bg-white/90 dark:bg-black/40 dark:text-white dark:ring-white/10 dark:hover:bg-black/55"

View File

@ -12,6 +12,8 @@ import {
ArrowsPointingOutIcon,
ArrowsPointingInIcon,
XMarkIcon,
SpeakerXMarkIcon,
SpeakerWaveIcon,
} from '@heroicons/react/24/outline'
import { DEFAULT_PLAYER_START_MUTED } from './videoPolicy'
import RecordJobActions from './RecordJobActions'
@ -320,6 +322,9 @@ export default function Player({
const [hlsReady, setHlsReady] = React.useState(false)
const isLive = isRunning && hlsReady
const [liveMuted, setLiveMuted] = React.useState(startMuted)
const [liveVolume, setLiveVolume] = React.useState(startMuted ? 0 : 1)
// ✅ Backend erwartet "id=" (nicht "name=")
// running: echte job.id (jobs-map lookup)
// finished: Dateiname ohne Extension als Stem (wenn dein Backend finished so mapped)
@ -1763,13 +1768,43 @@ export default function Player({
<div className="absolute inset-0 bg-black">
<LiveVideo
src={liveHlsSrc}
muted={startMuted}
muted={liveMuted}
volume={liveVolume}
onVolumeChange={(nextVolume, nextMuted) => {
setLiveVolume(nextVolume)
setLiveMuted(nextMuted)
}}
className="w-full h-full object-contain object-bottom"
/>
<div className="absolute right-2 bottom-2 z-[60] flex items-center gap-2">
<button
type="button"
className="pointer-events-auto inline-flex items-center justify-center rounded-full bg-black/65 px-2.5 py-1.5 text-white shadow-sm ring-1 ring-white/10 hover:bg-black/75"
title={liveMuted ? 'Ton an' : 'Stumm'}
aria-label={liveMuted ? 'Ton an' : 'Stumm'}
onClick={() => {
if (liveMuted) {
setLiveMuted(false)
if (liveVolume <= 0) setLiveVolume(1)
} else {
setLiveMuted(true)
}
}}
>
<span className="text-[13px] leading-none">
{liveMuted ? (
<SpeakerXMarkIcon className="h-4 w-4" />
) : (
<SpeakerWaveIcon className="h-4 w-4" />
)}
</span>
</button>
<div className="absolute right-2 bottom-2 z-[60] pointer-events-none inline-flex items-center gap-1.5 rounded-full bg-red-600/90 px-2 py-1 text-[11px] font-semibold text-white shadow-sm">
<span className="inline-block size-1.5 rounded-full bg-white animate-pulse" />
Live
<div className="pointer-events-none inline-flex items-center gap-1.5 rounded-full bg-red-600/90 px-2 py-1 text-[11px] font-semibold text-white shadow-sm">
<span className="inline-block size-1.5 rounded-full bg-white animate-pulse" />
Live
</div>
</div>
</div>
) : (
@ -1857,10 +1892,18 @@ export default function Player({
) : null}
{!isRunning ? (
<span className="rounded bg-black/40 px-1.5 py-0.5 font-medium">{runtimeLabel}</span>
) : null}
<>
<span className="rounded bg-black/40 px-1.5 py-0.5 font-medium">
{runtimeLabel}
</span>
{sizeLabel !== '—' ? <span className="rounded bg-black/40 px-1.5 py-0.5 font-medium">{sizeLabel}</span> : null}
{sizeLabel !== '—' ? (
<span className="rounded bg-black/40 px-1.5 py-0.5 font-medium">
{sizeLabel}
</span>
) : null}
</>
) : null}
</div>
</div>
</div>

View File

@ -60,6 +60,7 @@ const DEFAULTS: RecorderSettings = {
type Props = {
onAssetsGenerated?: () => void
onTaskRunningChange?: (running: boolean) => void
}
function shortTaskFilename(name?: string, max = 52) {
@ -69,7 +70,7 @@ function shortTaskFilename(name?: string, max = 52) {
return '…' + s.slice(-(max - 1))
}
export default function RecorderSettings({ onAssetsGenerated }: Props) {
export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChange }: Props) {
const [value, setValue] = useState<RecorderSettings>(DEFAULTS)
const [saving, setSaving] = useState(false)
const [saveSuccessUntilMs, setSaveSuccessUntilMs] = useState<number>(0)
@ -141,6 +142,15 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
fading: false,
})
useEffect(() => {
const running =
assetsTask.status === 'running' ||
cleanupTask.status === 'running' ||
cleaning
onTaskRunningChange?.(running)
}, [assetsTask.status, cleanupTask.status, cleaning, onTaskRunningChange])
const pauseGB = Number(value.lowDiskPauseBelowGB ?? DEFAULTS.lowDiskPauseBelowGB ?? 5)
const uiPauseGB = diskStatus?.pauseGB ?? pauseGB
const uiResumeGB = diskStatus?.resumeGB ?? (pauseGB + 3)
@ -216,6 +226,57 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
}
}, [])
useEffect(() => {
let alive = true
const loadTaskStatus = async () => {
try {
const res = await fetch('/api/tasks/generate-assets', { cache: 'no-store' })
if (!res.ok) return
const data = await res.json().catch(() => null)
if (!alive || !data) return
const running = Boolean(data.running)
const done = Number(data.done ?? 0)
const total = Number(data.total ?? 0)
const currentFile = shortTaskFilename(data.currentFile)
if (running) {
setAssetsTask((t) => ({
...t,
status: 'running',
title: 'Assets generieren',
text: currentFile || '',
done,
total,
err: undefined,
fading: false,
}))
} else {
setAssetsTask((t) => ({
...t,
status: 'idle',
title: 'Assets generieren',
text: '',
done: 0,
total: 0,
err: undefined,
fading: false,
}))
}
} catch {
// ignorieren
}
}
void loadTaskStatus()
return () => {
alive = false
}
}, [])
async function browse(target: 'record' | 'done' | 'ffmpeg') {
setErr(null)
setMsg(null)

View File

@ -14,6 +14,7 @@ export type TabItem = {
count?: number | string
icon?: TabIcon
disabled?: boolean
spinIcon?: boolean
}
export type TabsVariant =
@ -221,6 +222,7 @@ export default function Tabs({
{tabs.map((tab, idx) => {
const selected = tab.id === current.id
const disabled = !!tab.disabled
const Icon = tab.icon
return (
<button
@ -239,13 +241,26 @@ export default function Tabs({
disabled && 'cursor-not-allowed opacity-50 hover:bg-transparent hover:text-gray-500 dark:hover:text-gray-400'
)}
>
<span className="inline-flex max-w-full min-w-0 items-center justify-center">
<span className="inline-flex max-w-full min-w-0 items-center justify-center gap-2">
{Icon ? (
<Icon
aria-hidden="true"
className={clsx(
'size-4 shrink-0 transition-transform',
tab.spinIcon && 'animate-spin',
selected
? 'text-indigo-600 dark:text-indigo-400'
: 'text-gray-400 group-hover:text-gray-500 dark:text-gray-500 dark:group-hover:text-gray-300'
)}
/>
) : null}
<span className="min-w-0 truncate whitespace-nowrap" title={tab.label}>
{tab.label}
</span>
{tab.count !== undefined ? (
<span className="ml-2 shrink-0 tabular-nums min-w-[2.25rem] rounded-full bg-white/70 px-2 py-0.5 text-center text-xs font-medium text-gray-900 dark:bg-white/10 dark:text-white">
<span className="ml-1 shrink-0 tabular-nums min-w-[2.25rem] rounded-full bg-white/70 px-2 py-0.5 text-center text-xs font-medium text-gray-900 dark:bg-white/10 dark:text-white">
{tab.count}
</span>
) : null}

View File

@ -621,14 +621,10 @@ export default function VideoSplitModal({
setAiHits(metaAiHits)
setAiSegments(metaAiSegments)
if (metaAiSegments.length > 0 && effectiveDuration > 0) {
const nextSplits = metaAiSegments
.flatMap((seg) => [seg.start, seg.end])
.filter((t) => t > 0.2 && t < effectiveDuration - 0.2)
.sort((a, b) => a - b)
.filter((t, i, arr) => i === 0 || Math.abs(arr[i - 1] - t) >= 0.2)
setSplits(nextSplits)
// AI-Modus benutzt die Segmente direkt.
// Splits bleiben nur für manuellen Modus.
if (metaAiSegments.length > 0) {
setSplits([])
}
}
@ -923,7 +919,7 @@ export default function VideoSplitModal({
try {
await onApply?.({
job,
splits,
splits: aiSegments.length > 0 ? [] : splits,
segments: selectedSegments,
})
onClose()
@ -1170,7 +1166,7 @@ export default function VideoSplitModal({
})}
{/* Split-Marker */}
{splits.map((time) => {
{aiSegments.length === 0 && splits.map((time) => {
const left = duration > 0 ? (time / duration) * 100 : 0
return (
<button
@ -1207,6 +1203,7 @@ export default function VideoSplitModal({
</div>
</div>
{aiSegments.length === 0 && (
<div className="flex flex-wrap gap-2">
{splits.length === 0 ? (
<div className="text-sm text-gray-500 dark:text-gray-400">
@ -1236,6 +1233,7 @@ export default function VideoSplitModal({
))
)}
</div>
)}
</div>
</div>
</section>