dphn/Dolphin3.0-Mistral-24B is the ungated mirror of the Dolphin 3.0 Mistral 24B — exactly what you asked for. It's ~48GB fp16, which needs GPU+CPU split (device_map="auto" with 32GB on GPU, ~16GB in RAM). Let me kick off the download and update the service in parallel.

This commit is contained in:
mike
2026-06-24 22:56:01 +02:00
parent 54d96ef580
commit ee7569f38c
5 changed files with 933 additions and 377 deletions

View File

@@ -49,3 +49,5 @@ rating based pose, thumbs up/down find good/bad poses easier
(pairs well with the new pose index — could weight similar-pose results by rating) (pairs well with the new pose index — could weight similar-pose results by rating)
when refresh page, we lose track of current jobs running. when refresh page, we lose track of current jobs running.
generating poses themself should use the adviced dimensions rather than the base image reference.

View File

@@ -1834,7 +1834,10 @@
</div> </div>
<div id="lbFaceBook" style="display:none;margin-bottom:8px"> <div id="lbFaceBook" style="display:none;margin-bottom:8px">
<div class="sb-label" style="margin-bottom:4px">Face reference</div> <div class="sb-label" style="margin-bottom:4px">Face reference</div>
<img id="lbFaceThumb" style="width:72px;height:72px;object-fit:cover;border-radius:6px;border:1px solid #333;cursor:pointer" onclick="window.open(this.src,'_blank')" title="Face crop — click to view full"> <div style="display:flex;gap:8px;align-items:flex-start">
<img id="lbFaceThumb" style="width:72px;height:72px;object-fit:cover;border-radius:6px;border:1px solid #333;cursor:pointer;flex-shrink:0" onclick="window.open(this.src,'_blank')" title="Face crop — click to view full">
<button class="sb-btn" onclick="lbFindSimilarFaces()" title="Find groups with matching faces">Similar faces</button>
</div>
</div> </div>
<div class="sb-sep"></div> <div class="sb-sep"></div>
<div class="sb-label">Order & visibility</div> <div class="sb-label">Order & visibility</div>
@@ -2968,6 +2971,86 @@
if (btn) btn.disabled = false; if (btn) btn.disabled = false;
} }
async function lbFindSimilarFaces() {
if (!lbCurrentGid) return;
try {
const r = await fetch(`${API}/faces/similar`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ group_id: lbCurrentGid, limit: 12 }),
});
if (r.status === 404) { _renderFaceResults(null); return; }
if (!r.ok) { showToast('Similar faces failed: ' + await r.text(), 'error'); return; }
const d = await r.json();
_renderFaceResults(d.similar || []);
} catch (e) { showToast('Similar faces failed: ' + e, 'error'); }
}
async function lbBuildFaceIndex() {
try {
const r = await fetch(`${API}/faces/index`, { method: 'POST' });
if (!r.ok) { showToast('Face index failed: ' + await r.text(), 'error'); return; }
showToast('Building face index…', 'info');
_pollFaceIndex();
} catch (e) { showToast('Face index failed: ' + e, 'error'); }
}
async function _pollFaceIndex() {
try {
const r = await fetch(`${API}/faces/index/status`);
if (r.ok) {
const s = await r.json();
if (s.running) {
showToast(`Face index: ${s.done}/${s.total}`, 'info', 2500);
setTimeout(_pollFaceIndex, 2000);
} else {
showToast(`Face index ready (${s.indexed} embeddings)`, 'success');
}
}
} catch (e) {}
}
function _renderFaceResults(items) {
document.getElementById('faceResults')?.remove();
const viewer = document.getElementById('studioViewer');
if (!viewer) return;
const panel = document.createElement('div');
panel.id = 'faceResults';
panel.style.cssText = 'position:absolute;left:0;right:0;bottom:0;z-index:103;background:rgba(0,0,0,0.85);'
+ 'padding:8px;display:flex;gap:8px;overflow-x:auto;align-items:center;';
if (!items || !items.length) {
const msg = items === null
? 'No face embedding for this group — build the index first'
: 'No similar faces found';
panel.innerHTML = `<span style="color:#aaa;font-size:12px;flex:1">${msg}</span>`
+ '<button class="sb-btn" onclick="lbBuildFaceIndex();document.getElementById(\'faceResults\')?.remove()">Build index</button>'
+ '<button class="sb-btn" onclick="document.getElementById(\'faceResults\')?.remove()">Close</button>';
} else {
panel.innerHTML = '<span style="color:#aaa;font-size:11px;flex-shrink:0">Similar faces:</span>'
+ items.map(it => {
const u = IMAGE_FOLDER + it.filename + '?t=' + Date.now();
const gid = (it.group_id || '').replace(/'/g, "\\'");
const fn = it.filename.replace(/'/g, "\\'");
return `<div title="dist ${it.distance}" style="flex-shrink:0;cursor:pointer;text-align:center"
onclick="openFaceResult('${gid}','${fn}')">
<img src="${u}" loading="lazy" style="width:64px;height:64px;object-fit:cover;border-radius:5px;border:1px solid #444" onerror="this.style.opacity='0.3'">
<div style="font-size:9px;color:#777">${it.distance}</div></div>`;
}).join('')
+ '<button class="sb-btn" style="flex-shrink:0" onclick="document.getElementById(\'faceResults\')?.remove()">Close</button>';
}
viewer.appendChild(panel);
}
function openFaceResult(gid, fname) {
document.getElementById('faceResults')?.remove();
if (gid && groupData.has(gid)) {
openStudio(gid, 0);
} else {
const i = lbNames.indexOf(fname);
if (i >= 0) { lbIdx = i; updateStudio(); }
else showToast('Group not found in gallery: ' + fname, 'info', 5000);
}
}
function _renderPoseResults(items) { function _renderPoseResults(items) {
document.getElementById('poseResults')?.remove(); document.getElementById('poseResults')?.remove();
const viewer = document.getElementById('studioViewer'); const viewer = document.getElementById('studioViewer');
@@ -5236,19 +5319,15 @@
html += '</div>'; html += '</div>';
} }
html += `<div class="scene-frame-preview" id="sceneFramePreview" style="${_sceneVideo||_sceneFrameBytes?'':'display:none'}"> html += `<div class="scene-frame-preview" id="sceneFramePreview" style="${_sceneVideo||_sceneFrameBytes?'':'display:none'}">
<video id="sceneVideoEl" muted preload="auto" playsinline autoplay loop <video id="sceneVideoEl" muted preload="auto" playsinline loop controls
style="${_sceneVideo&&!_sceneFrameBytes?'':'display:none'};width:100%;height:100%;object-fit:contain"></video> style="${_sceneVideo&&!_sceneFrameBytes?'':'display:none'};width:100%;height:100%;object-fit:contain"></video>
<img id="sceneFrameImg" style="${_sceneFrameBytes?'':'display:none'};width:100%;height:100%;object-fit:contain" <img id="sceneFrameImg" style="${_sceneFrameBytes?'':'display:none'};width:100%;height:100%;object-fit:contain"
${_sceneFrameBytes?`src="data:image/png;base64,${_sceneFrameBytes}"`:''}/> ${_sceneFrameBytes?`src="data:image/png;base64,${_sceneFrameBytes}"`:''}/>
<div id="sceneFrameLoading" style="display:none;position:absolute;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;font-size:11px;color:#aaa">Loading…</div>
</div> </div>
<input type="range" class="scene-scrubber" id="sceneScrubber"
min="0" max="${_sceneDuration||100}" value="0" step="0.1"
oninput="sceneSeekVideo(this.value)"
style="${_sceneVideo?'':'display:none'}">
<div id="sceneTimeLabel" style="font-size:10px;color:#555;margin-bottom:4px;${_sceneVideo?'':'display:none'}">0:00</div>
<button class="sb-btn" id="sceneExtractBtn" onclick="sceneExtractFrame()" <button class="sb-btn" id="sceneExtractBtn" onclick="sceneExtractFrame()"
style="${_sceneVideo?'':'display:none'};margin-bottom:8px">Extract frame as reference</button> style="${_sceneVideo&&!_sceneFrameBytes?'':'display:none'};margin-bottom:8px">Extract frame as reference</button>
<button class="sb-btn" id="sceneReleaseBtn" onclick="sceneReleaseFrame()"
style="${_sceneFrameBytes?'':'display:none'};margin-bottom:8px">&#10006; Change frame</button>
<div class="sb-sep"></div> <div class="sb-sep"></div>
<div class="sb-label">Or upload image</div> <div class="sb-label">Or upload image</div>
<input type="file" id="sceneUploadInput" accept="image/*" style="display:none" onchange="sceneHandleUpload(event)"> <input type="file" id="sceneUploadInput" accept="image/*" style="display:none" onchange="sceneHandleUpload(event)">
@@ -5268,10 +5347,9 @@
if (_sceneVideo) { if (_sceneVideo) {
const vid = document.getElementById('sceneVideoEl'); const vid = document.getElementById('sceneVideoEl');
vid.src = `${API}/wireframe/${encodeURIComponent(_sceneVideo)}`; vid.src = `${API}/wireframe/${encodeURIComponent(_sceneVideo)}`;
if (_sceneDuration) { vid.addEventListener('seeking', () => {
const scrubber = document.getElementById('sceneScrubber'); if (_sceneFrameBytes) sceneReleaseFrame();
if (scrubber) { scrubber.max = _sceneDuration; scrubber.step = Math.max(0.05, _sceneDuration / 500); } });
}
} }
// Cards show static poster frames; videos mount only on hover (_tplPlay). // Cards show static poster frames; videos mount only on hover (_tplPlay).
} }
@@ -5279,58 +5357,20 @@
async function sceneSelectVideo(v) { async function sceneSelectVideo(v) {
_sceneVideo = v; _sceneFrameBytes = null; _sceneVideo = v; _sceneFrameBytes = null;
renderSidebarScenery(); renderSidebarScenery();
try {
const r = await fetch(`${API}/wireframe/duration/${encodeURIComponent(v)}`);
if (r.ok) {
_sceneDuration = (await r.json()).duration || 0;
const scrubber = document.getElementById('sceneScrubber');
if (scrubber) { scrubber.max = _sceneDuration; scrubber.step = Math.max(0.05, _sceneDuration / 500); }
}
} catch (_) {}
} }
let _sceneScrubTimer = null; function sceneReleaseFrame() {
_sceneFrameBytes = null;
function sceneSeekVideo(val) {
const t = parseFloat(val);
const lbl = document.getElementById('sceneTimeLabel');
if (lbl) lbl.textContent = formatSecs(t);
// If a captured frame was locked, release it so scrubbing works again
if (_sceneFrameBytes) {
_sceneFrameBytes = null;
const btn = document.getElementById('sceneExtractBtn');
if (btn) btn.textContent = 'Extract frame as reference';
const genBtn = document.getElementById('sceneGenBtn');
if (genBtn) genBtn.disabled = !_sceneVideo;
}
// Show video live while the user is dragging
const vid = document.getElementById('sceneVideoEl'); const vid = document.getElementById('sceneVideoEl');
const img = document.getElementById('sceneFrameImg'); const img = document.getElementById('sceneFrameImg');
if (vid) { vid.style.display = ''; vid.currentTime = t; } const extractBtn = document.getElementById('sceneExtractBtn');
const releaseBtn = document.getElementById('sceneReleaseBtn');
const genBtn = document.getElementById('sceneGenBtn');
if (vid) vid.style.display = '';
if (img) img.style.display = 'none'; if (img) img.style.display = 'none';
// After settling, fetch exact server-rendered frame and freeze on it if (extractBtn) extractBtn.style.display = '';
clearTimeout(_sceneScrubTimer); if (releaseBtn) releaseBtn.style.display = 'none';
_sceneScrubTimer = setTimeout(() => _sceneShowFrame(t), 300); if (genBtn) genBtn.disabled = !_sceneVideo;
}
async function _sceneShowFrame(t) {
if (!_sceneVideo) return;
try {
const r = await fetch(`${API}/wireframe/frame`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ video_name: _sceneVideo, time: t }),
});
if (!r.ok) return;
const d = await r.json();
const vid = document.getElementById('sceneVideoEl');
const img = document.getElementById('sceneFrameImg');
if (!img) return;
if (vid) vid.style.display = 'none';
img.src = 'data:image/png;base64,' + d.frame_b64;
img.style.display = '';
img.dataset.pendingFrame = d.frame_b64; // ready to capture without re-fetch
} catch (_) { /* video stays visible on error */ }
} }
async function sceneExtractFrame() { async function sceneExtractFrame() {

View File

@@ -111,6 +111,7 @@ def migrate_schema():
"ALTER TABLE person ADD COLUMN IF NOT EXISTS content_type TEXT DEFAULT 'image'", "ALTER TABLE person ADD COLUMN IF NOT EXISTS content_type TEXT DEFAULT 'image'",
"ALTER TABLE person ADD COLUMN IF NOT EXISTS faceswap_source_video TEXT", "ALTER TABLE person ADD COLUMN IF NOT EXISTS faceswap_source_video TEXT",
"ALTER TABLE person ADD COLUMN IF NOT EXISTS archived BOOLEAN DEFAULT FALSE", "ALTER TABLE person ADD COLUMN IF NOT EXISTS archived BOOLEAN DEFAULT FALSE",
"ALTER TABLE person ADD COLUMN IF NOT EXISTS face_embedding vector(512)",
]: ]:
cur.execute(sql) cur.execute(sql)
conn.commit() conn.commit()
@@ -122,16 +123,18 @@ def upsert_person(filename, filepath=None, name=None, group_id=None, tags=None,
embedding=None, clip_description=None, prompt=None, pose=None, embedding=None, clip_description=None, prompt=None, pose=None,
sort_order=None, group_name=None, hidden=None, sort_order=None, group_name=None, hidden=None,
has_background=None, source_refs=None, has_clothing=None, has_background=None, source_refs=None, has_clothing=None,
content_type=None, faceswap_source_video=None, archived=None): content_type=None, faceswap_source_video=None, archived=None,
face_embedding=None):
conn = get_db_connection() conn = get_db_connection()
cur = conn.cursor() cur = conn.cursor()
face_embedding_str = ("[" + ",".join(map(str, face_embedding)) + "]") if face_embedding is not None else None
try: try:
cur.execute(""" cur.execute("""
INSERT INTO person (filename, filepath, name, group_id, tags, embedding, INSERT INTO person (filename, filepath, name, group_id, tags, embedding,
clip_description, prompt, pose, sort_order, group_name, hidden, clip_description, prompt, pose, sort_order, group_name, hidden,
has_background, source_refs, has_clothing, has_background, source_refs, has_clothing,
content_type, faceswap_source_video, archived) content_type, faceswap_source_video, archived, face_embedding)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (filename) DO UPDATE ON CONFLICT (filename) DO UPDATE
SET filepath = COALESCE(EXCLUDED.filepath, person.filepath), SET filepath = COALESCE(EXCLUDED.filepath, person.filepath),
name = COALESCE(EXCLUDED.name, person.name), name = COALESCE(EXCLUDED.name, person.name),
@@ -149,12 +152,13 @@ def upsert_person(filename, filepath=None, name=None, group_id=None, tags=None,
has_clothing = COALESCE(EXCLUDED.has_clothing, person.has_clothing), has_clothing = COALESCE(EXCLUDED.has_clothing, person.has_clothing),
content_type = COALESCE(EXCLUDED.content_type, person.content_type), content_type = COALESCE(EXCLUDED.content_type, person.content_type),
faceswap_source_video = COALESCE(EXCLUDED.faceswap_source_video, person.faceswap_source_video), faceswap_source_video = COALESCE(EXCLUDED.faceswap_source_video, person.faceswap_source_video),
archived = COALESCE(EXCLUDED.archived, person.archived); archived = COALESCE(EXCLUDED.archived, person.archived),
face_embedding = COALESCE(EXCLUDED.face_embedding, person.face_embedding);
""", (filename, filepath, name, group_id, """, (filename, filepath, name, group_id,
json.dumps(tags) if tags else None, json.dumps(tags) if tags else None,
embedding, clip_description, prompt, pose, sort_order, group_name, hidden, embedding, clip_description, prompt, pose, sort_order, group_name, hidden,
has_background, source_refs, has_clothing, has_background, source_refs, has_clothing,
content_type, faceswap_source_video, archived)) content_type, faceswap_source_video, archived, face_embedding_str))
conn.commit() conn.commit()
finally: finally:
cur.close() cur.close()
@@ -330,3 +334,55 @@ def get_all_group_names():
finally: finally:
cur.close() cur.close()
_put_db_connection(conn) _put_db_connection(conn)
def get_face_embedding(filename):
"""Return the face_embedding as a list of floats for a filename, or None."""
conn = get_db_connection()
cur = conn.cursor()
try:
cur.execute("SELECT face_embedding FROM person WHERE filename = %s", (filename,))
row = cur.fetchone()
if row and row[0] is not None:
val = row[0]
# psycopg2 without a pgvector adapter returns vectors as plain strings "[f,f,...]"
if isinstance(val, str):
return [float(x) for x in val.strip("[]").split(",")]
return list(val)
return None
finally:
cur.close()
_put_db_connection(conn)
def search_similar_face(embedding, limit=12, exclude_group_id=None):
"""Cosine search on face_embedding (stored only for *_face.png rows).
Returns [(filename, group_id, distance), ...] sorted ascending by distance.
Rows belonging to exclude_group_id are skipped so a group doesn't match itself.
"""
conn = get_db_connection()
cur = conn.cursor()
try:
embedding_str = "[" + ",".join(map(str, embedding)) + "]"
if exclude_group_id:
cur.execute("""
SELECT filename, group_id, face_embedding <=> %s AS distance
FROM person
WHERE face_embedding IS NOT NULL
AND (group_id IS NULL OR group_id != %s)
ORDER BY distance ASC
LIMIT %s
""", (embedding_str, exclude_group_id, limit))
else:
cur.execute("""
SELECT filename, group_id, face_embedding <=> %s AS distance
FROM person
WHERE face_embedding IS NOT NULL
ORDER BY distance ASC
LIMIT %s
""", (embedding_str, limit))
return cur.fetchall()
finally:
cur.close()
_put_db_connection(conn)

