分類
方法

旅行不丟東西小撇步

住宿不丟東西的訣竅--恢復原狀。離店前簡單整理下床鋪,把遙控器放回原位,關掉燈和空調,帶走產生的垃圾。然後只需回望一眼房間即可確定自己所有物品已經帶齊。

出門不落東西的訣竅--預留充足的時間。如果出門前需要在家裡做飯吃飯,就留兩個小時;如果不吃飯,就留一個小時。在這一個小時裡完成洗漱以及未完成的打包,如最後才裝包的手機充電器和電腦。只要能避免慌張出門,就能大大降低出門落東西的概率。

出門不丟手機的訣竅……這個真的很難!以我丟手機的經驗來說,發生次數最多的是遺落在公交車上,遺落在轎車上以及遺落在沙灘上(在國外旅行痛失兩部手機的慘痛記憶)。我常年穿迪卡儂的一款速幹長褲,其口袋帶拉鍊,只需輕輕一拉,手機便萬無一失,遺憾的是我經常不拉。我目前的做法就是「記得拉拉鍊」,養成放在固定可靠地方的習慣。由於遺失手機,我們在很多地方都遇到好心人幫忙,也不失為一種旅行體驗。當然我們也撿到過兩三次手機,不過我們並沒有見過失主,我們最多將其放在更顯眼的位置,希望失主能順利尋回。

既然說到遺失手機,自然就帶來遺失數據的問題,這個其實比較簡單--及時備份。從大公司到開源軟件,選擇有不少。有些軟件也有自己的備份方法,要儘早多加利用,莫等珍貴資料遺失才追悔莫及。注意:本機備份等於沒有備份,一定要保存至另一個設備或雲端才算數。手機遺失通常還會伴隨 SIM 卡的遺失,像微信這種以「安全」為由,在新設備登錄必須短信驗證就廢了。此時一個備用的聯繫方式就很必要,這裡我強烈推薦下 DeltaChat(端對端加密、提供默認服務器方便註冊且不驗證個人資料、中國可用)。最近出國試了下 eSim 發現方便不少,價格和中國移動境外包差不多,但是數據不過牆。而且可以按量付費,非常適合我們這種不刷視頻,只是看看地圖的低流量用戶。手機遺失的時候也只需在網上註銷那張卡,然後重新申領一張就行了。

分類
软件

在 Alma Linux 9 上安裝和使用 Docker

安裝

sudo dnf update -y
sudo dnf config-manager --add-repo=https://download.docker.com/linux/centos/docker-ce.repo
sudo dnf install docker-ce docker-ce-cli containerd.io

#Start and Enable Docker
sudo systemctl start docker
#sudo systemctl enable docker

#Verify Installation
sudo docker run hello-world

sudo usermod -aG docker YOUR_USER
sudo systemctl enable docker

更換 Docker 源

時間來到 2024 年,默認的軟件源不是{"errors":[{"code":"UNAUTHORIZED","message":"authentication required","detail":null}]}就是Unable to find image 'hello-world:latest' locally docker: Error response from daemon: Get "https://registry-1.docker.io/v2/": read tcp 192.168.1.42:55114->54.236.113.205:443: read: connection reset by peer.,可以通過修改軟件源比如 https://docke.eu.org 來解決。

sudo nano /etc/docker/daemon.json
#內容為:
{
    "registry-mirrors": ["https://docke.eu.org"]
}

#然後重啟服務
sudo systemctl restart docker

使用代理

如果更換 Docker 源也不行,還有一個簡單的辦法,就是使用代理。

sudo mkdir -p /etc/systemd/system/docker.service.d
sudo nano /etc/systemd/system/docker.service.d/http-proxy.conf

[Service]
Environment="HTTP_PROXY=socks5://127.0.0.1:44082"
Environment="HTTPS_PROXY=socks5://127.0.0.1:44082"
Environment="NO_PROXY=localhost,127.0.0.1,::1"

#restart docker
sudo systemctl daemon-reload
sudo systemctl restart docker

#verify the setting
systemctl show docker | grep -i proxy

postgres Docker

