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++ {
|
||||
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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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}`)
|
||||
|
||||
@ -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="
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
// --------------------------
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
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 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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 ? (() => {
|
||||
|
||||
@ -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(() => {})
|
||||
}
|
||||
}}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user