Skip to content

Commit 5c0a580

Browse files
committed
release: v0.8.10 修复瀑布流抖动与媒体尺寸元数据
1 parent 17cf854 commit 5c0a580

5 files changed

Lines changed: 255 additions & 20 deletions

File tree

docs/release_notes.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,15 @@
33
## Unreleased
44
- 待补充
55

6+
## v0.8.10 (2026-02-22)
7+
- 修复 Library/Favorites 的 Quick Viewer 关闭后页面滚动锁死问题:补齐 body 滚动状态恢复逻辑,确保关闭弹层后列表可继续滚动。
8+
- Library API 增强媒体尺寸返回:`/api/library/items` 新增 `width` / `height` 字段,并在 `~/.tiklocal/metadata.json` 统一缓存图片与视频尺寸信息。
9+
- 尺寸探测策略优化:图片使用 Pillow 读取,视频使用 ffprobe 读取;缓存命中后直接复用,减少重复探测开销。
10+
- 修复图片 AI 元数据写入覆盖风险:生成标题/标签时改为 merge 写回,保留 `media_meta` 等已有字段。
11+
- Library/Favorites 瀑布流渲染升级为固定列最短列分发引擎,替代 CSS 多列自动流,降低滚动加载时右侧列反复跳动与回流重排。
12+
- 新增瀑布流响应式重排策略:仅在窗口变化时防抖重排,保持无限加载与 Quick Viewer 索引一致性。
13+
- 更新 `tests/test_library_upgrade.py`,补充瀑布流脚本标记与 `width`/`height` 字段断言,覆盖关键回归点。
14+
615
## v0.8.9 (2026-02-22)
716
- 信息架构升级:底部导航调整为 `Flow / Library / Favorites / Download / Settings`,其中 `Library` 成为视频+图片统一入口,`Favorites` 独立为一级入口。
817
- Library 交互重构为极简 Masonry:移除顶部传统筛选表单与卡片冗余文本,仅保留媒体本身的沉浸式浏览。

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "TikLocal"
3-
version = "0.8.9"
3+
version = "0.8.10"
44
description = "A local media server that combines the features of TikTok and Pinterest"
55
authors = ["ChanMo <chan.mo@outlook.com>"]
66
readme = "README.md"

tests/test_library_upgrade.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ def test_library_page_has_mode_tabs_and_no_masonry_label(client):
4747
assert "id=\"quick-play-status\"" in body
4848
assert "#quick-view.immersive .quick-caption-panel" in body
4949
assert "image-focus" not in body
50+
assert "waterfall-col" in body
51+
assert "scheduleWaterfallRelayout" in body
5052
assert "flow_ui_shared.js" in body
5153
assert "flow_state_controller.js" in body
5254
assert "createFlowStateController(" in body
@@ -73,6 +75,7 @@ def test_api_library_items_supports_modes_and_seed(client):
7375
all_data = all_res.get_json()
7476
assert all_data["success"] is True
7577
all_items = all_data["data"]["items"]
78+
assert all("width" in item and "height" in item for item in all_items)
7679
assert any(item["type"] == "video" for item in all_items)
7780
assert any(item["type"] == "image" for item in all_items)
7881

tiklocal/app.py

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33
import json
44
import random
55
import datetime
6+
import subprocess as sp
67
from urllib.parse import quote, unquote
78
from importlib.metadata import version, PackageNotFoundError
89
from pathlib import Path
910

1011
from flask import Flask, render_template, send_from_directory, request, redirect, send_file
12+
from PIL import Image
1113

1214
# Service Imports
1315
from tiklocal.services import LibraryService, FavoriteService, RecommendService, IMAGE_EXTENSIONS
@@ -272,9 +274,88 @@ def _collect_library_records(*, favorites_only: bool = False) -> list[dict]:
272274
records.sort(key=lambda item: (item['mtime_ts'], item['name']), reverse=True)
273275
return records
274276

