mirror of
https://github.com/imputnet/cobalt.git
synced 2025-07-10 07:18:30 +00:00
app: clipping prototype
This commit is contained in:
parent
25042742ad
commit
d965cf4da3
@ -32,6 +32,7 @@
|
||||
"express": "^4.21.2",
|
||||
"express-rate-limit": "^7.4.1",
|
||||
"ffmpeg-static": "^5.1.0",
|
||||
"ffprobe-static": "^3.1.0",
|
||||
"hls-parser": "^0.10.7",
|
||||
"ipaddr.js": "2.2.0",
|
||||
"mime": "^4.0.4",
|
||||
|
@ -278,44 +278,71 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => {
|
||||
|
||||
app.post('/metadata', apiLimiter);
|
||||
app.post('/metadata', async (req, res) => {
|
||||
const request = req.body;
|
||||
if (!request.url) {
|
||||
return fail(res, "error.api.link.missing");
|
||||
}
|
||||
const { success, data: normalizedRequest } = await normalizeRequest(request);
|
||||
if (!success) {
|
||||
return fail(res, "error.api.invalid_body");
|
||||
}
|
||||
const parsed = extract(
|
||||
normalizedRequest.url,
|
||||
APIKeys.getAllowedServices(req.rateLimitKey),
|
||||
);
|
||||
if (!parsed) {
|
||||
return fail(res, "error.api.link.invalid");
|
||||
}
|
||||
if ("error" in parsed) {
|
||||
let context;
|
||||
if (parsed?.context) {
|
||||
context = parsed.context;
|
||||
try {
|
||||
const request = req.body;
|
||||
|
||||
if (!request.url) {
|
||||
return fail(res, "error.api.link.missing");
|
||||
}
|
||||
return fail(res, `error.api.${parsed.error}`, context);
|
||||
}
|
||||
|
||||
if (parsed.host === "youtube") {
|
||||
const youtube = (await import("../processing/services/youtube.js")).default;
|
||||
const info = await youtube({ id: parsed.patternMatch.id });
|
||||
if (info.error) {
|
||||
return fail(res, info.error);
|
||||
const { success, data: normalizedRequest } = await normalizeRequest(request);
|
||||
if (!success) {
|
||||
return fail(res, "error.api.invalid_body");
|
||||
}
|
||||
const meta = {
|
||||
title: info.fileMetadata?.title,
|
||||
duration: info.duration || null,
|
||||
thumbnail: info.cover || null,
|
||||
author: info.fileMetadata?.artist || null,
|
||||
|
||||
const parsed = extract(
|
||||
normalizedRequest.url,
|
||||
APIKeys.getAllowedServices(req.rateLimitKey),
|
||||
);
|
||||
|
||||
if (!parsed) {
|
||||
return fail(res, "error.api.link.invalid");
|
||||
}
|
||||
|
||||
if ("error" in parsed) {
|
||||
let context;
|
||||
if (parsed?.context) {
|
||||
context = parsed.context;
|
||||
}
|
||||
return fail(res, `error.api.${parsed.error}`, context);
|
||||
}
|
||||
|
||||
if (parsed.host !== "youtube") {
|
||||
return res.status(501).json({
|
||||
status: "error",
|
||||
code: "not_implemented",
|
||||
message: "Metadata endpoint is only implemented for YouTube."
|
||||
});
|
||||
}
|
||||
|
||||
const youtube = (await import("../processing/services/youtube.js")).default;
|
||||
|
||||
const fetchInfo = {
|
||||
id: parsed.patternMatch.id.slice(0, 11),
|
||||
metadataOnly: true,
|
||||
};
|
||||
return res.json({ status: "success", metadata: meta });
|
||||
} else {
|
||||
return res.status(501).json({ status: "error", code: "not_implemented", message: "Metadata endpoint is only implemented for YouTube." });
|
||||
|
||||
const result = await youtube(fetchInfo);
|
||||
|
||||
if (result.error) {
|
||||
return fail(res, `error.api.${result.error}`);
|
||||
}
|
||||
|
||||
const metadata = {
|
||||
title: result.fileMetadata?.title || null,
|
||||
author: result.fileMetadata?.artist || null,
|
||||
duration: result.duration || null,
|
||||
thumbnail: result.cover || null,
|
||||
};
|
||||
|
||||
return res.json({
|
||||
status: "success",
|
||||
metadata: metadata
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Metadata endpoint error:', error);
|
||||
return fail(res, "error.api.generic");
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -36,6 +36,8 @@ export default function({
|
||||
subtitles: r.subtitles,
|
||||
cover: !disableMetadata ? r.cover : false,
|
||||
cropCover: !disableMetadata ? r.cropCover : false,
|
||||
clipStart: r.clipStart,
|
||||
clipEnd: r.clipEnd,
|
||||
},
|
||||
params = {};
|
||||
|
||||
|
@ -123,6 +123,21 @@ export default async function({ host, patternMatch, params, authType }) {
|
||||
subtitleLang,
|
||||
}
|
||||
|
||||
if (typeof params.clipStart === 'number') {
|
||||
fetchInfo.clipStart = params.clipStart;
|
||||
}
|
||||
if (typeof params.clipEnd === 'number') {
|
||||
fetchInfo.clipEnd = params.clipEnd;
|
||||
}
|
||||
|
||||
if (fetchInfo.clipStart !== undefined && fetchInfo.clipEnd !== undefined) {
|
||||
if (fetchInfo.clipStart >= fetchInfo.clipEnd) {
|
||||
return createResponse("error", {
|
||||
code: "error.api.clip.invalid_range"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (url.hostname === "music.youtube.com" || isAudioOnly) {
|
||||
fetchInfo.quality = "1080";
|
||||
fetchInfo.codec = "vp9";
|
||||
|
@ -60,5 +60,8 @@ export const apiSchema = z.object({
|
||||
|
||||
youtubeHLS: z.boolean().default(false),
|
||||
youtubeBetterAudio: z.boolean().default(false),
|
||||
|
||||
clipStart: z.number().min(0).optional(),
|
||||
clipEnd: z.number().min(0).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
@ -291,6 +291,13 @@ export default async function (o) {
|
||||
return { error: "content.too_long" };
|
||||
}
|
||||
|
||||
if (typeof o.clipStart === 'number' && o.clipStart >= basicInfo.duration) {
|
||||
return { error: "clip.start_exceeds_duration" };
|
||||
}
|
||||
if (typeof o.clipEnd === 'number' && o.clipEnd > basicInfo.duration) {
|
||||
return { error: "clip.end_exceeds_duration" };
|
||||
}
|
||||
|
||||
// return a critical error if returned video is "Video Not Available"
|
||||
// or a similar stub by youtube
|
||||
if (basicInfo.id !== o.id) {
|
||||
@ -495,6 +502,27 @@ export default async function (o) {
|
||||
}
|
||||
}
|
||||
|
||||
if (o.metadataOnly) {
|
||||
let cover = `https://i.ytimg.com/vi/${o.id}/maxresdefault.jpg`;
|
||||
try {
|
||||
const testMaxCover = await fetch(cover, { dispatcher: o.dispatcher })
|
||||
.then(r => r.status === 200)
|
||||
.catch(() => false);
|
||||
|
||||
if (!testMaxCover) {
|
||||
cover = basicInfo.thumbnail?.[0]?.url || null;
|
||||
}
|
||||
} catch {
|
||||
cover = basicInfo.thumbnail?.[0]?.url || null;
|
||||
}
|
||||
|
||||
return {
|
||||
fileMetadata,
|
||||
duration: basicInfo.duration,
|
||||
cover,
|
||||
};
|
||||
}
|
||||
|
||||
if (subtitles) {
|
||||
fileMetadata.sublanguage = subtitles.language;
|
||||
}
|
||||
@ -553,6 +581,8 @@ export default async function (o) {
|
||||
|
||||
cover,
|
||||
cropCover: basicInfo.author.endsWith("- Topic"),
|
||||
clipStart: o.clipStart,
|
||||
clipEnd: o.clipEnd,
|
||||
}
|
||||
}
|
||||
|
||||
@ -599,6 +629,8 @@ export default async function (o) {
|
||||
isHLS: useHLS,
|
||||
originalRequest,
|
||||
duration: basicInfo.duration,
|
||||
clipStart: o.clipStart,
|
||||
clipEnd: o.clipEnd,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -99,9 +99,18 @@ const render = async (res, streamInfo, ffargs, estimateMultiplier) => {
|
||||
const remux = async (streamInfo, res) => {
|
||||
const format = streamInfo.filename.split('.').pop();
|
||||
const urls = Array.isArray(streamInfo.urls) ? streamInfo.urls : [streamInfo.urls];
|
||||
const args = urls.flatMap(url => ['-i', url]);
|
||||
const isClipping = typeof streamInfo.clipStart === 'number' || typeof streamInfo.clipEnd === 'number';
|
||||
let args = [];
|
||||
|
||||
args.push(...urls.flatMap(url => ['-i', url]));
|
||||
|
||||
if (typeof streamInfo.clipStart === 'number') {
|
||||
args.push('-ss', streamInfo.clipStart.toString());
|
||||
}
|
||||
if (typeof streamInfo.clipEnd === 'number') {
|
||||
args.push('-to', streamInfo.clipEnd.toString());
|
||||
}
|
||||
|
||||
// if the stream type is merge, we expect two URLs
|
||||
if (streamInfo.type === 'merge' && urls.length !== 2) {
|
||||
return closeResponse(res);
|
||||
}
|
||||
@ -126,10 +135,19 @@ const remux = async (streamInfo, res) => {
|
||||
);
|
||||
}
|
||||
|
||||
args.push(
|
||||
'-c:v', 'copy',
|
||||
...(streamInfo.type === 'mute' ? ['-an'] : ['-c:a', 'copy'])
|
||||
);
|
||||
if (isClipping) {
|
||||
const vcodec = format === 'webm' ? 'libvpx-vp9' : 'libx264';
|
||||
const acodec = format === 'webm' ? 'libopus' : 'aac';
|
||||
args.push(
|
||||
'-c:v', vcodec,
|
||||
...(streamInfo.type === 'mute' ? ['-an'] : ['-c:a', acodec])
|
||||
);
|
||||
} else {
|
||||
args.push(
|
||||
'-c:v', 'copy',
|
||||
...(streamInfo.type === 'mute' ? ['-an'] : ['-c:a', 'copy'])
|
||||
);
|
||||
}
|
||||
|
||||
if (format === 'mp4') {
|
||||
args.push('-movflags', 'faststart+frag_keyframe+empty_moov');
|
||||
@ -149,15 +167,24 @@ const remux = async (streamInfo, res) => {
|
||||
|
||||
args.push('-f', format === 'mkv' ? 'matroska' : format, 'pipe:3');
|
||||
|
||||
await render(res, streamInfo, args);
|
||||
await render(res, streamInfo, args, estimateAudioMultiplier(streamInfo) * 1.1);
|
||||
}
|
||||
|
||||
const convertAudio = async (streamInfo, res) => {
|
||||
const args = [
|
||||
let args = [];
|
||||
|
||||
args.push(
|
||||
'-i', streamInfo.urls,
|
||||
'-vn',
|
||||
...(streamInfo.audioCopy ? ['-c:a', 'copy'] : ['-b:a', `${streamInfo.audioBitrate}k`]),
|
||||
];
|
||||
);
|
||||
|
||||
if (typeof streamInfo.clipStart === 'number') {
|
||||
args.push('-ss', streamInfo.clipStart.toString());
|
||||
}
|
||||
if (typeof streamInfo.clipEnd === 'number') {
|
||||
args.push('-to', streamInfo.clipEnd.toString());
|
||||
}
|
||||
|
||||
if (streamInfo.audioFormat === 'mp3' && streamInfo.audioBitrate === '8') {
|
||||
args.push('-ar', '12000');
|
||||
|
@ -45,6 +45,9 @@ export function createStream(obj) {
|
||||
|
||||
// url to a subtitle file
|
||||
subtitles: obj.subtitles,
|
||||
|
||||
clipStart: obj.clipStart,
|
||||
clipEnd: obj.clipEnd,
|
||||
};
|
||||
|
||||
// FIXME: this is now a Promise, but it is not awaited
|
||||
|
@ -37,6 +37,9 @@ importers:
|
||||
ffmpeg-static:
|
||||
specifier: ^5.1.0
|
||||
version: 5.2.0
|
||||
ffprobe-static:
|
||||
specifier: ^3.1.0
|
||||
version: 3.1.0
|
||||
hls-parser:
|
||||
specifier: ^0.10.7
|
||||
version: 0.10.9
|
||||
@ -1254,6 +1257,9 @@ packages:
|
||||
resolution: {integrity: sha512-WrM7kLW+do9HLr+H6tk7LzQ7kPqbAgLjdzNE32+u3Ff11gXt9Kkkd2nusGFrlWMIe+XaA97t+I8JS7sZIrvRgA==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
ffprobe-static@3.1.0:
|
||||
resolution: {integrity: sha512-Dvpa9uhVMOYivhHKWLGDoa512J751qN1WZAIO+Xw4L/mrUSPxS4DApzSUDbCFE/LUq2+xYnznEahTd63AqBSpA==}
|
||||
|
||||
file-entry-cache@8.0.0:
|
||||
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
@ -3189,6 +3195,8 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
ffprobe-static@3.1.0: {}
|
||||
|
||||
file-entry-cache@8.0.0:
|
||||
dependencies:
|
||||
flat-cache: 4.0.1
|
||||
|
348
web/src/components/save/ClipRangeSlider.svelte
Normal file
348
web/src/components/save/ClipRangeSlider.svelte
Normal file
@ -0,0 +1,348 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
min = 0,
|
||||
max = 1,
|
||||
start = $bindable(0),
|
||||
end = $bindable(1),
|
||||
step = 0.001,
|
||||
} = $props();
|
||||
|
||||
let track: HTMLDivElement;
|
||||
let dragging = $state<"start" | "end" | null>(null);
|
||||
let focused = $state<"start" | "end" | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
function handleDocumentClick(e: MouseEvent) {
|
||||
if (track && !track.contains(e.target as Node)) {
|
||||
focused = null;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('click', handleDocumentClick);
|
||||
return () => document.removeEventListener('click', handleDocumentClick);
|
||||
});
|
||||
|
||||
function percent(val: number) {
|
||||
return ((val - min) / (max - min)) * 100;
|
||||
}
|
||||
|
||||
function clamp(val: number, minVal: number, maxVal: number) {
|
||||
return Math.max(minVal, Math.min(maxVal, val));
|
||||
}
|
||||
|
||||
function posToValue(clientX: number) {
|
||||
const rect = track.getBoundingClientRect();
|
||||
const ratio = clamp((clientX - rect.left) / rect.width, 0, 1);
|
||||
return min + ratio * (max - min);
|
||||
}
|
||||
|
||||
function handlePointerDown(e: PointerEvent, which: "start" | "end") {
|
||||
dragging = which;
|
||||
focused = which;
|
||||
track.setPointerCapture(e.pointerId);
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
window.addEventListener("pointerup", handlePointerUp);
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function handlePointerMove(e: PointerEvent) {
|
||||
if (!dragging) return;
|
||||
let val = posToValue(e.clientX);
|
||||
val = Math.round(val / step) * step;
|
||||
if (dragging === "start") {
|
||||
start = clamp(val, min, end);
|
||||
if (start > end) start = end;
|
||||
} else {
|
||||
end = clamp(val, start, max);
|
||||
if (end < start) end = start;
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerUp(e: PointerEvent) {
|
||||
if (dragging) {
|
||||
track.releasePointerCapture(e.pointerId);
|
||||
}
|
||||
dragging = null;
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
window.removeEventListener("pointerup", handlePointerUp);
|
||||
}
|
||||
|
||||
function handleTrackClick(e: MouseEvent) {
|
||||
if (dragging) return;
|
||||
const val = posToValue(e.clientX);
|
||||
if (Math.abs(val - start) < Math.abs(val - end)) {
|
||||
start = clamp(val, min, end);
|
||||
focused = "start";
|
||||
} else {
|
||||
end = clamp(val, start, max);
|
||||
focused = "end";
|
||||
}
|
||||
}
|
||||
|
||||
function handleTrackKeydown(e: KeyboardEvent) {
|
||||
if (e.key.startsWith("Arrow") || e.key === "Home" || e.key === "End") {
|
||||
e.preventDefault();
|
||||
handleKeydown(e, focused || "start");
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent, which: "start" | "end") {
|
||||
const bigStep = (max - min) / 20;
|
||||
const smallStep = step;
|
||||
let newVal: number;
|
||||
|
||||
switch (e.key) {
|
||||
case "ArrowLeft":
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
newVal = (which === "start" ? start : end) - (e.shiftKey ? bigStep : smallStep);
|
||||
break;
|
||||
case "ArrowRight":
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
newVal = (which === "start" ? start : end) + (e.shiftKey ? bigStep : smallStep);
|
||||
break;
|
||||
case "Home":
|
||||
e.preventDefault();
|
||||
newVal = which === "start" ? min : start;
|
||||
break;
|
||||
case "End":
|
||||
e.preventDefault();
|
||||
newVal = which === "start" ? end : max;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
if (which === "start") {
|
||||
start = clamp(newVal, min, end);
|
||||
} else {
|
||||
end = clamp(newVal, start, max);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="slider-container">
|
||||
<div
|
||||
class="slider"
|
||||
bind:this={track}
|
||||
onclick={handleTrackClick}
|
||||
onkeydown={handleTrackKeydown}
|
||||
role="slider"
|
||||
aria-valuenow={start}
|
||||
aria-valuemin={min}
|
||||
aria-valuemax={max}
|
||||
aria-label="Clip range selector"
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="slider-track"></div>
|
||||
<div
|
||||
class="slider-range"
|
||||
style="left: {percent(start)}%; right: {100 - percent(end)}%"
|
||||
></div>
|
||||
<div
|
||||
class="slider-handle start"
|
||||
class:focused={focused === "start"}
|
||||
class:dragging={dragging === "start"}
|
||||
style="left: {percent(start)}%"
|
||||
tabindex="0"
|
||||
role="slider"
|
||||
aria-label="Start time"
|
||||
aria-valuemin={min}
|
||||
aria-valuemax={end}
|
||||
aria-valuenow={start}
|
||||
onpointerdown={(e) => handlePointerDown(e, 'start')}
|
||||
onkeydown={(e) => handleKeydown(e, 'start')}
|
||||
onfocus={() => focused = "start"}
|
||||
onblur={() => focused = null}
|
||||
>
|
||||
<div class="handle-tooltip" class:visible={dragging === "start" || focused === "start"}>
|
||||
{Math.floor(start / 60)}:{String(Math.floor(start % 60)).padStart(2, '0')}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="slider-handle end"
|
||||
class:focused={focused === "end"}
|
||||
class:dragging={dragging === "end"}
|
||||
style="left: {percent(end)}%"
|
||||
tabindex="0"
|
||||
role="slider"
|
||||
aria-label="End time"
|
||||
aria-valuemin={start}
|
||||
aria-valuemax={max}
|
||||
aria-valuenow={end}
|
||||
onpointerdown={(e) => handlePointerDown(e, 'end')}
|
||||
onkeydown={(e) => handleKeydown(e, 'end')}
|
||||
onfocus={() => focused = "end"}
|
||||
onblur={() => focused = null}
|
||||
>
|
||||
<div class="handle-tooltip" class:visible={dragging === "end" || focused === "end"}>
|
||||
{Math.floor(end / 60)}:{String(Math.floor(end % 60)).padStart(2, '0')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.slider-container {
|
||||
position: relative;
|
||||
margin: 16px 0 12px 0;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: relative;
|
||||
height: 32px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s cubic-bezier(0.2, 0, 0, 1);
|
||||
}
|
||||
|
||||
.slider:hover .slider-track {
|
||||
background: var(--input-border);
|
||||
transform: translateY(-50%) scaleY(1.2);
|
||||
}
|
||||
|
||||
.slider-track {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 6px;
|
||||
background: var(--button-hover);
|
||||
border-radius: 3px;
|
||||
transform: translateY(-50%);
|
||||
transition: all 0.2s cubic-bezier(0.2, 0, 0, 1);
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.slider-range {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
height: 8px;
|
||||
background: linear-gradient(90deg, var(--secondary) 0%);
|
||||
border-radius: 4px;
|
||||
transform: translateY(-50%);
|
||||
z-index: 1;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
transition: all 0.2s cubic-bezier(0.2, 0, 0, 1);
|
||||
}
|
||||
|
||||
.slider:hover .slider-range {
|
||||
height: 10px;
|
||||
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.slider-handle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: var(--primary);
|
||||
border: 2.5px solid var(--secondary);
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
cursor: grab;
|
||||
z-index: 3;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
transition: all 0.2s cubic-bezier(0.2, 0, 0, 1);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.slider-handle:hover {
|
||||
transform: translate(-50%, -50%) scale(1.1);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
.slider-handle.focused {
|
||||
border-color: var(--secondary);
|
||||
transform: translate(-50%, -50%) scale(1.05);
|
||||
}
|
||||
|
||||
.slider-handle.dragging {
|
||||
cursor: grabbing;
|
||||
transform: translate(-50%, -50%) scale(1.15);
|
||||
border-color: var(--secondary);
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.slider-handle.start {
|
||||
background: linear-gradient(135deg, var(--primary) 0%, #f8f8f8 100%);
|
||||
}
|
||||
|
||||
.slider-handle.end {
|
||||
background: linear-gradient(135deg, var(--primary) 0%, #f0f0f0 100%);
|
||||
}
|
||||
|
||||
.handle-tooltip {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(-8px);
|
||||
background: var(--secondary);
|
||||
color: var(--primary);
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.2s cubic-bezier(0.2, 0, 0, 1);
|
||||
pointer-events: none;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.handle-tooltip::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 4px solid transparent;
|
||||
border-top-color: var(--secondary);
|
||||
}
|
||||
|
||||
.handle-tooltip.visible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateX(-50%) translateY(-12px);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.slider {
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.slider-handle {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
.slider-track {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.slider-range {
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.slider:hover .slider-range {
|
||||
height: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.slider,
|
||||
.slider-track,
|
||||
.slider-range,
|
||||
.slider-handle,
|
||||
.handle-tooltip {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -18,6 +18,7 @@
|
||||
|
||||
import type { Optional } from "$lib/types/generic";
|
||||
import type { DownloadModeOption } from "$lib/types/settings";
|
||||
import type { CobaltSaveRequestBody } from "$lib/types/api";
|
||||
|
||||
import ClearButton from "$components/save/buttons/ClearButton.svelte";
|
||||
import DownloadButton from "$components/save/buttons/DownloadButton.svelte";
|
||||
@ -33,6 +34,10 @@
|
||||
import IconSparkles from "$components/icons/Sparkles.svelte";
|
||||
import IconClipboard from "$components/icons/Clipboard.svelte";
|
||||
|
||||
import API from "$lib/api/api";
|
||||
import ClipRangeSlider from "./ClipRangeSlider.svelte";
|
||||
import ClipCheckbox from "./buttons/ClipCheckbox.svelte";
|
||||
|
||||
let linkInput: Optional<HTMLInputElement>;
|
||||
|
||||
const validLink = (url: string) => {
|
||||
@ -58,6 +63,40 @@
|
||||
let downloadable = $derived(validLink($link));
|
||||
let clearVisible = $derived($link && !isLoading);
|
||||
|
||||
let clipMode = $state(false);
|
||||
let metadata: { title?: string; author?: string; duration?: number } | null = $state(null);
|
||||
let metaLoading = $state(false);
|
||||
let metaError: string | null = $state(null);
|
||||
let clipStart = $state(0);
|
||||
let clipEnd = $state(0);
|
||||
|
||||
async function fetchMetadata() {
|
||||
if (!validLink($link)) return;
|
||||
metaLoading = true;
|
||||
metaError = null;
|
||||
metadata = null;
|
||||
try {
|
||||
const res = await API.getMetadata($link);
|
||||
if (res.status === "success") {
|
||||
metadata = res.metadata;
|
||||
clipStart = 0;
|
||||
clipEnd = metadata?.duration || 0;
|
||||
} else {
|
||||
metaError = res.message || "Failed to fetch metadata.";
|
||||
}
|
||||
} catch (e: any) {
|
||||
metaError = e.message || "Failed to fetch metadata.";
|
||||
} finally {
|
||||
metaLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (clipMode && validLink($link)) {
|
||||
fetchMetadata();
|
||||
}
|
||||
});
|
||||
|
||||
$effect (() => {
|
||||
if (linkPrefill) {
|
||||
// prefilled link may be uri encoded
|
||||
@ -139,6 +178,12 @@
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
function formatTime(seconds: number) {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = (seconds % 60).toFixed(3).padStart(6, '0');
|
||||
return `${m}:${s}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
@ -192,6 +237,9 @@
|
||||
<ClearButton click={() => ($link = "")} />
|
||||
<DownloadButton
|
||||
url={$link}
|
||||
clipMode={clipMode}
|
||||
clipStart={clipStart}
|
||||
clipEnd={clipEnd}
|
||||
bind:disabled={isDisabled}
|
||||
bind:loading={isLoading}
|
||||
/>
|
||||
@ -225,12 +273,72 @@
|
||||
</SettingsButton>
|
||||
</Switcher>
|
||||
|
||||
|
||||
<ClipCheckbox checked={clipMode} onclick={() => clipMode = !clipMode} />
|
||||
|
||||
<ActionButton id="paste" click={pasteClipboard}>
|
||||
<IconClipboard />
|
||||
<span id="paste-desktop-text">{$t("save.paste")}</span>
|
||||
<span id="paste-mobile-text">{$t("save.paste.long")}</span>
|
||||
</ActionButton>
|
||||
</div>
|
||||
|
||||
{#if clipMode && validLink($link)}
|
||||
<div class="clip-controls">
|
||||
{#if metaLoading}
|
||||
<div class="loading-state">
|
||||
<div class="loading-spinner"></div>
|
||||
<span>Loading video metadata...</span>
|
||||
</div>
|
||||
{:else if metaError}
|
||||
<div class="error-state">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="15" y1="9" x2="9" y2="15"></line>
|
||||
<line x1="9" y1="9" x2="15" y2="15"></line>
|
||||
</svg>
|
||||
<span>{metaError}</span>
|
||||
</div>
|
||||
{:else if metadata}
|
||||
<div class="clip-metadata">
|
||||
<div class="metadata-header">
|
||||
<h4>Clip</h4>
|
||||
<div class="duration-badge">
|
||||
{typeof metadata.duration === 'number' ? formatTime(metadata.duration) : 'Unknown'}
|
||||
</div>
|
||||
</div>
|
||||
<div class="video-info">
|
||||
<div class="video-title" title={metadata.title}>
|
||||
{metadata.title}
|
||||
</div>
|
||||
{#if metadata.author}
|
||||
<div class="video-author">
|
||||
by {metadata.author}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ClipRangeSlider
|
||||
min={0}
|
||||
max={metadata.duration || 0}
|
||||
step={0.001}
|
||||
bind:start={clipStart}
|
||||
bind:end={clipEnd}
|
||||
/>
|
||||
|
||||
<div class="clip-time-display">
|
||||
<div class="time-indicators">
|
||||
<span class="start-time">{formatTime(clipStart)}</span>
|
||||
<span class="duration-selected">
|
||||
{formatTime(Math.max(0, clipEnd - clipStart))} selected
|
||||
</span>
|
||||
<span class="end-time">{formatTime(clipEnd)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@ -359,6 +467,140 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.clip-controls {
|
||||
margin-top: 12px;
|
||||
padding: 16px;
|
||||
background: var(--button);
|
||||
border-radius: var(--border-radius);
|
||||
border: 1px solid var(--button-stroke);
|
||||
animation: slideIn 0.3s cubic-bezier(0.2, 0, 0, 1);
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 0;
|
||||
color: var(--gray);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--button-hover);
|
||||
border-top: 2px solid var(--secondary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.error-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background: rgba(237, 34, 54, 0.1);
|
||||
border: 1px solid rgba(237, 34, 54, 0.2);
|
||||
border-radius: 8px;
|
||||
color: var(--red);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.clip-metadata {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.metadata-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.metadata-header h4 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
.duration-badge {
|
||||
background: var(--button-elevated);
|
||||
color: var(--button-text);
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
}
|
||||
|
||||
.video-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.video-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.video-author {
|
||||
font-size: 12px;
|
||||
color: var(--gray);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.clip-time-display {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.time-indicators {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
}
|
||||
|
||||
.start-time, .end-time {
|
||||
color: var(--gray);
|
||||
background: var(--button-hover);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
min-width: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.duration-selected {
|
||||
grid-area: duration;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media screen and (max-width: 440px) {
|
||||
#action-container {
|
||||
flex-direction: column;
|
||||
@ -376,5 +618,36 @@
|
||||
#paste-desktop-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.clip-controls {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.metadata-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.video-title {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.time-indicators {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-areas:
|
||||
"start end"
|
||||
"duration duration";
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.start-time {
|
||||
grid-area: start;
|
||||
}
|
||||
|
||||
.end-time {
|
||||
grid-area: end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
100
web/src/components/save/buttons/ClipCheckbox.svelte
Normal file
100
web/src/components/save/buttons/ClipCheckbox.svelte
Normal file
@ -0,0 +1,100 @@
|
||||
<script lang="ts">
|
||||
import { hapticSwitch } from "$lib/haptics";
|
||||
|
||||
export let checked: boolean;
|
||||
export let onclick: () => void;
|
||||
|
||||
const handleClick = () => {
|
||||
hapticSwitch();
|
||||
onclick();
|
||||
};
|
||||
</script>
|
||||
|
||||
<button
|
||||
id="button-clip-checkbox"
|
||||
class="clip-toggle button"
|
||||
class:active={checked}
|
||||
on:click={handleClick}
|
||||
aria-pressed={checked}
|
||||
aria-label="Toggle clip mode"
|
||||
>
|
||||
<div class="toggle-icon">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"></rect>
|
||||
<polyline points="8,12 12,16 16,8" opacity={checked ? 1 : 0.3} stroke-width={checked ? 2.5 : 2}></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="clip-label">Clip</span>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.clip-toggle {
|
||||
position: relative;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
padding: 8px 12px;
|
||||
gap: 8px;
|
||||
transition: all 0.2s cubic-bezier(0.2, 0, 0, 1);
|
||||
border: 1.5px solid transparent;
|
||||
min-width: fit-content;
|
||||
}
|
||||
|
||||
.clip-toggle:not(.active) {
|
||||
background: var(--button);
|
||||
color: var(--button-text);
|
||||
}
|
||||
|
||||
.clip-toggle.active {
|
||||
background: var(--secondary);
|
||||
color: var(--primary);
|
||||
border-color: var(--secondary);
|
||||
}
|
||||
|
||||
.toggle-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 0.2s cubic-bezier(0.2, 0, 0, 1);
|
||||
}
|
||||
|
||||
.clip-toggle.active .toggle-icon {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.toggle-icon svg {
|
||||
transition: all 0.2s cubic-bezier(0.2, 0, 0, 1);
|
||||
}
|
||||
|
||||
.clip-toggle.active .toggle-icon svg {
|
||||
filter: drop-shadow(0 0 4px rgba(255, 255, 255, 0.3));
|
||||
}
|
||||
|
||||
.clip-label {
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.clip-toggle:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.clip-toggle.active:hover {
|
||||
background: var(--button-active-hover);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.clip-toggle:active {
|
||||
transform: translateY(0);
|
||||
transition-duration: 0.1s;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 440px) {
|
||||
.clip-toggle {
|
||||
padding: 10px 16px;
|
||||
font-size: 14.5px;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -6,10 +6,14 @@
|
||||
import { downloadButtonState } from "$lib/state/omnibox";
|
||||
|
||||
import type { CobaltDownloadButtonState } from "$lib/types/omnibox";
|
||||
import type { CobaltSaveRequestBody } from "$lib/types/api";
|
||||
|
||||
export let url: string;
|
||||
export let disabled = false;
|
||||
export let loading = false;
|
||||
export let clipMode = false;
|
||||
export let clipStart: number
|
||||
export let clipEnd: number
|
||||
|
||||
$: buttonText = ">>";
|
||||
$: buttonAltText = $t("a11y.save.download");
|
||||
@ -56,7 +60,16 @@
|
||||
{disabled}
|
||||
on:click={() => {
|
||||
hapticSwitch();
|
||||
savingHandler({ url });
|
||||
if (clipMode) {
|
||||
const req: CobaltSaveRequestBody & { clipStart?: number; clipEnd?: number } = {
|
||||
url,
|
||||
clipStart,
|
||||
clipEnd,
|
||||
};
|
||||
savingHandler({ request: req });
|
||||
} else {
|
||||
savingHandler({ url });
|
||||
}
|
||||
}}
|
||||
aria-label={buttonAltText}
|
||||
>
|
||||
|
@ -137,7 +137,26 @@ const probeCobaltTunnel = async (url: string) => {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const getMetadata = async (url: string) => {
|
||||
const api = currentApiURL().replace(/\/?$/, '/metadata');
|
||||
const authorization = await getAuthorization();
|
||||
let extraHeaders = {};
|
||||
if (authorization && typeof authorization === "string") {
|
||||
extraHeaders = { "Authorization": authorization };
|
||||
}
|
||||
return fetch(api, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ url }),
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
...extraHeaders,
|
||||
},
|
||||
}).then(r => r.json());
|
||||
};
|
||||
|
||||
export default {
|
||||
request,
|
||||
probeCobaltTunnel,
|
||||
getMetadata,
|
||||
}
|
||||
|
@ -41,7 +41,7 @@ export const savingHandler = async ({ url, request, oldTaskId }: SavingHandlerAr
|
||||
|
||||
if (!request && !url) return;
|
||||
|
||||
const selectedRequest = request || {
|
||||
const selectedRequest = {
|
||||
url: url!,
|
||||
|
||||
// not lazy cuz default depends on device capabilities
|
||||
@ -67,6 +67,7 @@ export const savingHandler = async ({ url, request, oldTaskId }: SavingHandlerAr
|
||||
|
||||
allowH265: getSetting("save", "allowH265"),
|
||||
convertGif: getSetting("save", "convertGif"),
|
||||
...request,
|
||||
}
|
||||
|
||||
const response = await API.request(selectedRequest);
|
||||
|
Loading…
Reference in New Issue
Block a user