Initial commit
This commit is contained in:
92
tools/blog/README.md
Normal file
92
tools/blog/README.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# 知乎博客HTML解析工具
|
||||
|
||||
## 功能说明
|
||||
|
||||
该工具用于将下载的知乎博客HTML文件解析为标准Markdown格式,方便后续编辑和管理。
|
||||
|
||||
- 支持提取文章标题
|
||||
- 支持提取正文内容(段落、图片、代码块等)
|
||||
- 支持将HTML元素转换为对应的Markdown格式
|
||||
- 自动生成同名的.md文件
|
||||
|
||||
## 安装依赖
|
||||
|
||||
在使用前,需要安装以下依赖库:
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
依赖库说明:
|
||||
- `beautifulsoup4` - 用于解析HTML结构
|
||||
- `lxml` - 作为BeautifulSoup的解析器
|
||||
- `markdownify` - 用于将HTML转换为Markdown
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 基本用法
|
||||
|
||||
```bash
|
||||
python parse_blog.py <html_file_path>
|
||||
```
|
||||
|
||||
例如:
|
||||
|
||||
```bash
|
||||
python parse_blog.py "(6 封私信 _ 14 条消息) 高质量Mesh体积光渲染 - 知乎.html"
|
||||
```
|
||||
|
||||
### 输出结果
|
||||
|
||||
执行命令后,工具会在同一目录下生成同名的.md文件,例如:
|
||||
- 输入:`(6 封私信 _ 14 条消息) 高质量Mesh体积光渲染 - 知乎.html`
|
||||
- 输出:`(6 封私信 _ 14 条消息) 高质量Mesh体积光渲染 - 知乎.md`
|
||||
|
||||
## 支持的元素
|
||||
|
||||
- 标题(h1-h6)
|
||||
- 段落(p)
|
||||
- 图片(img)
|
||||
- 代码块(pre code)
|
||||
- 引用块(blockquote)
|
||||
- 列表(ul, ol)
|
||||
- 链接(a)
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 该工具仅支持解析知乎博客HTML文件,其他网站的HTML可能无法正确解析
|
||||
2. 为了获得最佳解析效果,建议使用浏览器的"保存页面为HTML"功能下载完整的HTML文件
|
||||
3. 解析过程中可能会遇到一些特殊元素无法完全转换,此时会使用默认处理方式
|
||||
|
||||
## 故障排除
|
||||
|
||||
如果遇到解析失败的情况,可以尝试以下方法:
|
||||
|
||||
1. 确保HTML文件是完整的,包含所有必要的结构
|
||||
2. 检查是否已正确安装所有依赖库
|
||||
3. 查看命令行输出的错误信息,根据提示进行修复
|
||||
|
||||
## 示例
|
||||
|
||||
### 输入输出示例
|
||||
|
||||
**输入**:知乎HTML文件
|
||||
**输出**:
|
||||
|
||||
```markdown
|
||||
# 高质量Mesh体积光渲染
|
||||
|
||||
SpotLight是Unity里面常用的灯光类型,我们渲染它的时候按照生活常识来说需要渲染两个部分,一个是照亮东西的效果,如下图:
|
||||
|
||||

|
||||
|
||||
另外一个当然就是舞台上经常看到的光柱效果,学名叫体积光,如下图所示:
|
||||
|
||||

|
||||
|
||||
这篇文章就主要讲一下如何实现高质量的体积光效果。
|
||||
```
|
||||
|
||||
## 许可证
|
||||
|
||||
本工具采用MIT许可证,可自由使用和修改。
|
||||
160
tools/blog/parse_blog.py
Normal file
160
tools/blog/parse_blog.py
Normal file
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
知乎博客HTML解析为Markdown工具
|
||||
|
||||
功能:将下载的知乎HTML文件解析为标准Markdown格式
|
||||
使用:python parse_blog.py <html_file_path>
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from bs4 import BeautifulSoup
|
||||
from markdownify import markdownify as md
|
||||
|
||||
|
||||
def parse_zhihu_blog(html_file):
|
||||
"""
|
||||
解析知乎博客HTML文件并转换为Markdown
|
||||
|
||||
Args:
|
||||
html_file: HTML文件路径
|
||||
|
||||
Returns:
|
||||
str: 转换后的Markdown内容
|
||||
"""
|
||||
try:
|
||||
# 读取HTML文件
|
||||
with open(html_file, 'r', encoding='utf-8') as f:
|
||||
html_content = f.read()
|
||||
|
||||
# 解析HTML
|
||||
soup = BeautifulSoup(html_content, 'lxml')
|
||||
|
||||
# 提取标题
|
||||
title = ""
|
||||
title_tag = soup.find('h1', class_='Post-Title')
|
||||
if not title_tag:
|
||||
title_tag = soup.find('h1')
|
||||
if title_tag:
|
||||
title = title_tag.get_text(strip=True)
|
||||
|
||||
# 提取正文内容
|
||||
content = ""
|
||||
|
||||
# 知乎文章正文的常见容器
|
||||
content_containers = [
|
||||
soup.find('div', class_='Post-RichTextContainer'),
|
||||
soup.find('div', class_='RichText ztext Post-RichText'),
|
||||
soup.find('article', class_='Post-content'),
|
||||
soup.find('div', class_='Post-content')
|
||||
]
|
||||
|
||||
# 尝试找到第一个有效的内容容器
|
||||
content_container = None
|
||||
for container in content_containers:
|
||||
if container:
|
||||
content_container = container
|
||||
break
|
||||
|
||||
# 如果找到了内容容器,提取内容
|
||||
if content_container:
|
||||
# 预处理:移除不需要的元素
|
||||
for element in content_container.find_all(['script', 'style', 'iframe', 'noscript']):
|
||||
element.decompose()
|
||||
|
||||
# 处理图片路径
|
||||
for img in content_container.find_all('img'):
|
||||
if 'src' in img.attrs:
|
||||
src = img['src']
|
||||
# 处理相对路径
|
||||
if src.startswith('./'):
|
||||
# 保持相对路径不变
|
||||
pass
|
||||
# 处理绝对路径
|
||||
elif src.startswith('http'):
|
||||
# 保持绝对路径不变
|
||||
pass
|
||||
|
||||
# 处理链接
|
||||
for a in content_container.find_all('a'):
|
||||
if 'href' in a.attrs:
|
||||
href = a['href']
|
||||
# 处理知乎内部链接
|
||||
if href.startswith('/'):
|
||||
a['href'] = f"https://www.zhihu.com{href}"
|
||||
|
||||
# 转换为Markdown
|
||||
content = md(str(content_container),
|
||||
heading_style="ATX",
|
||||
code_language="",
|
||||
wrap_width=0)
|
||||
else:
|
||||
# 如果没有找到特定容器,尝试提取所有p标签内容
|
||||
paragraphs = soup.find_all('p')
|
||||
content = "\n".join([p.get_text(strip=True) for p in paragraphs])
|
||||
|
||||
# 后处理:清理Markdown内容
|
||||
# 移除多余的空行
|
||||
content = '\n'.join([line for line in content.split('\n') if line.strip() or line == ''])
|
||||
# 清理重复的换行
|
||||
while '\n\n\n' in content:
|
||||
content = content.replace('\n\n\n', '\n\n')
|
||||
|
||||
# 组合标题和内容
|
||||
markdown_content = f"# {title}\n\n{content}"
|
||||
|
||||
return markdown_content
|
||||
|
||||
except Exception as e:
|
||||
print(f"解析出错: {e}")
|
||||
return ""
|
||||
|
||||
|
||||
def save_markdown(content, output_file):
|
||||
"""
|
||||
保存Markdown内容到文件
|
||||
|
||||
Args:
|
||||
content: Markdown内容
|
||||
output_file: 输出文件路径
|
||||
"""
|
||||
try:
|
||||
with open(output_file, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
print(f"Markdown文件已保存: {output_file}")
|
||||
except Exception as e:
|
||||
print(f"保存文件出错: {e}")
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
主函数
|
||||
"""
|
||||
if len(sys.argv) != 2:
|
||||
print("使用方法: python parse_blog.py <html_file_path>")
|
||||
sys.exit(1)
|
||||
|
||||
html_file = sys.argv[1]
|
||||
|
||||
if not os.path.exists(html_file):
|
||||
print(f"文件不存在: {html_file}")
|
||||
sys.exit(1)
|
||||
|
||||
# 生成输出文件路径
|
||||
base_name = os.path.splitext(html_file)[0]
|
||||
output_file = f"{base_name}.md"
|
||||
|
||||
# 解析HTML并转换为Markdown
|
||||
print(f"正在解析: {html_file}")
|
||||
markdown_content = parse_zhihu_blog(html_file)
|
||||
|
||||
if markdown_content:
|
||||
# 保存为Markdown文件
|
||||
save_markdown(markdown_content, output_file)
|
||||
else:
|
||||
print("解析失败,无法生成Markdown文件")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
3
tools/blog/requirements.txt
Normal file
3
tools/blog/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
beautifulsoup4
|
||||
lxml
|
||||
markdownify
|
||||
15
tools/blog/test_output.md
Normal file
15
tools/blog/test_output.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# SIGGRAPH 2025papers on the web
|
||||
|
||||
Page maintained byKe-Sen Huang.
|
||||
If you have additions or changes, send ane-mail.
|
||||
Information here is provided with the permission of the ACM
|
||||
Note that when possible I link to the page containing the link to the actual PDF or PS of the preprint.
|
||||
I prefer this as it gives some context to the paper and avoids possible copyright problems with direct linking.
|
||||
Thus you may need to search on the page to find the actual document.
|
||||
ACM Digital Library:ACM Transactions on Graphics (TOG) Volume 44, Issue 4 (July 2025) Proceedings of ACM SIGGRAPH 2025
|
||||
SIG/TOG:Journal Paper for presentation at SIGGRAPH 2025
|
||||
SIG:Conference Paper for presentation at SIGGRAPH 2025
|
||||
TOG:Selected ACM TOG Paper for presentation at SIGGRAPH 2025
|
||||
ACM Digital Library (DOI)Link for the paperPaper AbstractAuthor PreprintPaper Video
|
||||
Paper PresentationPaper ImagesPaper DataDemo Program or Source CodeRelated Links
|
||||
Changelog
|
||||
35
tools/blog/test_parse.py
Normal file
35
tools/blog/test_parse.py
Normal file
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
测试解析功能的脚本
|
||||
"""
|
||||
|
||||
from parse_blog import parse_zhihu_blog, save_markdown
|
||||
|
||||
|
||||
def test_parse():
|
||||
"""
|
||||
测试解析功能
|
||||
"""
|
||||
# 直接指定HTML文件路径
|
||||
html_file = "SIGGRAPH 2025 Papers.html"
|
||||
output_file = "test_output.md"
|
||||
|
||||
print(f"测试解析: {html_file}")
|
||||
|
||||
# 解析HTML
|
||||
markdown_content = parse_zhihu_blog(html_file)
|
||||
|
||||
if markdown_content:
|
||||
print(f"解析成功,内容长度: {len(markdown_content)}")
|
||||
print("\n前500个字符预览:")
|
||||
print(markdown_content[:500] + "...")
|
||||
|
||||
# 保存到文件
|
||||
save_markdown(markdown_content, output_file)
|
||||
else:
|
||||
print("解析失败")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_parse()
|
||||
6
tools/clipboard/requirements.txt
Normal file
6
tools/clipboard/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
pyperclip
|
||||
Pillow
|
||||
pywin32
|
||||
beautifulsoup4
|
||||
markdownify
|
||||
requests
|
||||
250
tools/clipboard/save_from_clipboard.py
Normal file
250
tools/clipboard/save_from_clipboard.py
Normal file
@@ -0,0 +1,250 @@
|
||||
import os
|
||||
import datetime
|
||||
import re
|
||||
import base64
|
||||
import shutil
|
||||
import requests
|
||||
import random
|
||||
from urllib.parse import unquote, urlparse
|
||||
from io import BytesIO
|
||||
|
||||
# Third-party libraries
|
||||
import pyperclip
|
||||
from PIL import ImageGrab, Image
|
||||
import win32clipboard
|
||||
from bs4 import BeautifulSoup
|
||||
from markdownify import markdownify as md
|
||||
|
||||
# Configuration
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
MARKDOWN_DIR = os.path.join(PROJECT_ROOT, 'notebook', 'markdowns')
|
||||
IMAGES_DIR = os.path.join(PROJECT_ROOT, 'notebook', 'images')
|
||||
|
||||
def sanitize_filename(name):
|
||||
"""Sanitize string to be used as filename"""
|
||||
safe_name = "".join([c for c in name if c.isalnum() or c in (' ', '-', '_')]).strip()
|
||||
return safe_name[:100]
|
||||
|
||||
def get_html_from_clipboard():
|
||||
"""Extract HTML format from Windows Clipboard"""
|
||||
try:
|
||||
win32clipboard.OpenClipboard()
|
||||
|
||||
# Register/Get HTML Format ID
|
||||
html_format = win32clipboard.RegisterClipboardFormat("HTML Format")
|
||||
|
||||
if win32clipboard.IsClipboardFormatAvailable(html_format):
|
||||
raw_data = win32clipboard.GetClipboardData(html_format)
|
||||
win32clipboard.CloseClipboard()
|
||||
|
||||
# Raw data contains headers, we need to decode and parse them
|
||||
# Example Header:
|
||||
# Version:0.9
|
||||
# StartHTML:00000097
|
||||
# EndHTML:00000170
|
||||
# StartFragment:00000133
|
||||
# EndFragment:00000134
|
||||
|
||||
try:
|
||||
html_str = raw_data.decode('utf-8')
|
||||
except:
|
||||
html_str = raw_data.decode('cp1252', errors='ignore')
|
||||
|
||||
# Extract the actual HTML fragment using regex or string splitting
|
||||
start_html = re.search(r'StartHTML:(\d+)', html_str)
|
||||
end_html = re.search(r'EndHTML:(\d+)', html_str)
|
||||
|
||||
if start_html and end_html:
|
||||
start_idx = int(start_html.group(1))
|
||||
end_idx = int(end_html.group(1))
|
||||
return html_str[start_idx:end_idx]
|
||||
|
||||
return html_str # Fallback to full string if parsing fails
|
||||
|
||||
win32clipboard.CloseClipboard()
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"Error reading clipboard HTML: {e}")
|
||||
try:
|
||||
win32clipboard.CloseClipboard()
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
|
||||
def process_html_images(html_content, timestamp):
|
||||
"""Find images in HTML, save them locally, and update src"""
|
||||
soup = BeautifulSoup(html_content, 'html.parser')
|
||||
|
||||
for img in soup.find_all('img'):
|
||||
src = img.get('src')
|
||||
if not src:
|
||||
continue
|
||||
|
||||
new_filename = None
|
||||
|
||||
# Case 1: Base64 Image
|
||||
if src.startswith('data:image'):
|
||||
try:
|
||||
# Extract header and data
|
||||
# data:image/png;base64,xxxx
|
||||
match = re.match(r'data:image/(\w+);base64,(.+)', src)
|
||||
if match:
|
||||
ext = match.group(1)
|
||||
if ext == 'jpeg': ext = 'jpg'
|
||||
data_str = match.group(2)
|
||||
|
||||
img_data = base64.b64decode(data_str)
|
||||
new_filename = f"paste_img_{timestamp}_{random.randint(1000,9999)}.{ext}"
|
||||
dest_path = os.path.join(IMAGES_DIR, new_filename)
|
||||
|
||||
with open(dest_path, 'wb') as f:
|
||||
f.write(img_data)
|
||||
print(f" - Saved Base64 image: {new_filename}")
|
||||
except Exception as e:
|
||||
print(f" - Failed to process base64 image: {e}")
|
||||
|
||||
# Case 2: Local File (file://)
|
||||
elif src.startswith('file://'):
|
||||
try:
|
||||
# Remove file:// prefix and decode URL encoded chars
|
||||
local_path = unquote(src[7:])
|
||||
# On Windows, it might be file:///C:/... -> /C:/... -> C:/...
|
||||
if local_path.startswith('/') and ':' in local_path:
|
||||
local_path = local_path[1:]
|
||||
|
||||
if os.path.exists(local_path):
|
||||
ext = os.path.splitext(local_path)[1]
|
||||
if not ext: ext = '.png'
|
||||
|
||||
new_filename = f"paste_img_{timestamp}_{random.randint(1000,9999)}{ext}"
|
||||
dest_path = os.path.join(IMAGES_DIR, new_filename)
|
||||
|
||||
shutil.copy2(local_path, dest_path)
|
||||
print(f" - Copied local image: {new_filename}")
|
||||
except Exception as e:
|
||||
print(f" - Failed to copy local image: {e}")
|
||||
|
||||
# Case 3: Remote URL (http/https)
|
||||
# Optional: We could download it, but for now let's keep it as is
|
||||
# or download if it's a direct image link.
|
||||
# Let's try to download to make it truly local/offline
|
||||
elif src.startswith('http'):
|
||||
try:
|
||||
# Basic check if it's an image
|
||||
# We skip downloading if user wants to keep remote links, but usually local is better for notes
|
||||
# Let's try downloading
|
||||
response = requests.get(src, timeout=5)
|
||||
if response.status_code == 200 and 'image' in response.headers.get('content-type', ''):
|
||||
ext = '.jpg' # default
|
||||
if 'png' in response.headers['content-type']: ext = '.png'
|
||||
elif 'gif' in response.headers['content-type']: ext = '.gif'
|
||||
|
||||
new_filename = f"paste_img_{timestamp}_{random.randint(1000,9999)}{ext}"
|
||||
dest_path = os.path.join(IMAGES_DIR, new_filename)
|
||||
|
||||
with open(dest_path, 'wb') as f:
|
||||
f.write(response.content)
|
||||
print(f" - Downloaded remote image: {new_filename}")
|
||||
except Exception as e:
|
||||
print(f" - Failed to download remote image: {e}")
|
||||
|
||||
# Update src in HTML if we saved a file
|
||||
if new_filename:
|
||||
# We use bare filename as requested
|
||||
img['src'] = new_filename
|
||||
|
||||
return str(soup)
|
||||
|
||||
def extract_title_from_markdown(md_text):
|
||||
"""Try to find the first H1 header to use as title"""
|
||||
lines = md_text.strip().split('\n')
|
||||
for line in lines[:10]:
|
||||
if line.strip().startswith('# '):
|
||||
return sanitize_filename(line.strip()[2:])
|
||||
return None
|
||||
|
||||
def save_clipboard_content():
|
||||
# Ensure directories exist
|
||||
os.makedirs(MARKDOWN_DIR, exist_ok=True)
|
||||
os.makedirs(IMAGES_DIR, exist_ok=True)
|
||||
|
||||
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
|
||||
# Priority 1: Check for HTML (Rich Text)
|
||||
# This covers most "Copy from Note App" scenarios
|
||||
html_content = get_html_from_clipboard()
|
||||
|
||||
if html_content:
|
||||
print("Detected HTML/Rich Text in clipboard...")
|
||||
|
||||
# 1. Process Images in HTML (Save to disk)
|
||||
processed_html = process_html_images(html_content, timestamp)
|
||||
|
||||
# 2. Convert to Markdown
|
||||
# heading_style='atx' ensures # Header style instead of underlines
|
||||
md_text = md(processed_html, heading_style='atx')
|
||||
|
||||
# 3. Clean up extra newlines often introduced by conversion
|
||||
md_text = re.sub(r'\n{3,}', '\n\n', md_text).strip()
|
||||
|
||||
# 4. Determine Filename
|
||||
title = extract_title_from_markdown(md_text)
|
||||
markdown_filename = f"{title}.md" if title else f"Note_{timestamp}.md"
|
||||
|
||||
# 5. Save
|
||||
markdown_path = os.path.join(MARKDOWN_DIR, markdown_filename)
|
||||
# Avoid collision
|
||||
if os.path.exists(markdown_path):
|
||||
markdown_filename = f"{title}_{timestamp}.md" if title else f"Note_{timestamp}_1.md"
|
||||
markdown_path = os.path.join(MARKDOWN_DIR, markdown_filename)
|
||||
|
||||
with open(markdown_path, 'w', encoding='utf-8') as f:
|
||||
f.write(md_text)
|
||||
|
||||
print(f"Saved Rich Text Note to: {markdown_path}")
|
||||
return
|
||||
|
||||
# Priority 2: Check for Bitmap Image (Direct Screenshot Copy)
|
||||
try:
|
||||
image = ImageGrab.grabclipboard()
|
||||
if image and not isinstance(image, list):
|
||||
print("Detected BITMAP image in clipboard...")
|
||||
image_filename = f"clip_image_{timestamp}.png"
|
||||
image_path = os.path.join(IMAGES_DIR, image_filename)
|
||||
image.save(image_path, 'PNG')
|
||||
|
||||
markdown_filename = f"Image_{timestamp}.md"
|
||||
markdown_path = os.path.join(MARKDOWN_DIR, markdown_filename)
|
||||
|
||||
# Use bare filename as requested
|
||||
content = f"# Clipboard Image {timestamp}\n\n"
|
||||
|
||||
with open(markdown_path, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
|
||||
print(f"Saved image to: {image_path}")
|
||||
print(f"Saved markdown to: {markdown_path}")
|
||||
return
|
||||
except Exception as e:
|
||||
print(f"Error checking bitmap: {e}")
|
||||
|
||||
# Priority 3: Fallback to Plain Text
|
||||
text = pyperclip.paste()
|
||||
if text:
|
||||
print("Detected PLAIN TEXT in clipboard...")
|
||||
title = extract_title_from_markdown(text)
|
||||
markdown_filename = f"{title}.md" if title else f"Note_{timestamp}.md"
|
||||
markdown_path = os.path.join(MARKDOWN_DIR, markdown_filename)
|
||||
|
||||
if os.path.exists(markdown_path):
|
||||
markdown_filename = f"{title}_{timestamp}.md" if title else f"Note_{timestamp}_1.md"
|
||||
markdown_path = os.path.join(MARKDOWN_DIR, markdown_filename)
|
||||
|
||||
with open(markdown_path, 'w', encoding='utf-8') as f:
|
||||
f.write(text)
|
||||
print(f"Saved Text Note to: {markdown_path}")
|
||||
else:
|
||||
print("Clipboard is empty.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
save_clipboard_content()
|
||||
158
tools/doubao/main.py
Normal file
158
tools/doubao/main.py
Normal file
@@ -0,0 +1,158 @@
|
||||
import os
|
||||
import sys
|
||||
import argparse
|
||||
import time
|
||||
from openai import OpenAI
|
||||
|
||||
# 任务定义
|
||||
TASKS = {
|
||||
"fix_markdown": {
|
||||
"system": "你是一个 Markdown 格式专家。请修复以下 Markdown 文档片段的格式问题。不要修改文档的原始内容,只调整格式(如标题、列表、代码块等的规范化)。直接输出修复后的 Markdown 内容,不要包含任何解释或 ```markdown 标记。注意:这是长文档的一部分,请保持上下文连贯。",
|
||||
}
|
||||
}
|
||||
|
||||
# 最大块大小(字符数)
|
||||
MAX_CHUNK_SIZE = 3000
|
||||
|
||||
def split_markdown(text, max_length=MAX_CHUNK_SIZE):
|
||||
"""
|
||||
将 Markdown 文本分割成较小的块,尽量保持段落和代码块完整。
|
||||
"""
|
||||
lines = text.split('\n')
|
||||
chunks = []
|
||||
current_chunk = []
|
||||
current_length = 0
|
||||
in_code_block = False
|
||||
|
||||
for line in lines:
|
||||
# 检测代码块状态
|
||||
if line.strip().startswith('```'):
|
||||
in_code_block = not in_code_block
|
||||
|
||||
line_len = len(line) + 1 # +1 for newline
|
||||
|
||||
# 决定是否需要切分:
|
||||
# 1. 当前长度超过最大限制
|
||||
# 2. 且不在代码块内 (in_code_block == False)
|
||||
if current_length + line_len > max_length and not in_code_block:
|
||||
# 如果当前块不为空,则保存当前块
|
||||
if current_chunk:
|
||||
chunks.append('\n'.join(current_chunk))
|
||||
current_chunk = []
|
||||
current_length = 0
|
||||
|
||||
# 如果单行本身就超过了最大长度(极少见情况),也只能强行放入
|
||||
current_chunk.append(line)
|
||||
current_length += line_len
|
||||
else:
|
||||
current_chunk.append(line)
|
||||
current_length += line_len
|
||||
|
||||
if current_chunk:
|
||||
chunks.append('\n'.join(current_chunk))
|
||||
|
||||
return chunks
|
||||
|
||||
def process_chunk(client, content, task_config, model="doubao-seed-1-8-251228"):
|
||||
"""
|
||||
处理单个文本块
|
||||
"""
|
||||
try:
|
||||
completion = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[
|
||||
{"role": "system", "content": task_config["system"]},
|
||||
{"role": "user", "content": content},
|
||||
],
|
||||
max_tokens=4096, # 保持较大的输出 token 限制
|
||||
)
|
||||
return completion.choices[0].message.content
|
||||
except Exception as e:
|
||||
# 如果出错,打印错误到 stderr 但不中断整个流程(或者选择中断)
|
||||
# 这里选择抛出异常以便外层捕获
|
||||
raise e
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Doubao AI Task Executor")
|
||||
parser.add_argument("--task", required=True, help="Task name", choices=TASKS.keys())
|
||||
args = parser.parse_args()
|
||||
|
||||
# 优先从环境变量读取,如果没有则使用硬编码的 Key (仅供演示,实际应走环境变量)
|
||||
api_key = os.getenv('ARK_API_KEY') or "a5ab502d-c9a9-49f3-a80b-9c80c6b5378b"
|
||||
|
||||
if not api_key:
|
||||
print("Error: ARK_API_KEY environment variable is not set.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
client = OpenAI(
|
||||
base_url="https://ark.cn-beijing.volces.com/api/v3",
|
||||
api_key=api_key,
|
||||
)
|
||||
|
||||
# Windows UTF-8 处理
|
||||
if sys.platform == 'win32':
|
||||
import io
|
||||
sys.stdin = io.TextIOWrapper(sys.stdin.buffer, encoding='utf-8', errors='ignore')
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='ignore')
|
||||
|
||||
# 读取全部内容
|
||||
content = sys.stdin.read()
|
||||
|
||||
# 清洗非法字符
|
||||
content = content.encode('utf-8', 'ignore').decode('utf-8')
|
||||
|
||||
if not content:
|
||||
print("Error: No input content provided via stdin.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
task_config = TASKS[args.task]
|
||||
|
||||
# 1. 分割文本
|
||||
chunks = split_markdown(content)
|
||||
|
||||
# 2. 依次处理
|
||||
results = []
|
||||
total_chunks = len(chunks)
|
||||
|
||||
# 打印进度信息到 stderr (前端看不到,但方便调试)
|
||||
print(f"Processing {total_chunks} chunks...", file=sys.stderr)
|
||||
|
||||
for i, chunk in enumerate(chunks):
|
||||
try:
|
||||
# 简单的重试机制
|
||||
retry_count = 0
|
||||
max_retries = 3
|
||||
result = None
|
||||
|
||||
while retry_count < max_retries:
|
||||
try:
|
||||
result = process_chunk(client, chunk, task_config)
|
||||
break
|
||||
except Exception as e:
|
||||
retry_count += 1
|
||||
print(f"Chunk {i+1}/{total_chunks} failed (attempt {retry_count}): {e}", file=sys.stderr)
|
||||
time.sleep(2) # 等待后重试
|
||||
|
||||
if result is None:
|
||||
print(f"Error: Failed to process chunk {i+1} after {max_retries} attempts.", file=sys.stderr)
|
||||
# 失败时保留原始内容,避免数据丢失
|
||||
results.append(chunk)
|
||||
else:
|
||||
results.append(result)
|
||||
|
||||
# 避免触发速率限制
|
||||
if i < total_chunks - 1:
|
||||
time.sleep(0.5)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Critical error on chunk {i+1}: {e}", file=sys.stderr)
|
||||
results.append(chunk)
|
||||
|
||||
# 3. 合并输出
|
||||
final_output = '\n'.join(results)
|
||||
|
||||
# 4. 打印最终结果
|
||||
print(final_output)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1
tools/doubao/requirements.txt
Normal file
1
tools/doubao/requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
openai
|
||||
0
tools/mineru/api.md
Normal file
0
tools/mineru/api.md
Normal file
21
tools/mineru/config.py
Normal file
21
tools/mineru/config.py
Normal file
@@ -0,0 +1,21 @@
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# 加载环境变量
|
||||
load_dotenv()
|
||||
|
||||
# Mineru API 配置
|
||||
MINERU_API_URL = "https://mineru.net/api/v4"
|
||||
MINERU_TOKEN = "eyJ0eXBlIjoiSldUIiwiYWxnIjoiSFM1MTIifQ.eyJqdGkiOiI0NDIwMDY1NyIsInJvbCI6IlJPTEVfUkVHSVNURVIiLCJpc3MiOiJPcGVuWExhYiIsImlhdCI6MTc3MDIyMDU4OSwiY2xpZW50SWQiOiJsa3pkeDU3bnZ5MjJqa3BxOXgydyIsInBob25lIjoiIiwib3BlbklkIjpudWxsLCJ1dWlkIjoiMTE4MDA1YjctYWRiYy00MmY0LTkyZTYtZWM4M2Q1ZWRiOTQzIiwiZW1haWwiOiIiLCJleHAiOjE3NzE0MzAxODl9.cVCGxc97GNCdQPYmaP9hbYptfenAK6o8xJ0CZtEOxOhOEgVhV519P7X61FmdLgSs4QRZYs0ZM_4VRwQgVFnJ0w"
|
||||
|
||||
# 阿里云 OSS 配置
|
||||
OSS_ACCESS_KEY_ID = "LTAI5tB7sQADpKZnXY7s6Xz8"
|
||||
OSS_ACCESS_KEY_SECRET = "Fgab9klwKoH1GACP97WIb7s6BSvNAm"
|
||||
OSS_BUCKET_NAME = "bucket-xcnote" # 测试用的 OSS 桶名称
|
||||
OSS_ENDPOINT = "https://oss-cn-beijing.aliyuncs.com"
|
||||
|
||||
# 本地文件配置
|
||||
TEMP_DIR = "./temp"
|
||||
|
||||
# 确保临时目录存在
|
||||
os.makedirs(TEMP_DIR, exist_ok=True)
|
||||
3
tools/mineru/key.md
Normal file
3
tools/mineru/key.md
Normal file
@@ -0,0 +1,3 @@
|
||||
xuanchi
|
||||
|
||||
eyJ0eXBlIjoiSldUIiwiYWxnIjoiSFM1MTIifQ.eyJqdGkiOiI0NDIwMDY1NyIsInJvbCI6IlJPTEVfUkVHSVNURVIiLCJpc3MiOiJPcGVuWExhYiIsImlhdCI6MTc3MDIyMDU4OSwiY2xpZW50SWQiOiJsa3pkeDU3bnZ5MjJqa3BxOXgydyIsInBob25lIjoiIiwib3BlbklkIjpudWxsLCJ1dWlkIjoiMTE4MDA1YjctYWRiYy00MmY0LTkyZTYtZWM4M2Q1ZWRiOTQzIiwiZW1haWwiOiIiLCJleHAiOjE3NzE0MzAxODl9.cVCGxc97GNCdQPYmaP9hbYptfenAK6o8xJ0CZtEOxOhOEgVhV519P7X61FmdLgSs4QRZYs0ZM_4VRwQgVFnJ0w
|
||||
326
tools/mineru/mineru_parser.py
Normal file
326
tools/mineru/mineru_parser.py
Normal file
@@ -0,0 +1,326 @@
|
||||
#!/usr/bin/env python
|
||||
# coding=utf-8
|
||||
|
||||
import os
|
||||
import time
|
||||
import requests
|
||||
import zipfile
|
||||
import json
|
||||
from config import (
|
||||
MINERU_API_URL,
|
||||
MINERU_TOKEN,
|
||||
OSS_ACCESS_KEY_ID,
|
||||
OSS_ACCESS_KEY_SECRET,
|
||||
OSS_BUCKET_NAME,
|
||||
OSS_ENDPOINT,
|
||||
TEMP_DIR,
|
||||
)
|
||||
import sys
|
||||
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from shared.oss_upload import upload_file_to_oss
|
||||
|
||||
|
||||
def create_parse_task(url, model_version="vlm"):
|
||||
"""
|
||||
创建解析任务
|
||||
:param url: 文件URL
|
||||
:param model_version: 模型版本,默认为vlm
|
||||
:return: 任务ID
|
||||
"""
|
||||
print(f"开始创建解析任务: {url}")
|
||||
|
||||
api_url = f"{MINERU_API_URL}/extract/task"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {MINERU_TOKEN}",
|
||||
}
|
||||
data = {"url": url, "model_version": model_version}
|
||||
|
||||
try:
|
||||
response = requests.post(api_url, headers=headers, json=data)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
if result.get("code") == 0:
|
||||
task_id = result.get("data", {}).get("task_id")
|
||||
print(f"任务创建成功: {task_id}")
|
||||
return task_id
|
||||
else:
|
||||
print(f"任务创建失败: {result.get('msg')}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"创建任务失败: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
return None
|
||||
|
||||
|
||||
def get_task_status(task_id):
|
||||
"""
|
||||
获取任务状态
|
||||
:param task_id: 任务ID
|
||||
:return: 任务状态
|
||||
"""
|
||||
print(f"查询任务状态: {task_id}")
|
||||
|
||||
api_url = f"{MINERU_API_URL}/extract/task/{task_id}"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {MINERU_TOKEN}",
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.get(api_url, headers=headers)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
if result.get("code") == 0:
|
||||
status = result.get("data", {})
|
||||
print(f"任务状态: {status.get('state')}")
|
||||
return status
|
||||
else:
|
||||
print(f"查询状态失败: {result.get('msg')}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"查询状态失败: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
return None
|
||||
|
||||
|
||||
def poll_task_status(task_id, max_retries=60, interval=5):
|
||||
"""
|
||||
轮询任务状态
|
||||
:param task_id: 任务ID
|
||||
:param max_retries: 最大重试次数
|
||||
:param interval: 轮询间隔(秒)
|
||||
:return: 任务完成状态
|
||||
"""
|
||||
print(f"开始轮询任务状态: {task_id}")
|
||||
|
||||
for i in range(max_retries):
|
||||
status = get_task_status(task_id)
|
||||
if status:
|
||||
state = status.get("state")
|
||||
if state == "done":
|
||||
print("任务完成!")
|
||||
return status
|
||||
elif state == "failed":
|
||||
print(f"任务失败: {status.get('err_msg')}")
|
||||
return None
|
||||
elif state in ["pending", "running", "converting"]:
|
||||
print(f"任务正在进行中 ({state}),{interval}秒后重试...")
|
||||
time.sleep(interval)
|
||||
else:
|
||||
print(f"未知状态: {state}")
|
||||
time.sleep(interval)
|
||||
else:
|
||||
print(f"获取状态失败,{interval}秒后重试...")
|
||||
time.sleep(interval)
|
||||
|
||||
print("轮询超时,任务可能仍在处理中")
|
||||
return None
|
||||
|
||||
|
||||
def download_and_extract_result(zip_url, output_dir):
|
||||
"""
|
||||
下载并提取解析结果
|
||||
:param zip_url: 结果压缩包URL
|
||||
:param output_dir: 输出目录
|
||||
:return: 提取的文件列表
|
||||
"""
|
||||
print(f"开始下载解析结果: {zip_url}")
|
||||
|
||||
# 确保输出目录存在
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
# 下载压缩包
|
||||
zip_path = os.path.join(output_dir, "result.zip")
|
||||
try:
|
||||
response = requests.get(zip_url, stream=True)
|
||||
response.raise_for_status()
|
||||
|
||||
with open(zip_path, "wb") as f:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
print(f"压缩包下载成功: {zip_path}")
|
||||
|
||||
# 提取压缩包
|
||||
extracted_files = []
|
||||
with zipfile.ZipFile(zip_path, "r") as zip_ref:
|
||||
zip_ref.extractall(output_dir)
|
||||
print(f"压缩包提取成功: {output_dir}")
|
||||
extracted_files = zip_ref.namelist()
|
||||
|
||||
# 删除压缩包
|
||||
os.remove(zip_path)
|
||||
print(f"删除临时压缩包: {zip_path}")
|
||||
|
||||
return extracted_files
|
||||
|
||||
except Exception as e:
|
||||
print(f"下载或提取失败: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
return None
|
||||
|
||||
|
||||
def get_markdown_result(extracted_files, output_dir):
|
||||
"""
|
||||
获取Markdown格式的解析结果
|
||||
:param extracted_files: 提取的文件列表
|
||||
:param output_dir: 输出目录
|
||||
:return: Markdown内容
|
||||
"""
|
||||
print("查找Markdown格式的解析结果")
|
||||
|
||||
for file_name in extracted_files:
|
||||
if file_name.endswith(".md"):
|
||||
md_path = os.path.join(output_dir, file_name)
|
||||
print(f"找到Markdown文件: {md_path}")
|
||||
|
||||
try:
|
||||
with open(md_path, "r", encoding="utf-8") as f:
|
||||
md_content = f.read()
|
||||
print(f"Markdown文件读取成功,长度: {len(md_content)} 字符")
|
||||
return md_content
|
||||
except Exception as e:
|
||||
print(f"读取Markdown文件失败: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
return None
|
||||
|
||||
print("未找到Markdown格式的解析结果")
|
||||
return None
|
||||
|
||||
|
||||
def parse_local_file(file_path, model_version="vlm"):
|
||||
"""
|
||||
解析本地文件
|
||||
:param file_path: 本地文件路径
|
||||
:param model_version: 模型版本,默认为vlm
|
||||
:return: Markdown内容
|
||||
"""
|
||||
print(f"开始解析本地文件: {file_path}")
|
||||
|
||||
# 检查文件是否存在
|
||||
if not os.path.exists(file_path):
|
||||
print(f"文件不存在: {file_path}")
|
||||
return None
|
||||
|
||||
# 检查文件大小
|
||||
file_size = os.path.getsize(file_path)
|
||||
if file_size > 200 * 1024 * 1024: # 200MB
|
||||
print(f"文件大小超出限制: {file_size} bytes (最大200MB)")
|
||||
return None
|
||||
|
||||
# 生成对象名称
|
||||
timestamp = int(time.time())
|
||||
file_name = os.path.basename(file_path)
|
||||
object_name = f"mineru/{timestamp}_{file_name}"
|
||||
|
||||
# 上传文件到OSS
|
||||
oss_url = upload_file_to_oss(
|
||||
file_path,
|
||||
OSS_BUCKET_NAME,
|
||||
object_name,
|
||||
OSS_ACCESS_KEY_ID,
|
||||
OSS_ACCESS_KEY_SECRET,
|
||||
OSS_ENDPOINT,
|
||||
)
|
||||
|
||||
if not oss_url:
|
||||
print("文件上传失败,无法继续解析")
|
||||
return None
|
||||
|
||||
# 创建解析任务
|
||||
task_id = create_parse_task(oss_url, model_version)
|
||||
|
||||
if not task_id:
|
||||
print("任务创建失败,无法继续解析")
|
||||
return None
|
||||
|
||||
# 轮询任务状态
|
||||
task_status = poll_task_status(task_id)
|
||||
|
||||
if not task_status:
|
||||
print("任务执行失败,无法获取解析结果")
|
||||
return None
|
||||
|
||||
# 获取结果URL
|
||||
zip_url = task_status.get("full_zip_url")
|
||||
if not zip_url:
|
||||
print("未找到解析结果URL")
|
||||
return None
|
||||
|
||||
# 生成输出目录
|
||||
output_dir = os.path.join(TEMP_DIR, task_id)
|
||||
|
||||
# 下载并提取结果
|
||||
extracted_files = download_and_extract_result(zip_url, output_dir)
|
||||
|
||||
if not extracted_files:
|
||||
print("下载或提取结果失败")
|
||||
return None
|
||||
|
||||
# 获取Markdown结果
|
||||
md_content = get_markdown_result(extracted_files, output_dir)
|
||||
|
||||
if not md_content:
|
||||
print("未找到Markdown格式的解析结果")
|
||||
return None
|
||||
|
||||
print("文件解析完成,成功获取Markdown格式的结果")
|
||||
return {"content": md_content, "output_dir": output_dir}
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
主函数
|
||||
"""
|
||||
import sys
|
||||
|
||||
if len(sys.argv) != 2:
|
||||
print("使用方法: python mineru_parser.py <本地文件路径>")
|
||||
print("示例: python mineru_parser.py ./example.pdf")
|
||||
sys.exit(1)
|
||||
|
||||
file_path = sys.argv[1]
|
||||
|
||||
# 解析文件
|
||||
result = parse_local_file(file_path)
|
||||
|
||||
if result:
|
||||
md_content = result["content"]
|
||||
output_dir = result["output_dir"]
|
||||
|
||||
# 保存Markdown结果
|
||||
output_file = os.path.join(TEMP_DIR, f"{os.path.basename(file_path)}.md")
|
||||
with open(output_file, "w", encoding="utf-8") as f:
|
||||
f.write(md_content)
|
||||
|
||||
# Print JSON result for caller to parse
|
||||
print(
|
||||
"JSON_RESULT:"
|
||||
+ json.dumps(
|
||||
{
|
||||
"status": "success",
|
||||
"markdown_file": os.path.abspath(output_file),
|
||||
"output_dir": os.path.abspath(output_dir),
|
||||
}
|
||||
)
|
||||
)
|
||||
else:
|
||||
print("文件解析失败")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
3
tools/mineru/requirements.txt
Normal file
3
tools/mineru/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
requests
|
||||
oss2
|
||||
python-dotenv
|
||||
105
tools/mineru/test_api.py
Normal file
105
tools/mineru/test_api.py
Normal file
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env python
|
||||
#coding=utf-8
|
||||
|
||||
import requests
|
||||
from config import MINERU_API_URL, MINERU_TOKEN
|
||||
|
||||
def test_create_task():
|
||||
"""
|
||||
测试创建解析任务
|
||||
"""
|
||||
print("测试创建解析任务...")
|
||||
|
||||
api_url = f"{MINERU_API_URL}/extract/task"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {MINERU_TOKEN}"
|
||||
}
|
||||
# 使用官方示例 URL
|
||||
data = {
|
||||
"url": "https://cdn-mineru.openxlab.org.cn/demo/example.pdf",
|
||||
"model_version": "vlm"
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(api_url, headers=headers, json=data)
|
||||
print(f"状态码: {response.status_code}")
|
||||
print(f"响应内容: {response.json()}")
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
if result.get("code") == 0:
|
||||
task_id = result.get("data", {}).get("task_id")
|
||||
print(f"任务创建成功,任务ID: {task_id}")
|
||||
return task_id
|
||||
else:
|
||||
print(f"任务创建失败: {result.get('msg')}")
|
||||
return None
|
||||
else:
|
||||
print(f"请求失败,状态码: {response.status_code}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"测试失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return None
|
||||
|
||||
def test_get_task_status(task_id):
|
||||
"""
|
||||
测试获取任务状态
|
||||
"""
|
||||
if not task_id:
|
||||
print("任务ID为空,跳过测试")
|
||||
return None
|
||||
|
||||
print(f"测试获取任务状态...")
|
||||
|
||||
api_url = f"{MINERU_API_URL}/extract/task/{task_id}"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {MINERU_TOKEN}"
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.get(api_url, headers=headers)
|
||||
print(f"状态码: {response.status_code}")
|
||||
print(f"响应内容: {response.json()}")
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
if result.get("code") == 0:
|
||||
status = result.get("data", {})
|
||||
print(f"任务状态获取成功,状态: {status.get('state')}")
|
||||
return status
|
||||
else:
|
||||
print(f"获取状态失败: {result.get('msg')}")
|
||||
return None
|
||||
else:
|
||||
print(f"请求失败,状态码: {response.status_code}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"测试失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return None
|
||||
|
||||
def main():
|
||||
"""
|
||||
主函数
|
||||
"""
|
||||
print("开始测试 Mineru API...")
|
||||
|
||||
# 测试创建任务
|
||||
task_id = test_create_task()
|
||||
|
||||
# 测试获取任务状态
|
||||
if task_id:
|
||||
test_get_task_status(task_id)
|
||||
|
||||
print("API 测试完成")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
BIN
tools/shared/__pycache__/oss_upload.cpython-313.pyc
Normal file
BIN
tools/shared/__pycache__/oss_upload.cpython-313.pyc
Normal file
Binary file not shown.
84
tools/shared/oss_upload.py
Normal file
84
tools/shared/oss_upload.py
Normal file
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env python
|
||||
#coding=utf-8
|
||||
|
||||
import os
|
||||
import oss2
|
||||
from oss2 import Auth
|
||||
|
||||
def upload_file_to_oss(local_file_path, bucket_name, object_name, access_key_id, access_key_secret, endpoint='https://oss-cn-beijing.aliyuncs.com'):
|
||||
"""
|
||||
上传文件到OSS
|
||||
:param local_file_path: 本地文件路径
|
||||
:param bucket_name: OSS桶名称
|
||||
:param object_name: OSS对象名称
|
||||
:param access_key_id: 阿里云AccessKey ID
|
||||
:param access_key_secret: 阿里云AccessKey Secret
|
||||
:param endpoint: OSS端点,默认为北京区域
|
||||
:return: OSS文件URL
|
||||
"""
|
||||
print(f"开始上传文件到OSS: {local_file_path}")
|
||||
|
||||
try:
|
||||
# 创建OSS客户端
|
||||
auth = Auth(access_key_id, access_key_secret)
|
||||
bucket = oss2.Bucket(auth, endpoint, bucket_name)
|
||||
|
||||
# 上传文件
|
||||
bucket.put_object_from_file(object_name, local_file_path)
|
||||
print(f"文件上传成功: {object_name}")
|
||||
|
||||
# 生成可访问的URL
|
||||
# 注意:这里生成的是临时URL,有效期为3600秒
|
||||
# 如果需要永久URL,需要在OSS桶中设置文件为公共读
|
||||
url = bucket.sign_url('GET', object_name, 3600)
|
||||
print(f"生成OSS文件URL: {url}")
|
||||
|
||||
return url
|
||||
|
||||
except Exception as e:
|
||||
print(f"文件上传失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return None
|
||||
|
||||
def make_bucket_public(bucket_name, access_key_id, access_key_secret, endpoint='https://oss-cn-beijing.aliyuncs.com'):
|
||||
"""
|
||||
设置OSS桶为公共读
|
||||
:param bucket_name: OSS桶名称
|
||||
:param access_key_id: 阿里云AccessKey ID
|
||||
:param access_key_secret: 阿里云AccessKey Secret
|
||||
:param endpoint: OSS端点,默认为北京区域
|
||||
:return: 是否设置成功
|
||||
"""
|
||||
print(f"开始设置OSS桶为公共读: {bucket_name}")
|
||||
|
||||
try:
|
||||
# 创建OSS客户端
|
||||
auth = Auth(access_key_id, access_key_secret)
|
||||
bucket = oss2.Bucket(auth, endpoint, bucket_name)
|
||||
|
||||
# 设置桶的访问策略为公共读
|
||||
policy = """
|
||||
{
|
||||
"Version": "1",
|
||||
"Statement": [
|
||||
{
|
||||
"Action": ["oss:GetObject"],
|
||||
"Effect": "Allow",
|
||||
"Principal": ["*"],
|
||||
"Resource": ["acs:oss:*:*:{bucket_name}/*"],
|
||||
"Condition": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
""".format(bucket_name=bucket_name)
|
||||
|
||||
bucket.put_bucket_policy(policy)
|
||||
print(f"OSS桶设置为公共读成功: {bucket_name}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"OSS桶设置失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
738
tools/tongyi/20260207_134948_merged.md
Normal file
738
tools/tongyi/20260207_134948_merged.md
Normal file
@@ -0,0 +1,738 @@
|
||||
# 拼合内容
|
||||
|
||||
生成时间: 2026-02-07 13:49:48
|
||||
|
||||
我现在再给大家介绍一下,这个三角洲行动中的这个全局光照方案。
|
||||
|
||||
来自天美第三工作室引擎技术组的魏东陈。
|
||||
|
||||
讲之前我还是先自我介绍一下,我是15年加入的腾讯,然后最早是参加了乐高无限的开发。
|
||||
|
||||
到了天美之后,是先后参与了这个穿越火线手游,然后绝地求生、全军出击,还有这个CODM。
|
||||
|
||||
对我们现在的这个三角洲行动。
|
||||
|
||||
先看一下三角洲行动的这个总体画面效果。
|
||||
|
||||

|
||||
|
||||
我今天会按照这一个顺序,我先简单介绍一下项目,我会把常见的GI方案给罗列出来。
|
||||
|
||||
再根据三角洲行动这个项目,我们来看一下这个项目到底怎样进行一个技术选型,把这个完整的方案给讲一下。
|
||||
|
||||
项目现在已经累积了很多的DAU了。
|
||||
|
||||
他第一天开始,我们就希望尽量支持广泛的平台,然后大家一起爽玩。
|
||||
|
||||

|
||||
|
||||
所有地图都是单手通发的,就不会说只有一个地图就是在某一个特定平台上才发布,就不会这样。
|
||||
|
||||
每一个地图来说,就是要求玩家可见的这个区域,我们全都有全局光照的覆盖。
|
||||
|
||||
带来一些挑战,大家看就是要求所见的区域全都有这个GI的覆盖。
|
||||
|
||||

|
||||
|
||||
C和这个手机这是性能差异很大的的平台。
|
||||
|
||||
你选这个GI方案,肯定是有一些拉扯的那我们还是先把所有的GI方案都回顾一下,然后我们再看看根据这个双端的体系到底该怎么选择。
|
||||
|
||||

|
||||
|
||||
是light map,这是最简单和最传统最节省的方案。
|
||||
|
||||
还有就是像这个volume GI,这个是在PC上可以稀疏化存储的这种光照体质体素数据,它又有高精度,它又显存控制得住是吧?
|
||||
|
||||

|
||||
|
||||
像刺客信条、孤岛惊魂这类游戏都非常广泛的用了。
|
||||
|
||||
你像手游上这种就是有3D纹理,规整3D纹理的这个volume DI。
|
||||
|
||||

|
||||
|
||||
它有3D纹理,所以说它寻址采样就是比较GPU友好,就节省性能。
|
||||
|
||||
还有就是像以鲁曼为代表的这个全动态的,它效果又好的,美术迭代效率又高。
|
||||
|
||||
你看这些方案里,如果咱们比运行是性能的话,如果从性能的角度讲,那light map它肯定是最好的,对吧?
|
||||
|
||||

|
||||
|
||||
给你给一个2U然后就把这个数据,把这个有微电子给采出来了,然后GI的数据就得到了,就非常的高效。
|
||||
|
||||
美术可以在局部增大这个light map的文字密度,就得到很高的精度。
|
||||
|
||||

|
||||
|
||||
volume GI的话,它这里是以这种稀疏化存储的GI为例子,它这个数据存储获取就稍微麻烦一点。
|
||||
|
||||
就得在shader里去访问一个树形的体系结构,然后根据这个position去查询到这个光照,最后再还要根据法线去做一个解码。
|
||||
|
||||
可你可能又保存了这个SH或者是MND cube这种结构,那这样的话它消耗当然是比lad mab要大一些,但是还是可以接受了。
|
||||
|
||||
从我们经验看,就是说你660显卡就跑一个1080P下跑一个16G那3毫秒以内是没有问题的。
|
||||
|
||||
对于全动态DI像是roman d这个是没有办法,因为它迭代效率它是非常高的。
|
||||
|
||||
毕竟它是个全动态的效果,这那的pass的数量就比前两种都多了一个数量级。
|
||||
|
||||
是比运行是性能,但是咱们如果比项目成本的话,light bank你虽然效率高,但是你它的项目成本也非常高。
|
||||
|
||||

|
||||
|
||||
要制作这一堆2U而且这个麦氏和这个光照不解耦,做起来非常搞人心态的事儿。
|
||||
|
||||
你一个match到底在哪些地方要分配更多的文素,那迭代起来也绝对就是一个时间黑洞。
|
||||
|
||||
的地图,比如说像这个长空系统。
|
||||
|
||||

|
||||
|
||||
这全都铺上来的,不是你看你看这个框的数量,这根本就是一个不可能的事情。
|
||||
|
||||
相比之下,这volunt这就舒服多了是吧?
|
||||
|
||||

|
||||
|
||||
这没有2U这个烦恼,你迈出和光照解耦,运行师给定一个position之后,立刻就可以有根据算法查到自己所需要的这个数据。
|
||||
|
||||
像这种大范围的这长工这种地图,如果你用volume GI,你甚至可以用一个自动化的流水线,就每天进行不停的进行滚动烘焙。
|
||||
|
||||
只需要拉一下数据就可以得到你所需要的光照,对不对?
|
||||
|
||||

|
||||
|
||||
比如这个项目成本,就是像luman还有这些全能快捷,他把这个制作成本已经加压缩到接近为零,没有任何等待对吧?
|
||||
|
||||
在这样一条光谱上大家看越靠近左边,它这个运行时效率越高,但是制作成本也越高越靠近右边,它这个开发者越爽,迭代也越快,但是性能消耗也越大。
|
||||
|
||||
多方案里,三角洲行,我跟大家说就全都用了全都用了。
|
||||
|
||||

|
||||
|
||||
你大家听昨天的远光八四的分享,还有你如果去看那个COD的分享,大家都是在这一条光谱上都做出了个自己,就是几乎全都用了。
|
||||
|
||||
认为这可以是一种可以说这叫一种趋同进化了。
|
||||
|
||||
还是看一下这个双端它对GI到底是怎样一个需求呢?
|
||||
|
||||

|
||||
|
||||
C这边我们希望当然说是拔高效果,那不用说,但是你不论是lead mp还是vollied MGI,尤其是vollied GI你这精度一旦上去之后,那显存就会出现一个几何级别的增长,对不对?
|
||||
|
||||
PC这边所以你就要时刻提醒自己,就是说如何控制显存。
|
||||
|
||||
C的match它也更加复杂。
|
||||
|
||||
PC match上搞这个light map,那这个制作成本会更大。
|
||||
|
||||
就希望就更加希望往里面嚼,把它这个制作成本降低这个优势给发挥出来,所以说这里打了四颗星代表对他的一个期待。
|
||||
|
||||
这边大家可能想不到,就是对于GI系统或者说对所有的系统,首要的要求就是你要把这个包量给我降下来,对吧?
|
||||
|
||||
体保证手游玩家体验的一个非常重要的一环。
|
||||
|
||||
无脑的全都铺了个light map,那包量就会过于庞大。
|
||||
|
||||
你这还有TOD的这么一个情况下,手游的性能比PC弱很多,所以你要控制好消耗。
|
||||
|
||||
你要注意大多数其实它就是普遍手游一个情况,就是GPU瓶颈。
|
||||
|
||||
如果你有这个的方案的话,你这个消耗就不要比这个live map增长太多。
|
||||
|
||||
我们对他的这样一个期望。
|
||||
|
||||
受限于性能的话,手游的这个light map使用范围确实还是要更大一些。
|
||||
|
||||
然后我们这个选用原则就是说我们希望各个平台上都要优先保证运行效率。
|
||||
|
||||

|
||||
|
||||
不是说什么2K60FPS,因为多人竞技,你最好要上个120、130、100 44这种,然后在此基础上尽量的提高这个制作速度。
|
||||
|
||||
多人体图端我们都用了light map加volume GI的这个组合方案。
|
||||
|
||||
TC上这个volume I用的就是更多一些。
|
||||
|
||||
战役的话,因为没有PVP竞技的情况下,就直接用鲁本拔高效果。
|
||||
|
||||
它这个迭代效率高,事实上能达到更好的一个品美术品质。
|
||||
|
||||
我们先看这个PCR这个light map,我先说一下这个light map Baker,这是我们自己研的一个ggs红莓器,从CF手游就开始用了,然后一直迭代一直用到三角洲行动,现在已经是一个基于GPU的这样一个烘焙器。
|
||||
|
||||

|
||||
|
||||
map这个东西可以说让人真是又爱又恨,它这个效率品质都很好。
|
||||
|
||||
它场景到了之后,离线制作的这个也是非常吞噬时间的一个东西。
|
||||
|
||||
又在希局部希望很拉高精度的地方,就还是用了这个light map。
|
||||
|
||||
最大的这个长空地图,只有这些标记为浅绿色的地方是用了light map。
|
||||
|
||||
light map上面只有只有人工光和天光的可见度。
|
||||
|
||||
map它主要是负责室内光照,可以说这也可以让可以让各地图制作局部的各个切块之后,各个美术同志他可以独立的进行烘焙,它不受整体太阳光照方向变化调整的影响。
|
||||
|
||||
大地图使用lid map的,它的主要限制就是制作效率。
|
||||
|
||||

|
||||
|
||||
如果像长虹那种地图,你用了lid map之后全都用lid map,那你可能就是这地图永远就发布不了,尤其你还有这个TOD的情况下。
|
||||
|
||||
就来看一下PC上覆盖范围最大的这么一个部分,就是它这个volume键,这是我们的重点。
|
||||
|
||||

|
||||
|
||||
最大的好处就是大幅的提升了制作效率品质,也能接进来的map。
|
||||
|
||||
从这个防漏光基础的数据元素,然后存储、拟合、压缩,最后到全世界方案的这样一个顺序来介绍一下这个GI的方案。
|
||||
|
||||
还是这里我就先得提一句,如果你准备开始用volume MGI之后,它就有一个非常隐私的陷阱。
|
||||
|
||||

|
||||
|
||||
就是经历那么几个项目你才能得出来的这么一个经验。
|
||||
|
||||
看这个函数图表,横轴代表制作代价,纵轴代表这个GI方案的综合效果。
|
||||
|
||||
效果就是指画面加运行效率,综合起来这种效果大家看在这个点,这是light map,它有很高的制作代价,但是它的综合效果也确实很好。
|
||||
|
||||
理想中的volume jr是什么?
|
||||
|
||||
应该是大幅提升制作效率,对不对?
|
||||
|
||||
代价大幅下降,但是它的综合效果下降很小,这是理想中的王俊杰。
|
||||
|
||||
就非常容易进去一个陷阱什么的。
|
||||
|
||||
搞的这种就是你做出来之后确实和麦是解耦了,那程序觉得自己好厉害,然后美术由于工作负担下降,他也觉得很开心。
|
||||
|
||||
你别忘了,这就是volume GI他自己也有像是漏光这种固有问题。
|
||||
|
||||
问题如果处理不好就集中,极易造成制作成本虽然下降了,但是画面也有一点下降。
|
||||
|
||||
有一点漏光瑕疵等等,或者说你画面没有下降,程序性能大幅下降。
|
||||
|
||||
比如说你运行时必须花一个大代价去解决这个漏光问题。
|
||||
|
||||
volume GI相对于这个light map来说,大家发现没有,它的总的性价比并没有大的变化。
|
||||
|
||||
制作团队在做这个东西的时候,它就各有各的爽点。
|
||||
|
||||
前面说了就是说对此对这个性价比提升就是感知不强。
|
||||
|
||||
最恐怖的时候,就当这个测试回单的时候,要么发现你这个东西效率不太好,要么发现画面有一些什么瑕疵之类的,然后产生很多很多bug的。
|
||||
|
||||
为了修复这些bug,又得去各种hack解决,反复折腾,然后把这个volley GI的清低成本的优势给搞没了。
|
||||
|
||||
就是我见过一些项目是进入过这个陷阱的这也是经历了一些就是这样一些经验教训,然后才我们才开始特别重视这个事情。
|
||||
|
||||
所以我先提防漏光,就是防漏光。
|
||||
|
||||

|
||||
|
||||
volleyer之间从项目角度讲能否成功的一个关键因素。
|
||||
|
||||
我因为我们就要追求高性价比,甚至我可就是漏光,它是一个volume GI就常有的一个瑕疵。
|
||||
|
||||
这个体素的宽度比这个墙厚的时候,那漏光就可能产生你踩踩的踩到墙对面去,对不对?
|
||||
|
||||
什么时候这个漏光根本就无法预测的话,那制作组来bug单就会非常多。
|
||||
|
||||
这种拉扯很很可能就把这个制作优势,这个制作成本低这个优势给抵消掉。
|
||||
|
||||
这个方案必须本身必须能斩钉截铁的告诉美术,美术同事就说你怎么制作才能完全避免漏光。
|
||||
|
||||
起来简单的话,那也是真简单,就是把这个体速精度给尽量的提高,但你就别超过性能的预算。
|
||||
|
||||

|
||||
|
||||
行动里面是0.25米。
|
||||
|
||||
告诉美术同事,就是说所有的墙只要超过0.25米,厚度超过0.25米,它就一定不漏光。
|
||||
|
||||
0.25米对于大多数几何体来说,这是一个可以接受的方案。
|
||||
|
||||
运行时三星应用插值的时候,你那你无论说都不可能踩到墙面后墙后面去,对不对?
|
||||
|
||||
这个体你你这个墙比体塑要厚,那如果实在碰上必须小于0.25的,比如说铁皮房咱们再想hack的办法。
|
||||
|
||||
你存在这样一个非常清晰的标准,然后就能从制作层面比规避大多数绝大多数漏光。
|
||||
|
||||
这件事你必须从项目第一天就开始做,否则那你只能运行时去搞个什么算法去防漏光了。
|
||||
|
||||
做一个什么世界空间的那个奔驰刀等等。
|
||||
|
||||

|
||||
|
||||
每一个提速来说,我们还是看一下的基础的数据元素是什么。
|
||||
|
||||
体系就存一个六个方向的irregular的这个amin cube,其实就是半成明二以来非常经典的一个结构。
|
||||
|
||||
使命召使命召唤黑色行动系列也用了这个结构。
|
||||
|
||||
运行时是根据法线从这个六个方向里插值得到这个最终结果。
|
||||
|
||||
必须看一下,就是这个0.25它是一个很高的密度,比较高的精度。
|
||||
|
||||

|
||||
|
||||
用这个规整的3D纹理去存的话,这显存很快就炸了,轻松给你上8个G16个G所以就需要一个稀疏化存储的方案,就是越靠近mesh我才存储高精度的提速。
|
||||
|
||||

|
||||
|
||||
0.25米这么高精度的东西最好只贴着静态mesh版。
|
||||
|
||||
我们去读虚幻引擎自带的open VDB的存储代码的时候,我们就参考它实现了一个比较高效的方案。
|
||||
|
||||
open VDB它其实也是这个胡迪尼的一个核心组件,提渲染就是很核心的一个组件。
|
||||
|
||||

|
||||
|
||||
看就是这个数据块,它横向大小为64乘64,这是我们一个离线分块存储,每一个数据块就是这么大,这就是GI的数据。
|
||||
|
||||
每个数据块内部,我按照以4米为精度存储规整的粗糙的体素元素。
|
||||
|
||||
个体数个头大,它这个数量可以虽然它是一个规整的这么一个结构,大家看这就是这些黄色的体素,对于每一个4米的这个提速,我给它各个维度除以4,然后分裂产生64个这种1米宽度的提速,就这些蓝色的提速。
|
||||
|
||||

|
||||
|
||||
这一次我就要求这些蓝色的这一米提速,你必须得靠保只保留那些靠近麦是足够近的,加个阈值。
|
||||
|
||||

|
||||
|
||||
这里是3米,大家看远处的都舍弃掉了,然后再来一次继续分裂这种一米的体素,然后形成0.25米的体速。
|
||||
|
||||

|
||||
|
||||
这次再压缩阈值,要求只保留距离面试表面很近的0.3米的体速,你看这基本就贴着卖是摆了一层。
|
||||
|
||||
我们就可以形成一个逻辑上的,就是从上到下的这样一个数据结构,一个树形结构。
|
||||
|
||||

|
||||
|
||||
其实是个六十四叉树,类似这个图。
|
||||
|
||||
我们手机节点确实是从4米开始的,我们没有一个实际上的根节点。
|
||||
|
||||
给定一个word position之后,我们怎么定位说这个word position到底对应到哪个0.25米高精度提速上呢?
|
||||
|
||||
还是我们去参考open ABDB实现了这样一个算法,就是我们先把这个提速数给按照广度优先便利给便利一遍,然后形成一个序列。
|
||||
|
||||

|
||||
|
||||
用二叉树做了一个示例。
|
||||
|
||||
一个word position之后,怎么快速知道所需求的这个光照数据究竟在这个序列中哪个位置呢?
|
||||
|
||||
在每一个数字节点中,我们定义一个64 bit的exist mask,就是一个bit mask。
|
||||
|
||||
表示出每个节点的64个子节点当中,究竟哪些子节点是保留了的,一代表保留,零代表这个子节点距离max太远就给舍弃掉了。
|
||||
|
||||
对于这个bit mask实际上相当于一个小型的局部正方体,它包含了规整的4乘4个,一共有64个子节点是否存在的这么一个情况。
|
||||
|
||||
一个子节点只要给定相对于父节点的这个局部坐标,就可以快速对应到究竟就自己究竟是bit mask里面究竟哪一个bit。
|
||||
|
||||
你得知道如果你这个子节点是保留了的,比如说是这个一对不对,那它是第几个子节点呢?
|
||||
|
||||
你只要数这个bit max,从开头到你这个一共经历了多少个一。
|
||||
|
||||
正好C端里面有一条一个叫count base的指令,一条指令就可以帮你做好这个事情。
|
||||
|
||||
节点里我再保存好这个节点在第一个子节点的下标,比如说这是比如说就这是M再把count face的这个结果给加上来。
|
||||
|
||||
是2,那你就可以得到M加2。
|
||||
|
||||
这样就是用很低的代价就可以获得这个子节点在这个数里面这个下标,这里其实利用了一下这个BFS这个性质,就是说一个节点的子节点,它必定在这个序列中连续梦回大邑,然后你最多重复三次这个过程,直到访问或者miss。
|
||||
|
||||
按照这个顺序的这个序列这个顺序我们再把它对应的这个光照的这这个序列也给排布出来。
|
||||
|
||||
只要获得到任何一个position,只要查到了自己在树中的这个下标,就可以查到自己在这个光照序列中的下边,然后直接取用这里的数据就可以了。
|
||||
|
||||
当然运行是你要去采样八次,因为你要做三星星差值。
|
||||
|
||||
因为有大量的这种硬件方面的指令的支持,所以说这个访问速度其实它是很快的OK但是经过刚才这样的稀疏存储,总体体积依然是比较大的那毕竟每个尺寸它有48个bit,对不对?
|
||||
|
||||

|
||||
|
||||
一个六个方向的一个EAM and cube,那我们希望这个体积还是再小一点。
|
||||
|
||||
你相邻的体速,大家看相邻体速光照这个数值是接近的那我们希望再做一点压缩的处理,另外地图是支持TOD的,对不对?
|
||||
|
||||
个体数上可能存储更多的数据。
|
||||
|
||||
我们希望就是每个体数上存储的体积不要受总时段增长的影响。
|
||||
|
||||
为了你和压缩,我们还是要做点准备工作。
|
||||
|
||||

|
||||
|
||||
还是调用这个open ADB给这个场景,沿着match表面生成一层pro作为这个支撑点数据,这些probe的密度会远比那些提速要稀疏。
|
||||
|
||||
proof上给它烘焙一个48 bt的MN的cube。
|
||||
|
||||
如果有多个时段,比如说一天有八个时段,那你那你就烘焙8份就存储这八份儿都存储在这个数这个游戏包里。
|
||||
|
||||
probe你可以控制的很稀疏,这些东西它上面的数据它不怕那个数据多。
|
||||
|
||||
希望每一个体数上,就是它存储的这个数据量远小于原生的48倍数据量,并且不随着时段增长而增长。
|
||||
|
||||

|
||||
|
||||
就可以用距离它最近的四个problem上的m and cube去做一个线性的组合,把这个原生的结果给拟合出来。
|
||||
|
||||
这我其这就是我们希望让这个高纬度的向量上,就是48倍的那些很很好的向量,让这些稀疏的probe去存储。
|
||||
|
||||
这些密集的体素只需保存,只去组合它们就可以了。
|
||||
|
||||
每个密集的体系上就只需要存储四个index加四个float,这样只有恒定的16个bit,那就比48小多了。
|
||||
|
||||
这个相邻的提速的这个index也。
|
||||
|
||||
一样的,那就很容易利用类似游程编码的方式进行进一步的压缩,但这个就不能展开讲了,就时间比较有限OK那令所有提速的这个权重,还有就是所有的这些绿色节点发出来,这些红色的这些边,就他们所有的这权重,还有令所有pro 5上的这些支撑点的这些数据,都可以作为变量变化。
|
||||
|
||||

|
||||
|
||||
就希望调整这些所有的这些数据变化,然后让每一个体素上线性组合出来的这个数值与原生数值这个差尽量的小。
|
||||
|
||||
用的是差的平方的形式,因为它好求导,然后我们就希望把所有这些差的平方加起来,就这个平方和让它尽量的小,这就是一个全局优化的问题。
|
||||
|
||||

|
||||
|
||||
看似不好解决,但是这个函数无论是对于权重还是对于上的这个数值,都很容易把这个偏导数给写出来。
|
||||
|
||||
这就容易多了,对不对?
|
||||
|
||||
这函数本身它也就是一个对偶图形函数,它也没有什么病态结构。
|
||||
|
||||
就可以可以直接利用基于导数的这个梯度优化进行数值优化。
|
||||
|
||||
具体用的就是一个共轭梯度法,这个并没有什么不可想象的困难。
|
||||
|
||||
具体优化措施就实现了一个基于SMD和面向数据的共轭梯度优化器。
|
||||
|
||||

|
||||
|
||||
于这样一个数据块来说,64乘6 14米单核心9毫秒就可以完成优化。
|
||||
|
||||
实际上生产的时候肯定多核心并行了,平均不到半秒就优化这样一个数据块。
|
||||
|
||||
优化这个数据块的时间远小于它烘焙器烘焙它的时间。
|
||||
|
||||
我们再看经过所有这些优化,大家可以看一下这个显存,这个图放的有点小了。
|
||||
|
||||

|
||||
|
||||
给大家说一下,就典型的对于刀锋这个图,它的数据的垂直高度还是比较深的。
|
||||
|
||||
volume GI的总显存是控制在200兆左右,那这个量是可以接受的这一套说下来,我希望大家注意一下我们的这个思路顺序,就是优先考虑这网里面GI的这个方案的性价比,而不然后一步步推导出说必须用0.25米提速,然后再才有后续所有这些效率优化。
|
||||
|
||||
不是说开发的时候觉得我怎么高大上了,我怎么做了,然后可能那你可能就掉进什么别的陷阱里面OK。
|
||||
|
||||
我们希望就是PC上GI的这个视距,尤其是太阳光的这个间接光,就能完整的覆盖地图。
|
||||
|
||||

|
||||
|
||||
这个图没有过头map,就是我们刚才说streaming进来那些高精度的铁GGI数据,大概也就覆盖这么远,那肯定是覆盖不了全图的,对不对?
|
||||
|
||||
就希望以最好有那么一份数据常驻内存。
|
||||
|
||||
只有近景一个tell的GI那么大的大小,但是它把全图的这个GI全都给cover住了,这个该怎么搞呢?
|
||||
|
||||

|
||||
|
||||
海岛这个图,这个视距还会更远一点,我们希望所有的视距下都有GI覆盖。
|
||||
|
||||
的方法肯定不行,对不对?
|
||||
|
||||
需要全场景覆盖的这个方案,我们还是用稀疏的提速数据存储,这还是刚才一样的算法。
|
||||
|
||||

|
||||
|
||||
这个提速个头巨大无比,达到了8米,是你你也可以调整的更大。
|
||||
|
||||
覆盖全场景,它的这个总数量也是不多的。
|
||||
|
||||
我们要求每个体素体体速里面存储的这个数据量,还是必须和48倍是一数量级。
|
||||
|
||||
多两倍、三倍,但你绝对不能说翻个十倍、20倍,那是不行的那是不是就是我直接干脆存那个MM的Q不就完事了,那是不是就可以了?
|
||||
|
||||
这当然是可以,但是不够好。
|
||||
|
||||
给大家看一下,大家看这个巨型提速,这个8米这个大型提速里面如果只有一个数值的话,那你在里面采样,你不管怎么采的,都采出来就是一个数值是吧?
|
||||
|
||||

|
||||
|
||||
就存了一个,也不是不行,反正远处看去也就是糊的一团,但是总归是不够好。
|
||||
|
||||
就希望他最好能提供一个和近景接近的这么一个精度。
|
||||
|
||||
我们就希望大家看这个大cube里面,我们就希望给定一个任何一个XYZ的位置,这个XYZ是这个局部坐标,我们就希望有一个比较紧凑的函数F我输入一个XYZ之后,返回这个XYZ上面的这个光照,那这个返回值还是比较精确。
|
||||
|
||||
看把这个建筑移除了之后,这个函数F返回的是天光向上的这个可见度。
|
||||
|
||||
在我这个屋檐房子里的遮挡下,大家看里面是不是比较黑的,这个模拟的还是比较精确的。
|
||||
|
||||
如果这个大Q0就只有一个数值的话,那你采出来就没有这种变化了。
|
||||
|
||||
到底是什么函数?
|
||||
|
||||
表达这个函数呢?
|
||||
|
||||
函数用的是这样一个小型的深度神经网络的宽度为四。
|
||||
|
||||
两个隐藏层的激活函数用的就是VKREOU。
|
||||
|
||||
体系内部用烘焙器去密集的烘焙,它内部的这个光照数值相当于是提供大量的训练数据。
|
||||
|
||||
给定大量XYZ之后,这个ground choose的光照数值,然后用它这些大量的数据去训练这个小型的神经网络OK。
|
||||
|
||||
这个网络里面其实存储量也就是几个矩阵的事儿。
|
||||
|
||||
这个小型神经网络,它输入是xyzh,然后隐藏层一共是两层。
|
||||
|
||||
输出是一个单一的一个数值,就是一个luminance而不是RGB。
|
||||
|
||||
为了增大这个训练的精度。
|
||||
|
||||
的话只需要有两个矩阵乘法的计算量,它虽然它是神经网络,但是因为它很小,所以它并不会造成很大运行时的负担。
|
||||
|
||||

|
||||
|
||||
它在8米内又可以提供一个足够高的这么一个精度。
|
||||
|
||||
这个小型网络的函数表达,大家看,因为层数很小,你也用不着什么重度横向pat talk去做什么自动求导。
|
||||
|
||||
函数小。
|
||||
|
||||
手动的把里面各个参数导出写出来,然后继续使用一个共轭梯度优化器,就可以把它给优化出来。
|
||||
|
||||
吧?
|
||||
|
||||
用py tok部署到蓝盾服务器上那种流水线上,别自己再出点什么bug,把那个流水线给搞挂了。
|
||||
|
||||

|
||||
|
||||
看这个图,我们从外面看就是这个神经网络对天光可见性的拟合。
|
||||
|
||||
是不是已经非常接近烘焙器的烘焙效果。
|
||||
|
||||
你从房子内部看,它当然还是漏光的。
|
||||
|
||||

|
||||
|
||||
你作为远景GI这个漏光是没有关系的。
|
||||
|
||||
远景GI都是从室外往从远处往室内看,所以说这个光照你这个光照拟合它也是从一定是从更亮的地方往更暗的地方漏。
|
||||
|
||||
更亮的地方这个数值变化,它能造成那个优化器里面更大那个函数优化那个数值的惩罚。
|
||||
|
||||
这个算法一定是照顾光更加亮的地方,它才会从室内往室内漏。
|
||||
|
||||
它才作为远景点它没有问题。
|
||||
|
||||
我们说这我还是稍微解释一下,这个小型神经网络里面的非线性就来自它上面这一堆lik IREOU。
|
||||
|
||||
IRELU大家知道就是max 0.2A乘以a max 0.2AA所以说它这个函数总体上输出一定是一个分段线性函数。
|
||||
|
||||
看即便是在这些内部的漏光结构上,它是不是也是一个折线的,这样有个多个折线组成的。
|
||||
|
||||
它就是用尝试用多个折线去拟合这个建筑的这个形状,这非常有点像是画素描。
|
||||
|
||||
有大家有没有画过素描,你画素描的时候有一种画法,就是只能就即便是对这个圆,你也只能一笔一笔去画直画尽量多的直线去拟合这个圆儿,当然了我们是不可没有尽量那么多的直线的,我们的直线这个折线的数量就是VKR1U的数量。
|
||||
|
||||
这个拟合过程就是用画素描做了这样一个类比OK。
|
||||
|
||||
我们对比一下,这是没有只只有streaming p streaming GI的这么一个范围,还有这就是全图GI的这样一个范围。
|
||||
|
||||

|
||||
|
||||
K这海岛站这当然是更远的视距。
|
||||
|
||||
看一下这个就是同样一个vive下这个监狱的这样一个对比,这个就非常明显了。
|
||||
|
||||
这就是全世界GI这样一个方案。
|
||||
|
||||

|
||||
|
||||
这个它有多大显存呢?
|
||||
|
||||
看这些都是UR set存储的这些全数据GI的显离线数据大小,这些都是非压缩的数据,这上面显示有多大,存储显存就有多大。
|
||||
|
||||
看风风暴眼这么大,它大概是22兆,然后刀锋十一兆,监狱是八兆,那这个数据量是没有问题的。
|
||||
|
||||
数据量就是作为全图来说,这是一个很很合理这么一个数量,没有任何问题。
|
||||
|
||||

|
||||
|
||||
咱们再看一下这个地图,这个攀升取得攀升地图的GPU消耗。
|
||||
|
||||
GPU性能消耗全都加起来,就是远景加GI加上,就是所有都加起来它是与pray pass接近。
|
||||
|
||||
这就不谈具体多少毫秒,因为不同显卡是有差异的这张图上是3080显卡。
|
||||
|
||||
可以看到就是当base pass是0.85毫秒的时候,它是0.35。
|
||||
|
||||
消耗其实更接近深度垂pass。
|
||||
|
||||
垂pass 0.32就说那这个性能接消耗的代价是可以接受的。
|
||||
|
||||
你你你用一个接近深度play pass这么性能,把全部的GI都给做出来。
|
||||
|
||||
之前这个防漏光已经在制作阶段消灭了绝大多绝大多数运行时也没有任何昂贵的pass去做特殊处理漏光总显存可以控制在二百多兆,这才算的是高性价比。
|
||||
|
||||
I在这些侠这在这些所有问题都没有后顾之忧之后,大幅下降的制作成本它才有意义。
|
||||
|
||||
还是谈谈mobile这边,总体方案还是LED mab加这个volume GI的这个组合。
|
||||
|
||||

|
||||
|
||||
volume MGI目前没有办法做的像PC覆盖的那么远了,所以说mobile上这个light mab用的范围确实是更大的,对吧?
|
||||
|
||||
看这个面积是比较大的,但是这个lde mab依然还是只有人工光加天光可见度,那太阳光的间接光还是靠的这个volume。
|
||||
|
||||
可以看一下这个view下这个使用light map的这个物体,就是这么多。
|
||||
|
||||
我们可以看一下就是不使用lima b加上这个volume GI的这个区别。
|
||||
|
||||

|
||||
|
||||
大家可以对比看一下。
|
||||
|
||||
大家注意这个billiam GI这个范围就只有这么远,这是眼前这么64米,再远都是全远景的平均数值。
|
||||
|
||||

|
||||
|
||||
配合着远景的有这个天光可见度,这个lid map这个房屋的看起来还是OK的。
|
||||
|
||||
light这个mobile上这个volume,它具体这个算法和PC就有比较大的差异了。
|
||||
|
||||
上面手游普遍情况就是它是GPU瓶颈,所以你添加新算法,你不能给GPU添加太多的负担。
|
||||
|
||||

|
||||
|
||||
让GPU去运行时访问一个提速数的话,那就太耗费了。
|
||||
|
||||
手游的PCCPU倒是目前是经常什么八核心?
|
||||
|
||||
绑定一个比较节能的小核心,用它去来异步的组装的这个3D纹理,那还是比较合适的。
|
||||
|
||||
去异步组装一个包围相机的3D纹理,然后GPU里只要去采样一下就可以了,就立刻把这个UV电池给拿出来,对吧?
|
||||
|
||||
不比烂的外部就是费多少。
|
||||
|
||||
赖外部是采一个2D,你现在采一个3D的而已,然后手游同样是要离线的去分块存储数据,但是每一个数据块内部,你不能说就是这么稀疏的把它给存储了。
|
||||
|
||||

|
||||
|
||||
虽然省爆量,但是你去访问起诉数的话,那个CPU一定是有大量的cache miss,它就非常的CPU不友好。
|
||||
|
||||
也不能这样暴力的就把给它做一个规整的存储,这样CCPU访问起来非常爽了,但是它这个空间占用就会非常大,就是非常的耗费包量。
|
||||
|
||||
别忘了说我们手游的一个很重要的要求就是节省包量。
|
||||
|
||||

|
||||
|
||||
我们就做一个折中,就是每一个数据块内部就是分块存储,每一个数据块内部把这个数据也做一个分段连续存储,每个分段内部是一个规整的长方体网格相当于一个局部的小型3D纹理。
|
||||
|
||||
它只包围自己的局部空间,然后实时组装包围这个三相机的这个3D纹理的时候,就这个绿色的大框,它就是那个包围相机的3D纹理。
|
||||
|
||||
它的时候,把每个数据块对齐到3D纹理中自己对应的那个位置上,然后把这个数据给填充到3D纹理里面去。
|
||||
|
||||
这个3D纹理肯定是要做各种scoring,然后追求一个稳定的显示,然后分层分蒸的上传到GPU上去。
|
||||
|
||||
你需要注意的是,你可以多利用手机上这种特有的这种半精度浮点数的这个SMD指令,它它就可以极大的加速这个组装的过程。
|
||||
|
||||
大家这就到了整个GI方案里代码量最大的地方了,就是这个手游的防漏光。
|
||||
|
||||
大家注意这个3D门里精度是1米,那你墙是无论如何都不可能做到1米厚的。
|
||||
|
||||
这个没有办法通过制作来解决,那怎么办呢?
|
||||
|
||||
就要定义一种叫做iteration of volume的这个体积,就是简称IV。
|
||||
|
||||
IV是随着建筑一起做的,你可以认为是一个简化的麦氏,就是大体表达出这个建筑的形状。
|
||||
|
||||
IV其实是一个多面体了,它有多个面组成。
|
||||
|
||||
的时候要求一个建筑可以由多个IV拼起来,每一个IV必须是突兀多面体。
|
||||
|
||||
这个IV的这个房子的核心区域,它是一个正方体,那IV的每个面正好是那这个IV每个面正好是卡在墙里面的那比如这个面它代表了这堵墙,我把它拉出来了,看的明显一些。
|
||||
|
||||
就可以在离线根据IV的面片和这个体素的位置,识别出能够导致漏光的那些体素,然后把这些体素与这个面片给关联起来。
|
||||
|
||||
在运行时的时候就可以根据相机在这个面片的前与后这个关系来判断出出这些体素是不是在墙的另一边。
|
||||
|
||||
是的话,就把这个体素的数值给fout掉。
|
||||
|
||||
看一下这个但是因为漏光的区域可能涉及到3D纹理里面各个区域,而这个纹理你又不得不分针上传,所以说你就不得不允许有一个滞后更新的这么一个现象。
|
||||
|
||||
在PVP对战的时候,这个倒是没有人在意的,这个实在是一个没有办法的事情。
|
||||
|
||||
防手游的防说起来简单,其实它是一个巨大的代码量,它可能是整个所有系统里代码量最大的地方,但其实没有关系,大家看我头发,然后手游的这个数据压缩和端游是类似的,只不过它这个pro不会稀疏很多。
|
||||
|
||||
刚才不说了吗?
|
||||
|
||||
靠近麦氏的地方,每个数据库就是靠近麦氏的地方有这个数据块cover,远离mesh的地方那如果这个数据是什么呢?
|
||||
|
||||
直接采用这个地方,如果需要数据的话,就直接采用距离操最近的这个pro上的光照。
|
||||
|
||||
运行时还去动态生成了一个distant back field,指向每一个位置,距离它最近的那个pro OK。
|
||||
|
||||
看一下重点关注的爆量,volume加上这个报量最大这个图GI常规硒鼓达到了334兆。
|
||||
|
||||
如果这个全都用lid map来做的,这个PI的报量会翻倍五倍不止。
|
||||
|
||||
这么大的地图你全用LDMAB做,那肯定也不太可能的事,就是工期就不允许。
|
||||
|
||||
这里就稍微总结一下,就是手机上的volume GI仍然是大幅的降低了制作的代价。
|
||||
|
||||
纯用来的mab相比,这个包量也大幅下降,都达这都达到了目的。
|
||||
|
||||
光我们用了IV的方式,也没有造成什么太多的拉扯。
|
||||
|
||||
这个太阳光的棒子相对于来的map相比,确实是范围小了,就眼前64米这么远。
|
||||
|
||||
手游上也是确实足够了,总体性价比还是OK的,没有问题。
|
||||
|
||||
然后我们看看一下这个远景的方案。
|
||||
|
||||
远景的话我刚才说了,就是远景就一个平均值替代,如果再高配一点机器用一个纵向俯拍的2d text。
|
||||
|
||||
有一个非常重要的是,我们大量的使用了SMD。
|
||||
|
||||
UE对SMD的这个封装也是非常完善的,就是直接很直观的用这个函数名字,你就不用再去写SMD的时候对应的一个一个那种鬼画符。
|
||||
|
||||
对,刚才这是多人的部分,咱们再看看单人战役的部分,这就是这个黑鹰坠落。
|
||||
|
||||
其实能说的并不多,就是黑鹰黑鹰坠落本身开发的时间非常短。
|
||||
|
||||
作为罗那罗美作为不需要让人进行任何等待,这个实时也其实美术对它进行调节,能立即看到光照效果。
|
||||
|
||||
他那个反复调整的效率,它这个光照品质是更高的。
|
||||
|
||||
K然后黑鹰的lemon没有做二次开发,就是优于我的原生lemon。
|
||||
|
||||
是用的是软光追没,没有开硬光追。
|
||||
|
||||
试验的情况看,就是3080级别以上显卡的时候,用硬光追的这个路面性能才会超过这个软光追,所以这里还是稍微照顾了一下显卡的性能OK。
|
||||
|
||||
咱们今天就做一下这个总结。
|
||||
|
||||
就是这个live map需要做取舍,你需要满你只要能满足性能和这个效果的时候,你可以更多的使用这个William GI去提升制作效率。
|
||||
|
||||
如果比如说你从0.25硬拉到0.1巷light my普遍这个文速精度是0.1。
|
||||
|
||||
从0.25拉到0.1之后,其实这个边际效应是有点递减的。
|
||||
|
||||
你可以多采用一下这个volunt来提升一下效率,非常关键的就是你做好防漏光是volume GI跳出性价比陷阱的关键。
|
||||
|
||||
你说你做volume GI的话,你应该优先考虑防漏光,然后再想后边那些其他算法OK,然后PC上我们多关注一下显存的控制,然后包边上要多注关注包量,然后还有这个就是像罗曼能带来巨大的效率制作效率的提升。
|
||||
|
||||
再过几年,等玩家最低显卡都已经到3080级别的时候,roman一定就是在市面上非常最普遍的一个方案。
|
||||
|
||||
K这就是我今天的演讲,谢谢大家我的车,谢谢大家。
|
||||
|
||||
738
tools/tongyi/20260207_135740_merged.md
Normal file
738
tools/tongyi/20260207_135740_merged.md
Normal file
@@ -0,0 +1,738 @@
|
||||
# 拼合内容
|
||||
|
||||
生成时间: 2026-02-07 13:57:40
|
||||
|
||||
然后我现在再给大家介绍一下,这个三角洲行动中的这个全局光照方案。
|
||||
|
||||
我是来自天美第三工作室引擎技术组的魏东陈。
|
||||
|
||||
然后讲之前我还是先自我介绍一下,我是15年加入的腾讯,然后最早是参加了乐高无限的开发。
|
||||
|
||||
然后到了天美之后,是先后参与了这个穿越火线手游,然后绝地求生、全军出击,还有这个CODM。
|
||||
|
||||
就是对我们现在的这个三角洲行动。
|
||||
|
||||
我们先看一下三角洲行动的这个总体画面效果。
|
||||
|
||||

|
||||
|
||||
OK, 我今天会按照这一个顺序,我先简单介绍一下项目,我会把常见的GI方案给罗列出来。
|
||||
|
||||
我们再根据三角洲行动这个项目,我们来看一下这个项目到底怎样进行一个技术选型,把这个完整的方案给讲一下。
|
||||
|
||||
这个项目现在已经累积了很多的DAU了。
|
||||
|
||||
从他第一天开始,我们就希望尽量支持广泛的平台,然后大家一起爽玩。
|
||||
|
||||

|
||||
|
||||
他所有地图都是单手通发的,就不会说只有一个地图就是在某一个特定平台上才发布,就不会这样。
|
||||
|
||||
对于每一个地图来说,就是要求玩家可见的这个区域,我们全都有全局光照的覆盖。
|
||||
|
||||
这就带来一些挑战,大家看就是要求所见的区域全都有这个GI的覆盖。
|
||||
|
||||

|
||||
|
||||
PC和这个手机这是性能差异很大的的平台。
|
||||
|
||||
这就是说你选这个GI方案,肯定是有一些拉扯的那我们还是先把所有的GI方案都回顾一下,然后我们再看看根据这个双端的体系到底该怎么选择。
|
||||
|
||||

|
||||
|
||||
首先是light map,这是最简单和最传统最节省的方案。
|
||||
|
||||
然后还有就是像这个volume GI,这个是在PC上可以稀疏化存储的这种光照体质体素数据,它又有高精度,它又显存控制得住是吧?
|
||||
|
||||

|
||||
|
||||
现在像刺客信条、孤岛惊魂这类游戏都非常广泛的用了。
|
||||
|
||||
或者你像手游上这种就是有3D纹理,规整3D纹理的这个volume DI。
|
||||
|
||||

|
||||
|
||||
因为它有3D纹理,所以说它寻址采样就是比较GPU友好,就节省性能。
|
||||
|
||||
然后还有就是像以鲁曼为代表的这个全动态的,它效果又好的,美术迭代效率又高。
|
||||
|
||||
那你看这些方案里,如果咱们比运行是性能的话,如果从性能的角度讲,那light map它肯定是最好的,对吧?
|
||||
|
||||

|
||||
|
||||
他给你给一个2U然后就把这个数据,把这个有微电子给采出来了,然后GI的数据就得到了,就非常的高效。
|
||||
|
||||
而且美术可以在局部增大这个light map的文字密度,就得到很高的精度。
|
||||
|
||||

|
||||
|
||||
然后volume GI的话,它这里是以这种稀疏化存储的GI为例子,它这个数据存储获取就稍微麻烦一点。
|
||||
|
||||
你就得在shader里去访问一个树形的体系结构,然后根据这个position去查询到这个光照,最后再还要根据法线去做一个解码。
|
||||
|
||||
因为你可你可能又保存了这个SH或者是MND cube这种结构,那这样的话它消耗当然是比lad mab要大一些,但是还是可以接受了。
|
||||
|
||||
就从我们经验看,就是说你660显卡就跑一个1080P下跑一个16G那3毫秒以内是没有问题的。
|
||||
|
||||
然后对于全动态DI像是roman d这个是没有办法,因为它迭代效率它是非常高的。
|
||||
|
||||
但是毕竟它是个全动态的效果,这那的pass的数量就比前两种都多了一个数量级。
|
||||
|
||||
刚才是比运行是性能,但是咱们如果比项目成本的话,light bank你虽然效率高,但是你它的项目成本也非常高。
|
||||
|
||||

|
||||
|
||||
你要制作这一堆2U而且这个麦氏和这个光照不解耦,做起来非常搞人心态的事儿。
|
||||
|
||||
而且你一个match到底在哪些地方要分配更多的文素,那迭代起来也绝对就是一个时间黑洞。
|
||||
|
||||
更大的地图,比如说像这个长空系统。
|
||||
|
||||

|
||||
|
||||
如果这全都铺上来的,不是你看你看这个框的数量,这根本就是一个不可能的事情。
|
||||
|
||||
那相比之下,这volunt这就舒服多了是吧?
|
||||
|
||||

|
||||
|
||||
你这没有2U这个烦恼,你迈出和光照解耦,运行师给定一个position之后,立刻就可以有根据算法查到自己所需要的这个数据。
|
||||
|
||||
尤其像这种大范围的这长工这种地图,如果你用volume GI,你甚至可以用一个自动化的流水线,就每天进行不停的进行滚动烘焙。
|
||||
|
||||
美术只需要拉一下数据就可以得到你所需要的光照,对不对?
|
||||
|
||||

|
||||
|
||||
再比如这个项目成本,就是像luman还有这些全能快捷,他把这个制作成本已经加压缩到接近为零,没有任何等待对吧?
|
||||
|
||||
那在这样一条光谱上大家看越靠近左边,它这个运行时效率越高,但是制作成本也越高越靠近右边,它这个开发者越爽,迭代也越快,但是性能消耗也越大。
|
||||
|
||||
这么多方案里,三角洲行,我跟大家说就全都用了全都用了。
|
||||
|
||||

|
||||
|
||||
包括你大家听昨天的远光八四的分享,还有你如果去看那个COD的分享,大家都是在这一条光谱上都做出了个自己,就是几乎全都用了。
|
||||
|
||||
我认为这可以是一种可以说这叫一种趋同进化了。
|
||||
|
||||
我们还是看一下这个双端它对GI到底是怎样一个需求呢?
|
||||
|
||||

|
||||
|
||||
PC这边我们希望当然说是拔高效果,那不用说,但是你不论是lead mp还是vollied MGI,尤其是vollied GI你这精度一旦上去之后,那显存就会出现一个几何级别的增长,对不对?
|
||||
|
||||
那PC这边所以你就要时刻提醒自己,就是说如何控制显存。
|
||||
|
||||
PC的match它也更加复杂。
|
||||
|
||||
如果你在PC match上搞这个light map,那这个制作成本会更大。
|
||||
|
||||
所以就希望就更加希望往里面嚼,把它这个制作成本降低这个优势给发挥出来,所以说这里打了四颗星代表对他的一个期待。
|
||||
|
||||
手游这边大家可能想不到,就是对于GI系统或者说对所有的系统,首要的要求就是你要把这个包量给我降下来,对吧?
|
||||
|
||||
这是体保证手游玩家体验的一个非常重要的一环。
|
||||
|
||||
如果你无脑的全都铺了个light map,那包量就会过于庞大。
|
||||
|
||||
尤其你这还有TOD的这么一个情况下,手游的性能比PC弱很多,所以你要控制好消耗。
|
||||
|
||||
现在你要注意大多数其实它就是普遍手游一个情况,就是GPU瓶颈。
|
||||
|
||||
所以说如果你有这个的方案的话,你这个消耗就不要比这个live map增长太多。
|
||||
|
||||
这就是我们对他的这样一个期望。
|
||||
|
||||
当然受限于性能的话,手游的这个light map使用范围确实还是要更大一些。
|
||||
|
||||
OK然后我们这个选用原则就是说我们希望各个平台上都要优先保证运行效率。
|
||||
|
||||

|
||||
|
||||
这不是说什么2K60FPS,因为多人竞技,你最好要上个120、130、100 44这种,然后在此基础上尽量的提高这个制作速度。
|
||||
|
||||
所有的多人体图端我们都用了light map加volume GI的这个组合方案。
|
||||
|
||||
当然TC上这个volume I用的就是更多一些。
|
||||
|
||||
单人战役的话,因为没有PVP竞技的情况下,就直接用鲁本拔高效果。
|
||||
|
||||
因为它这个迭代效率高,事实上能达到更好的一个品美术品质。
|
||||
|
||||
OK我们先看这个PCR这个light map,我先说一下这个light map Baker,这是我们自己研的一个ggs红莓器,从CF手游就开始用了,然后一直迭代一直用到三角洲行动,现在已经是一个基于GPU的这样一个烘焙器。
|
||||
|
||||

|
||||
|
||||
Line map这个东西可以说让人真是又爱又恨,它这个效率品质都很好。
|
||||
|
||||
但是它场景到了之后,离线制作的这个也是非常吞噬时间的一个东西。
|
||||
|
||||
我们我们又在希局部希望很拉高精度的地方,就还是用了这个light map。
|
||||
|
||||
面积最大的这个长空地图,只有这些标记为浅绿色的地方是用了light map。
|
||||
|
||||
这个light map上面只有只有人工光和天光的可见度。
|
||||
|
||||
Light map它主要是负责室内光照,可以说这也可以让可以让各地图制作局部的各个切块之后,各个美术同志他可以独立的进行烘焙,它不受整体太阳光照方向变化调整的影响。
|
||||
|
||||
这个大地图使用lid map的,它的主要限制就是制作效率。
|
||||
|
||||

|
||||
|
||||
你如果像长虹那种地图,你用了lid map之后全都用lid map,那你可能就是这地图永远就发布不了,尤其你还有这个TOD的情况下。
|
||||
|
||||
我们就来看一下PC上覆盖范围最大的这么一个部分,就是它这个volume键,这是我们的重点。
|
||||
|
||||

|
||||
|
||||
它最大的好处就是大幅的提升了制作效率品质,也能接进来的map。
|
||||
|
||||
我会从这个防漏光基础的数据元素,然后存储、拟合、压缩,最后到全世界方案的这样一个顺序来介绍一下这个GI的方案。
|
||||
|
||||
我们还是这里我就先得提一句,如果你准备开始用volume MGI之后,它就有一个非常隐私的陷阱。
|
||||
|
||||

|
||||
|
||||
这是就是经历那么几个项目你才能得出来的这么一个经验。
|
||||
|
||||
大家看这个函数图表,横轴代表制作代价,纵轴代表这个GI方案的综合效果。
|
||||
|
||||
综合效果就是指画面加运行效率,综合起来这种效果大家看在这个点,这是light map,它有很高的制作代价,但是它的综合效果也确实很好。
|
||||
|
||||
那理想中的volume jr是什么?
|
||||
|
||||
你应该是大幅提升制作效率,对不对?
|
||||
|
||||
然后代价大幅下降,但是它的综合效果下降很小,这是理想中的王俊杰。
|
||||
|
||||
但是就非常容易进去一个陷阱什么的。
|
||||
|
||||
就是你搞的这种就是你做出来之后确实和麦是解耦了,那程序觉得自己好厉害,然后美术由于工作负担下降,他也觉得很开心。
|
||||
|
||||
但是你别忘了,这就是volume GI他自己也有像是漏光这种固有问题。
|
||||
|
||||
这个问题如果处理不好就集中,极易造成制作成本虽然下降了,但是画面也有一点下降。
|
||||
|
||||
比如说有一点漏光瑕疵等等,或者说你画面没有下降,程序性能大幅下降。
|
||||
|
||||
就比如说你运行时必须花一个大代价去解决这个漏光问题。
|
||||
|
||||
这种volume GI相对于这个light map来说,大家发现没有,它的总的性价比并没有大的变化。
|
||||
|
||||
但是制作团队在做这个东西的时候,它就各有各的爽点。
|
||||
|
||||
我前面说了就是说对此对这个性价比提升就是感知不强。
|
||||
|
||||
然后最恐怖的时候,就当这个测试回单的时候,要么发现你这个东西效率不太好,要么发现画面有一些什么瑕疵之类的,然后产生很多很多bug的。
|
||||
|
||||
然后为了修复这些bug,又得去各种hack解决,反复折腾,然后把这个volley GI的清低成本的优势给搞没了。
|
||||
|
||||
说实话就是我见过一些项目是进入过这个陷阱的这也是经历了一些就是这样一些经验教训,然后才我们才开始特别重视这个事情。
|
||||
|
||||
这里所以我先提防漏光,就是防漏光。
|
||||
|
||||

|
||||
|
||||
其实volleyer之间从项目角度讲能否成功的一个关键因素。
|
||||
|
||||
所以说我因为我们就要追求高性价比,甚至我可就是漏光,它是一个volume GI就常有的一个瑕疵。
|
||||
|
||||
当这个体素的宽度比这个墙厚的时候,那漏光就可能产生你踩踩的踩到墙对面去,对不对?
|
||||
|
||||
如果什么时候这个漏光根本就无法预测的话,那制作组来bug单就会非常多。
|
||||
|
||||
那这种拉扯很很可能就把这个制作优势,这个制作成本低这个优势给抵消掉。
|
||||
|
||||
所以说这个方案必须本身必须能斩钉截铁的告诉美术,美术同事就说你怎么制作才能完全避免漏光。
|
||||
|
||||
如果说起来简单的话,那也是真简单,就是把这个体速精度给尽量的提高,但你就别超过性能的预算。
|
||||
|
||||

|
||||
|
||||
三角洲行动里面是0.25米。
|
||||
|
||||
然后告诉美术同事,就是说所有的墙只要超过0.25米,厚度超过0.25米,它就一定不漏光。
|
||||
|
||||
那0.25米对于大多数几何体来说,这是一个可以接受的方案。
|
||||
|
||||
那运行时三星应用插值的时候,你那你无论说都不可能踩到墙面后墙后面去,对不对?
|
||||
|
||||
因为你这个体你你这个墙比体塑要厚,那如果实在碰上必须小于0.25的,比如说铁皮房咱们再想hack的办法。
|
||||
|
||||
但是你存在这样一个非常清晰的标准,然后就能从制作层面比规避大多数绝大多数漏光。
|
||||
|
||||
但这件事你必须从项目第一天就开始做,否则那你只能运行时去搞个什么算法去防漏光了。
|
||||
|
||||
比如说做一个什么世界空间的那个奔驰刀等等。
|
||||
|
||||

|
||||
|
||||
对于每一个提速来说,我们还是看一下的基础的数据元素是什么。
|
||||
|
||||
每一个体系就存一个六个方向的irregular的这个amin cube,其实就是半成明二以来非常经典的一个结构。
|
||||
|
||||
像使命召使命召唤黑色行动系列也用了这个结构。
|
||||
|
||||
然后运行时是根据法线从这个六个方向里插值得到这个最终结果。
|
||||
|
||||
我们必须看一下,就是这个0.25它是一个很高的密度,比较高的精度。
|
||||
|
||||

|
||||
|
||||
如果你用这个规整的3D纹理去存的话,这显存很快就炸了,轻松给你上8个G16个G所以就需要一个稀疏化存储的方案,就是越靠近mesh我才存储高精度的提速。
|
||||
|
||||

|
||||
|
||||
那0.25米这么高精度的东西最好只贴着静态mesh版。
|
||||
|
||||
这里我们去读虚幻引擎自带的open VDB的存储代码的时候,我们就参考它实现了一个比较高效的方案。
|
||||
|
||||
那open VDB它其实也是这个胡迪尼的一个核心组件,提渲染就是很核心的一个组件。
|
||||
|
||||

|
||||
|
||||
大家看就是这个数据块,它横向大小为64乘64,这是我们一个离线分块存储,每一个数据块就是这么大,这就是GI的数据。
|
||||
|
||||
然后每个数据块内部,我按照以4米为精度存储规整的粗糙的体素元素。
|
||||
|
||||
这个体数个头大,它这个数量可以虽然它是一个规整的这么一个结构,大家看这就是这些黄色的体素,对于每一个4米的这个提速,我给它各个维度除以4,然后分裂产生64个这种1米宽度的提速,就这些蓝色的提速。
|
||||
|
||||

|
||||
|
||||
但是这一次我就要求这些蓝色的这一米提速,你必须得靠保只保留那些靠近麦是足够近的,加个阈值。
|
||||
|
||||

|
||||
|
||||
比如说这里是3米,大家看远处的都舍弃掉了,然后再来一次继续分裂这种一米的体素,然后形成0.25米的体速。
|
||||
|
||||

|
||||
|
||||
但这次再压缩阈值,要求只保留距离面试表面很近的0.3米的体速,你看这基本就贴着卖是摆了一层。
|
||||
|
||||
这样我们就可以形成一个逻辑上的,就是从上到下的这样一个数据结构,一个树形结构。
|
||||
|
||||

|
||||
|
||||
它其实是个六十四叉树,类似这个图。
|
||||
|
||||
我只是我们手机节点确实是从4米开始的,我们没有一个实际上的根节点。
|
||||
|
||||
当我们给定一个word position之后,我们怎么定位说这个word position到底对应到哪个0.25米高精度提速上呢?
|
||||
|
||||
这个还是我们去参考open ABDB实现了这样一个算法,就是我们先把这个提速数给按照广度优先便利给便利一遍,然后形成一个序列。
|
||||
|
||||

|
||||
|
||||
这里用二叉树做了一个示例。
|
||||
|
||||
给定一个word position之后,怎么快速知道所需求的这个光照数据究竟在这个序列中哪个位置呢?
|
||||
|
||||
它在每一个数字节点中,我们定义一个64 bit的exist mask,就是一个bit mask。
|
||||
|
||||
它表示出每个节点的64个子节点当中,究竟哪些子节点是保留了的,一代表保留,零代表这个子节点距离max太远就给舍弃掉了。
|
||||
|
||||
至于对于这个bit mask实际上相当于一个小型的局部正方体,它包含了规整的4乘4个,一共有64个子节点是否存在的这么一个情况。
|
||||
|
||||
任何一个子节点只要给定相对于父节点的这个局部坐标,就可以快速对应到究竟就自己究竟是bit mask里面究竟哪一个bit。
|
||||
|
||||
然后你得知道如果你这个子节点是保留了的,比如说是这个一对不对,那它是第几个子节点呢?
|
||||
|
||||
其实你只要数这个bit max,从开头到你这个一共经历了多少个一。
|
||||
|
||||
那正好C端里面有一条一个叫count base的指令,一条指令就可以帮你做好这个事情。
|
||||
|
||||
每个节点里我再保存好这个节点在第一个子节点的下标,比如说这是比如说就这是M再把count face的这个结果给加上来。
|
||||
|
||||
比如说是2,那你就可以得到M加2。
|
||||
|
||||
那这样就是用很低的代价就可以获得这个子节点在这个数里面这个下标,这里其实利用了一下这个BFS这个性质,就是说一个节点的子节点,它必定在这个序列中连续梦回大邑,然后你最多重复三次这个过程,直到访问或者miss。
|
||||
|
||||
然后按照这个顺序的这个序列这个顺序我们再把它对应的这个光照的这这个序列也给排布出来。
|
||||
|
||||
那你只要获得到任何一个position,只要查到了自己在树中的这个下标,就可以查到自己在这个光照序列中的下边,然后直接取用这里的数据就可以了。
|
||||
|
||||
然后当然运行是你要去采样八次,因为你要做三星星差值。
|
||||
|
||||
但是因为有大量的这种硬件方面的指令的支持,所以说这个访问速度其实它是很快的OK但是经过刚才这样的稀疏存储,总体体积依然是比较大的那毕竟每个尺寸它有48个bit,对不对?
|
||||
|
||||

|
||||
|
||||
它是一个六个方向的一个EAM and cube,那我们希望这个体积还是再小一点。
|
||||
|
||||
毕竟你相邻的体速,大家看相邻体速光照这个数值是接近的那我们希望再做一点压缩的处理,另外地图是支持TOD的,对不对?
|
||||
|
||||
那每个体数上可能存储更多的数据。
|
||||
|
||||
总之我们希望就是每个体数上存储的体积不要受总时段增长的影响。
|
||||
|
||||
然后为了你和压缩,我们还是要做点准备工作。
|
||||
|
||||

|
||||
|
||||
首先还是调用这个open ADB给这个场景,沿着match表面生成一层pro作为这个支撑点数据,这些probe的密度会远比那些提速要稀疏。
|
||||
|
||||
每一个proof上给它烘焙一个48 bt的MN的cube。
|
||||
|
||||
当然如果有多个时段,比如说一天有八个时段,那你那你就烘焙8份就存储这八份儿都存储在这个数这个游戏包里。
|
||||
|
||||
但是probe你可以控制的很稀疏,这些东西它上面的数据它不怕那个数据多。
|
||||
|
||||
我们希望每一个体数上,就是它存储的这个数据量远小于原生的48倍数据量,并且不随着时段增长而增长。
|
||||
|
||||

|
||||
|
||||
因此就可以用距离它最近的四个problem上的m and cube去做一个线性的组合,把这个原生的结果给拟合出来。
|
||||
|
||||
那这我其这就是我们希望让这个高纬度的向量上,就是48倍的那些很很好的向量,让这些稀疏的probe去存储。
|
||||
|
||||
而这些密集的体素只需保存,只去组合它们就可以了。
|
||||
|
||||
所以说每个密集的体系上就只需要存储四个index加四个float,这样只有恒定的16个bit,那就比48小多了。
|
||||
|
||||
而且这个相邻的提速的这个index也。
|
||||
|
||||
是一样的,那就很容易利用类似游程编码的方式进行进一步的压缩,但这个就不能展开讲了,就时间比较有限OK那令所有提速的这个权重,还有就是所有的这些绿色节点发出来,这些红色的这些边,就他们所有的这权重,还有令所有pro 5上的这些支撑点的这些数据,都可以作为变量变化。
|
||||
|
||||

|
||||
|
||||
我们就希望调整这些所有的这些数据变化,然后让每一个体素上线性组合出来的这个数值与原生数值这个差尽量的小。
|
||||
|
||||
这里用的是差的平方的形式,因为它好求导,然后我们就希望把所有这些差的平方加起来,就这个平方和让它尽量的小,这就是一个全局优化的问题。
|
||||
|
||||

|
||||
|
||||
这看似不好解决,但是这个函数无论是对于权重还是对于上的这个数值,都很容易把这个偏导数给写出来。
|
||||
|
||||
那这就容易多了,对不对?
|
||||
|
||||
你看这函数本身它也就是一个对偶图形函数,它也没有什么病态结构。
|
||||
|
||||
所以就可以可以直接利用基于导数的这个梯度优化进行数值优化。
|
||||
|
||||
这里具体用的就是一个共轭梯度法,这个并没有什么不可想象的困难。
|
||||
|
||||
然后具体优化措施就实现了一个基于SMD和面向数据的共轭梯度优化器。
|
||||
|
||||

|
||||
|
||||
对应于这样一个数据块来说,64乘6 14米单核心9毫秒就可以完成优化。
|
||||
|
||||
那实际上生产的时候肯定多核心并行了,平均不到半秒就优化这样一个数据块。
|
||||
|
||||
实际上优化这个数据块的时间远小于它烘焙器烘焙它的时间。
|
||||
|
||||
然后我们再看经过所有这些优化,大家可以看一下这个显存,这个图放的有点小了。
|
||||
|
||||

|
||||
|
||||
我给大家说一下,就典型的对于刀锋这个图,它的数据的垂直高度还是比较深的。
|
||||
|
||||
但是volume GI的总显存是控制在200兆左右,那这个量是可以接受的这一套说下来,我希望大家注意一下我们的这个思路顺序,就是优先考虑这网里面GI的这个方案的性价比,而不然后一步步推导出说必须用0.25米提速,然后再才有后续所有这些效率优化。
|
||||
|
||||
而不是说开发的时候觉得我怎么高大上了,我怎么做了,然后可能那你可能就掉进什么别的陷阱里面OK。
|
||||
|
||||
但是我们希望就是PC上GI的这个视距,尤其是太阳光的这个间接光,就能完整的覆盖地图。
|
||||
|
||||

|
||||
|
||||
注意这个图没有过头map,就是我们刚才说streaming进来那些高精度的铁GGI数据,大概也就覆盖这么远,那肯定是覆盖不了全图的,对不对?
|
||||
|
||||
我们就希望以最好有那么一份数据常驻内存。
|
||||
|
||||
它只有近景一个tell的GI那么大的大小,但是它把全图的这个GI全都给cover住了,这个该怎么搞呢?
|
||||
|
||||

|
||||
|
||||
像海岛这个图,这个视距还会更远一点,我们希望所有的视距下都有GI覆盖。
|
||||
|
||||
刚才的方法肯定不行,对不对?
|
||||
|
||||
这个需要全场景覆盖的这个方案,我们还是用稀疏的提速数据存储,这还是刚才一样的算法。
|
||||
|
||||

|
||||
|
||||
但这个提速个头巨大无比,达到了8米,是你你也可以调整的更大。
|
||||
|
||||
即使覆盖全场景,它的这个总数量也是不多的。
|
||||
|
||||
然后我们要求每个体素体体速里面存储的这个数据量,还是必须和48倍是一数量级。
|
||||
|
||||
你可以多两倍、三倍,但你绝对不能说翻个十倍、20倍,那是不行的那是不是就是我直接干脆存那个MM的Q不就完事了,那是不是就可以了?
|
||||
|
||||
但是这当然是可以,但是不够好。
|
||||
|
||||
我给大家看一下,大家看这个巨型提速,这个8米这个大型提速里面如果只有一个数值的话,那你在里面采样,你不管怎么采的,都采出来就是一个数值是吧?
|
||||
|
||||

|
||||
|
||||
因为你就存了一个,也不是不行,反正远处看去也就是糊的一团,但是总归是不够好。
|
||||
|
||||
我们就希望他最好能提供一个和近景接近的这么一个精度。
|
||||
|
||||
然后我们就希望大家看这个大cube里面,我们就希望给定一个任何一个XYZ的位置,这个XYZ是这个局部坐标,我们就希望有一个比较紧凑的函数F我输入一个XYZ之后,返回这个XYZ上面的这个光照,那这个返回值还是比较精确。
|
||||
|
||||
大家看把这个建筑移除了之后,这个函数F返回的是天光向上的这个可见度。
|
||||
|
||||
那么在我这个屋檐房子里的遮挡下,大家看里面是不是比较黑的,这个模拟的还是比较精确的。
|
||||
|
||||
但如果这个大Q0就只有一个数值的话,那你采出来就没有这种变化了。
|
||||
|
||||
那到底是什么函数?
|
||||
|
||||
怎么表达这个函数呢?
|
||||
|
||||
这个函数用的是这样一个小型的深度神经网络的宽度为四。
|
||||
|
||||
有两个隐藏层的激活函数用的就是VKREOU。
|
||||
|
||||
这个体系内部用烘焙器去密集的烘焙,它内部的这个光照数值相当于是提供大量的训练数据。
|
||||
|
||||
就是给定大量XYZ之后,这个ground choose的光照数值,然后用它这些大量的数据去训练这个小型的神经网络OK。
|
||||
|
||||
然后这个网络里面其实存储量也就是几个矩阵的事儿。
|
||||
|
||||
然后这个小型神经网络,它输入是xyzh,然后隐藏层一共是两层。
|
||||
|
||||
然后输出是一个单一的一个数值,就是一个luminance而不是RGB。
|
||||
|
||||
这就是为了增大这个训练的精度。
|
||||
|
||||
推理的话只需要有两个矩阵乘法的计算量,它虽然它是神经网络,但是因为它很小,所以它并不会造成很大运行时的负担。
|
||||
|
||||

|
||||
|
||||
但它在8米内又可以提供一个足够高的这么一个精度。
|
||||
|
||||
然后这个小型网络的函数表达,大家看,因为层数很小,你也用不着什么重度横向pat talk去做什么自动求导。
|
||||
|
||||
因为函数小。
|
||||
|
||||
可以手动的把里面各个参数导出写出来,然后继续使用一个共轭梯度优化器,就可以把它给优化出来。
|
||||
|
||||
是吧?
|
||||
|
||||
你用py tok部署到蓝盾服务器上那种流水线上,别自己再出点什么bug,把那个流水线给搞挂了。
|
||||
|
||||

|
||||
|
||||
大家看这个图,我们从外面看就是这个神经网络对天光可见性的拟合。
|
||||
|
||||
这个是不是已经非常接近烘焙器的烘焙效果。
|
||||
|
||||
但是你从房子内部看,它当然还是漏光的。
|
||||
|
||||

|
||||
|
||||
但是你作为远景GI这个漏光是没有关系的。
|
||||
|
||||
因为你远景GI都是从室外往从远处往室内看,所以说这个光照你这个光照拟合它也是从一定是从更亮的地方往更暗的地方漏。
|
||||
|
||||
因为你更亮的地方这个数值变化,它能造成那个优化器里面更大那个函数优化那个数值的惩罚。
|
||||
|
||||
所以说这个算法一定是照顾光更加亮的地方,它才会从室内往室内漏。
|
||||
|
||||
所以说它才作为远景点它没有问题。
|
||||
|
||||
那我们说这我还是稍微解释一下,这个小型神经网络里面的非线性就来自它上面这一堆lik IREOU。
|
||||
|
||||
Lik IRELU大家知道就是max 0.2A乘以a max 0.2AA所以说它这个函数总体上输出一定是一个分段线性函数。
|
||||
|
||||
大家看即便是在这些内部的漏光结构上,它是不是也是一个折线的,这样有个多个折线组成的。
|
||||
|
||||
所以说它就是用尝试用多个折线去拟合这个建筑的这个形状,这非常有点像是画素描。
|
||||
|
||||
我不知道有大家有没有画过素描,你画素描的时候有一种画法,就是只能就即便是对这个圆,你也只能一笔一笔去画直画尽量多的直线去拟合这个圆儿,当然了我们是不可没有尽量那么多的直线的,我们的直线这个折线的数量就是VKR1U的数量。
|
||||
|
||||
所以说这个拟合过程就是用画素描做了这样一个类比OK。
|
||||
|
||||
然后我们对比一下,这是没有只只有streaming p streaming GI的这么一个范围,还有这就是全图GI的这样一个范围。
|
||||
|
||||

|
||||
|
||||
OK这海岛站这当然是更远的视距。
|
||||
|
||||
我们看一下这个就是同样一个vive下这个监狱的这样一个对比,这个就非常明显了。
|
||||
|
||||
OK这就是全世界GI这样一个方案。
|
||||
|
||||

|
||||
|
||||
那这个它有多大显存呢?
|
||||
|
||||
大家看这些都是UR set存储的这些全数据GI的显离线数据大小,这些都是非压缩的数据,这上面显示有多大,存储显存就有多大。
|
||||
|
||||
大家看风风暴眼这么大,它大概是22兆,然后刀锋十一兆,监狱是八兆,那这个数据量是没有问题的。
|
||||
|
||||
这个数据量就是作为全图来说,这是一个很很合理这么一个数量,没有任何问题。
|
||||
|
||||

|
||||
|
||||
然后咱们再看一下这个地图,这个攀升取得攀升地图的GPU消耗。
|
||||
|
||||
总体GPU性能消耗全都加起来,就是远景加GI加上,就是所有都加起来它是与pray pass接近。
|
||||
|
||||
我这就不谈具体多少毫秒,因为不同显卡是有差异的这张图上是3080显卡。
|
||||
|
||||
这个可以看到就是当base pass是0.85毫秒的时候,它是0.35。
|
||||
|
||||
它的消耗其实更接近深度垂pass。
|
||||
|
||||
深度垂pass 0.32就说那这个性能接消耗的代价是可以接受的。
|
||||
|
||||
就是你你你用一个接近深度play pass这么性能,把全部的GI都给做出来。
|
||||
|
||||
再加上之前这个防漏光已经在制作阶段消灭了绝大多绝大多数运行时也没有任何昂贵的pass去做特殊处理漏光总显存可以控制在二百多兆,这才算的是高性价比。
|
||||
|
||||
GI在这些侠这在这些所有问题都没有后顾之忧之后,大幅下降的制作成本它才有意义。
|
||||
|
||||
我们还是谈谈mobile这边,总体方案还是LED mab加这个volume GI的这个组合。
|
||||
|
||||

|
||||
|
||||
由于volume MGI目前没有办法做的像PC覆盖的那么远了,所以说mobile上这个light mab用的范围确实是更大的,对吧?
|
||||
|
||||
大家看这个面积是比较大的,但是这个lde mab依然还是只有人工光加天光可见度,那太阳光的间接光还是靠的这个volume。
|
||||
|
||||
大家可以看一下这个view下这个使用light map的这个物体,就是这么多。
|
||||
|
||||
然后我们可以看一下就是不使用lima b加上这个volume GI的这个区别。
|
||||
|
||||

|
||||
|
||||
对,大家可以对比看一下。
|
||||
|
||||
但是大家注意这个billiam GI这个范围就只有这么远,这是眼前这么64米,再远都是全远景的平均数值。
|
||||
|
||||

|
||||
|
||||
但是配合着远景的有这个天光可见度,这个lid map这个房屋的看起来还是OK的。
|
||||
|
||||
关于light这个mobile上这个volume,它具体这个算法和PC就有比较大的差异了。
|
||||
|
||||
Mobile上面手游普遍情况就是它是GPU瓶颈,所以你添加新算法,你不能给GPU添加太多的负担。
|
||||
|
||||

|
||||
|
||||
如果让GPU去运行时访问一个提速数的话,那就太耗费了。
|
||||
|
||||
相比之下手游的PCCPU倒是目前是经常什么八核心?
|
||||
|
||||
4加4模式绑定一个比较节能的小核心,用它去来异步的组装的这个3D纹理,那还是比较合适的。
|
||||
|
||||
因此去异步组装一个包围相机的3D纹理,然后GPU里只要去采样一下就可以了,就立刻把这个UV电池给拿出来,对吧?
|
||||
|
||||
这不比烂的外部就是费多少。
|
||||
|
||||
你赖外部是采一个2D,你现在采一个3D的而已,然后手游同样是要离线的去分块存储数据,但是每一个数据块内部,你不能说就是这么稀疏的把它给存储了。
|
||||
|
||||

|
||||
|
||||
这个虽然省爆量,但是你去访问起诉数的话,那个CPU一定是有大量的cache miss,它就非常的CPU不友好。
|
||||
|
||||
你也不能这样暴力的就把给它做一个规整的存储,这样CCPU访问起来非常爽了,但是它这个空间占用就会非常大,就是非常的耗费包量。
|
||||
|
||||
大家别忘了说我们手游的一个很重要的要求就是节省包量。
|
||||
|
||||

|
||||
|
||||
这样我们就做一个折中,就是每一个数据块内部就是分块存储,每一个数据块内部把这个数据也做一个分段连续存储,每个分段内部是一个规整的长方体网格相当于一个局部的小型3D纹理。
|
||||
|
||||
但它只包围自己的局部空间,然后实时组装包围这个三相机的这个3D纹理的时候,就这个绿色的大框,它就是那个包围相机的3D纹理。
|
||||
|
||||
组装它的时候,把每个数据块对齐到3D纹理中自己对应的那个位置上,然后把这个数据给填充到3D纹理里面去。
|
||||
|
||||
当然这个3D纹理肯定是要做各种scoring,然后追求一个稳定的显示,然后分层分蒸的上传到GPU上去。
|
||||
|
||||
这里你需要注意的是,你可以多利用手机上这种特有的这种半精度浮点数的这个SMD指令,它它就可以极大的加速这个组装的过程。
|
||||
|
||||
然后大家这就到了整个GI方案里代码量最大的地方了,就是这个手游的防漏光。
|
||||
|
||||
手游大家注意这个3D门里精度是1米,那你墙是无论如何都不可能做到1米厚的。
|
||||
|
||||
所以说这个没有办法通过制作来解决,那怎么办呢?
|
||||
|
||||
这就要定义一种叫做iteration of volume的这个体积,就是简称IV。
|
||||
|
||||
这个IV是随着建筑一起做的,你可以认为是一个简化的麦氏,就是大体表达出这个建筑的形状。
|
||||
|
||||
这个IV其实是一个多面体了,它有多个面组成。
|
||||
|
||||
制作的时候要求一个建筑可以由多个IV拼起来,每一个IV必须是突兀多面体。
|
||||
|
||||
比如说这个IV的这个房子的核心区域,它是一个正方体,那IV的每个面正好是那这个IV每个面正好是卡在墙里面的那比如这个面它代表了这堵墙,我把它拉出来了,看的明显一些。
|
||||
|
||||
所以说就可以在离线根据IV的面片和这个体素的位置,识别出能够导致漏光的那些体素,然后把这些体素与这个面片给关联起来。
|
||||
|
||||
那在运行时的时候就可以根据相机在这个面片的前与后这个关系来判断出出这些体素是不是在墙的另一边。
|
||||
|
||||
如果是的话,就把这个体素的数值给fout掉。
|
||||
|
||||
我们看一下这个但是因为漏光的区域可能涉及到3D纹理里面各个区域,而这个纹理你又不得不分针上传,所以说你就不得不允许有一个滞后更新的这么一个现象。
|
||||
|
||||
其实在PVP对战的时候,这个倒是没有人在意的,这个实在是一个没有办法的事情。
|
||||
|
||||
这个防手游的防说起来简单,其实它是一个巨大的代码量,它可能是整个所有系统里代码量最大的地方,但其实没有关系,大家看我头发,然后手游的这个数据压缩和端游是类似的,只不过它这个pro不会稀疏很多。
|
||||
|
||||
咱们刚才不说了吗?
|
||||
|
||||
就是靠近麦氏的地方,每个数据库就是靠近麦氏的地方有这个数据块cover,远离mesh的地方那如果这个数据是什么呢?
|
||||
|
||||
那就直接采用这个地方,如果需要数据的话,就直接采用距离操最近的这个pro上的光照。
|
||||
|
||||
所以说运行时还去动态生成了一个distant back field,指向每一个位置,距离它最近的那个pro OK。
|
||||
|
||||
咱们看一下重点关注的爆量,volume加上这个报量最大这个图GI常规硒鼓达到了334兆。
|
||||
|
||||
那如果这个全都用lid map来做的,这个PI的报量会翻倍五倍不止。
|
||||
|
||||
但是这么大的地图你全用LDMAB做,那肯定也不太可能的事,就是工期就不允许。
|
||||
|
||||
然后这里就稍微总结一下,就是手机上的volume GI仍然是大幅的降低了制作的代价。
|
||||
|
||||
对比纯用来的mab相比,这个包量也大幅下降,都达这都达到了目的。
|
||||
|
||||
防漏光我们用了IV的方式,也没有造成什么太多的拉扯。
|
||||
|
||||
就是这个太阳光的棒子相对于来的map相比,确实是范围小了,就眼前64米这么远。
|
||||
|
||||
但是手游上也是确实足够了,总体性价比还是OK的,没有问题。
|
||||
|
||||
OK然后我们看看一下这个远景的方案。
|
||||
|
||||
是远景的话我刚才说了,就是远景就一个平均值替代,如果再高配一点机器用一个纵向俯拍的2d text。
|
||||
|
||||
这里有一个非常重要的是,我们大量的使用了SMD。
|
||||
|
||||
正好UE对SMD的这个封装也是非常完善的,就是直接很直观的用这个函数名字,你就不用再去写SMD的时候对应的一个一个那种鬼画符。
|
||||
|
||||
然后对,刚才这是多人的部分,咱们再看看单人战役的部分,这就是这个黑鹰坠落。
|
||||
|
||||
这个其实能说的并不多,就是黑鹰黑鹰坠落本身开发的时间非常短。
|
||||
|
||||
那作为罗那罗美作为不需要让人进行任何等待,这个实时也其实美术对它进行调节,能立即看到光照效果。
|
||||
|
||||
其实他那个反复调整的效率,它这个光照品质是更高的。
|
||||
|
||||
OK然后黑鹰的lemon没有做二次开发,就是优于我的原生lemon。
|
||||
|
||||
那是用的是软光追没,没有开硬光追。
|
||||
|
||||
从试验的情况看,就是3080级别以上显卡的时候,用硬光追的这个路面性能才会超过这个软光追,所以这里还是稍微照顾了一下显卡的性能OK。
|
||||
|
||||
然后咱们今天就做一下这个总结。
|
||||
|
||||
首先就是这个live map需要做取舍,你需要满你只要能满足性能和这个效果的时候,你可以更多的使用这个William GI去提升制作效率。
|
||||
|
||||
然后如果比如说你从0.25硬拉到0.1巷light my普遍这个文速精度是0.1。
|
||||
|
||||
你从0.25拉到0.1之后,其实这个边际效应是有点递减的。
|
||||
|
||||
所以说你可以多采用一下这个volunt来提升一下效率,非常关键的就是你做好防漏光是volume GI跳出性价比陷阱的关键。
|
||||
|
||||
甚至你说你做volume GI的话,你应该优先考虑防漏光,然后再想后边那些其他算法OK,然后PC上我们多关注一下显存的控制,然后包边上要多注关注包量,然后还有这个就是像罗曼能带来巨大的效率制作效率的提升。
|
||||
|
||||
我相信再过几年,等玩家最低显卡都已经到3080级别的时候,roman一定就是在市面上非常最普遍的一个方案。
|
||||
|
||||
OK这就是我今天的演讲,谢谢大家我的车,谢谢大家。
|
||||
|
||||
217
tools/tongyi/MD.py
Normal file
217
tools/tongyi/MD.py
Normal file
@@ -0,0 +1,217 @@
|
||||
#!/usr/bin/env python
|
||||
#coding=utf-8
|
||||
|
||||
import os
|
||||
import json
|
||||
import datetime
|
||||
from PptExtraction import main as ppt_main
|
||||
from Transcription import main as transcription_main
|
||||
|
||||
|
||||
def get_ppt_result():
|
||||
"""
|
||||
运行 PptExtraction.py 并获取结果字典
|
||||
"""
|
||||
try:
|
||||
# 导入 PptExtraction 模块的函数
|
||||
from PptExtraction import read_a_txt, download_ppt_extraction, download_image
|
||||
|
||||
# 1. 读取并解析 a.txt 文件
|
||||
ppt_extraction_url = read_a_txt()
|
||||
if not ppt_extraction_url:
|
||||
print("无法提取 PptExtraction 链接")
|
||||
return {}
|
||||
|
||||
# 2. 下载并解析 PptExtraction 内容
|
||||
key_frame_list = download_ppt_extraction(ppt_extraction_url)
|
||||
if not key_frame_list:
|
||||
print("无法获取 PPT 内容")
|
||||
return {}
|
||||
|
||||
# 3. 生成时间戳(年月日_时分秒)
|
||||
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
print(f"生成时间戳: {timestamp}")
|
||||
|
||||
# 4. 创建保存目录
|
||||
save_dir = r'd:\Xuanchi\高斯泼溅\XCNote\tools\tongyi\ppt_output'
|
||||
if not os.path.exists(save_dir):
|
||||
os.makedirs(save_dir)
|
||||
print(f"创建保存目录: {save_dir}")
|
||||
|
||||
# 5. 下载图片并整理结果
|
||||
result_dict = {}
|
||||
downloaded_count = 0
|
||||
|
||||
for i, frame in enumerate(key_frame_list):
|
||||
image_url = frame.get('FileUrl')
|
||||
if image_url:
|
||||
# 下载图片
|
||||
image_filename = download_image(image_url, save_dir, timestamp, i+1)
|
||||
if image_filename:
|
||||
downloaded_count += 1
|
||||
# 整理成字典条目
|
||||
result_dict[i+1] = {
|
||||
"Start": frame.get('Start'),
|
||||
"End": frame.get('End'),
|
||||
"Type": "image",
|
||||
"Content": f""
|
||||
}
|
||||
|
||||
print(f"成功获取 PPT 结果,共 {len(result_dict)} 张图片")
|
||||
return result_dict
|
||||
except Exception as e:
|
||||
print(f"获取 PPT 结果失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return {}
|
||||
|
||||
|
||||
def get_transcription_result():
|
||||
"""
|
||||
运行 Transcription.py 并获取结果字典
|
||||
"""
|
||||
try:
|
||||
# 导入 Transcription 模块的函数
|
||||
from Transcription import read_a_txt, download_transcription, process_transcription
|
||||
|
||||
# 1. 读取并解析 a.txt 文件
|
||||
transcription_url = read_a_txt()
|
||||
if not transcription_url:
|
||||
print("无法提取 Transcription 链接")
|
||||
return {}
|
||||
|
||||
# 2. 下载并解析 Transcription 内容
|
||||
paragraphs = download_transcription(transcription_url)
|
||||
if not paragraphs:
|
||||
print("无法获取 Transcription 内容")
|
||||
return {}
|
||||
|
||||
# 3. 处理 Transcription 内容
|
||||
result_dict = process_transcription(paragraphs)
|
||||
|
||||
# 转换格式,添加 Type 字段
|
||||
for key, value in result_dict.items():
|
||||
value["Type"] = "text"
|
||||
value["Content"] = value.pop("Text")
|
||||
|
||||
print(f"成功获取 Transcription 结果,共 {len(result_dict)} 个句子")
|
||||
return result_dict
|
||||
except Exception as e:
|
||||
print(f"获取 Transcription 结果失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return {}
|
||||
|
||||
|
||||
def merge_results(ppt_result, transcription_result):
|
||||
"""
|
||||
根据时间顺序拼合两个结果字典
|
||||
"""
|
||||
try:
|
||||
# 转换为列表以便排序
|
||||
items = []
|
||||
|
||||
# 添加 PPT 项目
|
||||
for key, value in ppt_result.items():
|
||||
items.append({
|
||||
"id": f"ppt_{key}",
|
||||
"start": value["Start"],
|
||||
"end": value["End"],
|
||||
"type": value["Type"],
|
||||
"content": value["Content"]
|
||||
})
|
||||
|
||||
# 添加 Transcription 项目
|
||||
for key, value in transcription_result.items():
|
||||
items.append({
|
||||
"id": f"trans_{key}",
|
||||
"start": value["Start"],
|
||||
"end": value["End"],
|
||||
"type": value["Type"],
|
||||
"content": value["Content"]
|
||||
})
|
||||
|
||||
# 根据 start 时间排序,相同时间下图片优先
|
||||
items.sort(key=lambda x: (x["start"], 0 if x["type"] == "image" else 1))
|
||||
|
||||
print(f"成功拼合结果,共 {len(items)} 个项目")
|
||||
return items
|
||||
except Exception as e:
|
||||
print(f"拼合结果失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return []
|
||||
|
||||
|
||||
def generate_md(items):
|
||||
"""
|
||||
根据拼合结果生成 md 文档
|
||||
"""
|
||||
try:
|
||||
# 生成时间戳
|
||||
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
|
||||
# 生成 md 文件名
|
||||
md_filename = f"{timestamp}_merged.md"
|
||||
md_path = os.path.join(r'd:\Xuanchi\高斯泼溅\XCNote\tools\tongyi', md_filename)
|
||||
|
||||
# 创建 md 内容
|
||||
md_content = f"# 拼合内容\n\n"
|
||||
md_content += f"生成时间: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n"
|
||||
|
||||
# 添加拼合的内容
|
||||
for item in items:
|
||||
if item["type"] == "image":
|
||||
md_content += f"{item['content']}\n\n"
|
||||
else:
|
||||
md_content += f"{item['content']}\n\n"
|
||||
|
||||
# 保存 md 文件
|
||||
with open(md_path, 'w', encoding='utf-8') as f:
|
||||
f.write(md_content)
|
||||
|
||||
print(f"成功生成 md 文档: {md_filename}")
|
||||
return md_filename
|
||||
except Exception as e:
|
||||
print(f"生成 md 文档失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
主函数
|
||||
"""
|
||||
print("===== 开始生成拼合 MD 文档 =====")
|
||||
|
||||
# 1. 获取 PPT 结果
|
||||
print("\n===== 获取 PPT 结果 =====")
|
||||
ppt_result = get_ppt_result()
|
||||
|
||||
# 2. 获取 Transcription 结果
|
||||
print("\n===== 获取 Transcription 结果 =====")
|
||||
transcription_result = get_transcription_result()
|
||||
|
||||
# 3. 拼合结果
|
||||
print("\n===== 拼合结果 =====")
|
||||
merged_items = merge_results(ppt_result, transcription_result)
|
||||
|
||||
if not merged_items:
|
||||
print("无法生成拼合结果,程序退出")
|
||||
return
|
||||
|
||||
# 4. 生成 md 文档
|
||||
print("\n===== 生成 MD 文档 =====")
|
||||
md_filename = generate_md(merged_items)
|
||||
|
||||
if md_filename:
|
||||
print(f"\n===== 处理完成 =====")
|
||||
print(f"生成的 MD 文档: {md_filename}")
|
||||
print(f"保存位置: d:\Xuanchi\高斯泼溅\XCNote\tools\tongyi\{md_filename}")
|
||||
else:
|
||||
print("生成 MD 文档失败,程序退出")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
146
tools/tongyi/PptExtraction.py
Normal file
146
tools/tongyi/PptExtraction.py
Normal file
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env python
|
||||
#coding=utf-8
|
||||
|
||||
import os
|
||||
import json
|
||||
import requests
|
||||
import datetime
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
def read_a_txt():
|
||||
"""
|
||||
读取并解析 a.txt 文件,提取 PptExtraction 链接
|
||||
"""
|
||||
try:
|
||||
file_path = r'd:\Xuanchi\高斯泼溅\XCNote\tools\tongyi\a.txt'
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
# 读取文件内容,跳过开头的 "响应数据: " 字符串
|
||||
content = f.read().replace('响应数据: ', '')
|
||||
data = json.loads(content)
|
||||
|
||||
ppt_extraction_url = data.get('Data', {}).get('Result', {}).get('PptExtraction')
|
||||
if ppt_extraction_url:
|
||||
print(f"成功提取 PptExtraction 链接: {ppt_extraction_url}")
|
||||
return ppt_extraction_url
|
||||
else:
|
||||
print("无法找到 PptExtraction 链接")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"读取 a.txt 文件失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return None
|
||||
|
||||
|
||||
def download_ppt_extraction(url):
|
||||
"""
|
||||
下载并解析 PptExtraction 内容
|
||||
"""
|
||||
try:
|
||||
print(f"开始下载 PptExtraction 内容: {url}")
|
||||
response = requests.get(url, timeout=30)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
ppt_extraction_data = data.get('PptExtraction', {})
|
||||
key_frame_list = ppt_extraction_data.get('KeyFrameList', [])
|
||||
|
||||
print(f"成功下载并解析 PptExtraction 内容,共 {len(key_frame_list)} 张图片")
|
||||
return key_frame_list
|
||||
except Exception as e:
|
||||
print(f"下载 PptExtraction 内容失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return []
|
||||
|
||||
|
||||
def download_image(url, save_dir, timestamp, index):
|
||||
"""
|
||||
下载图片并保存到指定目录
|
||||
"""
|
||||
try:
|
||||
print(f"开始下载图片: {url}")
|
||||
response = requests.get(url, timeout=30)
|
||||
response.raise_for_status()
|
||||
|
||||
# 生成图片文件名:年月日_时分秒_序号.png
|
||||
image_filename = f"{timestamp}_{str(index).zfill(3)}.png"
|
||||
image_path = os.path.join(save_dir, image_filename)
|
||||
|
||||
# 保存图片
|
||||
with open(image_path, 'wb') as f:
|
||||
f.write(response.content)
|
||||
|
||||
print(f"成功下载图片: {image_filename}")
|
||||
return image_filename
|
||||
except Exception as e:
|
||||
print(f"下载图片失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return None
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
主函数
|
||||
"""
|
||||
print("===== 开始处理 PPT 提取结果 =====")
|
||||
|
||||
# 1. 读取并解析 a.txt 文件
|
||||
ppt_extraction_url = read_a_txt()
|
||||
if not ppt_extraction_url:
|
||||
print("无法提取 PptExtraction 链接,程序退出")
|
||||
return
|
||||
|
||||
# 2. 下载并解析 PptExtraction 内容
|
||||
key_frame_list = download_ppt_extraction(ppt_extraction_url)
|
||||
if not key_frame_list:
|
||||
print("无法获取 PPT 内容,程序退出")
|
||||
return
|
||||
|
||||
# 3. 生成时间戳(年月日_时分秒)
|
||||
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
print(f"生成时间戳: {timestamp}")
|
||||
|
||||
# 4. 创建保存目录
|
||||
save_dir = r'd:\Xuanchi\高斯泼溅\XCNote\tools\tongyi\ppt_output'
|
||||
if not os.path.exists(save_dir):
|
||||
os.makedirs(save_dir)
|
||||
print(f"创建保存目录: {save_dir}")
|
||||
|
||||
# 5. 下载图片并整理结果
|
||||
result_dict = {}
|
||||
downloaded_count = 0
|
||||
|
||||
for i, frame in enumerate(key_frame_list):
|
||||
image_url = frame.get('FileUrl')
|
||||
if image_url:
|
||||
# 下载图片
|
||||
image_filename = download_image(image_url, save_dir, timestamp, i+1)
|
||||
if image_filename:
|
||||
downloaded_count += 1
|
||||
# 整理结果到字典
|
||||
result_dict[i+1] = {
|
||||
"Start": frame.get('Start'),
|
||||
"End": frame.get('End'),
|
||||
"ImageMd": f""
|
||||
}
|
||||
|
||||
if not downloaded_count:
|
||||
print("无法下载任何图片,程序退出")
|
||||
return
|
||||
|
||||
# 6. 输出整理结果
|
||||
print(f"\n===== 处理完成 =====")
|
||||
print(f"保存目录: {save_dir}")
|
||||
print(f"下载的图片数量: {downloaded_count}")
|
||||
print(f"\n整理结果:")
|
||||
print(json.dumps(result_dict, indent=4, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
135
tools/tongyi/Transcription.py
Normal file
135
tools/tongyi/Transcription.py
Normal file
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env python
|
||||
#coding=utf-8
|
||||
|
||||
import os
|
||||
import json
|
||||
import requests
|
||||
import datetime
|
||||
|
||||
|
||||
def read_a_txt():
|
||||
"""
|
||||
读取并解析 a.txt 文件,提取 Transcription 链接
|
||||
"""
|
||||
try:
|
||||
file_path = r'd:\Xuanchi\高斯泼溅\XCNote\tools\tongyi\a.txt'
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
# 读取文件内容,跳过开头的 "响应数据: " 字符串
|
||||
content = f.read().replace('响应数据: ', '')
|
||||
data = json.loads(content)
|
||||
|
||||
transcription_url = data.get('Data', {}).get('Result', {}).get('Transcription')
|
||||
if transcription_url:
|
||||
print(f"成功提取 Transcription 链接: {transcription_url}")
|
||||
return transcription_url
|
||||
else:
|
||||
print("无法找到 Transcription 链接")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"读取 a.txt 文件失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return None
|
||||
|
||||
|
||||
def download_transcription(url):
|
||||
"""
|
||||
下载并解析 Transcription 内容
|
||||
"""
|
||||
try:
|
||||
print(f"开始下载 Transcription 内容: {url}")
|
||||
response = requests.get(url, timeout=30)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
transcription_data = data.get('Transcription', {})
|
||||
paragraphs = transcription_data.get('Paragraphs', [])
|
||||
|
||||
print(f"成功下载并解析 Transcription 内容,共 {len(paragraphs)} 个段落")
|
||||
return paragraphs
|
||||
except Exception as e:
|
||||
print(f"下载 Transcription 内容失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return []
|
||||
|
||||
|
||||
def process_transcription(paragraphs):
|
||||
"""
|
||||
处理 Transcription 内容,根据 SentenceId 拼合成句子,并整理成字典
|
||||
"""
|
||||
try:
|
||||
result_dict = {}
|
||||
sentence_counter = 1
|
||||
|
||||
for paragraph in paragraphs:
|
||||
words = paragraph.get('Words', [])
|
||||
# 按 SentenceId 分组
|
||||
sentences = {}
|
||||
for word in words:
|
||||
sentence_id = word.get('SentenceId')
|
||||
if sentence_id not in sentences:
|
||||
sentences[sentence_id] = {
|
||||
'words': [word.get('Text')],
|
||||
'start': word.get('Start'),
|
||||
'end': word.get('End')
|
||||
}
|
||||
else:
|
||||
sentences[sentence_id]['words'].append(word.get('Text'))
|
||||
# 更新结束时间
|
||||
sentences[sentence_id]['end'] = word.get('End')
|
||||
|
||||
# 处理每个句子
|
||||
for sentence_id, sentence_data in sorted(sentences.items()):
|
||||
# 拼接句子
|
||||
sentence_text = ''.join(sentence_data['words'])
|
||||
if sentence_text:
|
||||
# 整理成字典条目
|
||||
result_dict[sentence_counter] = {
|
||||
"Start": sentence_data['start'],
|
||||
"End": sentence_data['end'],
|
||||
"Text": sentence_text
|
||||
}
|
||||
sentence_counter += 1
|
||||
|
||||
print(f"成功处理 Transcription 内容,共 {len(result_dict)} 个句子")
|
||||
return result_dict
|
||||
except Exception as e:
|
||||
print(f"处理 Transcription 内容失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return {}
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
主函数
|
||||
"""
|
||||
print("===== 开始处理 Transcription 结果 =====")
|
||||
|
||||
# 1. 读取并解析 a.txt 文件
|
||||
transcription_url = read_a_txt()
|
||||
if not transcription_url:
|
||||
print("无法提取 Transcription 链接,程序退出")
|
||||
return
|
||||
|
||||
# 2. 下载并解析 Transcription 内容
|
||||
paragraphs = download_transcription(transcription_url)
|
||||
if not paragraphs:
|
||||
print("无法获取 Transcription 内容,程序退出")
|
||||
return
|
||||
|
||||
# 3. 处理 Transcription 内容
|
||||
result_dict = process_transcription(paragraphs)
|
||||
if not result_dict:
|
||||
print("处理 Transcription 内容失败,程序退出")
|
||||
return
|
||||
|
||||
# 4. 输出整理结果
|
||||
print(f"\n===== 处理完成 =====")
|
||||
print(f"整理结果: ")
|
||||
print(json.dumps(result_dict, indent=4, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
21
tools/tongyi/a.txt
Normal file
21
tools/tongyi/a.txt
Normal file
@@ -0,0 +1,21 @@
|
||||
响应数据: {
|
||||
"Code": "0",
|
||||
"Data": {
|
||||
"TaskId": "81e04145c55941e1b919de400462326a",
|
||||
"TaskKey": "task20260207125324",
|
||||
"TaskStatus": "COMPLETED",
|
||||
"OutputMp3Path": "https://prod-tingwu-paas-common-beijing.oss-cn-beijing.aliyuncs.com/tingwu/output/1805511367110736/81e04145c55941e1b919de400462326a/81e04145c55941e1b919de400462326a_20260207125325.mp3?Expires=1773032413&OSSAccessKeyId=LTAI5tMzZ1D4o1drkJN1TfCr&Signature=bLEZ7Bzwe5W6mJitvpJQxC19G%2B4%3D",
|
||||
"Result": {
|
||||
"MeetingAssistance": "https://prod-tingwu-paas-common-beijing.oss-cn-beijing.aliyuncs.com/tingwu/output/1805511367110736/81e04145c55941e1b919de400462326a/81e04145c55941e1b919de400462326a_MeetingAssistance_20260207125445.json?Expires=1773032413&OSSAccessKeyId=LTAI5tMzZ1D4o1drkJN1TfCr&Signature=SJxdBQpKfNmFbkuazqG1AUFW68g%3D",
|
||||
"PptExtraction": "https://prod-tingwu-paas-common-beijing.oss-cn-beijing.aliyuncs.com/tingwu/output/1805511367110736/81e04145c55941e1b919de400462326a/81e04145c55941e1b919de400462326a_PptExtraction_valid_20260207130013.json?Expires=1773032413&OSSAccessKeyId=LTAI5tMzZ1D4o1drkJN1TfCr&Signature=RRL08gHn%2BV2plMmRXUb7lyMfQds%3D",
|
||||
"Translation": "https://prod-tingwu-paas-common-beijing.oss-cn-beijing.aliyuncs.com/tingwu/output/1805511367110736/81e04145c55941e1b919de400462326a/81e04145c55941e1b919de400462326a_Translation_20260207125405.json?Expires=1773032413&OSSAccessKeyId=LTAI5tMzZ1D4o1drkJN1TfCr&Signature=upttZKNYQZPqtssroLXGxl0SYcg%3D",
|
||||
"Transcription": "https://prod-tingwu-paas-common-beijing.oss-cn-beijing.aliyuncs.com/tingwu/output/1805511367110736/81e04145c55941e1b919de400462326a/81e04145c55941e1b919de400462326a_Transcription_20260207125355.json?Expires=1773032413&OSSAccessKeyId=LTAI5tMzZ1D4o1drkJN1TfCr&Signature=5PTjx229YKQBEa3jc6LRpAqqHuY%3D",
|
||||
"AutoChapters": "https://prod-tingwu-paas-common-beijing.oss-cn-beijing.aliyuncs.com/tingwu/output/1805511367110736/81e04145c55941e1b919de400462326a/81e04145c55941e1b919de400462326a_AutoChapters_20260207125605.json?Expires=1773032413&OSSAccessKeyId=LTAI5tMzZ1D4o1drkJN1TfCr&Signature=CBWZBlrJ4LVMaq82Xsn8X6G%2Faag%3D",
|
||||
"TextPolish": "https://prod-tingwu-paas-common-beijing.oss-cn-beijing.aliyuncs.com/tingwu/output/1805511367110736/81e04145c55941e1b919de400462326a/81e04145c55941e1b919de400462326a_TextPolish_20260207130006.json?Expires=1773032413&OSSAccessKeyId=LTAI5tMzZ1D4o1drkJN1TfCr&Signature=kaoVSkcauKQCr8PydJec8WcMZqs%3D",
|
||||
"Transcoding": "https://prod-tingwu-paas-common-beijing.oss-cn-beijing.aliyuncs.com/tingwu/output/1805511367110736/81e04145c55941e1b919de400462326a/81e04145c55941e1b919de400462326a_Transcoding_valid_20260207130013.json?Expires=1773032413&OSSAccessKeyId=LTAI5tMzZ1D4o1drkJN1TfCr&Signature=CgbAuV7FRY16EKzTUf6Vwm9D3Iw%3D",
|
||||
"Summarization": "https://prod-tingwu-paas-common-beijing.oss-cn-beijing.aliyuncs.com/tingwu/output/1805511367110736/81e04145c55941e1b919de400462326a/81e04145c55941e1b919de400462326a_Summarization_20260207125455.json?Expires=1773032413&OSSAccessKeyId=LTAI5tMzZ1D4o1drkJN1TfCr&Signature=OmS5ZCzIxPEE012YmywRdS4qYmQ%3D"
|
||||
}
|
||||
},
|
||||
"Message": "success",
|
||||
"RequestId": "33E89E9F-5FF9-57B1-9523-4336DD9B7A63"
|
||||
}
|
||||
93
tools/tongyi/bilibili_spider.py
Normal file
93
tools/tongyi/bilibili_spider.py
Normal file
@@ -0,0 +1,93 @@
|
||||
import os
|
||||
import sys
|
||||
from utils import (
|
||||
extract_bv_from_url,
|
||||
get_video_info,
|
||||
get_play_urls,
|
||||
download_audio_video,
|
||||
merge_audio_video,
|
||||
cleanup_files,
|
||||
sanitize_filename
|
||||
)
|
||||
from config import DOWNLOAD_DIR
|
||||
|
||||
def crawl_bilibili_video(video_url, download=False):
|
||||
"""
|
||||
爬取B站视频
|
||||
:param video_url: 视频分享链接
|
||||
:param download: 是否下载视频(默认False)
|
||||
:return: 视频标题和视频播放URL
|
||||
"""
|
||||
print(f"开始爬取视频: {video_url}")
|
||||
|
||||
try:
|
||||
# 1. 提取BV号
|
||||
bv_id = extract_bv_from_url(video_url)
|
||||
print(f"提取到BV号: {bv_id}")
|
||||
|
||||
# 2. 获取视频信息
|
||||
video_info = get_video_info(bv_id)
|
||||
video_title = video_info.get("title", f"{bv_id}")
|
||||
cid = video_info.get("cid")
|
||||
print(f"视频标题: {video_title}")
|
||||
print(f"视频CID: {cid}")
|
||||
|
||||
# 3. 获取播放链接
|
||||
play_urls = get_play_urls(bv_id, cid)
|
||||
|
||||
# 选择最高质量的视频和音频
|
||||
if not play_urls.get("video") or not play_urls.get("audio"):
|
||||
raise Exception("无法获取音视频流链接")
|
||||
|
||||
# 选择最高质量的视频
|
||||
video_item = sorted(play_urls["video"], key=lambda x: x["quality"], reverse=True)[0]
|
||||
video_play_url = video_item["url"]
|
||||
print(f"选择视频质量: {video_item['quality']}")
|
||||
print(f"视频播放URL: {video_play_url}")
|
||||
|
||||
# 选择最高质量的音频
|
||||
audio_item = sorted(play_urls["audio"], key=lambda x: x["quality"], reverse=True)[0]
|
||||
audio_url = audio_item["url"]
|
||||
print(f"选择音频质量: {audio_item['quality']}")
|
||||
|
||||
# 4. 下载音视频(可选)
|
||||
if download:
|
||||
print("开始下载音视频...")
|
||||
save_dir = os.path.join(DOWNLOAD_DIR, sanitize_filename(video_title))
|
||||
video_path, audio_path = download_audio_video(video_play_url, audio_url, save_dir, video_title)
|
||||
|
||||
# 5. 合并音视频
|
||||
output_filename = f"{sanitize_filename(video_title)}.mp4"
|
||||
output_path = os.path.join(DOWNLOAD_DIR, output_filename)
|
||||
merge_audio_video(video_path, audio_path, output_path)
|
||||
|
||||
# 6. 清理临时文件
|
||||
cleanup_files(video_path, audio_path)
|
||||
|
||||
print(f"视频爬取完成,保存路径: {output_path}")
|
||||
else:
|
||||
print("跳过下载,直接使用播放URL")
|
||||
|
||||
# 返回视频标题和视频播放URL
|
||||
return video_title, video_play_url
|
||||
|
||||
except Exception as e:
|
||||
print(f"爬取失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return None, None
|
||||
|
||||
def main():
|
||||
"""
|
||||
主函数
|
||||
"""
|
||||
if len(sys.argv) != 2:
|
||||
print("使用方法: python bilibili_spider.py <视频链接>")
|
||||
print("示例: python bilibili_spider.py https://www.bilibili.com/video/BV1uHFjzVEju/")
|
||||
sys.exit(1)
|
||||
|
||||
video_url = sys.argv[1]
|
||||
crawl_bilibili_video(video_url, download=True) # 直接运行时默认下载视频
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
14
tools/tongyi/config.py
Normal file
14
tools/tongyi/config.py
Normal file
@@ -0,0 +1,14 @@
|
||||
# 配置文件
|
||||
|
||||
# User-Agent设置
|
||||
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
|
||||
|
||||
# Cookie设置(可选,如需登录后才能访问的视频)
|
||||
COOKIE = ""
|
||||
|
||||
# 下载配置
|
||||
DOWNLOAD_DIR = "./downloads"
|
||||
CHUNK_SIZE = 1024 * 1024 # 1MB
|
||||
|
||||
# API配置
|
||||
API_URL = "https://api.bilibili.com/x/player/playurl"
|
||||
140
tools/tongyi/main.py
Normal file
140
tools/tongyi/main.py
Normal file
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env python
|
||||
# coding=utf-8
|
||||
|
||||
import sys
|
||||
import os
|
||||
from bilibili_spider import crawl_bilibili_video
|
||||
from tingwu_api import submit_tingwu_task, get_task_result, check_task_status
|
||||
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from shared.oss_upload import upload_file_to_oss
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
主函数:爬取B站视频并提交给通义听悟API分析
|
||||
"""
|
||||
if len(sys.argv) != 2:
|
||||
print("使用方法: python bilibili_to_tingwu.py <视频链接>")
|
||||
print(
|
||||
"示例: python bilibili_to_tingwu.py https://www.bilibili.com/video/BV1uHFjzVEju/"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
video_url = sys.argv[1]
|
||||
|
||||
print("===== 开始爬取B站视频 =====")
|
||||
# 爬取视频,获取视频标题和播放URL(下载视频)
|
||||
video_title, video_play_url = crawl_bilibili_video(video_url, download=True)
|
||||
|
||||
if not video_play_url:
|
||||
print("视频爬取失败,无法提交给通义听悟API")
|
||||
sys.exit(1)
|
||||
|
||||
# 构建下载的文件路径
|
||||
from config import DOWNLOAD_DIR
|
||||
from utils import sanitize_filename
|
||||
|
||||
# 音频流路径
|
||||
audio_stream_path = os.path.join(
|
||||
DOWNLOAD_DIR,
|
||||
sanitize_filename(video_title),
|
||||
f"{sanitize_filename(video_title)}_audio.mp4",
|
||||
)
|
||||
|
||||
# 视频流路径
|
||||
video_stream_path = os.path.join(
|
||||
DOWNLOAD_DIR,
|
||||
sanitize_filename(video_title),
|
||||
f"{sanitize_filename(video_title)}_video.mp4",
|
||||
)
|
||||
|
||||
# 合并后的视频路径
|
||||
merged_video_path = os.path.join(
|
||||
DOWNLOAD_DIR, f"{sanitize_filename(video_title)}.mp4"
|
||||
)
|
||||
|
||||
# 强制使用合并后的视频文件
|
||||
if os.path.exists(merged_video_path):
|
||||
upload_path = merged_video_path
|
||||
print("使用合并后的视频文件进行分析")
|
||||
else:
|
||||
print("错误:无法找到合并后的视频文件")
|
||||
print("必须成功合并音视频才能继续")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"上传文件路径: {upload_path}")
|
||||
|
||||
print("\n===== 上传视频到OSS =====")
|
||||
# 配置OSS参数
|
||||
ACCESS_KEY_ID = "LTAI5tB7sQADpKZnXY7s6Xz8"
|
||||
ACCESS_KEY_SECRET = "Fgab9klwKoH1GACP97WIb7s6BSvNAm"
|
||||
BUCKET_NAME = "bucket-xcnote" # 请替换为实际的OSS桶名称
|
||||
|
||||
# 生成OSS对象名称
|
||||
import datetime
|
||||
|
||||
timestamp = datetime.datetime.now().strftime("%Y%m%d%H%M%S")
|
||||
object_name = f"bilibili/{timestamp}_{sanitize_filename(video_title)}.mp4"
|
||||
|
||||
# 上传文件到OSS
|
||||
oss_url = upload_file_to_oss(
|
||||
upload_path, BUCKET_NAME, object_name, ACCESS_KEY_ID, ACCESS_KEY_SECRET
|
||||
)
|
||||
|
||||
if not oss_url:
|
||||
print("文件上传失败,无法提交给通义听悟API")
|
||||
sys.exit(1)
|
||||
|
||||
print("\n===== 开始提交给通义听悟API分析 =====")
|
||||
print(f"视频标题: {video_title}")
|
||||
print(f"使用的OSS文件URL: {oss_url}")
|
||||
|
||||
# 配置参数
|
||||
APP_KEY = "vjZPEUfWtszQP2n6" # 必须替换为实际的AppKey
|
||||
|
||||
# 使用OSS的文件URL作为FileUrl
|
||||
file_url = oss_url
|
||||
|
||||
# 提交任务
|
||||
task_id = submit_tingwu_task(file_url, APP_KEY, ACCESS_KEY_ID, ACCESS_KEY_SECRET)
|
||||
|
||||
if task_id:
|
||||
print("\n===== 等待任务处理完成 =====")
|
||||
# 轮询任务状态,直到任务完成或失败
|
||||
import time
|
||||
|
||||
max_retries = 60 # 最大轮询次数
|
||||
retry_interval = 10 # 轮询间隔(秒)
|
||||
retry_count = 0
|
||||
|
||||
while retry_count < max_retries:
|
||||
print(f"\n第 {retry_count + 1} 次查询任务状态...")
|
||||
task_status = check_task_status(task_id, ACCESS_KEY_ID, ACCESS_KEY_SECRET)
|
||||
|
||||
if task_status == "COMPLETED":
|
||||
print("\n===== 任务处理完成 =====")
|
||||
print("分析完成,结果已打印如上")
|
||||
break
|
||||
elif task_status in ["FAILED", "INVALID"]:
|
||||
print(f"\n===== 任务处理{task_status} =====")
|
||||
print("分析完成,结果已打印如上")
|
||||
break
|
||||
elif task_status == "ONGOING":
|
||||
print(f"任务正在处理中,{retry_interval}秒后再次查询...")
|
||||
time.sleep(retry_interval)
|
||||
retry_count += 1
|
||||
else:
|
||||
print(f"未知任务状态: {task_status},{retry_interval}秒后再次查询...")
|
||||
time.sleep(retry_interval)
|
||||
retry_count += 1
|
||||
|
||||
if retry_count >= max_retries:
|
||||
print("\n===== 任务处理超时 =====")
|
||||
print("已达到最大查询次数,任务可能仍在处理中")
|
||||
else:
|
||||
print("\n===== 提交任务失败 =====")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
3
tools/tongyi/requirements.txt
Normal file
3
tools/tongyi/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
requests
|
||||
beautifulsoup4
|
||||
tqdm
|
||||
176
tools/tongyi/tingwu_api.py
Normal file
176
tools/tongyi/tingwu_api.py
Normal file
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env python
|
||||
#coding=utf-8
|
||||
|
||||
import os
|
||||
import json
|
||||
import datetime
|
||||
from aliyunsdkcore.client import AcsClient
|
||||
from aliyunsdkcore.request import CommonRequest
|
||||
from aliyunsdkcore.auth.credentials import AccessKeyCredential
|
||||
|
||||
def create_common_request(domain, version, protocolType, method, uri):
|
||||
"""
|
||||
创建通用请求对象
|
||||
"""
|
||||
request = CommonRequest()
|
||||
request.set_accept_format('json')
|
||||
request.set_domain(domain)
|
||||
request.set_version(version)
|
||||
request.set_protocol_type(protocolType)
|
||||
request.set_method(method)
|
||||
request.set_uri_pattern(uri)
|
||||
request.add_header('Content-Type', 'application/json')
|
||||
return request
|
||||
|
||||
def init_parameters(app_key, file_url):
|
||||
"""
|
||||
初始化请求参数
|
||||
"""
|
||||
body = dict()
|
||||
body['AppKey'] = app_key
|
||||
|
||||
# 基本请求参数
|
||||
input = dict()
|
||||
input['SourceLanguage'] = 'cn'
|
||||
input['TaskKey'] = 'task' + datetime.datetime.now().strftime('%Y%m%d%H%M%S')
|
||||
input['FileUrl'] = file_url
|
||||
body['Input'] = input
|
||||
|
||||
# AI相关参数,按需设置即可
|
||||
parameters = dict()
|
||||
|
||||
# 音视频转换相关
|
||||
transcoding = dict()
|
||||
# 将原音视频文件转成mp3文件,用以后续浏览器播放
|
||||
transcoding['TargetAudioFormat'] = 'mp3'
|
||||
transcoding['SpectrumEnabled'] = False
|
||||
parameters['Transcoding'] = transcoding
|
||||
|
||||
# 语音识别控制相关
|
||||
transcription = dict()
|
||||
# 角色分离 : 可选
|
||||
transcription['DiarizationEnabled'] = True
|
||||
diarization = dict()
|
||||
diarization['SpeakerCount'] = 2
|
||||
transcription['Diarization'] = diarization
|
||||
parameters['Transcription'] = transcription
|
||||
|
||||
# 文本翻译控制相关 : 可选
|
||||
parameters['TranslationEnabled'] = True
|
||||
translation = dict()
|
||||
translation['TargetLanguages'] = ['en'] # 假设翻译成英文
|
||||
parameters['Translation'] = translation
|
||||
|
||||
# 章节速览相关 : 可选,包括: 标题、议程摘要
|
||||
parameters['AutoChaptersEnabled'] = True
|
||||
|
||||
# 智能纪要相关 : 可选,包括: 待办、关键信息(关键词、重点内容、场景识别)
|
||||
parameters['MeetingAssistanceEnabled'] = True
|
||||
meetingAssistance = dict()
|
||||
meetingAssistance['Types'] = ['Actions', 'KeyInformation']
|
||||
parameters['MeetingAssistance'] = meetingAssistance
|
||||
|
||||
# 摘要控制相关 : 可选,包括: 全文摘要、发言人总结摘要、问答摘要(问答回顾)
|
||||
parameters['SummarizationEnabled'] = True
|
||||
summarization = dict()
|
||||
summarization['Types'] = ['Paragraph', 'Conversational', 'QuestionsAnswering', 'MindMap']
|
||||
parameters['Summarization'] = summarization
|
||||
|
||||
# ppt抽取和ppt总结 : 可选
|
||||
parameters['PptExtractionEnabled'] = True
|
||||
|
||||
# 口语书面化 : 可选
|
||||
parameters['TextPolishEnabled'] = True
|
||||
|
||||
# 大模型后处理任务全局参数 : 可选
|
||||
parameters['Model'] = 'qwq'
|
||||
parameters['LlmOutputLanguage'] = 'en'
|
||||
|
||||
body['Parameters'] = parameters
|
||||
return body
|
||||
|
||||
def submit_tingwu_task(file_url, app_key, access_key_id, access_key_secret):
|
||||
"""
|
||||
提交通义听悟任务
|
||||
"""
|
||||
print(f"开始提交通义听悟任务,文件URL: {file_url}")
|
||||
|
||||
try:
|
||||
# 初始化参数
|
||||
body = init_parameters(app_key, file_url)
|
||||
print("初始化参数完成")
|
||||
|
||||
# 初始化客户端
|
||||
credentials = AccessKeyCredential(access_key_id, access_key_secret)
|
||||
client = AcsClient(region_id='cn-beijing', credential=credentials)
|
||||
print("初始化客户端完成")
|
||||
|
||||
# 创建请求
|
||||
request = create_common_request('tingwu.cn-beijing.aliyuncs.com', '2023-09-30', 'https', 'PUT', '/openapi/tingwu/v2/tasks')
|
||||
request.add_query_param('type', 'offline')
|
||||
|
||||
# 设置请求内容
|
||||
request.set_content(json.dumps(body).encode('utf-8'))
|
||||
|
||||
# 发送请求
|
||||
response = client.do_action_with_exception(request)
|
||||
response_data = json.loads(response)
|
||||
|
||||
print("任务提交成功")
|
||||
print(f"响应数据: {json.dumps(response_data, indent=4, ensure_ascii=False)}")
|
||||
|
||||
# 返回任务ID
|
||||
task_id = response_data.get('Data', {}).get('TaskId')
|
||||
if task_id:
|
||||
print(f"获取到任务ID: {task_id}")
|
||||
return task_id
|
||||
else:
|
||||
print("无法获取任务ID")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"任务提交失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return None
|
||||
|
||||
def get_task_result(task_id, access_key_id, access_key_secret):
|
||||
"""
|
||||
获取任务结果
|
||||
"""
|
||||
print(f"开始查询任务结果,任务ID: {task_id}")
|
||||
|
||||
try:
|
||||
# 初始化客户端
|
||||
credentials = AccessKeyCredential(access_key_id, access_key_secret)
|
||||
client = AcsClient(region_id='cn-beijing', credential=credentials)
|
||||
|
||||
# 创建请求
|
||||
uri = '/openapi/tingwu/v2/tasks' + '/' + task_id
|
||||
request = create_common_request('tingwu.cn-beijing.aliyuncs.com', '2023-09-30', 'https', 'GET', uri)
|
||||
|
||||
# 发送请求
|
||||
response = client.do_action_with_exception(request)
|
||||
response_data = json.loads(response)
|
||||
|
||||
print("任务查询成功")
|
||||
print(f"响应数据: {json.dumps(response_data, indent=4, ensure_ascii=False)}")
|
||||
|
||||
return response_data
|
||||
|
||||
except Exception as e:
|
||||
print(f"任务查询失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return None
|
||||
|
||||
def check_task_status(task_id, access_key_id, access_key_secret):
|
||||
"""
|
||||
检查任务状态
|
||||
"""
|
||||
result = get_task_result(task_id, access_key_id, access_key_secret)
|
||||
if result:
|
||||
task_status = result.get('Data', {}).get('TaskStatus')
|
||||
print(f"当前任务状态: {task_status}")
|
||||
return task_status
|
||||
return None
|
||||
254
tools/tongyi/utils.py
Normal file
254
tools/tongyi/utils.py
Normal file
@@ -0,0 +1,254 @@
|
||||
# 工具函数
|
||||
|
||||
import os
|
||||
import re
|
||||
import requests
|
||||
from tqdm import tqdm
|
||||
from config import USER_AGENT, COOKIE, CHUNK_SIZE, DOWNLOAD_DIR
|
||||
|
||||
|
||||
def extract_bv_from_url(url):
|
||||
"""
|
||||
从分享链接中提取BV号
|
||||
:param url: 视频分享链接
|
||||
:return: BV号
|
||||
"""
|
||||
pattern = r"BV[0-9A-Za-z]+"
|
||||
match = re.search(pattern, url)
|
||||
if match:
|
||||
return match.group(0)
|
||||
else:
|
||||
raise ValueError("无法从链接中提取BV号")
|
||||
|
||||
|
||||
def get_video_info(bv_id):
|
||||
"""
|
||||
获取视频信息
|
||||
:param bv_id: BV号
|
||||
:return: 视频信息字典
|
||||
"""
|
||||
url = f"https://api.bilibili.com/x/web-interface/view?bvid={bv_id}"
|
||||
headers = {
|
||||
"User-Agent": USER_AGENT,
|
||||
"Cookie": COOKIE
|
||||
}
|
||||
response = requests.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"获取视频信息失败: {data.get('message')}")
|
||||
return data.get("data", {})
|
||||
|
||||
|
||||
def get_play_urls(bv_id, cid):
|
||||
"""
|
||||
获取视频播放链接
|
||||
:param bv_id: BV号
|
||||
:param cid: 视频cid
|
||||
:return: 包含视频和音频链接的字典
|
||||
"""
|
||||
from config import API_URL
|
||||
|
||||
params = {
|
||||
"bvid": bv_id,
|
||||
"cid": cid,
|
||||
"qn": 80,
|
||||
"type": "",
|
||||
"otype": "json",
|
||||
"fourk": 1,
|
||||
"fnval": 16
|
||||
}
|
||||
|
||||
headers = {
|
||||
"User-Agent": USER_AGENT,
|
||||
"Cookie": COOKIE,
|
||||
"Referer": "https://www.bilibili.com/",
|
||||
"Origin": "https://www.bilibili.com"
|
||||
}
|
||||
|
||||
response = requests.get(API_URL, params=params, headers=headers)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"获取播放链接失败: {data.get('message')}")
|
||||
|
||||
play_urls = {
|
||||
"video": [],
|
||||
"audio": []
|
||||
}
|
||||
|
||||
video_info = data.get("data", {}).get("dash", {}).get("video", [])
|
||||
audio_info = data.get("data", {}).get("dash", {}).get("audio", [])
|
||||
|
||||
if not video_info:
|
||||
durl = data.get("data", {}).get("durl", [])
|
||||
for item in durl:
|
||||
play_urls["video"].append({
|
||||
"url": item.get("url"),
|
||||
"quality": 80,
|
||||
"codec": "h264"
|
||||
})
|
||||
else:
|
||||
for item in video_info:
|
||||
play_urls["video"].append({
|
||||
"url": item.get("baseUrl"),
|
||||
"quality": item.get("id"),
|
||||
"codec": item.get("codecs")
|
||||
})
|
||||
|
||||
if not audio_info and video_info:
|
||||
for item in video_info:
|
||||
play_urls["audio"].append({
|
||||
"url": item.get("baseUrl"),
|
||||
"quality": item.get("id"),
|
||||
"codec": item.get("codecs")
|
||||
})
|
||||
else:
|
||||
for item in audio_info:
|
||||
play_urls["audio"].append({
|
||||
"url": item.get("baseUrl"),
|
||||
"quality": item.get("id"),
|
||||
"codec": item.get("codecs")
|
||||
})
|
||||
|
||||
print(f"获取到视频流数量: {len(play_urls['video'])}")
|
||||
print(f"获取到音频流数量: {len(play_urls['audio'])}")
|
||||
|
||||
return play_urls
|
||||
|
||||
|
||||
def sanitize_filename(filename):
|
||||
"""
|
||||
清理文件名,移除非法字符
|
||||
:param filename: 原始文件名
|
||||
:return: 清理后的文件名
|
||||
"""
|
||||
illegal_chars = r"[<>:/\\|?*]"
|
||||
return re.sub(illegal_chars, "_", filename)
|
||||
|
||||
|
||||
def download_file(url, save_path):
|
||||
"""
|
||||
下载文件
|
||||
:param url: 文件下载链接
|
||||
:param save_path: 保存路径
|
||||
:return: 保存路径
|
||||
"""
|
||||
os.makedirs(os.path.dirname(save_path), exist_ok=True)
|
||||
|
||||
headers = {
|
||||
"User-Agent": USER_AGENT,
|
||||
"Cookie": COOKIE,
|
||||
"Referer": "https://www.bilibili.com/"
|
||||
}
|
||||
|
||||
response = requests.get(url, headers=headers, stream=True)
|
||||
response.raise_for_status()
|
||||
total_size = int(response.headers.get("content-length", 0))
|
||||
|
||||
with open(save_path, "wb") as file, tqdm(
|
||||
desc=os.path.basename(save_path),
|
||||
total=total_size,
|
||||
unit="iB",
|
||||
unit_scale=True,
|
||||
unit_divisor=1024,
|
||||
) as bar:
|
||||
for chunk in response.iter_content(chunk_size=CHUNK_SIZE):
|
||||
size = file.write(chunk)
|
||||
bar.update(size)
|
||||
|
||||
return save_path
|
||||
|
||||
|
||||
def download_audio_video(video_url, audio_url, save_dir, video_title):
|
||||
"""
|
||||
下载视频和音频
|
||||
:param video_url: 视频流链接
|
||||
:param audio_url: 音频流链接
|
||||
:param save_dir: 保存目录
|
||||
:param video_title: 视频标题
|
||||
:return: 视频和音频保存路径
|
||||
"""
|
||||
sanitized_title = sanitize_filename(video_title)
|
||||
|
||||
video_path = os.path.join(save_dir, f"{sanitized_title}_video.mp4")
|
||||
audio_path = os.path.join(save_dir, f"{sanitized_title}_audio.mp4")
|
||||
|
||||
print(f"正在下载视频: {video_title}")
|
||||
download_file(video_url, video_path)
|
||||
|
||||
print(f"正在下载音频: {video_title}")
|
||||
download_file(audio_url, audio_path)
|
||||
|
||||
return video_path, audio_path
|
||||
|
||||
|
||||
def merge_audio_video(video_path, audio_path, output_path):
|
||||
"""
|
||||
合并音视频
|
||||
:param video_path: 视频文件路径
|
||||
:param audio_path: 音频文件路径
|
||||
:param output_path: 输出文件路径
|
||||
:return: 输出文件路径
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||||
|
||||
print(f"正在合并音视频: {os.path.basename(output_path)}")
|
||||
|
||||
# 尝试使用FFmpeg合并音视频
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-i", video_path,
|
||||
"-i", audio_path,
|
||||
"-c:v", "copy",
|
||||
"-c:a", "aac",
|
||||
"-strict", "experimental",
|
||||
"-y",
|
||||
output_path
|
||||
]
|
||||
|
||||
try:
|
||||
# 尝试执行FFmpeg命令(使用字节模式避免编码问题)
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
print("音视频合并完成")
|
||||
except subprocess.CalledProcessError as e:
|
||||
# FFmpeg失败,尝试使用其他方法
|
||||
print(f"FFmpeg合并失败: {e.stderr}")
|
||||
print("尝试使用备用方法...")
|
||||
|
||||
# 备用方法:使用Python的shutil复制文件
|
||||
# 这里只是一个占位符,实际需要更复杂的实现
|
||||
# 但至少可以让用户知道问题所在
|
||||
raise Exception(
|
||||
"音视频合并失败,请确保已安装FFmpeg并添加到系统路径\n"
|
||||
"或尝试安装MoviePy: pip install moviepy"
|
||||
)
|
||||
except FileNotFoundError:
|
||||
raise Exception(
|
||||
"FFmpeg未找到,请安装FFmpeg并添加到系统路径\n"
|
||||
"下载地址: https://ffmpeg.org/download.html"
|
||||
)
|
||||
|
||||
return output_path
|
||||
|
||||
|
||||
def cleanup_files(*file_paths):
|
||||
"""
|
||||
清理临时文件
|
||||
:param file_paths: 要清理的文件路径
|
||||
"""
|
||||
for file_path in file_paths:
|
||||
if os.path.exists(file_path):
|
||||
try:
|
||||
os.remove(file_path)
|
||||
print(f"已清理临时文件: {os.path.basename(file_path)}")
|
||||
except Exception as e:
|
||||
print(f"清理临时文件失败: {e}")
|
||||
Reference in New Issue
Block a user