2024全国乡村定向系列赛事活动(浙江海宁站)

今天杭电定向队五人小队来到了海宁,准备参加本次乡村定向系列赛事——海宁站。去年其实已经来过一次海宁比赛了,但是当时我没有写博客的习惯,其实很大程度上丢失了很多回忆。那时外面只有三个人,还下着雨跑完了难度相当高的一张图。而今年这次的图就相对简单了很多,不仅距离近了,而且没有上山的路,所有点位也基本不会跑回头路,让我跑的非常舒服。
这次比赛的地图:

最后成绩:

复盘

出门很顺利,1、2点打下来都没有任何问题,路线也是我认为最合理的:

但是到了第三个点这里救有一个小失误了,我走的是123456,而3这段路是一条非常小的田里,几乎没有路可以走。而相邻的是一条非常大的田艮,我在田里跑了一段路之后发现不对才重新向北跑到5段上去。这里损失了大概30s。

3号点到4号点没问题,正常从下方穿过天桥就能过去,然后从4号点到5号点是本场第一个技术失误,穿过这个桥,来到一条大路上,我没有发现左手边的河已经不见了,我一直以为我在往2方向走,导致我最后找五号点的时候跑到场外去了,这里是一个非常严重的技术问题,这里浪费了很多时间。保守估计30s吧。5号点到6号点直接穿田而过,速度非常快。7号点页很好找,在路的拐角处。

后面的8号点在一个水中的亭子中,我跑到两个湖的分割线的时候有点恍惚,以为已经到了圈2的位置,不过没有耽误什么时间,一下反应过来就接着打完了8号点。

然后在上桥之前遇到了祝队,后面正常打到9号点。

最后就是本场比赛最大的失误点了,9号点打完,我接着绕这个房子跑,但是我,以为我已经到了房子的西边,而且!而且!!我没带指北针,但凡我这里看一眼指北针,我都知道在的不是一条南北走向的路,而是东西走向,我就这么水灵灵地,朝着东边跑了五分钟,最后被祝队追上,才意识到自己走错了,打完10号点直奔终点结束比赛。这里至少浪费了我4分钟啊,四分钟。比赛结束了第一名22分钟,我跑了27分钟,要是这里没跑错真有可能站台啊,呜呜呜。还是得多练。这里算是依次大遗憾了吧。

夸奖

这个图画的蛮好的,没有太多回头路,点位设计合理,人也不会很挤,前后没人的时候占大多时间,我喜欢这样的比赛,跑在规划好的路上,然后思考下一个点位该如何跑。人在路上,脑子也在路上。

定位、定向、导航、奔跑。

这四件事情非常的令人着迷,这就是定向的魅力。尤其是导航这块,路线的规划,扶手的选择,图例的解析。跃然纸上、动于天地。此乃定向越野运动。

waline评论系统搭建

我的hexo是私有部署,没有使用github的评论系统。使用waline作为评论系统,mysql作为后端数据库。
主要由两部分组成:

数据库

使用mysql简单部署,首先去这里下载waline.sql,保存到你自己的目录下,我取名为waline.sql,记住这个文件的路径,然后执行下面的语句。请将下面的<path/to/your/waline.sql>替换为你下载的文件的路径,请使用绝对路径。关于mysql客户端协议问题参考[^1]

1
2
3
4
5
6
7
8
9
sudo apt install mysql-server
mysql
# 需要解决Node.js mysql客户端不支持认证协议引起的错误,所以重新添加一个用户
alter user 'root'@'localhost' identified with mysql_native_password by '123456';
create database waline;
\u waline
source <path/to/your/waline.sql>
show tables;
\q

其中,最后show tables;的结果应该如下:

1
2
3
4
5
6
7
8
9
10
mysql> show tables
-> ;
+------------------+
| Tables_in_waline |
+------------------+
| wl_Comment |
| wl_Counter |
| wl_Users |
+------------------+
3 rows in set (0.00 sec)

由apt安装的mysql数据库,默认的用户密码保存在:/etc/mysql/debian.cnf
这里设置了root用户之后,下次登录需要使用密码登录:

1
2
mysql -p
Enter password: 123456

waline后端

安装好mysql后,我们需要为waline后端配置一些环境变量,你可以将这些写在你的~/.bashrc中,他们会在你打开终端的时候运行。^2

1
2
3
4
echo 'export MYSQL_DB=waline
export MYSQL_USER=root
export MYSQL_PASSWORD=123456' >> ~/.bashrc
source ~/.bashrc

检查环境变量:

1
env | grep SQL

如果能看到上面添加的几个值说明成功了。
最后一步是最简单的,我们使用独立部署作为参考:

1
2
npm install @waline/vercel
node node_modules/@waline/vercel/vanilla.js

第二条命令就能把后端服务启动,只要你正确配置了环境变量,就能成功将后端运行起来。

fliud配置

首先你来配置评论系统了,肯定已经对fliud有了一定的了解了,我需要你能找到它的配置文件所在的位置。打开它

1
vim _config.fliud.yml

搜索waline找到这一段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Waline
# 从 Valine 衍生而来,额外增加了服务端和多种功能
# Derived from Valine, with self-hosted service and new features
# See: https://waline.js.org/
waline:
serverURL: ''
path: window.location.pathname
meta: ['nick', 'mail', 'link']
requiredMeta: ['nick']
lang: 'zh-CN'
emoji: ['https://cdn.jsdelivr.net/gh/walinejs/emojis/weibo']
dark: 'html[data-user-color-scheme="dark"]'
wordLimit: 0
pageSize: 10

需要启动它你只需要填入serverURL字段即可,而且需要保证访问博客的人能正常访问到这个serverURL,所以你的waline也需要内网穿透并反向代理绑定域名,这部分自行处理。如果有网络的困难的话,请先本地游玩体验。等待网络技巧提升再来尝试。
然后需要启动评论系统,搜索comments,找到如下内容对于不同的page这个评论组件是分开的,如果你需要在文章中使用评论组件,需要在post下面找到这个,如果需要在友链中使用同理。

1
2
3
4
5
6
7
8
# 评论插件
# Comment plugin
comments:
enable: true
# 指定的插件,需要同时设置对应插件的必要参数
# The specified plugin needs to set the necessary parameters at the same time
# Options: utterances | disqus | gitalk | valine | waline | changyan | livere | remark42 | twikoo | cusdis | giscus | discuss
type: disqus

type字段改为waline,并确保enable字段为true。这可以保证你的评论是启动的,并使用waline配置。

