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++ { for i := 1; i < len(out); i++ {
n := out[i] n := out[i]
const mergeGapSeconds = 15.0 // Direkt aufeinanderfolgende Segmente mit gleichem Label immer mergen,
// unabhängig von der Lücke. Sobald ein anderes Label dazwischen liegt,
sameLabel := strings.EqualFold(cur.Label, n.Label) // wird automatisch nicht gemergt, weil wir nur mit dem direkten Nachfolger arbeiten.
nearEnough := n.StartSeconds <= cur.EndSeconds+mergeGapSeconds if strings.EqualFold(cur.Label, n.Label) {
if sameLabel && nearEnough {
if n.StartSeconds < cur.StartSeconds { if n.StartSeconds < cur.StartSeconds {
cur.StartSeconds = n.StartSeconds cur.StartSeconds = n.StartSeconds
} }

View File

@ -473,7 +473,13 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
cellW, cellW,
cellH, cellH,
); err != nil { ); 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 return
} }

View File

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

View File

@ -430,7 +430,7 @@ func startPreviewHLS(ctx context.Context, job *RecordJob, m3u8URL, previewDir, h
publishJobUpsert(job) 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() jobsMu.Lock()

View File

@ -17,7 +17,6 @@ import (
"image/jpeg" "image/jpeg"
"image/png" "image/png"
"io" "io"
"log"
"math" "math"
"math/rand" "math/rand"
"net/http" "net/http"
@ -621,7 +620,6 @@ func coverBatchEnter(force bool) {
coverBatchErrors = 0 coverBatchErrors = 0
coverBatchNoThumb = 0 coverBatchNoThumb = 0
coverBatchDecodeErr = 0 coverBatchDecodeErr = 0
log.Printf("[cover] BATCH START")
} }
coverBatchInflight++ coverBatchInflight++
@ -648,20 +646,6 @@ func coverBatchLeave(outcome string, status int) {
} }
coverBatchInflight-- 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}`) 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, "autoStartAddedDownloads": false,
"useChaturbateApi": true, "useChaturbateApi": true,
"useMyFreeCamsWatcher": true, "useMyFreeCamsWatcher": true,
"autoDeleteSmallDownloads": false, "autoDeleteSmallDownloads": true,
"autoDeleteSmallDownloadsBelowMB": 50, "autoDeleteSmallDownloadsBelowMB": 200,
"lowDiskPauseBelowGB": 5, "lowDiskPauseBelowGB": 5,
"blurPreviews": false, "blurPreviews": false,
"teaserPlayback": "hover", "teaserPlayback": "hover",
"teaserAudio": false, "teaserAudio": false,
"enableNotifications": true, "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) api.HandleFunc("/api/generated/coverinfo/list", generatedCoverInfoList)
// Tasks // Tasks
api.HandleFunc("/api/tasks/status", tasksStatusHandler)
api.HandleFunc("/api/tasks/generate-assets", tasksGenerateAssets) api.HandleFunc("/api/tasks/generate-assets", tasksGenerateAssets)
// -------------------------- // --------------------------

View File

@ -53,6 +53,10 @@ func snapshotAssetsState() AssetsTaskState {
return st return st
} }
func getGenerateAssetsTaskStatus() AssetsTaskState {
return snapshotAssetsState()
}
func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) { func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
@ -305,7 +309,7 @@ func runGenerateMissingAssets(ctx context.Context) {
updateAssetsState(func(st *AssetsTaskState) { updateAssetsState(func(st *AssetsTaskState) {
st.Error = "mindestens ein Eintrag konnte nicht vollständig analysiert werden (siehe Logs)" 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) { updateAssetsState(func(st *AssetsTaskState) {

View File

@ -48,6 +48,7 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
// doneDir auflösen // doneDir auflösen
doneAbs, err := resolvePathRelativeToApp(s.DoneDir) doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil || strings.TrimSpace(doneAbs) == "" { if err != nil || strings.TrimSpace(doneAbs) == "" {
setCleanupTaskError("doneDir auflösung fehlgeschlagen")
http.Error(w, "doneDir auflösung fehlgeschlagen", http.StatusBadRequest) http.Error(w, "doneDir auflösung fehlgeschlagen", http.StatusBadRequest)
return return
} }
@ -68,6 +69,8 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
mb = 0 mb = 0
} }
setCleanupTaskRunning("Räume auf…")
resp := cleanupResp{} resp := cleanupResp{}
// 1) Kleine Downloads löschen (wenn mb > 0) // 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.GeneratedOrphansRemoved = gcStats.Removed
resp.DeletedBytesHuman = formatBytesSI(resp.DeletedBytes) resp.DeletedBytesHuman = formatBytesSI(resp.DeletedBytes)
orphansTotalRemoved := resp.GeneratedOrphansRemoved
setCleanupTaskDone(fmt.Sprintf("geprüft: %d · Orphans: %d", resp.ScannedFiles, orphansTotalRemoved))
writeJSON(w, http.StatusOK, resp) 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.Checked += checkedMeta
stats.Removed += removedMeta 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 ModelsTab from './components/ui/ModelsTab'
import ProgressBar from './components/ui/ProgressBar' import ProgressBar from './components/ui/ProgressBar'
import ModelDetails from './components/ui/ModelDetails' 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 PerformanceMonitor from './components/ui/PerformanceMonitor'
import { useNotify } from './components/ui/notify' import { useNotify } from './components/ui/notify'
//import { startChaturbateOnlinePolling } from './lib/chaturbateOnlinePoller' //import { startChaturbateOnlinePolling } from './lib/chaturbateOnlinePoller'
@ -448,6 +458,7 @@ export default function App() {
const [cookies, setCookies] = useState<Record<string, string>>({}) const [cookies, setCookies] = useState<Record<string, string>>({})
const [cookiesLoaded, setCookiesLoaded] = useState(false) const [cookiesLoaded, setCookiesLoaded] = useState(false)
const [selectedTab, setSelectedTab] = useState('running') const [selectedTab, setSelectedTab] = useState('running')
const [settingsTaskRunning, setSettingsTaskRunning] = useState(false)
const [playerJob, setPlayerJob] = useState<RecordJob | null>(null) const [playerJob, setPlayerJob] = useState<RecordJob | null>(null)
const [playerExpanded, setPlayerExpanded] = useState(false) const [playerExpanded, setPlayerExpanded] = useState(false)
const [playerStartAtSec, setPlayerStartAtSec] = useState<number | null>(null) 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(() => { useEffect(() => {
void checkAuth() void checkAuth()
}, [checkAuth]) }, [checkAuth])
@ -2051,11 +2112,35 @@ export default function App() {
}, [jobs, pendingWatchedRooms]) }, [jobs, pendingWatchedRooms])
const tabs: TabItem[] = [ const tabs: TabItem[] = [
{ id: 'running', label: 'Laufende Downloads', count: runningTabCount }, {
{ id: 'finished', label: 'Abgeschlossene Downloads', count: doneCount }, id: 'running',
{ id: 'models', label: 'Models', count: modelsCount }, label: 'Laufende Downloads',
{ id: 'categories', label: 'Kategorien' }, count: runningTabCount,
{ id: 'settings', label: 'Einstellungen' }, 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]) const canStart = useMemo(() => sourceUrl.trim().length > 0 && !busy, [sourceUrl, busy])
@ -3256,7 +3341,12 @@ export default function App() {
{selectedTab === 'models' ? <ModelsTab /> : null} {selectedTab === 'models' ? <ModelsTab /> : null}
{selectedTab === 'categories' ? <CategoriesTab /> : null} {selectedTab === 'categories' ? <CategoriesTab /> : null}
{selectedTab === 'settings' ? <RecorderSettings onAssetsGenerated={bumpAssets} /> : null} {selectedTab === 'settings' ? (
<RecorderSettings
onAssetsGenerated={bumpAssets}
onTaskRunningChange={setSettingsTaskRunning}
/>
) : null}
</main> </main>
<CookieModal <CookieModal

View File

@ -62,13 +62,6 @@ function modelKeyFromFilename(fileOrPath: string): string | null {
return stem ? stem.trim() : 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) { async function ensureCover(category: string, thumbPath: string, modelName: string | null, refresh: boolean) {
const m = (modelName || '').trim() const m = (modelName || '').trim()
const url = const url =
@ -363,12 +356,27 @@ export default function CategoriesTab() {
// ✅ random Pick -> Thumb -> ensureCover (generiert Cover garantiert ohne 404, sofern Thumb existiert) // ✅ random Pick -> Thumb -> ensureCover (generiert Cover garantiert ohne 404, sofern Thumb existiert)
const pick = list[Math.floor(Math.random() * list.length)] const pick = list[Math.floor(Math.random() * list.length)]
const thumb = thumbUrlFromOutput(pick) const id = assetIdFromOutput(pick)
if (!id) {
if (!thumb) { return { tag: r.tag, ok: true, status: 0, text: 'skipped (no asset id)' }
return { tag: r.tag, ok: true, status: 0, text: 'skipped (no thumb url)' }
} }
// 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) const model = modelKeyFromFilename(pick)
await ensureCover(r.tag, thumb, model, true) await ensureCover(r.tag, thumb, model, true)

View File

@ -1166,15 +1166,18 @@ export default function FinishedDownloadsCardsView({
} }
const mobileStackDepth = 3 const mobileStackDepth = 3
const mobilePreloadDepth = mobileStackDepth + 3
// Sichtbarer Stack bleibt bei 3 Karten // ✅ 4 Karten mounten, aber nur 3 sichtbar stacken
const mobileVisibleStackRows = isSmall ? rows.slice(0, mobileStackDepth) : [] 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 // größerer Peek-Offset für stärkeren Stack-Effekt
const stackPeekOffsetPx = 15 const stackPeekOffsetPx = 15
// weil wir nach OBEN stacken, brauchen wir oben Platz // 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 ( return (
<div className="relative"> <div className="relative">
@ -1215,48 +1218,76 @@ export default function FinishedDownloadsCardsView({
const visible = mobileVisibleStackRows const visible = mobileVisibleStackRows
const topRow = visible[0] const topRow = visible[0]
const backRows = visible.slice(1) const backRows = visible.slice(1)
const preloadRow = mobilePreloadedRow
return ( return (
<> <>
{/* Hintere Karten zuerst (absolut, dekorativ) */} {/* Hintere Karten zuerst (absolut, dekorativ) */}
{backRows {backRows
.map((j, backIdx) => { .map((j, backIdx) => {
const idx = backIdx + 1 // 1,2... const idx = backIdx + 1 // 1,2...
const { k, cardInner } = renderCardItem(j, { const { k, cardInner } = renderCardItem(j, {
forceStill: true, forceStill: true,
disableInline: true, disableInline: true,
disablePreviewHover: true, disablePreviewHover: true,
isDecorative: true, isDecorative: true,
forceLoadStill: true, forceLoadStill: true,
blur: true, blur: true,
preloadTeaserWhenStill: 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>
)
}) })
.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 */} {/* Oberste Karte im Flow -> bestimmt die echte Höhe */}
{topRow ? (() => { {topRow ? (() => {

View File

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

View File

@ -4,7 +4,11 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import HoverPopover from './HoverPopover' import HoverPopover from './HoverPopover'
import LiveVideo from './LiveVideo' import LiveVideo from './LiveVideo'
import { XMarkIcon } from '@heroicons/react/24/outline' import {
XMarkIcon,
SpeakerXMarkIcon,
SpeakerWaveIcon,
} from '@heroicons/react/24/outline'
type Props = { type Props = {
jobId: string jobId: string
@ -68,6 +72,8 @@ export default function ModelPreview({
const enteredViewOnce = useRef(false) const enteredViewOnce = useRef(false)
const [pageVisible, setPageVisible] = useState(true) const [pageVisible, setPageVisible] = useState(true)
const [popupMuted, setPopupMuted] = useState(true)
const [popupVolume, setPopupVolume] = useState(1)
const toMs = (v: any): number => { const toMs = (v: any): number => {
if (typeof v === 'number' && Number.isFinite(v)) return v if (typeof v === 'number' && Number.isFinite(v)) return v
@ -276,18 +282,51 @@ export default function ModelPreview({
<div className="absolute inset-0"> <div className="absolute inset-0">
<LiveVideo <LiveVideo
src={hq} src={hq}
muted={false} muted={popupMuted}
volume={popupVolume}
roomStatus={roomStatus} roomStatus={roomStatus}
onVolumeChange={(nextVolume, nextMuted) => {
setPopupVolume(nextVolume)
setPopupMuted(nextMuted)
}}
className="w-full h-full object-contain object-bottom relative z-0" className="w-full h-full object-contain object-bottom relative z-0"
/> />
{showLiveBadge ? ( {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" /> <span className="inline-block size-1.5 rounded-full bg-white animate-pulse" />
Live Live
</div> </div>
) : null} ) : 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 <button
type="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" 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, ArrowsPointingOutIcon,
ArrowsPointingInIcon, ArrowsPointingInIcon,
XMarkIcon, XMarkIcon,
SpeakerXMarkIcon,
SpeakerWaveIcon,
} from '@heroicons/react/24/outline' } from '@heroicons/react/24/outline'
import { DEFAULT_PLAYER_START_MUTED } from './videoPolicy' import { DEFAULT_PLAYER_START_MUTED } from './videoPolicy'
import RecordJobActions from './RecordJobActions' import RecordJobActions from './RecordJobActions'
@ -320,6 +322,9 @@ export default function Player({
const [hlsReady, setHlsReady] = React.useState(false) const [hlsReady, setHlsReady] = React.useState(false)
const isLive = isRunning && hlsReady const isLive = isRunning && hlsReady
const [liveMuted, setLiveMuted] = React.useState(startMuted)
const [liveVolume, setLiveVolume] = React.useState(startMuted ? 0 : 1)
// ✅ Backend erwartet "id=" (nicht "name=") // ✅ Backend erwartet "id=" (nicht "name=")
// running: echte job.id (jobs-map lookup) // running: echte job.id (jobs-map lookup)
// finished: Dateiname ohne Extension als Stem (wenn dein Backend finished so mapped) // 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"> <div className="absolute inset-0 bg-black">
<LiveVideo <LiveVideo
src={liveHlsSrc} src={liveHlsSrc}
muted={startMuted} muted={liveMuted}
volume={liveVolume}
onVolumeChange={(nextVolume, nextMuted) => {
setLiveVolume(nextVolume)
setLiveMuted(nextMuted)
}}
className="w-full h-full object-contain object-bottom" 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"> <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" /> <span className="inline-block size-1.5 rounded-full bg-white animate-pulse" />
Live Live
</div>
</div> </div>
</div> </div>
) : ( ) : (
@ -1857,10 +1892,18 @@ export default function Player({
) : null} ) : null}
{!isRunning ? ( {!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> </div>
</div> </div>

View File

@ -60,6 +60,7 @@ const DEFAULTS: RecorderSettings = {
type Props = { type Props = {
onAssetsGenerated?: () => void onAssetsGenerated?: () => void
onTaskRunningChange?: (running: boolean) => void
} }
function shortTaskFilename(name?: string, max = 52) { function shortTaskFilename(name?: string, max = 52) {
@ -69,7 +70,7 @@ function shortTaskFilename(name?: string, max = 52) {
return '…' + s.slice(-(max - 1)) 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 [value, setValue] = useState<RecorderSettings>(DEFAULTS)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [saveSuccessUntilMs, setSaveSuccessUntilMs] = useState<number>(0) const [saveSuccessUntilMs, setSaveSuccessUntilMs] = useState<number>(0)
@ -141,6 +142,15 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
fading: false, 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 pauseGB = Number(value.lowDiskPauseBelowGB ?? DEFAULTS.lowDiskPauseBelowGB ?? 5)
const uiPauseGB = diskStatus?.pauseGB ?? pauseGB const uiPauseGB = diskStatus?.pauseGB ?? pauseGB
const uiResumeGB = diskStatus?.resumeGB ?? (pauseGB + 3) 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') { async function browse(target: 'record' | 'done' | 'ffmpeg') {
setErr(null) setErr(null)
setMsg(null) setMsg(null)

View File

@ -14,6 +14,7 @@ export type TabItem = {
count?: number | string count?: number | string
icon?: TabIcon icon?: TabIcon
disabled?: boolean disabled?: boolean
spinIcon?: boolean
} }
export type TabsVariant = export type TabsVariant =
@ -221,6 +222,7 @@ export default function Tabs({
{tabs.map((tab, idx) => { {tabs.map((tab, idx) => {
const selected = tab.id === current.id const selected = tab.id === current.id
const disabled = !!tab.disabled const disabled = !!tab.disabled
const Icon = tab.icon
return ( return (
<button <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' 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}> <span className="min-w-0 truncate whitespace-nowrap" title={tab.label}>
{tab.label} {tab.label}
</span> </span>
{tab.count !== undefined ? ( {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} {tab.count}
</span> </span>
) : null} ) : null}

View File

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