updated finisheddownloads

This commit is contained in:
Linrador 2026-03-16 15:11:45 +01:00
parent dffe5dde16
commit 6682b51f76
10 changed files with 154 additions and 115 deletions

View File

@ -695,7 +695,7 @@ func recordPreviewLiveFMP4(w http.ResponseWriter, r *http.Request) {
} }
// ffmpeg args: input = m3u8, output = fragmented mp4 to stdout // ffmpeg args: input = m3u8, output = fragmented mp4 to stdout
// ✅ nur Video (kein Audio), damit MSE codecs stabil sind // ✅ Video + Audio für Browser-Playback
args := []string{"-hide_banner", "-loglevel", "error"} args := []string{"-hide_banner", "-loglevel", "error"}
if ua != "" { if ua != "" {
args = append(args, "-user_agent", ua) args = append(args, "-user_agent", ua)
@ -707,9 +707,10 @@ func recordPreviewLiveFMP4(w http.ResponseWriter, r *http.Request) {
// Input // Input
args = append(args, "-i", m3u8) args = append(args, "-i", m3u8)
// Video encode (low-latency-ish) // Video + Audio encode (low-latency-ish)
args = append(args, args = append(args,
"-an", "-map", "0:v:0",
"-map", "0:a:0?",
"-vf", "scale=480:-2", "-vf", "scale=480:-2",
"-c:v", "libx264", "-c:v", "libx264",
"-preset", "veryfast", "-preset", "veryfast",
@ -720,6 +721,10 @@ func recordPreviewLiveFMP4(w http.ResponseWriter, r *http.Request) {
"-g", "48", "-g", "48",
"-keyint_min", "48", "-keyint_min", "48",
"-sc_threshold", "0", "-sc_threshold", "0",
"-c:a", "aac",
"-b:a", "128k",
"-ac", "2",
"-ar", "48000",
) )
// Output: fMP4 fragmented to stdout (single HTTP response) // Output: fMP4 fragmented to stdout (single HTTP response)

View File

@ -10,7 +10,6 @@ import (
"io" "io"
"net/http" "net/http"
"os" "os"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -60,22 +59,6 @@ func RecordStream(
jobsMu.Unlock() jobsMu.Unlock()
} }
if job != nil && strings.TrimSpace(job.PreviewDir) == "" {
assetID := assetIDForJob(job)
if strings.TrimSpace(assetID) == "" {
assetID = job.ID
}
previewDir := filepath.Join(os.TempDir(), "rec_preview", assetID)
jobsMu.Lock()
job.PreviewDir = previewDir
jobsMu.Unlock()
if err := startPreviewHLS(ctx, job, playlist.PlaylistURL, previewDir, httpCookie, hc.userAgent); err != nil {
fmt.Println("⚠️ preview start fehlgeschlagen:", err)
}
}
// 4) Datei öffnen // 4) Datei öffnen
file, err := os.Create(outputPath) file, err := os.Create(outputPath)
if err != nil { if err != nil {

View File

@ -12,7 +12,6 @@ import (
"net/url" "net/url"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -89,22 +88,6 @@ func RecordStreamMFC(
_ = publishJob(job.ID) _ = publishJob(job.ID)
} }
// ✅ Preview starten
if job != nil && job.PreviewDir == "" {
assetID := assetIDForJob(job)
if strings.TrimSpace(assetID) == "" {
assetID = job.ID
}
previewDir := filepath.Join(os.TempDir(), "rec_preview", assetID)
job.PreviewDir = previewDir
if err := startPreviewHLS(ctx, job, m3u8URL, previewDir, "", hc.userAgent); err != nil {
fmt.Println("⚠️ preview start fehlgeschlagen:", err)
job.PreviewDir = "" // rollback
}
}
// Aufnahme starten // Aufnahme starten
return handleM3U8Mode(ctx, m3u8URL, outputPath, job) return handleM3U8Mode(ctx, m3u8URL, outputPath, job)
} }

View File

@ -13,7 +13,7 @@
"lowDiskPauseBelowGB": 5, "lowDiskPauseBelowGB": 5,
"blurPreviews": false, "blurPreviews": false,
"teaserPlayback": "hover", "teaserPlayback": "hover",
"teaserAudio": false, "teaserAudio": true,
"enableNotifications": true, "enableNotifications": true,
"encryptedCookies": "" "encryptedCookies": ""
} }

View File

@ -171,10 +171,10 @@ func loadSettings() {
// ffmpeg-Pfad anhand Settings/Env/PATH bestimmen // ffmpeg-Pfad anhand Settings/Env/PATH bestimmen
ffmpegPath = detectFFmpegPath() ffmpegPath = detectFFmpegPath()
//fmt.Println("🔍 ffmpegPath:", ffmpegPath) fmt.Println("🔍 ffmpegPath:", ffmpegPath)
ffprobePath = detectFFprobePath() ffprobePath = detectFFprobePath()
//fmt.Println("🔍 ffprobePath:", ffprobePath) fmt.Println("🔍 ffprobePath:", ffprobePath)
} }
@ -403,10 +403,10 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
} else { } else {
ffmpegPath = detectFFmpegPath() ffmpegPath = detectFFmpegPath()
} }
fmt.Println("🔍 ffmpegPath:", ffmpegPath) //fmt.Println("🔍 ffmpegPath:", ffmpegPath)
ffprobePath = detectFFprobePath() ffprobePath = detectFFprobePath()
fmt.Println("🔍 ffprobePath:", ffprobePath) //fmt.Println("🔍 ffprobePath:", ffprobePath)
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")

