Files
pinkudex/markers-mockup.html
T
2026-05-26 22:46:00 +02:00

948 lines
36 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Pinkudex — Markers feature mockup</title>
<style>
:root {
--bg-0: #0b0d10;
--bg-1: #14171c;
--bg-2: #1c2027;
--bg-3: #252a33;
--fg: #e6e8ec;
--fg-dim: #a4abb6;
--fg-muted: #6b7380;
--cyan: #22d3ee;
--mint: #34d399;
--amber: #fbbf24;
--coral: #f87171;
--violet: #a78bfa;
--pink: #f472b6;
--glass: rgba(255,255,255,0.04);
--glass-border: rgba(255,255,255,0.10);
--glass-border-strong: rgba(255,255,255,0.18);
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
background: var(--bg-0);
color: var(--fg);
font: 14px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI";
min-height: 100vh;
}
.page { max-width: 1280px; margin: 0 auto; padding: 28px; }
h1 { font-weight: 500; font-size: 22px; margin: 0 0 6px; letter-spacing: -0.01em; }
h2 { font-weight: 500; font-size: 16px; margin: 28px 0 10px; }
.sub { color: var(--fg-muted); font-size: 13px; margin-bottom: 18px; }
/* Tabs to switch between the three views */
.tabs {
display: flex; gap: 4px; padding: 4px;
background: var(--bg-1); border: 1px solid var(--glass-border);
border-radius: 10px; margin-bottom: 24px; width: fit-content;
}
.tabs button {
background: transparent; border: 0; color: var(--fg-dim);
font-family: inherit; font-size: 12px; font-weight: 500;
padding: 9px 16px; border-radius: 7px; cursor: pointer;
transition: background 120ms, color 120ms;
}
.tabs button:hover { color: var(--fg); }
.tabs button.active {
background: rgba(34,211,238,0.15);
color: var(--cyan);
box-shadow: inset 0 0 0 1px rgba(34,211,238,0.4);
}
.view { display: none; animation: fadeIn 200ms ease-out; }
.view.active { display: block; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
/* ============ MARKERS WALL ============ */
.toolbar {
display: flex; gap: 12px; align-items: center;
padding: 10px 14px; background: var(--bg-1);
border: 1px solid var(--glass-border); border-radius: 12px;
margin-bottom: 14px;
}
.toolbar input {
flex: 1;
background: var(--bg-0); color: var(--fg);
border: 1px solid var(--glass-border-strong);
padding: 7px 10px; border-radius: 8px;
font-family: inherit; font-size: 13px; outline: none;
}
.toolbar input:focus { border-color: var(--cyan); }
.chips { display: flex; gap: 6px; flex-wrap: wrap; }
.chip {
font-family: ui-monospace, monospace; font-size: 11px;
padding: 4px 10px; border-radius: 999px;
background: var(--glass); border: 1px solid var(--glass-border);
color: var(--fg-dim); cursor: pointer; user-select: none;
transition: all 120ms;
}
.chip:hover { color: var(--fg); border-color: var(--glass-border-strong); }
.chip.active {
background: rgba(34,211,238,0.18);
border-color: rgba(34,211,238,0.5);
color: var(--cyan);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 14px;
}
.tile {
background: var(--bg-1);
border: 1px solid var(--glass-border);
border-radius: 12px; overflow: hidden;
cursor: pointer;
transition: transform 180ms, border-color 180ms;
}
.tile:hover {
transform: translateY(-2px);
border-color: var(--glass-border-strong);
}
.tile-media {
position: relative;
aspect-ratio: 16/9;
overflow: hidden;
background: var(--bg-2);
}
.tile-media .static, .tile-media .animated {
position: absolute; inset: 0; width: 100%; height: 100%;
display: block;
}
.tile-media .animated {
opacity: 0;
transition: opacity 200ms;
}
.tile:hover .tile-media .animated {
opacity: 1;
}
.tile-media .timestamp {
position: absolute; bottom: 8px; right: 8px;
background: rgba(0,0,0,0.75);
color: var(--fg);
font-family: ui-monospace, monospace; font-size: 10px;
padding: 2px 6px; border-radius: 4px;
backdrop-filter: blur(4px);
}
.tile-media .play-icon {
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%) scale(0.8);
width: 44px; height: 44px;
background: rgba(0,0,0,0.6); border: 2px solid var(--cyan);
border-radius: 50%; display: flex; align-items: center; justify-content: center;
color: var(--cyan); opacity: 0;
transition: opacity 200ms, transform 200ms;
pointer-events: none;
}
.tile:hover .play-icon { opacity: 1; transform: translate(-50%, -50%) scale(1); }
.tile-meta { padding: 10px 12px; }
.tile-tags {
display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 6px;
}
.tile-tag {
font-family: ui-monospace, monospace; font-size: 10px;
padding: 2px 7px; border-radius: 4px;
background: rgba(167,139,250,0.15);
color: var(--violet);
border: 1px solid rgba(167,139,250,0.3);
}
.tile-tag.primary {
background: rgba(34,211,238,0.15);
color: var(--cyan);
border-color: rgba(34,211,238,0.4);
font-weight: 600;
}
.tile-scene {
font-size: 11px; color: var(--fg-muted);
font-family: ui-monospace, monospace;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.tile-scene .code { color: var(--fg-dim); font-weight: 600; }
/* Synthetic "thumbnail" — gradients with patterns to differentiate */
.frame {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
color: rgba(255,255,255,0.55);
font-family: ui-monospace, monospace; font-size: 10px;
text-shadow: 0 1px 2px rgba(0,0,0,0.6);
}
/* Animated preview synthesis: cycle 3 frames every 2s */
.anim-frames {
position: absolute; inset: 0;
animation: cycleFrames 2.5s steps(3, end) infinite;
}
.anim-frames > div {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
}
.anim-frames > div:nth-child(1) { transform: translateX(0%); }
.anim-frames > div:nth-child(2) { transform: translateX(100%); }
.anim-frames > div:nth-child(3) { transform: translateX(200%); }
@keyframes cycleFrames {
0%,33% { transform: translateX(0%); }
34%,66% { transform: translateX(-100%); }
67%,100% { transform: translateX(-200%); }
}
/* Container for the cycling animation */
.anim-strip {
position: absolute; inset: 0;
width: 300%; height: 100%;
display: flex;
animation: stripScroll 2.4s linear infinite;
}
.anim-strip > .frame {
position: relative; inset: auto;
flex: 0 0 33.333%;
}
@keyframes stripScroll {
0% { transform: translateX(0%); }
33% { transform: translateX(0%); }
34% { transform: translateX(-33.333%); }
66% { transform: translateX(-33.333%); }
67% { transform: translateX(-66.666%); }
99% { transform: translateX(-66.666%); }
100% { transform: translateX(0%); }
}
/* "REC" indicator on hover to make it obvious it's animated */
.anim-indicator {
position: absolute; top: 8px; left: 8px;
background: rgba(248,113,113,0.85);
color: white;
font-family: ui-monospace, monospace; font-size: 9px;
padding: 2px 6px; border-radius: 3px;
text-transform: uppercase; letter-spacing: 0.08em;
font-weight: 700;
opacity: 0;
transition: opacity 200ms;
}
.tile:hover .anim-indicator { opacity: 1; animation: pulse 1.6s infinite; }
@keyframes pulse { 50% { opacity: 0.5; } }
/* Synthetic gradient backgrounds for fake thumbnails */
.bg-room { background: linear-gradient(135deg, #4a3b2a 0%, #2a1f15 100%); }
.bg-bed { background: linear-gradient(135deg, #5b3a3a 0%, #2a1818 100%); }
.bg-shower { background: linear-gradient(135deg, #2a4a5b 0%, #15252e 100%); }
.bg-kitchen { background: linear-gradient(135deg, #3a4a3a 0%, #1a2a1a 100%); }
.bg-office { background: linear-gradient(135deg, #2a3552 0%, #131a2e 100%); }
.bg-outdoor { background: linear-gradient(135deg, #5a4a2a 0%, #2a2110 100%); }
.bg-purple { background: linear-gradient(135deg, #3d2a52 0%, #1c1430 100%); }
.bg-pink { background: linear-gradient(135deg, #5a2a4a 0%, #2e1428 100%); }
/* Frame variants — slight differences to fake motion */
.frame::before {
content: ""; position: absolute;
width: 60%; height: 60%; border-radius: 50%;
background: radial-gradient(circle at 35% 35%, rgba(255,255,255,0.18), transparent 70%);
top: 50%; left: 50%; transform: translate(-50%, -50%);
}
.frame.f1::before { transform: translate(-50%, -50%) scale(0.95); }
.frame.f2::before { transform: translate(-46%, -52%) scale(1.0); }
.frame.f3::before { transform: translate(-54%, -48%) scale(1.02); }
/* ============ SCENE DETAIL with timeline pips ============ */
.scene-detail {
background: var(--bg-1);
border: 1px solid var(--glass-border);
border-radius: 14px; overflow: hidden;
}
.player {
position: relative;
aspect-ratio: 16/9;
background: #000;
overflow: hidden;
}
.player-content {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
background: linear-gradient(135deg, #1a1a2e, #0a0a14);
}
.player-content::before {
content: "▶"; font-size: 80px; color: rgba(34,211,238,0.3);
}
.player-controls {
position: absolute; left: 0; right: 0; bottom: 0;
background: linear-gradient(to top, rgba(0,0,0,0.92) 0%, transparent 100%);
padding: 18px 16px 12px;
}
.timeline-row {
position: relative;
margin-bottom: 12px;
}
/* Pips ABOVE the seekbar */
.pips {
position: relative;
height: 22px;
margin-bottom: 4px;
}
.pip {
position: absolute;
top: 0;
width: 3px; height: 14px;
background: var(--cyan);
border-radius: 2px;
cursor: pointer;
transition: height 150ms, width 150ms, background 150ms;
box-shadow: 0 0 4px rgba(34,211,238,0.5);
}
.pip:hover { height: 22px; width: 5px; }
.pip.active { background: var(--amber); height: 22px; width: 5px; box-shadow: 0 0 8px rgba(251,191,36,0.7); }
/* Pip preview popover on hover */
.pip-tooltip {
position: absolute;
bottom: 28px;
transform: translateX(-50%);
background: var(--bg-2);
border: 1px solid var(--glass-border-strong);
border-radius: 8px;
padding: 6px;
width: 160px;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
pointer-events: none;
opacity: 0;
transition: opacity 200ms;
z-index: 10;
}
.pip:hover .pip-tooltip { opacity: 1; }
.pip-tooltip .mini-thumb {
width: 100%; aspect-ratio: 16/9;
border-radius: 4px; overflow: hidden;
position: relative;
margin-bottom: 4px;
}
.pip-tooltip .label {
font-family: ui-monospace, monospace; font-size: 10px;
color: var(--fg);
}
.pip-tooltip .label .ts {
color: var(--fg-muted); margin-left: 6px;
}
.seekbar {
position: relative;
height: 6px;
background: rgba(255,255,255,0.18);
border-radius: 3px;
cursor: pointer;
}
.seekbar .progress {
position: absolute; inset: 0;
width: 35%;
background: var(--cyan);
border-radius: 3px;
box-shadow: 0 0 8px rgba(34,211,238,0.6);
}
.seekbar .head {
position: absolute;
top: 50%; transform: translate(-50%, -50%);
left: 35%;
width: 14px; height: 14px;
background: var(--cyan);
border-radius: 50%;
box-shadow: 0 0 10px rgba(34,211,238,0.9);
}
.player-controls-row {
display: flex; align-items: center; gap: 10px;
color: var(--fg);
font-family: ui-monospace, monospace; font-size: 11px;
}
.player-controls-row .play-btn {
width: 28px; height: 28px;
border-radius: 50%;
background: var(--cyan);
color: black;
display: flex; align-items: center; justify-content: center;
font-size: 13px; cursor: pointer;
}
.timestamp-display {
color: var(--fg-dim);
}
.marker-hint {
margin-left: auto;
color: var(--fg-muted); font-size: 10px;
}
.marker-hint kbd {
background: var(--bg-3);
border: 1px solid var(--glass-border-strong);
border-radius: 3px;
padding: 1px 5px;
font-family: ui-monospace, monospace; font-size: 10px;
color: var(--cyan);
}
.scene-info {
padding: 18px;
}
.scene-info h3 { margin: 0 0 4px; font-weight: 500; }
.scene-info .scene-meta {
color: var(--fg-muted); font-family: ui-monospace, monospace;
font-size: 11px; margin-bottom: 14px;
}
.markers-tabs {
display: flex; gap: 16px; border-bottom: 1px solid var(--glass-border);
margin-bottom: 14px;
}
.markers-tabs button {
background: transparent; border: 0; color: var(--fg-muted);
font-family: inherit; font-size: 13px; padding: 6px 0;
cursor: pointer; border-bottom: 2px solid transparent;
}
.markers-tabs button.active {
color: var(--cyan); border-bottom-color: var(--cyan);
}
.marker-row {
display: grid; grid-template-columns: 110px 1fr auto;
gap: 12px; align-items: center;
padding: 8px 6px;
border-radius: 8px;
cursor: pointer;
transition: background 120ms;
}
.marker-row:hover { background: var(--glass); }
.marker-row .row-thumb {
aspect-ratio: 16/9; border-radius: 4px;
background: var(--bg-2);
position: relative; overflow: hidden;
}
.marker-row .row-meta {
display: flex; flex-direction: column; gap: 4px;
}
.marker-row .row-tags {
display: flex; gap: 4px; flex-wrap: wrap;
}
.marker-row .row-time {
font-family: ui-monospace, monospace; font-size: 11px;
color: var(--fg-muted);
}
.marker-row .row-action {
font-family: ui-monospace, monospace; font-size: 11px;
color: var(--cyan);
padding: 4px 10px;
border: 1px solid rgba(34,211,238,0.4);
border-radius: 6px;
background: rgba(34,211,238,0.1);
}
/* ============ CREATE MARKER DIALOG ============ */
.dialog-backdrop {
position: fixed; inset: 0;
background: rgba(0,0,0,0.7);
backdrop-filter: blur(4px);
display: none;
align-items: center; justify-content: center;
z-index: 100;
}
.dialog-backdrop.show { display: flex; animation: fadeIn 200ms; }
.dialog {
background: var(--bg-1);
border: 1px solid var(--glass-border-strong);
border-radius: 14px;
padding: 20px;
width: 480px;
box-shadow: 0 24px 80px rgba(0,0,0,0.6);
}
.dialog h3 {
margin: 0 0 4px; font-weight: 500; font-size: 16px;
display: flex; align-items: center; gap: 8px;
}
.dialog h3 .ts-pill {
margin-left: auto;
font-family: ui-monospace, monospace; font-size: 11px;
background: rgba(34,211,238,0.15);
color: var(--cyan);
padding: 3px 8px; border-radius: 5px;
border: 1px solid rgba(34,211,238,0.4);
}
.dialog .hint { color: var(--fg-muted); font-size: 12px; margin-bottom: 14px; }
.dialog label {
display: block; font-size: 11px;
text-transform: uppercase; letter-spacing: 0.06em;
color: var(--fg-muted); margin: 12px 0 5px;
font-weight: 600;
}
.dialog input[type="text"] {
width: 100%;
background: var(--bg-0); color: var(--fg);
border: 1px solid var(--glass-border-strong);
padding: 8px 10px; border-radius: 7px;
font-family: inherit; font-size: 13px; outline: none;
}
.dialog input[type="text"]:focus { border-color: var(--cyan); }
.tag-search-results {
margin-top: 4px;
background: var(--bg-2);
border: 1px solid var(--glass-border);
border-radius: 7px;
max-height: 140px; overflow-y: auto;
}
.tag-search-results .opt {
padding: 6px 10px;
font-family: ui-monospace, monospace; font-size: 12px;
color: var(--fg-dim);
cursor: pointer;
transition: background 120ms;
}
.tag-search-results .opt:hover {
background: var(--glass);
color: var(--fg);
}
.selected-tags {
display: flex; gap: 6px; flex-wrap: wrap;
margin-top: 6px;
}
.selected-tag {
font-family: ui-monospace, monospace; font-size: 11px;
padding: 3px 8px; border-radius: 5px;
background: rgba(167,139,250,0.15);
color: var(--violet);
border: 1px solid rgba(167,139,250,0.3);
display: flex; align-items: center; gap: 4px;
}
.selected-tag.primary {
background: rgba(34,211,238,0.15);
color: var(--cyan);
border-color: rgba(34,211,238,0.4);
}
.selected-tag .x {
cursor: pointer; opacity: 0.6;
}
.selected-tag .x:hover { opacity: 1; }
.dialog-actions {
display: flex; justify-content: flex-end; gap: 8px;
margin-top: 16px;
padding-top: 14px;
border-top: 1px solid var(--glass-border);
}
.dialog-actions button {
font-family: inherit; font-size: 13px;
padding: 7px 14px; border-radius: 7px;
cursor: pointer; transition: all 120ms;
border: 1px solid var(--glass-border-strong);
}
.btn-cancel {
background: transparent;
color: var(--fg-dim);
}
.btn-cancel:hover { color: var(--fg); }
.btn-create {
background: var(--cyan);
color: black; font-weight: 600;
border-color: var(--cyan);
}
.btn-create:hover { background: #67e8f9; }
/* Storage / data inspector */
.inspector {
margin-top: 24px;
background: var(--bg-1);
border: 1px solid var(--glass-border);
border-radius: 12px;
padding: 16px;
}
.inspector h2 {
margin-top: 0;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--cyan);
font-weight: 600;
}
.inspector pre {
background: var(--bg-0);
border: 1px solid var(--glass-border);
border-radius: 8px;
padding: 12px;
overflow-x: auto;
font-family: ui-monospace, "SF Mono", monospace;
font-size: 11.5px;
color: var(--fg-dim);
line-height: 1.6;
}
.inspector pre .key { color: var(--cyan); }
.inspector pre .str { color: var(--mint); }
.inspector pre .num { color: var(--amber); }
.inspector pre .com { color: var(--fg-muted); font-style: italic; }
.legend {
display: flex; gap: 18px;
padding: 10px 14px; background: var(--bg-1);
border: 1px solid var(--glass-border); border-radius: 10px;
margin-bottom: 18px;
font-size: 12px; color: var(--fg-dim);
}
.legend .item { display: flex; align-items: center; gap: 6px; }
.legend .swatch {
width: 12px; height: 12px; border-radius: 3px;
}
</style>
</head>
<body>
<div class="page">
<h1>Markers feature — visual mockup</h1>
<div class="sub">
Click a tab. Hover the tiles to see the static→animated swap.
Click a tile or the timeline pips to simulate seeking. Press <kbd style="background: var(--bg-2); padding: 1px 5px; border-radius: 3px; border: 1px solid var(--glass-border-strong); font-family: ui-monospace, monospace; font-size: 11px;">N</kbd> while the Scene Detail tab is active to open the create-marker dialog.
</div>
<div class="tabs" role="tablist">
<button class="active" data-view="wall">Markers Wall</button>
<button data-view="scene">Scene Detail</button>
<button data-view="data">Data Model</button>
</div>
<!-- =============== WALL =============== -->
<div class="view active" id="view-wall">
<div class="toolbar">
<input type="text" placeholder="Search markers by tag, scene, or title…">
<div class="chips">
<span class="chip active">all</span>
<span class="chip">doggy</span>
<span class="chip">missionary</span>
<span class="chip">cowgirl</span>
<span class="chip">shower</span>
<span class="chip">+12 tags</span>
</div>
</div>
<div class="legend">
<div class="item"><span class="swatch" style="background: linear-gradient(135deg, #4a3b2a, #2a1f15);"></span>static thumbnail (idle)</div>
<div class="item"><span class="swatch" style="background: rgba(248,113,113,0.85);"></span>animated preview (hover)</div>
<div class="item"><span class="swatch" style="background: var(--cyan);"></span>click → jump to timestamp</div>
</div>
<div class="grid" id="markers-grid"></div>
</div>
<!-- =============== SCENE DETAIL =============== -->
<div class="view" id="view-scene">
<div class="scene-detail">
<div class="player">
<div class="player-content"></div>
<div class="player-controls">
<div class="timeline-row">
<div class="pips" id="pips-row"></div>
<div class="seekbar">
<div class="progress"></div>
<div class="head"></div>
</div>
</div>
<div class="player-controls-row">
<div class="play-btn"></div>
<span class="timestamp-display"><span id="cur-ts">14:32</span> / 1:42:18</span>
<span class="marker-hint">Press <kbd>N</kbd> to mark this moment</span>
</div>
</div>
</div>
<div class="scene-info">
<h3>Code: <span style="color: var(--cyan);">SSIS-456</span></h3>
<div class="scene-meta">part 1 · 1080p · 1h 42m · 5 markers</div>
<div class="markers-tabs">
<button class="active">Markers (5)</button>
<button>Cast</button>
<button>Tags</button>
<button>Files</button>
</div>
<div id="markers-list"></div>
</div>
</div>
</div>
<!-- =============== DATA MODEL =============== -->
<div class="view" id="view-data">
<div class="inspector">
<h2>What a marker actually is in the database</h2>
<pre><span class="com">-- markers table — one row per labeled timestamp</span>
CREATE TABLE markers (
<span class="key">id</span> INTEGER PRIMARY KEY,
<span class="key">image_id</span> INTEGER REFERENCES images(id) ON DELETE CASCADE,
<span class="key">part_idx</span> INTEGER NOT NULL DEFAULT 0, <span class="com">-- multi-part videos</span>
<span class="key">seconds</span> REAL NOT NULL, <span class="com">-- 872.3 = 14:32</span>
<span class="key">end_seconds</span> REAL, <span class="com">-- optional, for ranges</span>
<span class="key">title</span> TEXT, <span class="com">-- optional free-text</span>
<span class="key">primary_tag_id</span> INTEGER REFERENCES tags(id),
<span class="key">created_at</span> INTEGER NOT NULL,
<span class="key">updated_at</span> INTEGER NOT NULL
);
<span class="com">-- secondary tags (many-to-many)</span>
CREATE TABLE marker_tags (
<span class="key">marker_id</span> INTEGER REFERENCES markers(id) ON DELETE CASCADE,
<span class="key">tag_id</span> INTEGER REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (marker_id, tag_id)
);</pre>
</div>
<div class="inspector">
<h2>Example row + derived files on disk</h2>
<pre><span class="com">-- row in markers table</span>
{
<span class="key">id</span>: <span class="num">142</span>,
<span class="key">image_id</span>: <span class="num">4711</span>, <span class="com">-- the cover for SSIS-456</span>
<span class="key">part_idx</span>: <span class="num">0</span>,
<span class="key">seconds</span>: <span class="num">872.3</span>, <span class="com">-- 14:32</span>
<span class="key">end_seconds</span>: null,
<span class="key">title</span>: <span class="str">"close-up · bedroom"</span>,
<span class="key">primary_tag_id</span>: <span class="num">38</span>, <span class="com">-- "doggy"</span>
<span class="key">created_at</span>: <span class="num">1735776300000</span>,
<span class="key">updated_at</span>: <span class="num">1735776300000</span>
}
<span class="com">-- secondary tags from marker_tags</span>
[ <span class="num">12</span> <span class="com">"bedroom"</span>, <span class="num">19</span> <span class="com">"close-up"</span>, <span class="num">22</span> <span class="com">"POV"</span> ]
<span class="com">-- derived assets generated by ffmpeg, keyed by marker.id</span>
data/marker-thumbs/142.webp <span class="com">-- 320×180 static, ~40 KB</span>
<span class="com"> ffmpeg -ss 872.3 -i video.mp4 -frames:v 1 -vf scale=320:-1 ...</span>
data/marker-previews/142.webp <span class="com">-- 5s animated, no audio, ~280 KB</span>
<span class="com"> ffmpeg -ss 872.3 -t 5 -i video.mp4 -vf "scale=320:-1,fps=12" -loop 0 ...</span>
<span class="com">-- both are caches. delete them and they regenerate on next request.</span>
<span class="com">-- generation is queued, not blocking — same pattern as whisperjav.</span></pre>
</div>
<div class="inspector">
<h2>Wall tile rendering — how the hover swap works</h2>
<pre>&lt;div class=<span class="str">"tile"</span>&gt;
&lt;div class=<span class="str">"tile-media"</span>&gt;
&lt;img src=<span class="str">"/api/marker-thumb/142"</span> <span class="com">// static, always loaded</span>
class=<span class="str">"static"</span>&gt;
&lt;img src=<span class="str">"/api/marker-preview/142"</span> <span class="com">// animated webp, lazy-loaded on hover</span>
class=<span class="str">"animated"</span>
loading=<span class="str">"lazy"</span>&gt;
&lt;span class=<span class="str">"timestamp"</span>&gt;14:32&lt;/span&gt;
&lt;/div&gt;
&lt;div class=<span class="str">"tile-meta"</span>&gt;
&lt;span class=<span class="str">"primary-tag"</span>&gt;doggy&lt;/span&gt;
&lt;span class=<span class="str">"scene"</span>&gt;SSIS-456 · part 1&lt;/span&gt;
&lt;/div&gt;
&lt;/div&gt;
<span class="com">// CSS handles the swap purely with hover + opacity transition.</span>
<span class="com">// No JS needed for the swap; image fetch is browser-lazy.</span></pre>
</div>
</div>
</div>
<!-- Create marker dialog -->
<div class="dialog-backdrop" id="dialog-backdrop">
<div class="dialog">
<h3>
Create marker
<span class="ts-pill" id="dialog-ts">at 14:32.300</span>
</h3>
<div class="hint">Snapshot this moment so you can find it again. ffmpeg will queue a thumbnail and a 5s animated preview after save.</div>
<label>Primary tag (required)</label>
<input type="text" id="primary-tag-input" placeholder="Type to search tags…" autocomplete="off" value="doggy">
<div class="tag-search-results" id="primary-results" style="display: none;">
<div class="opt">doggy</div>
<div class="opt">doggy reverse</div>
<div class="opt">cowgirl</div>
</div>
<label>Secondary tags (optional)</label>
<input type="text" id="secondary-tag-input" placeholder="Add more tags…" autocomplete="off">
<div class="selected-tags">
<span class="selected-tag primary">doggy <span class="x">×</span></span>
<span class="selected-tag">bedroom <span class="x">×</span></span>
<span class="selected-tag">close-up <span class="x">×</span></span>
<span class="selected-tag">POV <span class="x">×</span></span>
</div>
<label>Title (optional)</label>
<input type="text" placeholder="e.g. close-up bedroom" value="close-up · bedroom">
<label>End time (optional, for ranges)</label>
<input type="text" placeholder="leave blank for instant marker" value="">
<div class="dialog-actions">
<button class="btn-cancel" id="dialog-cancel">Cancel</button>
<button class="btn-create" id="dialog-create">Create marker</button>
</div>
</div>
</div>
<script>
// ============= DATA =============
const MARKERS = [
{ id: 1, ts: "08:14", seconds: 494, primary: "shower", tags: ["wet", "solo"], scene: "MIDD-993", part: 0, bg: "bg-shower", frames: ["▦", "▣", "▤"] },
{ id: 2, ts: "14:32", seconds: 872, primary: "doggy", tags: ["bedroom", "POV"], scene: "SSIS-456", part: 0, bg: "bg-bed", frames: ["◉", "◎", "◍"] },
{ id: 3, ts: "23:05", seconds: 1385, primary: "missionary", tags: ["bedroom"], scene: "SSIS-456", part: 0, bg: "bg-bed", frames: ["◐", "◑", "◒"] },
{ id: 4, ts: "31:48", seconds: 1908, primary: "cowgirl", tags: ["bedroom", "POV"], scene: "SSIS-456", part: 0, bg: "bg-bed", frames: ["◴", "◵", "◶"] },
{ id: 5, ts: "42:09", seconds: 2529, primary: "kitchen", tags: ["standing"], scene: "PRED-123", part: 0, bg: "bg-kitchen", frames: ["◧", "◨", "◩"] },
{ id: 6, ts: "55:21", seconds: 3321, primary: "office", tags: ["clothed"], scene: "JUFE-771", part: 0, bg: "bg-office", frames: ["◰", "◱", "◲"] },
{ id: 7, ts: "01:02", seconds: 62, primary: "outdoor", tags: ["car", "POV"], scene: "REXD-501", part: 0, bg: "bg-outdoor", frames: ["◇", "◆", "◈"] },
{ id: 8, ts: "08:55", seconds: 535, primary: "doggy", tags: ["bathroom", "wet"], scene: "ROOM-013", part: 0, bg: "bg-shower", frames: ["◔", "◕", "●"] },
{ id: 9, ts: "17:22", seconds: 1042, primary: "facial", tags: ["close-up"], scene: "TPPN-172", part: 0, bg: "bg-pink", frames: ["✦", "✧", "★"] },
{ id: 10, ts: "29:40", seconds: 1780, primary: "cowgirl", tags: ["clothed", "office"], scene: "JUFE-771", part: 0, bg: "bg-office", frames: ["▲", "△", "▴"] },
{ id: 11, ts: "11:50", seconds: 710, primary: "blowjob", tags: ["POV"], scene: "USBA-055", part: 0, bg: "bg-purple", frames: ["✸", "✺", "✹"] },
{ id: 12, ts: "47:15", seconds: 2835, primary: "missionary", tags: ["bedroom", "lingerie"], scene: "PRED-217", part: 0, bg: "bg-pink", frames: ["✿", "❀", "❁"] },
{ id: 13, ts: "33:08", seconds: 1988, primary: "shower", tags: ["solo", "wet"], scene: "HSODA-027", part: 0, bg: "bg-shower", frames: ["◌", "◍", "◎"] },
{ id: 14, ts: "01:18", seconds: 78, primary: "outdoor", tags: ["beach", "swimsuit"], scene: "REAL-768", part: 0, bg: "bg-outdoor", frames: ["⬡", "⬢", "⬣"] },
{ id: 15, ts: "22:44", seconds: 1364, primary: "doggy", tags: ["bedroom"], scene: "SHKD-889", part: 0, bg: "bg-bed", frames: ["◇", "◈", "◆"] },
{ id: 16, ts: "06:33", seconds: 393, primary: "kitchen", tags: ["domestic"], scene: "PKPD-046", part: 0, bg: "bg-kitchen", frames: ["▸", "▹", "►"] },
];
// Markers used in the Scene Detail timeline (for SSIS-456)
const SCENE_DURATION = 6138; // 1h 42m 18s = 6138 seconds
const SCENE_MARKERS = MARKERS.filter(m => m.scene === "SSIS-456").concat([
{ id: 21, ts: "37:51", seconds: 2271, primary: "facial", tags: ["close-up"], scene: "SSIS-456", part: 0, bg: "bg-pink", frames: ["✦", "✧", "★"] },
{ id: 22, ts: "59:30", seconds: 3570, primary: "shower", tags: ["wet", "solo"], scene: "SSIS-456", part: 0, bg: "bg-shower", frames: ["◐", "◑", "◒"] },
]).sort((a,b) => a.seconds - b.seconds);
// ============= RENDERING =============
function renderTile(m) {
return `
<div class="tile" data-id="${m.id}">
<div class="tile-media">
<div class="static ${m.bg}">
<div class="frame f1" style="font-size: 36px;">${m.frames[0]}</div>
</div>
<div class="animated ${m.bg}">
<div class="anim-strip">
<div class="frame f1" style="font-size: 36px;">${m.frames[0]}</div>
<div class="frame f2" style="font-size: 36px;">${m.frames[1]}</div>
<div class="frame f3" style="font-size: 36px;">${m.frames[2]}</div>
</div>
</div>
<span class="anim-indicator">● live</span>
<span class="timestamp">${m.ts}</span>
<div class="play-icon">▶</div>
</div>
<div class="tile-meta">
<div class="tile-tags">
<span class="tile-tag primary">${m.primary}</span>
${m.tags.map(t => `<span class="tile-tag">${t}</span>`).join("")}
</div>
<div class="tile-scene"><span class="code">${m.scene}</span> · part ${m.part + 1}</div>
</div>
</div>
`;
}
document.getElementById("markers-grid").innerHTML = MARKERS.map(renderTile).join("");
// Scene markers list (rows in the detail tab)
function renderRow(m) {
return `
<div class="marker-row" data-id="${m.id}">
<div class="row-thumb ${m.bg}">
<div class="frame f1" style="font-size: 22px;">${m.frames[0]}</div>
</div>
<div class="row-meta">
<div class="row-tags">
<span class="tile-tag primary">${m.primary}</span>
${m.tags.map(t => `<span class="tile-tag">${t}</span>`).join("")}
</div>
<div class="row-time">${m.ts}.000</div>
</div>
<div class="row-action">Seek</div>
</div>
`;
}
document.getElementById("markers-list").innerHTML = SCENE_MARKERS.map(renderRow).join("");
// Pips on the timeline (positioned by % of duration)
function renderPips() {
return SCENE_MARKERS.map(m => {
const pct = (m.seconds / SCENE_DURATION) * 100;
const isActive = m.ts === "14:32";
return `
<div class="pip ${isActive ? "active" : ""}" style="left: ${pct.toFixed(2)}%;" data-id="${m.id}">
<div class="pip-tooltip" style="left: 50%;">
<div class="mini-thumb ${m.bg}">
<div class="frame f1" style="font-size: 18px;">${m.frames[0]}</div>
</div>
<div class="label">
<span class="tile-tag primary" style="font-size: 9px; padding: 1px 5px;">${m.primary}</span>
<span class="ts">${m.ts}</span>
</div>
</div>
</div>
`;
}).join("");
}
document.getElementById("pips-row").innerHTML = renderPips();
// ============= TAB SWITCHING =============
document.querySelectorAll(".tabs button").forEach(btn => {
btn.addEventListener("click", () => {
document.querySelectorAll(".tabs button").forEach(b => b.classList.remove("active"));
document.querySelectorAll(".view").forEach(v => v.classList.remove("active"));
btn.classList.add("active");
document.getElementById("view-" + btn.dataset.view).classList.add("active");
});
});
// ============= CREATE MARKER DIALOG =============
const dialogBackdrop = document.getElementById("dialog-backdrop");
function openDialog(timestamp) {
document.getElementById("dialog-ts").textContent = "at " + timestamp;
dialogBackdrop.classList.add("show");
}
function closeDialog() {
dialogBackdrop.classList.remove("show");
}
document.getElementById("dialog-cancel").addEventListener("click", closeDialog);
document.getElementById("dialog-create").addEventListener("click", () => {
closeDialog();
// simulate a flash on the seekbar
const head = document.querySelector(".seekbar .head");
if (head) {
head.style.boxShadow = "0 0 20px rgba(251,191,36,0.9)";
head.style.background = "var(--amber)";
setTimeout(() => { head.style.boxShadow = ""; head.style.background = ""; }, 800);
}
});
dialogBackdrop.addEventListener("click", (e) => {
if (e.target === dialogBackdrop) closeDialog();
});
// Hotkey: N to open create-marker dialog (only when Scene Detail is active)
document.addEventListener("keydown", (e) => {
if (e.key === "n" || e.key === "N") {
const sceneActive = document.getElementById("view-scene").classList.contains("active");
if (sceneActive && !dialogBackdrop.classList.contains("show")) {
e.preventDefault();
openDialog(document.getElementById("cur-ts").textContent + ".300");
}
}
if (e.key === "Escape" && dialogBackdrop.classList.contains("show")) {
closeDialog();
}
});
// Click a pip → simulate seek (just update the timestamp display + progress)
document.querySelectorAll(".pip").forEach(pip => {
pip.addEventListener("click", (e) => {
e.stopPropagation();
document.querySelectorAll(".pip").forEach(p => p.classList.remove("active"));
pip.classList.add("active");
const m = SCENE_MARKERS.find(x => x.id == pip.dataset.id);
if (m) {
document.getElementById("cur-ts").textContent = m.ts;
const pct = (m.seconds / SCENE_DURATION) * 100;
document.querySelector(".seekbar .progress").style.width = pct + "%";
document.querySelector(".seekbar .head").style.left = pct + "%";
}
});
});
// Click a marker row → same as clicking its pip
document.querySelectorAll(".marker-row").forEach(row => {
row.addEventListener("click", () => {
const pip = document.querySelector(`.pip[data-id="${row.dataset.id}"]`);
if (pip) pip.click();
});
});
</script>
</body>
</html>