Explorar el Código

feat(desktop-pet): 本地程序化生成形象动作帧(item 3)

liaoxg hace 1 día
padre
commit
931cf72bdf

+ 32 - 1
cfc-desktop-pet/src-tauri/src/commands.rs

@@ -87,6 +87,12 @@ pub async fn upload_mascot(
         return Err("图片过大(上限 10MB)".into());
     }
 
+    // 生成 data URL(前端 canvas 生成动作帧用,规避远程图片跨域污染)。
+    let data_url = {
+        let m = if mime.is_empty() { "image/png".to_string() } else { mime.clone() };
+        format!("data:{};base64,{}", m, base64::engine::general_purpose::STANDARD.encode(&bytes))
+    };
+
     let state = app.state::<AppState>();
     let record = state
         .api
@@ -99,7 +105,10 @@ pub async fn upload_mascot(
     cfg.custom_mascot_url = Some(url.clone());
     settings.save(&cfg)?;
 
-    let _ = app.emit("pet://custom-image", serde_json::json!({ "url": url }));
+    let _ = app.emit(
+        "pet://custom-image",
+        serde_json::json!({ "url": url, "dataUrl": data_url }),
+    );
     Ok(url)
 }
 
@@ -112,6 +121,28 @@ pub fn clear_custom_mascot(app: AppHandle, settings: State<SettingsStore>) -> Re
     Ok(())
 }
 
+/// 拉取远程图片并转成 data URL(供前端 canvas 生成动作帧,规避跨域污染)。
+#[tauri::command]
+pub async fn data_url_from_url(url: String) -> Result<String, String> {
+    if url.starts_with("data:") {
+        return Ok(url);
+    }
+    let resp = reqwest::get(&url).await.map_err(|e| format!("请求失败: {e}"))?;
+    if !resp.status().is_success() {
+        return Err(format!("请求失败: HTTP {}", resp.status()));
+    }
+    let mime = resp
+        .headers()
+        .get(reqwest::header::CONTENT_TYPE)
+        .and_then(|v| v.to_str().ok())
+        .unwrap_or("image/png")
+        .to_string();
+    let bytes = resp.bytes().await.map_err(|e| format!("读取失败: {e}"))?;
+    use base64::Engine;
+    let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
+    Ok(format!("data:{};base64,{}", mime, b64))
+}
+
 /// 微信扫码登录:取二维码链接 → 用系统浏览器打开 → 后台轮询换取 JWT → 写入 keyring。
 /// 结果通过 `pet://wechat-login` 事件回传
 /// (status: pending / success / notfound / expired / error / timeout)。

+ 1 - 0
cfc-desktop-pet/src-tauri/src/main.rs

@@ -51,6 +51,7 @@ fn main() {
             commands::quit_app,
             commands::upload_mascot,
             commands::clear_custom_mascot,
+            commands::data_url_from_url,
             commands::start_wechat_login,
         ])
         .run(tauri::generate_context!())

+ 106 - 0
cfc-desktop-pet/src/animation-gen.js

