feat: cursor lock with crop keyframing, remove fuzzy filename matching
- Lock button (G key) freezes export cursor, timeline scrubs playback only - In lock mode, clicking crop bar sets a keyframe at current playback time - Orange diamonds on timeline show keyframe positions - Export resolves per-clip crop center from nearest preceding keyframe - Crop bar/overlay updates while scrubbing to preview effective crop - Unlocking clears all keyframes - Replace fuzzy filename matching with exact match to prevent marker bleed Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +13,6 @@ import subprocess
|
||||
import tempfile
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import datetime, timezone
|
||||
from difflib import SequenceMatcher
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
@@ -230,26 +229,9 @@ def _portrait_crop_filter(ratio: str, crop_center: float) -> str:
|
||||
return f"crop={cw}:ih:{x}:0"
|
||||
|
||||
|
||||
_QUALITY_RE = re.compile(
|
||||
r'(?<![a-z0-9])(2160p?|4k|8k|1080p?|720p?|480p?|360p?|240p?'
|
||||
r'|hdr|sdr|x264|x265|h264|h265|hevc|avc'
|
||||
r'|blu[-_.]?ray|webrip|web[-_.]dl|dvdrip|hdtv)(?![a-z0-9])',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_SEP_RE = re.compile(r'[\s_\-\.]+')
|
||||
_SELVA_CATEGORIES = ["", "Human", "Animal", "Vehicle", "Tool", "Music", "Nature", "Sport", "Other"]
|
||||
|
||||
|
||||
def _normalize_filename(filename: str) -> str:
|
||||
"""Strip extension and common resolution/quality tags for fuzzy comparison."""
|
||||
# Use lookaround assertions instead of \b: \b treats '_' as a word char,
|
||||
# so 'clip_2160p' would not form a word boundary before '2160p'.
|
||||
name = os.path.splitext(filename)[0].lower()
|
||||
name = _QUALITY_RE.sub('', name)
|
||||
name = _SEP_RE.sub('_', name).strip('_')
|
||||
return name
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Subject tracking (YOLO-based, optional)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -519,23 +501,6 @@ class ProcessedDB:
|
||||
self._con.commit()
|
||||
return paths
|
||||
|
||||
def find_similar(self, filename: str, profile: str = "default") -> str | None:
|
||||
if not self._enabled:
|
||||
return None
|
||||
rows = self._con.execute(
|
||||
"SELECT DISTINCT filename FROM processed WHERE profile = ?",
|
||||
(profile,),
|
||||
).fetchall()
|
||||
norm_new = _normalize_filename(filename)
|
||||
best_ratio, best_match = 0.0, None
|
||||
for (stored,) in rows:
|
||||
ratio = SequenceMatcher(
|
||||
None, norm_new, _normalize_filename(stored)
|
||||
).ratio()
|
||||
if ratio >= 0.75 and ratio > best_ratio:
|
||||
best_ratio, best_match = ratio, stored
|
||||
return best_match
|
||||
|
||||
def _get_markers_for(self, match: str, profile: str = "default") -> list[tuple[float, int, str]]:
|
||||
rows = self._con.execute(
|
||||
"SELECT start_time, output_path FROM processed"
|
||||
@@ -552,14 +517,11 @@ class ProcessedDB:
|
||||
return list(seen_times.values())
|
||||
|
||||
def get_markers(self, filename: str, profile: str = "default") -> list[tuple[float, int, str]]:
|
||||
"""Return [(start_time, marker_number, output_path), ...] for the best
|
||||
fuzzy match of filename, sorted by start_time. Empty list if no match."""
|
||||
"""Return [(start_time, marker_number, output_path), ...] for exact
|
||||
filename match, sorted by start_time. Empty list if no match."""
|
||||
if not self._enabled:
|
||||
return []
|
||||
match = self.find_similar(filename, profile)
|
||||
if match is None:
|
||||
return []
|
||||
return self._get_markers_for(match, profile)
|
||||
return self._get_markers_for(filename, profile)
|
||||
|
||||
def get_profiles(self) -> list[str]:
|
||||
"""Return distinct profile names, ordered alphabetically."""
|
||||
@@ -583,11 +545,10 @@ class _DBWorker(QThread):
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
match = self._db.find_similar(self._filename, self._profile)
|
||||
markers = self._db._get_markers_for(match, self._profile) if match else []
|
||||
markers = self._db._get_markers_for(self._filename, self._profile)
|
||||
except Exception:
|
||||
match, markers = None, []
|
||||
self.result.emit(self._filename, match, markers)
|
||||
markers = []
|
||||
self.result.emit(self._filename, self._filename if markers else None, markers)
|
||||
|
||||
|
||||
class ExportWorker(QThread):
|
||||
@@ -682,6 +643,7 @@ class FrameGrabber(QThread):
|
||||
|
||||
class TimelineWidget(QWidget):
|
||||
cursor_changed = pyqtSignal(float) # emits position in seconds
|
||||
seek_changed = pyqtSignal(float) # emits seek position (lock mode)
|
||||
marker_delete_requested = pyqtSignal(str) # emits output_path
|
||||
marker_clicked = pyqtSignal(float, str) # emits (start_time, output_path)
|
||||
marker_deselected = pyqtSignal() # double-click on empty space
|
||||
@@ -697,6 +659,8 @@ class TimelineWidget(QWidget):
|
||||
self._cursor = 0.0
|
||||
self._clip_span = 14.0 # 8 + 2*spread, updated from MainWindow
|
||||
self._play_pos: float | None = None # current playback position (seconds)
|
||||
self._locked = False # when True, clicks scrub playback, not cursor
|
||||
self._crop_keyframes: list[tuple[float, float]] = [] # [(time, center)]
|
||||
self._markers: list[tuple[float, int, str]] = []
|
||||
self._hover_cache: list[tuple[float, str]] = [] # (t/duration, path)
|
||||
|
||||
@@ -717,7 +681,7 @@ class TimelineWidget(QWidget):
|
||||
self._seek_timer = QTimer()
|
||||
self._seek_timer.setSingleShot(True)
|
||||
self._seek_timer.setInterval(16) # ~60 fps
|
||||
self._seek_timer.timeout.connect(lambda: self.cursor_changed.emit(self._cursor))
|
||||
self._seek_timer.timeout.connect(self._emit_seek)
|
||||
|
||||
def set_duration(self, duration: float):
|
||||
self._duration = duration
|
||||
@@ -747,6 +711,10 @@ class TimelineWidget(QWidget):
|
||||
self._play_pos = t
|
||||
self.update()
|
||||
|
||||
def set_crop_keyframes(self, kfs: list[tuple[float, float]]) -> None:
|
||||
self._crop_keyframes = kfs
|
||||
self.update()
|
||||
|
||||
def _rebuild_hover_cache(self) -> None:
|
||||
"""Pre-compute (pixel_x_fraction, output_path) for hover detection."""
|
||||
if self._duration > 0:
|
||||
@@ -855,6 +823,20 @@ class TimelineWidget(QWidget):
|
||||
p.drawText(mx + 1, rh + 2, 13, 12,
|
||||
Qt.AlignmentFlag.AlignCenter, str(num))
|
||||
|
||||
# ── crop keyframe diamonds ────────────────────────────────────
|
||||
if self._crop_keyframes and self._duration > 0:
|
||||
for (kt, _kc) in self._crop_keyframes:
|
||||
kx = int(kt / self._duration * w)
|
||||
d = 4 # half-size of diamond
|
||||
ky = h - d - 2 # near bottom of track
|
||||
diamond = QPolygon([
|
||||
QPoint(kx, ky - d), QPoint(kx + d, ky),
|
||||
QPoint(kx, ky + d), QPoint(kx - d, ky),
|
||||
])
|
||||
p.setBrush(QColor(255, 180, 0))
|
||||
p.setPen(Qt.PenStyle.NoPen)
|
||||
p.drawPolygon(diamond)
|
||||
|
||||
# ── playhead ──────────────────────────────────────────────────
|
||||
p.setPen(self._cursor_pen)
|
||||
p.drawLine(x_start, rh, x_start, h)
|
||||
@@ -905,10 +887,16 @@ class TimelineWidget(QWidget):
|
||||
if event.buttons():
|
||||
self._seek(x)
|
||||
|
||||
def _emit_seek(self):
|
||||
if self._locked:
|
||||
self.seek_changed.emit(self._play_pos or 0.0)
|
||||
else:
|
||||
self.cursor_changed.emit(self._cursor)
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
# On release, flush any pending debounced seek immediately.
|
||||
self._seek_timer.stop()
|
||||
self.cursor_changed.emit(self._cursor)
|
||||
self._emit_seek()
|
||||
|
||||
def contextMenuEvent(self, event):
|
||||
if not self._hover_cache or self._duration <= 0:
|
||||
@@ -931,6 +919,11 @@ class TimelineWidget(QWidget):
|
||||
|
||||
def _seek(self, x: float):
|
||||
t = self._pos_to_time(int(x))
|
||||
if self._locked:
|
||||
self._play_pos = t
|
||||
self.update()
|
||||
self._seek_timer.start()
|
||||
else:
|
||||
self.set_cursor(t) # update visuals immediately
|
||||
self._seek_timer.start() # debounce the mpv seek
|
||||
|
||||
@@ -1529,6 +1522,7 @@ class MainWindow(QMainWindow):
|
||||
self._db_worker: _DBWorker | None = None
|
||||
self._frame_grabber: FrameGrabber | None = None
|
||||
self._fps: float = 25.0 # cached on file load via get_fps()
|
||||
self._crop_keyframes: list[tuple[float, float]] = [] # [(time, center), ...] sorted
|
||||
|
||||
# Widgets
|
||||
self._playlist = PlaylistWidget()
|
||||
@@ -1560,6 +1554,7 @@ class MainWindow(QMainWindow):
|
||||
_init_spread = float(self._settings.value("spread", "3.0"))
|
||||
self._timeline.set_clip_span(8.0 + (_init_clips - 1) * _init_spread)
|
||||
self._timeline.cursor_changed.connect(self._on_cursor_changed)
|
||||
self._timeline.seek_changed.connect(self._on_seek_changed)
|
||||
self._timeline.marker_delete_requested.connect(self._on_delete_marker)
|
||||
self._mpv.time_pos_changed.connect(self._timeline.set_play_position)
|
||||
self._timeline.marker_clicked.connect(self._on_marker_clicked)
|
||||
@@ -1582,6 +1577,11 @@ class MainWindow(QMainWindow):
|
||||
self._btn_pause.setToolTip("Pause playback (Space / K)")
|
||||
self._btn_pause.clicked.connect(self._on_pause)
|
||||
|
||||
self._btn_lock = QPushButton("🔒 Lock")
|
||||
self._btn_lock.setCheckable(True)
|
||||
self._btn_lock.setToolTip("Lock cursor — click/drag scrubs playback without moving the export point")
|
||||
self._btn_lock.toggled.connect(self._on_lock_toggled)
|
||||
|
||||
self._lbl_time = QLabel("-- / --")
|
||||
|
||||
self._txt_name = QLineEdit("clip")
|
||||
@@ -1793,6 +1793,7 @@ class MainWindow(QMainWindow):
|
||||
transport_row = QHBoxLayout()
|
||||
transport_row.addWidget(self._btn_play)
|
||||
transport_row.addWidget(self._btn_pause)
|
||||
transport_row.addWidget(self._btn_lock)
|
||||
transport_row.addWidget(self._lbl_time)
|
||||
transport_row.addStretch()
|
||||
transport_row.addWidget(self._lbl_next)
|
||||
@@ -1894,6 +1895,7 @@ class MainWindow(QMainWindow):
|
||||
QShortcut(QKeySequence("E"), self, context=ctx).activated.connect(self._on_export)
|
||||
QShortcut(QKeySequence("M"), self, context=ctx).activated.connect(self._jump_to_next_marker)
|
||||
QShortcut(QKeySequence("N"), self, context=ctx).activated.connect(self._playlist.advance)
|
||||
QShortcut(QKeySequence("G"), self, context=ctx).activated.connect(self._btn_lock.toggle)
|
||||
for key in ("?", "F1"):
|
||||
QShortcut(QKeySequence(key), self, context=ctx).activated.connect(self._show_shortcuts)
|
||||
|
||||
@@ -1909,6 +1911,7 @@ class MainWindow(QMainWindow):
|
||||
"<tr><td><b>E</b></td><td>Export</td></tr>"
|
||||
"<tr><td><b>M</b></td><td>Jump to next marker</td></tr>"
|
||||
"<tr><td><b>N</b></td><td>Next file in playlist</td></tr>"
|
||||
"<tr><td><b>G</b></td><td>Toggle cursor lock</td></tr>"
|
||||
"<tr><td><b>? / F1</b></td><td>This help</td></tr>"
|
||||
"<tr><td colspan='2'><hr></td></tr>"
|
||||
"<tr><td><b>Double-click marker</b></td><td>Enter overwrite mode</td></tr>"
|
||||
@@ -2043,16 +2046,7 @@ class MainWindow(QMainWindow):
|
||||
|
||||
def _refresh_markers(self) -> None:
|
||||
filename = os.path.basename(self._file_path)
|
||||
profile = self._profile
|
||||
# After an export we already know the exact stored filename, so skip
|
||||
# the expensive fuzzy match and query directly.
|
||||
if self._db._enabled:
|
||||
markers = self._db._get_markers_for(filename, profile)
|
||||
if not markers:
|
||||
# First export for this file — fall back to fuzzy match once.
|
||||
markers = self._db.get_markers(filename, profile)
|
||||
else:
|
||||
markers = []
|
||||
markers = self._db.get_markers(filename, self._profile)
|
||||
self._timeline.set_markers(markers)
|
||||
|
||||
def _refresh_playlist_checks(self) -> None:
|
||||
@@ -2230,7 +2224,26 @@ class MainWindow(QMainWindow):
|
||||
any_rand = self._chk_rand_portrait.isChecked() or self._chk_rand_square.isChecked()
|
||||
if ratio == "Off" and not any_rand:
|
||||
return
|
||||
self._crop_center = max(0.0, min(1.0, frac))
|
||||
frac = max(0.0, min(1.0, frac))
|
||||
if self._btn_lock.isChecked():
|
||||
# Lock mode: set a crop keyframe at the current playback position.
|
||||
play_t = self._timeline._play_pos
|
||||
if play_t is None:
|
||||
play_t = self._cursor
|
||||
# Replace existing keyframe at same time, or insert sorted.
|
||||
self._crop_keyframes = [
|
||||
(t, c) for t, c in self._crop_keyframes
|
||||
if abs(t - play_t) > 0.05
|
||||
]
|
||||
self._crop_keyframes.append((play_t, frac))
|
||||
self._crop_keyframes.sort()
|
||||
self._timeline.set_crop_keyframes(self._crop_keyframes)
|
||||
_log(f"Crop keyframe: t={play_t:.2f}s center={frac:.3f} ({len(self._crop_keyframes)} total)")
|
||||
self._crop_bar.set_crop_center(frac)
|
||||
if ratio != "Off":
|
||||
self._mpv.set_crop_overlay(_RATIOS[ratio], frac)
|
||||
return
|
||||
self._crop_center = frac
|
||||
self._settings.setValue("crop_center", str(self._crop_center))
|
||||
self._crop_bar.set_crop_center(self._crop_center)
|
||||
if ratio != "Off":
|
||||
@@ -2269,6 +2282,38 @@ class MainWindow(QMainWindow):
|
||||
|
||||
# --- Playback ---
|
||||
|
||||
def _on_lock_toggled(self, locked: bool):
|
||||
self._timeline._locked = locked
|
||||
self._btn_lock.setText("🔒 Lock" if locked else "🔓 Lock")
|
||||
if locked:
|
||||
self._btn_lock.setStyleSheet("background: #4a3000; border-color: #ffd230;")
|
||||
else:
|
||||
self._btn_lock.setStyleSheet("")
|
||||
# Clear keyframes when unlocking.
|
||||
if self._crop_keyframes:
|
||||
n = len(self._crop_keyframes)
|
||||
self._crop_keyframes.clear()
|
||||
self._timeline.set_crop_keyframes([])
|
||||
_log(f"Cleared {n} crop keyframe(s)")
|
||||
|
||||
def _on_seek_changed(self, t: float):
|
||||
"""Lock mode: scrub playback without moving the export cursor."""
|
||||
dur = self._mpv.get_duration()
|
||||
self._lbl_time.setText(f"{format_time(t)} / {format_time(dur)}")
|
||||
self._mpv.seek(t)
|
||||
# Update crop bar to show the effective center at this time.
|
||||
if self._crop_keyframes:
|
||||
center = self._crop_center
|
||||
for kt, kc in self._crop_keyframes:
|
||||
if kt <= t + 0.05:
|
||||
center = kc
|
||||
else:
|
||||
break
|
||||
self._crop_bar.set_crop_center(center)
|
||||
ratio = self._cmb_portrait.currentText()
|
||||
if ratio != "Off":
|
||||
self._mpv.set_crop_overlay(_RATIOS[ratio], center)
|
||||
|
||||
def _on_cursor_changed(self, t: float):
|
||||
self._cursor = t
|
||||
dur = self._mpv.get_duration()
|
||||
@@ -2400,6 +2445,20 @@ class MainWindow(QMainWindow):
|
||||
out = build_export_path(folder, name, self._export_counter, sub=sub)
|
||||
jobs.append((start, out, base_ratio, base_center))
|
||||
|
||||
# Apply crop keyframes: each sub-clip uses the latest keyframe
|
||||
# at or before its start time (keyframes set in lock mode).
|
||||
if self._crop_keyframes:
|
||||
for i, (s, o, r, c) in enumerate(jobs):
|
||||
if r is None:
|
||||
continue # no crop → skip
|
||||
center = base_center
|
||||
for kt, kc in self._crop_keyframes:
|
||||
if kt <= s + 0.05:
|
||||
center = kc
|
||||
else:
|
||||
break
|
||||
jobs[i] = (s, o, r, center)
|
||||
|
||||
# Random crop: ~1 per 3 clips gets a random crop + random position.
|
||||
# When both portrait and square are on, they share the quota.
|
||||
rand_portrait = self._chk_rand_portrait.isChecked()
|
||||
|
||||
+13
-56
@@ -1,6 +1,6 @@
|
||||
import tempfile, os, json
|
||||
from main import build_export_path, format_time, build_ffmpeg_command, build_sequence_dir, build_audio_extract_command, build_annotation_json_path, upsert_clip_annotation
|
||||
from main import _normalize_filename, ProcessedDB
|
||||
from main import ProcessedDB
|
||||
|
||||
|
||||
def test_build_export_path_first():
|
||||
@@ -53,63 +53,47 @@ def test_ffmpeg_command_with_resize():
|
||||
assert cmd[-1] == "/out/clip_001.mp4"
|
||||
|
||||
|
||||
# --- _normalize_filename ---
|
||||
|
||||
def test_normalize_strips_extension():
|
||||
assert _normalize_filename("clip.mp4") == "clip"
|
||||
|
||||
def test_normalize_strips_resolution():
|
||||
assert _normalize_filename("clip_2160p.mp4") == "clip"
|
||||
|
||||
def test_normalize_strips_1080p():
|
||||
assert _normalize_filename("clip_1080p.mkv") == "clip"
|
||||
|
||||
def test_normalize_strips_multiple_tags():
|
||||
assert _normalize_filename("show_1080p_HDR.mkv") == "show"
|
||||
|
||||
def test_normalize_lowercases():
|
||||
assert _normalize_filename("MyVideo_4K.mp4") == "myvideo"
|
||||
|
||||
def test_normalize_collapses_separators():
|
||||
assert _normalize_filename("my__video--2160p.mp4") == "my_video"
|
||||
|
||||
|
||||
# --- ProcessedDB ---
|
||||
|
||||
def test_db_add_and_find_exact():
|
||||
def test_db_add_and_get_markers():
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
||||
path = f.name
|
||||
try:
|
||||
db = ProcessedDB(path)
|
||||
db.add("video.mp4", 12.5, "/out/clip_001.mp4")
|
||||
assert db.find_similar("video.mp4") == "video.mp4"
|
||||
markers = db.get_markers("video.mp4")
|
||||
assert len(markers) == 1
|
||||
assert markers[0][0] == 12.5
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_db_find_similar_resolution_variant():
|
||||
def test_db_exact_match_only():
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
||||
path = f.name
|
||||
try:
|
||||
db = ProcessedDB(path)
|
||||
db.add("episode_s01e01_2160p.mkv", 0.0, "/out/ep_001.mp4")
|
||||
assert db.find_similar("episode_s01e01_1080p.mkv") == "episode_s01e01_2160p.mkv"
|
||||
# Different filename — no match even if similar
|
||||
assert db.get_markers("episode_s01e01_1080p.mkv") == []
|
||||
# Exact filename — match
|
||||
assert len(db.get_markers("episode_s01e01_2160p.mkv")) == 1
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_db_find_similar_no_match():
|
||||
def test_db_no_match():
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
||||
path = f.name
|
||||
try:
|
||||
db = ProcessedDB(path)
|
||||
db.add("alpha.mp4", 0.0, "/out/alpha_001.mp4")
|
||||
assert db.find_similar("completely_different_zzzz.mp4") is None
|
||||
assert db.get_markers("completely_different.mp4") == []
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_db_disabled_survives_bad_path():
|
||||
db = ProcessedDB("/no/such/directory/8cut.db")
|
||||
db.add("x.mp4", 0.0, "/out/x_001.mp4") # must not raise
|
||||
assert db.find_similar("x.mp4") is None
|
||||
assert db.get_markers("x.mp4") == []
|
||||
|
||||
def test_db_get_markers_returns_sorted():
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
||||
@@ -127,19 +111,6 @@ def test_db_get_markers_returns_sorted():
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_db_get_markers_fuzzy_match():
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
||||
path = f.name
|
||||
try:
|
||||
db = ProcessedDB(path)
|
||||
db.add("show_2160p.mkv", 5.0, "/out/s_001.mp4")
|
||||
markers = db.get_markers("show_1080p.mkv")
|
||||
assert len(markers) == 1
|
||||
assert markers[0][0] == 5.0
|
||||
assert markers[0][2] == "/out/s_001.mp4"
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_db_get_markers_no_match():
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
||||
path = f.name
|
||||
@@ -365,20 +336,6 @@ def test_db_markers_isolated_by_profile():
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
def test_db_find_similar_isolated_by_profile():
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
||||
path = f.name
|
||||
try:
|
||||
db = ProcessedDB(path)
|
||||
db.add("episode_2160p.mkv", 0.0, "/out/a.mp4", profile="hires")
|
||||
# Same normalized name but different profile → no match
|
||||
assert db.find_similar("episode_1080p.mkv", profile="lores") is None
|
||||
# Same profile → match
|
||||
assert db.find_similar("episode_1080p.mkv", profile="hires") == "episode_2160p.mkv"
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
def test_db_get_profiles():
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
||||
path = f.name
|
||||
|
||||
Reference in New Issue
Block a user