1
0
mirror of https://github.com/mifi/lossless-cut.git synced 2024-11-22 02:12:30 +01:00

improve segment tags editor

closes #1766
This commit is contained in:
Mikael Finstad 2023-11-07 14:19:26 +09:00
parent 0155d4c567
commit a32a1a35de
No known key found for this signature in database
GPG Key ID: 25AB36E3E81CBC26
6 changed files with 227 additions and 202 deletions

View File

@ -361,7 +361,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, onSelectSegmentsByTag, toggleSegmentSelected, selectOnlySegment, getApparentCutSegmentById, selectedSegments, selectedSegmentsOrInverse, nonFilteredSegmentsOrInverse, segmentsToExport, duplicateCurrentSegment, duplicateSegment,
cutSegments, cutSegmentsHistory, createSegmentsFromKeyframes, shuffleSegments, detectBlackScenes, detectSilentScenes, detectSceneChanges, removeCutSegment, invertAllSegments, fillSegmentsGaps, combineOverlappingSegments, combineSelectedSegments, shiftAllSegmentTimes, alignSegmentTimesToKeyframes, 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, updateSegAtIndex,
} = useSegments({ filePath, workingRef, setWorking, setCutProgress, mainVideoStream, duration, getRelevantTime, maxLabelLength, checkFileOpened, invertCutSegments, segmentsToChaptersOnly });
const jumpSegStart = useCallback((index) => userSeekAbs(apparentCutSegments[index].start), [apparentCutSegments, userSeekAbs]);
@ -2418,10 +2418,10 @@ const App = memo(() => {
onExtractSegmentFramesAsImages={extractSegmentFramesAsImages}
jumpSegStart={jumpSegStart}
jumpSegEnd={jumpSegEnd}
onViewSegmentTags={onViewSegmentTags}
onSelectSegmentsByLabel={onSelectSegmentsByLabel}
onSelectSegmentsByTag={onSelectSegmentsByTag}
onLabelSelectedSegments={onLabelSelectedSegments}
updateSegAtIndex={updateSegAtIndex}
/>
)}
</AnimatePresence>

View File