#file:docker-compose.yml
services:
  db:
    image: postgres
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    ports:
      - "5432:5432"
    volumes:
      - ./docker-data-pg:/var/lib/postgresql/data     
    deploy:
      resources:
        limits:
          cpus: ${POSTGRES_CPU_LIMIT}
          memory: ${POSTGRES_MEM_LIMIT}
    restart: ${POSTGRES_RESTART_POLICY}
#file:.env
POSTGRES_USER=userp
POSTGRES_PASSWORD=passwordp
POSTGRES_DB=dbname
#restart | no
POSTGRES_RESTART_POLICY=no
POSTGRES_CPU_LIMIT=2.0
POSTGRES_MEM_LIMIT=1024M 
#啟動 docker
sudo docker compose up -d

在網上看到有人提醒說,compose 文件中的資源限制(CPU、內存)會導致容器內服務重啟。推薦的做法是在容器內服務中配置硬件限制。

遷移 Docker 與數據

在舊電腦上:

#查看 docker 名字
docker ps
#導出所有數據庫數據
docker exec -t <container_name> pg_dumpall -U <username> > all_databases.sql

複製 docker-compose.yml 和 all_databases.sql 到新電腦上,然後:

#啟動 docker
sudo docker compose up -d
#導入所有數據庫數據(方法一)
sudo docker exec -i <container_name> psql -U <username> -d postgres < all_databases.sql
#導入所有數據庫數據(方法二)
sudo docker cp all_databases.sql <container_name>:/tmp/all_databases.sql
sudo docker exec -i <container_name> psql -U <username> -d postgres -f /tmp/all_databases.sql

Docker 常用命令

#build new docker
docker compose up -d --build
#查看 docker 名字
docker ps
#進入 docker 容器
docker exec -it DOCKER_NAME bash

#If You Change environment, volumes, ports, or depends_on (data safe)
docker compose up -d
#If You Change the Dockerfile or build Settings
docker compose up -d --build

#stop and remove docker container cleanly
docker compose down
#remove everything even data
docker compose down -v
#If you've removed services or images and want to reclaim disk space
docker system prune
分類
記事

251103

站點升級:服務器升級到 2GB 內存。數據庫和 WordPress 放到了 Docker 里。v2fly 在 Cloudflare 的中轉下速度仍然不錯。

分類
程序 软件

將 flightradar24 航班位置記錄轉換為 gpx 文檔

前幾天坐飛機,一覺醒來看到壯麗的雪山。拍了不少照片,但是並不清楚雪山的大概位置。想要給飛機上拍攝的照片加上地理位置,需要 gpx 文檔,然後用 digiKam 就可以標記照片的位置。怎麼取得航班的位置呢?有兩個方法。

第一個是 FlightAware。搜索到航班的歷史記錄如這個 ZH8960。頁面上有個「+ Google Earth」的圖標,點擊後會得到一個 kml 檔案。然後到 kml2gpx.com 即可將文檔轉換為所需要的 gpx 文檔。但是這個方法的問題在於 FlightAware 的數據會有一部分是直線,看起來就是沒有 GPS 數據,所以我最終使用的方法二。

第二個略複雜,是用 flightradar24 的數據。搜索結果頁也有 kml 檔案下載按鈕,但是需要成為會員才能使用。所以點擊旁邊的回放按鈕,來到回放頁面。然後找到包含 GPS 數據的網絡請求:按 F12 按鈕打開瀏覽器開發者工具 > 切換到「網絡」標籤 > 刷新頁面 > 在過濾請求的輸入框里填寫 flight-playback.json 即可看到所需要的請求 > 在請求上右鍵 > 選擇「保存回應為(Save Response As)即可獲得包含 GPS 數據的 json 文檔。然後使用我的這個 Python 腳本即可將 json 文檔轉換為 gpx 文檔。

######################
#file json2gpx.py
# convert json file from www.flightradar24.com to gps file.
# get the json file by open the web page: https://www.flightradar24.com/data/flights/zh8960#3a6f819a
# then save the request https://api.flightradar24.com/common/v1/flight-playback.json?flightId=3a6f819a&timestamp=1747838700 as flight-playback.json
# run python json2gpx.py, and get output.gpx
######################