配置好后,重启heox服务:

1
hexo clean && hexo g && hexo s

不出意外你可以在你设置的界面看到如下内容:

不过我这里已经登录了,建议使用github登录,首次创建的账号是站主账号。到这里基础的waline搭建就结束了,但是其实waline还有很多好玩的东西,之后搭建了可以在这里补充~

waline功能

reference

[^1]:# 解决Node.js mysql客户端不支持认证协议引发的“ER_NOT_SUPPORTED_AUTH_MODE”问题

甲壳虫跑团“耐力挑战赛”赛制章程

一、主办单位:杭州电子科技大学甲壳虫跑团

二、承办单位:杭州电子科技大学甲壳虫跑团

三、协办单位:杭州电子科技大学甲壳虫跑团

四、竞赛时间和地点:

2024年 12月 时间待定

杭州电子科技大学(下沙本部)

五、参赛资格:杭州电子科技大学各学院在校生或校友

六、竞赛办法:

  • (1)组别:
    • 男生组:男生在比赛当天任意时间连续跑步五公里、十公里、十五公里即可获得对应级别的奖励。
    • 女生组:女生在比赛当天任意时间连续跑步三公里、六公里、九公里即可获得对应级别的奖励。
  • (2)比赛凭证:比赛距离接受任何形式的距离记录,包括但不限于:手机APP、带定位的手表。

七、录取名次与奖项

  • 1、达到三个档次距离的同学均可获得对应的三、二、一等奖励,录取名次无上限。

八、报名日期和办法

待定

九、附注

  • 精神:耐力挑战赛旨在通过挑战自我、拼搏精神以及体育竞技的形式,促进高校学生的运动热情。属于强度较小的安全活动。
  • 代跑:本次比赛严禁代跑行为,一旦发现将取消涉事者的奖励,鼓励大家依靠自己的体力来选择距离目标。
  • 成绩有效论:运动应该连续,不能出现暂停休息的行为(允许中途补给,而非休息)。

十、末尽事宜,另行通知。本规程解释权归甲壳虫跑团所有。

甲壳虫跑团“十英里接力赛”赛制章程

一、主办单位:杭州电子科技大学甲壳虫跑团

二、承办单位:杭州电子科技大学甲壳虫跑团

三、协办单位:杭州电子科技大学甲壳虫跑团

四、竞赛时间和地点:

2024年 10月 23日 16:30

杭州电子科技大学(下沙本部)

五、参赛资格:杭州电子科技大学各学院在校生或校友

六、竞赛办法:

  • (1)组别:
    • 男生组:参赛运动员只由男生组成,必须四名队员。排好接力顺序,依次在400m标准田径场中跑完4000m,每名运动员只允许上场一次。
    • 混合组:参赛运动员中至少包含一名女生,必须四名队员,排好接力顺序,一次在400m标准田径场中跑完4000m,每名运动员只允许上场一次。
  • (2)到达时间:所有队伍必须按时到场,运动员需要在上一棒起跑前到达比赛场地。
  • (3)绶带:比赛时,运动员必须全程佩戴印有“甲壳虫跑团”的绶带,如果中途绶带掉落,需要重拾。绶带为成绩判断依据。比赛前运动员可以在绶带上签上自己的名字,比赛后也可以标记上最终成绩,作为一次宝贵的比赛经历。

七、录取名次与奖项

  • 1、男生组和混合组分开排名
  • 2、各组别取前3名颁奖,总计6个奖项

八、报名日期和办法

本次比赛报名需要缴纳报名费用,每支队伍20¥,用于购买绶带和补给。
报名截止于2024年10月22日

九、附注

本次比赛只记录团队成绩,个人成绩需要自己记录。本比赛非校级、非院级比赛,只希望提升大家的跑步热情,让大家都能够体验到高百的氛围。

十、末尽事宜,另行通知。本规程解释权归甲壳虫跑团所有。

甲壳虫跑团“十英里接力赛分院杯”赛制章程

一、主办单位:杭州电子科技大学甲壳虫跑团

二、承办单位:杭州电子科技大学甲壳虫跑团

三、协办单位:杭州电子科技大学甲壳虫跑团

四、竞赛时间和地点:

2024年 12月 待定

杭州电子科技大学(下沙本部)

五、参赛资格:杭州电子科技大学各学院在校生或校友

六、竞赛办法:

  • (1)组别:
    • 统一组别:每个学院需要组织2男2女四人队伍上场比赛,每人在标准四百米跑道上跑十圈,累计四千米;四人总计十英里。
  • (2)到达时间:所有队伍必须按时到场,运动员需要在上一棒起跑前到达比赛场地。
  • (3)绶带:比赛时,运动员必须全程佩戴印有“甲壳虫跑团”的绶带,如果中途绶带掉落,需要重拾。绶带为成绩判断依据。比赛前运动员可以在绶带上签上自己的名字,比赛后也可以标记上最终成绩,作为一次宝贵的比赛经历。

七、录取名次与奖项

  • 1、取前8名分发奖励。

八、报名日期和办法

待定

九、附注

  • 1.精神:十英里接力赛旨在通过团队合作、拼搏精神以及体育竞技的形式,促进各学院学生之间的友谊与交流。希望提升大家的跑步热情,让大家都能够体验到接力赛的氛围。
  • 2.个人成绩:本次比赛只记录团队成绩,个人成绩需要自己记录。
  • 3.参赛选手应该提前到场进行热身准备
  • 4.禁止使用任何形式的外力辅助或违规药物,一旦发现将取消参赛资格。

十、末尽事宜,另行通知。本规程解释权归甲壳虫跑团所有。

Unity 如何创建不带UI的截图?

首先我知道相机对象可以直接调用内部接口实现截图的效果,但是我们要拍摄一张不带UI的截图,那么我们可以使用第二个相机,不添加UI图层来实现这一操作。

我们要在场景中创建第二个相机,然后关闭它的Adiuo listener(我好像打错了,不过你应该能意会),然后将它的剔除遮罩中的UI取消勾选,并将你的UI图层设置为UI注意是图层,不是排序图层
像这样:

一定要取消Audio Listenner选项,不然会导致你的点击事件变得奇怪。然后创建一个脚本加入以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#region 截图
// 指定截图的相机
public Camera screenshotCamera;
// 保存当前相机的截图
public string SaveCurrenScreen(string filename)
{
string path = Path.Combine(Application.persistentDataPath, RecordData.NAME, "screenshot", "screenshot_" + filename + ".png");
Debug.Log("ScreenShot Save to: " + path);
// 文件夹不存在则创建
if (!Directory.Exists(Path.GetDirectoryName(path)))
{
Directory.CreateDirectory(Path.GetDirectoryName(path));
}
// 获取指定相机的截图:
SaveCameraView(path);
return path;
}

