Compare commits
7 Commits
v0.1.0
...
d46973a719
| Author | SHA1 | Date | |
|---|---|---|---|
|
d46973a719
|
|||
|
1ea7f19668
|
|||
|
30b04b42d4
|
|||
|
d85fbdac05
|
|||
|
9d1f1cfcf3
|
|||
|
3eebe01d63
|
|||
|
4bf65458d0
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -9,5 +9,6 @@ node_modules
|
|||||||
!.env.example
|
!.env.example
|
||||||
vite.config.js.timestamp-*
|
vite.config.js.timestamp-*
|
||||||
vite.config.ts.timestamp-*
|
vite.config.ts.timestamp-*
|
||||||
|
dist/
|
||||||
.cursor/
|
.cursor/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|||||||
10
README.md
10
README.md
@@ -33,6 +33,16 @@ npm run tauri dev
|
|||||||
npm run tauri build
|
npm run tauri build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Release Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run release
|
||||||
|
```
|
||||||
|
|
||||||
|
This runs the full Tauri production build, ad-hoc code signs the `.app` bundle, and copies the output to `dist/release/`.
|
||||||
|
|
||||||
|
The resulting `GUI Video Clipper.app` can be launched directly or dragged to `/Applications`.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
GUI Video Clipper is built with [Tauri v2](https://v2.tauri.app/) (Rust backend) and [Svelte 5](https://svelte.dev/) (TypeScript frontend).
|
GUI Video Clipper is built with [Tauri v2](https://v2.tauri.app/) (Rust backend) and [Svelte 5](https://svelte.dev/) (TypeScript frontend).
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# v0.1.0 Release Polish — Summary
|
||||||
|
|
||||||
|
## Task Description
|
||||||
|
|
||||||
|
Prepared GUI Video Clipper for its initial `v0.1.0` tagged release. This was a polish/branding/release-prep pass — no new features, purely metadata, versioning infrastructure, an About dialog, licensing, README, and pushing to the remote repository with a clean git history.
|
||||||
|
|
||||||
|
## Changes Made
|
||||||
|
|
||||||
|
### Versioning Infrastructure (Task 1)
|
||||||
|
- Created `VERSION` file at project root (single source of truth: `0.1.0`)
|
||||||
|
- Created `scripts/bump-version.sh` that syncs VERSION → `package.json`, `Cargo.toml`, `tauri.conf.json`, and `Cargo.lock`
|
||||||
|
- Script handles leading `v`/`V`, trailing whitespace, and validates semver format
|
||||||
|
|
||||||
|
### Metadata Cleanup (Task 2)
|
||||||
|
- Replaced all placeholder/template values across the codebase:
|
||||||
|
- `productName`: "Video Clipper" → "GUI Video Clipper"
|
||||||
|
- `identifier`: "com.erikfredericks.tauri-app" → "xyz.cottongin.gui-video-clipper"
|
||||||
|
- `Cargo.toml` name/description/authors/lib.name
|
||||||
|
- `main.rs` crate reference: `tauri_app_lib` → `gui_video_clipper_lib`
|
||||||
|
- `index.html` title and SetupWizard body text
|
||||||
|
|
||||||
|
### Path Scrubbing (Task 3)
|
||||||
|
- Replaced 6 absolute paths containing personal username in 3 plan doc files with relative equivalents
|
||||||
|
|
||||||
|
### LICENSE (Task 4)
|
||||||
|
- Added MIT license file (copyright 2026 cottongin)
|
||||||
|
|
||||||
|
### README (Task 5)
|
||||||
|
- Full rewrite replacing stock Tauri template: features list, prerequisites, build instructions, architecture overview, contributing guide, license
|
||||||
|
|
||||||
|
### About Dialog (Task 6)
|
||||||
|
- New `AboutDialog.svelte` component showing app icon, title, version (via `getVersion()`), author, license, repo link
|
||||||
|
- Three triggers: toolbar ℹ button, ⌘/ shortcut, clickable version label in status bar
|
||||||
|
- Wired into `App.svelte` and `StatusBar.svelte`
|
||||||
|
|
||||||
|
### Git History Scrub & Release (Tasks 7-8)
|
||||||
|
- Committed all untracked spec/plan docs
|
||||||
|
- Ran `git-filter-repo --replace-text` in two passes:
|
||||||
|
1. Path/identifier replacements (com.erikfredericks.tauri-app → xyz.cottongin.gui-video-clipper, absolute paths → empty)
|
||||||
|
2. Bare username replacement (erikfredericks → REDACTED_USERNAME)
|
||||||
|
- Final verification: 0 matches for "erikfredericks" across all 49 commits
|
||||||
|
- Added remote: `git@code.cottongin.xyz:cottongin/gui-video-clipper.git`
|
||||||
|
- Tagged `v0.1.0` and pushed to `origin/master`
|
||||||
|
|
||||||
|
## Lessons Learned
|
||||||
|
|
||||||
|
- `git-filter-repo --replace-text` is clean and fast but needs multiple passes when replacements include the target string as documentation (meta-references in spec/plan docs survive the first pass)
|
||||||
|
- The branch was `master` (from initial scaffold), not `main` — plan assumed `main`
|
||||||
|
- `npm pkg set version=X --json` emits a JSON parse error on some npm versions before the fallback succeeds — harmless but noisy
|
||||||
|
- Pre-existing `vite.config.ts` type errors cause `npm run check` to exit non-zero; unrelated to this work
|
||||||
|
|
||||||
|
## Follow-Up Items
|
||||||
|
|
||||||
|
- Fix pre-existing `vite.config.ts` type errors so `npm run check` passes clean
|
||||||
|
- Consider renaming `master` → `main` if preferred
|
||||||
|
- Future: CI/CD pipeline, `.dmg` packaging, changelog generation
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Build Cleanup & Release Packaging
|
||||||
|
|
||||||
|
**Date:** 2026-09-22 13:39
|
||||||
|
**Commits:** 4bf6545..1ea7f19 (5 commits)
|
||||||
|
|
||||||
|
## Task Description
|
||||||
|
|
||||||
|
Resolved all build warnings/errors across the frontend (svelte-check, Vite) and backend (cargo), fixed a failing test, and implemented a release build script that produces a distributable `GUI Video Clipper.app` bundle.
|
||||||
|
|
||||||
|
## Changes Made
|
||||||
|
|
||||||
|
### Task 1: TypeScript Error Fixes (`3eebe01`)
|
||||||
|
- Installed `@types/node` as dev dependency
|
||||||
|
- Added `"types": ["node"]` to `tsconfig.json`
|
||||||
|
- Changed `defineConfig` import in `vite.config.ts` from `vite` to `vitest/config` (extends config type with `test` property)
|
||||||
|
- Result: 4 TS errors → 0
|
||||||
|
|
||||||
|
### Task 2: Svelte A11y Warning Fixes (`9d1f1cf`)
|
||||||
|
- **CaptionSettingsPanel.svelte**: 9 label/control association fixes (id/for pairs and wrapping)
|
||||||
|
- **ClipList.svelte**: Added `tabindex="0"` and keyboard handler to listbox container
|
||||||
|
- **PreferencesPanel.svelte, ExportDialog.svelte, AboutDialog.svelte**: Added `tabindex="-1"` to dialog divs, keyboard handlers on overlays
|
||||||
|
- **VideoPlayer.svelte**: Added `<track kind="captions" />` to `<video>` element
|
||||||
|
- **App.svelte**: Added keyboard support to resize handle (`role="slider"`, arrow keys, aria-value attrs)
|
||||||
|
- Result: 19 a11y warnings → 0
|
||||||
|
|
||||||
|
### Task 3: Rust Dead Code Annotations (`d85fbda`)
|
||||||
|
- Added `#[allow(dead_code)]` with comments on 6 items across 4 files:
|
||||||
|
- `ExportEvent::Error`, `DownloadEvent::Error` (reserved for future use)
|
||||||
|
- `sanitize_vtt_for_ffmpeg`, `opacity_to_ass_back_colour`, `caption_style_to_force_style` (caption burn-in pipeline)
|
||||||
|
- `SubtitleFormat.ext`/`.url` (serde deserialization fields)
|
||||||
|
- Result: 6 warnings → 0
|
||||||
|
|
||||||
|
### Task 4: Test Fix (`30b04b4`)
|
||||||
|
- Changed `markInPoint(5)` to `markInPoint(12)` in clips.test.ts
|
||||||
|
- Time 5 was outside `EDIT_TOLERANCE` (0.5s) of clip boundary (10-20), so `isTimeInsideClip` returned false and the edit path didn't execute
|
||||||
|
- Result: 1 failing test → 31/31 pass
|
||||||
|
|
||||||
|
### Task 5: Release Build Script (`1ea7f19`)
|
||||||
|
- Created `scripts/build-release.sh`: builds, ad-hoc signs, copies to `dist/release/`
|
||||||
|
- Changed `tauri.conf.json` bundle targets from `"all"` to `["app", "dmg"]`
|
||||||
|
- Added `dist/` to `.gitignore`
|
||||||
|
- Added `"release"` npm script
|
||||||
|
- Updated README with Release Build section
|
||||||
|
- Script parses tauri build output for .app/.dmg paths (handles custom CARGO_TARGET_DIR)
|
||||||
|
- Result: `npm run release` → `GUI Video Clipper.app` (6.2M) + `.dmg` (2.8M)
|
||||||
|
|
||||||
|
## Verification Results
|
||||||
|
- `npm run check`: 0 errors, 0 warnings
|
||||||
|
- `npm test`: 31/31 pass
|
||||||
|
- `cargo build`: 0 warnings
|
||||||
|
- `npm run release`: produces signed .app + .dmg
|
||||||
|
|
||||||
|
## Lessons Learned
|
||||||
|
- **Cursor sandbox CARGO_TARGET_DIR**: The Tauri build outputs to a sandboxed cache directory, not `src-tauri/target/`. The build script must parse the actual output paths from `npx tauri build` output rather than hardcoding the expected location.
|
||||||
|
- **Svelte a11y linter strictness**: `role="separator"` with keyboard support still triggers `a11y_no_noninteractive_element_interactions`. Using `role="slider"` satisfies the linter for resize handles.
|
||||||
|
- **Dialog overlay keyboard patterns**: Adding `onkeydown` + `tabindex="-1"` to overlays and `e.stopPropagation()` to inner dialogs satisfies the click-needs-keyboard-equivalent rule, but the stopPropagation means Escape from inside the dialog won't bubble to the overlay handler. Not a regression since dialogs previously had no keyboard close.
|
||||||
|
- **Test EDIT_TOLERANCE awareness**: When testing functions that use proximity checks (`isTimeInsideClip`), test values must account for tolerance thresholds. Values outside tolerance trigger different code paths.
|
||||||
|
|
||||||
|
## Follow-up Items
|
||||||
|
- Push new commits to remote: `git push origin master`
|
||||||
|
- Consider re-tagging `v0.1.0` to include these cleanup commits, or tag as `v0.1.1`
|
||||||
|
- Dialog Escape-from-inside behavior could be improved in a future pass (add Escape handler on the dialog div itself, not just the overlay)
|
||||||
@@ -0,0 +1,733 @@
|
|||||||
|
# Build Cleanup & Release Packaging Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Eliminate all build warnings/errors across frontend and backend, fix the failing test, and implement a release build script that produces a distributable `GUI Video Clipper.app`.
|
||||||
|
|
||||||
|
**Architecture:** Direct fixes to existing files — no new components or modules except `scripts/build-release.sh`. TypeScript config fixes, Svelte a11y attribute additions, Rust `#[allow]` annotations, one test assertion update, and a new shell script.
|
||||||
|
|
||||||
|
**Tech Stack:** Svelte 5, TypeScript, Vite/Vitest, Rust/Cargo, Tauri v2, Bash
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- macOS is the only target platform
|
||||||
|
- All changes must result in zero warnings from `npm run check`, `cargo build`, and `npm test`
|
||||||
|
- Preserve existing runtime behavior — these are lint/warning fixes, not behavioral changes
|
||||||
|
- Follow existing code patterns and styles in each file
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: TypeScript Error Fixes
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `tsconfig.json`
|
||||||
|
- Modify: `vite.config.ts:1` (import line only)
|
||||||
|
- Test: `npm run check` (svelte-check)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: nothing
|
||||||
|
- Produces: Clean `npm run check` (0 errors for T1–T4; a11y warnings will still be present until Task 2)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Install `@types/node`**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
npm install -D @types/node
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Package added to `devDependencies` in `package.json`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add `"types": ["node"]` to `tsconfig.json`**
|
||||||
|
|
||||||
|
Edit `tsconfig.json` — add `"types": ["node"]` inside `compilerOptions`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"module": "ESNext",
|
||||||
|
"target": "ESNext",
|
||||||
|
"isolatedModules": true,
|
||||||
|
"types": ["node"],
|
||||||
|
"paths": {
|
||||||
|
"$lib/*": ["./src/lib/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.svelte", "tests/**/*.ts", "vite.config.ts"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Change `defineConfig` import in `vite.config.ts`**
|
||||||
|
|
||||||
|
Change line 4 from:
|
||||||
|
```typescript
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
```
|
||||||
|
to:
|
||||||
|
```typescript
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
```
|
||||||
|
|
||||||
|
This re-export extends `UserConfigExport` with vitest's `test` property.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify TS errors are resolved**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
npm run check 2>&1 | grep -E "^(Error|.*Error:)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: No lines matching "Error" — only a11y warnings remain.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tsconfig.json vite.config.ts package.json package-lock.json
|
||||||
|
git commit -m "fix: resolve TypeScript errors in vite.config.ts
|
||||||
|
|
||||||
|
- Install @types/node for node:path, node:process, node:url
|
||||||
|
- Import defineConfig from vitest/config for test config type
|
||||||
|
- Add types: [\"node\"] to tsconfig.json"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Svelte A11y Warning Fixes
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/lib/components/CaptionSettingsPanel.svelte:48-209` (label associations)
|
||||||
|
- Modify: `src/lib/components/ClipList.svelte:46` (tabindex + keydown)
|
||||||
|
- Modify: `src/lib/components/PreferencesPanel.svelte:60-61` (tabindex + keydown)
|
||||||
|
- Modify: `src/lib/components/ExportDialog.svelte:139-140` (tabindex + keydown)
|
||||||
|
- Modify: `src/lib/components/AboutDialog.svelte:15-16` (tabindex + keydown)
|
||||||
|
- Modify: `src/lib/components/VideoPlayer.svelte:187-197` (track element)
|
||||||
|
- Modify: `src/App.svelte:188-193` (resize handle keyboard support)
|
||||||
|
- Test: `npm run check` (svelte-check)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: Task 1 completed (TS errors resolved)
|
||||||
|
- Produces: Zero a11y warnings from `npm run check` and `npm run build`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Fix CaptionSettingsPanel labels (9 warnings)**
|
||||||
|
|
||||||
|
The labels at lines 48, 61, 74, 90, 108, 120, 165, 195, and 209 are not associated with their controls. Fix each by adding `id` attributes to controls and `for` attributes to labels. Use unique IDs prefixed with `caption-`.
|
||||||
|
|
||||||
|
**Line 48 — Font label + select:** Change:
|
||||||
|
```svelte
|
||||||
|
<label>Font</label>
|
||||||
|
<select
|
||||||
|
value={settings.fontFamily}
|
||||||
|
```
|
||||||
|
to:
|
||||||
|
```svelte
|
||||||
|
<label for="caption-font">Font</label>
|
||||||
|
<select
|
||||||
|
id="caption-font"
|
||||||
|
value={settings.fontFamily}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Line 61 — Font Size label + range input:** The label wraps the text but not the input. Wrap the input inside the label:
|
||||||
|
```svelte
|
||||||
|
<label>
|
||||||
|
Font Size
|
||||||
|
<span class="value">{settings.fontSize}px</span>
|
||||||
|
<input
|
||||||
|
type="range" min="12" max="36" step="1"
|
||||||
|
value={settings.fontSize}
|
||||||
|
oninput={(e) => update({ fontSize: parseInt((e.target as HTMLInputElement).value, 10) })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove the standalone `<input>` that was a sibling below the closing `</label>`.
|
||||||
|
|
||||||
|
**Line 74 — Text Color label:** Change:
|
||||||
|
```svelte
|
||||||
|
<label>Text Color</label>
|
||||||
|
<div class="row-controls">
|
||||||
|
<input
|
||||||
|
type="color" value={settings.textColor}
|
||||||
|
```
|
||||||
|
to:
|
||||||
|
```svelte
|
||||||
|
<label for="caption-text-color">Text Color</label>
|
||||||
|
<div class="row-controls">
|
||||||
|
<input
|
||||||
|
id="caption-text-color"
|
||||||
|
type="color" value={settings.textColor}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Line 90 — Dimmed Text label:** This is a heading-style label for a radio group. Use `id`/`for` pointing to the first radio:
|
||||||
|
```svelte
|
||||||
|
<label for="caption-dimmed-auto">Dimmed Text</label>
|
||||||
|
<div class="radio-row">
|
||||||
|
<label>
|
||||||
|
<input id="caption-dimmed-auto" type="radio" name="dimmed-mode" value="auto"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Line 108 — Dim Opacity label + range:** Wrap the input inside the label (same pattern as Font Size):
|
||||||
|
```svelte
|
||||||
|
<label>
|
||||||
|
Dim Opacity
|
||||||
|
<span class="value">{Math.round(settings.dimmedOpacity * 100)}%</span>
|
||||||
|
<input
|
||||||
|
type="range" min="10" max="90" step="5"
|
||||||
|
value={Math.round(settings.dimmedOpacity * 100)}
|
||||||
|
oninput={(e) => update({ dimmedOpacity: parseInt((e.target as HTMLInputElement).value, 10) / 100 })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove the standalone `<input>` below.
|
||||||
|
|
||||||
|
**Line 120 — Dimmed Color label:** Change:
|
||||||
|
```svelte
|
||||||
|
<label>Dimmed Color</label>
|
||||||
|
<div class="row-controls">
|
||||||
|
<input
|
||||||
|
type="color" value={settings.dimmedColor}
|
||||||
|
```
|
||||||
|
to:
|
||||||
|
```svelte
|
||||||
|
<label for="caption-dimmed-color">Dimmed Color</label>
|
||||||
|
<div class="row-controls">
|
||||||
|
<input
|
||||||
|
id="caption-dimmed-color"
|
||||||
|
type="color" value={settings.dimmedColor}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Line 165 — BG Opacity label + range:** Wrap input inside label (same as Font Size/Dim Opacity):
|
||||||
|
```svelte
|
||||||
|
<label>
|
||||||
|
BG Opacity
|
||||||
|
<span class="value">{Math.round(settings.backgroundOpacity * 100)}%</span>
|
||||||
|
<input
|
||||||
|
type="range" min="0" max="100" step="5"
|
||||||
|
value={Math.round(settings.backgroundOpacity * 100)}
|
||||||
|
oninput={(e) => update({ backgroundOpacity: parseInt((e.target as HTMLInputElement).value, 10) / 100 })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove the standalone `<input>` below.
|
||||||
|
|
||||||
|
**Line 195 — Shadow Depth label + range:** Wrap input inside label:
|
||||||
|
```svelte
|
||||||
|
<label>
|
||||||
|
Shadow Depth
|
||||||
|
<span class="value">{settings.shadowDepth}px</span>
|
||||||
|
<input
|
||||||
|
type="range" min="1" max="5" step="1"
|
||||||
|
value={settings.shadowDepth}
|
||||||
|
oninput={(e) => update({ shadowDepth: parseInt((e.target as HTMLInputElement).value, 10) })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove the standalone `<input>` below.
|
||||||
|
|
||||||
|
**Line 209 — Position label:** Same as Dimmed Text — heading for radio group:
|
||||||
|
```svelte
|
||||||
|
<label for="caption-pos-bottom">Position</label>
|
||||||
|
<div class="radio-row">
|
||||||
|
<label>
|
||||||
|
<input id="caption-pos-bottom" type="radio" name="caption-position" value="bottom"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Fix ClipList (2 warnings: tabindex + key handler)**
|
||||||
|
|
||||||
|
In `ClipList.svelte` line 46, the `<div class="clip-list" role="listbox">` needs `tabindex="0"` and a `onkeydown` handler. Change:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<div class="clip-list" role="listbox" onclick={handleContainerClick}>
|
||||||
|
```
|
||||||
|
to:
|
||||||
|
```svelte
|
||||||
|
<div
|
||||||
|
class="clip-list"
|
||||||
|
role="listbox"
|
||||||
|
tabindex="0"
|
||||||
|
onclick={handleContainerClick}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
selectClip(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Fix PreferencesPanel (2 warnings: tabindex + key handler)**
|
||||||
|
|
||||||
|
In `PreferencesPanel.svelte`, change lines 60-61:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<div class="overlay" role="presentation" onclick={onClose}>
|
||||||
|
<div class="panel" role="dialog" onclick={(e) => e.stopPropagation()}>
|
||||||
|
```
|
||||||
|
to:
|
||||||
|
```svelte
|
||||||
|
<div class="overlay" role="presentation" onclick={onClose} onkeydown={(e) => { if (e.key === 'Escape') onClose(); }}>
|
||||||
|
<div class="panel" role="dialog" tabindex="-1" onclick={(e) => e.stopPropagation()}>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Fix ExportDialog (2 warnings: tabindex + key handler)**
|
||||||
|
|
||||||
|
In `ExportDialog.svelte`, change lines 139-140:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<div class="overlay" role="presentation" onclick={onClose}>
|
||||||
|
<div class="dialog" role="dialog" onclick={(e) => e.stopPropagation()}>
|
||||||
|
```
|
||||||
|
to:
|
||||||
|
```svelte
|
||||||
|
<div class="overlay" role="presentation" onclick={onClose} onkeydown={(e) => { if (e.key === 'Escape') onClose(); }}>
|
||||||
|
<div class="dialog" role="dialog" tabindex="-1" onclick={(e) => e.stopPropagation()}>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Fix AboutDialog (2 warnings: tabindex + key handler)**
|
||||||
|
|
||||||
|
In `AboutDialog.svelte`, change lines 15-16:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<div class="overlay" role="presentation" onclick={onClose}>
|
||||||
|
<div class="dialog" role="dialog" onclick={(e) => e.stopPropagation()}>
|
||||||
|
```
|
||||||
|
to:
|
||||||
|
```svelte
|
||||||
|
<div class="overlay" role="presentation" onclick={onClose} onkeydown={(e) => { if (e.key === 'Escape') onClose(); }}>
|
||||||
|
<div class="dialog" role="dialog" tabindex="-1" onclick={(e) => e.stopPropagation()}>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Fix VideoPlayer (1 warning: video without track)**
|
||||||
|
|
||||||
|
In `VideoPlayer.svelte`, add a `<track>` inside the `<video>` element after the `playsinline` attribute (around line 197):
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<video
|
||||||
|
bind:this={videoElement}
|
||||||
|
src={videoSrc}
|
||||||
|
ontimeupdate={handleTimeUpdate}
|
||||||
|
onplay={handlePlay}
|
||||||
|
onpause={handlePause}
|
||||||
|
onerror={handleError}
|
||||||
|
onloadeddata={handleLoadedData}
|
||||||
|
preload="metadata"
|
||||||
|
playsinline
|
||||||
|
>
|
||||||
|
<track kind="captions" />
|
||||||
|
</video>
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: change the self-closing `></video>` to wrap the `<track>` element.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Fix App.svelte resize handle (1 warning: non-interactive element with mouse handler)**
|
||||||
|
|
||||||
|
In `App.svelte`, update the resize handle div (lines 188-193) to add keyboard support:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<div
|
||||||
|
class="resize-handle"
|
||||||
|
class:active={isResizing}
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="horizontal"
|
||||||
|
aria-valuenow={timelineHeight}
|
||||||
|
aria-valuemin={40}
|
||||||
|
aria-valuemax={500}
|
||||||
|
tabindex="0"
|
||||||
|
onmousedown={handleResizeStart}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
timelineHeight = Math.min(500, timelineHeight + 10);
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
timelineHeight = Math.max(40, timelineHeight - 10);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 8: Verify all a11y warnings are resolved**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
npm run check 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `svelte-check found 0 errors and 0 warnings`
|
||||||
|
|
||||||
|
Also verify the production build:
|
||||||
|
```bash
|
||||||
|
npm run build 2>&1 | grep -c "svelte"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: No a11y warnings in the Vite build output (only the normal build success lines).
|
||||||
|
|
||||||
|
- [ ] **Step 9: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/lib/components/CaptionSettingsPanel.svelte \
|
||||||
|
src/lib/components/ClipList.svelte \
|
||||||
|
src/lib/components/PreferencesPanel.svelte \
|
||||||
|
src/lib/components/ExportDialog.svelte \
|
||||||
|
src/lib/components/AboutDialog.svelte \
|
||||||
|
src/lib/components/VideoPlayer.svelte \
|
||||||
|
src/App.svelte
|
||||||
|
git commit -m "fix: resolve all Svelte a11y warnings
|
||||||
|
|
||||||
|
- Associate labels with controls in CaptionSettingsPanel (9 warnings)
|
||||||
|
- Add tabindex and keyboard handlers to dialog overlays (8 warnings)
|
||||||
|
- Add <track> to video element for caption accessibility
|
||||||
|
- Add keyboard support to resize handle separator"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Rust Dead Code Annotations
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src-tauri/src/commands/export.rs:23`
|
||||||
|
- Modify: `src-tauri/src/commands/video.rs:23`
|
||||||
|
- Modify: `src-tauri/src/services/clip_exporter.rs:10,204,210`
|
||||||
|
- Modify: `src-tauri/src/services/video_resolver.rs:7`
|
||||||
|
- Test: `cargo build`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: nothing
|
||||||
|
- Produces: Zero warnings from `cargo build`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Annotate `ExportEvent::Error` in `commands/export.rs`**
|
||||||
|
|
||||||
|
Add `#[allow(dead_code)]` on the `Error` variant (line 23):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
Finished {
|
||||||
|
paths: Vec<String>,
|
||||||
|
},
|
||||||
|
#[allow(dead_code)] // Reserved for future error-channel reporting
|
||||||
|
Error {
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Annotate `DownloadEvent::Error` in `commands/video.rs`**
|
||||||
|
|
||||||
|
Add `#[allow(dead_code)]` on the `Error` variant (line 23):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
Finished { success: bool, path: String },
|
||||||
|
#[allow(dead_code)] // Reserved for future error-channel reporting
|
||||||
|
Error { message: String },
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Annotate unused functions in `services/clip_exporter.rs`**
|
||||||
|
|
||||||
|
Add `#[allow(dead_code)]` on each unused function:
|
||||||
|
|
||||||
|
Line 10:
|
||||||
|
```rust
|
||||||
|
/// Strip YouTube auto-generated VTT karaoke tags and positioning metadata
|
||||||
|
/// that confuse ffmpeg's VTT parser / mov_text conversion.
|
||||||
|
/// Returns the path to a cleaned temp VTT file.
|
||||||
|
#[allow(dead_code)] // Scaffolded for caption burn-in pipeline
|
||||||
|
pub fn sanitize_vtt_for_ffmpeg(caption_path: &str, temp_dir: &str) -> Result<String, String> {
|
||||||
|
```
|
||||||
|
|
||||||
|
Line 204:
|
||||||
|
```rust
|
||||||
|
#[allow(dead_code)] // Scaffolded for caption burn-in pipeline
|
||||||
|
fn opacity_to_ass_back_colour(opacity: f64) -> String {
|
||||||
|
```
|
||||||
|
|
||||||
|
Line 210:
|
||||||
|
```rust
|
||||||
|
#[allow(dead_code)] // Scaffolded for caption burn-in pipeline
|
||||||
|
fn caption_style_to_force_style(style: &CaptionStyle) -> String {
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Annotate `SubtitleFormat` in `services/video_resolver.rs`**
|
||||||
|
|
||||||
|
Add `#[allow(dead_code)]` on the struct (line 7):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[allow(dead_code)] // Fields used for serde deserialization
|
||||||
|
pub struct SubtitleFormat {
|
||||||
|
pub ext: Option<String>,
|
||||||
|
pub url: Option<String>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify zero Rust warnings**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd src-tauri && cargo build 2>&1 | grep "warning:"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: No output (zero warnings).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src-tauri/src/commands/export.rs \
|
||||||
|
src-tauri/src/commands/video.rs \
|
||||||
|
src-tauri/src/services/clip_exporter.rs \
|
||||||
|
src-tauri/src/services/video_resolver.rs
|
||||||
|
git commit -m "fix: suppress Rust dead code warnings with annotations
|
||||||
|
|
||||||
|
- ExportEvent::Error and DownloadEvent::Error reserved for future use
|
||||||
|
- clip_exporter functions scaffolded for caption burn-in pipeline
|
||||||
|
- SubtitleFormat fields required for serde deserialization"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Test Fix
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `tests/lib/stores/clips.test.ts:60-68`
|
||||||
|
- Test: `npm test`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: nothing (test-only change)
|
||||||
|
- Produces: 31/31 tests passing
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update the failing test assertion**
|
||||||
|
|
||||||
|
In `tests/lib/stores/clips.test.ts`, change the test "updates selected clip start when I is pressed" (lines 60-68):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
it('updates selected clip start when I is pressed', () => {
|
||||||
|
addClip(10, 20);
|
||||||
|
const id = getClips()[0].id;
|
||||||
|
selectClip(id);
|
||||||
|
|
||||||
|
markInPoint(12);
|
||||||
|
expect(getClips()[0].startTime).toBe(12);
|
||||||
|
expect(getPendingInPoint()).toBeNull();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
The change: `markInPoint(5)` → `markInPoint(12)` and `toBe(5)` → `toBe(12)`.
|
||||||
|
|
||||||
|
Time 12 is inside the clip (10–20) so `isTimeInsideClip` returns true and the edit path runs. Time 5 was outside the 0.5s tolerance (needed ≥ 9.5).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify all tests pass**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
```
|
||||||
|
Test Files 5 passed (5)
|
||||||
|
Tests 31 passed (31)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/lib/stores/clips.test.ts
|
||||||
|
git commit -m "fix: correct clip in-point edit test for EDIT_TOLERANCE
|
||||||
|
|
||||||
|
markInPoint(5) is outside the clip (10-20) with 0.5s tolerance.
|
||||||
|
Use markInPoint(12) which is inside the clip and triggers the edit path."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Release Build Script & Packaging
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `scripts/build-release.sh`
|
||||||
|
- Modify: `src-tauri/tauri.conf.json:38` (bundle targets)
|
||||||
|
- Modify: `.gitignore` (add `dist/`)
|
||||||
|
- Modify: `package.json` (add `release` script)
|
||||||
|
- Modify: `README.md` (add Release Build section)
|
||||||
|
- Test: `npm run release`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: Tasks 1–4 completed (clean builds)
|
||||||
|
- Produces: `dist/release/GUI Video Clipper.app` — a runnable, ad-hoc signed macOS app bundle
|
||||||
|
|
||||||
|
- [ ] **Step 1: Change bundle targets in `tauri.conf.json`**
|
||||||
|
|
||||||
|
In `src-tauri/tauri.conf.json`, change line 38 from:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"targets": "all",
|
||||||
|
```
|
||||||
|
to:
|
||||||
|
```json
|
||||||
|
"targets": ["app", "dmg"],
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add `dist/` to `.gitignore`**
|
||||||
|
|
||||||
|
Append to `.gitignore`:
|
||||||
|
|
||||||
|
```
|
||||||
|
dist/
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add `release` script to `package.json`**
|
||||||
|
|
||||||
|
Add to the `"scripts"` section of `package.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"release": "bash scripts/build-release.sh"
|
||||||
|
```
|
||||||
|
|
||||||
|
Place it after the existing `"tauri"` script.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Create `scripts/build-release.sh`**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
VERSION_FILE="$PROJECT_ROOT/VERSION"
|
||||||
|
|
||||||
|
# ── Read and validate version ──────────────────────────────────────
|
||||||
|
if [ ! -f "$VERSION_FILE" ]; then
|
||||||
|
echo "ERROR: VERSION file not found" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
VERSION=$(cat "$VERSION_FILE" | sed 's/^[vV]//' | tr -d '[:space:]')
|
||||||
|
|
||||||
|
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||||
|
echo "ERROR: '$VERSION' is not valid semver (expected X.Y.Z)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Building GUI Video Clipper v$VERSION ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── Install frontend dependencies ──────────────────────────────────
|
||||||
|
echo "→ Installing dependencies…"
|
||||||
|
cd "$PROJECT_ROOT"
|
||||||
|
npm install --silent
|
||||||
|
|
||||||
|
# ── Run tauri build ────────────────────────────────────────────────
|
||||||
|
echo "→ Building release (this may take a few minutes)…"
|
||||||
|
npx tauri build 2>&1 | tail -5
|
||||||
|
|
||||||
|
# ── Locate built artifacts ─────────────────────────────────────────
|
||||||
|
# Tauri outputs to src-tauri/target/release/bundle/
|
||||||
|
BUNDLE_DIR="$PROJECT_ROOT/src-tauri/target/release/bundle"
|
||||||
|
APP_SRC="$BUNDLE_DIR/macos/GUI Video Clipper.app"
|
||||||
|
DMG_GLOB="$BUNDLE_DIR/dmg/GUI Video Clipper_*.dmg"
|
||||||
|
|
||||||
|
if [ ! -d "$APP_SRC" ]; then
|
||||||
|
echo "ERROR: .app bundle not found at '$APP_SRC'" >&2
|
||||||
|
echo "Check the build output above for errors." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Ad-hoc code sign ──────────────────────────────────────────────
|
||||||
|
echo "→ Ad-hoc signing .app…"
|
||||||
|
codesign --force --deep -s - "$APP_SRC"
|
||||||
|
|
||||||
|
# ── Copy to dist/release/ ─────────────────────────────────────────
|
||||||
|
DIST_DIR="$PROJECT_ROOT/dist/release"
|
||||||
|
mkdir -p "$DIST_DIR"
|
||||||
|
|
||||||
|
echo "→ Copying to dist/release/…"
|
||||||
|
rm -rf "$DIST_DIR/GUI Video Clipper.app"
|
||||||
|
cp -R "$APP_SRC" "$DIST_DIR/"
|
||||||
|
|
||||||
|
# Copy DMG if it was built
|
||||||
|
DMG_FILE=$(ls $DMG_GLOB 2>/dev/null | head -1 || true)
|
||||||
|
if [ -n "$DMG_FILE" ]; then
|
||||||
|
cp "$DMG_FILE" "$DIST_DIR/"
|
||||||
|
echo " ✓ DMG: $(basename "$DMG_FILE")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Summary ────────────────────────────────────────────────────────
|
||||||
|
APP_SIZE=$(du -sh "$DIST_DIR/GUI Video Clipper.app" | cut -f1)
|
||||||
|
echo ""
|
||||||
|
echo "=== Build Complete ==="
|
||||||
|
echo " Version: v$VERSION"
|
||||||
|
echo " App: dist/release/GUI Video Clipper.app ($APP_SIZE)"
|
||||||
|
if [ -n "$DMG_FILE" ]; then
|
||||||
|
DMG_SIZE=$(du -sh "$DIST_DIR/$(basename "$DMG_FILE")" | cut -f1)
|
||||||
|
echo " DMG: dist/release/$(basename "$DMG_FILE") ($DMG_SIZE)"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
echo "To run: open \"dist/release/GUI Video Clipper.app\""
|
||||||
|
```
|
||||||
|
|
||||||
|
Make it executable:
|
||||||
|
```bash
|
||||||
|
chmod +x scripts/build-release.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Update README with Release Build section**
|
||||||
|
|
||||||
|
Add a new section after "## Build & Run" in `README.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Release Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run release
|
||||||
|
```
|
||||||
|
|
||||||
|
This runs the full Tauri production build, ad-hoc code signs the `.app` bundle, and copies the output to `dist/release/`.
|
||||||
|
|
||||||
|
The resulting `GUI Video Clipper.app` can be launched directly or dragged to `/Applications`.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Verify the release build**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
npm run release
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- Script prints version, build progress, and summary
|
||||||
|
- `dist/release/GUI Video Clipper.app` exists
|
||||||
|
- App launches: `open "dist/release/GUI Video Clipper.app"`
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add scripts/build-release.sh \
|
||||||
|
src-tauri/tauri.conf.json \
|
||||||
|
.gitignore \
|
||||||
|
package.json \
|
||||||
|
README.md
|
||||||
|
git commit -m "feat: add release build script
|
||||||
|
|
||||||
|
- scripts/build-release.sh: builds, signs, and copies .app to dist/release/
|
||||||
|
- Bundle targets narrowed to app + dmg
|
||||||
|
- npm run release convenience script
|
||||||
|
- README documents the release build workflow"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final Verification
|
||||||
|
|
||||||
|
After all tasks, run the full check suite:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run check # 0 errors, 0 warnings
|
||||||
|
npm test # 31/31 pass
|
||||||
|
cd src-tauri && cargo build 2>&1 | grep "warning:" # no output
|
||||||
|
npm run release # produces dist/release/GUI Video Clipper.app
|
||||||
|
```
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# Build Cleanup & Release Packaging
|
||||||
|
|
||||||
|
**Date:** 2026-09-22
|
||||||
|
**Status:** Approved
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Resolve all build warnings and errors across the frontend (svelte-check, Vite) and backend (cargo), fix the failing test, and implement a release build script that produces a distributable `GUI Video Clipper.app` bundle.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Two workstreams:
|
||||||
|
|
||||||
|
1. **Warning/Error Cleanup** — zero warnings from `npm run check`, `cargo build`, and `npm test`
|
||||||
|
2. **Release Build Packaging** — `scripts/build-release.sh` that produces a signed `.app` in `dist/release/`
|
||||||
|
|
||||||
|
## 1. TypeScript Errors (4)
|
||||||
|
|
||||||
|
All in `vite.config.ts`:
|
||||||
|
|
||||||
|
| Error | Root Cause | Fix |
|
||||||
|
|-------|-----------|-----|
|
||||||
|
| Cannot find `node:path` | Missing `@types/node` | `npm install -D @types/node`, add `"types": ["node"]` to `tsconfig.json` |
|
||||||
|
| Cannot find `node:process` | Same | Same |
|
||||||
|
| Cannot find `node:url` | Same | Same |
|
||||||
|
| `test` not in `UserConfigExport` | `defineConfig` from `vite` doesn't know vitest's `test` field | Import `defineConfig` from `vitest/config` instead |
|
||||||
|
|
||||||
|
## 2. Svelte A11y Warnings (19)
|
||||||
|
|
||||||
|
### 2a. Labels not associated with controls (9 in CaptionSettingsPanel.svelte)
|
||||||
|
|
||||||
|
Labels are siblings of their controls rather than wrapping them. Fix by either:
|
||||||
|
- Wrapping the `<input>`/`<select>` inside the `<label>` element, or
|
||||||
|
- Adding `id` to the control and `for` to the label
|
||||||
|
|
||||||
|
Prefer the wrapping approach where layout allows.
|
||||||
|
|
||||||
|
### 2b. Dialog divs missing tabindex (4 files)
|
||||||
|
|
||||||
|
Add `tabindex="-1"` to `<div role="dialog">` in `PreferencesPanel`, `ExportDialog`, `AboutDialog`, and `<div role="listbox">` in `ClipList`. Makes them programmatically focusable without entering the tab order.
|
||||||
|
|
||||||
|
### 2c. Click handlers without key handlers (4 files)
|
||||||
|
|
||||||
|
Dialog overlay `onclick={onClose}` divs need corresponding `onkeydown` handlers. Add Escape key handling on the overlay. For `ClipList`'s listbox container, add an `onkeydown` handler mirroring the container click logic.
|
||||||
|
|
||||||
|
### 2d. `<video>` without `<track>` (1 in VideoPlayer.svelte)
|
||||||
|
|
||||||
|
Add an empty `<track kind="captions" default />` to the `<video>` element. Our custom caption overlay handles actual rendering.
|
||||||
|
|
||||||
|
### 2e. Resize handle (1 in App.svelte)
|
||||||
|
|
||||||
|
The resize handle `<div>` with `role="separator"` and `onmousedown` needs:
|
||||||
|
- `tabindex="0"` for keyboard focusability
|
||||||
|
- `onkeydown` handler for arrow key resizing
|
||||||
|
- `aria-valuenow`, `aria-valuemin`, `aria-valuemax` attributes
|
||||||
|
|
||||||
|
## 3. Rust Dead Code Warnings (6)
|
||||||
|
|
||||||
|
Add `#[allow(dead_code)]` with explanatory comments:
|
||||||
|
|
||||||
|
| Warning | Location | Annotation Rationale |
|
||||||
|
|---------|----------|---------------------|
|
||||||
|
| `ExportEvent::Error` unused | `commands/export.rs:23` | Reserved for future error-channel reporting |
|
||||||
|
| `DownloadEvent::Error` unused | `commands/video.rs:23` | Reserved for future error-channel reporting |
|
||||||
|
| `sanitize_vtt_for_ffmpeg` unused | `clip_exporter.rs:10` | Scaffolded for caption burn-in pipeline |
|
||||||
|
| `opacity_to_ass_back_colour` unused | `clip_exporter.rs:204` | Scaffolded for caption burn-in pipeline |
|
||||||
|
| `caption_style_to_force_style` unused | `clip_exporter.rs:210` | Scaffolded for caption burn-in pipeline |
|
||||||
|
| `SubtitleFormat.ext`/`.url` unread | `video_resolver.rs:8-9` | Fields required for serde deserialization |
|
||||||
|
|
||||||
|
## 4. Test Fix
|
||||||
|
|
||||||
|
**File:** `tests/lib/stores/clips.test.ts`
|
||||||
|
**Test:** "updates selected clip start when I is pressed"
|
||||||
|
|
||||||
|
The test calls `markInPoint(5)` on a clip spanning 10–20. With `EDIT_TOLERANCE` of 0.5s, time 5 is outside the clip boundary (needs ≥ 9.5). The test was written before the tolerance check existed.
|
||||||
|
|
||||||
|
**Fix:** Change `markInPoint(5)` to `markInPoint(12)` (inside the clip, within tolerance). Update the assertion to `expect(getClips()[0].startTime).toBe(12)`. This verifies the edit actually changes the start time (unlike `markInPoint(10)` which would pass trivially since start is already 10).
|
||||||
|
|
||||||
|
After fix: 31/31 tests pass.
|
||||||
|
|
||||||
|
## 5. Release Build Script
|
||||||
|
|
||||||
|
### 5a. `scripts/build-release.sh`
|
||||||
|
|
||||||
|
A shell script that:
|
||||||
|
|
||||||
|
1. Reads `VERSION` from the project root
|
||||||
|
2. Runs `npm install`
|
||||||
|
3. Runs `npx tauri build` (builds frontend + Rust + bundles .app and .dmg)
|
||||||
|
4. Ad-hoc code signs the `.app`: `codesign --force --deep -s - <path>`
|
||||||
|
5. Copies `.app` (and `.dmg` if built) to `dist/release/`
|
||||||
|
6. Prints summary: version, output path, bundle size
|
||||||
|
|
||||||
|
### 5b. Configuration changes
|
||||||
|
|
||||||
|
- `tauri.conf.json`: Change `"targets": "all"` to `"targets": ["app", "dmg"]`
|
||||||
|
- `.gitignore`: Add `dist/`
|
||||||
|
- `package.json`: Add `"release": "bash scripts/build-release.sh"`
|
||||||
|
|
||||||
|
### 5c. README update
|
||||||
|
|
||||||
|
Add a "Release Build" section documenting `npm run release` and its outputs.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
After all changes:
|
||||||
|
|
||||||
|
- `npm run check` exits 0 with 0 errors, 0 warnings
|
||||||
|
- `cargo build 2>&1 | grep warning` returns nothing
|
||||||
|
- `npm test` shows 31/31 pass
|
||||||
|
- `npm run release` produces `dist/release/GUI Video Clipper.app`
|
||||||
|
- The `.app` launches without the Gatekeeper right-click workaround
|
||||||
18
package-lock.json
generated
18
package-lock.json
generated
@@ -19,6 +19,7 @@
|
|||||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "^2",
|
||||||
"@testing-library/svelte": "^5.2.8",
|
"@testing-library/svelte": "^5.2.8",
|
||||||
|
"@types/node": "^26.6.2",
|
||||||
"jsdom": "^26.1.0",
|
"jsdom": "^26.1.0",
|
||||||
"svelte": "^5.56.3",
|
"svelte": "^5.56.3",
|
||||||
"svelte-check": "^4.6.0",
|
"svelte-check": "^4.6.0",
|
||||||
@@ -928,6 +929,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "26.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.2.tgz",
|
||||||
|
"integrity": "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~8.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vitest/mocker": {
|
"node_modules/@vitest/mocker": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.1.tgz",
|
||||||
@@ -2122,6 +2133,13 @@
|
|||||||
"node": ">=14.17"
|
"node": ">=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "8.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
|
||||||
|
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "8.3.0",
|
"version": "8.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "gui-video-clipper",
|
"name": "gui-video-clipper",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"description": "macOS GUI video clipper (Tauri + Svelte)",
|
"description": "macOS GUI video clipper (Tauri + Svelte)",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -9,7 +9,8 @@
|
|||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"check": "svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-check --tsconfig ./tsconfig.json",
|
||||||
"tauri": "tauri"
|
"tauri": "tauri",
|
||||||
|
"release": "bash scripts/build-release.sh"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "^2",
|
||||||
"@testing-library/svelte": "^5.2.8",
|
"@testing-library/svelte": "^5.2.8",
|
||||||
|
"@types/node": "^26.6.2",
|
||||||
"jsdom": "^26.1.0",
|
"jsdom": "^26.1.0",
|
||||||
"svelte": "^5.56.3",
|
"svelte": "^5.56.3",
|
||||||
"svelte-check": "^4.6.0",
|
"svelte-check": "^4.6.0",
|
||||||
|
|||||||
82
scripts/build-release.sh
Executable file
82
scripts/build-release.sh
Executable file
@@ -0,0 +1,82 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
VERSION_FILE="$PROJECT_ROOT/VERSION"
|
||||||
|
|
||||||
|
# ── Read and validate version ──────────────────────────────────────
|
||||||
|
if [ ! -f "$VERSION_FILE" ]; then
|
||||||
|
echo "ERROR: VERSION file not found" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
VERSION=$(cat "$VERSION_FILE" | sed 's/^[vV]//' | tr -d '[:space:]')
|
||||||
|
|
||||||
|
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||||
|
echo "ERROR: '$VERSION' is not valid semver (expected X.Y.Z)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Building GUI Video Clipper v$VERSION ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── Install frontend dependencies ──────────────────────────────────
|
||||||
|
echo "→ Installing dependencies…"
|
||||||
|
cd "$PROJECT_ROOT"
|
||||||
|
npm install --silent
|
||||||
|
|
||||||
|
# ── Run tauri build ────────────────────────────────────────────────
|
||||||
|
echo "→ Building release (this may take a few minutes)…"
|
||||||
|
BUILD_LOG=$(mktemp)
|
||||||
|
npx tauri build 2>&1 | tee "$BUILD_LOG" | tail -10
|
||||||
|
|
||||||
|
# ── Locate built artifacts ─────────────────────────────────────────
|
||||||
|
# Parse .app path from tauri build output (handles custom CARGO_TARGET_DIR)
|
||||||
|
APP_SRC=$(grep '\.app$' "$BUILD_LOG" | head -1 | sed 's/^[[:space:]]*//')
|
||||||
|
DMG_SRC=$(grep '\.dmg$' "$BUILD_LOG" | head -1 | sed 's/^[[:space:]]*//')
|
||||||
|
rm -f "$BUILD_LOG"
|
||||||
|
|
||||||
|
# Fall back to standard location if parsing failed
|
||||||
|
if [ -z "$APP_SRC" ] || [ ! -d "$APP_SRC" ]; then
|
||||||
|
BUNDLE_DIR="$PROJECT_ROOT/src-tauri/target/release/bundle"
|
||||||
|
APP_SRC="$BUNDLE_DIR/macos/GUI Video Clipper.app"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -d "$APP_SRC" ]; then
|
||||||
|
echo "ERROR: .app bundle not found" >&2
|
||||||
|
echo "Check the build output above for errors." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Ad-hoc code sign ──────────────────────────────────────────────
|
||||||
|
echo "→ Ad-hoc signing .app…"
|
||||||
|
codesign --force --deep -s - "$APP_SRC"
|
||||||
|
|
||||||
|
# ── Copy to dist/release/ ─────────────────────────────────────────
|
||||||
|
DIST_DIR="$PROJECT_ROOT/dist/release"
|
||||||
|
mkdir -p "$DIST_DIR"
|
||||||
|
|
||||||
|
echo "→ Copying to dist/release/…"
|
||||||
|
rm -rf "$DIST_DIR/GUI Video Clipper.app"
|
||||||
|
cp -R "$APP_SRC" "$DIST_DIR/"
|
||||||
|
|
||||||
|
# Copy DMG if it was built
|
||||||
|
DMG_FILE="$DMG_SRC"
|
||||||
|
if [ -n "$DMG_FILE" ] && [ -f "$DMG_FILE" ]; then
|
||||||
|
cp "$DMG_FILE" "$DIST_DIR/"
|
||||||
|
echo " ✓ DMG: $(basename "$DMG_FILE")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Summary ────────────────────────────────────────────────────────
|
||||||
|
APP_SIZE=$(du -sh "$DIST_DIR/GUI Video Clipper.app" | cut -f1)
|
||||||
|
echo ""
|
||||||
|
echo "=== Build Complete ==="
|
||||||
|
echo " Version: v$VERSION"
|
||||||
|
echo " App: dist/release/GUI Video Clipper.app ($APP_SIZE)"
|
||||||
|
if [ -n "$DMG_FILE" ] && [ -f "$DIST_DIR/$(basename "$DMG_FILE")" ]; then
|
||||||
|
DMG_SIZE=$(du -sh "$DIST_DIR/$(basename "$DMG_FILE")" | cut -f1)
|
||||||
|
echo " DMG: dist/release/$(basename "$DMG_FILE") ($DMG_SIZE)"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
echo "To run: open \"dist/release/GUI Video Clipper.app\""
|
||||||
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.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"dirs",
|
"dirs",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "gui-video-clipper"
|
name = "gui-video-clipper"
|
||||||
version = "0.1.0"
|
version = "0.1.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"
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ pub enum ExportEvent {
|
|||||||
Finished {
|
Finished {
|
||||||
paths: Vec<String>,
|
paths: Vec<String>,
|
||||||
},
|
},
|
||||||
|
#[allow(dead_code)] // Reserved for future error-channel reporting
|
||||||
Error {
|
Error {
|
||||||
message: String,
|
message: String,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ pub enum DownloadEvent {
|
|||||||
Progress { percent: f64 },
|
Progress { percent: f64 },
|
||||||
FilePath { path: String },
|
FilePath { path: String },
|
||||||
Finished { success: bool, path: String },
|
Finished { success: bool, path: String },
|
||||||
|
#[allow(dead_code)] // Reserved for future error-channel reporting
|
||||||
Error { message: String },
|
Error { message: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,89 @@ mod commands;
|
|||||||
mod models;
|
mod models;
|
||||||
mod services;
|
mod services;
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use commands::{dependencies, export, media_analysis, video};
|
use commands::{dependencies, export, media_analysis, video};
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
|
||||||
struct MediaServerPort(u16);
|
struct MediaServerPort(u16);
|
||||||
|
|
||||||
|
/// Prepend well-known macOS tool directories to PATH so that Homebrew-installed
|
||||||
|
/// binaries (ffmpeg, yt-dlp, etc.) are discoverable when the app is launched as
|
||||||
|
/// a standalone .app bundle (which inherits only a minimal system PATH).
|
||||||
|
///
|
||||||
|
/// Also sets PYENV_ROOT when pyenv is present so that pyenv shims can resolve
|
||||||
|
/// to the correct Python (and its pip-installed yt-dlp plugins).
|
||||||
|
fn fix_path_env() {
|
||||||
|
let home = std::env::var("HOME").unwrap_or_default();
|
||||||
|
|
||||||
|
let mut extra_dirs: Vec<PathBuf> = Vec::new();
|
||||||
|
|
||||||
|
if !home.is_empty() {
|
||||||
|
let home_path = PathBuf::from(&home);
|
||||||
|
|
||||||
|
// pyenv shims — must come before /opt/homebrew/bin so the pyenv-managed
|
||||||
|
// yt-dlp (which knows about pip-installed plugins) wins over Homebrew's.
|
||||||
|
let pyenv_root = home_path.join(".pyenv");
|
||||||
|
if pyenv_root.is_dir() {
|
||||||
|
extra_dirs.push(pyenv_root.join("shims"));
|
||||||
|
if std::env::var("PYENV_ROOT").is_err() {
|
||||||
|
std::env::set_var("PYENV_ROOT", &pyenv_root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// nvm — node/deno installed via nvm live under a versioned directory
|
||||||
|
let nvm_dir = home_path.join(".nvm/versions/node");
|
||||||
|
if nvm_dir.is_dir() {
|
||||||
|
if let Ok(entries) = std::fs::read_dir(&nvm_dir) {
|
||||||
|
// Pick the lexicographically last (highest) version
|
||||||
|
let mut versions: Vec<PathBuf> = entries
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.map(|e| e.path().join("bin"))
|
||||||
|
.filter(|p| p.is_dir())
|
||||||
|
.collect();
|
||||||
|
versions.sort();
|
||||||
|
if let Some(latest) = versions.pop() {
|
||||||
|
extra_dirs.push(latest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// User-local installs
|
||||||
|
for rel in [".local/bin", ".cargo/bin", ".deno/bin"] {
|
||||||
|
extra_dirs.push(home_path.join(rel));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// System-wide Homebrew / MacPorts paths
|
||||||
|
for dir in [
|
||||||
|
"/opt/homebrew/bin",
|
||||||
|
"/opt/homebrew/sbin",
|
||||||
|
"/usr/local/bin",
|
||||||
|
"/usr/local/sbin",
|
||||||
|
] {
|
||||||
|
extra_dirs.push(PathBuf::from(dir));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter to directories that actually exist
|
||||||
|
let extra_dirs: Vec<PathBuf> = extra_dirs.into_iter().filter(|p| p.is_dir()).collect();
|
||||||
|
|
||||||
|
let current = std::env::var("PATH").unwrap_or_default();
|
||||||
|
let current_dirs: HashSet<&str> = current.split(':').collect();
|
||||||
|
|
||||||
|
let mut new_path = String::new();
|
||||||
|
for dir in &extra_dirs {
|
||||||
|
let s = dir.to_string_lossy();
|
||||||
|
if !current_dirs.contains(s.as_ref()) {
|
||||||
|
new_path.push_str(&s);
|
||||||
|
new_path.push(':');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
new_path.push_str(¤t);
|
||||||
|
std::env::set_var("PATH", &new_path);
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn get_media_server_port(state: tauri::State<'_, MediaServerPort>) -> u16 {
|
fn get_media_server_port(state: tauri::State<'_, MediaServerPort>) -> u16 {
|
||||||
state.0
|
state.0
|
||||||
@@ -20,6 +98,8 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_store::Builder::new().build())
|
.plugin(tauri_plugin_store::Builder::new().build())
|
||||||
.plugin(tauri_plugin_process::init())
|
.plugin(tauri_plugin_process::init())
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
|
fix_path_env();
|
||||||
|
|
||||||
// Bind synchronously (no async needed), then spawn on Tauri's runtime
|
// Bind synchronously (no async needed), then spawn on Tauri's runtime
|
||||||
let (port, listener) = services::media_server::bind_media_server()
|
let (port, listener) = services::media_server::bind_media_server()
|
||||||
.expect("Failed to bind media server");
|
.expect("Failed to bind media server");
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use std::process::{Command, Stdio};
|
|||||||
/// Strip YouTube auto-generated VTT karaoke tags and positioning metadata
|
/// Strip YouTube auto-generated VTT karaoke tags and positioning metadata
|
||||||
/// that confuse ffmpeg's VTT parser / mov_text conversion.
|
/// that confuse ffmpeg's VTT parser / mov_text conversion.
|
||||||
/// Returns the path to a cleaned temp VTT file.
|
/// Returns the path to a cleaned temp VTT file.
|
||||||
|
#[allow(dead_code)] // Scaffolded for caption burn-in pipeline
|
||||||
pub fn sanitize_vtt_for_ffmpeg(caption_path: &str, temp_dir: &str) -> Result<String, String> {
|
pub fn sanitize_vtt_for_ffmpeg(caption_path: &str, temp_dir: &str) -> Result<String, String> {
|
||||||
let content = std::fs::read_to_string(caption_path)
|
let content = std::fs::read_to_string(caption_path)
|
||||||
.map_err(|e| format!("Failed to read VTT file '{}': {e}", caption_path))?;
|
.map_err(|e| format!("Failed to read VTT file '{}': {e}", caption_path))?;
|
||||||
@@ -201,12 +202,14 @@ fn hex_to_ass_color(hex: &str) -> String {
|
|||||||
|
|
||||||
/// Convert a background opacity (0.0=transparent, 1.0=opaque) to ASS BackColour.
|
/// Convert a background opacity (0.0=transparent, 1.0=opaque) to ASS BackColour.
|
||||||
/// ASS alpha: 00=opaque, FF=transparent (inverted from CSS).
|
/// ASS alpha: 00=opaque, FF=transparent (inverted from CSS).
|
||||||
|
#[allow(dead_code)] // Scaffolded for caption burn-in pipeline
|
||||||
fn opacity_to_ass_back_colour(opacity: f64) -> String {
|
fn opacity_to_ass_back_colour(opacity: f64) -> String {
|
||||||
let alpha = ((1.0 - opacity.clamp(0.0, 1.0)) * 255.0).round() as u8;
|
let alpha = ((1.0 - opacity.clamp(0.0, 1.0)) * 255.0).round() as u8;
|
||||||
format!("&H{:02X}000000", alpha)
|
format!("&H{:02X}000000", alpha)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build an ASS force_style string from user caption settings.
|
/// Build an ASS force_style string from user caption settings.
|
||||||
|
#[allow(dead_code)] // Scaffolded for caption burn-in pipeline
|
||||||
fn caption_style_to_force_style(style: &CaptionStyle) -> String {
|
fn caption_style_to_force_style(style: &CaptionStyle) -> String {
|
||||||
let font_size = (style.font_size as f64 * 2.0).round() as u32;
|
let font_size = (style.font_size as f64 * 2.0).round() as u32;
|
||||||
let primary_colour = hex_to_ass_color(&style.text_color);
|
let primary_colour = hex_to_ass_color(&style.text_color);
|
||||||
|
|||||||
@@ -54,17 +54,11 @@ pub fn check_js_runtime() -> Option<String> {
|
|||||||
check_tool_exists("deno").or_else(|| check_tool_exists("node"))
|
check_tool_exists("deno").or_else(|| check_tool_exists("node"))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn check_pot_plugin(cookie_source: &CookieSource) -> bool {
|
pub fn check_pot_plugin(_cookie_source: &CookieSource) -> bool {
|
||||||
let mut command = Command::new("yt-dlp");
|
// Use `yt-dlp -v` (no URL) which lists loaded plugins without network access.
|
||||||
command
|
// The old approach ran a full YouTube simulation (~9s, network-dependent) which
|
||||||
.arg("-v")
|
// failed silently in the .app bundle context.
|
||||||
.arg("--simulate")
|
let output = Command::new("yt-dlp").arg("-v").output();
|
||||||
.arg("--remote-components")
|
|
||||||
.arg("ejs:github")
|
|
||||||
.args(cookie_source.to_ytdlp_args())
|
|
||||||
.arg("https://www.youtube.com/watch?v=dQw4w9WgXcQ");
|
|
||||||
let output = command.output();
|
|
||||||
|
|
||||||
match output {
|
match output {
|
||||||
Ok(out) => {
|
Ok(out) => {
|
||||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use std::collections::HashMap;
|
|||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[allow(dead_code)] // Fields used for serde deserialization
|
||||||
pub struct SubtitleFormat {
|
pub struct SubtitleFormat {
|
||||||
pub ext: Option<String>,
|
pub ext: Option<String>,
|
||||||
pub url: Option<String>,
|
pub url: Option<String>,
|
||||||
|
|||||||
@@ -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.1.0",
|
"version": "0.1.1",
|
||||||
"identifier": "xyz.cottongin.gui-video-clipper",
|
"identifier": "xyz.cottongin.gui-video-clipper",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
@@ -34,7 +34,10 @@
|
|||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": "all",
|
"targets": [
|
||||||
|
"app",
|
||||||
|
"dmg"
|
||||||
|
],
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/32x32.png",
|
"icons/32x32.png",
|
||||||
"icons/128x128.png",
|
"icons/128x128.png",
|
||||||
|
|||||||
@@ -188,9 +188,23 @@
|
|||||||
<div
|
<div
|
||||||
class="resize-handle"
|
class="resize-handle"
|
||||||
class:active={isResizing}
|
class:active={isResizing}
|
||||||
role="separator"
|
role="slider"
|
||||||
aria-orientation="horizontal"
|
aria-orientation="horizontal"
|
||||||
|
aria-label="Resize timeline and clip list"
|
||||||
|
aria-valuenow={timelineHeight}
|
||||||
|
aria-valuemin={40}
|
||||||
|
aria-valuemax={500}
|
||||||
|
tabindex="0"
|
||||||
onmousedown={handleResizeStart}
|
onmousedown={handleResizeStart}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
timelineHeight = Math.min(500, timelineHeight + 10);
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
timelineHeight = Math.max(40, timelineHeight - 10);
|
||||||
|
}
|
||||||
|
}}
|
||||||
></div>
|
></div>
|
||||||
<div class="cliplist-pane">
|
<div class="cliplist-pane">
|
||||||
<ClipList onExport={() => (showExportDialog = true)} />
|
<ClipList onExport={() => (showExportDialog = true)} />
|
||||||
|
|||||||
@@ -12,8 +12,25 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="overlay" role="presentation" onclick={onClose}>
|
<div
|
||||||
<div class="dialog" role="dialog" onclick={(e) => e.stopPropagation()}>
|
class="overlay"
|
||||||
|
role="presentation"
|
||||||
|
tabindex="-1"
|
||||||
|
onclick={onClose}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') {
|
||||||
|
if (e.key !== 'Escape') e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="dialog"
|
||||||
|
role="dialog"
|
||||||
|
tabindex="-1"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
onkeydown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
<img class="app-icon" src="/favicon.svg" alt="GUI Video Clipper icon" />
|
<img class="app-icon" src="/favicon.svg" alt="GUI Video Clipper icon" />
|
||||||
<h2>GUI Video Clipper</h2>
|
<h2>GUI Video Clipper</h2>
|
||||||
{#if version}
|
{#if version}
|
||||||
|
|||||||
@@ -45,8 +45,9 @@
|
|||||||
<div class="scroll-area">
|
<div class="scroll-area">
|
||||||
<!-- Font Family -->
|
<!-- Font Family -->
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<label>Font</label>
|
<label for="caption-font">Font</label>
|
||||||
<select
|
<select
|
||||||
|
id="caption-font"
|
||||||
value={settings.fontFamily}
|
value={settings.fontFamily}
|
||||||
onchange={(e) => update({ fontFamily: (e.target as HTMLSelectElement).value })}
|
onchange={(e) => update({ fontFamily: (e.target as HTMLSelectElement).value })}
|
||||||
>
|
>
|
||||||
@@ -61,19 +62,20 @@
|
|||||||
<label>
|
<label>
|
||||||
Font Size
|
Font Size
|
||||||
<span class="value">{settings.fontSize}px</span>
|
<span class="value">{settings.fontSize}px</span>
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="range" min="12" max="36" step="1"
|
type="range" min="12" max="36" step="1"
|
||||||
value={settings.fontSize}
|
value={settings.fontSize}
|
||||||
oninput={(e) => update({ fontSize: parseInt((e.target as HTMLInputElement).value, 10) })}
|
oninput={(e) => update({ fontSize: parseInt((e.target as HTMLInputElement).value, 10) })}
|
||||||
/>
|
/>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Text Color + Bold -->
|
<!-- Text Color + Bold -->
|
||||||
<div class="control row">
|
<div class="control row">
|
||||||
<label>Text Color</label>
|
<label for="caption-text-color">Text Color</label>
|
||||||
<div class="row-controls">
|
<div class="row-controls">
|
||||||
<input
|
<input
|
||||||
|
id="caption-text-color"
|
||||||
type="color" value={settings.textColor}
|
type="color" value={settings.textColor}
|
||||||
oninput={(e) => update({ textColor: (e.target as HTMLInputElement).value })}
|
oninput={(e) => update({ textColor: (e.target as HTMLInputElement).value })}
|
||||||
/>
|
/>
|
||||||
@@ -87,10 +89,10 @@
|
|||||||
|
|
||||||
<!-- Dimmed Text Color -->
|
<!-- Dimmed Text Color -->
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<label>Dimmed Text</label>
|
<label for="caption-dimmed-auto">Dimmed Text</label>
|
||||||
<div class="radio-row">
|
<div class="radio-row">
|
||||||
<label>
|
<label>
|
||||||
<input type="radio" name="dimmed-mode" value="auto"
|
<input id="caption-dimmed-auto" type="radio" name="dimmed-mode" value="auto"
|
||||||
checked={settings.dimmedColorMode === 'auto'}
|
checked={settings.dimmedColorMode === 'auto'}
|
||||||
onchange={() => update({ dimmedColorMode: 'auto' })} />
|
onchange={() => update({ dimmedColorMode: 'auto' })} />
|
||||||
Auto
|
Auto
|
||||||
@@ -108,18 +110,19 @@
|
|||||||
<label>
|
<label>
|
||||||
Dim Opacity
|
Dim Opacity
|
||||||
<span class="value">{Math.round(settings.dimmedOpacity * 100)}%</span>
|
<span class="value">{Math.round(settings.dimmedOpacity * 100)}%</span>
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="range" min="10" max="90" step="5"
|
type="range" min="10" max="90" step="5"
|
||||||
value={Math.round(settings.dimmedOpacity * 100)}
|
value={Math.round(settings.dimmedOpacity * 100)}
|
||||||
oninput={(e) => update({ dimmedOpacity: parseInt((e.target as HTMLInputElement).value, 10) / 100 })}
|
oninput={(e) => update({ dimmedOpacity: parseInt((e.target as HTMLInputElement).value, 10) / 100 })}
|
||||||
/>
|
/>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="control row">
|
<div class="control row">
|
||||||
<label>Dimmed Color</label>
|
<label for="caption-dimmed-color">Dimmed Color</label>
|
||||||
<div class="row-controls">
|
<div class="row-controls">
|
||||||
<input
|
<input
|
||||||
|
id="caption-dimmed-color"
|
||||||
type="color" value={settings.dimmedColor}
|
type="color" value={settings.dimmedColor}
|
||||||
oninput={(e) => update({ dimmedColor: (e.target as HTMLInputElement).value })}
|
oninput={(e) => update({ dimmedColor: (e.target as HTMLInputElement).value })}
|
||||||
/>
|
/>
|
||||||
@@ -165,12 +168,12 @@
|
|||||||
<label>
|
<label>
|
||||||
BG Opacity
|
BG Opacity
|
||||||
<span class="value">{Math.round(settings.backgroundOpacity * 100)}%</span>
|
<span class="value">{Math.round(settings.backgroundOpacity * 100)}%</span>
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="range" min="0" max="100" step="5"
|
type="range" min="0" max="100" step="5"
|
||||||
value={Math.round(settings.backgroundOpacity * 100)}
|
value={Math.round(settings.backgroundOpacity * 100)}
|
||||||
oninput={(e) => update({ backgroundOpacity: parseInt((e.target as HTMLInputElement).value, 10) / 100 })}
|
oninput={(e) => update({ backgroundOpacity: parseInt((e.target as HTMLInputElement).value, 10) / 100 })}
|
||||||
/>
|
/>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -195,21 +198,21 @@
|
|||||||
<label>
|
<label>
|
||||||
Shadow Depth
|
Shadow Depth
|
||||||
<span class="value">{settings.shadowDepth}px</span>
|
<span class="value">{settings.shadowDepth}px</span>
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="range" min="1" max="5" step="1"
|
type="range" min="1" max="5" step="1"
|
||||||
value={settings.shadowDepth}
|
value={settings.shadowDepth}
|
||||||
oninput={(e) => update({ shadowDepth: parseInt((e.target as HTMLInputElement).value, 10) })}
|
oninput={(e) => update({ shadowDepth: parseInt((e.target as HTMLInputElement).value, 10) })}
|
||||||
/>
|
/>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Position -->
|
<!-- Position -->
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<label>Position</label>
|
<label for="caption-pos-bottom">Position</label>
|
||||||
<div class="radio-row">
|
<div class="radio-row">
|
||||||
<label>
|
<label>
|
||||||
<input type="radio" name="caption-position" value="bottom"
|
<input id="caption-pos-bottom" type="radio" name="caption-position" value="bottom"
|
||||||
checked={settings.position === 'bottom'}
|
checked={settings.position === 'bottom'}
|
||||||
onchange={() => update({ position: 'bottom' })} />
|
onchange={() => update({ position: 'bottom' })} />
|
||||||
Bottom
|
Bottom
|
||||||
|
|||||||
@@ -43,7 +43,17 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="clip-list" role="listbox" onclick={handleContainerClick}>
|
<div
|
||||||
|
class="clip-list"
|
||||||
|
role="listbox"
|
||||||
|
tabindex="0"
|
||||||
|
onclick={handleContainerClick}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
selectClip(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
{#if clips.length === 0}
|
{#if clips.length === 0}
|
||||||
<div class="empty">No clips yet — press I to mark in-point, O to mark out-point</div>
|
<div class="empty">No clips yet — press I to mark in-point, O to mark out-point</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -136,8 +136,25 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="overlay" role="presentation" onclick={onClose}>
|
<div
|
||||||
<div class="dialog" role="dialog" onclick={(e) => e.stopPropagation()}>
|
class="overlay"
|
||||||
|
role="presentation"
|
||||||
|
tabindex="-1"
|
||||||
|
onclick={onClose}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') {
|
||||||
|
if (e.key !== 'Escape') e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="dialog"
|
||||||
|
role="dialog"
|
||||||
|
tabindex="-1"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
onkeydown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
<h2>Export Clips</h2>
|
<h2>Export Clips</h2>
|
||||||
|
|
||||||
{#if !isExportReady}
|
{#if !isExportReady}
|
||||||
|
|||||||
@@ -57,8 +57,25 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="overlay" role="presentation" onclick={onClose}>
|
<div
|
||||||
<div class="panel" role="dialog" onclick={(e) => e.stopPropagation()}>
|
class="overlay"
|
||||||
|
role="presentation"
|
||||||
|
tabindex="-1"
|
||||||
|
onclick={onClose}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') {
|
||||||
|
if (e.key !== 'Escape') e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="panel"
|
||||||
|
role="dialog"
|
||||||
|
tabindex="-1"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
onkeydown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
<h2>Preferences</h2>
|
<h2>Preferences</h2>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
|
|||||||
@@ -194,7 +194,9 @@
|
|||||||
onloadeddata={handleLoadedData}
|
onloadeddata={handleLoadedData}
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
playsinline
|
playsinline
|
||||||
></video>
|
>
|
||||||
|
<track kind="captions" />
|
||||||
|
</video>
|
||||||
{#if activeCues.length > 0}
|
{#if activeCues.length > 0}
|
||||||
<div class="caption-overlay" style={captionPosition}>
|
<div class="caption-overlay" style={captionPosition}>
|
||||||
{#each activeCues as cue}
|
{#each activeCues as cue}
|
||||||
|
|||||||
@@ -62,8 +62,8 @@ describe('clip store', () => {
|
|||||||
const id = getClips()[0].id;
|
const id = getClips()[0].id;
|
||||||
selectClip(id);
|
selectClip(id);
|
||||||
|
|
||||||
markInPoint(5);
|
markInPoint(12);
|
||||||
expect(getClips()[0].startTime).toBe(5);
|
expect(getClips()[0].startTime).toBe(12);
|
||||||
expect(getPendingInPoint()).toBeNull();
|
expect(getPendingInPoint()).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
|
"types": ["node"],
|
||||||
"paths": {
|
"paths": {
|
||||||
"$lib/*": ["./src/lib/*"]
|
"$lib/*": ["./src/lib/*"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vitest/config';
|
||||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|||||||
Reference in New Issue
Block a user