@@ -0,0 +1,106 @@
+// 程序化动作帧生成器(provider 无关,本地 canvas 变换,无需 API Key)。
+// 依据基础形象为每个动作生成 8-16 帧序列(PNG data URL),供 pet-engine.js 的 playClip 播放。
+// 数据源必须是 data: URL 或同源资源;若为跨域远程图,canvas 会被污染导致 toDataURL 抛错,
+// 此时 Promise reject,前端静默回退 CSS 动画。
+(function () {
+  'use strict';
+
+  var MAX_DIM = 256;
+
+  // 每个状态 → 一段随时间 t∈[0,1) 的仿射变换。
+  // dx/dy: 相对宽/高的位移比例;rot: 弧度;sx/sy: 缩放。
+  var PRESETS = {
+    idle:                  { frames: 12, fps: 8,  loop: true,  tf: function (t) { var s = Math.sin(2 * Math.PI * t); return { dy: -0.015 * s, sx: 1 - 0.010 * s, sy: 1 + 0.020 * s }; } },
+    happy:                 { frames: 12, fps: 14, loop: true,  tf: function (t) { return { dy: -0.060 * Math.abs(Math.sin(2 * Math.PI * t)), rot: 0.030 * Math.sin(2 * Math.PI * t) }; } },
+    excited:               { frames: 14, fps: 16, loop: true,  tf: function (t) { return { dy: -0.080 * Math.abs(Math.sin(2 * Math.PI * t)), rot: 0.050 * Math.sin(2 * Math.PI * t) }; } },
+    celebrate:             { frames: 16, fps: 16, loop: true,  tf: function (t) { return { dy: -0.100 * Math.abs(Math.sin(Math.PI * t)), rot: 0.080 * Math.sin(2 * Math.PI * t) }; } },
+    busy:                  { frames: 8,  fps: 16, loop: true,  tf: function (t) { return { dx: 0.012 * Math.sin(4 * Math.PI * t), rot: 0.020 * Math.sin(4 * Math.PI * t) }; } },
+    miniGame:              { frames: 12, fps: 14, loop: true,  tf: function (t) { return { dy: -0.060 * Math.abs(Math.sin(2 * Math.PI * t)), rot: 0.050 * Math.sin(4 * Math.PI * t) }; } },
+    waitingForInteraction: { frames: 14, fps: 10, loop: true,  tf: function (t) { return { rot: 0.050 * Math.sin(2 * Math.PI * t), dx: 0.010 * Math.sin(2 * Math.PI * t) }; } },
+    remind:                { frames: 10, fps: 8,  loop: true,  tf: function (t) { return { rot: 0.060 * Math.sin(4 * Math.PI * t) }; } },
+    sleeping:              { frames: 16, fps: 6,  loop: true,  tf: function (t) { return { rot: 0.040, sy: 1 + 0.025 * Math.sin(2 * Math.PI * t), dy: 0.010 * Math.sin(2 * Math.PI * t) }; } },
+    worried:               { frames: 12, fps: 6,  loop: true,  tf: function (t) { return { dy: 0.015, sy: 1 - 0.020 * Math.abs(Math.sin(Math.PI * t)), rot: -0.030 * Math.sin(2 * Math.PI * t) }; } },
+    sad:                   { frames: 12, fps: 6,  loop: true,  tf: function (t) { return { dy: 0.015, sy: 1 - 0.020 * Math.abs(Math.sin(Math.PI * t)), rot: -0.030 * Math.sin(2 * Math.PI * t) }; } },
+    tired:                 { frames: 12, fps: 5,  loop: true,  tf: function (t) { return { dy: 0.020, rot: -0.020 * Math.sin(2 * Math.PI * t) }; } },
+    energetic:             { frames: 14, fps: 18, loop: true,  tf: function (t) { return { dy: -0.070 * Math.abs(Math.sin(2 * Math.PI * t)), rot: 0.040 * Math.sin(2 * Math.PI * t) }; } },
+    curious:               { frames: 12, fps: 8,  loop: true,  tf: function (t) { return { rot: 0.070 * Math.sin(2 * Math.PI * t), dy: -0.020 * Math.sin(2 * Math.PI * t) }; } },
+    watching:              { frames: 12, fps: 6,  loop: true,  tf: function (t) { return { rot: 0.030 * Math.sin(2 * Math.PI * t), dy: -0.010 * Math.abs(Math.sin(2 * Math.PI * t)) }; } },
+    custom:                { frames: 12, fps: 8,  loop: true,  tf: function (t) { var s = Math.sin(2 * Math.PI * t); return { dy: -0.015 * s }; } }
+  };
+
+  var KNOWN_STATES = ['idle', 'happy', 'busy', 'worried', 'excited', 'sad', 'tired',
+    'remind', 'energetic', 'curious', 'watching', 'waitingForInteraction', 'miniGame',
+    'sleeping', 'celebrate', 'custom'];
+
+  function presetFor(state) { return PRESETS[state] || PRESETS.idle; }
+
+  function loadImage(src) {
+    return new Promise(function (resolve, reject) {
+      var img = new Image();
+      img.onload = function () { resolve(img); };
+      img.onerror = function () { reject(new Error('image load failed')); };
+      img.src = src;
+    });
+  }
+
+  function makeClip(img, preset) {
+    var iw = img.naturalWidth || img.width || 120;
+    var ih = img.naturalHeight || img.height || 140;
+    var scale = Math.min(1, MAX_DIM / Math.max(iw, ih));
+    var cw = Math.max(1, Math.round(iw * scale));
+    var ch = Math.max(1, Math.round(ih * scale));
+    var canvas = document.createElement('canvas');
+    canvas.width = cw;
+    canvas.height = ch;
+    var ctx = canvas.getContext('2d');
+    var frames = [];
+    for (var i = 0; i < preset.frames; i++) {
+      var tr = preset.tf(i / preset.frames) || {};
+      ctx.clearRect(0, 0, cw, ch);
+      ctx.save();
+      ctx.translate(cw / 2 + (tr.dx || 0) * cw, ch / 2 + (tr.dy || 0) * ch);
+      if (tr.rot) ctx.rotate(tr.rot);
+      ctx.scale(tr.sx || 1, tr.sy || 1);
+      ctx.drawImage(img, -cw / 2, -ch / 2, cw, ch);
+      ctx.restore();
+      frames.push(canvas.toDataURL('image/png'));
+    }
+    return {
+      frames: frames,
+      sprite: null,
+      frame_width: cw,
+      frame_height: ch,
+      count: frames.length,
+      fps: preset.fps,
+      loop: preset.loop
+    };
+  }
+
+  // 依据 config.actions(状态→动作名)为每个动作生成剪辑,按动作名为键。
+  // 返回 Promise<{ [actionName]: ActionClip }>;canvas 被污染时 reject。
+  function generateForConfig(config, src) {
+    var actions = (config && config.actions) || {};
+    var map = {}; // actionName -> state
+    KNOWN_STATES.forEach(function (s) {
+      var a = actions[s] || s;
+      if (!(a in map)) map[a] = s;
+    });
+    Object.keys(actions).forEach(function (s) {
+      var a = actions[s];
+      if (a && !(a in map)) map[a] = s;
+    });
+
+    return loadImage(src).then(function (img) {
+      var cache = {}; // state -> clip
+      var out = {};
+      Object.keys(map).forEach(function (actionName) {
+        var state = map[actionName];
+        var clip = cache[state] || (cache[state] = makeClip(img, presetFor(state)));
+        out[actionName] = clip;
+      });
+      return out;
+    });
+  }
+
+  window.cfcPetGen = { generateForConfig: generateForConfig, presets: PRESETS };
+})();