void SaveCameraView(string path)
{
RenderTexture screenTexture = new RenderTexture(Screen.width, Screen.height, 24);
screenshotCamera.targetTexture = screenTexture;
RenderTexture.active = screenTexture;
screenshotCamera.Render();
Texture2D renderTexture = new Texture2D(Screen.width, Screen.height);
renderTexture.ReadPixels(new Rect(0, 0, Screen.width, Screen.height), 0, 0);
RenderTexture.active = null;
byte[] byArray = renderTexture.EncodeToPNG();
File.WriteAllBytes(path, byArray);
}
#endregion

然后只需要调用SaveCurrenScreen就好了。

Unity如何优雅地管理UI图层顺序

UI图层的排序在我的游戏制作途中算是一个非常大的问题。如何管理这些跳动的精灵是我一直以来的困惑,直到我的一次游戏实践中,我遇到这么一个问题:

我需要实现一个存档系统,它应该存在一个存档页面和两个打开页面,分别是游戏中的设置和游戏开始界面。

我尝试复用这个存档页面,但是发现如论如何也无法很好地处理我的UI遮挡关系。于是我想到了一个能够处理一切问题的处理方法:给需要处理特殊关系的UI添加Canvas组件,并设置排序图层。这样就能完美的解决图层的关系了。

但是不能只给这个UI添加单单一个 Canvas组件,它还需要一些附加组件,他们让我伤透了脑经。

他们分别是:

  • Canvas
    • 用于设置排序图层
  • Canvas Renderer
    • 用于渲染精灵
  • Canvas Scaler
    • 用于自动处理UI缩放
  • Graphic Raycaster
    • 用于捕获点击

缺一不可。

我曾经因为没有使用 Graphic Raycaster导致按钮始终无法点击,去判断为是图层顺序问题而改了一个多小时的经历。为我们的UI精灵添加这些组件之后,我们只需要设置Canvas为覆盖排序,并设置它的排序图层就好了。参考设置:

是不是非常简单呢?嘻嘻。

Unity如何在脚本中使用本地话字符?

简单的脚本调用Localization组件字符实现方法

首先确保你已经正确的在项目中安装了Lcolization组件,然后创建了词表。你可以在窗口/资产管理/Localization Tables中找到你现在已有的词表。

但是在这之前你也需要在编辑/项目设置/Localization中创建Locale,这一步请自行STFW


我创建了几个词用于演示。你可以自行创建需要的词语。

第一步:在需要使用的脚本中添加引用

1
2
3
using UnityEngine.Localization.Settings;
using UnityEngine.Localization.Tables;
using UnityEngine.Localization;

大多使用我们都会用到他们。

第二步:添加本地化模块

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#region 本地化
private string lsCurScence, lsGameTime, lsSaveTime, lsAutoSave, lsSave;
public LocalizedStringTable stringTable = new LocalizedStringTable { TableReference = "UI" };

private void OnEnable()
{
stringTable.TableChanged += LoadLocalizationString;
}
private void OnDisable()
{
stringTable.TableChanged -= LoadLocalizationString;
}
void LoadLocalizationString(StringTable table)
{
lsCurScence = GetLocalizedString(table, "CurrentScence");
lsGameTime = GetLocalizedString(table, "GameTime");
lsSaveTime = GetLocalizedString(table, "SaveTime");
lsAutoSave = GetLocalizedString(table, "AutoSave");
lsSave = GetLocalizedString(table, "Save");
}
static string GetLocalizedString(StringTable table, string key)
{
if(table == null || key == null)
{
Debug.Log("[LocalLization LOG] Table OR Key is null!");
return null;
}
var entry = table.GetEntry(key);
if (entry == null)
{
Debug.Log("[LocalLization LOG] There is not key: " + key);
return null;
}
return entry.GetLocalizedString();
}
#endregion

你只需要关心三个地方:

1
2
3
4
5
6
7
8
// part one
private string lsCurScence, lsGameTime, lsSaveTime, lsAutoSave, lsSave;

// part two
public LocalizedStringTable stringTable = new LocalizedStringTable { TableReference = "UI" };

// part three
void LoadLocalizationString(StringTable table)

首先第一部分,我们要声明用于保存本地化后的字符串的对象。
然后第二部分,选择你保存了这些词的词表,在创建词表的时候你应该设置了一个词表名。
最后是第三部分,在LoadLocalizarionString函数中获取你的本地化结果。

第三步:使用本地化结果

使用他们就像使用一个简单的字符串对象一样简单毕竟他们就是字符串对象。你可以在脚本的任何地方调用这些字符串,任何地方。无需担心它何时会更新。我们的脚本在OnEnable函数中注册了回调函数——LoadLocalizationString函数,它会重新获取本地化结果,这样保证了你每次获取的字符串总是和本地同步。

Unity如何保存存档?

这部分内容消耗了我大量的时间和精力,在这里稍微总结一下吧。首先我和策划达成一个共识:当前所有的数据都要保存在角色身上,所以我们创建一个saveData类在我们的hero中。这里我们可以加入任何我们需要保存的数据。

但是我们需要实现多个存档,而PlayerPref只能存储单个存档,并且类型十分有限,只能存储整形浮点字符串。于是我们只能结束json的序列化字符串的功能来实现我们的想法。但是这还是不能解决多存档的问题。我在b站上看到一个思路:使用PlayerPref保存存档信息,使用文件保存存档。这个想法让我眼前一亮,直接开始了结构的整理。

架构

我们有两个地方需要用到存档UI,一个是开始界面,玩家直接读档,一个是设置页面,玩家保存和读档。特殊的我们还需要实现自动存档来实现开始页面的继续游戏选项。我们总共设置八个存档,所以需要八个档位对象,挂载在存档表下。裆位对象需要实现外部展示、档位路由、按钮回调实现。存档表需要实现档位对象的管理。

对象名 功能 挂载精灵
档位(save) 实现存档按钮的回调函数,并展示存档信息 用于UI中展示的按钮
存档表(UIManager) 实现多档位的管理,处理档位之间的关系 最大的UIcanvas上
存档管理(RecordData) 记录多存档的信息,调用保存类的接口,将存档信息保存到PlayerPref 档位的滑动视图上
英雄(hero) 实现存档数据结构的设计,调用存档管理类的接口和保存类的接口 主角
保存类(SaveSystem) 实现数据写入PlayerPref和JSON文件 全局静态