import json
import xml.etree.ElementTree as ET
from datetime import datetime, timezone

#read json from file
with open('flight-playback.json', 'r') as f:
    data_all = json.load(f)
data = data_all['result']['response']['data']['flight']['track']

# Create GPX root
gpx = ET.Element("gpx", version="1.1", creator="JSON-to-GPX Converter", xmlns="http://www.topografix.com/GPX/1/1")
trk = ET.SubElement(gpx, "trk")
trkseg = ET.SubElement(trk, "trkseg")

# Add track points
for point in data:
    trkpt = ET.SubElement(trkseg, "trkpt", lat=str(point["latitude"]), lon=str(point["longitude"]))
    ET.SubElement(trkpt, "ele").text = str(point["altitude"]["meters"])
    timestamp_iso = datetime.fromtimestamp(point["timestamp"], tz=timezone.utc).isoformat().replace("+00:00", "Z")
    ET.SubElement(trkpt, "time").text = timestamp_iso

# Write to GPX file
tree = ET.ElementTree(gpx)
with open("output.gpx", "wb") as f:
    tree.write(f, encoding="utf-8", xml_declaration=True)

print("GPX file created as 'output.gpx'")

值得留意的是,flightradar24 僅為非會員提供一周內的免費數據。如果你要查詢的航班資料大於一周,就要付費了。

本文更新於 2025/10/26。

分類
软件

使用 Vegasaur 導出 Vegas Pro 視頻項目中的字幕

我想要將 Vegas Pro 中的字幕和文字導出並生成 Youtube 的 Chapter 章節。一開始我嘗試了 Tools 里的 Scripting,但是並沒有成功。後來找到 Vegasaur Toolkit 這個工具,它有 30 天免費試用,可以將軌道上的文字導出成 srt 字幕文件。然後我使用 ffmpeg 將 srt 轉換成 vtt 字幕文件,最後再通過 Python 腳本將其轉換成 Youtube 支持的章節文本文檔。

下載 Vegasaur Toolkit,並安裝。我的 Vegas Pro 版本是 18,我安裝的 Vegasaur Toolkit 版本是 3.9.5。安裝完,打開 .veg 文件,鼠標選中要導出的字幕軌道,然後點擊 View > Extensions > Vegasaur > Timeline > Text Generation Wizard.在彈出的窗口中選擇 Export text events,然後點 Next。然後選擇 Selected Tracks 和 Save to File 並設置要導出的文件名和文件格式。最後點擊 Finish 就導出了。

剩下的步驟我使用了腳本來輔助完成。我有兩條字幕軌道,章節文本基本上都位於第一條軌道,但是偶爾第而條軌道也會有,所有我還需要合併一下兩個軌道導出的字幕文件:

#vtt utils
import subprocess
import sys
from datetime import timedelta

#usage:

#srt to youtube chapters
#python main.py srt2yt 1.srt 2.srt

def add_style_to_vtt(input_file, output_file, style):
    with open(input_file, 'r') as f:
        lines = f.readlines()

    with open(output_file, 'w') as f:
        for line in lines:
            if '-->' in line:
                start, end = line.strip().split(' --> ')
                # Check if the timecodes are less than 60 minutes
                if len(start) <= 9:  # Check if start time is less than or equal to 60 minutes
                    start = '00:' + start
                if len(end) <= 9:  # Check if end time is less than or equal to 60 minutes
                    end = '00:' + end				
                # Add style settings after the timecode
                #line = line.strip() + ' ' + style + '\n'
                line = f'{start} --> {end} {style}\n'                
            f.write(line)

def combine_two_vtt(input_file_1, input_file_2, output_file):
    # with open(input_file_1, 'r') as f:
    #     lines_1 = f.readlines()

    # with open(input_file_2, 'r') as f:
    #     lines_2 = f.readlines()
    # with open(output_file, 'w') as f:
    #     f.writelines(lines_1 + lines_2)
    vtt1_lines = parse_vtt_file(input_file_1)
    vtt2_lines = parse_vtt_file(input_file_2)

    lines = vtt1_lines + vtt2_lines
    write_sorted_vtt(lines, output_file)