View File

@@ -1486,6 +1486,7 @@ def list_videos():
@app.get("/wireframe/frame/{video_name}") @app.get("/wireframe/frame/{video_name}")
def wireframe_frame(video_name: str, t: float = 0.5): def wireframe_frame(video_name: str, t: float = 0.5):
"""Extract a single frame at normalized time t (01) from a wireframe video. Returns PNG.""" """Extract a single frame at normalized time t (01) from a wireframe video. Returns PNG."""
import cv2
wireframe_dir = _load_wireframe_dir() wireframe_dir = _load_wireframe_dir()
video_path = os.path.join(wireframe_dir, video_name) video_path = os.path.join(wireframe_dir, video_name)
if not os.path.exists(video_path): if not os.path.exists(video_path):
@@ -1916,10 +1917,12 @@ def _extract_face_bg(filename: str, fpath: str):
face_fname = f"{gid_tag}_face.png" face_fname = f"{gid_tag}_face.png"
face_path = os.path.join(os.path.dirname(fpath), face_fname) face_path = os.path.join(os.path.dirname(fpath), face_fname)
cropped.save(face_path) cropped.save(face_path)
face_embed = face.normed_embedding.tolist() if hasattr(face, 'normed_embedding') and face.normed_embedding is not None else None
database.upsert_person(face_fname, filepath=face_path, group_id=group_id, database.upsert_person(face_fname, filepath=face_path, group_id=group_id,
name=person[0] if person else None, name=person[0] if person else None,
source_refs=json.dumps([filename])) source_refs=json.dumps([filename]),
print(f"[extract-face] saved {face_fname}") face_embedding=face_embed)
print(f"[extract-face] saved {face_fname}" + (" + face embedding" if face_embed else ""))
except Exception as e: except Exception as e:
print(f"[extract-face] error for {filename}: {e}") print(f"[extract-face] error for {filename}: {e}")
@@ -2120,6 +2123,88 @@ def extract_face_endpoint(filename: str):
return {"status": "queued", "filename": filename} return {"status": "queued", "filename": filename}
class FaceSimilarRequest(BaseModel):
group_id: str
limit: int = 12
@app.post("/faces/similar")
def face_similar(req: FaceSimilarRequest):
"""Find groups with visually similar faces using insightface embeddings.
Looks up the face embedding stored for {group_id}_face.png and returns
the top-N closest matches from other groups.
"""
face_fname = f"{req.group_id.replace('/', '_')}_face.png"
embedding = database.get_face_embedding(face_fname)
if embedding is None:
raise HTTPException(404, "No face embedding found for this group — set a preferred image first")
rows = database.search_similar_face(embedding, limit=req.limit, exclude_group_id=req.group_id)
# Each row is (filename, group_id, distance). Return the group thumbnail filename
# (the _face.png itself) so the frontend can render it directly.
results = [
{"filename": r[0], "group_id": r[1], "distance": round(float(r[2]), 4)}
for r in rows
]
return {"similar": results}
_face_index_status: dict = {"running": False, "done": 0, "total": 0, "indexed": 0}
def _face_index_worker():
"""Backfill face embeddings for all *_face.png files that lack one."""
global _face_index_status
output_dir = _load_output_dir()
face_files = [f for f in os.listdir(output_dir) if f.endswith("_face.png")]
_face_index_status.update({"running": True, "done": 0, "total": len(face_files), "indexed": 0})
try:
import cv2
app_fa, _ = _load_faceswapper()
except Exception as e:
print(f"[face-index] failed to load insightface: {e}")
_face_index_status["running"] = False
return
indexed = 0
for i, fname in enumerate(face_files):
existing = database.get_face_embedding(fname)
if existing is not None:
_face_index_status["done"] = i + 1
continue
fpath = os.path.join(output_dir, fname)
try:
bgr = cv2.imread(fpath)
if bgr is None:
continue
faces = app_fa.get(bgr)
if not faces:
continue
face = max(faces, key=lambda f: (f.bbox[2] - f.bbox[0]) * (f.bbox[3] - f.bbox[1]))
if not hasattr(face, 'normed_embedding') or face.normed_embedding is None:
continue
database.upsert_person(fname, face_embedding=face.normed_embedding.tolist())
indexed += 1
except Exception as e:
print(f"[face-index] {fname}: {e}")
_face_index_status.update({"done": i + 1, "indexed": indexed})
_face_index_status["running"] = False
print(f"[face-index] done: {indexed}/{len(face_files)} embeddings stored")
@app.post("/faces/index")
def build_face_index():
if _face_index_status.get("running"):
return {"status": "already_running", **_face_index_status}
threading.Thread(target=_face_index_worker, daemon=True).start()
return {"status": "started"}
@app.get("/faces/index/status")
def face_index_status():
return _face_index_status
@app.get("/faces/{group_id}") @app.get("/faces/{group_id}")
def face_status(group_id: str): def face_status(group_id: str):
"""Report whether a face crop exists for a group. """Report whether a face crop exists for a group.

File diff suppressed because it is too large Load Diff