@ -1,4 +1,4 @@
import React, { memo, useMemo, useRef, useCallback } from 'react';
import React, { memo, useMemo, useRef, useCallback, useState } from 'react';
import { FaYinYang, FaSave, FaPlus, FaMinus, FaTag, FaSortNumericDown, FaAngleRight, FaRegCheckCircle, FaRegCircle } from 'react-icons/fa';
import { AiOutlineSplitCells } from 'react-icons/ai';
import { motion } from 'framer-motion';
@ -7,6 +7,7 @@ import { ReactSortable } from 'react-sortablejs';
import isEqual from 'lodash/isEqual';
import useDebounce from 'react-use/lib/useDebounce';
import scrollIntoView from 'scroll-into-view-if-needed';
import { Dialog } from 'evergreen-ui';
import Swal from './swal';
import useContextMenu from './hooks/useContextMenu';
@ -15,6 +16,7 @@ import { saveColor, controlsBackground, primaryTextColor, darkModeTransition } f
import { useSegColors } from './contexts';
import { mySpring } from './animations';
import { getSegmentTags } from './segments';
import TagEditor from './components/TagEditor';
const buttonBaseStyle = {
margin: '0 3px', borderRadius: 3, color: 'white', cursor: 'pointer',
@ -23,7 +25,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, onSelectSegmentsByTag, 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, onEditSegmentTags, onExtractSegmentFramesAsImages, onInvertSelectedSegments, onDuplicateSegmentClick }) => {
const { t } = useTranslation();
const { getSegColor } = useSegColors();
@ -64,10 +66,10 @@ const Segment = memo(({ darkMode, seg, index, currentSegIndex, formatTimecode, g
{ type: 'separator' },
{ label: t('Segment tags'), click: () => onViewSegmentTags(index) },
{ label: t('Segment tags'), click: () => onEditSegmentTags(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, onSelectSegmentsByTag, onInvertSelectedSegments, updateOrder, onViewSegmentTags, index, onExtractSegmentFramesAsImages]);
}, [invertCutSegments, t, jumpSegStart, jumpSegEnd, addSegment, onLabelPress, onRemovePress, onLabelSelectedSegments, onRemoveSelected, onReorderPress, onDuplicateSegmentClick, seg, onSelectSingleSegment, onSelectAllSegments, onDeselectAllSegments, onSelectSegmentsByLabel, onSelectSegmentsByTag, onInvertSelectedSegments, updateOrder, onEditSegmentTags, index, onExtractSegmentFramesAsImages]);
useContextMenu(ref, contextMenuTemplate);
@ -159,7 +161,7 @@ const SegmentList = memo(({
updateSegOrder, updateSegOrders, addSegment, removeCutSegment, onRemoveSelected,
onLabelSegment, currentCutSeg, segmentAtCursor, toggleSegmentsList, splitCurrentSegment,
selectedSegments, isSegmentSelected, onSelectSingleSegment, onToggleSegmentSelected, onDeselectAllSegments, onSelectAllSegments, onSelectSegmentsByLabel, onSelectSegmentsByTag, onExtractSegmentFramesAsImages, onLabelSelectedSegments, onInvertSelectedSegments, onDuplicateSegmentClick,
jumpSegStart, jumpSegEnd, onViewSegmentTags,
jumpSegStart, jumpSegEnd, updateSegAtIndex,
}) => {
const { t } = useTranslation();
const { getSegColor } = useSegColors();
@ -269,69 +271,111 @@ const SegmentList = memo(({
);
}
const [editingSegmentTagsSegmentIndex, setEditingSegmentTagsSegmentIndex] = useState();
const [editingSegmentTags, setEditingSegmentTags] = useState();
const [editingTag, setEditingTag] = useState();
const onTagChange = useCallback((tag, value) => setEditingSegmentTags((existingTags) => ({
...existingTags,
[tag]: value,
})), []);
const onTagReset = useCallback((tag) => setEditingSegmentTags(({ [tag]: deleted, ...rest }) => rest), []);
const onEditSegmentTags = useCallback((index) => {
setEditingSegmentTagsSegmentIndex(index);
setEditingSegmentTags(getSegmentTags(apparentCutSegments[index]));
}, [apparentCutSegments]);
const onSegmentTagsCloseComplete = useCallback(() => {
setEditingSegmentTagsSegmentIndex();
setEditingSegmentTags();
}, []);
const onSegmentTagsConfirm = useCallback(() => {
updateSegAtIndex(editingSegmentTagsSegmentIndex, { tags: editingSegmentTags });
onSegmentTagsCloseComplete();
}, [editingSegmentTags, editingSegmentTagsSegmentIndex, onSegmentTagsCloseComplete, updateSegAtIndex]);
return (
<motion.div
style={{ width, background: controlsBackground, borderLeft: '1px solid var(--gray7)', color: 'var(--gray11)', transition: darkModeTransition, display: 'flex', flexDirection: 'column', overflowY: 'hidden' }}
initial={{ x: width }}
animate={{ x: 0 }}
exit={{ x: width }}
transition={mySpring}
>
<div style={{ fontSize: 14, padding: '0 5px', color: 'var(--gray12)' }} className="no-user-select">
<FaAngleRight
title={t('Close sidebar')}
size={20}
style={{ verticalAlign: 'middle', color: 'var(--gray11)', cursor: 'pointer', padding: 2 }}
role="button"
onClick={toggleSegmentsList}
/>
<>
<Dialog
title={t('Edit segment tags')}
isShown={editingSegmentTagsSegmentIndex != null}
hasCancel={false}
isConfirmDisabled={editingTag != null}
confirmLabel={t('Save')}
onConfirm={onSegmentTagsConfirm}
onCloseComplete={onSegmentTagsCloseComplete}
>
<div style={{ color: 'black' }}>
<TagEditor customTags={editingSegmentTags} editingTag={editingTag} setEditingTag={setEditingTag} onTagChange={onTagChange} onTagReset={onTagReset} addTagTitle={t('Add segment tag')} addTagText={t('Enter tag key')} />
</div>
</Dialog>
{header}
</div>
<motion.div
style={{ width, background: controlsBackground, borderLeft: '1px solid var(--gray7)', color: 'var(--gray11)', transition: darkModeTransition, display: 'flex', flexDirection: 'column', overflowY: 'hidden' }}
initial={{ x: width }}
animate={{ x: 0 }}
exit={{ x: width }}
transition={mySpring}
>
<div style={{ fontSize: 14, padding: '0 5px', color: 'var(--gray12)' }} className="no-user-select">
<FaAngleRight
title={t('Close sidebar')}
size={20}
style={{ verticalAlign: 'middle', color: 'var(--gray11)', cursor: 'pointer', padding: 2 }}
role="button"
onClick={toggleSegmentsList}
/>
<div style={{ padding: '0 10px', overflowY: 'scroll', flexGrow: 1 }} className="hide-scrollbar">
<ReactSortable list={sortableList} setList={setSortableList} disabled={!!invertCutSegments} handle=".segment-handle">
{sortableList.map(({ id, seg }, index) => {
const selected = !invertCutSegments && isSegmentSelected({ segId: seg.segId });
return (
<Segment
key={id}
darkMode={darkMode}
seg={seg}
index={index}
selected={selected}
onClick={onSegClick}
addSegment={addSegment}
onRemoveSelected={onRemoveSelected}
onRemovePress={() => removeCutSegment(index)}
onReorderPress={() => onReorderSegs(index)}
onLabelPress={() => onLabelSegment(index)}
jumpSegStart={() => jumpSegStart(index)}
jumpSegEnd={() => jumpSegEnd(index)}
updateOrder={(dir) => updateSegOrder(index, index + dir)}
getFrameCount={getFrameCount}
formatTimecode={formatTimecode}
currentSegIndex={currentSegIndex}
invertCutSegments={invertCutSegments}
onSelectSingleSegment={onSelectSingleSegment}
onToggleSegmentSelected={onToggleSegmentSelected}
onDeselectAllSegments={onDeselectAllSegments}
onSelectAllSegments={onSelectAllSegments}
onViewSegmentTags={onViewSegmentTags}
onSelectSegmentsByLabel={onSelectSegmentsByLabel}
onSelectSegmentsByTag={onSelectSegmentsByTag}
onExtractSegmentFramesAsImages={onExtractSegmentFramesAsImages}
onLabelSelectedSegments={onLabelSelectedSegments}
onInvertSelectedSegments={onInvertSelectedSegments}
onDuplicateSegmentClick={onDuplicateSegmentClick}
/>
);
})}
</ReactSortable>
</div>
{header}
</div>
{segments.length > 0 && renderFooter()}
</motion.div>
<div style={{ padding: '0 10px', overflowY: 'scroll', flexGrow: 1 }} className="hide-scrollbar">
<ReactSortable list={sortableList} setList={setSortableList} disabled={!!invertCutSegments} handle=".segment-handle">
{sortableList.map(({ id, seg }, index) => {
const selected = !invertCutSegments && isSegmentSelected({ segId: seg.segId });
return (
<Segment
key={id}
darkMode={darkMode}
seg={seg}
index={index}
selected={selected}
onClick={onSegClick}
addSegment={addSegment}
onRemoveSelected={onRemoveSelected}
onRemovePress={() => removeCutSegment(index)}
onReorderPress={() => onReorderSegs(index)}
onLabelPress={() => onLabelSegment(index)}
jumpSegStart={() => jumpSegStart(index)}
jumpSegEnd={() => jumpSegEnd(index)}
updateOrder={(dir) => updateSegOrder(index, index + dir)}
getFrameCount={getFrameCount}
formatTimecode={formatTimecode}
currentSegIndex={currentSegIndex}
invertCutSegments={invertCutSegments}
onSelectSingleSegment={onSelectSingleSegment}
onToggleSegmentSelected={onToggleSegmentSelected}
onDeselectAllSegments={onDeselectAllSegments}
onSelectAllSegments={onSelectAllSegments}
onEditSegmentTags={onEditSegmentTags}
onSelectSegmentsByLabel={onSelectSegmentsByLabel}
onSelectSegmentsByTag={onSelectSegmentsByTag}
onExtractSegmentFramesAsImages={onExtractSegmentFramesAsImages}
onLabelSelectedSegments={onLabelSelectedSegments}
onInvertSelectedSegments={onInvertSelectedSegments}
onDuplicateSegmentClick={onDuplicateSegmentClick}
/>
);
})}
</ReactSortable>
</div>
{segments.length > 0 && renderFooter()}
</motion.div>
</>
);
});

View File

@ -1,117 +1,28 @@
import React, { memo, useState, useMemo, useCallback, useRef, useEffect } from 'react';
import React, { memo, useState, useMemo, useCallback } from 'react';
import { FaImage, FaCheckCircle, FaPaperclip, FaVideo, FaVideoSlash, FaFileImport, FaVolumeUp, FaVolumeMute, FaBan, FaFileExport } from 'react-icons/fa';
import { GoFileBinary } from 'react-icons/go';
import { FiEdit, FiCheck, FiTrash } from 'react-icons/fi';
import { MdSubtitles } from 'react-icons/md';
import { Checkbox, BookIcon, TextInput, MoreIcon, Position, Popover, Menu, TrashIcon, EditIcon, InfoSignIcon, IconButton, Heading, SortAscIcon, SortDescIcon, Dialog, Button, PlusIcon, ForkIcon, WarningSignIcon } from 'evergreen-ui';
import { Checkbox, BookIcon, MoreIcon, Position, Popover, Menu, TrashIcon, EditIcon, InfoSignIcon, IconButton, Heading, SortAscIcon, SortDescIcon, Dialog, Button, ForkIcon, WarningSignIcon } from 'evergreen-ui';
import { useTranslation } from 'react-i18next';
import prettyBytes from 'pretty-bytes';
import AutoExportToggler from './components/AutoExportToggler';
import Select from './components/Select';
import { askForMetadataKey, showJson5Dialog } from './dialogs';
import { showJson5Dialog } from './dialogs';
import { formatDuration } from './util/duration';
import { getStreamFps } from './ffmpeg';
import { deleteDispositionValue } from './util';
import { getActiveDisposition, attachedPicDisposition } from './util/streams';
import TagEditor from './components/TagEditor';
const activeColor = '#429777';
const dispositionOptions = ['default', 'dub', 'original', 'comment', 'lyrics', 'karaoke', 'forced', 'hearing_impaired', 'visual_impaired', 'clean_effects', 'attached_pic', 'captions', 'descriptions', 'dependent', 'metadata'];
const unchangedDispositionValue = 'llc_disposition_unchanged';
const TagEditor = memo(({ existingTags, customTags, editingTag, setEditingTag, onTagChange, onTagReset }) => {
const EditFileDialog = memo(({ editingFile, allFilesMeta, customTagsByFile, setCustomTagsByFile, editingTag, setEditingTag }) => {
const { t } = useTranslation();
const ref = useRef();
const [editingTagVal, setEditingTagVal] = useState();
const [newTag, setNewTag] = useState();
const mergedTags = useMemo(() => ({ ...existingTags, ...customTags, ...(newTag ? { [newTag]: '' } : {}) }), [customTags, existingTags, newTag]);
const onResetClick = useCallback(() => {
onTagReset(editingTag);
setEditingTag();
setNewTag();
}, [editingTag, onTagReset, setEditingTag]);
function onEditClick(tag) {
if (newTag) {
onTagChange(editingTag, editingTagVal);
setEditingTag();
setNewTag();
} else if (editingTag != null) {
if (editingTagVal !== existingTags[editingTag]) {
onTagChange(editingTag, editingTagVal);
setEditingTag();
} else { // If not actually changed, no need to update
onResetClick();
}
} else {
setEditingTag(tag);
setEditingTagVal(mergedTags[tag]);
}
}
function onSubmit(e) {
e.preventDefault();
onEditClick();
}
const onAddPress = useCallback(async (e) => {
e.preventDefault();
e.target.blur();
const tag = await askForMetadataKey();
if (!tag || Object.keys(mergedTags).includes(tag)) return;
setEditingTag(tag);
setEditingTagVal('');
setNewTag(tag);
}, [mergedTags, setEditingTag]);
useEffect(() => {
ref.current?.focus();
}, [editingTag]);
return (
<>
<table style={{ color: 'black' }}>
<tbody>
{Object.keys(mergedTags).map((tag) => {
const editingThis = tag === editingTag;
const Icon = editingThis ? FiCheck : FiEdit;
const thisTagCustom = customTags[tag] != null;
const thisTagNew = existingTags[tag] == null;
return (
<tr key={tag}>
<td style={{ paddingRight: 20, color: thisTagNew ? activeColor : 'rgba(0,0,0,0.6)' }}>{tag}</td>
<td style={{ paddingTop: 5, paddingBottom: 5 }}>
{editingThis ? (
<form style={{ display: 'inline' }} onSubmit={onSubmit}>
<TextInput ref={ref} placeholder={t('Enter value')} value={editingTagVal || ''} onChange={(e) => setEditingTagVal(e.target.value)} />
</form>
) : (
<span style={{ color: thisTagCustom ? activeColor : undefined, fontWeight: thisTagCustom ? 'bold' : undefined }}>{mergedTags[tag]}</span>
)}
{(editingTag == null || editingThis) && <Icon title={t('Edit')} role="button" size={17} style={{ paddingLeft: 5, verticalAlign: 'middle', color: activeColor }} onClick={() => onEditClick(tag)} />}
{editingThis && <FiTrash title={t('Reset')} role="button" size={18} style={{ paddingLeft: 5, verticalAlign: 'middle' }} onClick={onResetClick} />}
</td>
</tr>
);
})}
</tbody>
</table>
<Button style={{ marginTop: 10 }} iconBefore={PlusIcon} onClick={onAddPress}>{t('Add metadata')}</Button>
</>
);
});
const EditFileDialog = memo(({ editingFile, allFilesMeta, customTagsByFile, setCustomTagsByFile }) => {
const [editingTag, setEditingTag] = useState();
const { formatData } = allFilesMeta[editingFile];
const existingTags = formatData.tags || {};
@ -128,7 +39,7 @@ const EditFileDialog = memo(({ editingFile, allFilesMeta, customTagsByFile, setC
});
}, [editingFile, setCustomTagsByFile]);
return <TagEditor existingTags={existingTags} customTags={customTags} editingTag={editingTag} setEditingTag={setEditingTag} onTagChange={onTagChange} onTagReset={onTagReset} />;
return <TagEditor existingTags={existingTags} customTags={customTags} editingTag={editingTag} setEditingTag={setEditingTag} onTagChange={onTagChange} onTagReset={onTagReset} addTagTitle={t('Add metadata')} addTagText={t('Enter metadata key')} />;
});
const getStreamDispositionsObj = (stream) => ((stream && stream.disposition) || {});
@ -208,7 +119,7 @@ const EditStreamDialog = memo(({ editingStream: { streamId: editingStreamId, pat
<Heading>Parameters</Heading>
<StreamParametersEditor stream={editingStream} streamParams={streamParams} updateStreamParams={(setter) => updateStreamParams(editingFile, editingStreamId, setter)} />
<Heading>Tags</Heading>
<TagEditor existingTags={existingTags} customTags={customTags} editingTag={editingTag} setEditingTag={setEditingTag} onTagChange={onTagChange} onTagReset={onTagReset} />
<TagEditor existingTags={existingTags} customTags={customTags} editingTag={editingTag} setEditingTag={setEditingTag} onTagChange={onTagChange} onTagReset={onTagReset} addTagTitle={t('Add metadata')} addTagText={t('Enter metadata key')} />
</div>
</Dialog>
);
@ -387,6 +298,7 @@ const StreamsSelector = memo(({
const [editingFile, setEditingFile] = useState();
const [editingStream, setEditingStream] = useState();
const { t } = useTranslation();
const [editingTag, setEditingTag] = useState();
function getFormatDuration(formatData) {
if (!formatData || !formatData.duration) return undefined;
@ -507,8 +419,9 @@ const StreamsSelector = memo(({
hasCancel={false}
confirmLabel={t('Done')}
onCloseComplete={() => setEditingFile()}
isConfirmDisabled={editingTag != null}
>
<EditFileDialog editingFile={editingFile} allFilesMeta={allFilesMeta} customTagsByFile={customTagsByFile} setCustomTagsByFile={setCustomTagsByFile} />
<EditFileDialog editingFile={editingFile} editingTag={editingTag} setEditingTag={setEditingTag} allFilesMeta={allFilesMeta} customTagsByFile={customTagsByFile} setCustomTagsByFile={setCustomTagsByFile} />
</Dialog>
{editingStream != null && (

View File

@ -0,0 +1,100 @@
import React, { memo, useRef, useState, useMemo, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { TextInput, TrashIcon, TickIcon, EditIcon, PlusIcon, Button, IconButton } from 'evergreen-ui';
import { askForMetadataKey } from '../dialogs';
const activeColor = '#429777';
const emptyObject = {};
function TagEditor({ existingTags = emptyObject, customTags = emptyObject, editingTag, setEditingTag, onTagChange, onTagReset, addTagTitle, addTagText }) {
const { t } = useTranslation();
const ref = useRef();
const [editingTagVal, setEditingTagVal] = useState();
const [newTag, setNewTag] = useState();
const mergedTags = useMemo(() => ({ ...existingTags, ...customTags, ...(newTag ? { [newTag]: '' } : {}) }), [customTags, existingTags, newTag]);
const onResetClick = useCallback(() => {
onTagReset(editingTag);
setEditingTag();
setNewTag();
}, [editingTag, onTagReset, setEditingTag]);
function onEditClick(tag) {
if (newTag) {
onTagChange(editingTag, editingTagVal);
setEditingTag();
setNewTag();
} else if (editingTag != null) {
if (editingTagVal !== existingTags[editingTag]) {
onTagChange(editingTag, editingTagVal);
setEditingTag();
} else { // If not actually changed, no need to update
onResetClick();
}
} else {
setEditingTag(tag);
setEditingTagVal(mergedTags[tag]);
}
}
function onSubmit(e) {
e.preventDefault();
onEditClick();
}
const onAddPress = useCallback(async (e) => {
e.preventDefault();
e.target.blur();
const tag = await askForMetadataKey({ title: addTagTitle, text: addTagText });
if (!tag || Object.keys(mergedTags).includes(tag)) return;
setEditingTag(tag);
setEditingTagVal('');
setNewTag(tag);
}, [addTagText, addTagTitle, mergedTags, setEditingTag]);
useEffect(() => {
ref.current?.focus();
}, [editingTag]);
return (
<>
<table style={{ color: 'black' }}>
<tbody>
{Object.keys(mergedTags).map((tag) => {
const editingThis = tag === editingTag;
const Icon = editingThis ? TickIcon : EditIcon;
const thisTagCustom = customTags[tag] != null;
const thisTagNew = existingTags[tag] == null;
return (
<tr key={tag}>
<td style={{ paddingRight: 20, color: thisTagNew ? activeColor : 'rgba(0,0,0,0.6)' }}>{tag}</td>
<td style={{ display: 'flex', alignItems: 'center' }}>
{editingThis ? (
<form style={{ display: 'inline' }} onSubmit={onSubmit}>
<TextInput ref={ref} placeholder={t('Enter value')} value={editingTagVal || ''} onChange={(e) => setEditingTagVal(e.target.value)} />
</form>
) : (
<span style={{ padding: '.5em 0', color: thisTagCustom ? activeColor : undefined, fontWeight: thisTagCustom ? 'bold' : undefined }}>{mergedTags[tag] || `<${t('empty')}>`}</span>
)}
{(editingTag == null || editingThis) && <IconButton icon={Icon} title={t('Edit')} appearance="minimal" style={{ marginLeft: '.4em' }} onClick={() => onEditClick(tag)} intent={editingThis ? 'success' : 'none'} />}
{editingThis && <IconButton icon={TrashIcon} title={thisTagCustom ? t('Delete') : t('Reset')} appearance="minimal" onClick={onResetClick} intent="danger" />}
</td>
</tr>
);
})}
</tbody>
</table>
<Button style={{ marginTop: 10 }} iconBefore={PlusIcon} onClick={onAddPress}>{t('Add metadata')}</Button>
</>
);
}
export default memo(TagEditor);

View File

@ -310,13 +310,13 @@ export async function askForAlignSegments() {
};
}
export async function askForMetadataKey() {
export async function askForMetadataKey({ title, text }) {
const { value } = await Swal.fire({
title: i18n.t('Add metadata'),
text: i18n.t('Enter metadata key'),
title,
text,
input: 'text',
showCancelButton: true,
inputPlaceholder: 'metadata_key',
inputPlaceholder: 'key',
inputValidator: (v) => v.includes('=') && i18n.t('Invalid character(s) found in key'),
});
return value;
@ -525,20 +525,6 @@ export async function selectSegmentsByTagDialog() {
return { tagName: value1, tagValue: value2 };
}
export async function showEditableJsonDialog({ text, title, inputLabel, inputValue, inputValidator }) {
const { value } = await Swal.fire({
input: 'textarea',
inputLabel,
text,
title,
inputPlaceholder: JSON5.stringify({ exampleTag: 'Example value' }, null, 2),
inputValue,
showCancelButton: true,
inputValidator,
});
return value;
}
export function showJson5Dialog({ title, json }) {
const html = (
<SyntaxHighlighter language="javascript" style={style} customStyle={{ textAlign: 'left', maxHeight: 300, overflowY: 'auto', fontSize: 14 }}>

View File

@ -1,7 +1,6 @@
import { useCallback, useRef, useMemo, useState } from 'react';
import { useStateWithHistory } from 'react-use/lib/useStateWithHistory';
import i18n from 'i18next';
import JSON5 from 'json5';
import pMap from 'p-map';
import sortBy from 'lodash/sortBy';
@ -10,7 +9,7 @@ import { detectSceneChanges as ffmpegDetectSceneChanges, readFrames, mapTimesToS
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, selectSegmentsByTagDialog } from '../dialogs';
import { createNumSegments as createNumSegmentsDialog, createFixedDurationSegments as createFixedDurationSegmentsDialog, createRandomSegments as createRandomSegmentsDialog, labelSegmentDialog, 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';
@ -273,22 +272,6 @@ export default ({
}
}, [filePath, mainVideoStream, modifySelectedSegmentTimes, setWorking, workingRef]);
const onViewSegmentTags = useCallback(async (index) => {
const segment = cutSegments[index];
function inputValidator(jsonStr) {
try {
const json = JSON5.parse(jsonStr);
if (!(typeof json === 'object' && Object.values(json).every((val) => typeof val === 'string'))) throw new Error();
return undefined;
} catch (err) {
return i18n.t('Invalid JSON');
}
}
const tags = getSegmentTags(segment);
const newTagsStr = await showEditableJsonDialog({ title: i18n.t('Segment tags'), text: i18n.t('View and edit segment tags in JSON5 format:'), inputValue: Object.keys(tags).length > 0 ? JSON5.stringify(tags, null, 2) : '', inputValidator });
if (newTagsStr != null) updateSegAtIndex(index, { tags: JSON5.parse(newTagsStr) });
}, [cutSegments, updateSegAtIndex]);
const updateSegOrder = useCallback((index, newOrder) => {
if (newOrder > cutSegments.length - 1 || newOrder < 0) return;
const newSegments = [...cutSegments];
@ -465,7 +448,6 @@ export default ({
enableSegments(segmentsToEnable);
}, [cutSegments, enableSegments]);
const onLabelSelectedSegments = useCallback(async () => {
if (selectedSegmentsRaw.length < 1) return;
const { name } = selectedSegmentsRaw[0];
@ -517,7 +499,6 @@ export default ({
combineSelectedSegments,
shiftAllSegmentTimes,
alignSegmentTimesToKeyframes,
onViewSegmentTags,
updateSegOrder,
updateSegOrders,
reorderSegsByStartTime,
@ -561,5 +542,6 @@ export default ({
toggleSegmentSelected,
selectOnlySegment,
setCutTime,
updateSegAtIndex,
};
};