这里的UIManager类和RecordData类再分清楚一点,RecordData类实际上没有保存任何与游戏相关的内容,而是把所有的存档的文件路径保存在了PlayerPref中,因为PlayerPref默认使用注册表保存,所以不宜保存大量数据,并且只需要保存一份数据就可以了。而UIManager类将所有的游戏数据保存在本地文件中,需要保存多个文件,接受RecordData管理。这里能理解那么这个存档系统就十分的清晰了。

类的实现

save

这个类主要用于展示我们的存档信息,每个对象只需要管理自己的一个存档就可以了。这份脚本需要挂载在所有的存档精灵上,所以它需要标识自己的存档序号,这个序号我选择手动标记。

数据部分

数据部分主要包括三块:控件info本地化

由于只需要静态本地化字符,所以不需要很复杂的设计,只需要将需要的字符全部本地化就好了。本地化设置可以参考:Unity如何在脚本中使用本地话字符?

控件部分我们需要控制档位精灵下面的所有精灵,包括文字、按钮、图像等。
info部分我设计为一个存档的决定因素,包括ID、图片路径、状态等。
这两部分代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#region 控件
public GameObject infoScence;
public GameObject infoGameTime;
public GameObject infoSaveTime;
public GameObject infoLabel;
public Image image;
public Button m_button;
#endregion

#region info
public int ID;
public string ImagePath;
public bool isSave, isLoad;
#endregion

方法

方法一览:

方法名 返回值类型 方法作用
Start void 初始化所有控件,设置状态
SetInfo void 接口函数,用于设置info
getID int 接口函数,用于返回当前存档的ID
OnClick void 回调函数,绑定回调事件,通过状态判断需要存档还是读档
LoadSelf void 尝试从文件中读取存档信息,文件路径从RecordData
SetStatus void 接口函数,用于设置状态
DeleteSelf void 删档函数,需要同步删除存档引用的截图

代码汇总

fold | file:save.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using TMPro;
using UnityEngine.UI;
using System;
using UnityEngine.Localization.Settings;
using UnityEngine.Localization.Tables;
using UnityEngine.Localization;
public class save : MonoBehaviour
{
#region 控件
public GameObject infoScence;
public GameObject infoGameTime;
public GameObject infoSaveTime;
public GameObject infoLabel;
public Image image;
public Button m_button;
#endregion

#region info
public int ID;
public string ImagePath;
public bool isSave, isLoad;
#endregion

#region 本地化
private string lsCurScence, lsGameTime, lsSaveTime, lsAutoSave, lsSave;
public LocalizedStringTable stringTable = new LocalizedStringTable { TableReference = "UI" };

private void OnEnable()
{
stringTable.TableChanged += LoadLocalizationString;
}
private void OnDisable()
{
stringTable.TableChanged -= LoadLocalizationString;
}
void LoadLocalizationString(StringTable table)
{
lsCurScence = GetLocalizedString(table, "CurrentScence");
lsGameTime = GetLocalizedString(table, "GameTime");
lsSaveTime = GetLocalizedString(table, "SaveTime");
lsAutoSave = GetLocalizedString(table, "AutoSave");
lsSave = GetLocalizedString(table, "Save");
}
static string GetLocalizedString(StringTable table, string key)
{
if(table == null || key == null)
{
Debug.Log("[LocalLization LOG] Table OR Key is null!");
return null;
}
var entry = table.GetEntry(key);
if (entry == null)
{
Debug.Log("[LocalLization LOG] There is not key: " + key);
return null;
}
return entry.GetLocalizedString();
}

/// <summary>
/// with include:
/// using UnityEngine.Localization;
/// </summary>
#endregion
void Start()
{
infoScence = gameObject.transform.Find("InfoScence").gameObject;
infoGameTime = gameObject.transform.Find("InfoGameTime").gameObject;
infoSaveTime = gameObject.transform.Find("InfoSaveTime").gameObject;
infoLabel = gameObject.transform.Find("id").gameObject;
image = GetComponent<Image>();
m_button = GetComponent<Button>();
isSave = isLoad = false;
m_button.onClick.AddListener(OnClick);
}
// TODO : load the screeenshot
public void SetInfo(string scence, string gameTime, string saveTime, string imagePath="")
{
// 本地化
infoScence.GetComponent<TextMeshProUGUI>().text = (scence == null ? "" : lsCurScence + ":" + scence);
infoGameTime.GetComponent<TextMeshProUGUI>().text = (gameTime == null ? "" : lsGameTime + ":" + gameTime);
infoSaveTime.GetComponent<TextMeshProUGUI>().text = (saveTime == null ? "" : lsSaveTime + ":" + saveTime);
if (imagePath != "")
{
byte[] bytes = System.IO.File.ReadAllBytes(imagePath);
Texture2D texture = new Texture2D(1, 1);
texture.LoadImage(bytes);
image.sprite = Sprite.Create(texture, new Rect(0, 0, texture.width, texture.height), new Vector2(0, 0));
ImagePath = imagePath;
Debug.Log("Succe Load The Image");
}
}
public int getID()
{
return ID;
}
public void OnClick()
{
if (isSave)
{
// TODO: 提示此操作不可逆
hero.Instance.Save(getID());
RecordData.instance.Load();
Debug.Log("Save the game" );
// save
}
else if (isLoad)
{
hero.Instance.Load(getID());
Debug.Log("Load the game");
//load
}
}
public void LoadSelf()
{
// load the id from json
// 本地化
if (ID == 1)
{
// localization
infoLabel.GetComponent<TextMeshProUGUI>().text = lsAutoSave;
}
else
{
infoLabel.GetComponent<TextMeshProUGUI>().text = lsSave + ID;
}
var saveData = SaveSystem.LoadFromJson<hero.SaveData>(RecordData.instance.getRecordName(getID()));
if (saveData != null)
{
// format the time to hh mm ss
TimeSpan ts = new TimeSpan(0, 0, ((int)saveData.GameTime));
var gameTime = string.Format("{0:D2}:{1:D2}:{2:D2}", ts.Hours, ts.Minutes, ts.Seconds);
SetInfo(saveData.currentScene, gameTime, saveData.saveTime ,saveData.imagePath);
Debug.Log("Load the save: " + getID());
}
else
Debug.Log("Save is empty :" + getID());
// 空存档不处理
}
public void SetStatus(bool save, bool load)
{
isSave = save;
isLoad = load;
}
public void DeleteSelf()
{
// 确保删除的和存档列表相同
LoadSelf();
// imagePath存在则删除
SaveSystem.FileDelete(ImagePath);
SaveSystem.FileDelete(RecordData.instance.getRecordName(getID()));
}
}

