import React, {useEffect, useRef, useState} from 'react'; import {cancelJob, fetchVideoDownloadDefaults, getJob, transcodeVideo, updateVideo} from '../api'; import {useWROLMode} from '../hooks/customHooks'; import { Button, Confirm, Group, Icon, Menu, Modal, Progress, Select, Stack, Status, Text, Textarea, TextInput, Toggle, toast, } from './ui'; import { audioContainerForCodec, TRANSCODE_COPY, TRANSCODE_REMOVE_AUDIO, TRANSCODE_REMOVE_VIDEO, transcodeAudioCodecOptions, transcodeAudioContainerOptions, transcodeContainerOptions, transcodeVideoCodecOptions, } from './Vars'; const JOB_POLL_MS = 2000; // Consecutive failed polls before a Job is given up as lost (the API restarted and forgot it). const JOB_POLL_MAX_FAILURES = 5; export const FINISHED_STATUSES = ['complete', 'failed', 'cancelled']; // ffprobe calls an embedded thumbnail a video stream. const THUMBNAIL_CODECS = ['mjpeg', 'png', 'bmp', 'gif']; export const isJobFinished = (job) => !!job && FINISHED_STATUSES.includes(job.status); /** The codec names of a Video's real streams, from the `codec_names`/`codec_types` the API sends. */ export function currentCodecs(video) { const names = video?.codec_names || []; const types = video?.codec_types || []; const video_ = names.filter((name, i) => types[i] === 'video' && !THUMBNAIL_CODECS.includes(name)); const audio = names.filter((name, i) => types[i] === 'audio'); return {video: video_, audio}; } /** * The transcode target implied by the user's ordered codec preferences: the first preferred codec * that ffmpeg can produce (the same rule the downloader uses). `copy` when none can be. */ export function preferredTarget(preferences, options) { const producible = options.map(i => i.value).filter(i => i !== TRANSCODE_COPY); for (const codec of preferences || []) { if (producible.includes(codec)) { return codec; } } return TRANSCODE_COPY; } /** The container of a video (or audio) file, when it is one ffmpeg can write for us. */ export function currentContainer(video) { const suffix = String(video?.video_path || '').split('.').pop().toLowerCase(); const known = [...transcodeContainerOptions, ...transcodeAudioContainerOptions]; return known.some(i => i.value === suffix) ? suffix : null; } /** * The container to offer for the chosen codecs: audio-only output gets the natural container of * the audio codec that will be in it (the target, or the source's when copied); output with video * keeps the file's own container when possible. */ export function containerFor({audioOutput, audioCodec, video}) { if (audioOutput) { const codec = audioCodec !== TRANSCODE_COPY ? audioCodec : currentCodecs(video).audio[0]; return audioContainerForCodec(codec); } const current = currentContainer(video); return transcodeContainerOptions.some(i => i.value === current) ? current : 'mp4'; } /** * The video codec choices: Remove video is withdrawn once the audio is being removed (nothing * would remain). */ export function videoCodecOptionsFor({removeAudio}) { return removeAudio ? transcodeVideoCodecOptions.filter(i => i.value !== TRANSCODE_REMOVE_VIDEO) : transcodeVideoCodecOptions; } /** * The audio codec choices: Remove audio is only for a video whose video stream is kept; an audio * file has nothing else, and removing the video already chose the audio as the result. */ export function audioCodecOptionsFor({audioOnly, removeVideo}) { return (audioOnly || removeVideo) ? transcodeAudioCodecOptions.filter(i => i.value !== TRANSCODE_REMOVE_AUDIO) : transcodeAudioCodecOptions; } /** * The codec pair after one select changes. Both streams can never be removed (nothing would * remain): choosing Remove for one stream puts the other back to Keep. */ export function chooseCodecs({videoCodec, audioCodec}, {video, audio}) { let nextVideo = video !== undefined ? (video || TRANSCODE_COPY) : videoCodec; let nextAudio = audio !== undefined ? (audio || TRANSCODE_COPY) : audioCodec; if (nextVideo === TRANSCODE_REMOVE_VIDEO && nextAudio === TRANSCODE_REMOVE_AUDIO) { if (video !== undefined) { nextAudio = TRANSCODE_COPY; } else { nextVideo = TRANSCODE_COPY; } } return {videoCodec: nextVideo, audioCodec: nextAudio}; } const statusKind = (status) => { if (status === 'complete') return 'complete'; if (status === 'running') return 'active'; if (status === 'failed' || status === 'cancelled') return 'failed'; return 'pending'; } /** * Poll a Job until it finishes; `onFinished(job)` is called once. A failed request is retried; * a 404 (the API restarted and forgot its Jobs) or `JOB_POLL_MAX_FAILURES` failures in a row stop * the polling and call `onLost(error)` instead. */ export function useJob(jobId, onFinished, onLost) { const [job, setJob] = useState(null); const onFinishedRef = useRef(onFinished); const onLostRef = useRef(onLost); onFinishedRef.current = onFinished; onLostRef.current = onLost; useEffect(() => { setJob(null); if (!jobId) { return; } let stopped = false; let timer = null; let failures = 0; const poll = async () => { let latest = null; try { latest = await getJob(jobId); failures = 0; } catch (e) { console.error(e); failures += 1; if (!stopped && (e?.status === 404 || failures >= JOB_POLL_MAX_FAILURES)) { if (onLostRef.current) onLostRef.current(e); return; } } if (stopped) return; if (latest) { setJob(latest); if (isJobFinished(latest)) { if (onFinishedRef.current) onFinishedRef.current(latest); return; } } timer = window.setTimeout(poll, JOB_POLL_MS); }; poll(); return () => { stopped = true; clearTimeout(timer); }; }, [jobId]); const cancel = async () => { const latest = await cancelJob(jobId); setJob(latest); return latest; }; return {job, cancel}; } /** Progress, status and log tail of one Job. */ export function JobProgress({job, onCancel}) { const [cancelling, setCancelling] = useState(false); const handleCancel = async () => { setCancelling(true); try { await onCancel(); } catch (e) { console.error(e); } finally { setCancelling(false); } }; if (!job) { return ; } const finished = isJobFinished(job); const percent = job.progress ?? 0; const logTail = (job.log || []).slice(-8); return {job.status} {!finished && onCancel && } {job.error && {job.error}} {logTail.length > 0 &&
{logTail.join('\n')}
}
} /** * Choose target codecs for one Video and queue the transcode Job (`onQueued(jobId)`), or show the * given `job`. The selects start at the user's preferred codecs from the Videos settings. */ export function TranscodeModal({ open, onClose, fileGroupId, video, audioOnly = false, job, jobId, onQueued, onCancelJob, }) { const wrolModeEnabled = useWROLMode(); const [videoCodec, setVideoCodec] = useState(TRANSCODE_COPY); const [audioCodec, setAudioCodec] = useState(TRANSCODE_COPY); const [container, setContainer] = useState('mp4'); const [fragmented, setFragmented] = useState(false); const [defaultsLoaded, setDefaultsLoaded] = useState(false); const [submitting, setSubmitting] = useState(false); const current = currentCodecs(video); const removeVideo = videoCodec === TRANSCODE_REMOVE_VIDEO; const removeAudio = !audioOnly && !removeVideo && audioCodec === TRANSCODE_REMOVE_AUDIO; // An audio file has no video stream to keep or remove; removing the video makes an audio file. const audioOutput = audioOnly || removeVideo; useEffect(() => { if (!open) { return; } setDefaultsLoaded(false); setFragmented(false); let stale = false; const load = async () => { let nextVideo = TRANSCODE_COPY; let nextAudio = TRANSCODE_COPY; try { const defaults = await fetchVideoDownloadDefaults(); if (stale) return; nextVideo = audioOnly ? TRANSCODE_COPY : preferredTarget(defaults?.video_codecs, transcodeVideoCodecOptions); nextAudio = preferredTarget(defaults?.audio_codecs, transcodeAudioCodecOptions); } catch (e) { console.error(e); } finally { if (!stale) { setVideoCodec(nextVideo); setAudioCodec(nextAudio); setContainer(containerFor({audioOutput: audioOnly, audioCodec: nextAudio, video})); setDefaultsLoaded(true); } } }; load(); return () => { stale = true; }; }, [open]); // `chooseCodecs` keeps Remove video and Remove audio from ever being chosen together; the // other select also withdraws its Remove option (see `videoCodecOptionsFor`/`audioCodecOptionsFor`). const applyCodecs = (change) => { const next = chooseCodecs({videoCodec, audioCodec}, change); setVideoCodec(next.videoCodec); setAudioCodec(next.audioCodec); setContainer(containerFor({ audioOutput: audioOnly || next.videoCodec === TRANSCODE_REMOVE_VIDEO, audioCodec: next.audioCodec, video, })); }; const changeVideoCodec = (value) => applyCodecs({video: value}); const changeAudioCodec = (value) => applyCodecs({audio: value}); const remuxOnly = !removeVideo && !removeAudio && videoCodec === TRANSCODE_COPY && audioCodec === TRANSCODE_COPY; const sameContainer = container === currentContainer(video); const containerOptions = audioOutput ? transcodeAudioContainerOptions : transcodeContainerOptions; // Fragments are an mp4 concept (m4a is an mp4). const canFragment = container === 'mp4' || container === 'm4a'; const handleStart = async () => { setSubmitting(true); try { const id = await transcodeVideo(fileGroupId, { video_codec: videoCodec === TRANSCODE_COPY ? null : videoCodec, audio_codec: audioCodec === TRANSCODE_COPY || (removeVideo && audioCodec === TRANSCODE_REMOVE_AUDIO) ? null : audioCodec, container, fragmented: canFragment && fragmented, }); onQueued(id); } catch (e) { // transcodeVideo already toasted. console.error(e); } finally { setSubmitting(false); } }; const describe = (codecs) => codecs.length ? codecs.join(', ') : 'unknown'; const showJob = !!jobId; let hint = null; if (defaultsLoaded && removeVideo) { hint = `The video stream is removed; the result is an audio file (.${container}). ` + (audioCodec === TRANSCODE_COPY ? 'The audio is copied without re-encoding.' : `The audio is converted to ${audioCodec}.`); } else if (defaultsLoaded && removeAudio) { hint = `The audio stream is removed; the result is a silent video (.${container}). ` + (videoCodec === TRANSCODE_COPY ? 'The video is copied without re-encoding.' : `The video is converted to ${videoCodec}.`); } else if (defaultsLoaded && remuxOnly) { const kept = audioOnly ? 'The audio is kept' : 'Both streams are kept'; if (sameContainer && canFragment && fragmented) { hint = `${kept}: the file is rewritten as-is in fragments (a quick remux, no quality loss).`; } else if (sameContainer) { hint = `${kept}: the file is rewritten as-is` + (canFragment ? ' with fast start (moves the index to the front for quicker playback start).' : '.'); } else { hint = `${kept}: only the container changes to ${container} (a quick remux, no quality loss).`; } } const buttonLabel = removeVideo ? 'Extract Audio' : removeAudio ? 'Remove Audio' : remuxOnly ? 'Remux' : 'Transcode'; return {audioOnly ? 'Transcode Audio' : 'Transcode Video'} {audioOnly ? `Current codec: audio ${describe(current.audio)}. ` : `Current codecs: video ${describe(current.video)}, audio ${describe(current.audio)}. `} Streams set to Keep are copied without re-encoding. Transcoding is slow and slightly reduces quality; the original file is replaced when it finishes. {!showJob && <> {!audioOnly &&