+ {/* Background pattern */}
+
+
+ {/* Animated audio waveform background — left and right sides filling to center */}
+
+ {[
+ [60, 1.3, 0.0], [100, 1.0, 0.12], [40, 1.6, 0.24], [120, 0.9, 0.08],
+ [80, 1.2, 0.2], [30, 1.8, 0.04], [110, 1.1, 0.28], [50, 1.5, 0.16],
+ [70, 1.4, 0.08], [140, 0.85, 0.24], [60, 1.3, 0.12], [90, 1.2, 0.0],
+ [130, 0.95, 0.2], [45, 1.7, 0.28], [80, 1.4, 0.04], [55, 1.1, 0.16],
+ [100, 1.3, 0.24], [35, 1.6, 0.12], [120, 0.9, 0.0], [65, 1.5, 0.2],
+ [85, 1.2, 0.08], [150, 0.8, 0.28], [40, 1.9, 0.16], [95, 1.1, 0.04],
+ [75, 1.4, 0.24], [25, 2.0, 0.12], [105, 1.05, 0.2], [155, 0.82, 0.08],
+ [50, 1.55, 0.28], [115, 0.92, 0.16], [70, 1.35, 0.04], [135, 0.88, 0.24],
+ [90, 1.15, 0.12], [42, 1.75, 0.0], [125, 0.98, 0.2], [58, 1.45, 0.08],
+ ].map(([peak, dur, delay], i) => (
+
+ ))}
+
+
+ {[
+ [100, 1.0, 0.0], [60, 1.3, 0.08], [130, 0.9, 0.16], [40, 1.6, 0.04],
+ [80, 1.2, 0.12], [150, 0.85, 0.24], [50, 1.5, 0.2], [110, 1.1, 0.28],
+ [70, 1.4, 0.08], [30, 1.8, 0.16], [140, 0.9, 0.0], [90, 1.2, 0.24],
+ [60, 1.3, 0.12], [120, 0.95, 0.04], [85, 1.4, 0.2], [45, 1.7, 0.28],
+ [160, 0.8, 0.08], [55, 1.5, 0.24], [100, 1.1, 0.0], [75, 1.3, 0.16],
+ [35, 1.9, 0.2], [115, 1.0, 0.12], [65, 1.6, 0.28], [140, 0.88, 0.04],
+ [95, 1.25, 0.24], [25, 1.85, 0.08], [125, 0.93, 0.16], [155, 0.78, 0.28],
+ [48, 1.65, 0.12], [82, 1.32, 0.2], [108, 1.08, 0.04], [72, 1.42, 0.24],
+ [135, 0.9, 0.16], [38, 1.78, 0.0], [62, 1.48, 0.08], [118, 1.02, 0.28],
+ ].map(([peak, dur, delay], i) => (
+
+ ))}
+
+
+
+
+
+
+ {/* App icon */}
+
+
+
+
+ TalkEdit
+
+
+ The offline video editor that doesn't slow down on long files.
- {/* Whisper model selector */}
-
-
-
-
-
- For noisy/YouTube videos use large-v3 or large-v3-turbo.
- English-only models are ~10% faster and more accurate for English content.
-
-
-
+ {/* Action row — button + model selector side by side */}
+
-
+
+ Transcription model:
+
+
+
+
{licenseStatus?.tag === 'Trial' && (
-
+
Free trial: {licenseStatus.days_remaining} day{licenseStatus.days_remaining !== 1 ? 's' : ''} remaining
@@ -661,7 +725,7 @@ export default function App() {
)}
{licenseStatus?.tag === 'Expired' && (
-
+
Trial expired
- {showDialog && (
-
setShowDialog(false)}
- onActivate={handleActivate}
- keyValue={key}
- setKeyValue={setKey}
- error={error}
- activating={activating}
- trialEnding={status.days_remaining <= 3}
- />
- )}
- >
- );
- }
-
- // Expired — show banner + activation dialog (both dismissible)
- return (
- <>
- setShowDialog(true)} />
-
{showDialog && (
setShowDialog(false)}
+ onClose={() => { setShowDialog(false); handleDeny(); }}
onActivate={handleActivate}
+ onDeny={handleDeny}
keyValue={key}
setKeyValue={setKey}
error={error}
activating={activating}
- expired
+ verifying={verifying}
+ confirmedEmail={confirmedEmail}
+ trialEnding={status.days_remaining <= 3}
/>
)}
>
);
}
+// Expired — show banner + activation dialog (both dismissible)
+return (
+ <>
+ setShowDialog(true)} />
+
+ {showDialog && (
+ { setShowDialog(false); handleDeny(); }}
+ onActivate={handleActivate}
+ onDeny={handleDeny}
+ keyValue={key}
+ setKeyValue={setKey}
+ error={error}
+ activating={activating}
+ verifying={verifying}
+ confirmedEmail={confirmedEmail}
+ expired
+ />
+ )}
+ >
+);
+}
+
/** Persistent top banner shown when trial expired — still allows export and loading */
function ExpiredBanner({ onActivate }: { onActivate: () => void }) {
return (
@@ -112,22 +144,78 @@ function ExpiredBanner({ onActivate }: { onActivate: () => void }) {
function LicenseActivateDialog({
onClose,
onActivate,
+ onDeny,
keyValue,
setKeyValue,
error,
activating,
+ verifying,
+ confirmedEmail,
trialEnding,
expired,
}: {
onClose: () => void;
onActivate: () => void;
+ onDeny: () => void;
keyValue: string;
setKeyValue: (v: string) => void;
error: string | null;
activating: boolean;
+ verifying: boolean;
+ confirmedEmail: string | null;
trialEnding?: boolean;
expired?: boolean;
}) {
+ const isProcessing = activating || verifying;
+
+ if (confirmedEmail) {
+ return (
+
+
e.stopPropagation()}
+ >
+
+
+
Confirm License
+
+
+
+
+ This license key is registered to:
+
+
{confirmedEmail}
+
+
+
+ License keys are tied to your email. Sharing this key may result in deactivation.
+
+
+
+
+
+
+
+
+ );
+ }
+
return (
- {activating ? (
+ {isProcessing ? (
) : (
-
+
)}
- Activate
+ {verifying ? 'Verifying...' : 'Verify Key'}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 475ba49..dc97a47 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -8,11 +8,26 @@
100% { transform: scaleY(0.3); opacity: 0.5; }
}
+@keyframes audioBounce {
+ 0% { height: 12px; }
+ 50% { height: var(--bar-peak); }
+ 100% { height: 12px; }
+}
+
.wave-bar {
animation: waveBar 0.9s ease-in-out infinite;
transform-origin: bottom;
}
+.welcome-audio-bar {
+ width: 4px;
+ border-radius: 2px;
+ background: #6366f1;
+ transform-origin: bottom;
+ animation: audioBounce var(--bar-duration) ease-in-out infinite;
+ animation-delay: var(--bar-delay);
+}
+
* {
margin: 0;
padding: 0;
diff --git a/frontend/src/lib/tauri-bridge.ts b/frontend/src/lib/tauri-bridge.ts
index a840ea4..73f0a1c 100644
--- a/frontend/src/lib/tauri-bridge.ts
+++ b/frontend/src/lib/tauri-bridge.ts
@@ -101,6 +101,10 @@ window.electronAPI = {
return invoke('activate_license', { licenseKey: key });
},
+ verifyLicense: (key: string): Promise => {
+ return invoke('verify_license', { licenseKey: key });
+ },
+
getAppStatus: (): Promise => {
return invoke('get_app_status');
},
diff --git a/frontend/src/store/editorStore.test.ts b/frontend/src/store/editorStore.test.ts
index 4ecedcc..57394e2 100644
--- a/frontend/src/store/editorStore.test.ts
+++ b/frontend/src/store/editorStore.test.ts
@@ -331,10 +331,14 @@ describe('editorStore', () => {
useEditorStore.getState().addSpeedRange(4, 5, 1.5);
const project = useEditorStore.getState().saveProject();
- expect(project.cutRanges.length).toBe(1);
- expect(project.muteRanges.length).toBe(1);
- expect(project.gainRanges.length).toBe(1);
- expect(project.speedRanges.length).toBe(1);
+ expect(project.cutRanges).toBeDefined();
+ expect(project.cutRanges!.length).toBe(1);
+ expect(project.muteRanges).toBeDefined();
+ expect(project.muteRanges!.length).toBe(1);
+ expect(project.gainRanges).toBeDefined();
+ expect(project.gainRanges!.length).toBe(1);
+ expect(project.speedRanges).toBeDefined();
+ expect(project.speedRanges!.length).toBe(1);
});
test('setProjectFilePath sets and reads back', () => {
diff --git a/frontend/src/store/licenseStore.ts b/frontend/src/store/licenseStore.ts
index 2601449..f32058b 100644
--- a/frontend/src/store/licenseStore.ts
+++ b/frontend/src/store/licenseStore.ts
@@ -42,7 +42,7 @@ export const useLicenseStore = create()(
status: null,
isLoaded: false,
showDialog: false,
- canEdit: true,
+ canEdit: false,
setStatus: (status) => {
const canEdit = status?.tag === 'Licensed' || status?.tag === 'Trial';
diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts
index 4d47d46..f25e9f2 100644
--- a/frontend/src/vite-env.d.ts
+++ b/frontend/src/vite-env.d.ts
@@ -29,6 +29,7 @@ interface DesktopAPI {
writeFile: (path: string, content: string) => Promise;
activateLicense: (key: string) => Promise;
getAppStatus: () => Promise;
+ verifyLicense: (key: string) => Promise;
deactivateLicense: () => Promise;
hasLicenseFeature: (feature: string) => Promise;
listModels: () => Promise;
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 6752e78..db488a2 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -245,6 +245,13 @@ fn get_app_status(app_handle: tauri::AppHandle) -> Result Result {
+ licensing::verify_license_key(&license_key)
+ .map_err(|e| e.to_string())
+}
+
/// Verify and activate a license key.
#[tauri::command]
fn activate_license(app_handle: tauri::AppHandle, license_key: String) -> Result {
@@ -417,6 +424,7 @@ pub fn run() {
get_app_status,
activate_license,
deactivate_license,
+ verify_license,
start_trial,
has_license_feature,
list_models,
diff --git a/src-tauri/src/licensing.rs b/src-tauri/src/licensing.rs
index d6e1c61..db71304 100644
--- a/src-tauri/src/licensing.rs
+++ b/src-tauri/src/licensing.rs
@@ -7,7 +7,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
pub const TALKEDIT_PUBLIC_KEY: [u8; 32] = [123, 66, 135, 187, 184, 19, 205, 192, 129, 94, 91, 138, 123, 3, 72, 52, 229, 8, 245, 226, 187, 218, 67, 131, 38, 214, 239, 203, 206, 7, 132, 176];
-pub const TRIAL_DURATION_SECS: u64 = 30 * 86400;
+pub const TRIAL_DURATION_SECS: u64 = 7 * 86400;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct LicensePayload {
@@ -25,6 +25,13 @@ pub struct TrialState {
pub started_at: u64,
}
+/// On-disk format with integrity seed to deter tampering.
+#[derive(Debug, Serialize, Deserialize)]
+struct TrialFile {
+ started_at: u64,
+ seed: u64,
+}
+
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "tag")]
pub enum AppStatus {
@@ -113,6 +120,52 @@ pub fn trial_file_path(app_data_dir: &PathBuf) -> PathBuf {
app_data_dir.join("trial.json")
}
+pub fn trial_sentinel_path(app_data_dir: &PathBuf) -> PathBuf {
+ app_data_dir.join(".trial_lock")
+}
+
+// Simple integrity check constant — not crypto-grade, but deters casual editing.
+const TRIAL_SEED: u64 = 0x9F3A_2E7D_C1B8_5604;
+
+pub fn get_or_start_trial(app_data_dir: &PathBuf) -> TrialState {
+ let path = trial_file_path(app_data_dir);
+ let sentinel = trial_sentinel_path(app_data_dir);
+ let now = now_secs();
+
+ // If sentinel exists but trial was deleted, refuse to create a new one.
+ let sentinel_exists = sentinel.exists();
+
+ if let Ok(content) = std::fs::read_to_string(&path) {
+ if let Ok(wrapped) = serde_json::from_str::(&content) {
+ // Verify integrity
+ if (wrapped.started_at ^ TRIAL_SEED) == wrapped.seed {
+ return TrialState { started_at: wrapped.started_at };
+ }
+ }
+ }
+
+ if sentinel_exists {
+ // Trial was tampered with — return an expired trial
+ return TrialState { started_at: 0 };
+ }
+
+ // Start new trial
+ let trial = TrialState { started_at: now };
+ if let Some(parent) = path.parent() {
+ let _ = std::fs::create_dir_all(parent);
+ }
+ let _ = std::fs::write(&sentinel, "1");
+ let _ = std::fs::write(
+ &path,
+ serde_json::to_string(&TrialFile {
+ started_at: trial.started_at,
+ seed: trial.started_at ^ TRIAL_SEED,
+ })
+ .unwrap(),
+ );
+ trial
+}
+
pub fn load_cached_license(app_data_dir: &PathBuf) -> Result {
let path = license_file_path(app_data_dir);
let content = std::fs::read_to_string(&path).map_err(|_| LicenseError::InvalidFormat)?;
@@ -136,26 +189,6 @@ pub fn remove_license(app_data_dir: &PathBuf) {
// --- Trial logic ---
-pub fn get_or_start_trial(app_data_dir: &PathBuf) -> TrialState {
- let path = trial_file_path(app_data_dir);
- let now = now_secs();
-
- // Try to load existing trial
- if let Ok(content) = std::fs::read_to_string(&path) {
- if let Ok(trial) = serde_json::from_str::(&content) {
- return trial;
- }
- }
-
- // Start new trial
- let trial = TrialState { started_at: now };
- if let Some(parent) = path.parent() {
- let _ = std::fs::create_dir_all(parent);
- }
- let _ = std::fs::write(&path, serde_json::to_string(&trial).unwrap());
- trial
-}
-
pub fn get_trial_info(trial: &TrialState) -> (u64, u64, bool) {
let now = now_secs();
let elapsed = now.saturating_sub(trial.started_at);
@@ -176,8 +209,8 @@ pub fn get_trial_days_remaining(trial: &TrialState) -> Option {
}
pub fn clear_trial(app_data_dir: &PathBuf) {
- let path = trial_file_path(app_data_dir);
- let _ = std::fs::remove_file(&path);
+ let _ = std::fs::remove_file(trial_file_path(app_data_dir));
+ let _ = std::fs::remove_file(trial_sentinel_path(app_data_dir));
}
/// Get the overall app status: Licensed > Trial > Expired.
@@ -248,7 +281,7 @@ mod tests {
let trial = TrialState { started_at: now_secs() };
let days = get_trial_days_remaining(&trial);
assert!(days.is_some());
- assert_eq!(days.unwrap(), 30);
+ assert_eq!(days.unwrap(), 7);
}
#[test]