+ 21 - 3
cfc-desktop-pet/src/bridge.js

@@ -14,15 +14,33 @@
   listen('pet://config', function (e) {
     if (window.cfcPetAPI) window.cfcPetAPI.loadConfig(e.payload);
   });
+  function applyCustomImage(url, dataUrl) {
+    if (!window.cfcPetAPI) return;
+    if (!dataUrl && url && /^https?:/i.test(url)) {
+      // 远程图片:先取 data URL(规避 canvas 跨域污染)再生成动作;失败则仍显示原图
+      try {
+        invoke('data_url_from_url', { url: url }).then(function (d) {
+          window.cfcPetAPI.setCustomImage(url, d);
+        }).catch(function () {
+          window.cfcPetAPI.setCustomImage(url, null);
+        });
+      } catch (_) {
+        window.cfcPetAPI.setCustomImage(url, null);
+      }
+    } else {
+      window.cfcPetAPI.setCustomImage(url, dataUrl);
+    }
+  }
+
   listen('pet://custom-image', function (e) {
-    if (window.cfcPetAPI) window.cfcPetAPI.setCustomImage(e.payload && e.payload.url);
+    applyCustomImage(e.payload && e.payload.url, e.payload && e.payload.dataUrl);
   });
 
   // 启动时应用已保存的自定义形象
   try {
     invoke('get_settings').then(function (s) {
-      if (s && s.customMascotUrl && window.cfcPetAPI) {
-        window.cfcPetAPI.setCustomImage(s.customMascotUrl);
+      if (s && s.customMascotUrl) {
+        applyCustomImage(s.customMascotUrl, null);
       }
     }).catch(function () {});
   } catch (_) { /* noop */ }

+ 1 - 0
cfc-desktop-pet/src/index.html

@@ -62,6 +62,7 @@
   <div class="divider"></div>
   <div class="item" data-action="quit">退出</div>
 </div>
+<script src="animation-gen.js"></script>
 <script src="pet-engine.js"></script>
 <script src="bridge.js"></script>
 <script src="interactions.js"></script>

+ 42 - 2
cfc-desktop-pet/src/pet-engine.js

@@ -10,6 +10,10 @@
   let config = null;
   let lastConfig = null;
   let customImage = null;
+  let customDataUrl = null;
+  let runtimeAnimations = {};
+  let animSig = null;
+  let animPending = false;
   let bubbleTimer = null;
   let frameTimer = null;
   let frameIdx = 0;
@@ -45,16 +49,19 @@
       config = typeof configJson === 'string' ? JSON.parse(configJson) : configJson;
       applyImage(config);
     },