非常不错,我们来看下一个类的实现。

UIManager

这是一个非常庞大的类,我写它的目的是用于管理所有的UI控件,并为他们设置回调函数,事实上,目前为止,除了存档这样逻辑复杂的控件以外其他的控件我都是使用该类简单地进行回调的。在一个类中处理所有的UI逻辑会显得很简单,但是今天不是来讲UI逻辑的,我们只将其中管理存档的部分代码,你可以参照这个设计理念设计一个简单的存档管理类,而不是UI管理。
首先我们需要获取当前存档结构的所有精灵:

file:UIManager.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45

public GameObject AllSave;
public string preAllSaveButtonName;
public List<string> AllSaveButtonName = new List<string> { "Save1", "Save2", "Save3", "Save4", "Save5", "Save6", "Save7", "Save8" };
public Dictionary<string, GameObject> AllSaveButton = new Dictionary<string, GameObject>();
public GameObject AllSaveCloseButton;
public bool isSave;
public bool isLoad;


private void Awake(){
// something others

preAllSaveButtonName = "Viewport/Content/";
isSave = isLoad = false;
AllSaveButtonName = new List<string> { "Save1", "Save2", "Save3", "Save4", "Save5", "Save6", "Save7", "Save8" };
AllSave = SettingScreen.transform.Find("AllSave").gameObject;
AllSaveCloseButton = AllSave.transform.Find("close").gameObject;
for (int i=0;i < AllSaveButtonName.Count; i++)
{
var button = AllSave.transform.Find(preAllSaveButtonName+AllSaveButtonName[i]);
if (button != null)
{
AllSaveButton.Add(AllSaveButtonName[i],button.gameObject);
}
else
{
Debug.LogError("Can't find button: " + AllSaveButtonName[i]);
}
}

}
private void Start(){
// something
if(AllSaveCloseButton.GetComponent<Button>() != null)
{
AllSaveCloseButton.GetComponent<Button>().onClick.AddListener(() =>
{
Debug.Log("Close activate!");
disableCanvas(AllSave);
isLoad = isSave = false;
});
}

}

然后我们设置打开存档的回调函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void OnOpenSave(){
isSave = true;
UpdateAllSave();
enableCanvas(AllSave);
Debug.Log("Save is avaliable");
}
void UpdateAllSave()
{
// to update the save from disk to UI
for (int i = 0; i < AllSaveButtonName.Count; i++)
{
var asave = AllSaveButton[AllSaveButtonName[i]].GetComponent<save>();
asave.LoadSelf();
asave.SetStatus(isSave,isLoad);
}
}

由于这只是部分的代码,我不能确定是否能直接运行(大概率不行),你需要自己编写属于你的管理类,这里只做参考。因为这个类也不是最重要的类,只是一个中间层而已。我们的存档思想主要体现在RecordData类中:

RecordData

我们需要在这个类中记录我们存档在计算机上的保存信息,并对应在内存(代码)中的映射关系。这个类近乎是一个模板,因为它和保存的数据结构没有任何关系,只需要接口对应就能完成它的工作。同样的,我把它设计为一个单例,这样保证我们不会有更多的存档。

数据部分

在数据部分,我们需要设定我们的最大存档的数量、以及在注册表中的键名(因为我们的这些信息是使用PlaerPref保存在注册表中的)、一个记录存档的数据类(目前里面只需要保存存档的文件路径就好了)。

数据名 作用
recordNum 记录最大保存数量
NAME 记录在注册表中的键名
recordName[] 记录所有保存的文件名
class SaveData 数据类,数据成员与本类中保存的一致,也可以直接保存一个该类对象

方法

方法表如下:

方法 作用
SaveData ForSave() 保存前的准备工作,将类中的数据复制到一个新对象中,并返回它
void ForLoad(SaveData saveData) 读入后的复制工作,接受从文件中读入的数据类对象
public void Save() 接口函数,用于将所有数据保存到注册表
public void Load() 接口函数,用于从注册表读取数据到文件
public string getRecordName(int ) 接口函数,用于读取对应id的文件名
public string genRecordName(int ) 接口函数,用于创建对应id的文件名
public int getNum() 接口函数,用于返回存档数

代码汇总

