Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/clipper/cache_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import hashlib
import sqlite3
import subprocess
import sys
import threading
import time
from datetime import datetime, timedelta
Expand Down Expand Up @@ -253,6 +254,20 @@ def _build_clipper_state(self, video_id: str, url: str,
cache_path_template = self.cache_dir / cache_filename

clipper_paths = ClipperPaths()
# In frozen (PyInstaller) builds, point to bundled binaries by default
if getattr(sys, "frozen", False):
bin_dir = "./bin"
ext = ".exe" if sys.platform == "win32" else ""
clipper_paths.ffmpegPath = f"{bin_dir}/ffmpeg{ext}"
clipper_paths.ffprobePath = f"{bin_dir}/ffprobe{ext}"
clipper_paths.ffplayPath = f"{bin_dir}/ffplay{ext}"
clipper_paths.ytdlPath = f"{bin_dir}/yt-dlp{ext}"
# Normalize slashes for subprocess readability
clipper_paths.ffmpegPath = clipper_paths.ffmpegPath.replace("\\", "/")
clipper_paths.ffprobePath = clipper_paths.ffprobePath.replace("\\", "/")
clipper_paths.ffplayPath = clipper_paths.ffplayPath.replace("\\", "/")
clipper_paths.ytdlPath = clipper_paths.ytdlPath.replace("\\", "/")

if ytdl_location:
clipper_paths.ytdlPath = ytdl_location
logger.info(f"Using custom yt-dlp location: {ytdl_location}")
Expand Down
12 changes: 6 additions & 6 deletions src/clipper/clip_maker.py
Original file line number Diff line number Diff line change
Expand Up @@ -1468,37 +1468,37 @@ def getDefaultEncodeSettings(videobr: int) -> DictStrAny:
}
elif videobr <= 4000:
encodeSettings = {
"crf": 24,
"crf": 22,
"autoTargetMaxBitrate": int(1.6 * videobr),
"twoPass": False,
}
elif videobr <= 6000:
encodeSettings = {
"crf": 26,
"crf": 24,
"autoTargetMaxBitrate": int(1.4 * videobr),
"twoPass": False,
}
elif videobr <= 10000:
encodeSettings = {
"crf": 28,
"crf": 26,
"autoTargetMaxBitrate": int(1.2 * videobr),
"twoPass": False,
}
elif videobr <= 14000:
encodeSettings = {
"crf": 30,
"crf": 26,
"autoTargetMaxBitrate": int(1.1 * videobr),
"twoPass": False,
}
elif videobr <= 18000:
encodeSettings = {
"crf": 30,
"crf": 26,
"autoTargetMaxBitrate": int(1.0 * videobr),
"twoPass": False,
}
elif videobr <= 25000:
encodeSettings = {
"crf": 32,
"crf": 30,
"autoTargetMaxBitrate": int(0.9 * videobr),
"twoPass": False,
}
Expand Down
6 changes: 0 additions & 6 deletions src/clipper/gui/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,6 @@ def process_files(self, markup_path: Optional[str] = None, video_path: Optional[
return {"status": "error", "message": "Either markup_path or markup_data must be provided"}

try:
print(f"DEBUG: Starting file processing with CLI-identical logic and GUI settings...")

# Validate inputs - either markup_path OR markup_data must be provided
if markup_path:
markup_file = Path(markup_path)
Expand All @@ -64,13 +62,11 @@ def process_files(self, markup_path: Optional[str] = None, video_path: Optional[

# Create a fresh clipper state for this processing session
self.cs = clipper_types.ClipperState()
print("DEBUG: Created fresh ClipperState for processing")

# Get comprehensive GUI settings
from clipper.gui.settings_manager import SettingsManager
settings_manager = SettingsManager()
gui_settings = settings_manager.get_combined_settings()
print(f"DEBUG: Loaded GUI settings: {len(gui_settings)} settings")

# Build minimal argv for required arguments only (no settings)
simulated_argv = ["yt_clipper"]
Expand Down Expand Up @@ -171,8 +167,6 @@ def process_files(self, markup_path: Optional[str] = None, video_path: Optional[
ytc_settings.getInputVideo(self.cs)
ytc_settings.getGlobalSettings(self.cs)

print("DEBUG: Starting clip processing...")

# Process clips exactly like CLI
if not self.cs.settings.get("preview", False):
clip_maker.makeClips(self.cs)
Expand Down
37 changes: 29 additions & 8 deletions src/clipper/ytc_logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import logging
from pathlib import Path
from types import TracebackType
from typing import IO, Dict
from typing import IO, Mapping

import coloredlogs
import verboselogs
Expand Down Expand Up @@ -63,15 +63,36 @@ def log(
| BaseException = None,
stack_info: bool = False,
stacklevel: int = 1,
extra: Dict[str, object] | None = None,
extra: Mapping[str, object] | None = None,
) -> None:
if not self.no_rich_logs:
level_name = logging.getLevelName(level)
color = self.console.get_style(f"logging.level.{level_name.lower()}")

msg = f"[{color}]{msg}"
if extra is None:
extra = {}
# Map numeric levels to known Rich theme style keys to avoid invalid style names
# that can occur with custom levels (e.g., "Level 34").
style_by_level = {
logging.DEBUG: "logging.level.debug",
verboselogs.VERBOSE: "logging.level.verbose",
logging.INFO: "logging.level.info",
# Some code paths use 'success' level via verboselogs
getattr(verboselogs, "SUCCESS", 25): "logging.level.success",
IMPORTANT: "logging.level.important",
NOTICE: "logging.level.notice",
HEADER: "logging.level.header",
REPORT: "logging.level.report",
logging.WARNING: "logging.level.warning",
logging.ERROR: "logging.level.error",
logging.CRITICAL: "logging.level.error",
}

style_key = style_by_level.get(level)
if style_key is None:
# Fall back to a best-effort mapping based on the level name
level_name = str(logging.getLevelName(level)).lower().replace(" ", "_")
candidate = f"logging.level.{level_name}"
style_key = candidate if candidate in THEME_COLORS_LOG_LEVELS else "logging.level.info"

# Wrap the message with the resolved style name; Rich will pick it from the theme
msg = f"[{style_key}]{msg}"
extra = {} if extra is None else dict(extra)
extra["markup"] = True

return super().log(
Expand Down
14 changes: 13 additions & 1 deletion src/clipper/ytdl.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import shlex
import subprocess
import sys
from pathlib import Path
from typing import Dict, List, Tuple

from clipper.clipper_types import ClipperPaths, ClipperState
Expand Down Expand Up @@ -44,7 +45,18 @@ def ytdl_bin_get_args_base(cs: ClipperState) -> List[str]:
ytdl_args.extend(["--output", shlex.quote(f'{settings["downloadVideoPath"]}')])

if getattr(sys, "frozen", False):
ytdl_args.extend(["--ffmpeg-location", shlex.quote(cp.ffmpegPath)])
# Only pass --ffmpeg-location if it points to an existing executable
ffmpeg_path = cp.ffmpegPath
try:
exists = Path(ffmpeg_path).is_file()
except Exception:
exists = False
if exists:
ytdl_args.extend(["--ffmpeg-location", shlex.quote(ffmpeg_path)])
else:
logger.debug(
f"Skipping --ffmpeg-location: path not found -> {ffmpeg_path!r}",
)

cookies = settings["cookiefile"]

Expand Down
182 changes: 182 additions & 0 deletions src/gui-frontend/src/components/ColorControls.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
<template>
<div class="color-controls">
<el-scrollbar height="100%">
<div class="color-controls-content">
<el-tabs v-model="activeTab" type="border-card">
<el-tab-pane label="Color Adjustments" name="basic">
<div class="control-group">
<div class="control-label">Brightness</div>
<el-slider v-model="brightness" :min="-0.5" :max="0.5" :step="0.01" @change="emitFilter" show-input input-size="small" />
</div>
<div class="control-group">
<div class="control-label">Contrast</div>
<el-slider v-model="contrast" :min="0" :max="3" :step="0.01" @change="emitFilter" show-input input-size="small" />
</div>
<div class="control-group">
<div class="control-label">Saturation</div>
<el-slider v-model="saturation" :min="0" :max="3" :step="0.01" @change="emitFilter" show-input input-size="small" />
</div>
<div class="control-group">
<div class="control-label">Hue</div>
<el-slider v-model="hue" :min="-180" :max="180" :step="1" @change="emitFilter" show-input input-size="small" />
</div>
<div class="control-group">
<div class="control-label">Gamma</div>
<el-slider v-model="gamma" :min="0.1" :max="3" :step="0.01" @change="emitFilter" show-input input-size="small" />
</div>
</el-tab-pane>
<el-tab-pane label="Advanced (Lift/Gamma/Gain)" name="advanced">
<LiftGammaGainWheels @advanced-filter-changed="handleAdvancedFilterChanged" />
</el-tab-pane>
</el-tabs>

<div class="filter-output-card">
<div class="filter-card-header">
<h5>Generated Filter</h5>
<div class="filter-header-controls">
<el-checkbox v-model="previewEnabledLocal" @change="$emit('toggle-preview', previewEnabledLocal)" size="small">Preview</el-checkbox>
</div>
</div>
<div class="filter-display">
<el-input v-model="generatedFilter" type="textarea" :rows="2" readonly placeholder="Color grading filter will appear here" class="filter-textarea" />
</div>
<div class="filter-actions">
<div class="action-group primary-actions">
<el-button size="small" type="primary" @click="copyFilter" :disabled="!generatedFilter" plain>
<el-icon><DocumentCopy /></el-icon>
Copy Filter
</el-button>
<el-button size="small" @click="showPasteDialog" plain>
<el-icon><Document /></el-icon>
Paste Filter
</el-button>
</div>
<div class="action-group secondary-actions">
<el-button size="small" type="success" @click="$emit('copy-to-all-clips', generatedFilter)" :disabled="!generatedFilter || isMockMarkup" plain v-if="!isMockMarkup">
<el-icon><CopyDocument /></el-icon>
Copy to All Clips
</el-button>
<el-button size="small" type="warning" @click="reset" plain>
<el-icon><RefreshLeft /></el-icon>
Reset
</el-button>
</div>
</div>
</div>
</div>
</el-scrollbar>
</div>
</template>

<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { DocumentCopy, Document, CopyDocument, RefreshLeft } from '@element-plus/icons-vue'
import LiftGammaGainWheels from './LiftGammaGainWheels.vue'
import { buildBasicFilter } from '@/utils/colorBasics'
import { parseFilterString, joinFilters } from '@/utils/filterString'
import { debounce } from '@/utils/debounce'


interface Props {
isMockMarkup?: boolean
previewEnabled?: boolean
initialFilter?: string | null
}

const props = withDefaults(defineProps<Props>(), {
isMockMarkup: false,
previewEnabled: true,
initialFilter: null
})

const emit = defineEmits<{
(e: 'filter-changed', filter: string): void
(e: 'toggle-preview', enabled: boolean): void
(e: 'copy-to-all-clips', filter: string): void
}>()

const activeTab = ref<'basic' | 'advanced'>('basic')
const brightness = ref(0)
const contrast = ref(1)
const saturation = ref(1)
const hue = ref(0)
const gamma = ref(1)
const advancedFilterState = ref('')
const previewEnabledLocal = ref<boolean>(props.previewEnabled)

const basicFilter = computed(() => buildBasicFilter({ brightness: brightness.value, contrast: contrast.value, saturation: saturation.value, hue: hue.value, gamma: gamma.value }))
const advancedFilter = computed(() => advancedFilterState.value)
const generatedFilter = computed(() => joinFilters(basicFilter.value, advancedFilter.value))

const debouncedFilterEmit = debounce(() => emit('filter-changed', generatedFilter.value), 180)
const emitFilter = () => { debouncedFilterEmit() }
const debouncedAdvancedHandler = debounce((filter: string) => { advancedFilterState.value = filter; emitFilter() }, 120)
const handleAdvancedFilterChanged = (filter: string) => { debouncedAdvancedHandler(filter) }

const reset = () => {
brightness.value = 0; contrast.value = 1; saturation.value = 1; hue.value = 0; gamma.value = 1; advancedFilterState.value = ''
emitFilter()
}

const copyFilter = async () => {
try { await navigator.clipboard.writeText(generatedFilter.value); ElMessage.success('Filter copied to clipboard') }
catch (e) { console.error('Failed to copy filter:', e); ElMessage.error('Failed to copy filter to clipboard') }
}

const showPasteDialog = async () => {
try {
const clipboardText = await navigator.clipboard.readText()
if (clipboardText.trim()) { pasteFilter(clipboardText.trim()) }
else { ElMessage.warning('Clipboard is empty') }
} catch (e) { ElMessage.error('Failed to access clipboard. Please check permissions.'); console.error('Clipboard access failed:', e) }
}

const pasteFilter = (text: string) => {
try { parseAndApplyFilter(text); emitFilter() }
catch (e) { ElMessage.error(`Failed to apply filter: ${String(e)}`) }
}

const parseAndApplyFilter = (filterString: string) => {
reset()
const parsed = parseFilterString(filterString)
brightness.value = parsed.brightness
contrast.value = parsed.contrast
saturation.value = parsed.saturation
hue.value = parsed.hue
gamma.value = parsed.gamma
if (parsed.advanced) advancedFilterState.value = parsed.advanced
}

watch(
() => props.initialFilter,
(f) => {
if (f != null) {
parseAndApplyFilter(f)
emitFilter()
}
},
{ immediate: true }
)

defineExpose({ generatedFilter })

const isMockMarkup = computed(() => props.isMockMarkup)
</script>

<style scoped>
.color-controls { height: 100%; overflow-y: auto; display: flex; flex-direction: column; gap: 16px; }
.control-group { margin-bottom: 16px; }
.control-label { font-size: 13px; color: var(--el-text-color-regular); margin-bottom: 8px; font-weight: 500; }
.filter-display { border-top: 1px solid var(--el-border-color); padding-top: 16px; }
.filter-output-card { border: 1px solid var(--el-border-color); border-radius: 8px; padding: 16px; background: var(--el-bg-color); margin-top: 16px; }
.filter-card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.filter-card-header h5 { margin: 0; font-size: 14px; font-weight: 600; color: var(--el-text-color-primary); }
.filter-header-controls { display: flex; align-items: center; gap: 8px; }
.filter-textarea { margin-bottom: 16px; }
.filter-textarea :deep(.el-textarea__inner) { font-family: monospace; font-size: 12px; line-height: 1.4; background: var(--el-fill-color-lighter); }
.filter-actions { display: flex; flex-direction: column; gap: 12px; }
.action-group { display: flex; gap: 8px; flex-wrap: wrap; }
.primary-actions .el-button { flex: 1; min-width: 120px; }
.secondary-actions .el-button { flex: 1; min-width: 100px; }
</style>
Loading
Loading