-    setCustomImage: function (url) {
+    setCustomImage: function (url, dataUrl) {
       customImage = url || null;
+      customDataUrl = dataUrl || null;
       stopClip();
       if (customImage) {
         petImg.src = customImage;
+        petImg.onload = function () { ensureAnimations(); };
         petImg.onerror = function () { petImg.src = DEFAULT_IMAGE; };
       } else if (lastConfig) {
         applyImage(lastConfig);
       } else {
         petImg.src = DEFAULT_IMAGE;
+        ensureAnimations();
       }
     },
     showBubble: function (text, durationMs) {
@@ -76,6 +83,7 @@
     const src = customImage || (cfg.default_image ? cfg.image_base_url + cfg.default_image : null);
     if (!src) return;
     petImg.src = src;
+    petImg.onload = function () { ensureAnimations(); };
     petImg.onerror = function () { petImg.src = DEFAULT_IMAGE; };
   }
 
@@ -84,7 +92,9 @@
     if (currentAnimation) { petEl.classList.remove(currentAnimation); currentAnimation = null; }
 
     const actionName = (config && config.actions && config.actions[stateName]) || stateName;
-    const clipCfg = (config && config.animations) ? config.animations[actionName] : null;
+    const clipCfg = (config && config.animations && config.animations[actionName])
+      || runtimeAnimations[actionName]
+      || null;
 
     if (clipCfg && clipCfg.frames && clipCfg.frames.length) {
       playClip(clipCfg);
@@ -122,6 +132,35 @@
     return DEFAULT_IMAGE;
   }
 
+  // 生成动作帧所用的基础形象:优先用 data URL(避免 canvas 跨域污染)。
+  function generationSrc() {
+    if (customImage) return customDataUrl || customImage;
+    if (lastConfig && lastConfig.default_image) return lastConfig.image_base_url + lastConfig.default_image;
+    return DEFAULT_IMAGE;
+  }
+
+  // 本地程序化生成动作帧(provider 无关、无需 API Key)。
+  // 成功后重新应用当前状态,使生成的动作立即生效。
+  // 以「基础形象 + 动作映射」为签名,同一形象下不重复生成。
+  function ensureAnimations() {
+    if (!window.cfcPetGen) return;
+    const src = generationSrc();
+    if (!src) return;
+    const cfg = lastConfig || { actions: {} };
+    const sig = src + '|' + JSON.stringify(cfg.actions || {});
+    if (animSig === sig && Object.keys(runtimeAnimations).length) return;
+    if (animPending) return;
+    animPending = true;
+    window.cfcPetGen.generateForConfig(cfg, src).then(function (clips) {
+      runtimeAnimations = clips || {};
+      animSig = sig;
+      animPending = false;
+      setState(currentState, null, null);
+    }).catch(function () {
+      animPending = false;
+    });
+  }
+
   function stopClip() {
     if (frameTimer) { clearInterval(frameTimer); frameTimer = null; }
   }
@@ -152,6 +191,7 @@
   }
 
   petImg.src = DEFAULT_IMAGE;
+  petImg.onload = function () { ensureAnimations(); };
 
   setInterval(function () {
     if (document.getElementById('pet-img')) {