fold | file:RecordData.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
using System.Collections;
using System.Collections.Generic;
using Unity.VisualScripting;
using UnityEngine;
using System.IO;
public class RecordData : MonoBehaviour
{
#region 单例
public static RecordData instance;
private void Awake()
{
if (instance == null)
{
DontDestroyOnLoad(gameObject);
instance = this;
// 创建全空数组
for (int i = 0; i < recordNum; i++)
{
recordName[i] = "";
}
Load();
}
else if (instance != this)
{
Destroy(gameObject);
}
}
#endregion

public const int recordNum = 8;
public const string NAME = "Saves";


private string[] recordName = new string[recordNum];
//public int lastID;
class SaveData
{
public string[] recordName = new string[recordNum];
//public int lastID;
}
SaveData ForSave()
{
var saveData = new SaveData();
for (int i = 0; i < recordNum; i++)
{
saveData.recordName[i] = recordName[i];
}
//saveData.lastID = lastID;
return saveData;
}
void ForLoad(SaveData saveData)
{
//lastID = saveData.lastID;
for (int i = 0; i < recordNum; i++)
{
recordName[i] = saveData.recordName[i];
}
}
public void Save()
{
SaveSystem.SaveByPlayerPrefs(NAME, ForSave());
}
public void Load()
{
if (PlayerPrefs.HasKey(NAME))
{
Debug.Log("Log the record SUCCE!!");
var saveData = SaveSystem.LoadFromPlayerPrefs<SaveData>(NAME);
for (int i = 0; i < recordNum; i++)
{
// 文件不存在则删除该记录
if (saveData.recordName[i] != "" && !File.Exists(saveData.recordName[i]))
{
saveData.recordName[i] = "";
}
}
ForLoad(saveData);
}
}
public string getRecordName(int id)
{
string name = "";
if (id >= 0 && id < recordNum)
name = recordName[id];
return name;
}
public string genRecordName(int id)
{
if(id >= 0 && id < recordNum)
{
recordName[id] = Path.Combine(Application.persistentDataPath, NAME, System.DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss") + ".json");
Save();
#if UNITY_EDITOR
for(int i=0;i<recordNum; i++)
{
Debug.Log("RecordData: " + i + " :" + recordName[i]);
}
#endif
return recordName[id];
}
return null;
}
public int getNum()
{
return recordNum;
}
}

讲解

这个类的实现思路很有意思,它只调用了两个与文件操作的接口,也就是后面要实现的SaveSystem类。自由度极高,它使用时间戳来给需要保存的存档命名,每次初始化检查存档文件是否存在,不存在则删除注册表中的记录。这样可以让用户删除存档文件的时候出现错误。如果你希望,还可以加一个反向添加的功能,这样就能让玩家自己添加存档了。这里我就不实现了。

SaveSystem

这里我先讲SaveSystem,这里主要是与文件的读写有关系的函数,也是相当独立的一个类,与保存到数据结构没有关系。

数据部分

SaveSystem没有必要的数据成员。如果需要为你的存档创建截图的话,你需要使用到一个相机对象。关于如何使用分离相机的方式创建截图可以参考这篇博客:Unity创建不带UI的截图

方法

PlayerPref

方法 作用
public static void SaveByPlayerPrefs(string, object) 接口函数,保存obj中的所有数据到注册表中,使用PlayerPref保存
pbulic static T LoadFromPlayerPrefs(string) 接口函数,用于读取某一个键中的数据,并反序列化为指定类

JSON

方法 作用
static string GetPath(string) 这个函数是内部函数,用于将相对路径指定到项目的绝对路径
public static void SaveByJson(string, object) 接口函数,这个函数将对象序列化后保存到指定路径中
public static T LoadFromJson(string) 接口函数,将文件中的数据读出并反序列化为指定类型
public static void FileDelete(string path) 接口函数,删除指定文件

代码汇总

fold | file:SaveSystem.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.IO;
using Unity.VisualScripting;
using System;
public class SaveSystem : MonoBehaviour
{
#region 单例
public static SaveSystem instance;
private void Awake()
{
if (instance == null)
{
DontDestroyOnLoad(gameObject);
instance = this;
}
else if (instance != this)
{
Destroy(gameObject);
}
}
#endregion


#region prefs
public static void SaveByPlayerPrefs(string key, object obj)
{
string json = JsonUtility.ToJson(obj);
PlayerPrefs.SetString(key, json);
PlayerPrefs.Save();
}
public static T LoadFromPlayerPrefs<T>(string key)
{
string json = PlayerPrefs.GetString(key,null);
if (json!=null)
{
return JsonUtility.FromJson<T>(json);
}
return default;
}
#endregion

#region JOSN
static string GetPath(string filename)
{
return Path.Combine(Application.persistentDataPath, filename);
}
public static void SaveByJson(string path, object obj)
{
string json = JsonUtility.ToJson(obj);
Debug.Log(json);
// path dir not exist
if (!Directory.Exists(Path.GetDirectoryName(path)))
{
Directory.CreateDirectory(Path.GetDirectoryName(path));
}
System.IO.File.WriteAllText(path, json);

}

public static T LoadFromJson<T>(string fileName)
{
if (fileName == ""||fileName==null)
{
Debug.Log("File name is empty");
return default;
}
string path = GetPath(fileName);
if(File.Exists(path))
{
string json = File.ReadAllText(path);
Debug.Log($"Loaded from {path}");
return JsonUtility.FromJson<T>(json);
}
else
{
Debug.Log("File not found in " + path);
return default;
}

}
public static void FileDelete(string path)
{
// path存在
if (File.Exists(GetPath(path)))
{
File.Delete(GetPath(path));
}
}

#endregion

hero

最后就是我们的主要数据控制部分了,在hero类或者你自己的Player类中,我们需要定义需要保存的数据结构,以及调用接口进行保存和读取操作。如果你看的仔细的话,在save.cs类中已经存在了对hero单例的函数调用,因为当前的所有数据都保存在hero中,我们需要它来具体读入和保存。

数据部分

首先我们要定义一个需要保存的数据结构,并声明为可序列化类:

1
2
3
[System.Serializable]public class SaveData{
// your data
}

这里可以保存任何你想保存的数据,包括基础类型、列表、字典等一切可序列化的数据。
如果你想实现自动保存的话,你还需要一个自动保存计时器。并定义自动保存挡位ID和它的自动保存时间:

1
2
3
private float SinceLastSaveTime;
private const int AUTO_SAVE_ID = 1;
private const int AUTO_SAVE_TIME = 10;

然后你还需要在Start函数中初始化他们,并在Update函数中更新计时器。
这部分我不细讲了。你可以自己实现,或者借助GPT的力量。

方法

方法 作用
SaveData ForSave() 保存前的数据准备
void ForLoad(SaveData) 读取后的数据拷贝
public void Save(int) 接口函数,用于保存当前数据到id档位
public void Load(int) 接口函数,用于读入id档位数据
public static void DeleteSave(int) 接口函数,用于删除某一个存档(此函数用于调试)
public void AutoSave() 接口函数,自动存档一次

代码我给出存档部分的,具体的数据请自己实现:

fold | file:hero.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
    #region JSON存档
SaveData ForSave()
{
var saveData = new SaveData();
// copy your data
return saveData;
}
void ForLoad(SaveData data)
{
// copy your data
}
public void Save(int id)
{
string RecordName = RecordData.instance.genRecordName(id);
if(RecordName == null)
{
Debug.LogError("RecordName is null");
Debug.LogError("ID: "+id.ToString());
return;
}
Debug.Log("Save to :"+RecordName);
SaveSystem.SaveByJson(RecordName, ForSave());
}
public void Load(int id)
{
var saveData = SaveSystem.LoadFromJson<SaveData>(RecordData.instance.getRecordName(id));
if (saveData != null)
{
ForLoad(saveData);
Debug.Log("Load the data from " + id.ToString() + " : " + saveData);
}
else
Debug.Log(id.ToString() + "空存档");
}

#if UNITY_EDITOR
[UnityEditor.MenuItem("Jobs/Delete All PlayerPrefs")]
public static void DeleteAllSave()
{
Debug.Log("Deleting all save!");
for(int i=0;i<RecordData.instance.getNum(); i++)
{
DeleteSave(i);
}
if (PlayerPrefs.HasKey(RecordData.NAME))
{
PlayerPrefs.DeleteKey(RecordData.NAME);
}
Debug.Log("All save deleted!");
}
#endif
public static void DeleteSave(int id)
{
UIManager.instance.DeleteSave(id);
}
public void AutoSave()
{
// in update save with 5 mins
Save(AUTO_SAVE_ID);
}
#endregion

后记

存档部分就到这里啦,其实要完成一个存档,不仅仅需要代码的支持,还要和组件一起合作实现,需要完成两边协作才行!希望这篇文章对你有帮助,而不是代码对你有帮助,这种实现方法,这种理念我认为是很好的。谢谢阅读~

如何使用ffmpeg无损压缩视频?

无损?这里指视觉无损,对于我们这些视力不太好的程序员来说视觉无损已经非常好了。我们需要使用到ffmpeg工具,这是一个十分强大的武器。但是刚接触到它的时候我会觉得它强大而无法掌握,直到现在我也不能说有多了解它,只能在文档的加持下才能完成自己需要的操作。今天我们需要无损压缩视频,首先我们要了解的是:我们的视频能否还能被压缩?

在压缩它之前,我们可以简单地检查一下我们的视频的编码方式,不管怎么样先安装ffmpeg再开始下面的操作吧。

1
sudo apt install ffmpeg

第一步:检查源文件

一个视频大多由三部分组成,画面(Video)、音频(Audio)、字幕(Subtitle)。我自己的翻译,英语不好还请自行理解我们可以简单地使用ffmpeg中附加的一个工具ffprobe来检查他们:

1
2
# ffprobe -i <checkfilename>
ffprobe -i 01\ 猴王初问世.mkv

你可以在结果中找到这三部分:

我们一般需要压缩视频的时候,大多从画面下手,也就是我们的Video,所以我们需要格外的关注我们的Video格式。(后面我都用Video代替画面)可以看到,这里我检查的是一个.mkv文件,它的Video编码格式是hevc也就是我们俗称的H.265,但是这个格式有一个很大的问题:兼容性很差。,需要注意的是.mkv是该视频的容器——而非编码方式,你需要使用ffprobe才能看到正确的编码格式。因为容器与编码方式是多对多的。

HEVC(High Efficiency Video Coding),也称为H.265或H.265/MPEG-H Part 2,是一种视频压缩标准,它是继H.264(AVC)/MPEG-4 Part 10之后的新一代视频编码技术。HEVC旨在提供更高的数据传输效率,即在相同的数据率下实现更高质量的图像,或者在相同的图像质量下减少所需的数据量。

我非常讨厌H.265,主要还是因为它兼容性实在是太差了。因为我的视频都是存放在我的NAS中的,我需要经常使用web浏览、播放他们,而hevc(H.265)在大多情况下无法完成这个工作。

我们常见的格式有:

常见的Video编码格式

这里参考wiki-视频压缩

视频编码标准主要是由ITU-TISOIEC两大组织制定而成,其发展如下表所示。(注:原文体验更佳)

年份 标准 制定组织 解除著作权保护
DRM-free
主要应用
1984 H.120 ITU-T
1990 H.261 ITU-T 视频会议视频通话
1993 MPEG-1第二部分 ISOIEC 影音光盘(VCD
1995 H.262/MPEG-2第二部分 ISOIECITU-T DVD影碟DVD-Video)、蓝光Blu-Ray)影碟、数字视频广播(DVB)、SVCD
1996 H.263 ITU-T 视频会议视频通话3G手机视频(3GP
1999 MPEG-4第二部分 ISOIEC
2003 H.264/MPEG-4 AVC ISOIECITU-T 蓝光Blu-Ray)影碟、高清DVDHD DVD)、数字视频广播(DVB)、流媒体、视频录制
2013 高效率视频编码(H.265/HEVC) ISO/IECITU-T 超高清蓝光光盘(UHD Blu-Ray)、数字视频广播(DVB)、流媒体、视频录制
2020 多功能视频编码(H.266/VVC) ISO/IECITU-T 未普及

