UI improvements #128 #189

pull/276/head
Mikael Finstad 6 years ago
parent bb601d2545
commit bab424131f

@ -5,12 +5,11 @@ const { formatDuration } = require('./util');
const TimelineSeg = ({ const TimelineSeg = ({
isCutRangeValid, duration: durationRaw, cutStartTime, cutEndTime, apparentCutStart, isCutRangeValid, duration, apparentCutStart, apparentCutEnd, isActive, segNum,
apparentCutEnd, isActive, segNum, onSegClick, color, onSegClick, color,
}) => { }) => {
const duration = durationRaw || 1;
const cutSectionWidth = `${((apparentCutEnd - apparentCutStart) / duration) * 100}%`;
const markerWidth = 4; const markerWidth = 4;
const cutSectionWidth = `${Math.max(((apparentCutEnd - apparentCutStart) / duration) * 100, 1)}%`;
const startTimePos = `${(apparentCutStart / duration) * 100}%`; const startTimePos = `${(apparentCutStart / duration) * 100}%`;
const markerBorder = isActive ? `2px solid ${color.lighten(0.5).string()}` : undefined; const markerBorder = isActive ? `2px solid ${color.lighten(0.5).string()}` : undefined;
@ -30,29 +29,39 @@ const TimelineSeg = ({
borderBottomRightRadius: markerBorderRadius, borderBottomRightRadius: markerBorderRadius,
}; };
const wrapperStyle = {
position: 'absolute',
top: 0,
bottom: 0,
left: startTimePos,
width: cutSectionWidth,
display: 'flex',
background: backgroundColor,
originX: 0,
borderRadius: markerBorderRadius,
};
const onThisSegClick = () => onSegClick(segNum); const onThisSegClick = () => onSegClick(segNum);
return ( return (
<motion.div <motion.div
style={{ position: 'absolute', top: 0, bottom: 0, left: startTimePos, width: cutSectionWidth, display: 'flex', background: backgroundColor, originX: 0 }} style={wrapperStyle}
layoutTransition={{ type: 'spring', damping: 30, stiffness: 1000 }} layoutTransition={{ type: 'spring', damping: 30, stiffness: 1000 }}
initial={{ opacity: 0, scaleX: 0 }} initial={{ opacity: 0, scaleX: 0 }}
animate={{ opacity: 1, scaleX: 1 }} animate={{ opacity: 1, scaleX: 1 }}
exit={{ opacity: 0, scaleX: 0 }} exit={{ opacity: 0, scaleX: 0 }}
role="button"
onClick={onThisSegClick} onClick={onThisSegClick}
> >
{cutStartTime !== undefined && ( <div style={startMarkerStyle} role="button" tabIndex="0" />
<div style={startMarkerStyle} role="button" tabIndex="0" />
)} <div
{isCutRangeValid && (cutStartTime !== undefined || cutEndTime !== undefined) && ( style={{ flexGrow: 1, textAlign: 'left', fontSize: 10 }}
<div title={apparentCutEnd > apparentCutStart && formatDuration({ seconds: apparentCutEnd - apparentCutStart })}
role="button" >
tabIndex="0" {segNum + 1}
style={{ flexGrow: 1 }} </div>
title={`${formatDuration({ seconds: cutEndTime - cutStartTime })}`} {apparentCutEnd > apparentCutStart && (
/>
)}
{cutEndTime !== undefined && (
<div style={endMarkerStyle} role="button" tabIndex="0" /> <div style={endMarkerStyle} role="button" tabIndex="0" />
)} )}
</motion.div> </motion.div>

@ -63,16 +63,16 @@ function handleProgress(process, cutDuration, onProgress) {
} }
async function cut({ async function cut({
filePath, format, cutFrom, cutTo, cutToApparent, videoDuration, rotation, filePath, format, cutFrom, cutTo, videoDuration, rotation,
onProgress, copyStreamIds, keyframeCut, outPath, onProgress, copyStreamIds, keyframeCut, outPath,
}) { }) {
console.log('Cutting from', cutFrom, 'to', cutToApparent); console.log('Cutting from', cutFrom, 'to', cutTo);
const cutDuration = cutToApparent - cutFrom; const cutDuration = cutTo - cutFrom;
// https://github.com/mifi/lossless-cut/issues/50 // https://github.com/mifi/lossless-cut/issues/50
const cutFromArgs = cutFrom === 0 ? [] : ['-ss', cutFrom]; const cutFromArgs = cutFrom === 0 ? [] : ['-ss', cutFrom];
const cutToArgs = cutTo === undefined || cutTo === videoDuration ? [] : ['-t', cutDuration]; const cutToArgs = cutTo === videoDuration ? [] : ['-t', cutDuration];
const inputCutArgs = keyframeCut ? [ const inputCutArgs = keyframeCut ? [
...cutFromArgs, ...cutFromArgs,
@ -133,9 +133,9 @@ async function cutMultiple({
let i = 0; let i = 0;
// eslint-disable-next-line no-restricted-syntax,no-unused-vars // eslint-disable-next-line no-restricted-syntax,no-unused-vars
for (const { cutFrom, cutTo, cutToApparent } of segments) { for (const { cutFrom, cutTo } of segments) {
const ext = path.extname(filePath) || `.${format}`; const ext = path.extname(filePath) || `.${format}`;
const cutSpecification = `${formatDuration({ seconds: cutFrom, fileNameFriendly: true })}-${formatDuration({ seconds: cutToApparent, fileNameFriendly: true })}`; const cutSpecification = `${formatDuration({ seconds: cutFrom, fileNameFriendly: true })}-${formatDuration({ seconds: cutTo, fileNameFriendly: true })}`;
const outPath = getOutPath(customOutDir, filePath, `${cutSpecification}${ext}`); const outPath = getOutPath(customOutDir, filePath, `${cutSpecification}${ext}`);
@ -151,7 +151,6 @@ async function cutMultiple({
keyframeCut, keyframeCut,
cutFrom, cutFrom,
cutTo, cutTo,
cutToApparent,
// eslint-disable-next-line no-loop-func // eslint-disable-next-line no-loop-func
onProgress: progress => onSingleProgress(i, progress), onProgress: progress => onSingleProgress(i, progress),
}); });

@ -61,12 +61,6 @@ input, button, textarea, :focus {
text-align: center; text-align: center;
} }
#current-time-display {
text-align: center;
color: rgba(255, 255, 255, 0.8);
padding: .5em;
}
.timeline-wrapper { .timeline-wrapper {
width: 100%; width: 100%;
position: relative; position: relative;

@ -9,6 +9,7 @@ import { Popover, Button } from 'evergreen-ui';
import fromPairs from 'lodash/fromPairs'; import fromPairs from 'lodash/fromPairs';
import clamp from 'lodash/clamp'; import clamp from 'lodash/clamp';
import clone from 'lodash/clone'; import clone from 'lodash/clone';
import sortBy from 'lodash/sortBy';
import HelpSheet from './HelpSheet'; import HelpSheet from './HelpSheet';
import TimelineSeg from './TimelineSeg'; import TimelineSeg from './TimelineSeg';
@ -113,6 +114,13 @@ const App = memo(() => {
setCopyStreamIds(v => ({ ...v, [index]: !v[index] })); setCopyStreamIds(v => ({ ...v, [index]: !v[index] }));
} }
function toggleMute() {
setMuted((v) => {
if (!v) toast.fire({ title: 'Muted preview (note that exported file will not be affected)' });
return !v;
});
}
function seekAbs(val) { function seekAbs(val) {
const video = videoRef.current; const video = videoRef.current;
if (val == null || Number.isNaN(val)) return; if (val == null || Number.isNaN(val)) return;
@ -167,6 +175,71 @@ const App = memo(() => {
const frameRenderEnabled = !!(rotationPreviewRequested || dummyVideoPath); const frameRenderEnabled = !!(rotationPreviewRequested || dummyVideoPath);
// Because segments could have undefined start / end (meaning extend to start of timeline or end duration)
function getSegApparentStart(time) {
if (time !== undefined) return time;
return 0;
}
const getSegApparentEnd = useCallback((time) => {
if (time !== undefined) return time;
if (duration !== undefined) return duration;
return 0; // Haven't gotten duration yet
}, [duration]);
const apparentCutSegments = cutSegments.map(cutSegment => ({
...cutSegment,
start: getSegApparentStart(cutSegment.start),
end: getSegApparentEnd(cutSegment.end),
}));
const invalidSegUuids = apparentCutSegments
.filter(cutSegment => cutSegment.start >= cutSegment.end)
.map(cutSegment => cutSegment.uuid);
const haveInvalidSegs = invalidSegUuids.length > 0;
const inverseCutSegments = (() => {
if (haveInvalidSegs) return undefined;
if (apparentCutSegments.length < 1) return undefined;
const sorted = sortBy(apparentCutSegments, 'start');
const foundOverlap = sorted.some((cutSegment, i) => {
if (i === 0) return false;
return sorted[i - 1].end > cutSegment.start;
});
if (foundOverlap) return undefined;
const ret = [];
if (sorted[0].start > 0) {
ret.push({
start: 0,
end: sorted[0].start,
});
}
sorted.forEach((cutSegment, i) => {
if (i === 0) return;
ret.push({
start: sorted[i - 1].end,
end: cutSegment.start,
});
});
const last = sorted[sorted.length - 1];
if (last.end < duration) {
ret.push({
start: last.end,
end: duration,
});
}
return ret;
})();
const setCutTime = useCallback((type, time) => { const setCutTime = useCallback((type, time) => {
const cloned = clone(cutSegments); const cloned = clone(cutSegments);
cloned[currentSeg][type] = time; cloned[currentSeg][type] = time;
@ -180,12 +253,9 @@ const App = memo(() => {
const getCutSeg = useCallback((i) => cutSegments[i !== undefined ? i : currentSeg], const getCutSeg = useCallback((i) => cutSegments[i !== undefined ? i : currentSeg],
[currentSeg, cutSegments]); [currentSeg, cutSegments]);
const getCutStartTime = useCallback((i) => getCutSeg(i).start, [getCutSeg]);
const getCutEndTime = useCallback((i) => getCutSeg(i).end, [getCutSeg]);
const addCutSegment = useCallback(() => { const addCutSegment = useCallback(() => {
const cutStartTime = getCutStartTime(); const cutStartTime = getCutSeg().start;
const cutEndTime = getCutEndTime(); const cutEndTime = getCutSeg().end;
if (cutStartTime === undefined && cutEndTime === undefined) return; if (cutStartTime === undefined && cutEndTime === undefined) return;
@ -204,7 +274,7 @@ const App = memo(() => {
setCutSegments(cutSegmentsNew); setCutSegments(cutSegmentsNew);
setCurrentSeg(currentSegNew); setCurrentSeg(currentSegNew);
}, [ }, [
getCutEndTime, getCutStartTime, cutSegments, currentTime, duration, getCutSeg, cutSegments, currentTime, duration,
]); ]);
const setCutStart = useCallback(() => { const setCutStart = useCallback(() => {
@ -286,18 +356,12 @@ const App = memo(() => {
setRotationPreviewRequested(true); setRotationPreviewRequested(true);
} }
const getApparentCutStartTime = useCallback((i) => { const getApparentCutStartTimeOrCurrent = useCallback((i) => getSegApparentStart(getCutSeg(i).start), [getCutSeg]);
const cutStartTime = getCutStartTime(i);
if (cutStartTime !== undefined) return cutStartTime;
return 0;
}, [getCutStartTime]);
const getApparentCutEndTime = useCallback((i) => { const getApparentCutEndTimeOrCurrent = useCallback((i) => {
const cutEndTime = getCutEndTime(i); const cutEndTime = getCutSeg(i).end;
if (cutEndTime !== undefined) return cutEndTime; return getSegApparentEnd(cutEndTime);
if (duration !== undefined) return duration; }, [getCutSeg, getSegApparentEnd]);
return 0; // Haven't gotten duration yet
}, [getCutEndTime, duration]);
const offsetCurrentTime = (currentTime || 0) + startTimeOffset; const offsetCurrentTime = (currentTime || 0) + startTimeOffset;
@ -343,8 +407,8 @@ const App = memo(() => {
setCutSegments(cutSegmentsNew); setCutSegments(cutSegmentsNew);
}, [currentSeg, cutSegments]); }, [currentSeg, cutSegments]);
const jumpCutStart = () => seekAbs(getApparentCutStartTime()); const jumpCutStart = () => seekAbs(getApparentCutStartTimeOrCurrent());
const jumpCutEnd = () => seekAbs(getApparentCutEndTime()); const jumpCutEnd = () => seekAbs(getApparentCutEndTimeOrCurrent());
function handleTap(e) { function handleTap(e) {
const target = timelineWrapperRef.current; const target = timelineWrapperRef.current;
@ -366,8 +430,10 @@ const App = memo(() => {
}, [playing]); }, [playing]);
async function deleteSourceClick() { async function deleteSourceClick() {
if (!filePath) return;
// eslint-disable-next-line no-alert // eslint-disable-next-line no-alert
if (working || !window.confirm('Are you sure you want to move the source file to trash?')) return; if (working || !window.confirm(`Are you sure you want to move the source file to trash? ${filePath}`)) return;
try { try {
setWorking(true); setWorking(true);
@ -381,19 +447,13 @@ const App = memo(() => {
} }
} }
const isCutRangeValid = useCallback((i) => getApparentCutStartTime(i) < getApparentCutEndTime(i),
[getApparentCutStartTime, getApparentCutEndTime]);
const cutClick = useCallback(async () => { const cutClick = useCallback(async () => {
if (working) { if (working) {
errorToast('I\'m busy'); errorToast('I\'m busy');
return; return;
} }
const cutStartTime = getCutStartTime(); if (haveInvalidSegs) {
const cutEndTime = getCutEndTime();
if (!(isCutRangeValid() || cutEndTime === undefined || cutStartTime === undefined)) {
errorToast('Start time must be before end time'); errorToast('Start time must be before end time');
return; return;
} }
@ -402,9 +462,8 @@ const App = memo(() => {
setWorking(true); setWorking(true);
const segments = cutSegments.map((seg, i) => ({ const segments = cutSegments.map((seg, i) => ({
cutFrom: getApparentCutStartTime(i), cutFrom: getApparentCutStartTimeOrCurrent(i),
cutTo: getCutEndTime(i), cutTo: getApparentCutEndTimeOrCurrent(i),
cutToApparent: getApparentCutEndTime(i),
})); }));
const outFiles = await ffmpeg.cutMultiple({ const outFiles = await ffmpeg.cutMultiple({
@ -444,9 +503,9 @@ const App = memo(() => {
setWorking(false); setWorking(false);
} }
}, [ }, [
effectiveRotation, getApparentCutStartTime, getApparentCutEndTime, getCutEndTime, effectiveRotation, getApparentCutStartTimeOrCurrent, getApparentCutEndTimeOrCurrent,
getCutStartTime, isCutRangeValid, working, cutSegments, duration, filePath, keyframeCut, working, cutSegments, duration, filePath, keyframeCut,
autoMerge, customOutDir, fileFormat, copyStreamIds, autoMerge, customOutDir, fileFormat, copyStreamIds, haveInvalidSegs,
]); ]);
function showUnsupportedFileMessage() { function showUnsupportedFileMessage() {
@ -499,6 +558,7 @@ const App = memo(() => {
return ret; return ret;
}, [getHtml5ifiedPath]); }, [getHtml5ifiedPath]);
const load = useCallback(async (fp, html5FriendlyPathRequested) => { const load = useCallback(async (fp, html5FriendlyPathRequested) => {
console.log('Load', { fp, html5FriendlyPathRequested }); console.log('Load', { fp, html5FriendlyPathRequested });
if (working) { if (working) {
@ -737,7 +797,7 @@ const App = memo(() => {
seekAbs(rel); seekAbs(rel);
}; };
const cutTime = type === 'start' ? getApparentCutStartTime() : getApparentCutEndTime(); const cutTime = type === 'start' ? getApparentCutStartTimeOrCurrent() : getApparentCutEndTimeOrCurrent();
return ( return (
<input <input
@ -756,7 +816,7 @@ const App = memo(() => {
const durationSafe = duration || 1; const durationSafe = duration || 1;
const currentTimePos = currentTime !== undefined && `${(currentTime / durationSafe) * 100}%`; const currentTimePos = currentTime !== undefined && `${(currentTime / durationSafe) * 100}%`;
const segColor = getCutSeg().color; const segColor = (getCutSeg() || {}).color;
const segBgColor = segColor.alpha(0.5).string(); const segBgColor = segColor.alpha(0.5).string();
const jumpCutButtonStyle = { const jumpCutButtonStyle = {
@ -797,17 +857,20 @@ const App = memo(() => {
<td>Output format (default autodetected)</td> <td>Output format (default autodetected)</td>
<td style={{ width: '50%' }}>{renderOutFmt()}</td> <td style={{ width: '50%' }}>{renderOutFmt()}</td>
</tr> </tr>
<tr> <tr>
<td>In timecode show</td> <td>Output directory</td>
<td> <td>
<button <button
type="button" type="button"
onClick={() => setTimecodeShowFrames(v => !v)} onClick={setOutputDir}
> >
{timecodeShowFrames ? 'frame numbers' : 'millisecond fractions'} {customOutDir ? 'Custom output directory' : 'Output files to same directory as current file'}
</button> </button>
<div>{customOutDir}</div>
</td> </td>
</tr> </tr>
<tr> <tr>
<td>Auto merge segments to one file after export?</td> <td>Auto merge segments to one file after export?</td>
<td> <td>
@ -847,24 +910,23 @@ const App = memo(() => {
</tr> </tr>
<tr> <tr>
<td>Output directory</td>
<td> <td>
<button Snapshot capture format
type="button" </td>
onClick={setOutputDir} <td>
> {renderCaptureFormatButton()}
{customOutDir ? 'Custom output directory' : 'Output files to same directory as current file'}
</button>
<div>{customOutDir}</div>
</td> </td>
</tr> </tr>
<tr> <tr>
<td>In timecode show</td>
<td> <td>
Snapshot capture format <button
</td> type="button"
<td> onClick={() => setTimecodeShowFrames(v => !v)}
{renderCaptureFormatButton()} >
{timecodeShowFrames ? 'Frame numbers' : 'Millisecond fractions'}
</button>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@ -950,21 +1012,21 @@ const App = memo(() => {
)} )}
{working && ( {working && (
<div style={{ <div style={{
color: 'white', background: 'rgba(0, 0, 0, 0.3)', borderRadius: '.5em', margin: '1em', padding: '.2em .5em', position: 'absolute', zIndex: 1, top: topBarHeight, left: 0, color: 'white', background: 'rgba(0, 0, 0, 0.3)', borderRadius: '.5em', margin: '1em', padding: '.2em .5em', position: 'absolute', zIndex: 1, top: topBarHeight, left: 0,
}} }}
> >
<i className="fa fa-cog fa-spin fa-3x fa-fw" style={{ verticalAlign: 'middle', width: '1em', height: '1em' }} /> <i className="fa fa-cog fa-spin fa-3x fa-fw" style={{ verticalAlign: 'middle', width: '1em', height: '1em' }} />
{cutProgress != null && ( {cutProgress != null && (
<span style={{ color: 'rgba(255, 255, 255, 0.7)', paddingLeft: '.4em' }}> <span style={{ color: 'rgba(255, 255, 255, 0.7)', paddingLeft: '.4em' }}>
{`${Math.floor(cutProgress * 100)} %`} {`${Math.floor(cutProgress * 100)} %`}
</span> </span>
)} )}
</div> </div>
)} )}
{/* eslint-disable jsx-a11y/media-has-caption */}
<div style={{ position: 'absolute', top: topBarHeight, left: 0, right: 0, bottom: bottomBarHeight }}> <div style={{ position: 'absolute', top: topBarHeight, left: 0, right: 0, bottom: bottomBarHeight }}>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video <video
muted={muted} muted={muted}
ref={videoRef} ref={videoRef}
@ -986,7 +1048,6 @@ const App = memo(() => {
/> />
)} )}
</div> </div>
{/* eslint-enable jsx-a11y/media-has-caption */}
{rotationPreviewRequested && ( {rotationPreviewRequested && (
<div style={{ <div style={{
@ -1006,7 +1067,7 @@ const App = memo(() => {
title="Mute preview? (will not affect output)" title="Mute preview? (will not affect output)"
size={30} size={30}
role="button" role="button"
onClick={() => setMuted(v => !v)} onClick={toggleMute}
/> />
</div> </div>
)} )}
@ -1029,18 +1090,39 @@ const App = memo(() => {
color={seg.color} color={seg.color}
onSegClick={currentSegNew => setCurrentSeg(currentSegNew)} onSegClick={currentSegNew => setCurrentSeg(currentSegNew)}
isActive={i === currentSeg} isActive={i === currentSeg}
isCutRangeValid={isCutRangeValid(i)}
duration={durationSafe} duration={durationSafe}
cutStartTime={getCutStartTime(i)} apparentCutStart={getApparentCutStartTimeOrCurrent(i)}
cutEndTime={getCutEndTime(i)} apparentCutEnd={getApparentCutEndTimeOrCurrent(i)}
apparentCutStart={getApparentCutStartTime(i)}
apparentCutEnd={getApparentCutEndTime(i)}
/> />
))} ))}
</AnimatePresence> </AnimatePresence>
<div id="current-time-display"> <div>
<span role="button" onClick={() => setTimecodeShowFrames(v => !v)}> {inverseCutSegments && inverseCutSegments.map((seg) => (
<div
key={seg.start}
style={{
position: 'absolute',
top: 0,
bottom: 0,
left: `${(seg.start / duration) * 100}%`,
width: `${Math.max(((seg.end - seg.start) / duration) * 100, 1)}%`,
display: 'flex',
alignItems: 'center',
}}
>
<div style={{ flexGrow: 1, borderBottom: '1px dashed rgba(255, 255, 255, 0.3)', marginLeft: 5, marginRight: 5 }} />
<FaTrashAlt
style={{ color: 'rgba(255, 255, 255, 0.3)' }}
size={16}
/>
<div style={{ flexGrow: 1, borderBottom: '1px dashed rgba(255, 255, 255, 0.3)', marginLeft: 5, marginRight: 5 }} />
</div>
))}
</div>
<div style={{ textAlign: 'center', color: 'rgba(255, 255, 255, 0.8)', padding: '.5em' }}>
<span role="button" onClick={() => setTimecodeShowFrames(v => !v)} style={{ background: 'rgba(0,0,0,0.5)', borderRadius: 3, padding: '2px 4px' }}>
{formatTimecode(offsetCurrentTime)} {formatTimecode(offsetCurrentTime)}
</span> </span>
</div> </div>
@ -1049,6 +1131,14 @@ const App = memo(() => {
</Hammer> </Hammer>
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}> <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<FaAngleLeft
title="Set cut start to current position"
style={{ background: segBgColor, borderRadius: 10, padding: 3 }}
size={16}
onClick={setCutStart}
role="button"
/>
<i <i
className="button fa fa-step-backward" className="button fa fa-step-backward"
role="button" role="button"
@ -1107,26 +1197,10 @@ const App = memo(() => {
title="Jump to end of video" title="Jump to end of video"
onClick={() => seekAbs(durationSafe)} onClick={() => seekAbs(durationSafe)}
/> />
</div>
<div>
<FaAngleLeft
title="Set cut start to current position"
style={{ background: segBgColor, borderRadius: 10, padding: 5 }}
size={16}
onClick={setCutStart}
role="button"
/>
<FaTrashAlt
title="Delete source file"
style={{ padding: 5 }}
size={16}
onClick={deleteSourceClick}
role="button"
/>
<FaAngleRight <FaAngleRight
title="Set cut end to current position" title="Set cut end to current position"
style={{ background: segBgColor, borderRadius: 10, padding: 5 }} style={{ background: segBgColor, borderRadius: 10, padding: 3 }}
size={16} size={16}
onClick={setCutEnd} onClick={setCutEnd}
role="button" role="button"
@ -1139,7 +1213,7 @@ const App = memo(() => {
size={30} size={30}
style={{ margin: '0 5px', color: 'white' }} style={{ margin: '0 5px', color: 'white' }}
role="button" role="button"
title="Add cut segment" title="Add segment"
onClick={addCutSegment} onClick={addCutSegment}
/> />
@ -1164,6 +1238,14 @@ const App = memo(() => {
/> />
</div> </div>
<FaTrashAlt
title="Delete source file"
style={{ padding: '5px 10px' }}
size={16}
onClick={deleteSourceClick}
role="button"
/>
{renderCaptureFormatButton()} {renderCaptureFormatButton()}
<IoIosCamera <IoIosCamera

Loading…
Cancel
Save