diff --git a/README.md b/README.md index 77240a99..bb5123e0 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,7 @@ The main feature is lossless trimming and cutting of video and audio files, whic - Losslessly split a video into one file per scene (note you probably have to shift segments, see [#330](https://github.com/mifi/lossless-cut/issues/330).) - Cut away silent parts of an audio/video - Split video into segments to for example respect Twitter's 140 second limit +- Annotate each segment with one or more tags, then use those tags to organize your segments or use it to create an output folder structure or hierarchy for your segments. ### Export cut times as YouTube Chapters 1. Export with Merge and "Create chapters from merged segments" enabled diff --git a/src/App.jsx b/src/App.jsx index f80a9669..648f610b 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -355,7 +355,7 @@ const App = memo(() => { }, [isFileOpened]); const { - cutSegments, cutSegmentsHistory, createSegmentsFromKeyframes, shuffleSegments, detectBlackScenes, detectSilentScenes, detectSceneChanges, removeCutSegment, invertAllSegments, fillSegmentsGaps, combineOverlappingSegments, combineSelectedSegments, shiftAllSegmentTimes, alignSegmentTimesToKeyframes, onViewSegmentTags, updateSegOrder, updateSegOrders, reorderSegsByStartTime, addSegment, setCutStart, setCutEnd, onLabelSegment, splitCurrentSegment, createNumSegments, createFixedDurationSegments, createRandomSegments, apparentCutSegments, haveInvalidSegs, currentSegIndexSafe, currentCutSeg, currentApparentCutSeg, inverseCutSegments, clearSegments, loadCutSegments, isSegmentSelected, setCutTime, setCurrentSegIndex, onLabelSelectedSegments, deselectAllSegments, selectAllSegments, selectOnlyCurrentSegment, toggleCurrentSegmentSelected, invertSelectedSegments, removeSelectedSegments, setDeselectedSegmentIds, onSelectSegmentsByLabel, toggleSegmentSelected, selectOnlySegment, getApparentCutSegmentById, selectedSegments, selectedSegmentsOrInverse, nonFilteredSegmentsOrInverse, segmentsToExport, duplicateCurrentSegment, duplicateSegment, + cutSegments, cutSegmentsHistory, createSegmentsFromKeyframes, shuffleSegments, detectBlackScenes, detectSilentScenes, detectSceneChanges, removeCutSegment, invertAllSegments, fillSegmentsGaps, combineOverlappingSegments, combineSelectedSegments, shiftAllSegmentTimes, alignSegmentTimesToKeyframes, onViewSegmentTags, updateSegOrder, updateSegOrders, reorderSegsByStartTime, addSegment, setCutStart, setCutEnd, onLabelSegment, splitCurrentSegment, createNumSegments, createFixedDurationSegments, createRandomSegments, apparentCutSegments, haveInvalidSegs, currentSegIndexSafe, currentCutSeg, currentApparentCutSeg, inverseCutSegments, clearSegments, loadCutSegments, isSegmentSelected, setCutTime, setCurrentSegIndex, onLabelSelectedSegments, deselectAllSegments, selectAllSegments, selectOnlyCurrentSegment, toggleCurrentSegmentSelected, invertSelectedSegments, removeSelectedSegments, setDeselectedSegmentIds, onSelectSegmentsByLabel, onSelectSegmentsByTag, toggleSegmentSelected, selectOnlySegment, getApparentCutSegmentById, selectedSegments, selectedSegmentsOrInverse, nonFilteredSegmentsOrInverse, segmentsToExport, duplicateCurrentSegment, duplicateSegment, } = useSegments({ filePath, workingRef, setWorking, setCutProgress, mainVideoStream, duration, getRelevantTime, maxLabelLength, checkFileOpened, invertCutSegments, segmentsToChaptersOnly }); const jumpSegStart = useCallback((index) => userSeekAbs(apparentCutSegments[index].start), [apparentCutSegments, userSeekAbs]); @@ -2386,6 +2386,7 @@ const App = memo(() => { jumpSegEnd={jumpSegEnd} onViewSegmentTags={onViewSegmentTags} onSelectSegmentsByLabel={onSelectSegmentsByLabel} + onSelectSegmentsByTag={onSelectSegmentsByTag} onLabelSelectedSegments={onLabelSelectedSegments} /> )} diff --git a/src/SegmentList.jsx b/src/SegmentList.jsx index 40c676bd..eee6e748 100644 --- a/src/SegmentList.jsx +++ b/src/SegmentList.jsx @@ -23,7 +23,7 @@ const buttonBaseStyle = { const neutralButtonColor = 'var(--gray8)'; -const Segment = memo(({ darkMode, seg, index, currentSegIndex, formatTimecode, getFrameCount, updateOrder, invertCutSegments, onClick, onRemovePress, onRemoveSelected, onLabelSelectedSegments, onReorderPress, onLabelPress, selected, onSelectSingleSegment, onToggleSegmentSelected, onDeselectAllSegments, onSelectSegmentsByLabel, onSelectAllSegments, jumpSegStart, jumpSegEnd, addSegment, onViewSegmentTags, onExtractSegmentFramesAsImages, onInvertSelectedSegments, onDuplicateSegmentClick }) => { +const Segment = memo(({ darkMode, seg, index, currentSegIndex, formatTimecode, getFrameCount, updateOrder, invertCutSegments, onClick, onRemovePress, onRemoveSelected, onLabelSelectedSegments, onReorderPress, onLabelPress, selected, onSelectSingleSegment, onToggleSegmentSelected, onDeselectAllSegments, onSelectSegmentsByLabel, onSelectSegmentsByTag, onSelectAllSegments, jumpSegStart, jumpSegEnd, addSegment, onViewSegmentTags, onExtractSegmentFramesAsImages, onInvertSelectedSegments, onDuplicateSegmentClick }) => { const { t } = useTranslation(); const { getSegColor } = useSegColors(); @@ -48,6 +48,7 @@ const Segment = memo(({ darkMode, seg, index, currentSegIndex, formatTimecode, g { label: t('Select all segments'), click: () => onSelectAllSegments() }, { label: t('Deselect all segments'), click: () => onDeselectAllSegments() }, { label: t('Select segments by label'), click: () => onSelectSegmentsByLabel(seg) }, + { label: t('Select segments by tag'), click: () => onSelectSegmentsByTag(seg) }, { label: t('Invert selected segments'), click: () => onInvertSelectedSegments() }, { type: 'separator' }, @@ -66,7 +67,7 @@ const Segment = memo(({ darkMode, seg, index, currentSegIndex, formatTimecode, g { label: t('Segment tags'), click: () => onViewSegmentTags(index) }, { label: t('Extract frames as image files'), click: () => onExtractSegmentFramesAsImages([seg.segId]) }, ]; - }, [invertCutSegments, t, jumpSegStart, jumpSegEnd, addSegment, onLabelPress, onRemovePress, onLabelSelectedSegments, onRemoveSelected, onReorderPress, onDuplicateSegmentClick, seg, onSelectSingleSegment, onSelectAllSegments, onDeselectAllSegments, onSelectSegmentsByLabel, onInvertSelectedSegments, updateOrder, onViewSegmentTags, index, onExtractSegmentFramesAsImages]); + }, [invertCutSegments, t, jumpSegStart, jumpSegEnd, addSegment, onLabelPress, onRemovePress, onLabelSelectedSegments, onRemoveSelected, onReorderPress, onDuplicateSegmentClick, seg, onSelectSingleSegment, onSelectAllSegments, onDeselectAllSegments, onSelectSegmentsByLabel, onSelectSegmentsByTag, onInvertSelectedSegments, updateOrder, onViewSegmentTags, index, onExtractSegmentFramesAsImages]); useContextMenu(ref, contextMenuTemplate); @@ -157,7 +158,7 @@ const SegmentList = memo(({ currentSegIndex, updateSegOrder, updateSegOrders, addSegment, removeCutSegment, onRemoveSelected, onLabelSegment, currentCutSeg, segmentAtCursor, toggleSegmentsList, splitCurrentSegment, - selectedSegments, isSegmentSelected, onSelectSingleSegment, onToggleSegmentSelected, onDeselectAllSegments, onSelectAllSegments, onSelectSegmentsByLabel, onExtractSegmentFramesAsImages, onLabelSelectedSegments, onInvertSelectedSegments, onDuplicateSegmentClick, + selectedSegments, isSegmentSelected, onSelectSingleSegment, onToggleSegmentSelected, onDeselectAllSegments, onSelectAllSegments, onSelectSegmentsByLabel, onSelectSegmentsByTag, onExtractSegmentFramesAsImages, onLabelSelectedSegments, onInvertSelectedSegments, onDuplicateSegmentClick, jumpSegStart, jumpSegEnd, onViewSegmentTags, }) => { const { t } = useTranslation(); @@ -318,6 +319,7 @@ const SegmentList = memo(({ onSelectAllSegments={onSelectAllSegments} onViewSegmentTags={onViewSegmentTags} onSelectSegmentsByLabel={onSelectSegmentsByLabel} + onSelectSegmentsByTag={onSelectSegmentsByTag} onExtractSegmentFramesAsImages={onExtractSegmentFramesAsImages} onLabelSelectedSegments={onLabelSelectedSegments} onInvertSelectedSegments={onInvertSelectedSegments} diff --git a/src/dialogs/index.jsx b/src/dialogs/index.jsx index 24bcf279..152aa08c 100644 --- a/src/dialogs/index.jsx +++ b/src/dialogs/index.jsx @@ -505,6 +505,26 @@ export async function selectSegmentsByLabelDialog(currentName) { return value; } +export async function selectSegmentsByTagDialog() { + const { value: value1 } = await Swal.fire({ + showCancelButton: true, + title: i18n.t('Select segments by tag'), + text: i18n.t('Enter tag name (in the next dialog you\'ll enter tag value)'), + input: 'text', + }); + if (!value1) return undefined; + + const { value: value2 } = await Swal.fire({ + showCancelButton: true, + title: i18n.t('Select segments by tag'), + text: i18n.t('Enter tag value'), + input: 'text', + }); + if (!value2) return undefined; + + return { tagName: value1, tagValue: value2 }; +} + export async function showEditableJsonDialog({ text, title, inputLabel, inputValue, inputValidator }) { const { value } = await Swal.fire({ input: 'textarea', diff --git a/src/hooks/useSegments.js b/src/hooks/useSegments.js index eaadc9a5..ad95726b 100644 --- a/src/hooks/useSegments.js +++ b/src/hooks/useSegments.js @@ -10,7 +10,7 @@ import { blackDetect, silenceDetect, detectSceneChanges as ffmpegDetectSceneChan import { handleError, shuffleArray } from '../util'; import { errorToast } from '../swal'; import { showParametersDialog } from '../dialogs/parameters'; -import { createNumSegments as createNumSegmentsDialog, createFixedDurationSegments as createFixedDurationSegmentsDialog, createRandomSegments as createRandomSegmentsDialog, labelSegmentDialog, showEditableJsonDialog, askForShiftSegments, askForAlignSegments, selectSegmentsByLabelDialog } from '../dialogs'; +import { createNumSegments as createNumSegmentsDialog, createFixedDurationSegments as createFixedDurationSegmentsDialog, createRandomSegments as createRandomSegmentsDialog, labelSegmentDialog, showEditableJsonDialog, askForShiftSegments, askForAlignSegments, selectSegmentsByLabelDialog, selectSegmentsByTagDialog } from '../dialogs'; import { createSegment, findSegmentsAtCursor, sortSegments, invertSegments, getSegmentTags, combineOverlappingSegments as combineOverlappingSegments2, combineSelectedSegments as combineSelectedSegments2, isDurationValid, getSegApparentStart, getSegApparentEnd as getSegApparentEnd2 } from '../segments'; import * as ffmpegParameters from '../ffmpeg-parameters'; import { maxSegmentsAllowed } from '../util/constants'; @@ -436,18 +436,31 @@ export default ({ if (segments) loadCutSegments(segments); }, [checkFileOpened, duration, loadCutSegments]); - const onSelectSegmentsByLabel = useCallback(async () => { - const { name } = currentCutSeg; - const value = await selectSegmentsByLabelDialog(name); - if (value == null) return; - const segmentsToEnable = cutSegments.filter((seg) => (seg.name || '') === value); + const enableSegments = useCallback((segmentsToEnable) => { if (segmentsToEnable.length === 0 || segmentsToEnable.length === cutSegments.length) return; // no point setDeselectedSegmentIds((existing) => { const ret = { ...existing }; segmentsToEnable.forEach(({ segId }) => { ret[segId] = false; }); return ret; }); - }, [currentCutSeg, cutSegments]); + }, [cutSegments.length]); + + const onSelectSegmentsByLabel = useCallback(async () => { + const { name } = currentCutSeg; + const value = await selectSegmentsByLabelDialog(name); + if (value == null) return; + const segmentsToEnable = cutSegments.filter((seg) => (seg.name || '') === value); + enableSegments(segmentsToEnable); + }, [currentCutSeg, cutSegments, enableSegments]); + + const onSelectSegmentsByTag = useCallback(async () => { + const value = await selectSegmentsByTagDialog(); + if (value == null) return; + const { tagName, tagValue } = value; + const segmentsToEnable = cutSegments.filter((seg) => getSegmentTags(seg)[tagName] === tagValue); + enableSegments(segmentsToEnable); + }, [cutSegments, enableSegments]); + const onLabelSelectedSegments = useCallback(async () => { if (selectedSegmentsRaw.length < 1) return; @@ -540,6 +553,7 @@ export default ({ invertSelectedSegments, removeSelectedSegments, onSelectSegmentsByLabel, + onSelectSegmentsByTag, toggleSegmentSelected, selectOnlySegment, setCutTime,