feat: v0.2.1 — Player controls polish, screenshot capture, quit shortcut
v0.2.0 introduced Player mode with floating controls, auto-hide UI,
PiP, fullscreen, JKL shuttle, speed selector, captions, and cut-clip.
v0.2.1 builds on that with extensive UI polish and new features:
Player Controls Polish:
- Enlarged center playback icons (Play 48, Rewind/FF 36) with filled SVGs
- Moved Cut Clip scissors into center group with hidden cut-before button
- Narrowed floating panel from 70% to 60% width
- Custom-styled volume slider matching seek bar (linear-gradient fill)
- Pure opacity fade animation (0.35s) replacing slide-in
- Tinted panel background (rgba(53,54,80,0.75)) visible on black canvas
- Drop shadow on center transport controls
- Scissors buttons dimmed via color (not opacity) with single-property transitions
- ScissorsLineDashed icon for cut-before variant
- Timestamps in single seek row (time | bar | duration) with sans-serif font
- Milliseconds shown at smaller size, revealed on hover
Screenshot Capture (S / Shift+S):
- Canvas compositing at native video resolution with optional caption burn-in
- Caption rendering matches CSS preview (font, background, outline, word highlight)
- New Rust save_screenshot command using existing filename pattern
- Output: {title} - {ms}ms-screenshot.png
- crossorigin=anonymous on video element for untainted canvas
Other:
- Q keyboard shortcut to quit via Tauri process plugin
- formatTimeParts() utility for split timestamp rendering
- Updated About dialog shortcuts list
- Version bump to 0.2.1
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,55 @@
|
|||||||
|
# v0.2.1 — Polish, Icons, Cut Clip & Shortcuts Reference
|
||||||
|
|
||||||
|
## Task Description
|
||||||
|
|
||||||
|
Design, plan, and implement v0.2.1 of the GUI Video Clipper app. Six items: raise floating controls position, replace emoji icons with Lucide SVGs, fix blur pop-in animation, make C/P shortcuts bidirectional toggles, add "cut clip" feature from Player mode, and add keyboard shortcuts reference in a tabbed About dialog.
|
||||||
|
|
||||||
|
## Changes Made
|
||||||
|
|
||||||
|
### Commits (d8a6e0e → d7a2071)
|
||||||
|
|
||||||
|
1. **`646f2e5`** — `fix: raise controls to 64px, smooth blur via ::before pseudo-element`
|
||||||
|
- `src/lib/components/PlayerControls.svelte`: Changed `bottom: 48px` → `64px`, moved `background` + `backdrop-filter` from `.player-controls-panel` to a `::before` pseudo-element with its own opacity transition
|
||||||
|
|
||||||
|
2. **`be27b1b`** — `feat: replace emoji icons with Lucide SVG components`
|
||||||
|
- Installed `@lucide/svelte` dependency
|
||||||
|
- `src/lib/components/PlayerControls.svelte`: Replaced 🔇/🔉/🔊, ⏪, ❚❚/▶, ⏩, CC, ⧉, ⛶/⤓ with Lucide components (VolumeX/Volume1/Volume2, Rewind, Pause/Play, FastForward, Captions, PictureInPicture2, Maximize/Minimize)
|
||||||
|
- `src/lib/components/TransportControls.svelte`: Replaced ◄K/K►, ◄|/|►, ❚❚/▶, 🔇/🔉/🔊 with ChevronFirst/ChevronLast, SkipBack/SkipForward, Pause/Play, VolumeX/Volume1/Volume2
|
||||||
|
- `src/App.svelte`: Replaced ⬒/⬓, ℹ, ⚙ with PanelLeft/PanelBottom, Info, Settings
|
||||||
|
|
||||||
|
3. **`d964c97`** — `feat: C and P both toggle between Clipper/Player modes`
|
||||||
|
- `src/App.svelte`: Combined `'p'/'P'/'c'/'C'` cases to all call `toggleMode()`
|
||||||
|
|
||||||
|
4. **`ac7cb70`** — `feat: cut clip from Player mode (X/Z keys, scissors button)`
|
||||||
|
- Created `src/lib/utils/clipRange.ts` with `computeQuickClipRange()` utility
|
||||||
|
- Created `tests/lib/utils/clipRange.test.ts` (8 tests)
|
||||||
|
- `src/lib/components/PlayerControls.svelte`: Added scissors button with `onCutClip` prop
|
||||||
|
- `src/App.svelte`: Added `cutClip()` function, `X`/`Z` keyboard shortcuts (Player mode only)
|
||||||
|
|
||||||
|
5. **`507e3b7`** — `feat: tabbed About dialog with keyboard shortcuts reference`
|
||||||
|
- Rewrote `src/lib/components/AboutDialog.svelte` with "About" / "Shortcuts" tabs
|
||||||
|
- Created `tests/lib/components/AboutDialog.test.ts` (5 tests)
|
||||||
|
- Dialog dynamically sizes: ~360px for About, ~520px for Shortcuts
|
||||||
|
|
||||||
|
6. **`d7a2071`** — `fix: guard against zero-length clip at EOF`
|
||||||
|
- `src/lib/utils/clipRange.ts`: Added `start >= end` guard returning `null`
|
||||||
|
- Added test for `computeQuickClipRange(120, 120, 'at')` → `null`
|
||||||
|
|
||||||
|
## Test Results
|
||||||
|
|
||||||
|
- **69/69 tests passing** across 10 test files
|
||||||
|
- **svelte-check**: 0 errors, 0 warnings
|
||||||
|
|
||||||
|
## Follow-Up Items
|
||||||
|
|
||||||
|
- Remaining non-Lucide glyphs in ClipList, VideoPlayer, TimelineContextMenu (visual consistency for v0.2.2)
|
||||||
|
- Icon-only buttons could benefit from `aria-label` for screen reader accessibility
|
||||||
|
- Shortcuts reference in AboutDialog is static — could extract to shared source of truth with keyboard handler
|
||||||
|
- No `role="tabpanel"` / `aria-controls` wiring on About dialog tabs (full WAI-ARIA tabs pattern)
|
||||||
|
|
||||||
|
## Lessons Learned
|
||||||
|
|
||||||
|
- **WebKit `backdrop-filter` + `opacity` compositing**: WebKit doesn't smoothly composite `backdrop-filter` through an ancestor `opacity` transition. The fix is to isolate the blur into a `::before` pseudo-element with its own opacity transition.
|
||||||
|
- **`@lucide/svelte` is the official package name** (not `lucide-svelte`). Uses tree-shakable named imports: `import { Play } from '@lucide/svelte'`.
|
||||||
|
- **Svelte component testing text collisions**: When testing components with duplicate visible text (e.g., "Play / Pause" appears twice in a shortcuts table), use `getAllByText` or `getByRole` with name filters instead of `getByText`.
|
||||||
|
- **Edge case: zero-length clips**: When clamping clip ranges to video bounds, always guard against `start >= end` after clamping to prevent zero-duration clips.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# v0.2.1 Fixes: Blur, Icon Sizing, Timeline Zoom
|
||||||
|
|
||||||
|
## Task Description
|
||||||
|
|
||||||
|
Three fixes for the v0.2.1 release: (1) fix the backdrop blur pop-in animation in Player mode floating controls, raise controls another 20px; (2) enlarge center playback icons by 50%; (3) zoom timeline to clip region when using cut-clip from Player mode.
|
||||||
|
|
||||||
|
## Changes Made
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
|
||||||
|
1. **`f6ad41f`** — `fix: blur via visibility (no opacity), raise to 84px, enlarge center icons 50%`
|
||||||
|
- `src/lib/components/PlayerControls.svelte`:
|
||||||
|
- **Blur fix**: Restructured CSS so the overlay no longer transitions `opacity` at all. The `::before` blur pseudo-element uses `visibility: hidden/visible` (instant toggle, always `opacity: 1` when visible) so WebKit never has to composite `backdrop-filter` during an opacity transition. A new `.panel-content` wrapper handles the fade+slide animation (`opacity 0->1`, `translateY(6px)->0`) independently.
|
||||||
|
- **Position**: `bottom: 64px` -> `bottom: 84px`
|
||||||
|
- **Icon sizes**: Play/Pause `size={18}` -> `size={27}`, Rewind/FastForward `size={16}` -> `size={24}`, play button `min-width: 40px` -> `48px`
|
||||||
|
|
||||||
|
2. **`619fe59`** — `feat: timeline zooms to clip region on cut-clip from Player mode`
|
||||||
|
- `src/lib/components/Timeline.svelte`: Added `zoomRegion` and `onZoomApplied` optional props. Added a second `$effect` (after the duration-reset effect) that zooms to the given region with 20% padding (min 2s) on each side.
|
||||||
|
- `src/App.svelte`: Added `pendingTimelineZoom` state. `cutClip()` sets it to the created clip's range before switching to Clipper mode. Passed `zoomRegion={pendingTimelineZoom}` and `onZoomApplied` callback to `<Timeline>`.
|
||||||
|
|
||||||
|
## Test Results
|
||||||
|
|
||||||
|
- 69/69 tests passing, svelte-check 0 errors/0 warnings
|
||||||
|
|
||||||
|
## Lessons Learned
|
||||||
|
|
||||||
|
- **WebKit `backdrop-filter` + `opacity` compositing**: The `::before` pseudo-element approach from the previous attempt still failed because ANY opacity transition (even on the pseudo-element itself, not just ancestors) prevents WebKit from compositing `backdrop-filter` during the animation. The only reliable fix is to NEVER transition opacity on the element that has `backdrop-filter`. Use `visibility: hidden/visible` for instant show/hide of the blur layer, and animate content separately.
|
||||||
|
- **Svelte 5 `$effect` ordering**: When two effects run on mount, the one declared later wins for overlapping state mutations. This is leveraged for the timeline zoom — the duration-reset effect runs first (sets full view), then the zoom effect runs second and overrides if `zoomRegion` is set.
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Player Controls Polish — Round 2
|
||||||
|
|
||||||
|
**Date:** 2026-09-23 07:56 UTC
|
||||||
|
**Task:** Five UI polish changes to the floating PlayerControls panel
|
||||||
|
|
||||||
|
## Changes Made
|
||||||
|
|
||||||
|
All changes in `src/lib/components/PlayerControls.svelte`.
|
||||||
|
|
||||||
|
### 1. Enlarged center playback icons by 50%
|
||||||
|
- Play/Pause: 27 -> 40
|
||||||
|
- Rewind/FastForward: 24 -> 36
|
||||||
|
- `.ctrl-btn.play-btn` min-width: 48px -> 64px
|
||||||
|
|
||||||
|
### 2. Reorganized Cut Clip buttons into center group
|
||||||
|
- Moved the "cut at playhead" Scissors button from the right settings group into the center playback group, placed after FastForward. Icon enlarged to 36 to match.
|
||||||
|
- Added a new "cut before" Scissors button to the left of Rewind. It is hidden by default (`width: 0; opacity: 0; pointer-events: none`) and reveals on `.controls-group.center:hover` with a smooth transition. Appears at 0.7 opacity, full opacity on direct hover.
|
||||||
|
- Removed the old Scissors button and its right-click handler from the right group.
|
||||||
|
- New center group order: [Cut-before (hidden)] [Rewind] [Play/Pause] [FastForward] [Cut-at]
|
||||||
|
|
||||||
|
### 3. Narrowed floating panel by ~15%
|
||||||
|
- Overlay insets changed from `left: 15%; right: 15%` (70% width) to `left: 20%; right: 20%` (60% width).
|
||||||
|
|
||||||
|
### 4. Restyled volume slider to match seek bar
|
||||||
|
- Replaced native `accent-color` styling with `appearance: none` custom CSS.
|
||||||
|
- Track uses transparent background with JS-driven `linear-gradient` inline style showing accent color for the filled portion and `rgba(255,255,255,0.2)` for the dormant portion.
|
||||||
|
- Thumb is a 10px circle matching seek bar thumb, hidden by default and shown on hover.
|
||||||
|
- Added `volumePercent` derived state to compute the gradient stop value.
|
||||||
|
|
||||||
|
### 5. Replaced slide animation with pure fade
|
||||||
|
- Removed `transform: translateY(6px)` from `.panel-content` and its `translateY(0)` visible state.
|
||||||
|
- Changed transition to `opacity 0.35s ease` only (was `opacity 0.2s ease, transform 0.2s ease`).
|
||||||
|
- Blur `::before` pseudo-element now fades with `opacity 0.35s ease` instead of instant `visibility` toggle. Uses `visibility 0s 0.35s` delay on hide and `visibility 0s 0s` on show to keep pointer-events correct while avoiding the flash.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- `svelte-check`: 0 errors, 0 warnings
|
||||||
|
- `vitest run`: 69 tests passed across 10 test files
|
||||||
|
|
||||||
|
## Lessons Learned
|
||||||
|
- WebKit's `backdrop-filter` still renders correctly with `opacity` transitions as long as the blur layer itself isn't being destroyed/recreated. The `visibility` + delayed `transition` pattern keeps the composited blur layer alive during fade.
|
||||||
|
- Using `linear-gradient` as an inline style on range inputs is the cleanest way to show a filled/unfilled track without shadow DOM hacks, since WebKit's `::-webkit-slider-runnable-track` doesn't support `background` in scoped Svelte styles as reliably.
|
||||||
|
|
||||||
|
## Follow-up Items
|
||||||
|
- None identified. Visual QA recommended to confirm feel of 0.35s fade timing and hidden cut-before button hover UX.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Quit + Screenshot Shortcuts
|
||||||
|
|
||||||
|
**Date:** 2026-09-23 09:08 UTC
|
||||||
|
**Task:** Add Q to quit, S/Shift+S to save screenshot with/without captions, update About dialog shortcuts
|
||||||
|
|
||||||
|
## Changes Made
|
||||||
|
|
||||||
|
### Rust backend
|
||||||
|
- **`src-tauri/src/commands/export.rs`** — Added `save_screenshot` Tauri command. Accepts output directory, video title, timestamp label, and PNG byte data. Uses the existing `generate_output_path` and `expand_tilde_path` from `clip_exporter` for consistent filename generation. Creates output directory if it doesn't exist.
|
||||||
|
- **`src-tauri/src/lib.rs`** — Registered `export::save_screenshot` in the invoke handler.
|
||||||
|
|
||||||
|
### Frontend bindings
|
||||||
|
- **`src/lib/bindings/export.ts`** — Added `saveScreenshot()` function that invokes the new Rust command.
|
||||||
|
|
||||||
|
### Screenshot utility
|
||||||
|
- **`src/lib/utils/screenshot.ts`** (new file) — Canvas-based screenshot capture:
|
||||||
|
- Draws the current video frame onto an offscreen canvas at native resolution (`videoWidth` x `videoHeight`)
|
||||||
|
- Optionally composites captions using Canvas 2D text APIs, matching the CSS caption style (font, color, background, outline/shadow, word highlighting, position)
|
||||||
|
- Scales caption font size proportionally from preview to native resolution
|
||||||
|
- Exports as PNG blob, converts to byte array, sends to Rust backend
|
||||||
|
- Filename format: `{videoTitle} - {totalMs}ms-screenshot.png`
|
||||||
|
|
||||||
|
### VideoPlayer
|
||||||
|
- **`src/lib/components/VideoPlayer.svelte`** — Added `export function getParsedCues()` to expose parsed VTT cues to the parent component.
|
||||||
|
|
||||||
|
### App keybindings
|
||||||
|
- **`src/App.svelte`** — Added:
|
||||||
|
- `Q` — Quit app via `exit(0)` from `@tauri-apps/plugin-process`
|
||||||
|
- `S` — Screenshot with captions
|
||||||
|
- `Shift+S` — Screenshot without captions
|
||||||
|
- `bind:this={videoPlayerRef}` on VideoPlayer for accessing `getParsedCues()`
|
||||||
|
- `captureScreenshot(withCaptions)` async function that orchestrates the capture
|
||||||
|
|
||||||
|
### About dialog
|
||||||
|
- **`src/lib/components/AboutDialog.svelte`** — Added Q, S, and Shift+S to the global shortcuts list.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- `svelte-check`: 0 errors, 0 warnings
|
||||||
|
- `vitest run`: 69 tests passed across 10 test files
|
||||||
|
- `cargo check`: compiles clean
|
||||||
|
|
||||||
|
## Lessons Learned
|
||||||
|
- Canvas 2D text rendering requires explicit scaling when compositing at native resolution vs. CSS preview resolution. Using `videoHeight / clientHeight` as the scale factor ensures caption size matches visually.
|
||||||
|
- The `toBlob` → `arrayBuffer` → `Array.from(Uint8Array)` pipeline is necessary because Tauri's IPC expects `Vec<u8>` on the Rust side, which maps to `number[]` in TypeScript invoke calls.
|
||||||
|
|
||||||
|
## Follow-up Items
|
||||||
|
- Visual QA recommended to verify caption rendering fidelity on screenshots matches the preview exactly (especially text wrapping and word-level highlighting).
|
||||||
|
- Could add a brief toast notification on successful screenshot save (currently only logs to console).
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "gui-video-clipper",
|
"name": "gui-video-clipper",
|
||||||
"version": "0.2.0",
|
"version": "0.2.1",
|
||||||
"description": "macOS GUI video clipper (Tauri + Svelte)",
|
"description": "macOS GUI video clipper (Tauri + Svelte)",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@@ -1281,7 +1281,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gui-video-clipper"
|
name = "gui-video-clipper"
|
||||||
version = "0.2.0"
|
version = "0.2.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"dirs",
|
"dirs",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "gui-video-clipper"
|
name = "gui-video-clipper"
|
||||||
version = "0.2.0"
|
version = "0.2.1"
|
||||||
description = "A macOS GUI app for clipping online videos"
|
description = "A macOS GUI app for clipping online videos"
|
||||||
authors = ["cottongin"]
|
authors = ["cottongin"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use crate::models::{ExportConfig, ExportScope};
|
use crate::models::{ExportConfig, ExportScope};
|
||||||
use crate::services::clip_exporter;
|
use crate::services::clip_exporter;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
use std::path::Path;
|
||||||
use tauri::ipc::Channel;
|
use tauri::ipc::Channel;
|
||||||
|
|
||||||
#[derive(Clone, Serialize)]
|
#[derive(Clone, Serialize)]
|
||||||
@@ -26,6 +27,30 @@ pub enum ExportEvent {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn save_screenshot(
|
||||||
|
output_dir: String,
|
||||||
|
video_title: String,
|
||||||
|
timestamp_label: String,
|
||||||
|
png_data: Vec<u8>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let expanded_dir = clip_exporter::expand_tilde_path(&output_dir);
|
||||||
|
let dir_path = Path::new(&expanded_dir);
|
||||||
|
if !dir_path.exists() {
|
||||||
|
std::fs::create_dir_all(dir_path)
|
||||||
|
.map_err(|e| format!("Failed to create output directory: {e}"))?;
|
||||||
|
}
|
||||||
|
let path = clip_exporter::generate_output_path(
|
||||||
|
&expanded_dir,
|
||||||
|
&video_title,
|
||||||
|
&format!("{}-screenshot", timestamp_label),
|
||||||
|
"png",
|
||||||
|
);
|
||||||
|
std::fs::write(&path, &png_data)
|
||||||
|
.map_err(|e| format!("Failed to write screenshot: {e}"))?;
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn export_clips(
|
pub async fn export_clips(
|
||||||
config: ExportConfig,
|
config: ExportConfig,
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ pub fn run() {
|
|||||||
media_analysis::check_embedded_subtitles,
|
media_analysis::check_embedded_subtitles,
|
||||||
media_analysis::list_system_fonts,
|
media_analysis::list_system_fonts,
|
||||||
export::export_clips,
|
export::export_clips,
|
||||||
|
export::save_screenshot,
|
||||||
cache::load_cached_analysis,
|
cache::load_cached_analysis,
|
||||||
cache::save_analysis_to_cache,
|
cache::save_analysis_to_cache,
|
||||||
cache::clear_video_cache,
|
cache::clear_video_cache,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "GUI Video Clipper",
|
"productName": "GUI Video Clipper",
|
||||||
"version": "0.2.0",
|
"version": "0.2.1",
|
||||||
"identifier": "xyz.cottongin.gui-video-clipper",
|
"identifier": "xyz.cottongin.gui-video-clipper",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
import { loadPreferences, preferences, setClipListPosition, setClipListWidth, setAppMode } from '$lib/stores/preferences.svelte';
|
import { loadPreferences, preferences, setClipListPosition, setClipListWidth, setAppMode } from '$lib/stores/preferences.svelte';
|
||||||
import { getSelectedClipId, removeClip, markInPoint, markOutPoint, addClip } from '$lib/stores/clips.svelte';
|
import { getSelectedClipId, removeClip, markInPoint, markOutPoint, addClip } from '$lib/stores/clips.svelte';
|
||||||
import { computeQuickClipRange } from '$lib/utils/clipRange';
|
import { computeQuickClipRange } from '$lib/utils/clipRange';
|
||||||
|
import { captureFrame } from '$lib/utils/screenshot';
|
||||||
|
import { exit } from '@tauri-apps/plugin-process';
|
||||||
import {
|
import {
|
||||||
adjustShuttle,
|
adjustShuttle,
|
||||||
resetShuttleRate,
|
resetShuttleRate,
|
||||||
@@ -39,6 +41,7 @@
|
|||||||
let shuttleRate = $state(1);
|
let shuttleRate = $state(1);
|
||||||
|
|
||||||
let transportControls = $state<TransportControls | null>(null);
|
let transportControls = $state<TransportControls | null>(null);
|
||||||
|
let videoPlayerRef = $state<VideoPlayer | null>(null);
|
||||||
|
|
||||||
// Resizable split: timeline height in px (clip list gets the rest)
|
// Resizable split: timeline height in px (clip list gets the rest)
|
||||||
let timelineHeight = $state(180);
|
let timelineHeight = $state(180);
|
||||||
@@ -198,6 +201,29 @@
|
|||||||
setAppMode('clipper');
|
setAppMode('clipper');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function captureScreenshot(withCaptions: boolean) {
|
||||||
|
if (session.status !== 'ready') {
|
||||||
|
console.warn('[screenshot] No video ready, skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
console.log(`[screenshot] Capturing frame (captions: ${withCaptions})...`);
|
||||||
|
const cues = videoPlayerRef?.getParsedCues() ?? [];
|
||||||
|
const path = await captureFrame({
|
||||||
|
currentTime: session.currentTime,
|
||||||
|
videoTitle: session.title,
|
||||||
|
outputDirectory: preferences.outputDirectory,
|
||||||
|
withCaptions,
|
||||||
|
captionsEnabled,
|
||||||
|
captionSettings: preferences.captionSettings,
|
||||||
|
parsedCues: cues,
|
||||||
|
});
|
||||||
|
console.log(`[screenshot] Saved: ${path}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[screenshot] Failed:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleToggleCaptions() {
|
function handleToggleCaptions() {
|
||||||
captionsEnabled = !captionsEnabled;
|
captionsEnabled = !captionsEnabled;
|
||||||
}
|
}
|
||||||
@@ -312,6 +338,16 @@
|
|||||||
showExportDialog = true;
|
showExportDialog = true;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case 'q':
|
||||||
|
case 'Q':
|
||||||
|
e.preventDefault();
|
||||||
|
exit(0);
|
||||||
|
break;
|
||||||
|
case 's':
|
||||||
|
case 'S':
|
||||||
|
e.preventDefault();
|
||||||
|
captureScreenshot(!e.shiftKey);
|
||||||
|
break;
|
||||||
case '/':
|
case '/':
|
||||||
if (e.metaKey) {
|
if (e.metaKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -423,7 +459,7 @@
|
|||||||
|
|
||||||
<!-- Video area: always mounted, never destroyed -->
|
<!-- Video area: always mounted, never destroyed -->
|
||||||
<div class="video-area" class:player-video={isPlayerMode}>
|
<div class="video-area" class:player-video={isPlayerMode}>
|
||||||
<VideoPlayer bind:captionsEnabled playerControlsVisible={isPlayerMode && showPlayerControls} />
|
<VideoPlayer bind:this={videoPlayerRef} bind:captionsEnabled playerControlsVisible={isPlayerMode && showPlayerControls} />
|
||||||
{#if isPlayerMode}
|
{#if isPlayerMode}
|
||||||
<PlayerControls
|
<PlayerControls
|
||||||
visible={showPlayerControls}
|
visible={showPlayerControls}
|
||||||
|
|||||||
@@ -81,3 +81,17 @@ export async function exportClips(
|
|||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function saveScreenshot(
|
||||||
|
outputDir: string,
|
||||||
|
videoTitle: string,
|
||||||
|
timestampLabel: string,
|
||||||
|
pngData: number[],
|
||||||
|
): Promise<string> {
|
||||||
|
return invoke('save_screenshot', {
|
||||||
|
outputDir,
|
||||||
|
videoTitle,
|
||||||
|
timestampLabel,
|
||||||
|
pngData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,6 +26,9 @@
|
|||||||
{ key: '< (Shift+,)', action: 'Previous keyframe' },
|
{ key: '< (Shift+,)', action: 'Previous keyframe' },
|
||||||
{ key: '> (Shift+.)', action: 'Next keyframe' },
|
{ key: '> (Shift+.)', action: 'Next keyframe' },
|
||||||
{ key: 'C / P', action: 'Toggle mode' },
|
{ key: 'C / P', action: 'Toggle mode' },
|
||||||
|
{ key: 'S', action: 'Screenshot (with captions)' },
|
||||||
|
{ key: 'Shift+S', action: 'Screenshot (without captions)' },
|
||||||
|
{ key: 'Q', action: 'Quit' },
|
||||||
{ key: '⌘/', action: 'About' },
|
{ key: '⌘/', action: 'About' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
setPlaybackRate,
|
setPlaybackRate,
|
||||||
getPlaybackRate,
|
getPlaybackRate,
|
||||||
} from '$lib/transport/playback';
|
} from '$lib/transport/playback';
|
||||||
import { formatTime } from '$lib/utils/time';
|
import { formatTime, formatTimeParts } from '$lib/utils/time';
|
||||||
import {
|
import {
|
||||||
VolumeX,
|
VolumeX,
|
||||||
Volume1,
|
Volume1,
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
Pause,
|
Pause,
|
||||||
Play,
|
Play,
|
||||||
Scissors,
|
Scissors,
|
||||||
|
ScissorsLineDashed,
|
||||||
Captions,
|
Captions,
|
||||||
PictureInPicture2,
|
PictureInPicture2,
|
||||||
Maximize,
|
Maximize,
|
||||||
@@ -65,6 +66,13 @@
|
|||||||
session.duration > 0 ? session.currentTime / session.duration : 0
|
session.duration > 0 ? session.currentTime / session.duration : 0
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let currentParts = $derived(formatTimeParts(session.currentTime));
|
||||||
|
let durationParts = $derived(formatTimeParts(session.duration));
|
||||||
|
|
||||||
|
let volumePercent = $derived(
|
||||||
|
((isMuted ? 0 : volume) * 100).toFixed(1)
|
||||||
|
);
|
||||||
|
|
||||||
function handleSeekBarMouseDown(e: MouseEvent) {
|
function handleSeekBarMouseDown(e: MouseEvent) {
|
||||||
isSeeking = true;
|
isSeeking = true;
|
||||||
seekFromMouseEvent(e);
|
seekFromMouseEvent(e);
|
||||||
@@ -185,29 +193,42 @@
|
|||||||
step="0.05"
|
step="0.05"
|
||||||
value={isMuted ? 0 : volume}
|
value={isMuted ? 0 : volume}
|
||||||
oninput={handleVolumeChange}
|
oninput={handleVolumeChange}
|
||||||
|
style="background: linear-gradient(to right, var(--accent, #89b4fa) {volumePercent}%, rgba(255,255,255,0.2) {volumePercent}%)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Center group: playback -->
|
<!-- Center group: playback -->
|
||||||
<div class="controls-group center">
|
<div class="controls-group center">
|
||||||
|
<button
|
||||||
|
class="ctrl-btn scissors-btn cut-before-btn"
|
||||||
|
onclick={() => onCutClip('before')}
|
||||||
|
title="Cut clip 10s before playhead (Z)"
|
||||||
|
type="button"
|
||||||
|
><ScissorsLineDashed size={36} /></button>
|
||||||
<button
|
<button
|
||||||
class="ctrl-btn"
|
class="ctrl-btn"
|
||||||
onclick={() => seekBy(-10)}
|
onclick={() => seekBy(-10)}
|
||||||
title="Skip back 10s"
|
title="Skip back 10s"
|
||||||
type="button"
|
type="button"
|
||||||
><Rewind size={24} /></button>
|
><Rewind size={36} /></button>
|
||||||
<button
|
<button
|
||||||
class="ctrl-btn play-btn"
|
class="ctrl-btn play-btn"
|
||||||
onclick={togglePlayPause}
|
onclick={togglePlayPause}
|
||||||
title={session.isPlaying ? 'Pause' : 'Play'}
|
title={session.isPlaying ? 'Pause' : 'Play'}
|
||||||
type="button"
|
type="button"
|
||||||
>{#if session.isPlaying}<Pause size={27} />{:else}<Play size={27} />{/if}</button>
|
>{#if session.isPlaying}<Pause size={48} />{:else}<Play size={48} />{/if}</button>
|
||||||
<button
|
<button
|
||||||
class="ctrl-btn"
|
class="ctrl-btn"
|
||||||
onclick={() => seekBy(10)}
|
onclick={() => seekBy(10)}
|
||||||
title="Skip forward 10s"
|
title="Skip forward 10s"
|
||||||
type="button"
|
type="button"
|
||||||
><FastForward size={24} /></button>
|
><FastForward size={36} /></button>
|
||||||
|
<button
|
||||||
|
class="ctrl-btn scissors-btn"
|
||||||
|
onclick={() => onCutClip('at')}
|
||||||
|
title="Cut clip at playhead (X)"
|
||||||
|
type="button"
|
||||||
|
><Scissors size={36} /></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right group: settings -->
|
<!-- Right group: settings -->
|
||||||
@@ -228,14 +249,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
|
||||||
class="ctrl-btn"
|
|
||||||
onclick={() => onCutClip('at')}
|
|
||||||
oncontextmenu={(e) => { e.preventDefault(); onCutClip('before'); }}
|
|
||||||
title="Cut clip at playhead (X) · Right-click: 10s before (Z)"
|
|
||||||
type="button"
|
|
||||||
><Scissors size={16} /></button>
|
|
||||||
|
|
||||||
{#if hasCaptions}
|
{#if hasCaptions}
|
||||||
<button
|
<button
|
||||||
class="ctrl-btn"
|
class="ctrl-btn"
|
||||||
@@ -265,36 +278,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Seek bar (middle) -->
|
<!-- Seek row: timestamp | seek bar | duration -->
|
||||||
<div
|
<div class="seek-row">
|
||||||
class="seek-bar"
|
<span class="time-display time-current">{currentParts.main}<span class="time-ms">{currentParts.ms}</span></span>
|
||||||
bind:this={seekBarEl}
|
<div
|
||||||
onmousedown={handleSeekBarMouseDown}
|
class="seek-bar"
|
||||||
onmousemove={handleSeekBarMouseMove}
|
bind:this={seekBarEl}
|
||||||
onmouseup={handleSeekBarMouseUp}
|
onmousedown={handleSeekBarMouseDown}
|
||||||
onmouseleave={handleSeekBarMouseLeave}
|
onmousemove={handleSeekBarMouseMove}
|
||||||
role="slider"
|
onmouseup={handleSeekBarMouseUp}
|
||||||
tabindex="0"
|
onmouseleave={handleSeekBarMouseLeave}
|
||||||
aria-label="Seek"
|
role="slider"
|
||||||
aria-valuenow={session.currentTime}
|
tabindex="0"
|
||||||
aria-valuemin={0}
|
aria-label="Seek"
|
||||||
aria-valuemax={session.duration}
|
aria-valuenow={session.currentTime}
|
||||||
>
|
aria-valuemin={0}
|
||||||
<div class="seek-track">
|
aria-valuemax={session.duration}
|
||||||
<div class="seek-fill" style="width: {progress * 100}%"></div>
|
>
|
||||||
<div class="seek-thumb" style="left: {progress * 100}%"></div>
|
<div class="seek-track">
|
||||||
</div>
|
<div class="seek-fill" style="width: {progress * 100}%"></div>
|
||||||
{#if hoverTime !== null}
|
<div class="seek-thumb" style="left: {progress * 100}%"></div>
|
||||||
<div class="seek-tooltip" style="left: {hoverX}px">
|
|
||||||
{formatTime(hoverTime)}
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{#if hoverTime !== null}
|
||||||
</div>
|
<div class="seek-tooltip" style="left: {hoverX}px">
|
||||||
|
{formatTime(hoverTime)}
|
||||||
<!-- Timestamps (bottom) -->
|
</div>
|
||||||
<div class="timestamps-row">
|
{/if}
|
||||||
<span class="time-display">{formatTime(session.currentTime)}</span>
|
</div>
|
||||||
<span class="time-display">{formatTime(session.duration)}</span>
|
<span class="time-display time-duration">{durationParts.main}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -305,8 +316,8 @@
|
|||||||
.player-controls-overlay {
|
.player-controls-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 84px;
|
bottom: 84px;
|
||||||
left: 15%;
|
left: 20%;
|
||||||
right: 15%;
|
right: 20%;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
}
|
}
|
||||||
@@ -322,42 +333,45 @@
|
|||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Blur background — visibility toggle, never opacity-transition */
|
/* Blur background — fades in/out with content */
|
||||||
.player-controls-panel::before {
|
.player-controls-panel::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(0, 0, 0, 0.75);
|
background: rgba(53, 54, 80, 0.75);
|
||||||
backdrop-filter: blur(20px);
|
backdrop-filter: blur(20px);
|
||||||
-webkit-backdrop-filter: blur(20px);
|
-webkit-backdrop-filter: blur(20px);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
|
opacity: 0;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
|
transition: opacity 0.35s ease, visibility 0s 0.35s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-controls-overlay.visible .player-controls-panel::before {
|
.player-controls-overlay.visible .player-controls-panel::before {
|
||||||
|
opacity: 1;
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
|
transition: opacity 0.35s ease, visibility 0s 0s;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Content wrapper — fade + slide animation */
|
/* Content wrapper — pure opacity fade */
|
||||||
.panel-content {
|
.panel-content {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(6px);
|
transition: opacity 0.35s ease;
|
||||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-controls-overlay.visible .panel-content {
|
.player-controls-overlay.visible .panel-content {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Seek bar --- */
|
/* --- Seek bar --- */
|
||||||
.seek-bar {
|
.seek-bar {
|
||||||
width: 100%;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -420,13 +434,13 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls-group {
|
.controls-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls-group.left {
|
.controls-group.left {
|
||||||
@@ -436,6 +450,13 @@
|
|||||||
|
|
||||||
.controls-group.center {
|
.controls-group.center {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
opacity: 0.9;
|
||||||
|
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.4));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fill center transport icons (rewind, play/pause, forward) but not scissors */
|
||||||
|
.controls-group.center > .ctrl-btn:not(.scissors-btn):not(.cut-before-btn) :global(svg) {
|
||||||
|
fill: currentColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls-group.right {
|
.controls-group.right {
|
||||||
@@ -470,29 +491,86 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ctrl-btn.play-btn {
|
.ctrl-btn.play-btn {
|
||||||
min-width: 48px;
|
min-width: 72px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Volume --- */
|
/* Scissors buttons — dimmed via color, single-property transition */
|
||||||
|
.scissors-btn {
|
||||||
|
color: rgba(205, 214, 244, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scissors-btn:hover {
|
||||||
|
color: var(--accent, #89b4fa);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cut-before: invisible by default, dimmed on self-hover */
|
||||||
|
.cut-before-btn {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cut-before-btn:hover {
|
||||||
|
color: rgba(205, 214, 244, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Volume slider (custom styled to match seek bar) --- */
|
||||||
.vol-slider {
|
.vol-slider {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
width: 70px;
|
width: 70px;
|
||||||
height: 4px;
|
height: 4px;
|
||||||
accent-color: var(--accent, #89b4fa);
|
border-radius: 2px;
|
||||||
|
outline: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vol-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent, #89b4fa);
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vol-slider:hover::-webkit-slider-thumb {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Time --- */
|
/* --- Time --- */
|
||||||
.time-display {
|
.time-display {
|
||||||
font-family: var(--font-mono, monospace);
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
color: var(--text-secondary, #a6adc8);
|
color: var(--text-secondary, #a6adc8);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timestamps-row {
|
.time-current {
|
||||||
|
min-width: 5.5ch;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-duration {
|
||||||
|
min-width: 5.5ch;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-ms {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seek-row:hover .time-ms {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Seek row: single-line timestamp | bar | duration --- */
|
||||||
|
.seek-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
align-items: center;
|
||||||
padding: 0 4px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Speed wrapper --- */
|
/* --- Speed wrapper --- */
|
||||||
|
|||||||
@@ -215,6 +215,10 @@
|
|||||||
export function getVideoElement(): HTMLVideoElement | null {
|
export function getVideoElement(): HTMLVideoElement | null {
|
||||||
return videoElement;
|
return videoElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getParsedCues(): VttCue[] {
|
||||||
|
return parsedCues;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="video-player">
|
<div class="video-player">
|
||||||
@@ -222,6 +226,7 @@
|
|||||||
<video
|
<video
|
||||||
bind:this={videoElement}
|
bind:this={videoElement}
|
||||||
src={videoSrc}
|
src={videoSrc}
|
||||||
|
crossorigin="anonymous"
|
||||||
ontimeupdate={handleTimeUpdate}
|
ontimeupdate={handleTimeUpdate}
|
||||||
onplay={handlePlay}
|
onplay={handlePlay}
|
||||||
onpause={handlePause}
|
onpause={handlePause}
|
||||||
|
|||||||
212
src/lib/utils/screenshot.ts
Normal file
212
src/lib/utils/screenshot.ts
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import type { CaptionSettings } from '$lib/stores/preferences.svelte';
|
||||||
|
import type { VttCue } from '$lib/utils/vttParser';
|
||||||
|
import { getActiveCues, getActiveWords } from '$lib/utils/vttParser';
|
||||||
|
import { saveScreenshot } from '$lib/bindings/export';
|
||||||
|
|
||||||
|
function hexToRgb(hex: string): { r: number; g: number; b: number } {
|
||||||
|
const h = hex.replace('#', '');
|
||||||
|
const r = parseInt(h.substring(0, 2), 16);
|
||||||
|
const g = parseInt(h.substring(2, 4), 16);
|
||||||
|
const b = parseInt(h.substring(4, 6), 16);
|
||||||
|
return { r, g, b };
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCaptions(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
cues: VttCue[],
|
||||||
|
currentTime: number,
|
||||||
|
canvasWidth: number,
|
||||||
|
canvasHeight: number,
|
||||||
|
settings: CaptionSettings,
|
||||||
|
scale: number,
|
||||||
|
): void {
|
||||||
|
const activeCues = getActiveCues(cues, currentTime);
|
||||||
|
if (activeCues.length === 0) return;
|
||||||
|
|
||||||
|
const fontSize = Math.round(settings.fontSize * scale);
|
||||||
|
const fontWeight = settings.bold ? 'bold' : 'normal';
|
||||||
|
const font = `${fontWeight} ${fontSize}px ${settings.fontFamily}`;
|
||||||
|
ctx.font = font;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'top';
|
||||||
|
|
||||||
|
const lineHeight = fontSize * 1.4;
|
||||||
|
const padding = Math.round(4 * scale);
|
||||||
|
const hPadding = Math.round(10 * scale);
|
||||||
|
const maxWidth = canvasWidth * 0.8;
|
||||||
|
|
||||||
|
const positionY =
|
||||||
|
settings.position === 'top'
|
||||||
|
? canvasHeight * 0.08
|
||||||
|
: canvasHeight * 0.92 - activeCues.length * lineHeight;
|
||||||
|
|
||||||
|
const centerX = canvasWidth / 2;
|
||||||
|
|
||||||
|
for (let i = 0; i < activeCues.length; i++) {
|
||||||
|
const cue = activeCues[i];
|
||||||
|
const y = positionY + i * lineHeight;
|
||||||
|
|
||||||
|
if (settings.wordHighlight && cue.words) {
|
||||||
|
const words = getActiveWords(cue, currentTime);
|
||||||
|
const fullText = words.map((w) => w.text).join(' ');
|
||||||
|
const textMetrics = ctx.measureText(fullText);
|
||||||
|
const textWidth = Math.min(textMetrics.width, maxWidth);
|
||||||
|
|
||||||
|
if (settings.backgroundEnabled) {
|
||||||
|
const { r, g, b } = hexToRgb(settings.backgroundColor);
|
||||||
|
ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${settings.backgroundOpacity})`;
|
||||||
|
ctx.fillRect(
|
||||||
|
centerX - textWidth / 2 - hPadding,
|
||||||
|
y - padding,
|
||||||
|
textWidth + hPadding * 2,
|
||||||
|
lineHeight + padding * 2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let xOffset = centerX - textWidth / 2;
|
||||||
|
for (const word of words) {
|
||||||
|
const wordWithSpace = word.text + ' ';
|
||||||
|
if (word.spoken) {
|
||||||
|
ctx.fillStyle = settings.textColor;
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
} else {
|
||||||
|
if (settings.dimmedColorMode === 'custom') {
|
||||||
|
ctx.fillStyle = settings.dimmedColor;
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
} else {
|
||||||
|
ctx.fillStyle = settings.textColor;
|
||||||
|
ctx.globalAlpha = settings.dimmedOpacity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applyTextEffects(ctx, wordWithSpace, xOffset, y, settings, scale);
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.fillText(wordWithSpace, xOffset, y);
|
||||||
|
xOffset += ctx.measureText(wordWithSpace).width;
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
} else {
|
||||||
|
const textMetrics = ctx.measureText(cue.text);
|
||||||
|
const textWidth = Math.min(textMetrics.width, maxWidth);
|
||||||
|
|
||||||
|
if (settings.backgroundEnabled) {
|
||||||
|
const { r, g, b } = hexToRgb(settings.backgroundColor);
|
||||||
|
ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${settings.backgroundOpacity})`;
|
||||||
|
ctx.fillRect(
|
||||||
|
centerX - textWidth / 2 - hPadding,
|
||||||
|
y - padding,
|
||||||
|
textWidth + hPadding * 2,
|
||||||
|
lineHeight + padding * 2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillStyle = settings.textColor;
|
||||||
|
applyTextEffects(ctx, cue.text, centerX, y, settings, scale);
|
||||||
|
ctx.fillText(cue.text, centerX, y, maxWidth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTextEffects(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
_text: string,
|
||||||
|
_x: number,
|
||||||
|
_y: number,
|
||||||
|
settings: CaptionSettings,
|
||||||
|
scale: number,
|
||||||
|
): void {
|
||||||
|
if (settings.shadowEnabled) {
|
||||||
|
const depth = settings.shadowDepth * scale;
|
||||||
|
ctx.shadowColor = settings.shadowColor;
|
||||||
|
ctx.shadowOffsetX = depth;
|
||||||
|
ctx.shadowOffsetY = depth;
|
||||||
|
ctx.shadowBlur = depth * 2;
|
||||||
|
} else if (settings.textOutline) {
|
||||||
|
ctx.shadowColor = 'rgba(0,0,0,0.9)';
|
||||||
|
ctx.shadowOffsetX = 1 * scale;
|
||||||
|
ctx.shadowOffsetY = 1 * scale;
|
||||||
|
ctx.shadowBlur = 2 * scale;
|
||||||
|
} else {
|
||||||
|
ctx.shadowColor = 'transparent';
|
||||||
|
ctx.shadowOffsetX = 0;
|
||||||
|
ctx.shadowOffsetY = 0;
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
canvas.toBlob(
|
||||||
|
(blob) => {
|
||||||
|
if (blob) resolve(blob);
|
||||||
|
else reject(new Error('Canvas toBlob returned null'));
|
||||||
|
},
|
||||||
|
'image/png',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScreenshotOptions {
|
||||||
|
currentTime: number;
|
||||||
|
videoTitle: string;
|
||||||
|
outputDirectory: string;
|
||||||
|
withCaptions: boolean;
|
||||||
|
captionsEnabled: boolean;
|
||||||
|
captionSettings: CaptionSettings;
|
||||||
|
parsedCues: VttCue[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function captureFrame(options: ScreenshotOptions): Promise<string> {
|
||||||
|
const videoEl = document.querySelector('video');
|
||||||
|
if (!videoEl) throw new Error('No video element found');
|
||||||
|
if (videoEl.readyState < 2) throw new Error('Video not ready for capture');
|
||||||
|
|
||||||
|
const nativeWidth = videoEl.videoWidth;
|
||||||
|
const nativeHeight = videoEl.videoHeight;
|
||||||
|
if (nativeWidth === 0 || nativeHeight === 0) {
|
||||||
|
throw new Error('Video has zero dimensions');
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = nativeWidth;
|
||||||
|
canvas.height = nativeHeight;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) throw new Error('Failed to get canvas 2D context');
|
||||||
|
|
||||||
|
ctx.drawImage(videoEl, 0, 0, nativeWidth, nativeHeight);
|
||||||
|
|
||||||
|
if (
|
||||||
|
options.withCaptions &&
|
||||||
|
options.captionsEnabled &&
|
||||||
|
options.parsedCues.length > 0
|
||||||
|
) {
|
||||||
|
const renderedHeight = videoEl.clientHeight || 600;
|
||||||
|
const scale = nativeHeight / renderedHeight;
|
||||||
|
renderCaptions(
|
||||||
|
ctx,
|
||||||
|
options.parsedCues,
|
||||||
|
options.currentTime,
|
||||||
|
nativeWidth,
|
||||||
|
nativeHeight,
|
||||||
|
options.captionSettings,
|
||||||
|
scale,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await canvasToBlob(canvas);
|
||||||
|
const buffer = await blob.arrayBuffer();
|
||||||
|
const pngData = Array.from(new Uint8Array(buffer));
|
||||||
|
|
||||||
|
const totalMs = Math.round(options.currentTime * 1000);
|
||||||
|
const timestampLabel = `${totalMs}ms`;
|
||||||
|
|
||||||
|
const savedPath = await saveScreenshot(
|
||||||
|
options.outputDirectory,
|
||||||
|
options.videoTitle,
|
||||||
|
timestampLabel,
|
||||||
|
pngData,
|
||||||
|
);
|
||||||
|
|
||||||
|
return savedPath;
|
||||||
|
}
|
||||||
@@ -14,6 +14,20 @@ export function formatTime(totalSeconds: number): string {
|
|||||||
return `${mm}:${ss}.${ms}`;
|
return `${mm}:${ss}.${ms}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatTimeParts(totalSeconds: number): { main: string; ms: string } {
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const seconds = Math.floor(totalSeconds % 60);
|
||||||
|
const millis = Math.round((totalSeconds % 1) * 1000);
|
||||||
|
|
||||||
|
const mm = String(minutes).padStart(2, '0');
|
||||||
|
const ss = String(seconds).padStart(2, '0');
|
||||||
|
const ms = String(millis).padStart(3, '0');
|
||||||
|
|
||||||
|
const main = hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`;
|
||||||
|
return { main, ms: `.${ms}` };
|
||||||
|
}
|
||||||
|
|
||||||
export function parseTime(str: string): number | null {
|
export function parseTime(str: string): number | null {
|
||||||
const hmsMatch = str.match(/^(\d+):(\d{2}):(\d{2})(?:\.(\d{1,3}))?$/);
|
const hmsMatch = str.match(/^(\d+):(\d{2}):(\d{2})(?:\.(\d{1,3}))?$/);
|
||||||
if (hmsMatch) {
|
if (hmsMatch) {
|
||||||
|
|||||||
Reference in New Issue
Block a user