diff --git a/app/src/api.js b/app/src/api.js index 2d57e456..b06ef557 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -236,6 +236,65 @@ export async function getVideo(fileGroupId) { return [data['file_group'], data['prev'], data['next']]; } +export async function updateVideo(fileGroupId, {title, description}) { + // Edits the Video's details (written to its info json). Only the fields given are changed. + // Returns the updated video file group. + const response = await apiPut(`${VIDEOS_API}/${fileGroupId}`, {title, description}); + if (!response.ok) { + const message = await getErrorMessage(response, 'Unable to save the video. See server logs.'); + toast({type: 'error', title: 'Failed to save', description: message, time: 5000}); + throw new Error(message); + } + const data = await response.json(); + return data['file_group']; +} + +export async function transcodeVideo(fileGroupId, { + video_codec = null, audio_codec = null, container = 'mp4', fragmented = false, +}) { + // Queues a transcode Job; returns the job id. Watch it with `getJob`. + const body = {video_codec, audio_codec, container, fragmented}; + const response = await apiPost(`${VIDEOS_API}/${fileGroupId}/transcode`, body); + if (!response.ok) { + const message = await getErrorMessage(response, 'Unable to queue the transcode. See server logs.'); + toast({type: 'error', title: 'Failed to transcode', description: message, time: 5000}); + throw new Error(message); + } + const data = await response.json(); + return data['job_id']; +} + +export async function getJobs() { + const response = await apiGet(`${API_URI}/jobs`); + if (!response.ok) { + throw new Error(await getErrorMessage(response, 'Unable to get the jobs')); + } + const data = await response.json(); + return data['jobs']; +} + +export async function getJob(jobId) { + const response = await apiGet(`${API_URI}/jobs/${jobId}`); + if (!response.ok) { + const error = new Error(await getErrorMessage(response, 'Unable to get the job')); + error.status = response.status; + throw error; + } + const data = await response.json(); + return data['job']; +} + +export async function cancelJob(jobId) { + const response = await apiDelete(`${API_URI}/jobs/${jobId}`); + if (!response.ok) { + const message = await getErrorMessage(response, 'Unable to cancel the job'); + toast({type: 'error', title: 'Failed to cancel', description: message, time: 5000}); + throw new Error(message); + } + const data = await response.json(); + return data['job']; +} + export async function getVideoComments(fileGroupId) { const response = await apiGet(`${VIDEOS_API}/${fileGroupId}/comments`); let data = await response.json(); diff --git a/app/src/components/ThemeSamplePage.js b/app/src/components/ThemeSamplePage.js index 7767a317..3f95df25 100644 --- a/app/src/components/ThemeSamplePage.js +++ b/app/src/components/ThemeSamplePage.js @@ -16,6 +16,7 @@ import { IconStack, MediaFilterToggle, MediaGate, + Menu, Message, MultiSelect, NumberInput, @@ -382,6 +383,22 @@ export function ThemeSamplePage() { )} +
+
A dropdown of actions
+ {/* Mirrors the video page's Edit control. */} + + + + + + + }>Refresh + }>Transcode... + } disabled>Unavailable + + + +
A row of actions
{/* `.wrolpi-button-row` is how a page lists the actions on a thing -- the diff --git a/app/src/components/Vars.js b/app/src/components/Vars.js index 02d293c3..01631236 100644 --- a/app/src/components/Vars.js +++ b/app/src/components/Vars.js @@ -158,6 +158,41 @@ export const downloadAudioCodecOptions = [ {key: 'vorbis', text: 'vorbis', value: 'vorbis'}, ]; +// Codecs WROLPi's ffmpeg step can produce. Mirrors TRANSCODE_VIDEO_TARGETS / TRANSCODE_AUDIO_TARGETS / +// TRANSCODE_CONTAINERS in modules/videos/transcode.py; the API rejects anything else. +export const TRANSCODE_COPY = 'copy'; +// REMOVE_VIDEO in modules/videos/transcode.py: drop the video stream, the result is an audio file. +export const TRANSCODE_REMOVE_VIDEO = 'none'; +export const transcodeVideoCodecOptions = [ + {value: TRANSCODE_COPY, label: 'Keep (copy)'}, + {value: 'h264', label: 'h264 (avc1)'}, + {value: TRANSCODE_REMOVE_VIDEO, label: 'Remove video (audio only)'}, +]; +export const transcodeAudioCodecOptions = [ + {value: TRANSCODE_COPY, label: 'Keep (copy)'}, + {value: 'aac', label: 'aac'}, + {value: 'opus', label: 'opus'}, + {value: 'mp3', label: 'mp3'}, +]; +// Containers for output with a video stream ... +export const transcodeContainerOptions = [ + {value: 'mp4', label: 'mp4'}, + {value: 'mkv', label: 'mkv'}, +]; +// ... and for audio-only output. AUDIO_CONTAINER_CODECS in transcode.py says what each holds. +export const transcodeAudioContainerOptions = [ + {value: 'm4a', label: 'm4a (aac)'}, + {value: 'ogg', label: 'ogg (opus)'}, + {value: 'mp3', label: 'mp3'}, +]; + +/** The audio container a player expects for a codec (default_audio_container in transcode.py). */ +export function audioContainerForCodec(codec) { + if (codec === 'mp3') return 'mp3'; + if (['opus', 'vorbis', 'flac'].includes(codec)) return 'ogg'; + return 'm4a'; +} + export const Downloaders = { Archive: 'archive', File: 'file', diff --git a/app/src/components/VideoEditMenu.js b/app/src/components/VideoEditMenu.js new file mode 100644 index 00000000..5fa2eee6 --- /dev/null +++ b/app/src/components/VideoEditMenu.js @@ -0,0 +1,612 @@ +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_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'; +} + +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; + // 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]); + + const changeVideoCodec = (value) => { + const next = value || TRANSCODE_COPY; + setVideoCodec(next); + setContainer(containerFor({audioOutput: audioOnly || next === TRANSCODE_REMOVE_VIDEO, audioCodec, video})); + }; + const changeAudioCodec = (value) => { + const next = value || TRANSCODE_COPY; + setAudioCodec(next); + setContainer(containerFor({audioOutput, audioCodec: next, video})); + }; + + const remuxOnly = !removeVideo && 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 ? 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 && 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' : 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 && +