到现在,我们使用的最多的仍然是H.264。我非常喜欢这个编码,兼容性好,体积也不大。

常见的容器

这里参考wiki-视频文件格式

视频档 简介 扩展名
Flash Video Adobe Flash延伸出来的的一种流行网络视频封装格式。此格式作为早期网络视频载体而曾非常普及,但已于2020年被替换性淘汰。 flv
AVI
(Audio Video Interleave)
比较早的AVI是微软开发的。其含义是Audio Video Interactive,就是把视频和音频编码混合在一起存储。AVI也是最长寿的格式,首次发布于 1992 年,虽然发布过改版(V2.0于1996年发布),但已显老态。AVI格式上限制比较多,只能有一个视频轨道和一个音频轨道(现在有非标准插件可加入最多两个音频轨道),还可以有一些附加轨道,如文字等。AVI格式不提供任何控制功能。 avi
WMV
(Windows Media Video)
同样是微软开发的一组数字视频编解码格式的通称,ASF(Advanced Systems Format)是其封装格式。ASF封装的WMV档具有“数字版权保护”功能。 wmv/asf
wmvhd
MPEG
(Moving Picture Experts Group)
是一个国际标准化组织(ISO)认可的媒体封装形式,受到大部分机器的支持。其存储方式多样,可以适应不同的应用环境。MPEG-4档的档容器格式在Part 1(mux)、14(asp)、15(avc)等中规定。MPEG的控制功能丰富,可以有多个视频(即角度)、音轨、字幕(位图字幕)等等。MPEG的一个简化版本3GP还广泛的用于准3G手机上。 dat(VCD)
vob(DVD)
mpg/mpeg
mp4
3gp/3g2(手机)
Matroska 是一种新的多媒体封装格式,这个封装格式可把多种不同编码的视频及16条或以上不同格式的音频和语言不同的字幕封装到一个Matroska Media档内。它也是其中一种开放源代码的多媒体封装格式。Matroska同时还可以提供非常好的交互功能,而且比MPEG更方便、强大。 mkv
Real Video
Real Media(RM)
是由RealNetworks开发的一种档容器。它通常只能容纳Real Video和Real Audio编码的媒体。该档带有一定的交互功能,允许编写脚本以控制播放。RM,尤其是可变比特率的RMVB格式,没有复杂的Profile/Level,制作起来较H.264视频格式简单,非常受到网络上传者的欢迎。此外很多人仍有RMVB体积小高质量的错误认知,这个不太正确的观念也导致很多人倾向使用rmvb,事实上在相同码率下,rmvb编码和H.264这个高度压缩的视频编码相比,体积会较大。 rm/rmvb
QuickTime File Format 是由苹果公司开发的容器。1998年2月11日,国际标准化组织(ISO)认可QuickTime文件格式作为MPEG-4标准的基础。QuickTime可存储的内容相当丰富,除了视频、音频以外还可支持图片、文字(文本字幕)等。 mov
qt
Ogg Media是一个完全开放性的多媒体系统项目,OGM(Ogg Media File)是其容器格式。OGM可以支持多视频、音频、字幕(文本字幕)等多种轨道。 ogg/ogv/oga
MOD JVC生产的硬盘摄录机所采用的单元格式名称。 mod

