feat(core): notification inbox infinite scroll + command palette fix (+ pending WIP)

消息中心:全量渲染 → 真·后端分页滚动加载
- backend(ops/views): NotificationPagination(10/页,page_size 可覆盖)+
  响应回 type_counts(按收件人绝对计数,不受分页/搜索影响)
- frontend(messages): 自管分页,滚到底加载下一批;tab/搜索走服务端并重置到第1页;
  代号作废在途旧请求防切换卡空白;乐观标已读;「已加载 X / Y」分母用当前筛选总数
- api/App/types: listNotifications 支持 page/page_size/search;allNotifications 携带 type_counts

命令面板(侧边栏搜索):修复点开后 UI 错位
- app-shell: 遮罩 className 漏了基类 shell-command-bg(只有 .show)致无定位塌到左下;
  补回基类 + header 类名对齐 .shell-command-h
- messages-page.css: 工作台收进视口高度,收件箱在面板内滚动

本次提交一并带入此前若干未提交 WIP(account/ai-tools/library/pipeline/products/settings +
accounts/ai/assets/billing/projects 后端),按用户要求整体推 dev。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-10 09:37:41 +08:00
co-authored by Claude Opus 4.8
parent aa4bdeac83
commit 3fac38c5ef
29 changed files with 724 additions and 150 deletions
+49 -6
View File
@@ -44,6 +44,19 @@ def _download_asset_primary_file(asset, target_path: Path) -> None:
target_path.write_bytes(response.content)
def _has_audio_stream(path: Path) -> bool:
"""探测视频文件是否含音频流(决定是否保留该片段的原声/人声)。ffprobe 失败时保守按无声处理。"""
try:
proc = subprocess.run(
["ffprobe", "-v", "error", "-select_streams", "a", "-show_entries",
"stream=index", "-of", "csv=p=0", str(path)],
capture_output=True, timeout=60,
)
return bool(proc.stdout.strip())
except Exception: # noqa: BLE001
return False
def _load_font(size: int):
from PIL import ImageFont
@@ -123,9 +136,14 @@ def _output_starts(specs: list[dict], xfade: float) -> tuple[list[float], float]
return starts, max(0.1, total)
_AFMT = "aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo"
def _build_export_command(*, n: int, specs: list[dict], starts: list[float], total: float,
transition: str, sub_overlays: list[tuple[str, float, float]],
bgm_name: str | None, bgm_volume: float) -> list[str]:
bgm_name: str | None, bgm_volume: float,
has_audio: list[bool] | None = None) -> list[str]:
has_audio = has_audio or [False] * n
parts: list[str] = []
for i, s in enumerate(specs):
parts.append(
@@ -153,8 +171,30 @@ def _build_export_command(*, n: int, specs: list[dict], starts: list[float], tot
f"[{vlabel}][{idx}:v]overlay=x=(W-w)/2:y=H-h-150:enable='between(t,{start:.3f},{end:.3f})'[{out}]"
)
vlabel = out
if bgm_name:
parts.append(f"[{n}:a]volume={bgm_volume:.3f},atrim=0:{total:.3f},asetpts=PTS-STARTPTS[aout]")
# 音频:片段自带的人声/原声必须保留(有声片段取原音轨,无声片段补等长静音,否则 concat 会缺流);
# 若另挂了 BGM,则把 BGM 混到原声之上(amix,normalize=0 不自动衰减原声音量)。
# 三种片段全无声且无 BGM 时,保持旧行为=纯视频不带音轨。
want_audio = any(has_audio) or bool(bgm_name)
audio_label: str | None = None
if want_audio:
for i, s in enumerate(specs):
if has_audio[i]:
parts.append(
f"[{i}:a]atrim=start={s['ts']:.3f}:end={s['te']:.3f},asetpts=PTS-STARTPTS,{_AFMT}[a{i}]"
)
else:
parts.append(
f"anullsrc=channel_layout=stereo:sample_rate=44100,atrim=0:{s['dur']:.3f},"
f"asetpts=PTS-STARTPTS,{_AFMT}[a{i}]"
)
parts.append("".join(f"[a{i}]" for i in range(n)) + f"concat=n={n}:v=0:a=1[avoice0]")
parts.append(f"[avoice0]atrim=0:{total:.3f},asetpts=PTS-STARTPTS[avoice]")
audio_label = "avoice"
if bgm_name:
parts.append(f"[{n}:a]volume={bgm_volume:.3f},atrim=0:{total:.3f},asetpts=PTS-STARTPTS,{_AFMT}[abgm]")
parts.append("[avoice][abgm]amix=inputs=2:duration=longest:dropout_transition=0:normalize=0[aout]")
audio_label = "aout"
cmd = ["ffmpeg", "-y"]
for i in range(n):
@@ -164,10 +204,10 @@ def _build_export_command(*, n: int, specs: list[dict], starts: list[float], tot
for png, _s, _e in sub_overlays:
cmd += ["-loop", "1", "-i", png]
cmd += ["-filter_complex", ";".join(parts), "-map", f"[{vlabel}]"]
if bgm_name:
cmd += ["-map", "[aout]"]
if audio_label:
cmd += ["-map", f"[{audio_label}]"]
cmd += ["-c:v", "libx264", "-pix_fmt", "yuv420p", "-r", "30", "-preset", "veryfast"]
if bgm_name:
if audio_label:
cmd += ["-c:a", "aac", "-b:a", "192k"]
cmd += ["-t", f"{total:.3f}", "-movflags", "+faststart", "output.mp4"]
return cmd
@@ -237,6 +277,8 @@ def run_export_job(export_job_id: str) -> ExportJob:
tmp = Path(tmp_dir)
for index, clip in enumerate(clips):
_download_asset_primary_file(clip.asset, tmp / f"clip{index}.mp4")
# 逐片段探测是否自带音轨:有声→保留原声,无声→补静音(见 _build_export_command)
has_audio = [_has_audio_stream(tmp / f"clip{index}.mp4") for index in range(len(clips))]
bgm_name = None
if bgm_track is not None and bgm_track.asset_id:
@@ -258,6 +300,7 @@ def run_export_job(export_job_id: str) -> ExportJob:
command = _build_export_command(
n=len(clips), specs=specs, starts=starts, total=total, transition=transition,
sub_overlays=sub_overlays, bgm_name=bgm_name, bgm_volume=(bgm_track.volume / 100.0) if bgm_track else 1.0,
has_audio=has_audio,
)
proc = subprocess.run(command, cwd=str(tmp), capture_output=True)
if proc.returncode != 0: