

<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>Max的程式語言筆記</title>
	<atom:link href="https://stackoverflow.max-everyday.com/feed/" rel="self" type="application/rss+xml" />
	<link>https://stackoverflow.max-everyday.com</link>
	<description>我要當一個豬頭，快樂過每一天</description>
	<lastBuildDate>Wed, 04 Nov 2026 03:54:26 +0000</lastBuildDate>
	<language>zh-TW</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.1.4</generator>

<image>
	<url>https://stackoverflow.max-everyday.com/wp-content/uploads/2017/02/max-stackoverflow-256.png</url>
	<title>Max的程式語言筆記</title>
	<link>https://stackoverflow.max-everyday.com</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>自動幫照片加黑色背景與邊緣漸層，製作質感桌布</title>
		<link>https://stackoverflow.max-everyday.com/2026/11/make_wallpaper/</link>
					<comments>https://stackoverflow.max-everyday.com/2026/11/make_wallpaper/#respond</comments>
		
		<dc:creator><![CDATA[max-stackoverflow]]></dc:creator>
		<pubDate>Wed, 04 Nov 2026 03:52:17 +0000</pubDate>
				<category><![CDATA[Python筆記]]></category>
		<category><![CDATA[Python]]></category>
		<guid isPermaLink="false">https://stackoverflow.max-everyday.com/?p=8841</guid>

					<description><![CDATA[有一批直式照片想變成橫式桌布，左右兩側的黑色空白...]]></description>
										<content:encoded><![CDATA[
<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph">有一批直式照片想變成橫式桌布，左右兩側的黑色空白感覺很突兀，<br>有沒有辦法讓它自然地「淡入」黑色？</p>
</blockquote>



<p class="wp-block-paragraph">執行的結果, 資料夾下的圖片都換成 1920&#215;1080 的大小:</p>



<figure class="wp-block-image size-full"><img fetchpriority="high" decoding="async" width="658" height="437" src="https://stackoverflow.max-everyday.com/wp-content/uploads/2026/11/FreeCommander_2026-11-04-11-48-2x.jpg?v=1793764176" alt="" class="wp-image-8847" srcset="https://stackoverflow.max-everyday.com/wp-content/uploads/2026/11/FreeCommander_2026-11-04-11-48-2x.jpg?v=1793764176 658w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/11/FreeCommander_2026-11-04-11-48-2x-600x398.jpg?v=1793764176 600w" sizes="(max-width: 658px) 100vw, 658px" /></figure>



<p class="wp-block-paragraph"></p>



<p class="wp-block-paragraph">這篇文章分享一套兩支 Python 腳本的解決方案，全程自動化，支援批次處理整個資料夾。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">效果示意</h2>



<pre class="wp-block-preformatted">原始直式照片              →     加黑色背景           <br>  ┌──────────┐               ┌────────────────────┐<br>  │          │               │░░░│        │░░░░░░░│<br>  │  Photo   │    →          │░░░│ Photo  │░░░░░░░│<br>  │ 1440x1920│               │░░░│        │░░░░░░░│<br>  └──────────┘               └────────────────────┘<br>  （直式 9:16）              （橫式 16:9，兩側黑邊）  </pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">前置需求</h2>



<p class="wp-block-paragraph"><strong>Python 3.8+</strong> 與以下套件：</p>



<pre class="wp-block-code"><code>pip install opencv-python numpy</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">腳本架構</h2>



<p class="wp-block-paragraph">整個方案分成兩支獨立腳本，採<strong>鬆耦合</strong>設計：</p>



<pre class="wp-block-code"><code>make_wallpaper.py          dissolve.py
       │                        │
  ① 讀取來源圖片           ④ 偵測左右黑邊
  ② 貼到黑色畫布           ⑤ 套用邊緣漸層特效
  ③ subprocess 呼叫 ──────&#x25b6;⑥ 輸出到 output/
  ⑦ 複製結果到 wallpaper/</code></pre>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph">[!NOTE]<br><code>dissolve.py</code> 透過 <code>subprocess</code> 呼叫，兩支腳本可獨立維護與升級，不互相依賴。</p>
</blockquote>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">腳本說明</h2>



<h3 class="wp-block-heading"><code>dissolve.py</code> — 邊緣漸層特效</h3>



<p class="wp-block-paragraph"><strong>核心概念</strong>：自動偵測圖片左右兩側的「黑色邊緣」，並對邊界內縮一段距離套用漸層特效。</p>



<p class="wp-block-paragraph"><strong>支援三種特效類型：</strong></p>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>特效</th><th>說明</th><th>效果</th></tr></thead><tbody><tr><td><code>fade</code></td><td>透明度線性遞減</td><td>邊緣漸漸淡入黑色，最自然</td></tr><tr><td><code>blur</code></td><td>高斯模糊漸層融合</td><td>邊緣有朦朧感</td></tr><tr><td><code>dissolve</code></td><td>隨機像素溶解</td><td>邊緣有顆粒噪點感</td></tr></tbody></table></figure>



<p class="wp-block-paragraph"><strong>重要限制</strong>：<code>dissolve.py</code> 設計上<strong>假設圖片本身已存在黑邊</strong>（例如信箱格式的影片截圖）。若圖片無黑邊（左右邊緣直接是畫面內容），腳本不會套用任何特效。</p>



<h3 class="wp-block-heading"><code>make_wallpaper.py</code> — 主控腳本</h3>



<p class="wp-block-paragraph">解決上述限制的關鍵。它會：</p>



<ol class="wp-block-list">
<li>把每張照片<strong>縮放後置中貼到黑色背景畫布</strong>（預設 1920×1080）</li>



<li>存入暫存目錄</li>



<li>呼叫 <code>dissolve.py</code> 對暫存目錄批次處理</li>



<li>把結果複製到 <code>&lt;cwd&gt;/wallpaper/</code> 資料夾</li>
</ol>



<p class="wp-block-paragraph">貼到黑色背景後，原本「沒有黑邊」的問題自動消失，<code>dissolve.py</code> 的邊緣偵測邏輯就能正確作用。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">快速上手</h2>



<h3 class="wp-block-heading">1. 準備目錄結構</h3>



<p class="wp-block-paragraph">把照片放入一個資料夾，兩支腳本放在同一層：</p>



<pre class="wp-block-code"><code>leejueun/
├── dissolve.py
├── make_wallpaper.py
└── original/
    ├── 01-photo.jpg
    ├── 02-photo.jpg
    └── 03-photo.jpg</code></pre>



<h3 class="wp-block-heading">2. 執行指令</h3>



<pre class="wp-block-code"><code># 基本用法（使用所有預設值）
python make_wallpaper.py --cwd original</code></pre>



<p class="wp-block-paragraph">執行後輸出在 <code>original/wallpaper/</code>：</p>



<pre class="wp-block-code"><code>original/
├── wallpaper/
│   ├── 01-photo.jpg   &#x2705; 1920x1080，含 fade 效果
│   ├── 02-photo.jpg
│   └── 03-photo.jpg
└── ...（原始照片不動）</code></pre>



<h3 class="wp-block-heading">3. 完整參數說明</h3>



<pre class="wp-block-code"><code>python make_wallpaper.py &#91;選項]</code></pre>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>參數</th><th>預設值</th><th>說明</th></tr></thead><tbody><tr><td><code>--cwd</code></td><td><code>.</code>（目前目錄）</td><td>來源圖片的資料夾路徑</td></tr><tr><td><code>--canvas</code></td><td><code>1920x1080</code></td><td>輸出畫布尺寸，格式 <code>WxH</code></td></tr><tr><td><code>--effect</code></td><td><code>fade</code></td><td>特效類型：<code>fade</code> / <code>blur</code> / <code>dissolve</code></td></tr><tr><td><code>--width</code></td><td><code>60</code></td><td>漸層特效寬度（像素）</td></tr><tr><td><code>--dissolve</code></td><td>自動偵測</td><td>若 <code>dissolve.py</code> 在其他路徑時指定</td></tr></tbody></table></figure>



<h3 class="wp-block-heading">4. 更多範例</h3>



<pre class="wp-block-code"><code># 製作 2K 桌布，漸層寬度加大到 100px
python make_wallpaper.py --cwd original --canvas 2560x1440 --width 100

# 改用模糊特效
python make_wallpaper.py --cwd original --effect blur --width 80

# 指定來源目錄（絕對路徑）
python make_wallpaper.py --cwd "C:\Photos\vacation"

# 兩支腳本不在同個資料夾時
python make_wallpaper.py --cwd original --dissolve "C:\tools\dissolve.py"</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">單獨使用 <code>dissolve.py</code></h2>



<p class="wp-block-paragraph">如果你的圖片<strong>本身就有黑邊</strong>（例如電影截圖、4:3 老照片），可以直接用 <code>dissolve.py</code>：</p>



<pre class="wp-block-code"><code>python dissolve.py --cwd original --effect fade --width 30</code></pre>



<p class="wp-block-paragraph">輸出在 <code>original/output/</code>。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">設計筆記：為什麼選擇 subprocess？</h2>



<p class="wp-block-paragraph">在寫 <code>make_wallpaper.py</code> 時評估了兩種整合方式：</p>



<p class="wp-block-paragraph"><strong>方案 A：<code>import dissolve</code>（直接 import）</strong></p>



<pre class="wp-block-code"><code># 緊耦合，dissolve.py 更新後需同步確認
import dissolve
dissolve.add_edge_effect(path, width, effect)</code></pre>



<p class="wp-block-paragraph"><strong>方案 B：<code>subprocess</code>（選用）</strong></p>



<pre class="wp-block-code"><code>subprocess.run(&#91;sys.executable, 'dissolve.py', '--cwd', tmp_dir, ...])</code></pre>



<p class="wp-block-paragraph">選擇 <code>subprocess</code> 的理由：</p>



<ul class="wp-block-list">
<li><code>dissolve.py</code> 未來可以換成任何語言改寫，只要 CLI 介面不變就能繼續使用</li>



<li>兩支腳本的 Python 環境、相依套件版本可以不同</li>



<li>錯誤隔離：<code>dissolve.py</code> 崩潰不會影響主程式的清理流程（<code>tempfile</code> 仍會被刪除）</li>



<li>測試容易：每支腳本都能獨立單元測試</li>
</ul>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph">[!TIP]<br>若有效能需求（大量圖片需要平行處理），可改用 <code>concurrent.futures.ProcessPoolExecutor</code> 直接呼叫函式，但需要將 <code>dissolve.py</code> 的核心邏輯抽成模組。</p>
</blockquote>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">常見問題</h2>



<p class="wp-block-paragraph"><strong>Q: 執行後輸出圖片和原圖看起來一樣，沒有漸層效果？</strong></p>



<p class="wp-block-paragraph">原因是直接對「沒有黑邊」的圖執行 <code>dissolve.py</code>。請改用 <code>make_wallpaper.py</code>，它會先幫圖片加上黑色背景，再呼叫 <code>dissolve.py</code>。</p>



<p class="wp-block-paragraph"><strong>Q: 圖片被拉伸了？</strong></p>



<p class="wp-block-paragraph"><code>make_wallpaper.py</code> 預設使用 <code>cv2.INTER_LANCZOS4</code> 高品質縮放並<strong>維持長寬比</strong>，圖片會置中，不會拉伸，只是兩側（或上下）會有黑邊。</p>



<p class="wp-block-paragraph"><strong>Q: 支援 PNG 嗎？</strong></p>



<p class="wp-block-paragraph">目前兩支腳本只搜尋 <code>*.jpg</code>。若需要 PNG 支援，可修改 <code>glob.glob</code> 的 pattern：</p>



<pre class="wp-block-code"><code># 在 dissolve.py 與 make_wallpaper.py 中修改這行
files = glob.glob(os.path.join(target_dir, '*.jpg'))

# 改成支援多格式
import itertools
patterns = &#91;'*.jpg', '*.jpeg', '*.png']
files = list(itertools.chain.from_iterable(
    glob.glob(os.path.join(target_dir, p)) for p in patterns
))</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">完整原始碼</h2>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>檔案</th><th>功能</th></tr></thead><tbody><tr><td><code>dissolve.py</code></td><td>偵測黑邊並套用邊緣漸層特效</td></tr><tr><td><code>make_wallpaper.py</code></td><td>加黑色背景 + 呼叫 dissolve.py 的主控腳本</td></tr></tbody></table></figure>



<h2 class="wp-block-heading"><code>make_wallpaper.py</code></h2>



<pre class="wp-block-code"><code>"""
make_wallpaper.py
-----------------
1. 讀取 --cwd 目錄下所有 jpg 圖片
2. 以黑色背景畫布置中貼上（預設 1920x1080）
3. 透過 subprocess 呼叫 dissolve.py 對畫布套用 fade 效果
4. 最終輸出至 &lt;cwd&gt;/wallpaper/

用法範例：
  python make_wallpaper.py --cwd original
  python make_wallpaper.py --cwd original --width 60 --effect fade
  python make_wallpaper.py --cwd original --canvas 2560x1440
"""

import argparse
import glob
import os
import shutil
import subprocess
import sys
import tempfile

import cv2
import numpy as np


# ---------------------------------------------------------------------------
# Step 1 — 建立黑色畫布並置中貼圖
# ---------------------------------------------------------------------------

def place_on_black_canvas(image_path: str, output_path: str, canvas_w: int, canvas_h: int) -&gt; bool:
    """
    將圖片垂直置中貼到 canvas_w x canvas_h 黑色畫布上，存至 output_path。
    回傳是否成功。
    """
    img = cv2.imread(image_path)
    if img is None:
        print(f'&#91;警告] 無法讀取圖片：{image_path}，跳過。')
        return False

    h, w = img.shape&#91;:2]

    # 計算縮放比例，讓圖片完整放入畫布（維持長寬比）
    scale = min(canvas_w / w, canvas_h / h)
    new_w = int(w * scale)
    new_h = int(h * scale)
    resized = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_LANCZOS4)

    canvas = np.zeros((canvas_h, canvas_w, 3), dtype=np.uint8)

    # 垂直置中、水平置中
    x_off = (canvas_w - new_w) // 2
    y_off = (canvas_h - new_h) // 2
    canvas&#91;y_off:y_off + new_h, x_off:x_off + new_w] = resized

    os.makedirs(os.path.dirname(output_path), exist_ok=True)
    # 以 PNG 無損格式儲存暫存圖，避免 JPEG 量化誤差在 pipeline 中累積
    cv2.imwrite(output_path, canvas)
    return True


# ---------------------------------------------------------------------------
# Step 2 — 以 subprocess 呼叫 dissolve.py（完全解耦）
# ---------------------------------------------------------------------------

def run_dissolve(dissolve_script: str, cwd_dir: str, effect: str, width: int, ease: float) -&gt; bool:
    """
    以 subprocess 呼叫 dissolve.py，對 cwd_dir 目錄下的圖片套用特效。
    輸出會存至 cwd_dir/output/。
    """
    cmd = &#91;
        sys.executable,
        dissolve_script,
        '--cwd', cwd_dir,
        '--effect', effect,
        '--width', str(width),
        '--ease', str(ease),
    ]
    print(f'&#91;呼叫] {" ".join(cmd)}')
    result = subprocess.run(cmd, capture_output=False)
    return result.returncode == 0


# ---------------------------------------------------------------------------
# 主程式
# ---------------------------------------------------------------------------

def parse_canvas(value: str):
    """解析 '1920x1080' 格式，回傳 (width, height)。"""
    try:
        w, h = value.lower().split('x')
        return int(w), int(h)
    except Exception:
        raise argparse.ArgumentTypeError(f'canvas 格式錯誤，請使用 WxH，例如 1920x1080，收到：{value}')


def main():
    parser = argparse.ArgumentParser(
        description='替圖片加上黑色背景，再套用 dissolve.py 的邊緣漸層特效，輸出桌布圖。'
    )
    parser.add_argument(
        '--cwd', type=str, default='.',
        help='來源圖片目錄，預設為目前資料夾。'
    )
    parser.add_argument(
        '--canvas', type=parse_canvas, default='1920x1080',
        metavar='WxH',
        help='輸出畫布尺寸，預設 1920x1080。'
    )
    parser.add_argument(
        '--effect', type=str, default='fade',
        choices=&#91;'dissolve', 'blur', 'fade'],
        help='邊緣漸層特效類型，預設 fade。'
    )
    parser.add_argument(
        '--width', type=int, default=40,
        help='漸層特效寬度（像素），預設 40。'
    )
    parser.add_argument(
        '--dissolve', type=str, default=None,
        metavar='PATH',
        help='dissolve.py 的路徑，預設自動於同目錄尋找。'
    )
    parser.add_argument(
        '--ease', type=float, default=2.0,
        help='fade 曲線指數（僅 fade 有效）。1.0=線性，2.0=二次方（預設）。傳遞至 dissolve.py。'
    )

    args = parser.parse_args()

    # --- 路徑解析 ---
    source_dir = args.cwd
    if not os.path.isdir(source_dir):
        print(f'錯誤：找不到目錄 {source_dir}')
        sys.exit(1)

    canvas_w, canvas_h = args.canvas

    # 自動尋找 dissolve.py（同腳本目錄）
    dissolve_script = args.dissolve
    if dissolve_script is None:
        dissolve_script = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'dissolve.py')
    if not os.path.isfile(dissolve_script):
        print(f'錯誤：找不到 dissolve.py，請用 --dissolve 指定路徑。（找尋位置：{dissolve_script}）')
        sys.exit(1)

    # 收集來源圖片（jpg / jpeg）
    jpg_files = sorted(glob.glob(os.path.join(source_dir, '*.jpg')))
    if not jpg_files:
        print('在指定目錄中找不到任何 jpg 圖片。')
        sys.exit(0)

    print(f'找到 {len(jpg_files)} 張圖片，畫布尺寸 {canvas_w}x{canvas_h}，特效：{args.effect}（寬度 {args.width}px）')

    # --- 暫存目錄（貼黑色背景後的中間結果） ---
    tmp_dir = tempfile.mkdtemp(prefix='make_wallpaper_')
    try:
        # Step 1：貼黑色背景，暫存為 PNG（無損，避免 JPEG 量化誤差累積）
        processed = 0
        for src in jpg_files:
            # 暫存副檔名改為 .png
            fname_png = os.path.splitext(os.path.basename(src))&#91;0] + '.png'
            dst = os.path.join(tmp_dir, fname_png)
            if place_on_black_canvas(src, dst, canvas_w, canvas_h):
                processed += 1

        if processed == 0:
            print('沒有任何圖片成功處理，結束。')
            return

        print(f'&#91;步驟 1/2] 已建立黑色背景圖片：{processed} 張（暫存至 {tmp_dir}）')

        # Step 2：呼叫 dissolve.py 套用特效
        ok = run_dissolve(dissolve_script, tmp_dir, args.effect, args.width, args.ease)
        if not ok:
            print('錯誤：dissolve.py 執行失敗。')
            sys.exit(1)

        # Step 3：將 dissolve 輸出（tmp_dir/output/）轉換為 JPG 並複製到 &lt;source_dir&gt;/wallpaper/
        dissolve_output_dir = os.path.join(tmp_dir, 'output')
        final_output_dir = os.path.join(source_dir, 'wallpaper')
        os.makedirs(final_output_dir, exist_ok=True)

        # dissolve.py 輸出的副檔名與輸入一致（這裡是 .png）
        result_files = sorted(glob.glob(os.path.join(dissolve_output_dir, '*.png')))
        for f in result_files:
            # 最終輸出改回 .jpg
            basename_jpg = os.path.splitext(os.path.basename(f))&#91;0] + '.jpg'
            dst = os.path.join(final_output_dir, basename_jpg)
            # 以高品質 JPEG 儲存（只在最後輸出時壓縮一次）
            img_final = cv2.imread(f)
            cv2.imwrite(dst, img_final, &#91;cv2.IMWRITE_JPEG_QUALITY, 95])
            print(f'&#91;完成] {dst}')

        print(f'\n&#91;步驟 2/2] 全部完成！輸出目錄：{final_output_dir}')

    finally:
        shutil.rmtree(tmp_dir, ignore_errors=True)


if __name__ == '__main__':
    main()
</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p class="wp-block-paragraph">dissolve.py 參考:<br><a href="https://stackoverflow.max-everyday.com/2026/10/python-cv-effect-fade/">https://stackoverflow.max-everyday.com/2026/10/python-cv-effect-fade/</a></p>



<p class="wp-block-paragraph"></p>
]]></content:encoded>
					
					<wfw:commentRss>https://stackoverflow.max-everyday.com/2026/11/make_wallpaper/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>SVG 動畫的排版小陷阱：讓線條和箭頭同步出現</title>
		<link>https://stackoverflow.max-everyday.com/2026/10/svg-path-marker-end/</link>
					<comments>https://stackoverflow.max-everyday.com/2026/10/svg-path-marker-end/#respond</comments>
		
		<dc:creator><![CDATA[max-stackoverflow]]></dc:creator>
		<pubDate>Mon, 26 Oct 2026 21:21:44 +0000</pubDate>
				<category><![CDATA[css筆記]]></category>
		<category><![CDATA[css]]></category>
		<category><![CDATA[Debug]]></category>
		<guid isPermaLink="false">https://stackoverflow.max-everyday.com/?p=8830</guid>

					<description><![CDATA[最近在處理 SVG 動畫時，遇到了一個有趣的畫面...]]></description>
										<content:encoded><![CDATA[
<figure class="wp-block-image size-large"><img decoding="async" width="1024" height="572" src="https://stackoverflow.max-everyday.com/wp-content/uploads/2026/10/watermarked_img_7311881495471058215_clean-1024x572.jpg?v=1793049781" alt="" class="wp-image-8833" srcset="https://stackoverflow.max-everyday.com/wp-content/uploads/2026/10/watermarked_img_7311881495471058215_clean-1024x572.jpg?v=1793049781 1024w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/10/watermarked_img_7311881495471058215_clean-600x335.jpg?v=1793049781 600w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/10/watermarked_img_7311881495471058215_clean-767x428.jpg?v=1793049781 767w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/10/watermarked_img_7311881495471058215_clean.jpg?v=1793049781 1376w" sizes="(max-width: 1024px) 100vw, 1024px" /></figure>



<p class="wp-block-paragraph">最近在處理 SVG 動畫時，遇到了一個有趣的畫面瑕疵。當我們使用 path 標籤搭配 marker-end 來加上箭頭時，瀏覽器展現動畫的行為跟我們預期的有點落差。</p>



<p class="wp-block-paragraph">這篇文章來記錄這個問題的根本原因，以及如何透過簡單的技巧完美解決。</p>



<h2 class="wp-block-heading">問題是甚麼</h2>



<p class="wp-block-paragraph">在網頁中，如果我們寫了 path 結合 marker-end 這樣的語法，本意是希望路徑在畫完的時候，終點才出現這個箭頭。</p>



<p class="wp-block-paragraph">但是實際執行動畫時，只要路徑開始顯現，也就是透明度變成 1 的那一個瞬間，瀏覽器就會在終點立刻把這個箭頭渲染出來。</p>



<p class="wp-block-paragraph">這會導致一個很不協調的現象：線條還在慢慢從起點畫過來，但終點已經提早立好了一個箭頭。</p>



<h2 class="wp-block-heading">為什麼會這樣</h2>



<p class="wp-block-paragraph">這是因為 SVG 的 marker-end 本質上就是一個綁定在路徑端點上的裝飾元件。</p>



<p class="wp-block-paragraph">當我們使用 stroke-dashoffset 來控制線條慢慢畫出來時，路徑只要一有顯示的跡象，瀏覽器就會認為端點已經存在，於是提前把裝飾的箭頭畫上去，沒有辦法隨著線條進度同步延遲出現。</p>



<h2 class="wp-block-heading">解決方案</h2>



<p class="wp-block-paragraph">為了讓箭頭配合線條畫到的時間點才出現，我們需要分開處理路徑和箭頭。</p>



<p class="wp-block-paragraph">第一步是移除 SVG 的 marker-end 屬性，這樣可以避免瀏覽器在路徑終點預先繪製箭頭。</p>



<p class="wp-block-paragraph">第二步是獨立一個箭頭元件出來，這裡使用 polygon 標籤來當作獨立的箭頭，並且根據路徑終點的座標以及切線斜率來旋轉對齊端點。</p>



<h2 class="wp-block-heading">程式碼實作</h2>



<p class="wp-block-paragraph">調整過後的程式碼像這樣：</p>



<p class="wp-block-paragraph">HTML</p>



<pre class="wp-block-code"><code>&lt;svg class="marathon-svg" viewBox="0 0 1400 260" xmlns="http://www.w3.org/2000/svg"&gt;
    &lt;!-- Track path --&gt;
    &lt;path id="track" d="M80,150 Q280,40 480,120 Q680,240 880,140 Q1080,50 1280,150 L1360,180" stroke="#C8922A" stroke-width="6" stroke-linecap="round" fill="none" stroke-dasharray="1360" stroke-dashoffset="1360" style="
            animation: dashDraw 3s ease forwards;
            --dash-len: 1360;
            animation-delay: calc(var(--scene-t,0s) * -1 + 9s);
            animation-play-state: paused;
            "&gt;&lt;/path&gt;
    &lt;!-- Arrowhead that appears when drawing reaches the end (at 11.5s) --&gt;
    &lt;polygon points="0 -9, 22 0, 0 9" fill="#C8922A" transform="translate(1360, 180) rotate(20.56)" style="
            opacity: 0;
            animation: fadeIn 0.3s ease forwards;
            animation-delay: calc(var(--scene-t,0s) * -1 + 11.5s);
            animation-play-state: paused;
            "&gt;&lt;/polygon&gt;
&lt;/svg&gt;
</code></pre>



<p class="wp-block-paragraph">透過把箭頭拉出來獨立控制，並且設定好對應的 animation-delay 讓它在時間點剛好對上線條畫完的一刻，就能解決箭頭提前出現的不協調感。</p>
]]></content:encoded>
					
					<wfw:commentRss>https://stackoverflow.max-everyday.com/2026/10/svg-path-marker-end/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>自動套用溶解特效在圖片邊緣</title>
		<link>https://stackoverflow.max-everyday.com/2026/10/python-cv-effect-fade/</link>
					<comments>https://stackoverflow.max-everyday.com/2026/10/python-cv-effect-fade/#respond</comments>
		
		<dc:creator><![CDATA[max-stackoverflow]]></dc:creator>
		<pubDate>Fri, 23 Oct 2026 05:08:27 +0000</pubDate>
				<category><![CDATA[Python筆記]]></category>
		<category><![CDATA[Python]]></category>
		<guid isPermaLink="false">https://stackoverflow.max-everyday.com/?p=8821</guid>

					<description><![CDATA[在網路上下載了很照片, 想放到電視當背景, 網路...]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">在網路上下載了很照片, 想放到電視當背景, 網路上很多都是直立式圖片, 但電視是橫的, 所以想自動把直的套用溶解特效:</p>



<figure class="wp-block-image size-full"><img decoding="async" width="773" height="697" src="https://stackoverflow.max-everyday.com/wp-content/uploads/2026/10/chrome_2026-10-23-12-40-2i.png?v=1792730621" alt="" class="wp-image-8822" srcset="https://stackoverflow.max-everyday.com/wp-content/uploads/2026/10/chrome_2026-10-23-12-40-2i.png?v=1792730621 773w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/10/chrome_2026-10-23-12-40-2i-600x541.png?v=1792730621 600w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/10/chrome_2026-10-23-12-40-2i-767x692.png?v=1792730621 767w" sizes="(max-width: 773px) 100vw, 773px" /></figure>



<p class="wp-block-paragraph">確認效果OK 之後, 使用下列的提示詞取得自動轉換用的腳本:</p>



<pre class="wp-block-code"><code>give me a python script to do it , auto detect 這種左右側背景都是黑色的圖片, 生成左右側溶解特效. 
* python script 可以透過 args 指定 width, width default value=20 (px).
* default scan current folder *.jpg
* able to assign --cwd to change currect workding dir</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">增加特效參數的 Python 腳本</h2>



<p class="wp-block-paragraph">這是一支擴充後的程式碼。我們新增了 <code>--effect</code> 參數，讓你可以自由切換不同的邊緣處理特效，包含預設的溶解，以及模糊與透明度漸層，達到讓圖片邊緣不那麼明顯的目的<sup></sup>。</p>



<h2 class="wp-block-heading">功能特色</h2>



<ul class="wp-block-list">
<li>支援三種邊緣特效：溶解、模糊、透明度漸層。</li>



<li>可透過參數自由切換特效類型與特效寬度。</li>



<li>自動偵測左右黑邊並套用特效。</li>
</ul>



<h2 class="wp-block-heading">程式碼</h2>



<p class="wp-block-paragraph">請將 dissolve.py 更新為以下內容：</p>



<p class="wp-block-paragraph">Python</p>



<pre class="wp-block-code"><code>import argparse
import glob
import os
import cv2
import numpy as np


def add_edge_effect(image_path, width, effect_type, ease=2.0):
  img = cv2.imread(image_path)
  if img is None:
    return

  h, w, c = img.shape
  gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

  col_max = np.max(gray, axis=0)
  black_threshold = 10
  # 額外往黑色區域內縮的 pixel 數，確保 fade 從純黑出發，
  # 避免 black_threshold 漏掉的暗色邊緣像素形成亮線。
  border_padding = 3

  left_border = 0
  for x in range(w // 2):
    if col_max&#91;x] > black_threshold:
      # 往左退 border_padding，但不超出純黑區域（col_max == 0 的最後一個位置）
      left_border = max(0, x - border_padding)
      break

  right_border = w - 1
  for x in range(w - 1, w // 2, -1):
    if col_max&#91;x] > black_threshold:
      right_border = x
      break

  right_border = min(w, right_border + 1 + border_padding)
  if right_border > w:
    right_border = w

  output = img.copy()

  if effect_type == 'dissolve':
    np.random.seed(42)
    if left_border > 0:
      start_x = left_border
      end_x = min(w, left_border + width)
      for y in range(h):
        for x in range(start_x, end_x):
          prob = (x - start_x) / width
          if np.random.rand() > prob:
            output&#91;y, x] = &#91;0, 0, 0]

    if right_border &lt; w:
      start_x = max(0, right_border - width)
      end_x = right_border
      for y in range(h):
        for x in range(start_x, end_x):
          prob = (end_x - x) / width
          if np.random.rand() > prob:
            output&#91;y, x] = &#91;0, 0, 0]

  elif effect_type == 'blur':
    # 對整張圖進行模糊，然後利用遮罩將邊緣與原圖融合
    blurred = cv2.GaussianBlur(img, (width * 2 + 1, width * 2 + 1), 0)
    if left_border > 0:
      start_x = left_border
      end_x = min(w, left_border + width)
      for x in range(start_x, end_x):
        alpha = (x - start_x) / width
        output&#91;:, x] = (
            img&#91;:, x] * alpha + blurred&#91;:, x] * (1 - alpha)
        ).astype(np.uint8)

    if right_border &lt; w:
      start_x = max(0, right_border - width)
      end_x = right_border
      for x in range(start_x, end_x):
        alpha = (end_x - x) / width
        output&#91;:, x] = (
            img&#91;:, x] * alpha + blurred&#91;:, x] * (1 - alpha)
        ).astype(np.uint8)

  elif effect_type == 'fade':
    # t 為正規化位置 (0.0 ~ 1.0)：
    #   左側：t=0 → 外側(黑色)，t=1 → 內側(內容)
    #   右側：t=0 → 內側(內容)，t=1 → 外側(黑色)
    #
    # alpha 曲線：alpha = 1 - t^ease
    #   ease=1 → 線性等差（舊行為）
    #   ease=2 → 二次方，靠近內側(t=0)斜率趨近 0，初期幾乎不變暗
    #   ease 越大 → 越晚才開始大幅變暗，更接近「突然消失」
    #
    # 右側示意（ease=2）：
    #   t:     0.0   0.3   0.5   0.7   1.0
    #   alpha: 1.00  0.91  0.75  0.51  0.00
    #   vs 線性: 1.00  0.70  0.50  0.30  0.00

    if left_border > 0:
      start_x = left_border
      end_x = min(w, left_border + width) + 1
      for x in range(start_x, end_x):
        t = (end_x - x) / width         # t=1 → 外側(黑)，t=0 → 內側
        alpha = 1.0 - t ** ease          # 內側(t=0)斜率=0，初期減少慢
        alpha = max(0.0, min(1.0, alpha))
        output&#91;:, x] = np.round(img&#91;:, x] * alpha).astype(np.uint8)

    if right_border &lt; w:
      start_x = max(0, right_border - width) - 1
      end_x = right_border
      for x in range(start_x, end_x):
        t = (x - start_x) / width       # t=0 → 內側(內容)，t=1 → 外側(黑)
        alpha = 1.0 - t ** ease          # 內側(t=0)斜率=0，初期減少慢
        alpha = max(0.0, min(1.0, alpha))
        output&#91;:, x] = np.round(img&#91;:, x] * alpha).astype(np.uint8)

  dir_name, file_name = os.path.split(image_path)
  output_dir = os.path.join(dir_name, 'output')
  os.makedirs(output_dir, exist_ok=True)
  output_path = os.path.join(output_dir, file_name)
  cv2.imwrite(output_path, output)
  print(f'已處理完成：{output_path}')


def main():
  parser = argparse.ArgumentParser(
      description='自動偵測左右黑邊並產生邊緣漸層特效。'
  )
  parser.add_argument(
      '--width', type=int, default=20, help='特效寬度，預設為 20 像素。'
  )
  parser.add_argument(
      '--cwd', type=str, default='.', help='工作目錄路徑，預設為目前資料夾。'
  )
  parser.add_argument(
      '--effect',
      type=str,
      default='dissolve',
      choices=&#91;'dissolve', 'blur', 'fade'],
      help='特效類型：dissolve 溶解、blur 模糊、fade 透明度漸層。',
  )
  parser.add_argument(
      '--ease',
      type=float,
      default=2.0,
      help='fade 曲線指數（僅 fade 有效）。1.0=線性等差，2.0=二次方（預設），值越大內側保持亮度越久。',
  )

  args = parser.parse_args()

  target_dir = args.cwd
  if not os.path.isdir(target_dir):
    print(f'錯誤：找不到目錄 {target_dir}')
    return

  # 同時支援 jpg 與 png，優先找 png（make_wallpaper.py 的無損暫存格式）
  files = glob.glob(os.path.join(target_dir, '*.png'))
  if not files:
    files = glob.glob(os.path.join(target_dir, '*.jpg'))

  if not files:
    print('在指定目錄中找不到任何 jpg 或 png 圖片。')
    return

  for file_path in sorted(files):
    add_edge_effect(file_path, args.width, args.effect, args.ease)


if __name__ == '__main__':
  main()</code></pre>



<h2 class="wp-block-heading">使用方式</h2>



<p class="wp-block-paragraph">你可以透過 <code>--effect</code> 參數指定想要的特效類型：</p>



<p class="wp-block-paragraph">預設溶解特效：</p>



<p class="wp-block-paragraph">Bash</p>



<pre class="wp-block-code"><code>python dissolve.py
</code></pre>



<p class="wp-block-paragraph">使用模糊特效：</p>



<p class="wp-block-paragraph">Bash</p>



<pre class="wp-block-code"><code>python dissolve.py --effect blur --width 15
</code></pre>



<p class="wp-block-paragraph">使用漸層淡出特效：</p>



<p class="wp-block-paragraph">Bash</p>



<pre class="wp-block-code"><code>python dissolve.py --effect fade --width 20
</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p class="wp-block-paragraph">錯誤處理</p>



<p class="wp-block-paragraph">Error message:</p>



<pre class="wp-block-code"><code>File c:\cleanyourtv\themes\leejueun\wallpapers\dissolve.py", line 3, in &lt;module&gt;
&nbsp; &nbsp; import cv2
ModuleNotFoundError: No module named 'cv2'
</code></pre>



<p class="wp-block-paragraph">處理模組錯誤</p>



<p class="wp-block-paragraph">這代表你的電腦沒有安裝 OpenCV 套件。請注意，在 pip 裡面安裝 OpenCV 的套件名稱不是 cv2，而是 opencv-python。</p>



<h2 class="wp-block-heading">解決步驟</h2>



<p class="wp-block-paragraph">請在終端機中輸入以下指令來安裝正確的套件：</p>



<p class="wp-block-paragraph">Bash</p>



<pre class="wp-block-code"><code>python -m pip install opencv-python
</code></pre>



<p class="wp-block-paragraph">安裝完成之後，再次執行你的 Python 腳本即可正常運作。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p class="wp-block-paragraph">實際在電視上的執行結果</p>



<figure class="wp-block-image is-resized"><img decoding="async" src="https://max-everyday.com/wp-content/uploads/2026/10/15552-1024x768.jpg" alt="" style="aspect-ratio:1.3333333333333333;width:1024px;height:auto"/></figure>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p class="wp-block-paragraph">解釋 fade 原理與實際效果, 並說明像素內容的變化:</p>



<pre class="wp-block-code"><code>    # t 為正規化位置 (0.0 ~ 1.0)：
    #   左側：t=0 → 外側(黑色)，t=1 → 內側(內容)
    #   右側：t=0 → 內側(內容)，t=1 → 外側(黑色)
    #
    # alpha 曲線：alpha = 1 - t^ease
    #   ease=1 → 線性等差（舊行為）
    #   ease=2 → 二次方，靠近內側(t=0)斜率趨近 0，初期幾乎不變暗
    #   ease 越大 → 越晚才開始大幅變暗，更接近「突然消失」
    #
    # 右側示意（ease=2）：
    #   t:     0.0   0.3   0.5   0.7   1.0
    #   alpha: 1.00  0.91  0.75  0.51  0.00
    #   vs 線性: 1.00  0.70  0.50  0.30  0.00

for x in range(start_x, end_x):
  t = (x - start_x) / width       # t=0 → 內側(內容)，t=1 → 外側(黑)
  alpha = 1.0 - t ** ease          # 內側(t=0)斜率=0，初期減少慢
  alpha = max(0.0, min(1.0, alpha))
  output&#91;:, x] = np.round(img&#91;:, x] * alpha).astype(np.uint8)</code></pre>



<p class="wp-block-paragraph">這是右側 fade的程式碼。逐行拆解：</p>



<h2 class="wp-block-heading">變數意義</h2>



<p class="wp-block-paragraph">假設 width = 60，右側內容邊界 right_border = 1365：</p>



<pre class="wp-block-code"><code>start_x = max(0, right_border - width) = 1305
end_x = right_border = 1365</code></pre>



<p class="wp-block-paragraph"></p>



<h2 class="wp-block-heading">alpha 的變化</h2>



<pre class="wp-block-code"><code>alpha = (end_x - x) / width</code></pre>



<pre class="wp-block-preformatted">x │ end_x - x │ alpha │ 意義<br>---<br>1305（start_x） │ 1365 - 1305 = 60 │ 60/60 = 1.0 │ 原始亮度<br>1325（中間） │ 1365 - 1325 = 40 │ 40/60 ≈ 0.67 │ 67% 亮度<br>1344 │ 1365 - 1344 = 21 │ 21/60 = 0.35 │ 35% 亮度<br>1364（end_x-1） │ 1365 - 1364 = 1 │ 1/60 ≈ 0.017 │ 幾乎全黑<br><br><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f4cc.png" alt="📌" class="wp-smiley" style="height: 1em; max-height: 1em;" /> alpha 從 1 → 0，越往右越暗。</pre>



<h2 class="wp-block-heading">像素乘法</h2>



<pre class="wp-block-code"><code>output&#91;:, x] = (img&#91;:, x] * alpha).astype(np.uint8)</code></pre>



<p class="wp-block-paragraph">以某個像素 img[y, x] = [180, 120, 90]（B, G, R）為例：</p>



<pre class="wp-block-preformatted">x │ alpha │ B │ G │ R<br>---<br>1305 │ 1.00 │ 180×1.00 = 180 │ 120×1.00 = 120 │ 90×1.00 = 90<br>1325 │ 0.67 │ 180×0.67 = 120 │ 120×0.67 = 80 │ 90×0.67 = 60<br>1344 │ 0.35 │ 180×0.35 = 63 │ 120×0.35 = 42 │ 90×0.35 = 31<br>1364 │ 0.017 │ 180×0.017 = 3 │ 120×0.017 = 2 │ 90×0.017 = 1</pre>



<pre class="wp-block-preformatted">│ x ≥ 1365（end_x 以外）：完全不觸碰，保持原值</pre>



<h2 class="wp-block-heading">整體視覺效果示意</h2>



<pre class="wp-block-code"><code>x:    1200   1305          1365   1500
      ──────┬─────────────────┬──────
content     │  fade zone      │ black
      ──────┴─────────────────┴──────
alpha:      1.0 ──────────► 0.0
brightness: ████▓▓▓▓▒▒▒▒░░░░▒▒░░░░
                              ↑
                        幾乎純黑，接黑色背景
</code></pre>



<h2 class="wp-block-heading">注意事項</h2>



<p class="wp-block-paragraph">astype(np.uint8) 會截斷小數，不是四捨五入：</p>



<pre class="wp-block-code"><code>180 * (1/60) = 3.0   → 3   &#x2705;
120 * (1/60) = 2.0   → 2   &#x2705;
 90 * (21/60) = 31.5 → 31  &#x26a0; 損失 0.5，累積後在低亮度區可能產生色差</code></pre>



<p class="wp-block-paragraph">直接使用等差, 會造成視覺上產生出一條較亮的白線</p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="797" src="https://stackoverflow.max-everyday.com/wp-content/uploads/2026/10/krita_2026-11-04-11-43-2w-1024x797.png?v=1793763879" alt="" class="wp-image-8845" srcset="https://stackoverflow.max-everyday.com/wp-content/uploads/2026/10/krita_2026-11-04-11-43-2w-1024x797.png?v=1793763879 1024w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/10/krita_2026-11-04-11-43-2w-767x597.png?v=1793763879 767w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/10/krita_2026-11-04-11-43-2w-600x467.png?v=1793763879 600w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/10/krita_2026-11-04-11-43-2w.png?v=1793763879 1182w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<p class="wp-block-paragraph">改用 曲線：alpha = 1 &#8211; t^ease , 效果就好很多.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p class="wp-block-paragraph">make_wallpaper.py 參考:<br><a href="https://stackoverflow.max-everyday.com/2026/11/make_wallpaper/">https://stackoverflow.max-everyday.com/2026/11/make_wallpaper/</a></p>



<p class="wp-block-paragraph"></p>
]]></content:encoded>
					
					<wfw:commentRss>https://stackoverflow.max-everyday.com/2026/10/python-cv-effect-fade/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>如果 AI 生成的 Flex ：如何用 CSS Grid 打造完美的左右雙欄卡片</title>
		<link>https://stackoverflow.max-everyday.com/2026/10/css-from-flex-to-grid/</link>
					<comments>https://stackoverflow.max-everyday.com/2026/10/css-from-flex-to-grid/#respond</comments>
		
		<dc:creator><![CDATA[max-stackoverflow]]></dc:creator>
		<pubDate>Tue, 20 Oct 2026 16:04:15 +0000</pubDate>
				<category><![CDATA[css筆記]]></category>
		<category><![CDATA[css]]></category>
		<guid isPermaLink="false">https://stackoverflow.max-everyday.com/?p=8815</guid>

					<description><![CDATA[在網頁前端開發中，我們經常需要將區塊拆分成左右兩...]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">在網頁前端開發中，我們經常需要將區塊拆分成左右兩欄，例如左邊放標籤或圖示、右邊放標題與內文。當我們把任務交給 AI 生成時，最常見的反應就是直接使用 Flexbox 排版。</p>



<p class="wp-block-paragraph">雖然 Flexbox 非常好用，但在處理多行、不規則高度或是希望呈現傳統表格對齊（例如類似 HTML 表格中的 tr 包含兩個 td ）的結構時，以下示範如何把第4列搬到第2例右側。</p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="609" src="https://stackoverflow.max-everyday.com/wp-content/uploads/2026/10/chrome_2026-10-20-23-49-28-1024x609.png?v=1792512039" alt="" class="wp-image-8816" srcset="https://stackoverflow.max-everyday.com/wp-content/uploads/2026/10/chrome_2026-10-20-23-49-28-1024x609.png?v=1792512039 1024w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/10/chrome_2026-10-20-23-49-28-600x357.png?v=1792512039 600w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/10/chrome_2026-10-20-23-49-28-767x456.png?v=1792512039 767w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/10/chrome_2026-10-20-23-49-28.png?v=1792512039 1059w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<h2 class="wp-block-heading">AI 生成的盲點：為什麼 Flexbox 在這裡會翻車？</h2>



<p class="wp-block-paragraph">如果讓 AI 直接用 <code>display: flex</code> 來處理這種左側是標籤、右側是標題加內文的卡片，通常會遇到以下幾個痛點：</p>



<ol start="1" class="wp-block-list">
<li>高度與垂直對齊難以控制：當右側的文字因為換行變成兩行或三行時，左側的標籤或圖示往往會跟著亂跑，無法與右側內容保持一致的垂直置中或頂端對齊。</li>



<li>巢狀結構過於肥胖：為了模擬左右兩欄，AI 往往會生出好幾層 <code>div</code> 來互相巢狀包裹，導致 HTML 結構變得很冗長。</li>



<li>欄位寬度不穩定：如果左側的標籤文字長度不一致，會直接導致右側的文字區域跟著被擠壓變形。</li>
</ol>



<h2 class="wp-block-heading">核心原理：為什麼改用 CSS Grid？</h2>



<p class="wp-block-paragraph">為了達到類似傳統表格（ tr 搭配兩個 td ）的嚴謹對齊效果，最佳解法其實是 <strong>CSS Grid</strong>。</p>



<p class="wp-block-paragraph">我們只需要將卡片本身設為 <code>display: grid</code>，並透過 <code>grid-template-columns: 200px 1fr</code>：</p>



<ul class="wp-block-list">
<li>第一欄（左側）固定寬度或給予適當的空間放標籤與圖示。</li>



<li>第二欄（右側）設定為 <code>1fr</code>，自動佔滿剩餘的所有寬度。</li>
</ul>



<p class="wp-block-paragraph">這樣一來，不管右側的文字換幾行、內容有多長，左右兩欄的對齊關係依然穩固如山，完全不需要擔心版面變形。</p>



<p class="wp-block-paragraph"></p>



<p class="wp-block-paragraph"></p>



<p class="wp-block-paragraph">這是一篇針對此次 CSS 版面調整所撰寫的技術部落格文章，內容包含原理簡介、AI 生成常見陷阱、實際遇到的問題以及解法，並已將程式碼對照調整為清楚的 Before 與 After 區塊。</p>



<h2 class="wp-block-heading">從 AI 生成的 Flex 陷阱談起：如何用 CSS Grid 打造完美的左右雙欄卡片</h2>



<p class="wp-block-paragraph">在網頁前端開發中，我們經常需要將區塊拆分成左右兩欄，例如左邊放標籤或圖示、右邊放標題與內文。當我們把任務交給 AI 生成時，最常見的反應就是直接使用 Flexbox 排版。</p>



<p class="wp-block-paragraph">雖然 Flexbox 非常好用，但在處理多行、不規則高度或是希望呈現傳統表格對齊（例如類似 HTML 表格中的 tr 包含兩個 td ）的結構時，使用 Flexbox 往往會衍生出許多意想不到的排版災難。</p>



<h2 class="wp-block-heading">AI 生成的盲點：為什麼 Flexbox 在這裡會翻車？</h2>



<p class="wp-block-paragraph">如果讓 AI 直接用 <code>display: flex</code> 來處理這種左側是標籤、右側是標題加內文的卡片，通常會遇到以下幾個痛點：</p>



<ol start="1" class="wp-block-list">
<li>高度與垂直對齊難以控制：當右側的文字因為換行變成兩行或三行時，左側的標籤或圖示往往會跟著亂跑，無法與右側內容保持一致的垂直置中或頂端對齊。</li>



<li>巢狀結構過於肥胖：為了模擬左右兩欄，AI 往往會生出好幾層 <code>div</code> 來互相巢狀包裹，導致 HTML 結構變得很冗長。</li>



<li>欄位寬度不穩定：如果左側的標籤文字長度不一致，會直接導致右側的文字區域跟著被擠壓變形。</li>
</ol>



<h2 class="wp-block-heading">核心原理：為什麼改用 CSS Grid？</h2>



<p class="wp-block-paragraph">為了達到類似傳統表格（ tr 搭配兩個 td ）的嚴謹對齊效果，最佳解法其實是 <strong>CSS Grid</strong>。</p>



<p class="wp-block-paragraph">我們只需要將卡片本身設為 <code>display: grid</code>，並透過 <code>grid-template-columns: 200px 1fr</code>：</p>



<ul class="wp-block-list">
<li>第一欄（左側）固定寬度或給予適當的空間放標籤與圖示。</li>



<li>第二欄（右側）設定為 <code>1fr</code>，自動佔滿剩餘的所有寬度。</li>
</ul>



<p class="wp-block-paragraph">這樣一來，不管右側的文字換幾行、內容有多長，左右兩欄的對齊關係依然穩固如山，完全不需要擔心版面變形。</p>



<h2 class="wp-block-heading">實戰修改前後對照</h2>



<h3 class="wp-block-heading">CSS 修改前後對照</h3>



<p class="wp-block-paragraph">Before（修改前）：</p>



<p class="wp-block-paragraph">CSS</p>



<pre class="wp-block-code"><code>/* Right: 3 Alignment Pillars */
.alignment-pillars {
  position: absolute;
  top: 170px;
  left: 510px;
  display: flex;
  flex-direction: column;
  gap: 18px;
}

.align-card {
  padding: 18px 26px;
  opacity: 0;
}
</code></pre>



<p class="wp-block-paragraph">After（修改後）：</p>



<p class="wp-block-paragraph">CSS</p>



<pre class="wp-block-code"><code>/* Right: 3 Alignment Pillars */
.alignment-pillars {
  position: absolute;
  top: 170px;
  left: 510px;
  display: flex;
  flex-direction: column;
  gap: 18px;
  width: 600px;
}

.align-card {
  padding: 18px 26px;
  opacity: 0;
  display: grid;
  grid-template-columns: 200px 1fr;
  gap: 20px;
  align-items: center;
}

.align-card-left {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 8px;
}

.align-card-right {
  display: flex;
  flex-direction: column;
  gap: 4px;
}
</code></pre>



<h3 class="wp-block-heading">HTML 修改前後對照</h3>



<p class="wp-block-paragraph">Before（修改前）：</p>



<p class="wp-block-paragraph">HTML</p>



<pre class="wp-block-code"><code><code>&lt;!-- Pillar 1 -->
  &lt;div class="align-card align-1 card-base">
    &lt;div class="pillar-tag p1">心法 1：釐清真正的需求&lt;/div>
    &lt;div class="pillar-title">拿到任務先別急著打開檔案或叫 AI 產出&lt;/div>
    &lt;div class="pillar-desc">多問幾個為什麼，找出背後真正要解決的核心問題。先問清定價需求，一頁表格就能直擊核心。&lt;/div>
  &lt;/div>
</code></code></pre>



<p class="wp-block-paragraph">After（修改後）：</p>



<p class="wp-block-paragraph">HTML</p>



<pre class="wp-block-code"><code><code>&lt;!-- Pillar 1 -->
  &lt;div class="align-card align-1 card-base">
    &lt;div class="align-card-left">
      &lt;div class="pillar-tag p1">心法 1：釐清真正的需求&lt;/div>
    &lt;/div>
    &lt;div class="align-card-right">
      &lt;div class="pillar-title">拿到任務先別急著打開檔案或叫 AI 產出&lt;/div>
      &lt;div class="pillar-desc">多問幾個為什麼，找出背後真正要解決的核心問題。先問清定價需求，一頁表格就能直擊核心。&lt;/div>
    &lt;/div>
  &lt;/div>
</code></code></pre>



<h2 class="wp-block-heading">總結</h2>



<p class="wp-block-paragraph">下次當你需要處理類似表格列、左右對齊的區塊時，如果 AI 自動幫你寫了滿滿的 Flexbox 巢狀結構，不妨主動要求它改用 <strong>CSS Grid</strong>。只要定義好欄位寬度比例，就能用最乾淨的程式碼，換取最穩定、最不容易跑版的畫面表現。</p>



<p class="wp-block-paragraph"></p>
]]></content:encoded>
					
					<wfw:commentRss>https://stackoverflow.max-everyday.com/2026/10/css-from-flex-to-grid/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>解決 WordPress 深色模式切換時超連結「白轉黑 0.5 秒」殘影閃爍問題</title>
		<link>https://stackoverflow.max-everyday.com/2026/10/wordpress-bgcolor-from-white-to-black-blink/</link>
					<comments>https://stackoverflow.max-everyday.com/2026/10/wordpress-bgcolor-from-white-to-black-blink/#respond</comments>
		
		<dc:creator><![CDATA[max-stackoverflow]]></dc:creator>
		<pubDate>Thu, 15 Oct 2026 19:58:10 +0000</pubDate>
				<category><![CDATA[css筆記]]></category>
		<category><![CDATA[WordPress筆記]]></category>
		<guid isPermaLink="false">https://stackoverflow.max-everyday.com/?p=8800</guid>

					<description><![CDATA[在為 WordPress 主題自訂深色模式（Da...]]></description>
										<content:encoded><![CDATA[
<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="572" src="https://stackoverflow.max-everyday.com/wp-content/uploads/2026/10/watermarked_img_14618693422224554944_clean-1024x572.jpg?v=1792154892" alt="" class="wp-image-8804" srcset="https://stackoverflow.max-everyday.com/wp-content/uploads/2026/10/watermarked_img_14618693422224554944_clean-1024x572.jpg?v=1792154892 1024w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/10/watermarked_img_14618693422224554944_clean-600x335.jpg?v=1792154892 600w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/10/watermarked_img_14618693422224554944_clean-767x428.jpg?v=1792154892 767w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/10/watermarked_img_14618693422224554944_clean.jpg?v=1792154892 1376w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<p class="wp-block-paragraph">在為 WordPress 主題自訂深色模式（Dark Mode / Dark Theme）切換功能時，許多開發者常會遇到一個相當破壞使用體驗的視覺 Bug：</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph"><strong>現象描述：</strong><br>點擊切換為深色模式（或由深色轉淺色）的瞬間，頁面上的<strong>超連結（<code>&lt;a&gt;</code>）或文字區塊背景</strong>會先短暫閃出一塊白色（<code>#ffffff</code>），接著在約 <strong>0.5 秒</strong>的時間內才緩慢漸變過渡為黑色（<code>#000000</code> / <code>#16181d</code>）。</p>
</blockquote>



<p class="wp-block-paragraph">這並非 JavaScript 執行延遲，而是 CSS 過渡動畫（Transitions）與瀏覽器補間運算相互作用產生的典型副作用。本文將完整記錄該問題的<strong>根本成因</strong>與<strong>最佳實踐解法</strong>。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">一、問題根本原因分析</h2>



<p class="wp-block-paragraph">這個問題主要由兩個 CSS 特性疊加造成：</p>



<h3 class="wp-block-heading">1. 原生主題的 <code>transition: all 0.5s ease;</code> 廣泛繼承</h3>



<p class="wp-block-paragraph">在許多 WordPress 主題（包括 Gutenshop）的樣式表（<code>style.css</code>）中，為了讓按鈕與部分互動元素具備平滑動畫，常常撰寫了如下規則：</p>



<pre class="wp-block-code"><code>transition: all 0.5s ease;</code></pre>



<p class="wp-block-paragraph">當使用 JavaScript 動態在 <code>&lt;body&gt;</code> 或 <code>&lt;html&gt;</code> 上新增/切換 <code>.theme-dark</code> 類別時，瀏覽器會監測到樣式變更。由於設定了 <code>transition: all</code>，<strong>頁面上所有繼承或套用了該規則的屬性（包含 <code>background-color</code>、<code>color</code>、<code>border-color</code>）都會進入 0.5 秒的漸變過渡狀態</strong>。</p>



<h3 class="wp-block-heading">2. 顏色插值運算（Color Interpolation）與透明背景缺失</h3>



<ul class="wp-block-list">
<li>預設情況下，常規文字超連結 <code>&lt;a&gt;</code> 的 <code>background-color</code> 通常是 <code>transparent</code>（透明）或繼承父容器。</li>



<li>當深淺模式切換時，如果某些元件或祖先元素設定了背景色變換，瀏覽器在計算 <code>transparent</code>（實質為 <code>rgba(0,0,0,0)</code> 或 <code>rgba(255,255,255,0)</code>）至目標色（如 <code>#16181d</code>）的中間過渡幀時，容易將未明定背景的元素以白色底（<code>#fff</code>）作為基底進行過渡插值。</li>



<li>結果就形成了：<strong>超連結背景突然變白，並耗時 0.5 秒慢慢過渡到黑色</strong>的突兀閃爍。</li>
</ul>



<h3 class="wp-block-heading">3. 首次造訪無 Cookie 時，未即時寫入使用者系統偏好</h3>



<ul class="wp-block-list">
<li>若使用者尚未手動點擊切換過，瀏覽器中就沒有 <code>gutenshop_theme</code> 的 Cookie。</li>



<li>每次重新整理或跳轉頁面時，若僅依賴延遲載入的腳本偵測 <code>prefers-color-scheme: dark</code>，頁面在 HTML 解析初期無法從 Cookie 取得狀態，導致從預設的淺色（白）重新被腳本動態判定改為深色（黑），觸發二次切換閃爍。</li>



<li><strong>最佳解法：</strong> 在初次載入（Page Load）偵測到無 Cookie 時，<strong>立即將使用者的 System Theme 寫入 Cookie</strong>，並在 <code>&lt;head&gt;</code> 的 Anti-flicker 行內腳本同步寫入，確保下一個畫面與未來所有子頁面在 DOM 渲染前就能以 Cookie 命中深色模式，徹底杜絕閃爍。</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">二、解決方案</h2>



<p class="wp-block-paragraph">要徹底杜絕這個現象，最佳做法是「<strong>雙管齊下</strong>」：</p>



<ol class="wp-block-list">
<li><strong>切換當下全域靜音過渡（Disable Transitions during Toggle）</strong>：切換主題時暫時鎖死所有 transition，讓深淺色瞬間套用，不觸發任何補間動畫。</li>



<li><strong>約束超連結的 transition 範圍</strong>：明確鎖定 <code>&lt;a&gt;</code> 的背景為透明，並限制僅有 <code>color</code>（文字顏色）允許 hover 漸變，禁止背景參與 transition。</li>
</ol>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">步驟 1：在 CSS 中加入停用過渡與連結約束規則</h3>



<p class="wp-block-paragraph">在你的獨立深色模式樣式檔（如 <code>css/dark-theme.css</code>）加入以下規則：</p>



<pre class="wp-block-code"><code>/* ==========================================================================
   1. 暫停過渡工具類：切換主題瞬間徹底消除 0.5 秒的顏色補間閃爍
   ========================================================================== */
.disable-transitions,
.disable-transitions *,
.disable-transitions *::before,
.disable-transitions *::after {
  -webkit-transition: none !important;
  -moz-transition: none !important;
  -o-transition: none !important;
  -ms-transition: none !important;
  transition: none !important;
}

/* ==========================================================================
   2. 超連結背景與過渡約束：
      明確限制常規連結背景為透明，且過渡僅限文字顏色 (color)，防止背景補間
   ========================================================================== */
a:not(.button):not(.added_to_cart):not(.theme-switch-link) {
  background-color: transparent;
  transition-property: color;
  transition-duration: 0.2s;
}

/* 深色模式下連結文字配色 */
body.theme-dark a {
  color: #60a5fa;
}

body.theme-dark a:hover,
body.theme-dark a:focus {
  color: #93c5fd;
}</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">步驟 2：在切換 JavaScript 中包裝過渡阻斷邏輯</h3>



<p class="wp-block-paragraph">在處理主題切換的 JavaScript（如 <code>js/theme-switch.js</code>）的 <code>applyTheme</code> 函數中：</p>



<ul class="wp-block-list">
<li>切換前先在根節點 <code>&lt;html&gt;</code> 加上 <code>.disable-transitions</code>。</li>



<li>透過存取 <code>window.getComputedStyle().opacity</code> 強制觸發瀏覽器重繪（Force Reflow），保證類別立即生效且不會被瀏覽器批次合併渲染。</li>



<li>完成類別切換後，使用 <code>requestAnimationFrame</code> 在下一個畫面幀將 <code>.disable-transitions</code> 移除，確保按鈕後續正常的滑鼠 hover 動畫不受影響。</li>
</ul>



<pre class="wp-block-code"><code>/**
 * 套用深淺主題類別
 * @param {string} theme 'dark' 或 'light'
 */
function applyTheme(theme) {
  var isDark = (theme === 'dark');

  // 1. 立即停止當前所有 CSS 過渡動畫，防止 0.5s 白黑殘影閃爍
  document.documentElement.classList.add('disable-transitions');

  // 2. 切換深淺模式類別
  document.body.classList.toggle('theme-dark', isDark);
  document.body.classList.toggle('theme-light', !isDark);
  document.documentElement.classList.toggle('theme-dark', isDark);
  document.documentElement.classList.toggle('theme-light', !isDark);

  // 3. 強制觸發重繪 (Reflow)，確保樣式變更在 transition 被關閉的狀態下生效
  window.getComputedStyle(document.documentElement).opacity;

  // 4. 下一個渲染週期恢復原本的 CSS 過渡效果（按鈕 hover 等仍維持流暢動畫）
  requestAnimationFrame(function () {
    document.documentElement.classList.remove('disable-transitions');
  });

  // 5. 同步切換按鈕的 aria 屬性與提示文字
  var links = document.querySelectorAll('.theme-switch-link');
  links.forEach(function (el) {
    el.title = isDark ? '切換至淺色模式' : '切換至深色模式';
    el.setAttribute('aria-pressed', isDark ? 'true' : 'false');
  });
}

/**
 * 頁面初始化：當無 Cookie 時，立即將使用者的系統主題 (OS Theme) 寫入 Cookie
 */
function init() {
  var saved = getCookie(COOKIE_NAME);
  var currentTheme;

  if (saved === 'dark' || saved === 'light') {
    currentTheme = saved;
  } else {
    // 首次造訪無 cookie：抓取系統主題並立即寫入 cookie，避免二次載入或跳轉時重複判斷而閃爍
    currentTheme = getSystemTheme();
    setCookie(COOKIE_NAME, currentTheme, COOKIE_DAYS);
  }

  var isDark = (currentTheme === 'dark');
  applyTheme(currentTheme);
  injectDesktopToggle(isDark);
  injectMobileToggle(isDark);
  document.addEventListener('click', handleClick);
  watchOSTheme();
}</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">步驟 3：在 Head 注入極速防閃爍腳本並更新版本號</h3>



<p class="wp-block-paragraph">在 <code>functions.php</code> 的 <code>wp_enqueue_scripts</code> 中：</p>



<ol class="wp-block-list">
<li>注入極速 Inline 腳本：若初次無 Cookie，亦立即將系統主題寫入 Cookie 並先加上 <code>theme-dark</code> 類別，在 HTML 剛載入時就完成判定。</li>



<li>推進樣式表與腳本版本號（例如 <code>1.0.1</code> / <code>1.0.3</code>），避免瀏覽器快取舊檔案：</li>
</ol>



<pre class="wp-block-code"><code>// functions.php
wp_register_style( 
    'gutenshop-dark-theme', 
    get_template_directory_uri() . '/css/dark-theme.css', 
    array( 'gutenshop-style' ), 
    '1.0.1' 
);
wp_enqueue_style( 'gutenshop-dark-theme' );

// 防閃爍腳本：搶先注入 &lt;html&gt;，若無 cookie 則同步寫入系統主題 cookie
$anti_flicker_js = "(function(){try{var m=document.cookie.match(/(^|;\\s*)gutenshop_theme=(&#91;^;]+)/);var t=m?decodeURIComponent(m&#91;2]):null;var d=(window.matchMedia&amp;&amp;window.matchMedia('(prefers-color-scheme: dark)').matches)?'dark':'light';var theme=t||d;if(!t){var exp=new Date();exp.setTime(exp.getTime()+(365*24*60*60*1000));document.cookie='gutenshop_theme='+encodeURIComponent(d)+'; expires='+exp.toUTCString()+'; path=/; SameSite=Lax';}if(theme==='dark'){document.documentElement.classList.add('theme-dark');}}catch(e){}})();";
wp_add_inline_script( 'gutenshop-navigation', $anti_flicker_js, 'before' );

wp_register_script( 
    'gutenshop-theme-switch', 
    get_template_directory_uri() . '/js/theme-switch.js', 
    array( 'gutenshop-custom-js-jquery' ), 
    '1.0.3', 
    true 
);
wp_enqueue_script( 'gutenshop-theme-switch' );</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">三、成果驗證</h2>



<p class="wp-block-paragraph">套用此修改後：</p>



<ol class="wp-block-list">
<li><strong>點擊深淺模式切換按鈕</strong>：背景與所有文字立刻無縫切換，<strong>完全不再出現 0.5 秒的白色閃爍與變色殘影</strong>。</li>



<li><strong>一般互動不受影響</strong>：一般的按鈕 hover、下拉選單滑動等原有動畫依然正常流暢運作。</li>



<li><strong>效能優化</strong>：利用強制 Reflow 配合 <code>requestAnimationFrame</code>，不會造成頁面重載或多餘運算開銷。</li>
</ol>
]]></content:encoded>
					
					<wfw:commentRss>https://stackoverflow.max-everyday.com/2026/10/wordpress-bgcolor-from-white-to-black-blink/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>如何為 WordPress 主題無痛加入深色模式？Git Patch 與高效率 Prompt 實戰</title>
		<link>https://stackoverflow.max-everyday.com/2026/10/wordpress-dark-mode-git-patch-prompt/</link>
					<comments>https://stackoverflow.max-everyday.com/2026/10/wordpress-dark-mode-git-patch-prompt/#respond</comments>
		
		<dc:creator><![CDATA[max-stackoverflow]]></dc:creator>
		<pubDate>Thu, 15 Oct 2026 16:19:35 +0000</pubDate>
				<category><![CDATA[WordPress筆記]]></category>
		<category><![CDATA[AI]]></category>
		<category><![CDATA[css]]></category>
		<category><![CDATA[wordpress]]></category>
		<guid isPermaLink="false">https://stackoverflow.max-everyday.com/?p=8796</guid>

					<description><![CDATA[在現代網頁設計中，深色模式（Dark Mode ...]]></description>
										<content:encoded><![CDATA[
<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="572" src="https://stackoverflow.max-everyday.com/wp-content/uploads/2026/10/watermarked_img_4204396195813658961_clean-1024x572.jpg?v=1792081142" alt="" class="wp-image-8798" srcset="https://stackoverflow.max-everyday.com/wp-content/uploads/2026/10/watermarked_img_4204396195813658961_clean-1024x572.jpg?v=1792081142 1024w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/10/watermarked_img_4204396195813658961_clean-767x428.jpg?v=1792081142 767w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/10/watermarked_img_4204396195813658961_clean-600x335.jpg?v=1792081142 600w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/10/watermarked_img_4204396195813658961_clean.jpg?v=1792081142 1376w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<p class="wp-block-paragraph">在現代網頁設計中，深色模式（Dark Mode / Dark Theme）已經是用戶體驗（UX）的基本標配。除了降低在暗處閱讀時的眼部疲勞，也能延長 OLED 螢幕的電池續航力。</p>



<p class="wp-block-paragraph">本篇技術筆記以開源主題 <strong>Guntenbee</strong> 為例，完整記錄如何架構一個具備「<strong>系統主題偏好自動偵測</strong> + <strong>Cookie 記憶</strong> + <strong>切換按鈕</strong> + <strong>防閃爍 (Anti-flicker)</strong>」的深色模式方案。同時針對日後「<strong>原作者主題升級</strong>」時如何以零成本（Git Patch / Child Theme）或極低 Token 的 AI 提示詞無痛重新套用進行深度解析。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">一、核心需求與架構設計</h2>



<p class="wp-block-paragraph">一個健全的深色模式切換機制需要具備以下要點：</p>



<ol class="wp-block-list">
<li><strong>使用者優先權階層</strong>：
<ul class="wp-block-list">
<li>若使用者曾在站上點擊切換過，以 <strong>Cookie 儲存的偏好</strong> 為最優先。</li>



<li>若為首次造訪或未設定 Cookie，自動偵測使用者的<strong>系統外觀模式</strong>（<code>prefers-color-scheme: dark</code>）。</li>
</ul>
</li>



<li><strong>避免白光閃爍（Anti-flicker / FOUC）</strong>：
<ul class="wp-block-list">
<li>瀏覽器解析 HTML 時，如果等到完整腳本下載並在 DOMContentLoaded 才執行加 class，畫面上會出現短暫的白光閃爍。因此需在 <code>&lt;head></code> 注入極輕量的行內（Inline）腳本提早設定 class。</li>
</ul>
</li>



<li><strong>直覺易用的開關位置</strong>：
<ul class="wp-block-list">
<li>將切換開關置入 Header 頂部導覽列（<code>&lt;div class="head-mast-inner"></code>），確保各裝置與版型都能一致顯示。</li>
</ul>
</li>
</ol>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">二、功能實作步驟</h2>



<h3 class="wp-block-heading">1. 樣式實作：獨立樣式表 (<code>css/dark-theme.css</code>)</h3>



<p class="wp-block-paragraph">將所有深色模式樣式統一抽離至獨立檔案，避免直接污染原作者的 <code>style.css</code>，大幅降低後續維護與合併時的衝突風險。</p>



<pre class="wp-block-code"><code>/* ==========================================================================
   Dark Theme Styles
   ========================================================================== */

/* 全域背景與文字 */
body.theme-dark {
  background-color: #121417;
  color: #c9d1d9;
}

body.theme-dark h1,
body.theme-dark h2,
body.theme-dark h3,
body.theme-dark h4,
body.theme-dark h5,
body.theme-dark h6 {
  color: #f0f6fc;
}

/* 頂部導覽、底欄與行動端選單 */
body.theme-dark .head-mast,
body.theme-dark .head-outro,
body.theme-dark .navigation-mobile-wrap {
  background-color: #181b20;
  color: #c9d1d9;
}

/* 下拉選單 */
body.theme-dark .navigation-main ul {
  background-color: #1a1e24;
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.45);
  border: 1px solid #2b3036;
}

/* 輸入框與表單元件 */
body.theme-dark input&#91;type="text"],
body.theme-dark input&#91;type="search"],
body.theme-dark select,
body.theme-dark textarea {
  background-color: #1a1e24;
  color: #e6edf3;
  border-color: #343a42;
}

/* 主題切換按鈕與 SVG 圖示切換 */
.theme-switch-toggle {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  border-radius: 50%;
  background: transparent;
  border: 0;
  cursor: pointer;
  color: inherit;
  transition: color 0.18s ease, background-color 0.18s ease;
}

.theme-switch-icon svg {
  width: 18px;
  height: 18px;
  fill: currentColor;
}

/* 淺色模式顯示月亮，深色模式顯示太陽 */
.theme-switch-icon .icon-sun { display: none; }
.theme-switch-icon .icon-moon { display: inline-block; }
body.theme-dark .theme-switch-icon .icon-sun { display: inline-block; }
body.theme-dark .theme-switch-icon .icon-moon { display: none; }</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">2. 行為邏輯：偵測與持久化 (<code>js/theme-switch.js</code>)</h3>



<p class="wp-block-paragraph">透過原生 JavaScript 實現無相依性（Vanilla JS）的偵測與 Cookie 儲存：</p>



<pre class="wp-block-code"><code>(function () {
  'use strict';

  var COOKIE_NAME = 'guntenbee_theme';
  var COOKIE_DAYS = 365;

  function getCookie(name) {
    var match = document.cookie.match(new RegExp('(^|;\\s*)' + name + '=(&#91;^;]+)'));
    return match ? decodeURIComponent(match&#91;2]) : null;
  }

  function setCookie(name, value, days) {
    var expires = '';
    if (days) {
      var date = new Date();
      date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
      expires = '; expires=' + date.toUTCString();
    }
    document.cookie = name + '=' + encodeURIComponent(value) + expires + '; path=/; SameSite=Lax';
  }

  function getSystemTheme() {
    return (window.matchMedia &amp;&amp; window.matchMedia('(prefers-color-scheme: dark)').matches)
      ? 'dark'
      : 'light';
  }

  function getPreferredTheme() {
    var savedTheme = getCookie(COOKIE_NAME);
    return (savedTheme === 'dark' || savedTheme === 'light') ? savedTheme : getSystemTheme();
  }

  function applyTheme(theme) {
    var isDark = (theme === 'dark');
    document.body.classList.toggle('theme-dark', isDark);
    document.body.classList.toggle('theme-light', !isDark);
    document.documentElement.classList.toggle('theme-dark', isDark);
    document.documentElement.classList.toggle('theme-light', !isDark);

    var toggles = document.querySelectorAll('.theme-switch-toggle');
    toggles.forEach(function (btn) {
      btn.setAttribute('aria-pressed', isDark ? 'true' : 'false');
      btn.setAttribute('title', isDark ? '切換至淺色模式' : '切換至深色模式');
    });
  }

  function init() {
    applyTheme(getPreferredTheme());

    document.addEventListener('click', function (e) {
      var toggle = e.target.closest('.theme-switch-toggle');
      if (toggle) {
        e.preventDefault();
        var currentTheme = document.body.classList.contains('theme-dark') ? 'dark' : 'light';
        var nextTheme = (currentTheme === 'dark') ? 'light' : 'dark';
        setCookie(COOKIE_NAME, nextTheme, COOKIE_DAYS);
        applyTheme(nextTheme);
      }
    });

    // 監聽作業系統外觀模式動態變化
    if (window.matchMedia) {
      window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function () {
        if (!getCookie(COOKIE_NAME)) {
          applyTheme(getSystemTheme());
        }
      });
    }
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init);
  } else {
    init();
  }
})();</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">3. WordPress 資源載入與防閃爍 (<code>inc/scripts-styles.php</code>)</h3>



<p class="wp-block-paragraph">在 <code>wp_enqueue_scripts</code> 鉤子中註冊資源，並加入極速 Inline 腳本：</p>



<pre class="wp-block-code"><code>// Enqueue 樣式與腳本
wp_register_style( 'gutenbee-dark-theme', get_theme_file_uri( '/css/dark-theme.css' ), array( 'gutenbee-style' ), gutenbee_asset_version() );
wp_enqueue_style( 'gutenbee-dark-theme' );

// 防閃爍腳本：在頁面剛開始解析 DOM 時搶先將 theme-dark 注入 &lt;html&gt;
$anti_flicker_js = "(function(){try{var m=document.cookie.match(/(^|;\\s*)guntenbee_theme=(&#91;^;]+)/);var t=m?decodeURIComponent(m&#91;2]):null;var d=(window.matchMedia&amp;&amp;window.matchMedia('(prefers-color-scheme: dark)').matches)?'dark':'light';if((t||d)==='dark'){document.documentElement.classList.add('theme-dark');}}catch(e){}})();";
wp_add_inline_script( 'gutenbee-dependencies', $anti_flicker_js, 'before' );

wp_register_script( 'gutenbee-theme-switch', get_theme_file_uri( '/js/theme-switch.js' ), array(), gutenbee_asset_version(), true );
wp_enqueue_script( 'gutenbee-theme-switch' );</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">4. 範本標籤與按鈕輸出 (<code>inc/template-tags.php</code> &amp; Header Templates)</h3>



<ol class="wp-block-list">
<li>定義輔助函數：<code>function gutenbee_the_theme_switch_button() { ?> &lt;button type="button" class="theme-switch-toggle" aria-label="切換深淺模式"> &lt;span class="theme-switch-icon" aria-hidden="true"> &lt;svg class="icon-moon" viewBox="0 0 24 24">&lt;path d="M12.3 2a10 10 0 0 0-.19 14 10 10 0 0 0 11.63 4A10 10 0 1 1 12.3 2z"/>&lt;/svg> &lt;svg class="icon-sun" viewBox="0 0 24 24">&lt;circle cx="12" cy="12" r="5"/>&lt;line x1="12" y1="1" x2="12" y2="3" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>&lt;line x1="12" y1="21" x2="12" y2="23" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>&lt;line x1="4.22" y1="4.22" x2="5.64" y2="5.64" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>&lt;line x1="18.36" y1="18.36" x2="19.78" y2="19.78" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>&lt;line x1="1" y1="12" x2="3" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>&lt;line x1="21" y1="12" x2="23" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>&lt;line x1="4.22" y1="19.78" x2="5.64" y2="18.36" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>&lt;line x1="18.36" y1="5.64" x2="19.78" y2="4.22" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>&lt;/svg> &lt;/span> &lt;/button> &lt;?php }</code></li>



<li>在 <code>template-parts/header/header-full.php</code>、<code>header-split.php</code>、<code>header-navbar.php</code> 的 <code>&lt;div class="head-mast-inner"></code> 內部直接呼叫 <code>gutenbee_the_theme_switch_button()</code> 即可完美呈現。</li>
</ol>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">三、未來原作者升級主題時的處理策略</h2>



<p class="wp-block-paragraph">當原主題作者發布新版本更新時，若直接覆蓋整個主題資料夾，客製化代碼將會遺失。以下提供三種不同維護情境的應對方式：</p>



<h3 class="wp-block-heading">策略 A：建立子主題（Child Theme）—— 永遠不被覆蓋（推薦最佳實踐）</h3>



<p class="wp-block-paragraph">WordPress 的標準升級防禦機制是子主題。只需將新寫好的 <code>dark-theme.css</code>、<code>theme-switch.js</code> 及對應 template parts 放進子主題資料夾，日後父主題無論升級幾次，客製化邏輯都不會被覆蓋。</p>



<h3 class="wp-block-heading">策略 B：利用 Git Patch 自動打補丁（零 Token 快速升級）</h3>



<p class="wp-block-paragraph">若習慣直接於主題目錄工作，建議在功能完成時立即產生 patch 檔案備份：</p>



<pre class="wp-block-code"><code># 產出補丁檔
git diff &gt; dark-theme.patch</code></pre>



<p class="wp-block-paragraph">當日後原作者有新版發布時：</p>



<pre class="wp-block-code"><code># 下載並解壓縮覆蓋原主題後，執行一鍵套用
git apply --reject --whitespace=fix dark-theme.patch</code></pre>



<p class="wp-block-paragraph"><code>git apply</code> 具備自動比對行號上下文與建立新檔案的能力，速度快且完全不花費任何 AI token。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">策略 C：低 Token 的精準 AI 提示詞（AI Prompt Template）</h3>



<p class="wp-block-paragraph">若在遠端或未配備 Git 的環境，需請 AI 助理重新在原作者新版主題植入 Dark Theme 功能時，<strong>千萬不要使用模糊指令</strong>（例如「<em>幫我在這個主題加深色模式</em>」，這會讓 AI 花費數萬 Token 去探索全文結構與測試選擇器）。</p>



<p class="wp-block-paragraph">直接複製使用以下經結構化優化的 <strong>精準 Prompt</strong>：</p>



<pre class="wp-block-code"><code>請為 @guntenbee 主題實作 Dark Theme 功能。請直接依照以下規格建立檔案與注入代碼，不需漫遊搜尋其他檔案：

1. 新增樣式與腳本：
   - 建立 `css/dark-theme.css`：定義 `body.theme-dark` 樣式（深色底色 #121417、文字 #c9d1d9、容器/選單 #181b20）與 `.theme-switch-toggle` 按鈕 SVG 切換動態。
   - 建立 `js/theme-switch.js`：讀取/寫入 Cookie `guntenbee_theme`（效期 365 天）；若無 Cookie 則以 `prefers-color-scheme: dark` 為準，並監聽 OS 主題變化與按鈕點擊事件。

2. 註冊與防閃爍（修改 `inc/scripts-styles.php`）：
   - 在 `gutenbee_enqueue_scripts` 中以 `wp_enqueue_style` 載入 `dark-theme.css`。
   - 注入 Inline Script 到 `&lt;head&gt;`：若 Cookie 或系統偏好為 dark，即時加上 `document.documentElement.classList.add('theme-dark')` 以防止白光閃爍。
   - 以 `wp_enqueue_script` 載入 `theme-switch.js`。

3. 按鈕與鉤子整合：
   - 在 `inc/template-tags.php` 新增 `gutenbee_the_theme_switch_button()` 輸出太陽/月亮按鈕 HTML。
   - 在 `template-parts/header/header-*.php` 各樣板的 `&lt;div class="head-mast-inner"&gt;` 的 `head-content-slot` 中呼叫該按鈕函式。
   - 在 `inc/default-hooks.php` 的 `gutenbee_body_class` 過濾器中檢查 `$_COOKIE&#91;'guntenbee_theme']`，有 dark 值即附加 `theme-dark` class。</code></pre>



<h4 class="wp-block-heading">為什麼這個 Prompt 具備高性價比（Low Token Usage）？</h4>



<ol class="wp-block-list">
<li><strong>消除探勘步驟</strong>：精確指出檔案名稱（如 <code>inc/scripts-styles.php</code>、<code>template-parts/header/header-*.php</code>），AI 不需調用目錄搜尋工具消耗大量 Context Window。</li>



<li><strong>精準函式命名</strong>：直接給予目標鉤子（<code>gutenbee_body_class</code>、<code>gutenbee_enqueue_scripts</code>），AI 思考路徑縮短，輸出 token 減少 70% 以上。</li>



<li><strong>成果可預測且易於維護</strong>：模組化規範使每一次生成的樣式結構一致，日後維護與問題排查極為單純。</li>
</ol>
]]></content:encoded>
					
					<wfw:commentRss>https://stackoverflow.max-everyday.com/2026/10/wordpress-dark-mode-git-patch-prompt/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Qwen-Image-2.1-Turbo 加速版的教學：文字生圖與局部修改</title>
		<link>https://stackoverflow.max-everyday.com/2026/10/qwen-image-2-1-turbo/</link>
					<comments>https://stackoverflow.max-everyday.com/2026/10/qwen-image-2-1-turbo/#respond</comments>
		
		<dc:creator><![CDATA[max-stackoverflow]]></dc:creator>
		<pubDate>Mon, 12 Oct 2026 09:23:32 +0000</pubDate>
				<category><![CDATA[電腦相關應用]]></category>
		<guid isPermaLink="false">https://stackoverflow.max-everyday.com/?p=8786</guid>

					<description><![CDATA[Qwen-Image-2.1-Turbo 釋出八...]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Qwen-Image-2.1-Turbo 釋出八步生成與圖片編輯權重。本文整理官方環境要求、預存取樣時程、商品圖提示詞與逐張驗收方法，並說明研究授權、CUDA 範例及商用前要確認的條件。</p>



<h3 class="wp-block-heading">核心架構與技術特點</h3>



<p class="wp-block-paragraph">這個模型基於七十億參數的視覺生成架構，採用三十二層的 Single-Stream DiT 設計。與需要二十五到四十步推理的標準基礎模型不同，Turbo 版本透過特殊的加速機制與預存取樣時程，將去噪步驟大幅縮短至八步。它的預設無分類器引導係數固定為一，省去了額外的前向計算過程，因此能以極快的速度完成推理。</p>



<h3 class="wp-block-heading">主要功能</h3>



<p class="wp-block-paragraph">模型支援文字生成圖片，能夠產出高達 2K 解析度的畫面。除了文生圖之外，它也具備強大的圖片編輯能力。使用者可以透過自然語言指令、參考圖、塗繪標註或蒙版，對既有圖像進行局部修改、更換背景、增加配件或調整場景，同時保留主體與商品的身份特徵。此外，它也支援原生透明度處理。</p>



<h3 class="wp-block-heading">應用場景</h3>



<p class="wp-block-paragraph">由於其八步快速出圖的特性，這個模型非常適合需要頻繁反覆修改、快速產出草稿或進行電商商品圖批次處理的工作流程。開發者通常會透過 Hugging Face 的 Diffusers 套件以及 ComfyUI 等介面來載入與部署這款模型。</p>



<h3 class="wp-block-heading">兩者的核心差異</h3>



<p class="wp-block-paragraph">Qwen/Qwen-Image-2.1-Turbo 是 Qwen/Qwen-Image-2.1 的蒸餾加速版本。兩者在底層使用了相同的七十億參數 Single-Stream DiT 視覺生成架構，但推理機制與速度上有顯著不同：<sup></sup><sup></sup><sup></sup><sup></sup></p>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><td><strong>比較項目</strong></td><td><strong>Qwen/Qwen-Image-2.1（標準原版）</strong></td><td><strong>Qwen/Qwen-Image-2.1-Turbo（加速版）</strong></td></tr></thead><tbody><tr><td>推理步數<sup></sup><sup></sup><sup></sup><sup></sup></td><td>需要 25 至 40 步<sup></sup><sup></sup><sup></sup><sup></sup></td><td>僅需 8 步（內建預設排程）<sup></sup><sup></sup><sup></sup><sup></sup></td></tr><tr><td>無分類器引導（CFG）<sup></sup><sup></sup><sup></sup><sup></sup></td><td>需要設定 CFG &gt; 1（需雙倍前向計算）<sup></sup><sup></sup><sup></sup><sup></sup></td><td>預設 CFG = 1（嵌入權重，無<sup></sup>額外計算）<sup></sup><sup></sup><sup></sup></td></tr><tr><td>生成速度<sup></sup><sup></sup><sup></sup></td><td>較慢，但控制精細度高<sup></sup><sup></sup><sup></sup></td><td>快約<sup></sup> 5 至 8 倍，非常適合快速預覽與大量生成<sup></sup><sup></sup></td></tr><tr><td>提示詞遵循能力<sup></sup><sup></sup></td><td>極佳，適合<sup></sup>長文字或複雜多指令提示<sup></sup></td><td>稍遜於原版，極複雜指令可能有些微<sup></sup>調偏差</td></tr></tbody></table></figure>



<h3 class="wp-block-heading">Turbo 可以完全覆蓋原版的所有功能嗎</h3>



<p class="wp-block-paragraph">答案是：在功能支援上可以完全覆蓋，但在極限品質與控制力上無法完全取代原版。</p>



<p class="wp-block-paragraph"><strong>功能範疇完全重疊：</strong></p>



<p class="wp-block-paragraph">Turbo 版本完全繼承了原版的所有核心功能，包括文字生成圖片、透明背景 RGBA 圖層生成、多圖參考編輯、塗繪與蒙版局部修改、以及人物與商品特徵保留。在 Diffusers 程式庫中，兩者也是使用同一個管道載入。</p>



<p class="wp-block-paragraph"><strong>無法完全取代原版的特定場景：</strong></p>



<ul class="wp-block-list">
<li>極複雜提示詞：Turbo 版本透過蒸餾技術將引導資訊固化在權重中。當提示詞包含非常長且繁複的多層次指令時，標準原版在 40 步與高 CFG 下的細節表現仍會比 Turbo 更精準。</li>



<li>自由調整參數：原版允許使用者自由調整係數與微調取樣步數，以在創造力與文字貼合度之間找到最佳平衡。Turbo 版本的排程與係數已被鎖定與最佳化，調參彈性較低。</li>
</ul>



<h3 class="wp-block-heading">使用建議</h3>



<ul class="wp-block-list">
<li>如果需要高效率生產、電商批次處理、即時互動生成或快速草稿設計，應優先選擇 Turbo。</li>



<li>如果需要處理非常精細的藝術設計、極度複雜的邏輯指令，或是追求極致細節的最終成品，建議保留原版。</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Q: 顯示卡 RTX 4050 Laptop 6GB VRAM 可以跑的動?</h2>



<p class="wp-block-paragraph">以預設的標準精度（BF16 或 FP16）來看，Qwen-Image-2.1 系列模型大約需要十六到二十九 GB 以上的顯示記憶體，因此 RTX 4050 Laptop 的六 GB 顯示記憶體無法直接流暢運行原生日誌與完整精度。</p>



<p class="wp-block-paragraph">不過，如果你透過以下幾種優化方式，這張顯示卡還是有機會可以嘗試本地端運行：</p>



<ul class="wp-block-list">
<li>降低精度與量化：透過 GGUF 量化版本或 INT4、INT8 壓縮權重，將模型體積縮小，讓記憶體需求降至六 GB 甚至更低，但這會犧牲部分生成畫質。</li>



<li>善用系統記憶體（RAM）：如果筆電的共用記憶體與主記憶體足夠大，部分運行框架支援將部分運算卸載到 CPU 或系統記憶體，只是生成速度會變得很慢。</li>



<li>雲端算力替代：如果本地運行經常遇到顯示記憶體不足（Out of Memory）的錯誤，建議改用 Google Colab 或雲端 GPU 服務來執行。</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Q: 如何找到降低精度與量化的版本?</h2>



<p class="wp-block-paragraph">在 Hugging Face 或開源社群尋找像 Qwen-Image-2.1 系列這種大型 DiT 模型的量化（Quantized）或降低精度版本時，主要有以下幾條途徑與搜尋方法：</p>



<h3 class="wp-block-heading">一、 直接在 Hugging Face 搜尋對應的量化庫</h3>



<p class="wp-block-paragraph">量化版本通常不會放在官方原版的 repo 中，而是由社群創作者（如 Unsloth、city96、leejet 等）轉換後發布。你可以直接在 Hugging Face 的搜尋列輸入以下關鍵字：</p>



<ul class="wp-block-list">
<li><strong>GGUF 版本（最適合 6GB VRAM 使用）：</strong>
<ul class="wp-block-list">
<li>搜尋：<code>Qwen-Image-2.1 GGUF</code> 或 <code>Qwen-Image-2.1-Turbo GGUF</code></li>



<li>核心量化層級選擇：
<ul class="wp-block-list">
<li><code>Q4_K_M</code>（約 4.2 GB）：最推薦 RTX 4050 6GB 使用，模型可以直接完全載入顯示記憶體。</li>



<li><code>Q5_0</code> / <code>Q5_K_M</code>（約 5.0 &#8211; 5.3 GB）：細節稍微提升，但可能會用到少許系統記憶體（RAM）做緩衝。</li>
</ul>
</li>
</ul>
</li>



<li><strong>INT8 / NF4 版本（Comfy-Org 官方或社群微調）：</strong>
<ul class="wp-block-list">
<li>搜尋：<code>Qwen-Image-2.1-Turbo-INT8</code> 或 <code>qwen_image_2.1_turbo_int8_convrot</code></li>



<li>這是直接將權重壓成 INT8 格式的 safetensors 檔案，適合傳統 ComfyUI 流程。</li>
</ul>
</li>
</ul>



<h3 class="wp-block-heading">二、 搭配 ComfyUI 運行 GGUF 量化版（最建議的做法）</h3>



<p class="wp-block-paragraph">若要在 RTX 4050 上順利運行 GGUF 模型，單靠預設的 ComfyUI 節點可能會遇到「不支援架構」的錯誤。推薦使用支援該模型架構的擴充元件與組件：</p>



<ol start="1" class="wp-block-list">
<li><strong>安裝自訂節點：</strong>使用 <code>leejet</code> 維護的 <code>ComfyUI-GGUF</code> 套件（比舊版 city96 能更完善支援 Qwen 2.1 架構）。</li>



<li><strong>下載配套檔案：</strong>
<ul class="wp-block-list">
<li><strong>UNet / Diffusion 模型：</strong> 放至 <code>ComfyUI/models/unet/</code>，選擇下載好的 <code>.gguf</code> 檔案（例如 <code>qwen-image-2.1-Q4_K_M.gguf</code>）。</li>



<li><strong>Text Encoder（文本編碼器）：</strong>Qwen-Image 2.1 搭配的是 8B 的文本編碼器。為了省記憶體，同樣建議尋找量化過的文字編碼器，如 <code>qwen3vl_8b_w4a8.safetensors</code> 或 <code>qwen3vl_8b_int8</code> 並放入 <code>models/text_encoders/</code>。</li>
</ul>
</li>



<li><strong>替換節點：</strong>在 ComfyUI 工作流中，將原本的 <code>Load Diffusion Model</code> 替換為 <code>Unet Loader (GGUF)</code> 即可載入。</li>
</ol>



<h3 class="wp-block-heading">三、 使用整合型工具（如 Ollama / LM Studio 等，若支援）</h3>



<p class="wp-block-paragraph">如果不想手動設定 ComfyUI 工作流，也可以關注像是 <strong>Draw Things</strong>（Mac/iOS 支援，部分 Windows 框架亦有移植）或 <strong>Civitai</strong> 網站上是否有創作者上傳整理好的單一打包模型，通常搜尋欄位輸入 <code>Qwen-Image-2.1 Turbo Quantized</code> 即可找到相關資源。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading"><a href="https://huggingface.co/unsloth/Qwen-Image-2.1-Turbo-GGUF">https://huggingface.co/unsloth/Qwen-Image-2.1-Turbo-GGUF</a></h2>



<p class="wp-block-paragraph">which version best for my RTX 4050 Laptop 6GB RAM?</p>



<p class="wp-block-paragraph">▋ 最推薦的版本：Q4_K_M (4.2 GB)<sup></sup></p>



<p class="wp-block-paragraph">對於 RTX 4050 Laptop 6GB 顯示記憶體來說，<strong>Q4_K_M 是兼顧品質與運行成功率的最佳首選</strong>。</p>



<p class="wp-block-paragraph">▋ 各版本在 6GB 顯示記憶體下的實測建議與分析</p>



<p class="wp-block-paragraph">在生成圖片時，顯示記憶體（VRAM）除了要存放主要的 DiT 模型權重之外，還必須留出空間給：</p>



<ol start="1" class="wp-block-list">
<li><strong>Text Encoder（文本編碼器，如 Qwen3-VL 8B）：</strong> 需額外佔用約 0.7 &#8211; 2 GB 顯示記憶體。</li>



<li><strong>VAE 解碼器與 CUDA 計算緩衝區（Compute Buffers）：</strong> 約需 1.0 &#8211; 1.5 GB。</li>
</ol>



<p class="wp-block-paragraph">因此，模型的檔案大小不能剛好等於 6 GB，必須留出至少 1.5 &#8211; 2 GB 的動態開銷空間。</p>



<ul class="wp-block-list">
<li><strong>Q4_K_M (4.2 GB) —— 最平衡首選</strong>
<ul class="wp-block-list">
<li><strong>分析：</strong>模型本身佔用約 4.2 GB。搭配 ComfyUI 的模型卸載（CPU Offloading）機制，可以在 6GB VRAM 內剛好塞下，避免發生 OOM（Out of Memory）記憶體溢位崩潰。</li>



<li><strong>畫質損耗：</strong> Unsloth 的 Dynamic 4-bit 量化對畫面細節與文字遵循能力保留得相當完整，幾乎感受不出明顯品質下降。</li>
</ul>
</li>



<li><strong>Q4_K_S (3.91 GB) —— 備用方案（記憶體不夠時使用）</strong>
<ul class="wp-block-list">
<li><strong>分析：</strong> 檔案更小，如果你的系統背景程式佔用了較多 VRAM（例如開著 Chrome 或多個螢幕），導致 Q4_K_M 報錯，請改用此版本。</li>
</ul>
</li>



<li><strong>Q5_K_S / Q5_K_M (4.5 GB &#8211; 5.39 GB) —— 邊緣臨界，易爆 VRAM</strong>
<ul class="wp-block-list">
<li><strong>分析：</strong>4.5 GB 以上的檔案加上 VAE 與編碼器開銷後，非常容易超過 6GB 限制。若要硬跑，系統會被迫將部分模型卸載到電腦的主記憶體（RAM），導致生成速度變得很慢。</li>
</ul>
</li>



<li><strong>Q6_K / Q6_K_XL (6.27 GB &#8211; 6.72 GB) —— 完全不建議</strong>
<ul class="wp-block-list">
<li><strong>分析：</strong>單模型檔案大小就已超過你的顯示記憶體上限。</li>
</ul>
</li>
</ul>



<p class="wp-block-paragraph">▋ 6GB VRAM 順暢運行的關鍵設定</p>



<ol start="1" class="wp-block-list">
<li><strong>圖形解析度：</strong>請先從 <strong>1024&#215;1024</strong> 開始生成。切勿一開始就設為 2048&#215;2048。</li>



<li><strong>Text Encoder (文本編碼器)：</strong>建議下載量化過的文字編碼器（如 <code>qwen3vl_8b_w4a8</code> 或 <code>int8</code>），或者在 ComfyUI 中開啟 <strong>CPU Text Encoder</strong> 模式，把文字處理丟給電腦主記憶體。</li>



<li><strong>VAE 設定：</strong>在 ComfyUI 的 VAE Loader 設定中開啟 <strong>Tiled VAE</strong>（區塊解碼），可以大幅節省生成最後階段的 VRAM 峰值用量。</li>
</ol>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Q: 如何串接 AI agent CLI 生圖</h2>



<p class="wp-block-paragraph">要把文字互動介面與本地端生圖模型串接起來，主要有兩個常見且可行的解法。</p>



<p class="wp-block-paragraph">第一種是透過 ComfyUI 內建的 API 伺服器來串接。ComfyUI 啟動後會在背景提供一套完整的網頁伺服器與 API 功能。你可以在終端機裡寫一個簡單的 Python 程式或是用 curl 指令，把提示詞打包成 JSON 格式發送到 ComfyUI 的連接埠，讓它在背景自動生成圖片並回傳檔案路徑。驗證這個步驟是否成功的方法是，打開瀏覽器確認網頁伺服器有正常回應。</p>



<p class="wp-block-paragraph">第二種是自己寫一個輕量化的轉接層。有些 agent cli 工具預設只能對接類似 OpenAI 的文字或圖片生成 API。你可以用 Python 的 FastAPI 寫一個簡單的伺服器，對外提供標準的 API 介面，當收到終端機傳來的生圖請求時，程式再自動去呼叫底層的生圖工具。驗證這個步驟是否成功的方法是，用終端機發送一筆測試請求，確認伺服器能夠正確接收並處理。</p>



<h2 class="wp-block-heading">give me solution for my RTX 4050 Laptop 6GB VRAM</h2>



<p class="wp-block-paragraph">你的顯示記憶體只有 6GB ，直接執行官方的原始程式碼一定會立刻跳出記憶體不足的錯誤。為了讓程式碼能在你的電腦上順利跑完，我們必須修改幾個關鍵設定，包含大幅降低圖片解析度以及開啟 CPU 記憶體卸載功能。</p>



<p class="wp-block-paragraph">這是一份修改過後的 Python 程式碼，可以讓你在 RTX 4050 上面嘗試執行：</p>



<p class="wp-block-paragraph">Python</p>



<pre class="wp-block-code"><code>import torch
from diffusers import QwenImage21Pipeline

pipe = QwenImage21Pipeline.from_pretrained(
    "Qwen/Qwen-Image-2.1-Turbo",
    torch_dtype=torch.float16,
)

pipe.enable_model_cpu_offload()

prompt = "A vertically oriented educational chemistry study poster fills the frame, designed like a hand-drawn classroom notes page with a clean white background, thick dark navy-blue outer border, and thin inner navy rectangular border lines. The overall aesthetic is colorful, high-contrast, and hand-lettered in a signature Topper style using marker and watercolor effects. At the top left, a small header in black handwriting reads \"Topper’s Notes\", accented by short decorative navy strokes radiating from it. Centered across the top is the main lesson heading in large stylized black-and-yellow bold lettering: \"Science – Chapter 1:\" followed below by the much larger title \"Chemical Reactions and Equations\". The title has energetic yellow highlighter strokes underneath and small sparkle doodles nearby. At the top right, inside a small outlined page-number box, the label reads \"Page X\". Below the header, a wide rounded rectangle framed in dark navy contains a pink brush-stroke highlight banner with the subheading \"IMPORTANT CHEMICAL EQUATION:\" in black. Inside the same equation panel, the reaction is written very large as \"Fe + CuSO₄ → FeSO₄ + Cu\". The symbol \"Fe\" is highlighted in bright orange with an underline, \"CuSO₄\" is vivid blue and underlined, \"FeSO₄\" is bright green and underlined, and \"Cu\" is metallic bronze and underlined. Beneath the left side of the equation is a lavender rounded label connected by a leader line to the iron component; it says \"Reactants\" and below it \"(before the reaction)\", with \"Reactants\" colored purple and underlined. Beneath the right side is a matching pale-green rounded label connected by a leader line to the products; it says \"Products\" and below it \"(after the reaction)\", with \"Products\" colored green and underlined. Along the lower part of this equation box are stylized ball-and-stick molecule models and atomic orbital drawings: a silver-gray sphere labeled \"Fe\" sits left of center, another cluster shows gray, red, and white spheres representing sulfate groups around blue and green atoms, and a copper-colored sphere appears near the right. Small atom-and-orbit icons sit along the bottom edge of the panel in blue, green, and brown outline styles. The middle portion is enclosed by a large dashed green rounded rectangle titled \"REACTIVITY CONCEPT\" in green uppercase letters with decorative rays. On the left side of this section, a stepped vertical reactivity-series ladder is drawn with alternating colored horizontal blocks. From top to bottom it lists \"K\", \"Na\", \"Ca\", \"Mg\", \"Al\", \"Zn\", \"Fe\" with a green oval callout reading \"(More reactive)\", then continues downward with \"Cu\", \"Ag\", \"Hg\", \"Pt\", \"Au\" with an orange oval callout reading \"(Less reactive)\". Each step is marked by a dark upward-pointing triangular arrow, showing decreasing reactivity downward. In the central-right area, full body text as follows: \"Iron (Fe) is higher in the reactivity series than Copper (Cu). This makes Iron more reactive.\" Below this definition is a visual arrow sequence of overlapping circles: orange \"Fe\" overlaps blue \"Cu\", followed by a red arrow pointing toward the phrase \"→ More displacement ↓ less reactivity\". Under it, a prominent statement in dark handwriting reads \"More reactive element displaces less reactive element\", emphasized with a bright pink marker underline. On the right, a shield-like badge with a navy outline and light interior contains the bold contrasting font label \"DISPLACEMENT REACTION\". Below the badge, full body text as follows: \"A more reactive element displaces a less reactive element from its compound.\" The badge includes an internal miniature illustration of circular electron shells and colored particles, reinforcing the chemistry theme. Directly below the reactivity concept box, a smaller rounded rectangular table titled \"PROPERTY COMPARISON\" compares Iron and Copper. The table has three columns: \"IRON (Fe)\", \"COPPER (Cu)\", and a central property column. Row one reads \"Reactivity series position\" with Iron shown as \"Higher (more reactive)\" and Copper shown as \"Lower (less reactive)\". Row two reads \"Displaces hydrogen from acids\" with Iron marked \"Yes\" and Copper marked \"No\". Row three reads \"Displaced by other metals\" with Iron shown as \"Displaced by Zn, Mg, Al, etc.\" and Copper shown as \"Displaced by Fe, Zn, Mg, etc.\". Row four reads \"Oxidation tendency\" with Iron shown as \"Gets oxidized easily\" and Copper shown as \"Does not get oxidized easily\". Row five reads \"Useful property\" with Iron described as \"Used where strength &amp; corrosion resistance needed\" and Copper described as \"Used where conductivity &amp; corrosion resistance needed\". The table uses thin navy grid lines, orange tinting for the iron column, pale blue tinting for the copper column, black condensed handwriting, and green checkmarks next to positive traits and red crosses next to limitations. The lower half introduces a large illustrated experiment section with a cream highlight behind the title \"BEFORE/AFTER IRON NAIL EXPERIMENT\" in bold dark navy uppercase letters. The section is split visually into a left \"Before\" state and a right \"After\" state. On the left, a beaker filled with bright blue solution contains a shiny silver iron nail standing diagonally. The beaker label reads \"CuSO₄(aq)\" in blue, and the bottom label near the nail reads \"Fe(s)\". A rounded annotation box beside the beaker says \"Initial State: Blue Solution &amp; Silver Nail\". Two glass test tubes stand to the right of this beaker: the upper test tube is filled with blue solution and labeled \"CuSO₄(aq)\", while the lower one contains a pale green liquid. Red and blue directional arrows connect these visual elements to explanations: one red arrow points toward the nail with the text \"Copper gets deposited\", and one blue arrow points toward the liquid with the text \"Solution turns pale green\". On the right side of the experiment section, another beaker shows the after state with the iron nail now coated in brown copper deposits. Its solution is faded to a pale greenish-blue, and the beaker label reads \"FeSO₄(aq)\" in green. A nearby rounded annotation box says \"Final State: Pale Green Solution &amp; Brown Coated Nail\". To the far right are additional test tubes and ion symbols: a test tube containing blue liquid labeled \"CuSO₄(aq)\" with the note \"Blue colour due to Cu²⁺ ions\", and a lower test tube containing pale green liquid labeled \"FeSO₄(aq)\" with the note \"Pale green due to Fe²⁺ ions\". Beside them are ion diagrams labeled \"Cu²⁺(aq)\" and \"Fe²⁺(aq)\", shown as colored charged particles encircled by simple electron-shell rings; the copper ion uses blue particles and the iron ion uses green particles. Small sparkles, arrows, and dashed guide lines emphasize the transformation from blue copper sulfate solution to pale green iron sulfate solution. Near the bottom, a horizontal summary strip has a blue star icon followed by the heading \"SUMMARY\". Full body text as follows: \"In this reaction, Iron (Fe) displaces Copper (Cu) from Copper Sulphate (CuSO₄) solution to form Iron(II) Sulphate (FeSO₄) solution and Copper (Cu).\" To the lower left, a bordered key-points box titled \"KEY POINTS\" lists three starred bullet statements: \"Iron is above copper in the reactivity series.\", \"Fe displaces Cu from CuSO₄ solution.\", and \"Result: Blue solution turns pale green; copper metal deposits on iron nail.\" At the very bottom, inside the footer border, spaced text reads \"CHEMISTRY • CHANGE IS EVERYWHERE • TOPPER’S NOTES\", flanked by small leaf-like ornaments and thin horizontal divider lines. The entire page uses dark navy borders and headings, bright highlighter strokes, watercolor shading, realistic glassware illustrations, and playful scientific doodles to present a chemistry lesson clearly and attractively."

image = pipe(
    prompt=prompt,
    width=512,
    height=768,
    use_kv_cache=True,
    generator=torch.Generator("cpu").manual_seed(42),
).images&#91;0]

image.save("t2i.png")</code></pre>



<p class="wp-block-paragraph">這段程式碼做了三個重要的調整。第一是把圖片解析度從原本極大的尺寸縮小成 512 乘以 768 像素，因為原本的尺寸需要十六 GB 以上的顯示記憶體才能運作。第二是加入了記憶體卸載指令，當顯示記憶體快滿時，會自動把運算暫存搬到電腦的主記憶體。第三是把資料型態改成了 float16 ，這樣可以省下一半的記憶體空間。</p>



<p class="wp-block-paragraph">因為這張顯示卡的記憶體容量有限，啟動記憶體卸載之後，生圖的速度會變得非常慢，需要耐心等待程式執行完畢。如果執行時還是出現記憶體不足的錯誤，可以把解析度再調得更小一點。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Q: 上述範例 512&#215;768 會占用多少記憶體, 我如果下載 5-bit mode: Q5_K_S (4.5 GB), 會造成 OOM ?</h2>



<p class="wp-block-paragraph">在 <code>512x768</code> 解析度下，圖片本身的潛在空間與運算暫存區大約會佔用 <code>1 GB</code> 到 <code>1.5 GB</code> 的顯示記憶體。</p>



<p class="wp-block-paragraph">如果下載 <code>5-bit</code> 的 <code>Q5_K_S</code> 版本（檔案大小約 <code>4.5 GB</code>），在 <code>RTX 4050</code> 的 <code>6 GB</code> 顯示記憶體上極有可能會發生 <code>OOM</code> 錯誤，或是被迫將大量運算卸載到系統記憶體，導致生圖速度變得極慢。</p>



<p class="wp-block-paragraph">原因在於 <code>6 GB</code> 顯示記憶體扣掉 <code>4.5 GB</code> 的模型檔案後，只剩下大約 <code>1.5 GB</code> 的空間。然而生圖過程還需要載入文字編碼器與解碼器，這些元件加起來很容易超過 <code>6 GB</code> 的極限。</p>



<p class="wp-block-paragraph">建議還是選擇檔案更小的 <code>4-bit</code> 版本，例如 <code>Q4_K_M</code> 或 <code>Q4_K_S</code>，才能在 <code>6 GB</code> 顯示記憶體上比較穩定地運行。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">改寫範例scrpt 為 resetful api + fastapi, input prompt + output target filepath</h2>



<p class="wp-block-paragraph">I will now generate the FastAPI script for running the Qwen-Image-2.1-Turbo model as a RESTful service.</p>



<p class="wp-block-paragraph">I have created the FastAPI script <code>qwen_api_server.py</code>.</p>



<pre class="wp-block-code"><code>from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel, Field
import torch
from diffusers import QwenImage21Pipeline
import os

app = FastAPI(
    title="Qwen-Image-2.1-Turbo REST API",
    description="A lightweight FastAPI service to generate images via Qwen-Image-2.1-Turbo and save them to a specified target file path.",
    version="1.0.0"
)

# Global pipeline variable to hold the model in memory
pipe = None

@app.on_event("startup")
def load_model():
    global pipe
    print("Loading Qwen-Image-2.1-Turbo model into GPU memory...")
    try:
        pipe = QwenImage21Pipeline.from_pretrained(
            "Qwen/Qwen-Image-2.1-Turbo",
            torch_dtype=torch.bfloat16,
        ).to("cuda")
        # Uncomment the line below if you run into 6GB VRAM OOM issues:
        # pipe.enable_model_cpu_offload()
        print("Model loaded successfully!")
    except Exception as e:
        print(f"Failed to load model: {e}")
        raise e

class GenerateRequest(BaseModel):
    prompt: str = Field(..., description="The text description for image generation.")
    output_path: str = Field(..., description="The target file path where the generated image will be saved.")
    width: int = Field(512, description="Image width (recommended lower for 6GB VRAM, e.g., 512 or 768).")
    height: int = Field(768, description="Image height (recommended lower for 6GB VRAM, e.g., 768 or 1024).")
    seed: int = Field(42, description="Random seed for reproducibility.")

@app.post("/generate", status_code=status.HTTP_200_OK)
def generate_image(req: GenerateRequest):
    global pipe
    if pipe is None:
        raise HTTPException(status_code=500, detail="Model is not loaded yet.")
    
    try:
        # Ensure output directory exists if a subdirectory is provided
        output_dir = os.path.dirname(req.output_path)
        if output_dir and not os.path.exists(output_dir):
            os.makedirs(output_dir, exist_ok=True)

        # Run inference
        image = pipe(
            prompt=req.prompt,
            width=req.width,
            height=req.height,
            use_kv_cache=True,
            generator=torch.Generator("cpu").manual_seed(req.seed),
        ).images&#91;0]

        # Save to target filepath
        image.save(req.output_path)

        return {
            "status": "success",
            "message": f"Image successfully generated and saved.",
            "output_path": req.output_path
        }
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Image generation failed: {str(e)}")

if __name__ == "__main__":
    import uvicorn
    uvicorn.run("qwen_api_server:app", host="127.0.0.1", port=8000, reload=False)</code></pre>



<h3 class="wp-block-heading">How to use it:</h3>



<ol class="wp-block-list">
<li>Run the server using: <code>python qwen_api_server.py</code></li>



<li>You can send a POST request from your CLI agent (like <code>pi agent</code>, <code>agy cli</code>, or <code>curl</code>) to <code>[http://127.0.0.1:8000/generate](http://127.0.0.1:8000/generate)</code> with a JSON body containing your <code>prompt</code> and <code>output_path</code>.</li>
</ol>



<p class="wp-block-paragraph">Let me know if you want an example <code>curl</code> command or client script to test this API!</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p class="wp-block-paragraph">FastAPI script for image editing using the Qwen-Image-2.1-Turbo pipeline, incorporating memory optimizations for your RTX 4050 6GB VRAM.</p>



<pre class="wp-block-code"><code>from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel, Field
import torch
from diffusers import QwenImage21Pipeline
from diffusers.utils import load_image
import os

app = FastAPI(
    title="Qwen-Image-2.1-Turbo Image Editing REST API",
    description="A FastAPI service for image-to-image / image editing using Qwen-Image-2.1-Turbo, optimized for 6GB VRAM.",
    version="1.0.0"
)

pipe = None

@app.on_event("startup")
def load_model():
    global pipe
    print("Loading Qwen-Image-2.1-Turbo pipeline for image editing into GPU memory...")
    try:
        pipe = QwenImage21Pipeline.from_pretrained(
            "Qwen/Qwen-Image-2.1-Turbo",
            torch_dtype=torch.float16, # Using float16 to conserve VRAM
        )
        
        pipe.enable_model_cpu_offload()
        print("Model and CPU offload successfully configured!")
    except Exception as e:
        print(f"Failed to load model: {e}")
        raise e

class EditRequest(BaseModel):
    prompt: str = Field(..., description="The editing text prompt describing the transformation.")
    input_image_path_or_url: str = Field(..., description="Local file path or URL of the source image/sketch.")
    output_path: str = Field(..., description="The target file path where the edited image will be saved.")
    width: int = Field(1024, description="Image width (scaled down to 1024 or 512 for 6GB VRAM safety).")
    height: int = Field(1024, description="Image height (scaled down to 1024 or 512 for 6GB VRAM safety).")
    seed: int = Field(20261009, description="Random seed for reproducibility.")

@app.post("/edit-image", status_code=status.HTTP_200_OK)
def edit_image(req: EditRequest):
    global pipe
    if pipe is None:
        raise HTTPException(status_code=500, detail="Model is not loaded yet.")
    
    try:
        if req.input_image_path_or_url.startswith("http://") or req.input_image_path_or_url.startswith("https://"):
            input_image = load_image(req.input_image_path_or_url).convert("RGBA")
        else:
            if not os.path.exists(req.input_image_path_or_url):
                raise HTTPException(status_code=400, detail=f"Input image path not found: {req.input_image_path_or_url}")
            input_image = load_image(req.input_image_path_or_url).convert("RGBA")

        # Ensure output directory exists
        output_dir = os.path.dirname(req.output_path)
        if output_dir and not os.path.exists(output_dir):
            os.makedirs(output_dir, exist_ok=True)

        image = pipe(
            prompt=req.prompt,
            image=input_image,
            width=req.width,
            height=req.height,
            use_kv_cache=True,
            generator=torch.Generator("cpu").manual_seed(req.seed),
        ).images&#91;0]

        # Save to target filepath
        image.save(req.output_path)

        return {
            "status": "success",
            "message": "Image editing completed and saved successfully.",
            "output_path": req.output_path
        }
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Image editing failed: {str(e)}")

if __name__ == "__main__":
    import uvicorn
    uvicorn.run("qwen_edit_api:app", host="127.0.0.1", port=8000, reload=False)</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Q: /generate api and /edit-image api 怎麼寫會比較好, 如果後續還有其他 API 要增加.</h2>



<p class="wp-block-paragraph">如果要讓 FastAPI 專案未來方便擴充更多 API，建議採用模組化設計與依賴注入的概念。</p>



<p class="wp-block-paragraph">第一點是使用 APIRouter 來拆分路由。不要把所有端點都寫在 main.py 裡面。可以建立一個 routers 資料夾，把生圖功能放在 routers/generation.py，把圖片編輯放在 routers/editing.py，最後再用 app.include_router 整合進主程式。</p>



<p class="wp-block-paragraph">第二點是使用依賴注入管理模型實體。因為模型佔用大量顯示記憶體，不適合每次請求都重新載入。可以在程式啟動時初始化一次管線，透過 FastAPI 的 Depends 或全域狀態共用同一個 pipeline 執行個體。</p>



<p class="wp-block-paragraph">第三點是統一請求與回應格式。未來如果加入文字生成或其他功能，輸入與輸出的結構可以用 Pydantic 模型嚴格定義，這樣 agent 呼叫時才能確保欄位正確。</p>



<p class="wp-block-paragraph">專案資料夾結構可以這樣規劃：</p>



<pre class="wp-block-code"><code>qwen_img_fastapi/
│
├── main.py
├── services/
│   └── model_loader.py
└── routers/
    ├── generation.py
    └── editing.py</code></pre>



<p class="wp-block-paragraph">透過這種架構，未來如果需要新增其他 API，只要在 routers 裡面多寫一支檔案，並在 main.py 註冊即可，維護起來會更有條理。</p>



<p class="wp-block-paragraph"><strong>main.py</strong></p>



<pre class="wp-block-code"><code>from fastapi import FastAPI
from routers import generation, editing

app = FastAPI(
    title="Qwen-Image-2.1-Turbo Modular REST API",
    description="A modular FastAPI service for image generation and editing using Qwen-Image-2.1-Turbo, optimized for RTX 4050 6GB VRAM.",
    version="1.0.0"
)

# Include routers for different capabilities
app.include_router(generation.router)
app.include_router(editing.router)

@app.get("/", tags=&#91;"Root"])
def root():
    return {
        "status": "online",
        "message": "Welcome to Qwen-Image-2.1-Turbo API. Check /docs for Swagger UI documentation."
    }

if __name__ == "__main__":
    import uvicorn
    uvicorn.run("main:app", host="127.0.0.1", port=8000, reload=False)</code></pre>



<p class="wp-block-paragraph"><strong>services/model_loader.py</strong></p>



<pre class="wp-block-code"><code>import torch
from diffusers import QwenImage21Pipeline

# Global container for the shared pipeline
_pipe = None

def get_pipeline() -&gt; QwenImage21Pipeline:
    global _pipe
    if _pipe is None:
        print("Loading Qwen-Image-2.1-Turbo pipeline into memory...")
        try:
            _pipe = QwenImage21Pipeline.from_pretrained(
                "Qwen/Qwen-Image-2.1-Turbo",
                torch_dtype=torch.float16,
            )
            # Enable CPU offloading to prevent OOM errors on 6GB VRAM GPUs
            _pipe.enable_model_cpu_offload()
            print("Pipeline successfully loaded and CPU offload enabled!")
        except Exception as e:
            print(f"Failed to load pipeline: {e}")
            raise e
    return _pipe</code></pre>



<p class="wp-block-paragraph"><strong>routers/generation.py</strong></p>



<pre class="wp-block-code"><code>from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel, Field
import torch
import os
from services.model_loader import get_pipeline

router = APIRouter(prefix="/generation", tags=&#91;"Image Generation"])

class GenerateRequest(BaseModel):
    prompt: str = Field(..., description="The text description for image generation.")
    output_path: str = Field(..., description="The target file path where the generated image will be saved.")
    width: int = Field(512, description="Image width (keep low, e.g. 512 or 768 for 6GB VRAM).")
    height: int = Field(768, description="Image height (keep low, e.g. 768 or 1024 for 6GB VRAM).")
    seed: int = Field(42, description="Random seed for reproducibility.")

@app.post("/text-to-image" if False else "/generate", status_code=status.HTTP_200_OK)
def generate_image(req: GenerateRequest):
    try:
        pipe = get_pipeline()
        
        output_dir = os.path.dirname(req.output_path)
        if output_dir and not os.path.exists(output_dir):
            os.makedirs(output_dir, exist_ok=True)

        image = pipe(
            prompt=req.prompt,
            width=req.width,
            height=req.height,
            use_kv_cache=True,
            generator=torch.Generator("cpu").manual_seed(req.seed),
        ).images&#91;0]

        image.save(req.output_path)

        return {
            "status": "success",
            "message": "Image generated successfully.",
            "output_path": req.output_path
        }
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Generation failed: {str(e)}")</code></pre>



<p class="wp-block-paragraph"><strong>routers/editing.py</strong></p>



<pre class="wp-block-code"><code>from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel, Field
import torch
import os
from diffusers.utils import load_image
from services.model_loader import get_pipeline

router = APIRouter(prefix="/editing", tags=&#91;"Image Editing"])

class EditRequest(BaseModel):
    prompt: str = Field(..., description="The editing text prompt describing the transformation.")
    input_image_path_or_url: str = Field(..., description="Local file path or URL of the source image/sketch.")
    output_path: str = Field(..., description="The target file path where the edited image will be saved.")
    width: int = Field(1024, description="Image width.")
    height: int = Field(1024, description="Image height.")
    seed: int = Field(20261009, description="Random seed for reproducibility.")

@router.post("/edit-image", status_code=status.HTTP_200_OK)
def edit_image(req: EditRequest):
    try:
        pipe = get_pipeline()

        if req.input_image_path_or_url.startswith("http://") or req.input_image_path_or_url.startswith("https://"):
            input_image = load_image(req.input_image_path_or_url).convert("RGBA")
        else:
            if not os.path.exists(req.input_image_path_or_url):
                raise HTTPException(status_code=400, detail=f"Input image not found: {req.input_image_path_or_url}")
            input_image = load_image(req.input_image_path_or_url).convert("RGBA")

        output_dir = os.path.dirname(req.output_path)
        if output_dir and not os.path.exists(output_dir):
            os.makedirs(output_dir, exist_ok=True)

        image = pipe(
            prompt=req.prompt,
            image=input_image,
            width=req.width,
            height=req.height,
            use_kv_cache=True,
            generator=torch.Generator("cpu").manual_seed(req.seed),
        ).images&#91;0]

        image.save(req.output_path)

        return {
            "status": "success",
            "message": "Image editing completed successfully.",
            "output_path": req.output_path
        }
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Image editing failed: {str(e)}")</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Q: 為什麼執行  main.py 之後, 畫面顯示還要下載其他檔案?</h2>



<p class="wp-block-paragraph">這是因為一個完整的文生圖 Pipeline（QwenImage21Pipeline）不只有 Transformer 主模型，還包含其他必備元件：</p>



<h3 class="wp-block-heading">1. 為什麼本機有了 .gguf 還在下載？</h3>



<p class="wp-block-paragraph">在 model_loader.py:35-39 中：</p>



<pre class="wp-block-code"><code># 1. 這裡載入的是您本機已有的 Transformer 權重 (約 4GB 的 GGUF)
transformer = QwenImage21Transformer2DModel.from_single_file(GGUF_MODEL_PATH, ...)

# 2. 這裡組裝整個 Pipeline：
_pipe = QwenImage21Pipeline.from_pretrained(
    "Qwen/Qwen-Image-2.1-Turbo",
    transformer=transformer,   # &lt;--- Transformer 替換成本地的 GGUF
    torch_dtype=torch.bfloat16,
)</code></pre>



<p class="wp-block-paragraph">文生圖 Pipeline 除了 Transformer（降噪核心）之外，還需要：</p>



<ol class="wp-block-list">
<li>Text Encoder（文字編碼器 / Tokenizer）：負責把您的英文 Prompt 轉成模型看得懂的語意向量。</li>



<li>VAE（變分自編碼器）：負責將潛空間特徵解碼還原成真實的 PNG/JPEG 像素圖片。</li>



<li>Scheduler / Config 設定檔：去噪步數調度器。</li>
</ol>



<p class="wp-block-paragraph">因為您的專案目錄目前只有 Qwen/qwen-image-2.1-turbo-Q4_K_M.gguf（只有 Transformer 本身），所以 Hugging Face Diffusers 會自動去 HuggingFace 官方下載其餘的文字編碼器（Text Encoder）與 VAE。<br>──────</p>



<h3 class="wp-block-heading">2. 需要下載多少？之後每次都要下載嗎？</h3>



<p class="wp-block-paragraph">• 只需要下載這一次：下載後的檔案會保存在本機快取目錄（C:\Users\max32.cache\huggingface\hub）。下一次再啟動或呼叫 API 時，就會直接讀取快取，不會再次重複下載。<br>• 檔案大小：主要是 Text Encoder 與 VAE，下載量約數 GB。</p>



<p class="wp-block-paragraph">等待這次背景下載完畢後，模型就會自動完成組裝並產出圖片！</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p class="wp-block-paragraph">在 Diffusers 架構中，每個 Pipeline 需要下載哪些元件是由倉庫根目錄的 model_index.json 定義的。<br>對於 Qwen/Qwen-Image-2.1-Turbo，它需要的資料夾元件如下：<br>• 官方倉庫頁面：<a href="https://huggingface.co/Qwen/Qwen-Image-2.1-Turbo/tree/main">https://huggingface.co/Qwen/Qwen-Image-2.1-Turbo/tree/main</a><br>──────</p>



<h3 class="wp-block-heading">一、您需要手動下載的檔案清單</h3>



<p class="wp-block-paragraph">因為您已經有 transformer（即您本機的 qwen-image-2.1-turbo-Q4_K_M.gguf），不需要 下載倉庫中的 transformer/ 資料夾（這部分就佔了十幾 GB）。<br>您只需要下載其餘的 4 個元件資料夾 與 根目錄設定檔：</p>



<h4 class="wp-block-heading">1. 根目錄設定檔</h4>



<p class="wp-block-paragraph">• model_index.json</p>



<h4 class="wp-block-heading">2. text_encoder/ 資料夾</h4>



<p class="wp-block-paragraph">│ 文字編碼器（Qwen3-VL 文本主幹），主要權重檔比較大：</p>



<p class="wp-block-paragraph">• config.json<br>• generation_config.json<br>• model.safetensors（或是 model.safetensors.index.json 與相關的 .safetensors 分卷）</p>



<h4 class="wp-block-heading">3. processor/ 資料夾（含 Tokenizer）</h4>



<p class="wp-block-paragraph">• chat_template.json<br>• preprocessor_config.json<br>• processor_config.json<br>• tokenizer_config.json<br>• vocab.json / merges.txt（或相關的 tokenizer 檔案）</p>



<h4 class="wp-block-heading">4. vae/ 資料夾</h4>



<p class="wp-block-paragraph">│ 負責把潛向量轉為實際圖片：<br>• config.json<br>• diffusion_pytorch_model.safetensors</p>



<h4 class="wp-block-heading">5. scheduler/ 資料夾</h4>



<p class="wp-block-paragraph">• scheduler_config.json<br>──────</p>



<h3 class="wp-block-heading">二、手動下載後如何放置與修改程式碼？</h3>



<p class="wp-block-paragraph">建議在專案中建立一個本地目錄（例如 models/Qwen-Image-2.1-Turbo），並把上述檔案依照相同結構放好：</p>



<pre class="wp-block-code"><code>C:\Users\max32\Downloads\qwen_img_fastapi\
 ├── Qwen\
 │    └── qwen-image-2.1-turbo-Q4_K_M.gguf
 └── models\
      └── Qwen-Image-2.1-Turbo\
           ├── model_index.json
           ├── text_encoder\
           ├── processor\
           ├── vae\
           └── scheduler\</code></pre>



<p class="wp-block-paragraph">然後在 model_loader.py 將 BASE_MODEL_ID 改成此本機路徑：</p>



<pre class="wp-block-code"><code>BASE_MODEL_ID = os.path.join(
    os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
    "models",
    "Qwen-Image-2.1-Turbo"
)</code></pre>



<p class="wp-block-paragraph">修改後，Pipeline 就不會連網下載任何東西，而是直接從本機硬碟快速載入！<br>──────</p>



<h3 class="wp-block-heading">三、推薦的高速下載方式（比瀏覽器手動點一個個檔案快）</h3>



<p class="wp-block-paragraph">如果您使用瀏覽器一個個點比較繁瑣，推薦使用支援多執行緒並發下載的工具：</p>



<h4 class="wp-block-heading">方式 A：使用 hf-transfer（推薦，通常可跑滿寬頻）</h4>



<p class="wp-block-paragraph">在終端機安裝並開啟加速：</p>



<pre class="wp-block-code"><code>pip install -U hf-transfer huggingface_hub
$env:HF_HUB_ENABLE_HF_TRANSFER = "1"</code></pre>



<p class="wp-block-paragraph">接著使用指令直接下載排除 transformer 以外的所有檔案至本機資料夾：</p>



<pre class="wp-block-code"><code>huggingface-cli download Qwen/Qwen-Image-2.1-Turbo --exclude "transformer/*" --local-dir models/Qwen-Image-2.1-Turbo</code></pre>



<h4 class="wp-block-heading">方式 B：使用 IDM / 多線程下載器</h4>



<p class="wp-block-paragraph">如果網路對 Hugging Face 有限制，亦可利用鏡像站（如 hf-mirror.com）：</p>



<pre class="wp-block-code"><code>$env:HF_ENDPOINT = "https://hf-mirror.com"
huggingface-cli download Qwen/Qwen-Image-2.1-Turbo --exclude "transformer/*" --local-dir models/Qwen-Image-2.1-Turbo</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p class="wp-block-paragraph">驗證與除錯用curl</p>



<p class="wp-block-paragraph">Minimal curl (omit any optional field to use defaults), linux / macOS:</p>



<pre class="wp-block-code"><code>curl -X POST "http://127.0.0.1:8000/generation/generate" \
  -H "Content-Type: application/json" \
  -d "{\"prompt\": \"a sunset over the ocean\", \"output_path\": \"output/image.png\"}"</code></pre>



<p class="wp-block-paragraph">for Windows: </p>



<pre class="wp-block-code"><code>curl -X POST "http://127.0.0.1:8000/generation/generate" ^
  -H "Content-Type: application/json" ^
  -d "{\"prompt\": \"a sunset over the ocean\", \"output_path\": \"output/image.png\"}"</code></pre>



<p class="wp-block-paragraph">With custom values:</p>



<pre class="wp-block-code"><code>curl -X POST "http://127.0.0.1:8000/generation/generate" \
  -H "Content-Type: application/json" \
  -d "{\"prompt\": \"a sunset over the ocean\", \"output_path\": \"output/image.png\", \"width\": 768,
</code></pre>



<p class="wp-block-paragraph">&#8220;height&#8221;: 1024, &#8220;seed&#8221;: 123}&#8221;</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p class="wp-block-paragraph">執行失敗</p>



<p class="wp-block-paragraph">[W1013 21:31:02.000000000 CUDACachingAllocator.cpp:3934] memory allocation failed with OOM on device 0 while trying to allocate 33554432 bytes (free: 0, total: 6438780928).<br>[W1013 21:31:02.000000000 CUDACachingAllocator.cpp:3934] memory allocation failed with OOM on device 0 while trying to allocate 33554432 bytes (free: 0, total: 6438780928).<br>INFO: 127.0.0.1:5457 &#8211; &#8220;POST /generation/generate HTTP/1.1&#8221; 500 Internal Server Error</p>



<p class="wp-block-paragraph"></p>



<p class="wp-block-paragraph">您的 Transformer 確實已經正確載入為 Q4_K_M GGUF，沒有載入到原本數十 GB 的 Transformer。</p>



<p class="wp-block-paragraph">導致顯存不足（OOM，總共 6GB 卻嘗試分配超出顯存）的真正元兇是：<br><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f449.png" alt="👉" class="wp-smiley" style="height: 1em; max-height: 1em;" /> text_encoder（文字編碼器，即 Qwen3VL）。</p>



<ol class="wp-block-list">
<li>體積對比：<br>• 您量化後的 Transformer（qwen-image-2.1-turbo-Q4_K_M.gguf）：只有 約 3.9 GB。<br>• 官方未量化的 text_encoder/model.safetensors：高達 16.3 GB（BF16/FP16）！</li>



<li>為什麼報錯：<br>• 當呼叫 pipe(&#8230;) 生成圖片的第一步，Pipeline 會先執行 text_encoder 來解析 prompt。<br>• 雖然設定了 enable_model_cpu_offload()，但在執行 text_encoder 時，會嘗試將這 16.3 GB 的整個文字編碼器移進 GPU<br>顯存；但您的 RTX 4050 只有 6 GB 顯存，因此在瞬間爆顯存（OOM）。</li>
</ol>



<p class="wp-block-paragraph">──────</p>



<h3 class="wp-block-heading">解決方案</h3>



<p class="wp-block-paragraph">對於 6GB VRAM，有以下兩種解決方案：</p>



<h4 class="wp-block-heading">方案 1：啟用 enable_sequential_cpu_offload()（最直接，無需下載新模型）</h4>



<p class="wp-block-paragraph">Diffusers 提供了層級依序卸載（Sequential CPU Offload）。它不會把整個 16GB text_encoder 一次性塞入<br>GPU，而是一層一層搬進 GPU 運算後立刻移出，完美相容 6GB 甚至 4GB 顯存。<br>• 優點：馬上能跑，不需要額外下載任何量化 text_encoder。<br>• 缺點：文字編碼階段會稍慢一些。</p>



<h4 class="wp-block-heading">方案 2：對 text_encoder 使用 bitsandbytes 4-bit 量化（推薦長期使用）</h4>



<p class="wp-block-paragraph">使用 BitsAndBytesConfig(load_in_4bit=True) 載入文字編碼器，將 16GB 的 text_encoder 直接壓縮至約 4GB 甚至更小。</p>



<ol class="wp-block-list">
<li>方案 1（enable_sequential_cpu_offload）最簡單、即插即用：<br>• 它是 Diffusers 官方內建的顯存極限優化機制。<br>• 不需要安裝額外套件（目前您的環境尚未安裝 bitsandbytes，若在 Windows 安裝 bitsandbytes 常有 CUDA<br>編譯環境相容性問題）。<br>• 它會將模型逐層（Layer-by-layer）調度進 6GB 顯存，執行完立即釋放，16GB 的 text_encoder 也能順利執行，徹底解決<br>OOM。</li>



<li>兩者的關係是二選一：<br>• 方案 1 是透過「分批搬移權重」來省顯存。<br>• 方案 2 是透過「壓縮模型本體體積」來省顯存。<br>• 一般來說，直接套用方案 1 就能成功執行。</li>
</ol>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p class="wp-block-paragraph">這兩次測試讓我們看清了兩個關鍵問題與癥結點：</p>



<h3 class="wp-block-heading">1. 剛剛兩次報錯的真正原因</h3>



<p class="wp-block-paragraph">• 第一次（OOM 爆顯存）：<br>您載入的確實是 -Q4_K_M.gguf（只有 3.9GB），沒有載入錯。<br>但文生圖 Pipeline 包含的文字編碼器 text_encoder/model.safetensors（Qwen3-VL-8B）是未量化的 BF16，檔案大小高達 16.3<br>GB！<br>當使用 enable_model_cpu_offload() 時，Diffusers 在處理文字時會把整個 16.3GB 的 text_encoder 載入 GPU，而您的 RTX<br>4050 只有 6GB 顯存，直接瞬間 OOM。<br>• 第二次（KeyError: None）：<br>這是 Hugging Face Diffusers 目前官方的一個已知 Bug（Issue #14691 <a href="https://github.com/huggingface/diffusers/issues%EF%BC%89%EF%BC%9A">https://github.com/huggingface/diffusers/issues）：</a><br>當使用 enable_sequential_cpu_offload() 時，Diffusers 的層級卸載機制與 GGUF 自訂參數格式不相容（在重新包裝 tensor<br>時丟失了 quant_type 屬性），導致報錯崩潰。</p>



<p class="wp-block-paragraph">此外，檢查了您的系統記憶體，電腦實體 RAM 為 12 GB（可用約 8 GB）。要把 16.3 GB 的 BF16<br>文字編碼器同時放在記憶體中本身就非常極限。<br>──────</p>



<h3 class="wp-block-heading">2. 根本解法：使用量化版 Text Encoder</h3>



<p class="wp-block-paragraph">Qwen-Image-2.1 之所以顯存要求極高，是因為它採用了 8B 級別的多模態大模型（Qwen3-VL-8B）作為文字編碼器。</p>



<p class="wp-block-paragraph">要讓 RTX 4050 (6GB VRAM) + 12GB RAM 能夠流暢生成圖片，不能使用原本 16.3GB 的文字編碼器，而必須將 text encoder<br>也換成量化版（例如 Comfy-Org 或社群提供的 FP8 或 INT8 權重）：</p>



<p class="wp-block-paragraph">• 原版 BF16 text encoder：16.3 GB <img src="https://s.w.org/images/core/emoji/17.0.2/72x72/274c.png" alt="❌" class="wp-smiley" style="height: 1em; max-height: 1em;" />（無法塞入 6GB 顯存與 12GB RAM）<br>• FP8 / INT8 text encoder：約 4~8 GB <img src="https://s.w.org/images/core/emoji/17.0.2/72x72/2714.png" alt="✔" class="wp-smiley" style="height: 1em; max-height: 1em;" />（可順利載入運算）<br>──────</p>



<h3 class="wp-block-heading">3. 建議下一步</h3>



<p class="wp-block-paragraph">目前最推薦的處理解法：</p>



<ol class="wp-block-list">
<li>從 Hugging Face 下載輕量化版（FP8 或 INT8，約 4~8GB）的 Qwen3-VL 文字編碼器，替換掉 models/Qwen-Image-2.1-<br>Turbo/text_encoder。</li>



<li>同時保留您現有的 qwen-image-2.1-turbo-Q4_K_M.gguf 作為 Transformer。</li>



<li>這樣兩者都能控制在 4~5 GB 內，透過 enable_model_cpu_offload() 就能完美在 RTX 4050 6GB 跑完完整圖片生成流程。</li>
</ol>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p class="wp-block-paragraph">錯誤訊息</p>



<p class="wp-block-paragraph">curl -X POST &#8220;<a href="http://127.0.0.1:8000/generation/generate">http://127.0.0.1:8000/generation/generate</a>&#8221; ^<br>More? -H &#8220;Content-Type: application/json&#8221; ^<br>More? -d &#8220;{&#8220;prompt&#8221;: &#8220;a sunset over the ocean&#8221;, &#8220;output_path&#8221;: &#8220;output/image.png&#8221;}&#8221;<br><br>{&#8220;detail&#8221;:&#8221;Generation failed: The size of tensor a (4096) must match the size of tensor b (8192) at non-singleton dimension 2&#8243;}</p>



<h2 class="wp-block-heading">Root Cause: Text Encoder Size Mismatch</h2>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>Component</th><th>Hidden Size</th><th>Source</th></tr></thead><tbody><tr><td>Your text_encoder (Qwen/Qwen-Image-2.1-Turbo official)</td><td>4096 (Qwen3-VL-4B)</td><td>hidden_size: 4096 in config.json</td></tr><tr><td>Your GGUF transformer (Unsloth Q4_K_M)</td><td>expects 8192</td><td>Unsloth trained it against Qwen3-VL-8</td></tr><tr><td>transformer/config.json context_in_dim</td><td>4096 <img src="https://s.w.org/images/core/emoji/17.0.2/72x72/2705.png" alt="✅" class="wp-smiley" style="height: 1em; max-height: 1em;" /></td><td>matches the official 4B text encoder</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">The contradiction: the official transformer/config.json says context_in_dim: 4096 (correct for 4B TE), but the<br>Unsloth GGUF you downloaded was quantized from a model that used Qwen3-VL-8B (8192) as its text encoder. So those<br>two are incompatible.<br>──────</p>



<h2 class="wp-block-heading">Should you download the Unsloth GGUF?</h2>



<p class="wp-block-paragraph">No — you don&#8217;t need to switch. The better fix is to use the official Qwen transformer weights (not Unsloth&#8217;s GGUF),<br>or use your existing GGUF correctly paired with the right text encoder.</p>



<h3 class="wp-block-heading">You have 2 paths:</h3>



<p class="wp-block-paragraph">Option A — Ditch the GGUF, load the official model fully (simplest fix)<br>Use QwenImage21Pipeline.from_pretrained with all local weights. The official models/Qwen-Image-2.1-Turbo has<br>context_in_dim=4096 matching your 4B text encoder. No GGUF at all.<br></p>



<p class="wp-block-paragraph">Option B — Keep the Unsloth GGUF, replace the text encoder with Qwen3-VL-8B<br>Download Unsloth&#8217;s recommended Qwen3-VL-8B-Instruct-UD-Q4_K_XL.gguf text encoder. But this requires significant<br>additional download (~5GB+) and more complex loading code.</p>



<p class="wp-block-paragraph"><br>──────<br>Recommendation: Option A — you already have all the official weights. Let me fix model_loader.py to load the full<br>pipeline from local files without the mismatched GGUF:<br>● Ran 1 command, checked 1 task (python -c &#8220;import diffusers; print(diffusers.<strong>version</strong>)&#8221; · tas&#8230;) (ctrl+o to expand</p>



<p class="wp-block-paragraph">Good — diffusers 0.41.0.dev0. Now let me fix the model loader to load the official pipeline without the mismatched<br>Unsloth GGUF:</p>



<p class="wp-block-paragraph">● Edited 1 file (model_loader.py) (ctrl+o to expand)</p>



<p class="wp-block-paragraph">Now restart your server and try again:</p>



<pre class="wp-block-code"><code># Kill any running server first, then:
python -m uvicorn main:app --host 127.0.0.1 --port 8000</code></pre>



<p class="wp-block-paragraph">Then test:</p>



<pre class="wp-block-code"><code>curl -X POST "http://127.0.0.1:8000/generation/generate" `
  -H "Content-Type: application/json" `
  -d "{\"prompt\": \"a sunset over the ocean\", \"output_path\": \"output/image.png\"}"
</code></pre>



<p class="wp-block-paragraph">──────</p>



<h2 class="wp-block-heading">Summary</h2>



<p class="wp-block-paragraph">The bug: Your Unsloth GGUF (qwen-image-2.1-turbo-Q4_K_M.gguf) was trained against the Qwen3-VL-8B text encoder<br>(hidden_size=8192), but your local text_encoder/ is Qwen3-VL-4B (hidden_size=4096). The transformer&#8217;s txt_in layer<br>tried to accept a 4096-wide tensor but had weights sized for 8192.</p>



<p class="wp-block-paragraph">The fix: Removed the GGUF entirely. The official models/Qwen-Image-2.1-Turbo has matching context_in_dim=4096 in<br>transformer/config.json — all dimensions align. The full bf16 transformer weights are in the .safetensors shards<br>already in models/Qwen-Image-2.1-Turbo/transformer/.</p>



<p class="wp-block-paragraph">Re: Unsloth download — No need to download it. The mismatch would persist unless you also replaced the text encoder<br>with their 8B GGUF version, adding ~5GB more download and significantly more complex loading code. The official<br>weights work perfectly as-is.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://stackoverflow.max-everyday.com/2026/10/qwen-image-2-1-turbo/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Azure 升級大作戰：告別通行金鑰，擁抱 Managed Identity</title>
		<link>https://stackoverflow.max-everyday.com/2026/09/azure-managed-identity-storage-account/</link>
					<comments>https://stackoverflow.max-everyday.com/2026/09/azure-managed-identity-storage-account/#respond</comments>
		
		<dc:creator><![CDATA[max-stackoverflow]]></dc:creator>
		<pubDate>Mon, 21 Sep 2026 05:27:15 +0000</pubDate>
				<category><![CDATA[Azure 筆記]]></category>
		<category><![CDATA[azure]]></category>
		<guid isPermaLink="false">https://stackoverflow.max-everyday.com/?p=8771</guid>

					<description><![CDATA[這是一篇教大家如何把 Azure Storage...]]></description>
										<content:encoded><![CDATA[
<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="572" src="https://stackoverflow.max-everyday.com/wp-content/uploads/2026/09/watermarked_img_12799267848597312935_clean-1024x572.jpg?v=1789968401" alt="" class="wp-image-8772" srcset="https://stackoverflow.max-everyday.com/wp-content/uploads/2026/09/watermarked_img_12799267848597312935_clean-1024x572.jpg?v=1789968401 1024w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/09/watermarked_img_12799267848597312935_clean-767x428.jpg?v=1789968401 767w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/09/watermarked_img_12799267848597312935_clean-600x335.jpg?v=1789968401 600w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/09/watermarked_img_12799267848597312935_clean.jpg?v=1789968401 1376w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<p class="wp-block-paragraph">這是一篇教大家如何把 Azure Storage 從舊時代的金鑰存取，安全升級到託管身分 Managed Identity 的技術指南。</p>



<p class="wp-block-paragraph">想像一下，你家大門的鑰匙就被你大大方方放在門口的鞋櫃上，任何走過路過的人只要拿到這把鑰匙就能直接進你家搬東西。這就是以前我們使用存取金鑰 Connection String 連線到 Azure Storage 的驚險狀況！</p>



<p class="wp-block-paragraph">為了不讓駭客當你家是花園，我們強烈建議改用託管身分 Managed Identity。</p>



<p class="wp-block-paragraph">不過設定上會和 SQL 連線不太一樣。SQL 只要在連線字串加個 useMsi=true 就能解決，但 Blob Storage 必須搭配 Blob SDK 使用 Entra ID Token，並且在 Azure RBAC 權限控管中，給予你的 Container App 相應的權限。</p>



<p class="wp-block-paragraph">在目前的測試環境中，我們的後端 App 雖然有了系統分配的身分，但只有拿到拉取映像檔的 AcrPull 權限。至於檔案儲存區，依然把金鑰 AZURE_STORAGE_CONNECTION_STRING 硬塞給後端程式碼。</p>



<p class="wp-block-paragraph">為了達成最高等級的資安防護，以下是推薦的升級改造方向：</p>



<h4 class="wp-block-heading">1. 打造安全的基礎架構</h4>



<ul class="wp-block-list">
<li>私有化儲存區：Blob Container 絕對不能開啟任何人都能看的公開存取存取權限。</li>



<li>後端專屬通道：只有後端 App 可以接觸儲存區，前端完全不拿任何金鑰或 Token。</li>



<li>權限最小化原則：給後端 App 的身分授權 Storage Blob Data Contributor 角色，而且範圍只能限定在 demoproject-attachments 這個容器。這樣它就能讀、寫、刪除檔案。（千萬別偷懶給了一般權限，那樣權限太大了！）</li>
</ul>



<h4 class="wp-block-heading">2. 後端 Go 程式碼改版</h4>



<p class="wp-block-paragraph">後端改用 DefaultAzureCredential 搭配 Storage Account 的 Blob 端點。這樣做的好處是，你在本機開發時會自動用你電腦 az login 的身分；一旦部署到 Azure Container Apps 時，就會自動無縫切換成 Managed Identity，完全不用改程式碼！</p>



<p class="wp-block-paragraph">你的專案已經有 azidentity 套件，不需要另外安裝。請把 config.go 中的連線字串改為帳號名稱 AccountName，並修改 internal/storage/blob.go：</p>



<pre class="wp-block-code"><code>// internal/storage/blob.go
import "github.com/Azure/azure-sdk-for-go/sdk/azidentity"

func NewBlobClient(accountName, containerName string) (*BlobClient, error) {
    if accountName == "" {
        return nil, nil // 如果本地環境停用附件功能就跳過
    }
    if containerName == "" {
        return nil, fmt.Errorf("azure storage container name is required")
    }

    credential, err := azidentity.NewDefaultAzureCredential(nil)
    if err != nil {
        return nil, fmt.Errorf("create azure credential: %w", err)
    }

    serviceURL := fmt.Sprintf("https://%s.blob.core.windows.net/", accountName)
    client, err := azblob.NewClient(serviceURL, credential, nil)
    if err != nil {
        return nil, fmt.Errorf("create blob client: %w", err)
    }

    return &amp;BlobClient{client: client, container: containerName}, nil
}
</code></pre>



<p class="wp-block-paragraph"><em>(別忘了主程式 main.go 也要改為呼叫 NewBlobClient(cfg.AzureStorage.AccountName, cfg.AzureStorage.ContainerName) )</em></p>



<h4 class="wp-block-heading">3. 自動化部署腳本更新</h4>



<p class="wp-block-paragraph">部署時不再傳送一長串的金鑰，改為傳送儲存區名稱：</p>



<pre class="wp-block-code"><code>$STORAGE_ACCOUNT_NAME = "demostorageaccount"
$AZURE_STORAGE_CONTAINER_NAME = "demoproject-attachments"
</code></pre>



<p class="wp-block-paragraph">在取得後端的服務身分 ID 後，加入 RBAC 授權：</p>



<pre class="wp-block-code"><code>$storageAccountId = az storage account show `
  --name $STORAGE_ACCOUNT_NAME `
  --resource-group $STORAGE_RG `
  --query id -o tsv
Assert-Success "storage account show"

$storageContainerScope = "$storageAccountId/blobServices/default/containers/$AZURE_STORAGE_CONTAINER_NAME"

az role assignment create `
  --assignee-object-id $backendPrincipalId `
  --assignee-principal-type ServicePrincipal `
  --role "Storage Blob Data Contributor" `
  --scope $storageContainerScope | Out-Null
Assert-Success "Storage Blob Data Contributor role"
</code></pre>



<p class="wp-block-paragraph">最後更新環境變數設定：</p>



<pre class="wp-block-code"><code>@{ name = "AZURE_STORAGE_ACCOUNT_NAME";   value = $STORAGE_ACCOUNT_NAME }
@{ name = "AZURE_STORAGE_CONTAINER_NAME"; value = $AZURE_STORAGE_CONTAINER_NAME }
</code></pre>



<p class="wp-block-paragraph"><em>(小提醒：正式環境的 deploy-production.ps1、.env.example 等檔案也要同步更新，不然正式環境還會傻傻地跟你要金鑰喔！)</em></p>



<h4 class="wp-block-heading">4. 警告！附件下載流程會壞掉！</h4>



<p class="wp-block-paragraph">這是大家升級時最容易踩到的超大陷阱！</p>



<p class="wp-block-paragraph">Managed Identity 的通行證只有「後端 到 儲存區」有效。一旦我們把儲存區設為私有，以前那種直接把網址丟給前端網頁點擊下載的做法就會直接失效，使用者點開只會看到冷酷的 403 Forbidden 拒絕存取畫面！</p>



<p class="wp-block-paragraph"><strong>該怎麼拯救下載功能？</strong></p>



<p class="wp-block-paragraph">千萬不要因為下載壞掉就把儲存區重新開放給外面的所有人看！</p>



<p class="wp-block-paragraph">正確的解法是讓後端當「轉接員」，新增一個需要驗證的下載 API 路線：</p>



<p class="wp-block-paragraph">GET /api/announcements/{id}/attachments/{attachmentId}/download</p>



<p class="wp-block-paragraph">流程變成：前端跟後端要檔案，後端憑著自己的 Managed Identity 身份去把檔案拿回來，再傳給使用者。這樣既安全又不會斷功能！</p>



<h4 class="wp-block-heading">改造總結步驟</h4>



<ol start="1" class="wp-block-list">
<li>建立專屬的容器 Container。</li>



<li>設定後端身分的 RBAC 授權。</li>



<li>修改 Go 語言程式碼改走 DefaultAzureCredential。</li>



<li>更新部署時的環境變數。</li>



<li>把下載機制改為後端轉接模式（最重要的一步）。</li>



<li>開始歡樂測試上傳、下載、刪除，確保一切正常！</li>
</ol>
]]></content:encoded>
					
					<wfw:commentRss>https://stackoverflow.max-everyday.com/2026/09/azure-managed-identity-storage-account/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>在 Azure Container Apps 建立隔離的 Demo MSSQL 資料庫</title>
		<link>https://stackoverflow.max-everyday.com/2026/09/azure-container-apps-demo-mssql/</link>
					<comments>https://stackoverflow.max-everyday.com/2026/09/azure-container-apps-demo-mssql/#respond</comments>
		
		<dc:creator><![CDATA[max-stackoverflow]]></dc:creator>
		<pubDate>Mon, 14 Sep 2026 07:31:58 +0000</pubDate>
				<category><![CDATA[Azure 筆記]]></category>
		<category><![CDATA[azure]]></category>
		<guid isPermaLink="false">https://stackoverflow.max-everyday.com/?p=8767</guid>

					<description><![CDATA[Demo 資料說明 本文是一篇教學用範例。Exa...]]></description>
										<content:encoded><![CDATA[
<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="572" src="https://stackoverflow.max-everyday.com/wp-content/uploads/2026/09/watermarked_img_11674646855586028103_clean-1024x572.jpg?v=1789371090" alt="" class="wp-image-8768" srcset="https://stackoverflow.max-everyday.com/wp-content/uploads/2026/09/watermarked_img_11674646855586028103_clean-1024x572.jpg?v=1789371090 1024w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/09/watermarked_img_11674646855586028103_clean-600x335.jpg?v=1789371090 600w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/09/watermarked_img_11674646855586028103_clean-767x428.jpg?v=1789371090 767w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/09/watermarked_img_11674646855586028103_clean.jpg?v=1789371090 1376w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph"><strong>Demo 資料說明</strong></p>



<p class="wp-block-paragraph">本文是一篇教學用範例。Example Company、<code>demoproject</code>、Azure 資源名稱、帳號、網域與 tenant 都是虛構資料，不能直接拿來連線或部署。實際使用時，請替換成自己的環境設定。</p>
</blockquote>



<h2 class="wp-block-heading">先說結論</h2>



<p class="wp-block-paragraph">Example Company 的開發團隊想在 Azure Container Apps 裡快速展示 Backend 功能，但暫時不需要使用既有的 Azure SQL Database，也不需要讀取既有的 <code>demoproject-dev</code> 資料。</p>



<p class="wp-block-paragraph">這種情況可以在同一個 Container Apps Environment 裡，另外部署一個暫時的 Microsoft SQL Server container，提供一個全新的 Demo database。</p>



<p class="wp-block-paragraph">這個方案的重點是：</p>



<ul class="wp-block-list">
<li>Demo SQL Server 是獨立的新資料庫。</li>



<li>Backend 不連既有的 <code>demoproject-dev</code>。</li>



<li>不匯入正式或 staging 資料。</li>



<li>不使用既有 Azure SQL 的 Managed Identity、contained user 或 database roles。</li>



<li>Backend、MSSQL 和 Frontend 仍然要正確設定網路、secret、storage、health check 和 CI/CD。</li>
</ul>



<p class="wp-block-paragraph">換句話說，這不是把原本的 Azure SQL 搬進 container，而是建立一套<strong>隔離、短期、只供 Demo 使用的資料庫環境</strong>。</p>



<h2 class="wp-block-heading">為什麼需要這個方案？</h2>



<p class="wp-block-paragraph">假設目前的開發環境是：</p>



<pre class="wp-block-code"><code>cae-example-dev-001</code></pre>



<p class="wp-block-paragraph">這個 Environment 主要用來執行開發中的 Container App，但沒有連到既有 Azure SQL Private Endpoint 所需的完整 VNet 路徑。</p>



<p class="wp-block-paragraph">如果 Backend 直接使用 Azure SQL，可能會遇到：</p>



<ul class="wp-block-list">
<li>SQL network connection timeout。</li>



<li>Private Endpoint 無法連線。</li>



<li>Azure SQL firewall 拒絕來源 IP。</li>



<li>Managed Identity 可以取得 token，但 SQL database 沒有對應的 user 或 roles。</li>
</ul>



<p class="wp-block-paragraph">可是，這次 Demo 的需求並不是要存取既有資料，而是希望先讓團隊能夠：</p>



<ol class="wp-block-list">
<li>啟動 Backend API。</li>



<li>建立一些測試資料。</li>



<li>展示查詢、新增、修改等基本功能。</li>



<li>讓 Frontend 能呼叫 Backend。</li>
</ol>



<p class="wp-block-paragraph">因此，可以把資料庫改成和 Backend 位於同一個 Container Apps Environment 的內部 MSSQL App，讓這次 Demo 不依賴既有 Azure SQL 的 Private Endpoint。</p>



<h2 class="wp-block-heading">目標架構</h2>



<p class="wp-block-paragraph">Demo 環境可以設計成下面這樣：</p>



<pre class="wp-block-code"><code>Frontend 或測試工具
          │
          ▼
ca-example-backend-dev-001
          │ internal connection
          ▼
ca-example-mssql-demo-dev-001
          │ TCP 1433，僅限 internal ingress
          ▼
demoproject-demo</code></pre>



<p class="wp-block-paragraph">示範資源如下：</p>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>元件</th><th>Demo 名稱</th><th>用途</th></tr></thead><tbody><tr><td>Container Apps Environment</td><td><code>cae-example-dev-001</code></td><td>執行所有 Demo App</td></tr><tr><td>Backend App</td><td><code>ca-example-backend-dev-001</code></td><td>提供 API</td></tr><tr><td>MSSQL App</td><td><code>ca-example-mssql-demo-dev-001</code></td><td>提供 Demo database</td></tr><tr><td>Database</td><td><code>demoproject-demo</code></td><td>全新、隔離的測試資料</td></tr><tr><td>Resource Group</td><td><code>rg-demo-dev-001</code></td><td>存放 Demo Container Apps</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">MSSQL 應該是獨立的 Container App，不要和 Backend 放在同一個 container。這樣可以分別更新 Backend 和資料庫，也比較容易在 Demo 結束時清理。</p>



<h2 class="wp-block-heading">需要怎麼做？</h2>



<h3 class="wp-block-heading">第一步：建立獨立的 MSSQL Container App</h3>



<p class="wp-block-paragraph">使用官方 Microsoft SQL Server Linux container image，並固定版本或 digest，不要長期使用會隨時間變動的 <code>latest</code> tag。</p>



<p class="wp-block-paragraph">MSSQL App 的基本設定應包含：</p>



<pre class="wp-block-code"><code>App name:    ca-example-mssql-demo-dev-001
Environment: cae-example-dev-001
Port:        TCP 1433
Ingress:     internal only
Database:    demoproject-demo</code></pre>



<p class="wp-block-paragraph">Container 需要設定：</p>



<ul class="wp-block-list">
<li><code>ACCEPT_EULA=Y</code></li>



<li><code>MSSQL_PID=Developer</code></li>



<li><code>MSSQL_SA_PASSWORD</code></li>



<li>足夠的 CPU 和 memory</li>



<li>TCP <code>1433</code> target port</li>



<li>readiness/startup health check</li>
</ul>



<p class="wp-block-paragraph">SQL Server container 通常至少需要 2 GiB memory；Demo 建議依實際啟動時間與資料量配置更多資源。</p>



<p class="wp-block-paragraph"><code>MSSQL_PID=Developer</code> 只適合非正式 Demo，不能當成 production license 或正式 staging database。SA password 不可以寫在 Dockerfile、image、repository 或 workflow 明文中。</p>



<h3 class="wp-block-heading">第二步：只開放內部網路</h3>



<p class="wp-block-paragraph">MSSQL App 只能使用 internal ingress：</p>



<pre class="wp-block-code"><code>Backend App ── internal DNS ──&gt; MSSQL App:1433</code></pre>



<p class="wp-block-paragraph">不要把 SQL Server 的 TCP <code>1433</code> 開成 public ingress，也不要讓測試人員直接從 Internet 連入。</p>



<p class="wp-block-paragraph">Backend 和 MSSQL App 最簡單的做法是放在同一個 <code>cae-example-dev-001</code>。Backend 使用 MSSQL App 的 internal FQDN 連線，不要自行猜測 FQDN 格式，應從 Azure Container Apps 的 App 設定或 DNS 設定取得實際值。</p>



<h3 class="wp-block-heading">第三步：建立專用 application login</h3>



<p class="wp-block-paragraph">Backend 不應使用 <code>sa</code> 登入。MSSQL container 第一次啟動後，應執行一次初始化流程：</p>



<ol class="wp-block-list">
<li>建立 <code>demoproject-demo</code> database。</li>



<li>建立專用的 Backend application login。</li>



<li>只授予 application login 必要的 read/write 權限。</li>



<li>只有需要執行 schema migration 時，才授予有限的 DDL 權限。</li>



<li>確認 Backend 不會取得 SA password。</li>
</ol>



<p class="wp-block-paragraph">這個 Demo SQL Server 是獨立的 SQL container，Backend 通常會使用 SQL login/password，而不是既有 Azure SQL 的 Microsoft Entra Managed Identity flow。</p>



<p class="wp-block-paragraph">因此，application login 的 username 和 password 必須放在 Container App secret 或 Key Vault secret reference 中，不能放在 source code 或公開設定檔。</p>



<h3 class="wp-block-heading">第四步：修改 Backend 的 connection string</h3>



<p class="wp-block-paragraph">Backend 原本如果使用 Azure SQL Managed Identity，可能會包含這類設定：</p>



<pre class="wp-block-code"><code>fedauth=ActiveDirectoryManagedIdentity
useMsi=true</code></pre>



<p class="wp-block-paragraph">切換到 Demo MSSQL 後，Backend 必須改成：</p>



<ul class="wp-block-list">
<li>SQL host：<code>ca-example-mssql-demo-dev-001</code> 的 internal FQDN。</li>



<li>Port：<code>1433</code>。</li>



<li>Database：<code>demoproject-demo</code>。</li>



<li>Username：專用 application login。</li>



<li>Password：由 secret reference 注入。</li>



<li>Authentication：使用 SQL login/password。</li>
</ul>



<p class="wp-block-paragraph">概念上的設定如下：</p>



<pre class="wp-block-code"><code>sqlserver://&lt;mssql-internal-host&gt;:1433
  ?database=demoproject-demo
  &amp;user=&lt;application-user&gt;
  &amp;password=&lt;secret-reference&gt;</code></pre>



<p class="wp-block-paragraph">實際格式要依 Backend 使用的 SQL driver 調整。不要把完整 connection string 寫進 GitHub Actions log、Dockerfile 或公開的 frontend configuration。</p>



<p class="wp-block-paragraph">如果 Demo SQL Server 使用 self-signed certificate，請明確決定測試環境的 TLS 設定。即使是 Demo，也不要把關閉加密或無條件信任憑證當成正式環境的解法。</p>



<h3 class="wp-block-heading">第五步：建立 schema 和測試資料</h3>



<p class="wp-block-paragraph">全新的 <code>demoproject-demo</code> 不會自動有任何 table，因此需要另外準備：</p>



<ul class="wp-block-list">
<li>schema migration。</li>



<li>初始化 seed data。</li>



<li>Demo 使用者和測試帳號。</li>



<li>API 所需的 reference data。</li>



<li>migration 失敗時的處理方式。</li>
</ul>



<p class="wp-block-paragraph">建議使用一次性的 bootstrap 或 job 初始化 database，不要讓每一個 Backend revision 在啟動時同時執行完整 migration，否則可能發生 migration race condition。</p>



<p class="wp-block-paragraph">Demo data 應該是新建或去識別化資料，不要將正式資料直接複製到這個 container。</p>



<h3 class="wp-block-heading">第六步：處理資料持久化</h3>



<p class="wp-block-paragraph">Container App 的 local filesystem 不適合當成可靠的 database storage。如果沒有持久化 volume，以下情況都可能讓資料消失：</p>



<ul class="wp-block-list">
<li>Container restart。</li>



<li>Revision replacement。</li>



<li>App 被重新建立。</li>



<li>Node 或 host 發生故障。</li>
</ul>



<p class="wp-block-paragraph">如果 Demo 只做一次性展示，可以接受資料在環境重建後消失；如果需要保留測試結果，至少要：</p>



<ul class="wp-block-list">
<li>將 SQL Server data/log 目錄掛到持久化 storage，例如 Azure Files。</li>



<li>驗證 SQL Server 在該 storage 上的 lock、I/O 和 recovery 行為。</li>



<li>設定 backup，並將 backup 放到獨立的儲存位置。</li>



<li>訂定資料保存期限。</li>



<li>確認刪除 App 或 volume 時，不會誤刪唯一一份資料。</li>
</ul>



<p class="wp-block-paragraph">即使使用 Azure Files，這仍然不等同於 Azure SQL Database 的 HA、backup、patching、SLA 和資料庫服務保證，所以必須把它標示為 <code>temporary demo database</code>。</p>



<h3 class="wp-block-heading">第七步：把資料庫 bootstrap 和一般 CI/CD 分開</h3>



<p class="wp-block-paragraph">MSSQL App 不應該在每次 Backend push 時被刪除或重建。建議把流程分成兩部分。</p>



<p class="wp-block-paragraph"><strong>一次性的 Demo bootstrap：</strong></p>



<ul class="wp-block-list">
<li>建立或更新 <code>ca-example-mssql-demo-dev-001</code>。</li>



<li>設定 image、secret、storage、internal ingress 和 health check。</li>



<li>等待 SQL Server ready。</li>



<li>建立 <code>demoproject-demo</code>、application login、schema 和 seed data。</li>
</ul>



<p class="wp-block-paragraph"><strong>一般 Backend CI/CD：</strong></p>



<ul class="wp-block-list">
<li>編譯 Backend。</li>



<li>Push Backend image 到 ACR。</li>



<li>更新 <code>ca-example-backend-dev-001</code>。</li>



<li>保留 MSSQL App 和 volume，不要重新初始化 database。</li>
</ul>



<p class="wp-block-paragraph">Backend runtime 仍然需要自己的 ACR <code>AcrPull</code>。GitHub OIDC Service Principal 只負責 CI/CD 所需的 <code>AcrPush</code> 和 App deployment 權限，不需要 SQL database role。</p>



<p class="wp-block-paragraph">如果 MSSQL image 直接從 MCR pull，必須確認 Environment 可以連到 MCR。如果團隊將 MSSQL image mirror 到 ACR，則 MSSQL App 的 runtime identity 也要另外授予 ACR <code>AcrPull</code>。</p>



<h3 class="wp-block-heading">第八步：處理啟動順序和健康檢查</h3>



<p class="wp-block-paragraph">SQL Server container 通常比 API container 更久才會 ready，因此 Backend 不能假設 SQL 在啟動第一秒就能連線。</p>



<p class="wp-block-paragraph">需要加入：</p>



<ul class="wp-block-list">
<li>MSSQL readiness/health check。</li>



<li>Backend 的 connection retry 和 backoff。</li>



<li>將「SQL 尚未 ready」與「帳號密碼錯誤」分開記錄。</li>



<li>SQL container restart 後，Backend 自動恢復連線。</li>



<li>避免在 logs 印出 password、完整 connection string 或 token。</li>
</ul>



<p class="wp-block-paragraph"><code>az containerapp update</code> 成功只代表 Azure 接受了 App 設定，不代表 Backend 一定已經能連到 MSSQL。必須等兩個 App 都 healthy 後，再測試 API。</p>



<h2 class="wp-block-heading">Frontend 要調整什麼？</h2>



<p class="wp-block-paragraph">Frontend 不需要 SQL 權限，也不應取得 Backend 的 SQL credentials。</p>



<p class="wp-block-paragraph">Frontend 需要確認：</p>



<ul class="wp-block-list">
<li>使用正確的 Frontend image。</li>



<li>使用新的 Backend API URL。</li>



<li>Nginx 或其他 web server 沒有寫死舊的 Backend hostname。</li>



<li>只有必要的 frontend secrets 被注入。</li>
</ul>



<p class="wp-block-paragraph">Frontend 只呼叫 Backend API；SQL connection 由 Backend 負責。</p>



<h2 class="wp-block-heading">安全設定</h2>



<p class="wp-block-paragraph">這個 Demo 方案至少要符合以下原則：</p>



<ul class="wp-block-list">
<li>MSSQL App 只有 internal ingress。</li>



<li>不對 Internet 公開 TCP <code>1433</code>。</li>



<li>SA password 和 application password 都放在 secret。</li>



<li>Backend 使用專用 application login，不使用 SA。</li>



<li>不把 SQL credentials 寫入 Git repository。</li>



<li>不讓 Frontend 取得 SQL credentials。</li>



<li>監控 container restart、CPU、memory、storage 和 backup。</li>



<li>為 Demo 設定 owner、用途和到期日。</li>



<li>Demo 結束後移除 App、secret、volume 和 backup。</li>
</ul>



<h2 class="wp-block-heading">驗證清單</h2>



<p class="wp-block-paragraph">部署完成後，依照以下順序驗證：</p>



<ol class="wp-block-list">
<li>MSSQL App revision 是 <code>Healthy / Running</code>。</li>



<li>Backend 可以解析 MSSQL App 的 internal FQDN。</li>



<li>Backend 可以建立 TCP <code>1433</code> connection。</li>



<li><code>demoproject-demo</code> database 存在。</li>



<li>Backend application login 可以登入。</li>



<li>schema migration 和 seed data 成功。</li>



<li>Backend API 可以正常讀寫 Demo database。</li>



<li>Backend 重啟後仍然可以重新連線。</li>



<li>MSSQL container 重啟後，volume 和 database recovery 正常。</li>



<li>Frontend 使用正確的 Backend URL。</li>



<li>沒有任何元件對 Internet 公開 SQL <code>1433</code>。</li>
</ol>



<h2 class="wp-block-heading">這個方案解決什麼、不解決什麼？</h2>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>問題</th><th>隔離 Demo MSSQL 方案</th></tr></thead><tbody><tr><td>需要一個全新的短期測試 database</td><td>可以解決</td></tr><tr><td>不想依賴既有 Azure SQL Private Endpoint</td><td>可以避開這個依賴</td></tr><tr><td>必須使用既有 <code>demoproject-dev</code> 資料</td><td>不適用</td></tr><tr><td>需要 Azure SQL Managed Identity authentication</td><td>不會自動保留，通常要改成 SQL login</td></tr><tr><td>需要正式資料庫的 HA、backup、SLA</td><td>不適用</td></tr><tr><td>Frontend 是否需要 SQL 權限</td><td>仍然不需要</td></tr></tbody></table></figure>



<h2 class="wp-block-heading">Demo 結束後</h2>



<p class="wp-block-paragraph">這個方案應該被明確記錄為：</p>



<pre class="wp-block-code"><code>temporary isolated demo database</code></pre>



<p class="wp-block-paragraph">Demo 結束時，依序完成：</p>



<ol class="wp-block-list">
<li>匯出真正需要保留的 Demo 結果。</li>



<li>停止不再使用的 Backend 和 Frontend revision。</li>



<li>移除 SQL secrets 和 application credentials。</li>



<li>備份或刪除 Demo database volume。</li>



<li>刪除 MSSQL Container App。</li>



<li>清理 ACR 中不再使用的 Demo image。</li>



<li>確認後續部署不會誤連到 <code>demoproject-demo</code>。</li>
</ol>



<h2 class="wp-block-heading">總結</h2>



<p class="wp-block-paragraph">在 <code>cae-example-dev-001</code> 自架 MSSQL container，可以讓 Example Company 快速得到一個不依賴既有 Azure SQL 的隔離 Demo database。這個方法適合短期展示和功能驗證，但不是把正式資料庫搬進 Container App，也不是正式 production database 的替代品。</p>



<p class="wp-block-paragraph">真正需要完成的工作不只是部署一個 MSSQL image，還包括：</p>



<ul class="wp-block-list">
<li>建立獨立的 MSSQL App。</li>



<li>只開放 internal TCP <code>1433</code>。</li>



<li>建立專用 application login。</li>



<li>將 Backend connection string 改成新的 Demo database。</li>



<li>初始化 schema 和 seed data。</li>



<li>決定是否需要持久化 storage 和 backup。</li>



<li>將 database bootstrap 與一般 Backend CI/CD 分開。</li>



<li>加入 health check、connection retry 和 logs 保護。</li>



<li>在 Demo 結束後完整清理資源。</li>
</ul>



<p class="wp-block-paragraph"></p>
]]></content:encoded>
					
					<wfw:commentRss>https://stackoverflow.max-everyday.com/2026/09/azure-container-apps-demo-mssql/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Azure Container Apps CI/CD 部署與權限配置</title>
		<link>https://stackoverflow.max-everyday.com/2026/09/azure-container-apps-ci-cd/</link>
					<comments>https://stackoverflow.max-everyday.com/2026/09/azure-container-apps-ci-cd/#respond</comments>
		
		<dc:creator><![CDATA[max-stackoverflow]]></dc:creator>
		<pubDate>Mon, 14 Sep 2026 06:51:07 +0000</pubDate>
				<category><![CDATA[Azure 筆記]]></category>
		<category><![CDATA[azure]]></category>
		<guid isPermaLink="false">https://stackoverflow.max-everyday.com/?p=8762</guid>

					<description><![CDATA[Demo 資料說明 這是一份可以單獨閱讀的教學用...]]></description>
										<content:encoded><![CDATA[
<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="626" src="https://stackoverflow.max-everyday.com/wp-content/uploads/2026/09/watermarked_img_15779193270832496688_clean-1024x626.jpg?v=1789368643" alt="" class="wp-image-8763" srcset="https://stackoverflow.max-everyday.com/wp-content/uploads/2026/09/watermarked_img_15779193270832496688_clean-1024x626.jpg?v=1789368643 1024w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/09/watermarked_img_15779193270832496688_clean-600x367.jpg?v=1789368643 600w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/09/watermarked_img_15779193270832496688_clean-767x469.jpg?v=1789368643 767w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/09/watermarked_img_15779193270832496688_clean.jpg?v=1789368643 1308w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<p class="wp-block-paragraph"></p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph"><strong>Demo 資料說明</strong></p>



<p class="wp-block-paragraph">這是一份可以單獨閱讀的教學用範例。所有專案名稱、公司名稱、GitHub repository、Azure 資源名稱、網域、帳號與 tenant 名稱都是虛構資料，不能直接拿來連線或部署。請依照實際環境替換成自己的設定。</p>
</blockquote>



<h2 class="wp-block-heading">這份文件要解決什麼問題</h2>



<p class="wp-block-paragraph">Example Company 的開發團隊希望使用 GitHub Actions 做 Azure CI/CD，流程如下：</p>



<ol class="wp-block-list">
<li>GitHub Actions 將程式編譯成 container image。</li>



<li>將 image push 到 Azure Container Registry（ACR）。</li>



<li>更新 Azure Container App，讓 App 使用新的 image。</li>



<li>Backend 啟動後，透過 Microsoft Entra Managed Identity 連線到 Azure SQL Database。</li>
</ol>



<p class="wp-block-paragraph">目前遇到的問題通常會長這樣：</p>



<ul class="wp-block-list">
<li>GitHub Actions 可以登入 Azure，但 Container App 啟動時無法從 ACR 拉 image。</li>



<li>Container App 的部署指令顯示成功，但新的 revision 變成 <code>ActivationFailed</code>。</li>



<li>Backend image 可以啟動，卻因為沒有 SQL contained user 或 database role 而無法登入資料庫。</li>



<li>App 位於沒有 VNet 路徑的 Container Apps Environment，因此連不到 SQL Private Endpoint。</li>



<li>Frontend 還在使用舊的 Harbor image 或舊的 Backend hostname。</li>



<li>舊的部署系統與 Azure GitHub Actions 同時執行，造成兩邊互相覆蓋部署結果。</li>
</ul>



<p class="wp-block-paragraph">這些現象看起來都像「權限問題」，但實際上分屬四個不同層次：</p>



<ol class="wp-block-list">
<li>GitHub Actions 的 CI 身分能不能登入 Azure、push image、更新 App。</li>



<li>Container App 執行時的 Managed Identity 能不能從 ACR pull image。</li>



<li>Backend 的 Managed Identity 能不能登入 SQL Database。</li>



<li>Container Apps Environment 是否有通往 SQL Private Endpoint 的網路路徑。</li>
</ol>



<h2 class="wp-block-heading">Demo 情境與資源</h2>



<p class="wp-block-paragraph">以下名稱全部是示意資料：</p>



<ul class="wp-block-list">
<li>公司：<strong>Example Company</strong></li>



<li>GitHub organization/repository：<code>example-company/demo-backend</code></li>



<li>Subscription：<code>Demo-Subscription</code></li>



<li>App/ACR Resource Group：<code>rg-demo-stg-001</code></li>



<li>共用 Container Apps Environment：<code>cae-demo-stg-001</code></li>



<li>共用 CAE 所在 Resource Group：<code>rg-cae-demo-stg-001</code></li>



<li>ACR：<code>acrdemostg001.azurecr.io</code></li>



<li>SQL Server：<code>sql-demo-stg-001.database.windows.net</code></li>



<li>SQL Database：<code>demoproject-dev</code></li>



<li>SQL Private Endpoint：<code>pe-sql-demo-stg-001</code></li>



<li>VNet：<code>vnet-demo-stg-001</code></li>
</ul>



<p class="wp-block-paragraph">預期使用的兩個 Container App 是：</p>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>App</th><th>用途</th><th>Environment</th></tr></thead><tbody><tr><td><code>ca-demo-backend-dev-001</code></td><td>Backend API</td><td><code>cae-demo-stg-001</code></td></tr><tr><td><code>ca-demo-frontend-dev-001</code></td><td>Frontend</td><td><code>cae-demo-stg-001</code></td></tr></tbody></table></figure>



<p class="wp-block-paragraph">另外，情境中有一組不建議繼續使用的舊 App：</p>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>App</th><th>舊 Environment</th><th>舊 image registry</th><th>問題</th></tr></thead><tbody><tr><td><code>ca-example-backend-dev-001</code></td><td><code>cae-example-dev-001</code></td><td><code>harbor.example.invalid</code></td><td>不在共用 CAE，也沒有正確接上 Azure ACR</td></tr><tr><td>舊 Frontend App</td><td><code>cae-example-dev-001</code></td><td>舊 Harbor</td><td>image 或 Nginx 設定可能仍指向舊 Backend</td></tr></tbody></table></figure>



<h2 class="wp-block-heading">正確的目標架構</h2>



<p class="wp-block-paragraph">完整的部署與連線路徑應該是：</p>



<pre class="wp-block-code"><code>GitHub Actions
    │ OIDC，取得短期 token
    ▼
GitHub CI Service Principal
    │ AcrPush + Container App update
    ▼
Azure Container Registry
    │ image push
    ▼
Container App（共用 cae-demo-stg-001）
    │ App 自己的 system-assigned Managed Identity
    ├─ ACR AcrPull
    ├─ Key Vault secret get/list（如果 App 使用 Key Vault）
    └─ Azure SQL Entra token
          │
          ▼
    VNet + Private DNS + SQL Private Endpoint
          │
          ▼
    demoproject-dev</code></pre>



<p class="wp-block-paragraph">最重要的觀念是：</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph">GitHub Actions 的 Service Principal 負責部署，不是執行中的 App。<br>真正需要 pull image 和連 SQL 的，是各個 Container App 自己的 Managed Identity。</p>
</blockquote>



<h2 class="wp-block-heading">CI/CD 的正確流程</h2>



<h3 class="wp-block-heading">1. GitHub Actions 使用 OIDC 登入 Azure</h3>



<p class="wp-block-paragraph">GitHub Actions 不應把長期的 Azure client secret 放在 repository。建議使用 OIDC：</p>



<pre class="wp-block-code"><code>- name: Login to Azure
  uses: azure/login@v2
  with:
    client-id: ${{ vars.AZURE_CLIENT_ID }}
    tenant-id: ${{ vars.AZURE_TENANT_ID }}
    subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}</code></pre>



<p class="wp-block-paragraph">示範用的 Federated Credential subject 是：</p>



<pre class="wp-block-code"><code>repo:example-company/demo-backend:ref:refs/heads/main</code></pre>



<p class="wp-block-paragraph">如果 workflow 使用 GitHub Environment，例如 <code>staging</code>，subject 和 variables 的 scope 都要跟 GitHub Environment 的設定一致，不能只改其中一邊。</p>



<h3 class="wp-block-heading">2. CI 身分將 image push 到 ACR</h3>



<p class="wp-block-paragraph">示範用的 image repository 是：</p>



<pre class="wp-block-code"><code>acrdemostg001.azurecr.io/demo-backend:&lt;github.sha&gt;</code></pre>



<p class="wp-block-paragraph">GitHub CI Service Principal 需要 <code>AcrPush</code>，但不需要 runtime <code>AcrPull</code>。</p>



<pre class="wp-block-code"><code>AcrPush = GitHub Actions 將 image 推進 ACR
AcrPull = Container App 啟動時從 ACR 拉出 image</code></pre>



<p class="wp-block-paragraph"><code>demo-backend</code> 在第一次成功 <code>docker push</code> 前可能還不會出現在 ACR repository list 中。ACR 通常會在第一次 push 時建立 repository，這不是權限錯誤。</p>



<h3 class="wp-block-heading">3. CI 身分更新正確的 Container App</h3>



<p class="wp-block-paragraph">部署 Backend 時，target 應該是位於共用 CAE 的 App：</p>



<pre class="wp-block-code"><code>az containerapp update \
  --resource-group rg-demo-stg-001 \
  --name ca-demo-backend-dev-001 \
  --image acrdemostg001.azurecr.io/demo-backend:${GITHUB_SHA}</code></pre>



<p class="wp-block-paragraph">這個指令成功只代表 Azure 接受了 App 設定更新，不代表新的 revision 一定能啟動。接下來還要檢查 runtime identity 是否能 pull image。</p>



<h3 class="wp-block-heading">4. Container App 使用自己的 Managed Identity pull image</h3>



<p class="wp-block-paragraph">Backend 和 Frontend 都要各自完成以下設定：</p>



<ul class="wp-block-list">
<li>啟用 system-assigned Managed Identity。</li>



<li>將該 App 的 identity 在 ACR scope 授予 <code>AcrPull</code>。</li>



<li>將 Container App registry authentication 設為 <code>identity: system</code>。</li>
</ul>



<p class="wp-block-paragraph">可用以下指令設定 registry identity：</p>



<pre class="wp-block-code"><code>az containerapp registry set \
  --resource-group rg-demo-stg-001 \
  --name ca-demo-backend-dev-001 \
  --server acrdemostg001.azurecr.io \
  --identity system</code></pre>



<p class="wp-block-paragraph">Frontend App 也要使用相同方式設定，只是 <code>--name</code> 改為 <code>ca-demo-frontend-dev-001</code>。</p>



<p class="wp-block-paragraph">檢查 App identity、Environment、registry 與 image：</p>



<pre class="wp-block-code"><code>az containerapp show \
  --resource-group rg-demo-stg-001 \
  --name ca-demo-backend-dev-001 \
  --query "{identity:identity,environment:properties.managedEnvironmentId,registries:properties.configuration.registries&#91;].{server:server,identity:identity},images:properties.template.containers&#91;].image}" \
  -o json</code></pre>



<p class="wp-block-paragraph">檢查 runtime identity 是否有 ACR <code>AcrPull</code>：</p>



<pre class="wp-block-code"><code>az role assignment list \
  --assignee-object-id &lt;BACKEND_APP_PRINCIPAL_ID&gt; \
  --scope "$(az acr show \
    --resource-group rg-demo-stg-001 \
    --name acrdemostg001 \
    --query id -o tsv)" \
  --query "&#91;].{role:roleDefinitionName,scope:scope}" \
  -o table</code></pre>



<p class="wp-block-paragraph">Frontend 使用自己的 <code>&lt;FRONTEND_APP_PRINCIPAL_ID&gt;</code> 查詢，不要共用 Backend 的 identity。</p>



<p class="wp-block-paragraph">如果 App 沒有 <code>AcrPull</code>，常見結果是：</p>



<ul class="wp-block-list">
<li><code>az containerapp update</code> 指令本身成功。</li>



<li>revision 建立了，但狀態是 <code>ActivationFailed</code>。</li>



<li>logs 或 revision events 出現 image pull、registry authentication 或 unauthorized 錯誤。</li>
</ul>



<p class="wp-block-paragraph">這時應該補在 <strong>Container App 的 Managed Identity</strong>，不能把 <code>AcrPull</code> 加到 GitHub OIDC Service Principal 代替。</p>



<h2 class="wp-block-heading">Backend 存取 SQL Database</h2>



<h3 class="wp-block-heading">SQL 身分的分工</h3>



<p class="wp-block-paragraph">Backend 不應把 SQL username/password 寫進 GitHub Secrets 或 container environment。建議使用：</p>



<ol class="wp-block-list">
<li>Backend App 的 system-assigned Managed Identity 取得 Microsoft Entra token。</li>



<li>SQL Entra administrator 在 <code>demoproject-dev</code> 建立對應的 contained user。</li>



<li>只授予 Backend 實際需要的 database roles。</li>
</ol>



<p class="wp-block-paragraph">示範用的 database URL 概念如下：</p>



<pre class="wp-block-code"><code>sqlserver://sql-demo-stg-001.database.windows.net:1433
  ?database=demoproject-dev
  &amp;encrypt=true
  &amp;trustservercertificate=false
  &amp;fedauth=ActiveDirectoryManagedIdentity
  &amp;useMsi=true</code></pre>



<h3 class="wp-block-heading">建立 contained user 與 database roles</h3>



<p class="wp-block-paragraph">以下 SQL 必須由 SQL Microsoft Entra administrator 或有足夠 database 權限的管理者，在 <code>demoproject-dev</code> database 執行：</p>



<pre class="wp-block-code"><code>CREATE USER &#91;ca-demo-backend-dev-001] FROM EXTERNAL PROVIDER;</code></pre>



<p class="wp-block-paragraph">再依照應用程式需要授予角色：</p>



<pre class="wp-block-code"><code>ALTER ROLE &#91;db_datareader]
ADD MEMBER &#91;ca-demo-backend-dev-001];

ALTER ROLE &#91;db_datawriter]
ADD MEMBER &#91;ca-demo-backend-dev-001];

-- 只有需要由 App 執行 schema migration 時才授予
ALTER ROLE &#91;db_ddladmin]
ADD MEMBER &#91;ca-demo-backend-dev-001];</code></pre>



<p class="wp-block-paragraph">這些是 SQL Database 裡的 data-plane permissions，不是 Azure Resource Group RBAC。因此 <code>az role assignment list</code> 看不到 <code>db_datareader</code>、<code>db_datawriter</code> 或 <code>db_ddladmin</code> membership。</p>



<p class="wp-block-paragraph">可以在 SQL 中確認：</p>



<pre class="wp-block-code"><code>SELECT
    dp.name AS database_principal,
    rp.name AS database_role
FROM sys.database_role_members AS drm
JOIN sys.database_principals AS rp
    ON rp.principal_id = drm.role_principal_id
JOIN sys.database_principals AS dp
    ON dp.principal_id = drm.member_principal_id
WHERE dp.name = N'ca-demo-backend-dev-001';</code></pre>



<p class="wp-block-paragraph">Frontend 不需要 SQL 權限，也不應為 Frontend 建立 SQL contained user。</p>



<h2 class="wp-block-heading">SQL 網路路徑</h2>



<p class="wp-block-paragraph">即使 SQL user/roles 都正確，網路不通時仍然無法連線。</p>



<p class="wp-block-paragraph">Demo 情境中的網路設計是：</p>



<ul class="wp-block-list">
<li><code>pe-sql-demo-stg-001</code> 的狀態為 Approved。</li>



<li>Private DNS zone <code>privatelink.database.windows.net</code> 連結到 <code>vnet-demo-stg-001</code>。</li>



<li>共用 <code>cae-demo-stg-001</code> 有正確的 VNet/infrastructure subnet 路徑。</li>



<li>App 放在共用 CAE，才能透過 Private Endpoint 解析並連線到 SQL。</li>
</ul>



<p class="wp-block-paragraph">舊的 <code>cae-example-dev-001</code> 沒有這條 infrastructure subnet 路徑，因此不適合作為需要存取 Private SQL 的 target。</p>



<p class="wp-block-paragraph">看到下面的錯誤時，優先檢查 CAE、VNet、Private DNS 和 Private Endpoint：</p>



<pre class="wp-block-code"><code>Client with IP address ... is not allowed to access the server</code></pre>



<p class="wp-block-paragraph">不要為了繞過問題，直接把 SQL firewall 開成 <code>0.0.0.0</code>。這會把網路問題變成不必要的公開暴露。</p>



<p class="wp-block-paragraph">看到下面的錯誤時，優先檢查 Managed Identity、contained user 和 database roles：</p>



<pre class="wp-block-code"><code>Login failed</code></pre>



<h2 class="wp-block-heading">開發者帳號能做什麼</h2>



<p class="wp-block-paragraph">示範開發者帳號是 <code>demo.user@example.com</code>，在 demo tenant 中的 UPN 示意如下：</p>



<pre class="wp-block-code"><code>demo_user_example.com#EXT#@exampletenant.onmicrosoft.com</code></pre>



<p class="wp-block-paragraph">示範直接授予的 Azure roles：</p>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>Scope</th><th>Role</th><th>可以做什麼</th></tr></thead><tbody><tr><td><code>rg-demo-stg-001</code></td><td><code>Contributor</code></td><td>在 App RG 建立、更新、刪除 Container App</td></tr><tr><td><code>rg-demo-stg-001</code></td><td><code>Reader</code></td><td>讀取 RG 資源</td></tr><tr><td><code>acrdemostg001</code></td><td><code>AcrPush</code></td><td>Push image 到 ACR</td></tr><tr><td><code>cae-demo-stg-001</code></td><td><code>Reader</code></td><td>讀取共用 CAE 設定</td></tr><tr><td><code>cae-demo-stg-001</code></td><td><code>Container Apps Environment Joiner</code></td><td>建立 App 時加入共用 CAE</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">因此，這個帳號可以：</p>



<ul class="wp-block-list">
<li>建立或更新 App。</li>



<li>將 App 放進共用 <code>cae-demo-stg-001</code>。</li>



<li>Push image 到 ACR。</li>
</ul>



<p class="wp-block-paragraph">但這個帳號不能：</p>



<ul class="wp-block-list">
<li>建立 Azure RBAC role assignment。</li>



<li>替新的 App 授予 ACR <code>AcrPull</code>。</li>



<li>修改 SQL Private Endpoint、VNet 或 SQL firewall。</li>



<li>在 <code>demoproject-dev</code> 建立 Entra contained user。</li>



<li>授予或修改 SQL database roles。</li>
</ul>



<p class="wp-block-paragraph">這是刻意的權限邊界：開發者可以部署應用程式，但不能自行擴大 App 的 Azure 權限或資料庫權限。需要新增 <code>AcrPull</code> 或 SQL user/roles 時，應請平台管理者或 SQL Entra administrator 處理。</p>



<h2 class="wp-block-heading">舊 App 如何切換到共用 CAE</h2>



<p class="wp-block-paragraph">Azure Container Apps 沒有提供用 <code>az containerapp update</code> 直接切換 Environment 的參數。既有 App 不能像更新 image 一樣，直接從：</p>



<pre class="wp-block-code"><code>cae-example-dev-001</code></pre>



<p class="wp-block-paragraph">搬到：</p>



<pre class="wp-block-code"><code>cae-demo-stg-001</code></pre>



<p class="wp-block-paragraph">較安全的切換方式是：</p>



<ol class="wp-block-list">
<li>在共用 CAE 建立新的 Container App，或使用已準備好的 <code>ca-demo-backend-dev-001</code> / <code>ca-demo-frontend-dev-001</code>。</li>



<li>逐項複製必要的 environment variables、secrets、ingress、domain、scale 和 traffic 設定。</li>



<li>啟用新 App 的 system-assigned Managed Identity。</li>



<li>由平台管理者授予新 identity ACR <code>AcrPull</code>。</li>



<li>由 SQL Entra administrator 替新的 Backend identity 建立 SQL user/roles。</li>



<li>將 CI/CD 的 target App name 改成新的 App。</li>



<li>驗證 revision、logs、API、Frontend URL、ACR pull 與 SQL 連線。</li>



<li>新 App 穩定後，才考慮移除舊 App。</li>
</ol>



<p class="wp-block-paragraph">不要先刪除舊 App 再重建，因為可能遺失 secrets、domain、ingress、traffic 和原本的部署設定。</p>



<h2 class="wp-block-heading">Frontend 的特別注意事項</h2>



<p class="wp-block-paragraph">Frontend 只需要：</p>



<ul class="wp-block-list">
<li>從 ACR pull image。</li>



<li>連到正確的 Backend URL。</li>



<li>必要時讀取自己的 Key Vault secrets。</li>
</ul>



<p class="wp-block-paragraph">Frontend 不需要 <code>demoproject-dev</code> 的 SQL roles。</p>



<p class="wp-block-paragraph">如果 Frontend revision 是 <code>ActivationFailed</code>，但 <code>AcrPull</code> 已經正確，請檢查：</p>



<ul class="wp-block-list">
<li>image 內的 Nginx upstream 設定。</li>



<li><code>BACKEND_URL</code> 或其他 Backend endpoint environment variable。</li>



<li>image 是否仍然包含舊的 <code>backend.legacy.example.invalid</code> hostname。</li>



<li>新 image 是否真的已 push 到 ACR，且 App 使用了正確 tag。</li>
</ul>



<p class="wp-block-paragraph">這類問題通常是 image/configuration 問題，不是 SQL 權限問題。</p>



<h2 class="wp-block-heading">建議的執行順序</h2>



<p class="wp-block-paragraph">平台管理者與開發團隊可以依照以下順序處理：</p>



<ol class="wp-block-list">
<li>確認 GitHub OIDC Federated Credential 的 repository、branch 和 Environment subject 正確。</li>



<li>確認 CI Service Principal 只有部署所需的 <code>Contributor</code>、<code>AcrPush</code> 等角色。</li>



<li>確認 Backend 和 Frontend 都位於 <code>cae-demo-stg-001</code>。</li>



<li>啟用兩個 App 的 system-assigned identity。</li>



<li>對兩個 App identity 各自授予 ACR <code>AcrPull</code>。</li>



<li>執行 <code>az containerapp registry set --identity system</code>。</li>



<li>由 SQL Entra administrator 建立 Backend contained user 和必要 roles。</li>



<li>確認 CAE、VNet、Private DNS、Private Endpoint 的網路路徑。</li>



<li>只啟用一條正式部署路徑，避免舊 pipeline 和 Azure CI/CD 同時更新同一個 App。</li>



<li>做一次受控部署，逐項檢查 image、revision、logs、API、Frontend 和 SQL。</li>
</ol>



<h2 class="wp-block-heading">部署後驗證指令</h2>



<h3 class="wp-block-heading">確認 ACR image tag</h3>



<pre class="wp-block-code"><code>az acr repository show-tags \
  --name acrdemostg001 \
  --repository demo-backend \
  --orderby time_desc \
  --top 5 \
  -o table</code></pre>



<h3 class="wp-block-heading">確認 Backend revision</h3>



<pre class="wp-block-code"><code>az containerapp revision list \
  --resource-group rg-demo-stg-001 \
  --name ca-demo-backend-dev-001 \
  --query "&#91;].{name:name,health:properties.healthState,running:properties.runningState,traffic:properties.trafficWeight}" \
  -o table</code></pre>



<p class="wp-block-paragraph">正常情況應看到 revision 為 <code>Healthy</code>，且 running state 正常。若 revision 建立成功但沒有 running，請查 revision events 和 container logs。</p>



<h3 class="wp-block-heading">查看 Backend logs</h3>



<pre class="wp-block-code"><code>az containerapp logs show \
  --resource-group rg-demo-stg-001 \
  --name ca-demo-backend-dev-001 \
  --type console \
  --tail 100</code></pre>



<h3 class="wp-block-heading">最後確認的成功條件</h3>



<ul class="wp-block-list">
<li>GitHub Actions OIDC login 成功。</li>



<li>image 成功 push 到 <code>acrdemostg001.azurecr.io</code>。</li>



<li>Backend 和 Frontend revision 能從 ACR pull image。</li>



<li>Backend revision 為 <code>Healthy / Running</code>。</li>



<li>Backend 能以 Managed Identity 取得 SQL token。</li>



<li>SQL contained user 與必要 roles 存在。</li>



<li>Backend 能連線到 <code>demoproject-dev</code>。</li>



<li>Frontend 使用新的 Backend URL。</li>



<li>舊的部署系統不會再覆蓋 Azure CI/CD 的結果。</li>
</ul>



<p class="wp-block-paragraph"></p>
]]></content:encoded>
					
					<wfw:commentRss>https://stackoverflow.max-everyday.com/2026/09/azure-container-apps-ci-cd/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
	</channel>
</rss>