View File

@ -27,6 +27,7 @@ import { isHotName, stripHotPrefix } from './hotName'
import LabeledSwitch from './LabeledSwitch' import LabeledSwitch from './LabeledSwitch'
import Switch from './Switch' import Switch from './Switch'
import LoadingSpinner from './LoadingSpinner' import LoadingSpinner from './LoadingSpinner'
import type { FinishedDownloadsTeaserState, TeaserPlaybackMode } from './teaserPlayback'
type SortMode = type SortMode =
| 'completed_desc' | 'completed_desc'
@ -38,8 +39,6 @@ type SortMode =
| 'size_desc' | 'size_desc'
| 'size_asc' | 'size_asc'
type TeaserPlaybackMode = 'still' | 'hover' | 'all'
type Props = { type Props = {
jobs: RecordJob[] jobs: RecordJob[]
doneJobs: RecordJob[] doneJobs: RecordJob[]
@ -357,6 +356,16 @@ export default function FinishedDownloads({
// ✅ schützt gegen alte Effect-Instanzen / StrictMode-Cleanup / Race Conditions // ✅ schützt gegen alte Effect-Instanzen / StrictMode-Cleanup / Race Conditions
const refillSessionRef = React.useRef(0) const refillSessionRef = React.useRef(0)
const teaserState = React.useMemo<FinishedDownloadsTeaserState>(
() => ({
mode: teaserPlaybackMode,
activeKey: teaserKey,
hoverKey: hoverTeaserKey,
audioEnabled: Boolean(teaserAudio),
}),
[teaserPlaybackMode, teaserKey, hoverTeaserKey, teaserAudio]
)
type UndoAction = type UndoAction =
| { kind: 'delete'; undoToken: string; originalFile: string; rowKey?: string; from?: 'done' | 'keep' } | { kind: 'delete'; undoToken: string; originalFile: string; rowKey?: string; from?: 'done' | 'keep' }
| { kind: 'keep'; keptFile: string; originalFile: string; rowKey?: string } | { kind: 'keep'; keptFile: string; originalFile: string; rowKey?: string }
@ -1928,7 +1937,7 @@ export default function FinishedDownloads({
// 🖱️ Desktop: Teaser nur bei Hover (Settings: 'hover' = Standard). Mobile: weiterhin Viewport-Fokus (Effect darunter) // 🖱️ Desktop: Teaser nur bei Hover (Settings: 'hover' = Standard). Mobile: weiterhin Viewport-Fokus (Effect darunter)
useEffect(() => { useEffect(() => {
const active = const active =
teaserPlaybackMode === 'hover' && teaserState.mode === 'hover' &&
!canHover && !canHover &&
(view === 'cards' || view === 'gallery' || view === 'table') (view === 'cards' || view === 'gallery' || view === 'table')
@ -1985,7 +1994,7 @@ export default function FinishedDownloads({
io.disconnect() io.disconnect()
if (teaserIORef.current === io) teaserIORef.current = null if (teaserIORef.current === io) teaserIORef.current = null
} }
}, [view, teaserPlaybackMode, canHover, inlinePlay?.key]) }, [view, teaserState.mode, canHover, inlinePlay?.key])
// 🧠 Laufzeit-Anzeige: bevorzugt Videodauer, sonst Fallback auf startedAt/endedAt // 🧠 Laufzeit-Anzeige: bevorzugt Videodauer, sonst Fallback auf startedAt/endedAt
const runtimeOf = (job: RecordJob): string => { const runtimeOf = (job: RecordJob): string => {
@ -2456,10 +2465,7 @@ export default function FinishedDownloads({
isLoading={isLoading} isLoading={isLoading}
blurPreviews={blurPreviews} blurPreviews={blurPreviews}
durations={durations} durations={durations}
teaserKey={teaserKey} teaserState={teaserState}
teaserPlayback={teaserPlaybackMode}
teaserAudio={teaserAudio}
hoverTeaserKey={hoverTeaserKey}
inlinePlay={inlinePlay} inlinePlay={inlinePlay}
deletingKeys={deletingKeys} deletingKeys={deletingKeys}
keepingKeys={keepingKeys} keepingKeys={keepingKeys}
@ -2515,11 +2521,8 @@ export default function FinishedDownloads({
resolutions={resolutions} resolutions={resolutions}
durations={durations} durations={durations}
canHover={canHover} canHover={canHover}
teaserAudio={teaserAudio} teaserState={teaserState}
hoverTeaserKey={hoverTeaserKey}
setHoverTeaserKey={setHoverTeaserKey} setHoverTeaserKey={setHoverTeaserKey}
teaserPlayback={teaserPlaybackMode}
teaserKey={teaserKey}
registerTeaserHost={registerTeaserHost} registerTeaserHost={registerTeaserHost}
handleDuration={handleDuration} handleDuration={handleDuration}
handleResolution={handleResolution} handleResolution={handleResolution}
@ -2556,10 +2559,7 @@ export default function FinishedDownloads({
blurPreviews={blurPreviews} blurPreviews={blurPreviews}
durations={durations} durations={durations}
handleDuration={handleDuration} handleDuration={handleDuration}
teaserKey={teaserKey} teaserState={teaserState}
teaserPlayback={teaserPlaybackMode}
teaserAudio={teaserAudio}
hoverTeaserKey={hoverTeaserKey}
keyFor={keyFor} keyFor={keyFor}
baseName={baseName} baseName={baseName}
modelNameFromOutput={modelNameFromOutput} modelNameFromOutput={modelNameFromOutput}

View File

@ -11,6 +11,11 @@ import TagOverflowRow from './TagOverflowRow'
import PreviewScrubber from './PreviewScrubber' import PreviewScrubber from './PreviewScrubber'
import { isHotName, stripHotPrefix } from './hotName' import { isHotName, stripHotPrefix } from './hotName'
import { formatResolution } from './formatters' import { formatResolution } from './formatters'
import type { FinishedDownloadsTeaserState } from './teaserPlayback'
import {
shouldAnimateTeaser,
shouldEnableTeaserAudio,
} from './teaserPlayback'
type InlinePlayState = { key: string; nonce: number } | null type InlinePlayState = { key: string; nonce: number } | null
@ -18,14 +23,11 @@ type Props = {
rows: RecordJob[] rows: RecordJob[]
isLoading?: boolean isLoading?: boolean
isSmall: boolean isSmall: boolean
teaserPlayback: 'still' | 'hover' | 'all'
teaserAudio?: boolean
hoverTeaserKey?: string | null
blurPreviews?: boolean blurPreviews?: boolean
durations: Record<string, number> durations: Record<string, number>
teaserKey: string | null
inlinePlay: InlinePlayState inlinePlay: InlinePlayState
teaserState: FinishedDownloadsTeaserState
deletingKeys: Set<string> deletingKeys: Set<string>
keepingKeys: Set<string> keepingKeys: Set<string>
@ -524,12 +526,9 @@ export default function FinishedDownloadsCardsView({
rows, rows,
isSmall, isSmall,
isLoading, isLoading,
teaserPlayback,
teaserAudio,
hoverTeaserKey,
blurPreviews, blurPreviews,
durations, durations,
teaserKey, teaserState,
inlinePlay, inlinePlay,
deletingKeys, deletingKeys,
keepingKeys, keepingKeys,
@ -704,10 +703,12 @@ export default function FinishedDownloadsCardsView({
const realInlineActive = inlinePlay?.key === k const realInlineActive = inlinePlay?.key === k
const inlineActive = opts?.disableInline ? false : realInlineActive const inlineActive = opts?.disableInline ? false : realInlineActive
const allowSound = const allowSound = shouldEnableTeaserAudio({
!opts?.forceStill && state: teaserState,
Boolean(teaserAudio) && itemKey: k,
(inlineActive || hoverTeaserKey === k) inlineActive,
disabled: Boolean(opts?.forceStill),
})
const previewMuted = !allowSound const previewMuted = !allowSound
const inlineNonce = inlineActive ? inlinePlay?.nonce ?? 0 : 0 const inlineNonce = inlineActive ? inlinePlay?.nonce ?? 0 : 0
@ -717,15 +718,13 @@ export default function FinishedDownloadsCardsView({
// ✅ Im Mobile-Stack soll nur die Top-Card Teaser-Video bekommen. // ✅ Im Mobile-Stack soll nur die Top-Card Teaser-Video bekommen.
// Untere Karten zeigen immer nur Bild (Still), selbst wenn teaserKey mal matcht. // Untere Karten zeigen immer nur Bild (Still), selbst wenn teaserKey mal matcht.
const allowTeaserAnimation = const allowTeaserAnimation =
opts?.forceStill opts?.mobileStackTopOnlyVideo
? false ? teaserState.mode !== 'still'
: opts?.mobileStackTopOnlyVideo : shouldAnimateTeaser({
? (teaserPlayback === 'still' ? false : true) state: teaserState,
: (teaserPlayback === 'all' itemKey: k,
? true disabled: Boolean(opts?.forceStill),
: teaserPlayback === 'hover' })
? teaserKey === k
: false)
const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k) const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k)

View File

@ -14,6 +14,12 @@ import TagOverflowRow from './TagOverflowRow'
import { isHotName, stripHotPrefix } from './hotName' import { isHotName, stripHotPrefix } from './hotName'
import { formatResolution } from './formatters' import { formatResolution } from './formatters'
import PreviewScrubber from './PreviewScrubber' import PreviewScrubber from './PreviewScrubber'
import type { FinishedDownloadsTeaserState } from './teaserPlayback'
import {
shouldAnimateTeaser,
shouldEnableTeaserAudio,
shouldObserveTeasers,
} from './teaserPlayback'
type ModelFlags = { type ModelFlags = {
favorite?: boolean favorite?: boolean
@ -35,10 +41,7 @@ type Props = {
isLoading?: boolean isLoading?: boolean
blurPreviews?: boolean blurPreviews?: boolean
durations: Record<string, number> durations: Record<string, number>
teaserPlayback: 'still' | 'hover' | 'all' teaserState: FinishedDownloadsTeaserState
teaserAudio?: boolean
hoverTeaserKey?: string | null
teaserKey: string | null
handleDuration: (job: RecordJob, seconds: number) => void handleDuration: (job: RecordJob, seconds: number) => void
@ -150,10 +153,7 @@ export default function FinishedDownloadsGalleryView({
isLoading, isLoading,
blurPreviews, blurPreviews,
durations, durations,
teaserPlayback, teaserState,
teaserAudio,
hoverTeaserKey,
teaserKey,
handleDuration, handleDuration,
handleScrubberClickIndex, handleScrubberClickIndex,
@ -189,18 +189,18 @@ export default function FinishedDownloadsGalleryView({
enqueueToggleHot, enqueueToggleHot,
}: Props) { }: Props) {
// ✅ Teaser-Observer nur aktiv, wenn Preview überhaupt "laufen" soll // ✅ Teaser-Observer nur aktiv, wenn Preview überhaupt "laufen" soll
const shouldObserveTeasers = teaserPlayback === 'hover' || teaserPlayback === 'all' const observeTeasers = shouldObserveTeasers(teaserState.mode)
// ✅ Wrapper: bei still unregistrieren / nicht registrieren // ✅ Wrapper: bei still unregistrieren / nicht registrieren
const registerTeaserHostIfNeeded = React.useCallback( const registerTeaserHostIfNeeded = React.useCallback(
(key: string) => (el: HTMLDivElement | null) => { (key: string) => (el: HTMLDivElement | null) => {
if (!shouldObserveTeasers) { if (!observeTeasers) {
registerTeaserHost(key)(null) registerTeaserHost(key)(null)
return return
} }
registerTeaserHost(key)(el) registerTeaserHost(key)(el)
}, },
[registerTeaserHost, shouldObserveTeasers] [registerTeaserHost, observeTeasers]
) )
const parseTags = (raw?: string): string[] => { const parseTags = (raw?: string): string[] => {
@ -276,7 +276,10 @@ export default function FinishedDownloadsGalleryView({
const k = keyFor(j) const k = keyFor(j)
// Sound nur bei Hover auf genau diesem Teaser // Sound nur bei Hover auf genau diesem Teaser
const allowSound = Boolean(teaserAudio) && hoverTeaserKey === k const allowSound = shouldEnableTeaserAudio({
state: teaserState,
itemKey: k,
})
const previewMuted = !allowSound const previewMuted = !allowSound
const model = modelNameFromOutput(j.output) const model = modelNameFromOutput(j.output)
@ -490,15 +493,11 @@ export default function FinishedDownloadsGalleryView({
variant="fill" variant="fill"
showPopover={false} showPopover={false}
blur={blurPreviews} blur={blurPreviews}
animated={ animated={shouldAnimateTeaser({
hideTeaserUnderOverlay state: teaserState,
? false itemKey: k,
: teaserPlayback === 'all' disabled: hideTeaserUnderOverlay,
? true })}
: teaserPlayback === 'hover'
? teaserKey === k
: false
}
animatedMode="teaser" animatedMode="teaser"
animatedTrigger="always" animatedTrigger="always"
muted={previewMuted} muted={previewMuted}

View File

@ -5,12 +5,16 @@
import * as React from 'react' import * as React from 'react'
import Table, { type Column, type SortState } from './Table' import Table, { type Column, type SortState } from './Table'
import type { RecordJob } from '../../types' import type { RecordJob } from '../../types'
import FinishedVideoPreview from './FinishedVideoPreview' import FinishedVideoPreview from './FinishedVideoPreview'
import RecordJobActions from './RecordJobActions' import RecordJobActions from './RecordJobActions'
import TagOverflowRow from './TagOverflowRow' import TagOverflowRow from './TagOverflowRow'
import { isHotName, stripHotPrefix } from './hotName' import { isHotName, stripHotPrefix } from './hotName'
import { formatResolution } from './formatters' import { formatResolution } from './formatters'
import type { FinishedDownloadsTeaserState } from './teaserPlayback'
import {
shouldAnimateTeaser,
shouldEnableTeaserAudio,
} from './teaserPlayback'
type SortMode = type SortMode =
| 'completed_desc' | 'completed_desc'
@ -39,11 +43,8 @@ type Props = {
// teaser/preview // teaser/preview
canHover: boolean canHover: boolean
teaserAudio?: boolean teaserState: FinishedDownloadsTeaserState
hoverTeaserKey: string | null
setHoverTeaserKey: React.Dispatch<React.SetStateAction<string | null>> setHoverTeaserKey: React.Dispatch<React.SetStateAction<string | null>>
teaserPlayback?: 'still' | 'hover' | 'all'
teaserKey: string | null
registerTeaserHost: (key: string) => (el: HTMLDivElement | null) => void registerTeaserHost: (key: string) => (el: HTMLDivElement | null) => void
handleDuration: (job: RecordJob, seconds: number) => void handleDuration: (job: RecordJob, seconds: number) => void
handleResolution: (job: RecordJob, w: number, h: number) => void handleResolution: (job: RecordJob, w: number, h: number) => void
@ -95,11 +96,8 @@ export default function FinishedDownloadsTableView({
durations, durations,
canHover, canHover,
teaserAudio, teaserState,
hoverTeaserKey,
setHoverTeaserKey, setHoverTeaserKey,
teaserPlayback = 'hover',
teaserKey,
registerTeaserHost, registerTeaserHost,
handleDuration, handleDuration,
handleResolution, handleResolution,
@ -225,7 +223,10 @@ export default function FinishedDownloadsTableView({
widthClassName: 'w-[140px]', widthClassName: 'w-[140px]',
cell: (j) => { cell: (j) => {
const k = keyFor(j) const k = keyFor(j)
const allowSound = Boolean(teaserAudio) && hoverTeaserKey === k const allowSound = shouldEnableTeaserAudio({
state: teaserState,
itemKey: k,
})
const previewMuted = !allowSound const previewMuted = !allowSound
const spriteInfo = previewSpriteInfoOf(j) const spriteInfo = previewSpriteInfoOf(j)
const scrubActiveIndex = scrubActiveByKey[k] const scrubActiveIndex = scrubActiveByKey[k]
@ -268,7 +269,10 @@ export default function FinishedDownloadsTableView({
className="w-28 h-16 rounded-md ring-1 ring-black/5 dark:ring-white/10" className="w-28 h-16 rounded-md ring-1 ring-black/5 dark:ring-white/10"
showPopover={false} showPopover={false}
blur={blurPreviews} blur={blurPreviews}
animated={teaserPlayback === 'all' ? true : teaserPlayback === 'hover' ? teaserKey === k : false} animated={shouldAnimateTeaser({
state: teaserState,
itemKey: k,
})}
animatedMode="teaser" animatedMode="teaser"
animatedTrigger="always" animatedTrigger="always"
assetNonce={assetNonce} assetNonce={assetNonce}
@ -474,16 +478,13 @@ export default function FinishedDownloadsTableView({
keyFor, keyFor,
baseName, baseName,
durations, durations,
teaserAudio,
hoverTeaserKey,
registerTeaserHost, registerTeaserHost,
canHover, canHover,
setHoverTeaserKey, setHoverTeaserKey,
handleDuration, handleDuration,
handleResolution, handleResolution,
blurPreviews, blurPreviews,
teaserPlayback, teaserState,
teaserKey,
assetNonce, assetNonce,
lower, lower,
modelNameFromOutput, modelNameFromOutput,

View File

@ -0,0 +1,69 @@
// frontend\src\components\ui\teaserPlayback.ts
export type TeaserPlaybackMode = 'still' | 'hover' | 'all'
export type FinishedDownloadsTeaserState = {
mode: TeaserPlaybackMode
activeKey: string | null
hoverKey: string | null
audioEnabled: boolean
}
type TeaserAnimationOptions = {
state: FinishedDownloadsTeaserState
itemKey: string
disabled?: boolean
forceAll?: boolean
}
type TeaserAudioOptions = {
state: FinishedDownloadsTeaserState
itemKey: string
inlineActive?: boolean
disabled?: boolean
}
export function shouldObserveTeasers(mode: TeaserPlaybackMode): boolean {
return mode === 'hover' || mode === 'all'
}
export function shouldAnimateTeaser({
state,
itemKey,
disabled = false,
forceAll = false,
}: TeaserAnimationOptions): boolean {
if (disabled) return false
if (forceAll) return true
switch (state.mode) {
case 'all':
return true
case 'hover':
return state.hoverKey === itemKey
case 'still':
default:
return false
}
}
export function shouldEnableTeaserAudio({
state,
itemKey,
inlineActive = false,
disabled = false,
}: TeaserAudioOptions): boolean {
if (disabled) return false
if (!state.audioEnabled) return false
// Inline immer bevorzugen
if (inlineActive) return true
// Desktop-Hover
if (state.hoverKey === itemKey) return true
// Mobile / aktiver Teaser im Viewport
if (state.activeKey === itemKey) return true
return false
}