updated tasks
This commit is contained in:
parent
e7a13652f5
commit
5e5b8025e8
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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}`)
|
||||||
|
|||||||
@ -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="
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
// --------------------------
|
// --------------------------
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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
84
backend/tasks_status.go
Normal 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)
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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 ? (() => {
|
||||||
|
|||||||
@ -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(() => {})
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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] 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="absolute right-2 bottom-2 z-[60] flex items-center gap-2">
|
||||||
<span className="inline-block size-1.5 rounded-full bg-white animate-pulse" />
|
<button
|
||||||
Live
|
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="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>
|
||||||
</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>
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user