277+
def _read_media_dims_from_metadata(name: str) -> tuple[int | None, int | None]:
278+
payload = metadata_store.get(name)
279+
if not isinstance(payload, dict):
280+
return None, None
281+
media_meta = payload.get('media_meta')
282+
if not isinstance(media_meta, dict):
283+
return None, None
284+
try:
285+
width = int(media_meta.get('width') or 0)
286+
height = int(media_meta.get('height') or 0)
287+
except (TypeError, ValueError):
288+
return None, None
289+
if width <= 0 or height <= 0:
290+
return None, None
291+
return width, height
292+
293+
def _save_media_dims_to_metadata(name: str, media_type: str, width: int, height: int) -> None:
294+
if width <= 0 or height <= 0:
295+
return
296+
current = metadata_store.get(name)
297+
payload = dict(current) if isinstance(current, dict) else {}
298+
payload['media_meta'] = {
299+
'type': media_type,
300+
'width': int(width),
301+
'height': int(height),
302+
'updated_at': datetime.datetime.utcnow().isoformat() + 'Z',
303+
}
304+
metadata_store.set(name, payload, overwrite=True)
305+
306+
def _probe_media_dims(name: str, media_type: str) -> tuple[int | None, int | None]:
307+
target = library_service.resolve_path(name)
308+
if not target or not target.exists():
309+
return None, None
310+
311+
if media_type == 'image':
312+
try:
313+
with Image.open(target) as img:
314+
width, height = img.size
315+
if int(width) > 0 and int(height) > 0:
316+
return int(width), int(height)
317+
except Exception:
318+
return None, None
319+
return None, None
320+
321+
try:
322+
cmd = [
323+
'ffprobe',
324+
'-v', 'error',
325+
'-select_streams', 'v:0',
326+
'-show_entries', 'stream=width,height',
327+
'-of', 'json',
328+
str(target),
329+
]
330+
proc = sp.run(cmd, capture_output=True, text=True, timeout=8)
331+
if proc.returncode != 0:
332+
return None, None
333+
payload = json.loads(proc.stdout or '{}')
334+
streams = payload.get('streams') or []
335+
if not streams:
336+
return None, None
337+
stream = streams[0] if isinstance(streams[0], dict) else {}
338+
width = int(stream.get('width') or 0)
339+
height = int(stream.get('height') or 0)
340+
if width > 0 and height > 0:
341+
return width, height
342+
except Exception:
343+
return None, None
344+
return None, None
345+
346+
def _get_or_probe_media_dims(name: str, media_type: str) -> tuple[int | None, int | None]:
347+
width, height = _read_media_dims_from_metadata(name)
348+
if width and height:
349+
return width, height
350+
width, height = _probe_media_dims(name, media_type)
351+
if width and height:
352+
_save_media_dims_to_metadata(name, media_type, width, height)
353+
return width, height
354+
275355
def _serialize_library_item(record: dict) -> dict:
276356
name = str(record.get('name') or '')
277357
media_type = str(record.get('media_type') or 'video')
358+
width, height = _get_or_probe_media_dims(name, media_type)
278359
encoded = quote(name)
279360
media_url = f"/media?uri={encoded}"
280361
return {
@@ -285,6 +366,8 @@ def _serialize_library_item(record: dict) -> dict:
285366
'thumb_url': media_url if media_type == 'image' else f"/thumb?uri={encoded}",
286367
'mtime_ts': float(record.get('mtime_ts') or 0),
287368
'size_bytes': int(record.get('size_bytes') or 0),
369+
'width': int(width) if width else None,
370+
'height': int(height) if height else None,
288371
}
289372

290373
def _apply_library_mode(records: list[dict], *, mode: str, min_mb: int, seed: str) -> list[dict]:
@@ -895,8 +978,10 @@ def api_image_metadata():
895978
result['prompt_source'] = prompt_source
896979
result['llm_source'] = llm_source
897980
result.setdefault('prompt_hash', compute_prompt_hash(effective_prompt))
898-
metadata_store.set(uri, result, overwrite=True)
899-
return {'success': True, 'data': result}
981+
merged = dict(existing) if isinstance(existing, dict) else {}
982+
merged.update(result)
983+
metadata_store.set(uri, merged, overwrite=True)
984+
return {'success': True, 'data': merged}
900985
except Exception as e:
901986
return {'success': False, 'error': str(e)}, 500
902987

0 commit comments

Comments
 (0)