def time_to_seconds(time_str):
    try:
        hours, minutes, seconds = map(float, time_str.split(':'))
    except:
        hours = 0
        minutes, seconds = map(float, time_str.split(':'))
    return hours * 3600 + minutes * 60 + seconds

def seconds_to_time(seconds):
    hours = int(seconds // 3600)
    minutes = int((seconds % 3600) // 60)
    seconds = seconds % 60
    return f'{hours:02.0f}:{minutes:02.0f}:{seconds:06.3f}'

def youtube_chapters(input_file, output_file):
    with open(input_file, 'r') as f:
        lines = f.readlines()

    with open(output_file, 'w') as f:
        last_time = '00:00'
        i = 0
        while i < len(lines):
            if '-->' in lines[i]:
                caption_list=[]
                first_dot_index = lines[i].find('.')
                current_time = lines[i][:first_dot_index]
                #if current_time - last time < 10: then current time = last time + 10 seconds
                if time_to_seconds(current_time) - time_to_seconds(last_time) < 10:
                    current_time = seconds_to_time(time_to_seconds(last_time) + 10)
                # set the current time to the last time
                last_time = current_time
                caption_list.append(current_time)

                while i < len(lines)-1 and lines[i+1] != '\n':
                    caption_list.append(lines[i+1].strip())
                    i += 1

                if len(caption_list) == 4:
                    #remove the third element of list
                    caption_list.pop(2)
                caption = ' '.join(caption_list)
                f.write(caption + '\n')
            i += 1

def parse_vtt_timestamp(ts):
    """Parses a VTT timestamp into a timedelta object."""
    parts = ts.split(":")
    if len(parts) == 3:
        h, m, s = parts
    elif len(parts) == 2:
        h = 0
        m, s = parts
    else:
        raise ValueError(f"Unexpected timestamp format: {ts}")
    s, ms = s.split(".")
    return timedelta(hours=int(h), minutes=int(m), seconds=int(s), milliseconds=int(ms))

def parse_vtt_file(file_path):
    with open(file_path, 'r', encoding='utf-8') as f:
        lines = f.readlines()

    entries = []
    i = 0
    while i < len(lines):
        line = lines[i].strip()
        if "-->" in line:
            timestamp = line
            start_time = parse_vtt_timestamp(timestamp.split(" --> ")[0])
            content = []
            i += 1
            while i < len(lines) and lines[i].strip() != "":
                content.append(lines[i].rstrip('\n'))
                i += 1
            entries.append((start_time, timestamp, content))
        else:
            i += 1
    return entries

def write_sorted_vtt(entries, output_path):
    entries.sort(key=lambda x: x[0])
    with open(output_path, 'w', encoding='utf-8') as f:
        f.write("WEBVTT\n\n")
        for _, timestamp, content in entries:
            f.write(f"{timestamp}\n")
            for line in content:
                f.write(f"{line}\n")
            f.write("\n")

def format_youtube_time(td):
    total_seconds = int(td.total_seconds())
    hours = total_seconds // 3600
    minutes = (total_seconds % 3600) // 60
    seconds = total_seconds % 60
    if hours > 0:
        return f"{hours:02}:{minutes:02}:{seconds:02}"
    else:
        return f"{minutes:02}:{seconds:02}"

def parse_vtt_for_chapters(file_path):
    with open(file_path, 'r', encoding='utf-8') as f:
        lines = f.readlines()

    entries = []
    i = 0
    while i < len(lines):
        line = lines[i].strip()
        if "-->" in line:
            timestamp_line = line
            start_str, end_str = timestamp_line.split(" --> ")
            start = parse_vtt_timestamp(start_str.strip())
            end = parse_vtt_timestamp(end_str.strip())
            duration = end - start

            i += 1
            content = []
            while i < len(lines) and lines[i].strip():
                content.append(lines[i].strip())
                i += 1

            # Expected format: Chinese name on 1st line, English name on 3rd line
            if len(content) >= 2:
                chinese_name = content[0]
                english_name = content[-1]
                entries.append((chinese_name, english_name, start, duration))
        else:
            i += 1

    # Deduplicate: keep only the longest-duration entry per Chinese name
    chapter_dict = {}
    for cname, ename, start, duration in entries:
        if cname not in chapter_dict or duration > chapter_dict[cname][1]:
            chapter_dict[cname] = (ename, duration, start)

    # Sort by start time
    sorted_chapters = sorted(
        [(data[2], cname, data[0]) for cname, data in chapter_dict.items()],
        key=lambda x: x[0]
    )

    return sorted_chapters

def write_youtube_chapters(chapters, output_path):
    with open(output_path, 'w', encoding='utf-8') as f:
        for start, cname, ename in chapters:
            time_str = format_youtube_time(start)
            f.write(f"{time_str} {cname} {ename}\n")

if __name__ == "__main__":
    # choose function from command line arguments
    if len(sys.argv) > 2:
        # combine two vtt files
        func = sys.argv[1]
        if func == 'combine':
            input_file_1 = sys.argv[2]
            input_file_2 = sys.argv[3]
            output_file = input_file_1[:-4] + '.combined.vtt'
            combine_two_vtt(input_file_1, input_file_2, output_file)
        elif func == 'style':
            input_file = sys.argv[2]
            output_file = input_file[:-4] + '.style.vtt'
            style = sys.argv[3]
            # style = 'position:100% align:right size:50%'
            # style = 'position:0% align:left size:50%'
            add_style_to_vtt(input_file, output_file, style)
        elif func == 'youtube':
            input_file = sys.argv[2]
            output_file = input_file[:-4] + '.youtube.txt'
            # youtube_chapters(input_file, output_file)
            chapters = parse_vtt_for_chapters(input_file)
            write_youtube_chapters(chapters, output_file)
        elif func == 'srt2yt':
            input_file_1 = sys.argv[2]
            input_file_2 = sys.argv[3]
            vtt1 = input_file_1[:-4] + '.vtt'
            vtt2 = input_file_2[:-4] + '.vtt'
            subprocess.run(['ffmpeg', '-i', input_file_1, vtt1])
            subprocess.run(['ffmpeg', '-i', input_file_2, vtt2])
            #wait for user to input anything to continue
            input("Press enter to continue...")
            combined_file = input_file_1[:-4] + '.combined.vtt'
            combine_two_vtt(vtt1, vtt2, combined_file)
            yt_file = input_file_1[:-4] + '.youtube.txt'
            chapters = parse_vtt_for_chapters(combined_file)
            write_youtube_chapters(chapters, yt_file)

腳本中還有一些 Youtube 支持的 vtt 格式字幕樣式的嘗試。

分類
說說

250504

早上 Emanon 像往常一樣做了煎餅。吃晚飯去後山轉了一圈,這座小山很少有人爬。斑腿泛樹蛙趴在人工修建的蓄水池沿上,它們和它們的後代只有很小的機率離開這個人工水池——它的四壁又高又直,只有一個稍矮的小豁口。水池四周還有金屬欄杆,在欄杆下面吊着的蛹過了三天了還是沒有任何動靜。小路邊的同一顆荔枝樹上,總能看到龍眼雞。今年的應該是一個荔枝的大年,荔枝目前長得都挺好,已經有大拇指大小。今天還有很多豹尺蛾成蝶,走在路上很容易驚起一片。比較有趣的是兩次遇到老鼠,之前從來沒遇到過。可惜沒能留下照片。閒聊到 rat 和 mouse 的區別,但是我們兩個都不知道。回來問 AI,AI 說 rat 個體較大,尾巴較長,行為上更具攻擊性,而且多與人類共生。而 mouse 一般生活在草地和森林。所以今天見到的應該是兩隻 mice。走到下山口的時候,看到有人在登記上山。一位女性正要填表格,她問道這個山叫什麼名字,然後她看到了表格上「行山」字樣,就自言自語說原來是行山啊。行山這個粵語在我的輸入法里也不是一個詞彙。

今天是五四青年節。祝願青年朋友們能有好工作、有好食物、有好空氣、有好心情。

分類
說說

250412

前幾天吃帶魚,想起賣大梯子的東北演員小二顧本彬。願他在天堂過的快樂。