可以看到常见的格式有:flv, avi, wmv, asf, wmvhd, dat, vob, mpg, mpeg, mp4, 3pg, 3g2, mkv, rm, rmvb, mov, qt, ogg, ogv, oga, mod。而在我们的移动设备中常用的其实只剩下了:

容器格式 可用编码格式 用途
MP4 H.264/AVC, H.265/HEVC, MPEG-2, MPEG-4 SP/VSP, VC-1 最广泛的通用格式,几乎所有移动设备和桌面计算机都支持
WEBM VP8, VP9 主要用于网页上,免费且不受专利限制
FLV Soreson Spark, H.264 主要用于Flash Player
MKV H.264, H.265, VP8/VP9 支持极高的质量和大量的元数据,但是兼容性较差

我喜欢H.264MP4,这两种格式有着非常强大的兼容性,并且有着不错的压缩效率。了解了这些之后,你也可以在wiki上继续了解音频、字幕的格式。但是本文只会介绍如何使用ffmpeg将格式转化为我最喜欢的格式——H.264MP4

第二步:简单的了解一下我们的工具

ffmpeg提供了清晰,但是复杂的框架。如果你觉得看过文章之后还是不喜欢使用可以尝试网上的一些ffmpeg命令生成器。但是我比较笨,那个不太会用。Whatever 我希望你可以自己去wiki上学习ffmpeg:wiki-ffmpeg

请尝试使用

1
2
3
ffmpeg --help
ffmpeg --help > /dev/null
ffmpeg --help > /dev/null 2>&1

你应该会发现三次输出均有些不同,特别的是第二次的指令仍然会在控制台打印一些信息出来,就像这样:

它是ffmpeg的配置输出,属于错误输出流,所以第三句命令在将错误输出也重定向到黑洞时就不会再出现他们了,新手在使用ffmpeg的时候经常会被这个配置单吓到,请不用害怕,我们暂时可以跳过它。仔细阅读下面的manual

输入输出-i

ffmpeg的输入需要使用-i指定,输出文件名放在命令的最后。如果不设置输出名,是不会执行任何写操作的:

可以看到,它会提醒你需要设置输出文件。

使用预设-preset

首先检查你的ffmpeg时候存在预设功能:

1
ffmpeg --help | grep preset

如果能找到就可以使用:
ffmpeg支持很多种预设,速度从快到慢:

预设 参考压缩速度 参考指令 大小 bitrate
原视频 - - 2.0G 5173 kb/s
ultrafast frame=68643 fps=197 q=32.0 size= 1990400kB time=00:45:46.43 bitrate=5936.9kbits/s dup=2 drop=0 speed=7.88x ffmpeg -i ../08\ name.mkv -preset ultrafast -c:v libx264 ultrafast_08.mp4 2.3G 5703 kb/s
superfast frame=22297 fps=107 q=34.0 size= 477440kB time=00:14:52.50 bitrate=4382.3kbits/s dup=2 drop=0 speed=4.29x ffmpeg -i ../08\ name.mkv -preset superfast -c:v libx264 ultrafast_08.mp4 1.5G 3667 kb/s
veryfast frame= 2057 fps= 97 q=40.0 size= 28928kB time=00:01:22.98 bitrate=2855.6kbits/s dup=1 drop=0 speed= 3.9x /smae with up 1.1G 2628 kb/s
faster frame= 6196 fps= 73 q=40.0 size= 117760kB time=00:04:08.45 bitrate=3882.8kbits/s dup=2 drop=0 speed=2.91x /smae with up 1.3G 3164 kb/s
defult frame=82457 fps= 57 q=-1.0 Lsize= 1273402kB time=00:54:58.16 bitrate=3162.9kbits/s dup=3 drop=0 speed=2.28x /smae with up 1.3G 3026 kb/s
fast so slow without test /smae with up 1.3G 3241 kb/s
medium so slow without test /smae with up
slow so slow without test /smae with up
slower so slow without test /smae with up
veryslow so slow without test /smae with up
extreme so slow without test /smae with up
none so slow without test /smae with up

但是实际上,编码速度和文件大小并不呈反比,我只测试了几种预设:

通过这张表和码率我们可以清晰的看到速度与压缩率不是成正比的,一般我们需要一个相对平衡的点,我习惯使用fast进行压缩。

设置输出Video编码格式-c:v

如果你读过上面的ffmpeg维基百科,你应该能够看到类似的指令。ffmpeg不需要指定输入文件的格式,和输出文件的容器格式,因为后缀名可以体现一个视频的容器格式。所以我们只需要指定他们的-c:vVideo格式,-c:aAudio格式。

第三步:写出自己的指令!

比如我们有一个名为:test.mkv 的文件,我们需要将它设置为容器格式为:MP4 Video编码格式为:H.264 的名为out.mp4 的视频,我们可以这么写:

1
ffmpeg -i test.mkv -c:v libx264 out.mp4

然后在加上一些压缩:

1
ffmpeg -i test.mkv -preset fast -c:v libx264 out.mp4

这里我选择了fast预设,它的肉眼损失很小,并且能有很好的压缩率。

第三步:批处理

我们从网上下载的资源经常不会遵守命名规则,可能有空格和中文,这个时候就不是很好分割他们。这里我给出一个使用后缀名识别中文与空格文件的批处理指令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#!/bin/bash

input_dir="../"
tail=".mkv"

# 下面无需修改
IFS=$"\t\n"
input_list=$(ls $input_dir | grep $tail)
echo "File list:"
for file in $input_list;do
echo $file
done
read -p "Make Sure?(Y/N)" flag
if [ "$flag" == "N" ];then
echo "Cancel"
exit 1
fi
for file in $input_list;do
echo "Solving $file ...."
ffmpeg -i "$file" -preset fast -c:v libx264 "output-$(basename "$file" .mkv).mp4"
echo "Converted $file to H.264 format"
done

我添加了一个展示待处理文件的功能,防止误操作。你可以放心的运行这个批处理。如果有能力可以自行修改预设或者压缩率。

|