新增tracker&视频封面实时模糊(未启用)&搜索页面改进
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
/.nyc_output
|
/.nyc_output
|
||||||
/coverage
|
/coverage
|
||||||
/node_modules
|
**/node_modules/
|
||||||
/sign
|
/sign
|
||||||
/dist
|
/dist
|
||||||
/build
|
/build
|
||||||
|
|||||||
@@ -55,6 +55,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
|
"jpeg-js": "^0.4.4",
|
||||||
"semver": "^7.6.3"
|
"semver": "^7.6.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,422 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as jpeg from 'jpeg-js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用于表示像素的 RGB 颜色
|
||||||
|
* [r, g, b] 每个范围 0~255
|
||||||
|
*/
|
||||||
|
type Color = [number, number, number];
|
||||||
|
|
||||||
|
/*************************************************************************
|
||||||
|
* 1. 使用 jpeg-js 解码 JPG,并抽取像素用于 K-means
|
||||||
|
*************************************************************************/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从指定文件读取并解码 JPG 得到原始 RGBA 像素数据。
|
||||||
|
* @param filePath
|
||||||
|
* @returns { data: Uint8Array; width: number; height: number }
|
||||||
|
*/
|
||||||
|
function decodeJpg(filePath: string) {
|
||||||
|
const jpgBuffer = fs.readFileSync(filePath);
|
||||||
|
// jpeg.decode 返回 { data: Buffer, width, height }
|
||||||
|
// data 是 RGBA (width * height * 4)
|
||||||
|
const decoded = jpeg.decode(jpgBuffer, { useTArray: true });
|
||||||
|
return {
|
||||||
|
data: decoded.data, // Uint8Array,顺序 RGBA
|
||||||
|
width: decoded.width,
|
||||||
|
height: decoded.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算两种颜色(RGB)的欧几里得距离
|
||||||
|
*/
|
||||||
|
function distance(c1: Color, c2: Color): number {
|
||||||
|
const dr = c1[0] - c2[0];
|
||||||
|
const dg = c1[1] - c2[1];
|
||||||
|
const db = c1[2] - c2[2];
|
||||||
|
return Math.sqrt(dr * dr + dg * dg + db * db);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*************************************************************************
|
||||||
|
* 2. K-means 聚类核心
|
||||||
|
*************************************************************************/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对给定像素集合执行 K-means 聚类,返回聚类中心(颜色)。
|
||||||
|
* @param pixels 所有像素的 RGB 数组
|
||||||
|
* @param k 聚类数量
|
||||||
|
* @param maxIterations 迭代次数上限
|
||||||
|
* @param tolerance 判断收敛的距离阈值
|
||||||
|
* @returns {Color[]} k 个聚类中心(RGB)
|
||||||
|
*/
|
||||||
|
function kMeansCluster(
|
||||||
|
pixels: Color[],
|
||||||
|
k: number,
|
||||||
|
maxIterations = 50,
|
||||||
|
tolerance = 1.0
|
||||||
|
): Color[] {
|
||||||
|
// 1. 随机初始化 k 个聚类中心 (从像素中随机选 k 个)
|
||||||
|
const centers: Color[] = [];
|
||||||
|
for (let i = 0; i < k; i++) {
|
||||||
|
const randomIndex = Math.floor(Math.random() * pixels.length);
|
||||||
|
centers.push([...pixels[randomIndex]] as Color);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用于存储每次迭代时每个像素的所属聚类
|
||||||
|
let assignments: number[] = new Array(pixels.length).fill(-1);
|
||||||
|
|
||||||
|
for (let iter = 0; iter < maxIterations; iter++) {
|
||||||
|
// 记录旧中心,用于判断收敛
|
||||||
|
const oldCenters = centers.map((c) => [...c]) as Color[];
|
||||||
|
|
||||||
|
// 2.1 为每个像素找到离自己最近的中心
|
||||||
|
for (let pIndex = 0; pIndex < pixels.length; pIndex++) {
|
||||||
|
let minDist = Infinity;
|
||||||
|
let clusterIndex = 0;
|
||||||
|
for (let cIndex = 0; cIndex < k; cIndex++) {
|
||||||
|
const dist = distance(pixels[pIndex], centers[cIndex]);
|
||||||
|
if (dist < minDist) {
|
||||||
|
minDist = dist;
|
||||||
|
clusterIndex = cIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assignments[pIndex] = clusterIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2.2 重新计算每个聚类的中心(将同一个 cluster 里的所有像素平均值作为新的中心)
|
||||||
|
const sums: Color[] = new Array(k).fill([0, 0, 0]).map(() => [0, 0, 0]);
|
||||||
|
const counts: number[] = new Array(k).fill(0);
|
||||||
|
|
||||||
|
for (let pIndex = 0; pIndex < pixels.length; pIndex++) {
|
||||||
|
const clusterIndex = assignments[pIndex];
|
||||||
|
sums[clusterIndex][0] += pixels[pIndex][0];
|
||||||
|
sums[clusterIndex][1] += pixels[pIndex][1];
|
||||||
|
sums[clusterIndex][2] += pixels[pIndex][2];
|
||||||
|
counts[clusterIndex]++;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let cIndex = 0; cIndex < k; cIndex++) {
|
||||||
|
if (counts[cIndex] > 0) {
|
||||||
|
centers[cIndex][0] = sums[cIndex][0] / counts[cIndex];
|
||||||
|
centers[cIndex][1] = sums[cIndex][1] / counts[cIndex];
|
||||||
|
centers[cIndex][2] = sums[cIndex][2] / counts[cIndex];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2.3 判断是否收敛:新旧中心之间的最大移动距离 < tolerance
|
||||||
|
let maxShift = 0;
|
||||||
|
for (let cIndex = 0; cIndex < k; cIndex++) {
|
||||||
|
const shift = distance(centers[cIndex], oldCenters[cIndex]);
|
||||||
|
if (shift > maxShift) {
|
||||||
|
maxShift = shift;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (maxShift < tolerance) {
|
||||||
|
// 收敛
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回最终的 k 个聚类中心(颜色分量取整)
|
||||||
|
return centers.map(c => [
|
||||||
|
Math.round(c[0]),
|
||||||
|
Math.round(c[1]),
|
||||||
|
Math.round(c[2])
|
||||||
|
] as Color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提取 3 个主要颜色主流程:
|
||||||
|
* 1. 读取并解码图片 -> RGBA
|
||||||
|
* 2. 抽取 (R,G,B) 像素
|
||||||
|
* 3. 用 K-means 求得 3 个主要颜色
|
||||||
|
*/
|
||||||
|
function extractTop3Colors(filePath: string): Color[] {
|
||||||
|
console.log(`[INFO] decoding JPG: ${filePath}`);
|
||||||
|
const { data, width, height } = decodeJpg(filePath);
|
||||||
|
|
||||||
|
// data: RGBA,(width * height * 4)
|
||||||
|
const sampleStep = Math.max(1, Math.floor((width * height) / 20000));
|
||||||
|
|
||||||
|
const pixels: Color[] = [];
|
||||||
|
for (let i = 0; i < width * height; i += sampleStep) {
|
||||||
|
const r = data[i * 4 + 0];
|
||||||
|
const g = data[i * 4 + 1];
|
||||||
|
const b = data[i * 4 + 2];
|
||||||
|
// alpha 通常忽略
|
||||||
|
pixels.push([r, g, b]);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[INFO] Running K-means for 3 clusters (sample size = ${pixels.length})...`);
|
||||||
|
const k = 3;
|
||||||
|
const centers = kMeansCluster(pixels, k);
|
||||||
|
|
||||||
|
return centers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*************************************************************************
|
||||||
|
* 3. 在新图上绘制 3 个「从圆心色 -> 背景透明」的圆,并保存为 JPG
|
||||||
|
*************************************************************************/
|
||||||
|
|
||||||
|
/** 画布大小 */
|
||||||
|
const CANVAS_WIDTH = 430;
|
||||||
|
const CANVAS_HEIGHT = 250;
|
||||||
|
|
||||||
|
/** 背景颜色 */
|
||||||
|
const BACKGROUND_COLOR: [number, number, number, number] = [0, 0, 0, 255];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 圆心/半径的范围,可自行修改:
|
||||||
|
* - 圆心距离画布边缘至少 MARGIN
|
||||||
|
* - 半径范围 [MIN_RADIUS, MAX_RADIUS]
|
||||||
|
*/
|
||||||
|
const MARGIN = 2;
|
||||||
|
const MIN_RADIUS = 80;
|
||||||
|
const MAX_RADIUS = 110;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 圆与圆之间的最小距离(基于圆心与圆心之间的距离减去半径之和)。
|
||||||
|
* 如果希望**完全不重叠**,可令:
|
||||||
|
* distanceBetweenCenters >= (r1 + r2 + MIN_GAP_BETWEEN_CIRCLES).
|
||||||
|
*/
|
||||||
|
const MIN_GAP_BETWEEN_CIRCLES = 20;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增:圆心颜色亮度因子 (0~1),用于降低圆心过亮
|
||||||
|
* 例如:0.8 -> 稍微暗一点;0.5 -> 明显暗
|
||||||
|
*/
|
||||||
|
const COLOR_BRIGHTNESS_FACTOR = 0.62;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对 RGBA 像素进行 alpha 混合:
|
||||||
|
* outA = srcA + dstA * (1 - srcA)
|
||||||
|
* outRGB = (srcRGB * srcA + dstRGB * dstA * (1 - srcA)) / outA
|
||||||
|
*
|
||||||
|
* data 数组中每像素 4 通道:R, G, B, A,值范围 [0,255]
|
||||||
|
* alpha 范围 [0,1]
|
||||||
|
*/
|
||||||
|
function alphaBlendPixel(
|
||||||
|
data: Uint8Array,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
width: number,
|
||||||
|
color: Color,
|
||||||
|
alpha: number
|
||||||
|
) {
|
||||||
|
const offset = (y * width + x) * 4;
|
||||||
|
const dstR = data[offset + 0];
|
||||||
|
const dstG = data[offset + 1];
|
||||||
|
const dstB = data[offset + 2];
|
||||||
|
const dstA = data[offset + 3] / 255; // 转为 0~1
|
||||||
|
|
||||||
|
const srcR = color[0];
|
||||||
|
const srcG = color[1];
|
||||||
|
const srcB = color[2];
|
||||||
|
const srcA = alpha;
|
||||||
|
|
||||||
|
const outA = srcA + dstA * (1 - srcA);
|
||||||
|
if (outA > 0) {
|
||||||
|
const outR = (srcR * srcA + dstR * dstA * (1 - srcA)) / outA;
|
||||||
|
const outG = (srcG * srcA + dstG * dstA * (1 - srcA)) / outA;
|
||||||
|
const outB = (srcB * srcA + dstB * dstA * (1 - srcA)) / outA;
|
||||||
|
|
||||||
|
data[offset + 0] = Math.round(outR);
|
||||||
|
data[offset + 1] = Math.round(outG);
|
||||||
|
data[offset + 2] = Math.round(outB);
|
||||||
|
data[offset + 3] = Math.round(outA * 255);
|
||||||
|
} else {
|
||||||
|
// outA=0 -> 全透明
|
||||||
|
data[offset + 0] = 0;
|
||||||
|
data[offset + 1] = 0;
|
||||||
|
data[offset + 2] = 0;
|
||||||
|
data[offset + 3] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在 data 上绘制一个「从 centerColor->背景透明」的径向渐变圆
|
||||||
|
* @param data 画布(RGBA)
|
||||||
|
* @param width, height 画布尺寸
|
||||||
|
* @param cx, cy 圆心坐标
|
||||||
|
* @param radius 半径
|
||||||
|
* @param centerColor 圆心处的颜色 (RGB)
|
||||||
|
*/
|
||||||
|
function drawRadialGradientCircle(
|
||||||
|
data: Uint8Array,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
cx: number,
|
||||||
|
cy: number,
|
||||||
|
radius: number,
|
||||||
|
centerColor: Color
|
||||||
|
) {
|
||||||
|
// 仅在圆的外接矩形范围内绘制
|
||||||
|
const minX = Math.max(0, cx - radius);
|
||||||
|
const maxX = Math.min(width - 1, cx + radius);
|
||||||
|
const minY = Math.max(0, cy - radius);
|
||||||
|
const maxY = Math.min(height - 1, cy + radius);
|
||||||
|
|
||||||
|
for (let y = minY; y <= maxY; y++) {
|
||||||
|
for (let x = minX; x <= maxX; x++) {
|
||||||
|
const dx = x - cx;
|
||||||
|
const dy = y - cy;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
if (dist <= radius) {
|
||||||
|
// 根据距离,计算该点的 alpha:
|
||||||
|
// 圆心 dist=0 -> alpha=1
|
||||||
|
// 半径 dist=radius -> alpha=0
|
||||||
|
const alpha = 1 - dist / radius;
|
||||||
|
alphaBlendPixel(data, x, y, width, centerColor, alpha);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工具函数:判断新圆 (cx, cy, r) 与已放置的圆数组 placedCircles
|
||||||
|
* 是否满足“互不重叠”,即:
|
||||||
|
* distanceBetweenCenters >= (r + oldCircle.r + MIN_GAP_BETWEEN_CIRCLES)
|
||||||
|
*/
|
||||||
|
function canPlaceCircle(
|
||||||
|
cx: number,
|
||||||
|
cy: number,
|
||||||
|
r: number,
|
||||||
|
placedCircles: { cx: number, cy: number, r: number }[],
|
||||||
|
minGap: number
|
||||||
|
): boolean {
|
||||||
|
for (const c of placedCircles) {
|
||||||
|
const dist = Math.sqrt((cx - c.cx) ** 2 + (cy - c.cy) ** 2);
|
||||||
|
// 如果距离 < 两个半径和 + minGap,就判定为重叠
|
||||||
|
if (dist < (r + c.r + minGap)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成画布并绘制 3 个不会彼此重叠的渐变圆
|
||||||
|
* @param colors 3 个原始颜色 (RGB)
|
||||||
|
*/
|
||||||
|
function generateCirclesImage(colors: Color[]): { data: Uint8Array; width: number; height: number } {
|
||||||
|
const width = CANVAS_WIDTH;
|
||||||
|
const height = CANVAS_HEIGHT;
|
||||||
|
|
||||||
|
// 1. 创建画布并填充背景
|
||||||
|
const data = new Uint8Array(width * height * 4);
|
||||||
|
for (let i = 0; i < width * height; i++) {
|
||||||
|
data[i * 4 + 0] = BACKGROUND_COLOR[0];
|
||||||
|
data[i * 4 + 1] = BACKGROUND_COLOR[1];
|
||||||
|
data[i * 4 + 2] = BACKGROUND_COLOR[2];
|
||||||
|
data[i * 4 + 3] = BACKGROUND_COLOR[3];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 依次为 3 个颜色生成圆,确保不重叠
|
||||||
|
const placedCircles: { cx: number; cy: number; r: number }[] = [];
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
let circlePlaced = false;
|
||||||
|
|
||||||
|
// 最多尝试多次,若都无法放置,就跳过
|
||||||
|
const MAX_TRIES = 100;
|
||||||
|
|
||||||
|
const radius = randInt(MIN_RADIUS, MAX_RADIUS);
|
||||||
|
var cx = 0;
|
||||||
|
var cy = 0;
|
||||||
|
for (let attempt = 0; attempt < MAX_TRIES; attempt++) {
|
||||||
|
cx = randInt(radius + MARGIN, width - radius - MARGIN);
|
||||||
|
cy = randInt(radius + MARGIN, height - radius - MARGIN);
|
||||||
|
|
||||||
|
if (canPlaceCircle(cx, cy, radius, placedCircles, MIN_GAP_BETWEEN_CIRCLES)) {
|
||||||
|
// 找到不重叠的位置了
|
||||||
|
placedCircles.push({ cx, cy, r: radius });
|
||||||
|
circlePlaced = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!circlePlaced) {
|
||||||
|
console.warn(
|
||||||
|
`[WARN] 无法在 ${MAX_TRIES} 次随机尝试中给第${i + 1}个圆找到合适位置,但仍然绘制。`
|
||||||
|
);
|
||||||
|
placedCircles.push({ cx, cy, r: radius });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 把成功放置的圆绘制到画布
|
||||||
|
// 在这里应用 COLOR_BRIGHTNESS_FACTOR,降低每个圆心的RGB
|
||||||
|
placedCircles.forEach((c, idx) => {
|
||||||
|
// 取对应颜色(若 idx>=colors.length,可加保护)
|
||||||
|
const origColor = colors[idx] || [255, 255, 255];
|
||||||
|
// 调整亮度
|
||||||
|
const adjustedColor: Color = [
|
||||||
|
Math.min(255, Math.max(0, origColor[0] * COLOR_BRIGHTNESS_FACTOR)),
|
||||||
|
Math.min(255, Math.max(0, origColor[1] * COLOR_BRIGHTNESS_FACTOR)),
|
||||||
|
Math.min(255, Math.max(0, origColor[2] * COLOR_BRIGHTNESS_FACTOR)),
|
||||||
|
];
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[INFO] Circle #${idx + 1}: center=(${c.cx},${c.cy}), radius=${c.r}, color=${adjustedColor}`
|
||||||
|
);
|
||||||
|
drawRadialGradientCircle(data, width, height, c.cx, c.cy, c.r, adjustedColor);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { data, width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 RGBA 数据保存为 JPG 文件
|
||||||
|
*/
|
||||||
|
function saveAsJpeg(data: Uint8Array, width: number, height: number, outPath: string) {
|
||||||
|
const rawImageData = {
|
||||||
|
data: Buffer.from(data), // jpeg-js 需要 Buffer 类型
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
};
|
||||||
|
// 设定 jpg 质量
|
||||||
|
const jpegImageData = jpeg.encode(rawImageData, 80);
|
||||||
|
fs.writeFileSync(outPath, jpegImageData.data);
|
||||||
|
console.log(`[INFO] Saved: ${outPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工具函数:在 [min, max] 范围内随机整数
|
||||||
|
*/
|
||||||
|
function randInt(min: number, max: number): number {
|
||||||
|
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*************************************************************************
|
||||||
|
* 4. 主函数:读取命令行参数并执行
|
||||||
|
*************************************************************************/
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
if (args.length < 1) {
|
||||||
|
console.log(`用法: node kmeans-colors.js <jpg文件路径>`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const filePath = args[0];
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
console.error(`[ERROR] 文件不存在: ${filePath}`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) 提取 3 个主色
|
||||||
|
const top3Colors = extractTop3Colors(filePath);
|
||||||
|
console.log(`\n[RESULT] 主要颜色 (RGB):`);
|
||||||
|
top3Colors.forEach((c, idx) => {
|
||||||
|
console.log(` #${idx + 1}: R=${c[0]}, G=${c[1]}, B=${c[2]}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2) 用这 3 个颜色在 400×400 的画布上绘制不重叠的渐变圆
|
||||||
|
const { data, width, height } = generateCirclesImage(top3Colors);
|
||||||
|
|
||||||
|
// 3) 保存结果为 JPG
|
||||||
|
const outPath = 'output-circles.jpg';
|
||||||
|
saveAsJpeg(data, width, height, outPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
main();
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 5.1 KiB |
+21
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "image-color-pick-gen",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "image-color-pick-gen",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"jpeg-js": "^0.4.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jpeg-js": {
|
||||||
|
"version": "0.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz",
|
||||||
|
"integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "image-color-pick-gen",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"jpeg-js": "^0.4.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
console.log("1")
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 202 KiB |
@@ -33,6 +33,7 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
'@src': path.resolve(__dirname, 'src'),
|
||||||
'@components': path.resolve(__dirname, 'src/components'),
|
'@components': path.resolve(__dirname, 'src/components'),
|
||||||
'@less': path.resolve(__dirname, 'src/less'),
|
'@less': path.resolve(__dirname, 'src/less'),
|
||||||
'@protobuf': path.resolve(__dirname, 'src/protobuf'),
|
'@protobuf': path.resolve(__dirname, 'src/protobuf'),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import * as jumpcheck from "./jumpcheck"
|
|||||||
import * as htmlparser from "./htmlparser"
|
import * as htmlparser from "./htmlparser"
|
||||||
import * as savedcontent from "./savedcontent"
|
import * as savedcontent from "./savedcontent"
|
||||||
import * as bgimg from "./bgimg"
|
import * as bgimg from "./bgimg"
|
||||||
|
import * as usertracker from "./usertracker"
|
||||||
import logger from "./logger/logger"
|
import logger from "./logger/logger"
|
||||||
export default {
|
export default {
|
||||||
dayjs: dayjs,
|
dayjs: dayjs,
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { file } from '../tsimports'
|
||||||
|
|
||||||
|
interface FileMoveOptions {
|
||||||
|
srcUri: string;
|
||||||
|
dstUri: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileCopyOptions {
|
||||||
|
srcUri: string;
|
||||||
|
dstUri: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileListOptions {
|
||||||
|
uri: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileGetOptions {
|
||||||
|
uri: string;
|
||||||
|
recursive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileDeleteOptions {
|
||||||
|
uri: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileWriteTextOptions {
|
||||||
|
uri: string;
|
||||||
|
text: string;
|
||||||
|
encoding?: string;
|
||||||
|
append?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileWriteArrayBufferOptions {
|
||||||
|
uri: string;
|
||||||
|
buffer: Uint8Array;
|
||||||
|
position?: number;
|
||||||
|
append?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileReadTextOptions {
|
||||||
|
uri: string;
|
||||||
|
encoding?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileReadArrayBufferOptions {
|
||||||
|
uri: string;
|
||||||
|
position?: number;
|
||||||
|
length?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileAccessOptions {
|
||||||
|
uri: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileMkdirOptions {
|
||||||
|
uri: string;
|
||||||
|
recursive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileRmdirOptions {
|
||||||
|
uri: string;
|
||||||
|
recursive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class FileAPI {
|
||||||
|
async move(options: FileMoveOptions): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
file.move({
|
||||||
|
...options,
|
||||||
|
success: (uri: string) => resolve(uri),
|
||||||
|
fail: (_, code: number) => reject(new Error(`Move failed with code ${code}`)),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async copy(options: FileCopyOptions): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
file.copy({
|
||||||
|
...options,
|
||||||
|
success: (uri: string) => resolve(uri),
|
||||||
|
fail: (_, code: number) => reject(new Error(`Copy failed with code ${code}`)),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(options: FileListOptions): Promise<{ fileList: { uri: string; lastModifiedTime: number; length: number }[] }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
file.list({
|
||||||
|
...options,
|
||||||
|
success: (data: { fileList: { uri: string; lastModifiedTime: number; length: number }[] }) => resolve(data),
|
||||||
|
fail: (_, code: number) => reject(new Error(`List failed with code ${code}`)),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(options: FileGetOptions): Promise<{ uri: string; length: number; lastModifiedTime: number; type: string; subFiles?: any[] }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
file.get({
|
||||||
|
...options,
|
||||||
|
success: (data: any) => resolve(data),
|
||||||
|
fail: (_, code: number) => reject(new Error(`Get failed with code ${code}`)),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(options: FileDeleteOptions): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
file.delete({
|
||||||
|
...options,
|
||||||
|
success: () => resolve(),
|
||||||
|
fail: (_, code: number) => reject(new Error(`Delete failed with code ${code}`)),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeText(options: FileWriteTextOptions): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
file.writeText({
|
||||||
|
...options,
|
||||||
|
success: () => resolve(),
|
||||||
|
fail: (_, code: number) => reject(new Error(`WriteText failed with code ${code}`)),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeArrayBuffer(options: FileWriteArrayBufferOptions): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
file.writeArrayBuffer({
|
||||||
|
...options,
|
||||||
|
success: () => resolve(),
|
||||||
|
fail: (_, code: number) => reject(new Error(`WriteArrayBuffer failed with code ${code}`)),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async readText(options: FileReadTextOptions): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
file.readText({
|
||||||
|
...options,
|
||||||
|
success: (data: { text: string }) => resolve(data.text),
|
||||||
|
fail: (_, code: number) => reject(new Error(`ReadText failed with code ${code}`)),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async readArrayBuffer(options: FileReadArrayBufferOptions): Promise<Uint8Array> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
file.readArrayBuffer({
|
||||||
|
...options,
|
||||||
|
success: (data: { buffer: Uint8Array }) => resolve(data.buffer),
|
||||||
|
fail: (_, code: number) => reject(new Error(`ReadArrayBuffer failed with code ${code}`)),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async access(options: FileAccessOptions): Promise<boolean> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
file.access({
|
||||||
|
...options,
|
||||||
|
success: () => resolve(true),
|
||||||
|
fail: (_, code: number) => reject(new Error(`Access failed with code ${code}`)),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async mkdir(options: FileMkdirOptions): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
file.mkdir({
|
||||||
|
...options,
|
||||||
|
success: () => resolve(),
|
||||||
|
fail: (_, code: number) => reject(new Error(`Mkdir failed with code ${code}`)),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async rmdir(options: FileRmdirOptions): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
file.rmdir({
|
||||||
|
...options,
|
||||||
|
success: () => resolve(),
|
||||||
|
fail: (_, code: number) => reject(new Error(`Rmdir failed with code ${code}`)),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const asyncFile = new FileAPI();
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { storage } from "../tsimports";
|
||||||
|
|
||||||
|
interface StorageGetOptions {
|
||||||
|
key: string;
|
||||||
|
default?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StorageSetOptions {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StorageDeleteOptions {
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class StorageAPI {
|
||||||
|
async get(options: StorageGetOptions): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
storage.get({
|
||||||
|
...options,
|
||||||
|
success: (data: string) => resolve(data),
|
||||||
|
fail: (_, code: number) => reject(new Error(`Get failed with code ${code}`)),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(options: StorageSetOptions): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
storage.set({
|
||||||
|
...options,
|
||||||
|
success: () => resolve(),
|
||||||
|
fail: (_, code: number) => reject(new Error(`Set failed with code ${code}`)),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async clear(): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
storage.clear({
|
||||||
|
success: () => resolve(),
|
||||||
|
fail: (_, code: number) => reject(new Error(`Clear failed with code ${code}`)),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(options: StorageDeleteOptions): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
storage.delete({
|
||||||
|
...options,
|
||||||
|
success: () => resolve(),
|
||||||
|
fail: (_, code: number) => reject(new Error(`Delete failed with code ${code}`)),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const asyncStorage = new StorageAPI();
|
||||||
@@ -85,6 +85,9 @@ export default {
|
|||||||
token: data.token,
|
token: data.token,
|
||||||
success: (data) => {
|
success: (data) => {
|
||||||
global.logger.log("[BetterOnlineImage] Image Saved in: " + data.uri)
|
global.logger.log("[BetterOnlineImage] Image Saved in: " + data.uri)
|
||||||
|
this.$emit('loaded', {
|
||||||
|
uri: data.uri
|
||||||
|
})
|
||||||
this.localsrc = data.uri
|
this.localsrc = data.uri
|
||||||
this.show_loading = false
|
this.show_loading = false
|
||||||
this.show_image = true
|
this.show_image = true
|
||||||
|
|||||||
@@ -0,0 +1,419 @@
|
|||||||
|
import * as jpeg from 'jpeg-js';
|
||||||
|
import { asyncFile } from '../asyncapi/file';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用于表示像素的 RGB 颜色
|
||||||
|
* [r, g, b] 每个范围 0~255
|
||||||
|
*/
|
||||||
|
type Color = [number, number, number];
|
||||||
|
|
||||||
|
/*************************************************************************
|
||||||
|
* 1. 使用 jpeg-js 解码 JPG,并抽取像素用于 K-means
|
||||||
|
*************************************************************************/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从指定文件读取并解码 JPG 得到原始 RGBA 像素数据。
|
||||||
|
* @param filePath
|
||||||
|
* @returns { data: Uint8Array; width: number; height: number }
|
||||||
|
*/
|
||||||
|
async function decodeJpg(filePath: string) {
|
||||||
|
global.logger.log(`[INFO] JPG读取中`);
|
||||||
|
const jpgBuffer = await asyncFile.readArrayBuffer({ uri: filePath });
|
||||||
|
// jpeg.decode 返回 { data: Buffer, width, height }
|
||||||
|
// data 是 RGBA (width * height * 4)
|
||||||
|
global.logger.log(`[INFO] 调用JPEG库解码JPG`);
|
||||||
|
const decoded = jpeg.decode(jpgBuffer, { useTArray: true });
|
||||||
|
return {
|
||||||
|
data: decoded.data, // Uint8Array,顺序 RGBA
|
||||||
|
width: decoded.width,
|
||||||
|
height: decoded.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算两种颜色(RGB)的欧几里得距离
|
||||||
|
*/
|
||||||
|
function distance(c1: Color, c2: Color): number {
|
||||||
|
const dr = c1[0] - c2[0];
|
||||||
|
const dg = c1[1] - c2[1];
|
||||||
|
const db = c1[2] - c2[2];
|
||||||
|
return Math.sqrt(dr * dr + dg * dg + db * db);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*************************************************************************
|
||||||
|
* 2. K-means 聚类核心
|
||||||
|
*************************************************************************/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对给定像素集合执行 K-means 聚类,返回聚类中心(颜色)。
|
||||||
|
* @param pixels 所有像素的 RGB 数组
|
||||||
|
* @param k 聚类数量
|
||||||
|
* @param maxIterations 迭代次数上限
|
||||||
|
* @param tolerance 判断收敛的距离阈值
|
||||||
|
* @returns {Color[]} k 个聚类中心(RGB)
|
||||||
|
*/
|
||||||
|
function kMeansCluster(
|
||||||
|
pixels: Color[],
|
||||||
|
k: number,
|
||||||
|
maxIterations = 50,
|
||||||
|
tolerance = 1.0
|
||||||
|
): Color[] {
|
||||||
|
// 1. 随机初始化 k 个聚类中心 (从像素中随机选 k 个)
|
||||||
|
const centers: Color[] = [];
|
||||||
|
for (let i = 0; i < k; i++) {
|
||||||
|
const randomIndex = Math.floor(Math.random() * pixels.length);
|
||||||
|
centers.push([...pixels[randomIndex]] as Color);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用于存储每次迭代时每个像素的所属聚类
|
||||||
|
let assignments: number[] = new Array(pixels.length).fill(-1);
|
||||||
|
|
||||||
|
for (let iter = 0; iter < maxIterations; iter++) {
|
||||||
|
// 记录旧中心,用于判断收敛
|
||||||
|
const oldCenters = centers.map((c) => [...c]) as Color[];
|
||||||
|
|
||||||
|
// 2.1 为每个像素找到离自己最近的中心
|
||||||
|
for (let pIndex = 0; pIndex < pixels.length; pIndex++) {
|
||||||
|
let minDist = Infinity;
|
||||||
|
let clusterIndex = 0;
|
||||||
|
for (let cIndex = 0; cIndex < k; cIndex++) {
|
||||||
|
const dist = distance(pixels[pIndex], centers[cIndex]);
|
||||||
|
if (dist < minDist) {
|
||||||
|
minDist = dist;
|
||||||
|
clusterIndex = cIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assignments[pIndex] = clusterIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2.2 重新计算每个聚类的中心(将同一个 cluster 里的所有像素平均值作为新的中心)
|
||||||
|
const sums: Color[] = new Array(k).fill([0, 0, 0]).map(() => [0, 0, 0]);
|
||||||
|
const counts: number[] = new Array(k).fill(0);
|
||||||
|
|
||||||
|
for (let pIndex = 0; pIndex < pixels.length; pIndex++) {
|
||||||
|
const clusterIndex = assignments[pIndex];
|
||||||
|
sums[clusterIndex][0] += pixels[pIndex][0];
|
||||||
|
sums[clusterIndex][1] += pixels[pIndex][1];
|
||||||
|
sums[clusterIndex][2] += pixels[pIndex][2];
|
||||||
|
counts[clusterIndex]++;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let cIndex = 0; cIndex < k; cIndex++) {
|
||||||
|
if (counts[cIndex] > 0) {
|
||||||
|
centers[cIndex][0] = sums[cIndex][0] / counts[cIndex];
|
||||||
|
centers[cIndex][1] = sums[cIndex][1] / counts[cIndex];
|
||||||
|
centers[cIndex][2] = sums[cIndex][2] / counts[cIndex];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2.3 判断是否收敛:新旧中心之间的最大移动距离 < tolerance
|
||||||
|
let maxShift = 0;
|
||||||
|
for (let cIndex = 0; cIndex < k; cIndex++) {
|
||||||
|
const shift = distance(centers[cIndex], oldCenters[cIndex]);
|
||||||
|
if (shift > maxShift) {
|
||||||
|
maxShift = shift;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (maxShift < tolerance) {
|
||||||
|
// 收敛
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回最终的 k 个聚类中心(颜色分量取整)
|
||||||
|
return centers.map(c => [
|
||||||
|
Math.round(c[0]),
|
||||||
|
Math.round(c[1]),
|
||||||
|
Math.round(c[2])
|
||||||
|
] as Color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提取 3 个主要颜色主流程:
|
||||||
|
* 1. 读取并解码图片 -> RGBA
|
||||||
|
* 2. 抽取 (R,G,B) 像素
|
||||||
|
* 3. 用 K-means 求得 3 个主要颜色
|
||||||
|
*/
|
||||||
|
async function extractTop3Colors(filePath: string): Promise<Color[]> {
|
||||||
|
global.logger.log(`[INFO] decoding JPG: ${filePath}`);
|
||||||
|
const { data, width, height } = await decodeJpg(filePath);
|
||||||
|
|
||||||
|
// data: RGBA,(width * height * 4)
|
||||||
|
const sampleStep = Math.max(1, Math.floor((width * height) / 20000));
|
||||||
|
|
||||||
|
const pixels: Color[] = [];
|
||||||
|
for (let i = 0; i < width * height; i += sampleStep) {
|
||||||
|
const r = data[i * 4 + 0];
|
||||||
|
const g = data[i * 4 + 1];
|
||||||
|
const b = data[i * 4 + 2];
|
||||||
|
// alpha 通常忽略
|
||||||
|
pixels.push([r, g, b]);
|
||||||
|
}
|
||||||
|
|
||||||
|
global.logger.log(`[INFO] Running K-means for 3 clusters (sample size = ${pixels.length})...`);
|
||||||
|
const k = 3;
|
||||||
|
const centers = kMeansCluster(pixels, k);
|
||||||
|
|
||||||
|
return centers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*************************************************************************
|
||||||
|
* 3. 在新图上绘制 3 个「从圆心色 -> 背景透明」的圆,并保存为 JPG
|
||||||
|
*************************************************************************/
|
||||||
|
|
||||||
|
/** 画布大小 */
|
||||||
|
const CANVAS_WIDTH = 430;
|
||||||
|
const CANVAS_HEIGHT = 250;
|
||||||
|
|
||||||
|
/** 背景颜色 */
|
||||||
|
const BACKGROUND_COLOR: [number, number, number, number] = [0, 0, 0, 255];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 圆心/半径的范围,可自行修改:
|
||||||
|
* - 圆心距离画布边缘至少 MARGIN
|
||||||
|
* - 半径范围 [MIN_RADIUS, MAX_RADIUS]
|
||||||
|
*/
|
||||||
|
const MARGIN = 2;
|
||||||
|
const MIN_RADIUS = 80;
|
||||||
|
const MAX_RADIUS = 110;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 圆与圆之间的最小距离(基于圆心与圆心之间的距离减去半径之和)。
|
||||||
|
* 如果希望**完全不重叠**,可令:
|
||||||
|
* distanceBetweenCenters >= (r1 + r2 + MIN_GAP_BETWEEN_CIRCLES).
|
||||||
|
*/
|
||||||
|
const MIN_GAP_BETWEEN_CIRCLES = 20;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增:圆心颜色亮度因子 (0~1),用于降低圆心过亮
|
||||||
|
* 例如:0.8 -> 稍微暗一点;0.5 -> 明显暗
|
||||||
|
*/
|
||||||
|
const COLOR_BRIGHTNESS_FACTOR = 0.62;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对 RGBA 像素进行 alpha 混合:
|
||||||
|
* outA = srcA + dstA * (1 - srcA)
|
||||||
|
* outRGB = (srcRGB * srcA + dstRGB * dstA * (1 - srcA)) / outA
|
||||||
|
*
|
||||||
|
* data 数组中每像素 4 通道:R, G, B, A,值范围 [0,255]
|
||||||
|
* alpha 范围 [0,1]
|
||||||
|
*/
|
||||||
|
function alphaBlendPixel(
|
||||||
|
data: Uint8Array,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
width: number,
|
||||||
|
color: Color,
|
||||||
|
alpha: number
|
||||||
|
) {
|
||||||
|
const offset = (y * width + x) * 4;
|
||||||
|
const dstR = data[offset + 0];
|
||||||
|
const dstG = data[offset + 1];
|
||||||
|
const dstB = data[offset + 2];
|
||||||
|
const dstA = data[offset + 3] / 255; // 转为 0~1
|
||||||
|
|
||||||
|
const srcR = color[0];
|
||||||
|
const srcG = color[1];
|
||||||
|
const srcB = color[2];
|
||||||
|
const srcA = alpha;
|
||||||
|
|
||||||
|
const outA = srcA + dstA * (1 - srcA);
|
||||||
|
if (outA > 0) {
|
||||||
|
const outR = (srcR * srcA + dstR * dstA * (1 - srcA)) / outA;
|
||||||
|
const outG = (srcG * srcA + dstG * dstA * (1 - srcA)) / outA;
|
||||||
|
const outB = (srcB * srcA + dstB * dstA * (1 - srcA)) / outA;
|
||||||
|
|
||||||
|
data[offset + 0] = Math.round(outR);
|
||||||
|
data[offset + 1] = Math.round(outG);
|
||||||
|
data[offset + 2] = Math.round(outB);
|
||||||
|
data[offset + 3] = Math.round(outA * 255);
|
||||||
|
} else {
|
||||||
|
// outA=0 -> 全透明
|
||||||
|
data[offset + 0] = 0;
|
||||||
|
data[offset + 1] = 0;
|
||||||
|
data[offset + 2] = 0;
|
||||||
|
data[offset + 3] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在 data 上绘制一个「从 centerColor->背景透明」的径向渐变圆
|
||||||
|
* @param data 画布(RGBA)
|
||||||
|
* @param width, height 画布尺寸
|
||||||
|
* @param cx, cy 圆心坐标
|
||||||
|
* @param radius 半径
|
||||||
|
* @param centerColor 圆心处的颜色 (RGB)
|
||||||
|
*/
|
||||||
|
function drawRadialGradientCircle(
|
||||||
|
data: Uint8Array,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
cx: number,
|
||||||
|
cy: number,
|
||||||
|
radius: number,
|
||||||
|
centerColor: Color
|
||||||
|
) {
|
||||||
|
// 仅在圆的外接矩形范围内绘制
|
||||||
|
const minX = Math.max(0, cx - radius);
|
||||||
|
const maxX = Math.min(width - 1, cx + radius);
|
||||||
|
const minY = Math.max(0, cy - radius);
|
||||||
|
const maxY = Math.min(height - 1, cy + radius);
|
||||||
|
|
||||||
|
for (let y = minY; y <= maxY; y++) {
|
||||||
|
for (let x = minX; x <= maxX; x++) {
|
||||||
|
const dx = x - cx;
|
||||||
|
const dy = y - cy;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
if (dist <= radius) {
|
||||||
|
// 根据距离,计算该点的 alpha:
|
||||||
|
// 圆心 dist=0 -> alpha=1
|
||||||
|
// 半径 dist=radius -> alpha=0
|
||||||
|
const alpha = 1 - dist / radius;
|
||||||
|
alphaBlendPixel(data, x, y, width, centerColor, alpha);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工具函数:判断新圆 (cx, cy, r) 与已放置的圆数组 placedCircles
|
||||||
|
* 是否满足“互不重叠”,即:
|
||||||
|
* distanceBetweenCenters >= (r + oldCircle.r + MIN_GAP_BETWEEN_CIRCLES)
|
||||||
|
*/
|
||||||
|
function canPlaceCircle(
|
||||||
|
cx: number,
|
||||||
|
cy: number,
|
||||||
|
r: number,
|
||||||
|
placedCircles: { cx: number, cy: number, r: number }[],
|
||||||
|
minGap: number
|
||||||
|
): boolean {
|
||||||
|
for (const c of placedCircles) {
|
||||||
|
const dist = Math.sqrt((cx - c.cx) ** 2 + (cy - c.cy) ** 2);
|
||||||
|
// 如果距离 < 两个半径和 + minGap,就判定为重叠
|
||||||
|
if (dist < (r + c.r + minGap)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成画布并绘制 3 个不会彼此重叠的渐变圆
|
||||||
|
* @param colors 3 个原始颜色 (RGB)
|
||||||
|
*/
|
||||||
|
function generateCirclesImage(colors: Color[]): { data: Uint8Array; width: number; height: number } {
|
||||||
|
const width = CANVAS_WIDTH;
|
||||||
|
const height = CANVAS_HEIGHT;
|
||||||
|
|
||||||
|
// 1. 创建画布并填充背景
|
||||||
|
const data = new Uint8Array(width * height * 4);
|
||||||
|
for (let i = 0; i < width * height; i++) {
|
||||||
|
data[i * 4 + 0] = BACKGROUND_COLOR[0];
|
||||||
|
data[i * 4 + 1] = BACKGROUND_COLOR[1];
|
||||||
|
data[i * 4 + 2] = BACKGROUND_COLOR[2];
|
||||||
|
data[i * 4 + 3] = BACKGROUND_COLOR[3];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 依次为 3 个颜色生成圆,确保不重叠
|
||||||
|
const placedCircles: { cx: number; cy: number; r: number }[] = [];
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
let circlePlaced = false;
|
||||||
|
|
||||||
|
// 最多尝试多次,若都无法放置,就跳过
|
||||||
|
const MAX_TRIES = 100;
|
||||||
|
|
||||||
|
const radius = randInt(MIN_RADIUS, MAX_RADIUS);
|
||||||
|
var cx = 0;
|
||||||
|
var cy = 0;
|
||||||
|
for (let attempt = 0; attempt < MAX_TRIES; attempt++) {
|
||||||
|
cx = randInt(radius + MARGIN, width - radius - MARGIN);
|
||||||
|
cy = randInt(radius + MARGIN, height - radius - MARGIN);
|
||||||
|
|
||||||
|
if (canPlaceCircle(cx, cy, radius, placedCircles, MIN_GAP_BETWEEN_CIRCLES)) {
|
||||||
|
// 找到不重叠的位置了
|
||||||
|
placedCircles.push({ cx, cy, r: radius });
|
||||||
|
circlePlaced = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!circlePlaced) {
|
||||||
|
global.logger.warn(
|
||||||
|
`[WARN] 无法在 ${MAX_TRIES} 次随机尝试中给第${i + 1}个圆找到合适位置,但仍然绘制。`
|
||||||
|
);
|
||||||
|
placedCircles.push({ cx, cy, r: radius });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 把成功放置的圆绘制到画布
|
||||||
|
// 在这里应用 COLOR_BRIGHTNESS_FACTOR,降低每个圆心的RGB
|
||||||
|
placedCircles.forEach((c, idx) => {
|
||||||
|
// 取对应颜色(若 idx>=colors.length,可加保护)
|
||||||
|
const origColor = colors[idx] || [255, 255, 255];
|
||||||
|
// 调整亮度
|
||||||
|
const adjustedColor: Color = [
|
||||||
|
Math.min(255, Math.max(0, origColor[0] * COLOR_BRIGHTNESS_FACTOR)),
|
||||||
|
Math.min(255, Math.max(0, origColor[1] * COLOR_BRIGHTNESS_FACTOR)),
|
||||||
|
Math.min(255, Math.max(0, origColor[2] * COLOR_BRIGHTNESS_FACTOR)),
|
||||||
|
];
|
||||||
|
|
||||||
|
global.logger.log(
|
||||||
|
`[INFO] Circle #${idx + 1}: center=(${c.cx},${c.cy}), radius=${c.r}, color=${adjustedColor}`
|
||||||
|
);
|
||||||
|
drawRadialGradientCircle(data, width, height, c.cx, c.cy, c.r, adjustedColor);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { data, width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 RGBA 数据保存为 JPG 文件
|
||||||
|
*/
|
||||||
|
async function saveAsJpeg(data: Uint8Array, width: number, height: number, outPath: string) {
|
||||||
|
const rawImageData = {
|
||||||
|
data: Buffer.from(data), // jpeg-js 需要 Buffer 类型
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
};
|
||||||
|
// 设定 jpg 质量
|
||||||
|
const jpegImageData = jpeg.encode(rawImageData, 80);
|
||||||
|
await asyncFile.writeArrayBuffer({
|
||||||
|
uri: outPath,
|
||||||
|
buffer: jpegImageData.data
|
||||||
|
});
|
||||||
|
global.logger.log(`[INFO] Saved: ${outPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工具函数:在 [min, max] 范围内随机整数
|
||||||
|
*/
|
||||||
|
function randInt(min: number, max: number): number {
|
||||||
|
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用Kmeans取色并生成最多三个渐变色圆形的blur背景图
|
||||||
|
*/
|
||||||
|
export async function kmeansCircleGradientImage(imgPath: string) {
|
||||||
|
global.logger.log(`[kmeansCircleGradientImage] 开始处理图片${imgPath}`)
|
||||||
|
|
||||||
|
// 提取 3 个主色
|
||||||
|
const top3Colors = await extractTop3Colors(imgPath);
|
||||||
|
global.logger.log(`\n[kmeansCircleGradientImage] 主要颜色 (RGB):`);
|
||||||
|
top3Colors.forEach((c, idx) => {
|
||||||
|
global.logger.log(` #${idx + 1}: R=${c[0]}, G=${c[1]}, B=${c[2]}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
global.logger.log(`[kmeansCircleGradientImage] 绘制图片取色模糊...`)
|
||||||
|
|
||||||
|
// 用这 3 个颜色在 400×400 的画布上绘制不重叠的渐变圆
|
||||||
|
const { data, width, height } = generateCirclesImage(top3Colors);
|
||||||
|
|
||||||
|
global.logger.log(`[kmeansCircleGradientImage] 输出result图片...`)
|
||||||
|
|
||||||
|
// 保存结果为 JPG
|
||||||
|
const outPath = 'internal://files/km-gen-tmp.jpg';
|
||||||
|
await saveAsJpeg(data, width, height, outPath);
|
||||||
|
|
||||||
|
global.logger.log(`[kmeansCircleGradientImage] 完成工作`)
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
|
|
||||||
.swipermain {
|
.swipermain {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 97%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiplabel {
|
.tiplabel {
|
||||||
|
|||||||
@@ -25,14 +25,14 @@
|
|||||||
<div class="tiplabel" if="{{comprehensive_status.has_users_result}}">
|
<div class="tiplabel" if="{{comprehensive_status.has_users_result}}">
|
||||||
<text class="listtip">用户</text>
|
<text class="listtip">用户</text>
|
||||||
</div>
|
</div>
|
||||||
<div style="width: 100%; height: 99px; justify-content: center"
|
<div style="width: 100%; justify-content: center"
|
||||||
if="{{comprehensive_status.has_users_result}}">
|
if="{{comprehensive_status.has_users_result}}">
|
||||||
<user user="{{search.users[0]}}"></user>
|
<user user="{{search.users[0]}}"></user>
|
||||||
</div>
|
</div>
|
||||||
<div class="tiplabel" style="margin-top: 10px" if="{{comprehensive_status.has_video_result}}">
|
<div class="tiplabel" style="margin-top: 10px" if="{{comprehensive_status.has_video_result}}">
|
||||||
<text class="listtip">视频</text>
|
<text class="listtip">视频</text>
|
||||||
</div>
|
</div>
|
||||||
<div style="width: 100%; height: 170px; justify-content: center"
|
<div style="width: 100%; justify-content: center"
|
||||||
if="{{comprehensive_status.has_video_result}}" for="{{search.comprehensive_videos}}">
|
if="{{comprehensive_status.has_video_result}}" for="{{search.comprehensive_videos}}">
|
||||||
<div style="width: 77%; justify-content: center; margin-top: 5px" onclick="OpenVideo({{$item.bvid}})">
|
<div style="width: 77%; justify-content: center; margin-top: 5px" onclick="OpenVideo({{$item.bvid}})">
|
||||||
<videoshow video="{{$item}}"></videoshow>
|
<videoshow video="{{$item}}"></videoshow>
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="swiper_box">
|
<div class="swiper_box">
|
||||||
<scroll if="{{typebar_if.c2}}" class="swiper_container" scroll-y="true" @scroll="ScrollOverviewCheck">
|
<scroll if="{{typebar_if.c2}}" class="swiper_container" scroll-y="true" @scroll="ScrollOverviewCheck">
|
||||||
<div style="width: 100%; height: 170px; justify-content: center" for="{{search.videos}}">
|
<div style="width: 100%; justify-content: center" for="{{search.videos}}">
|
||||||
<div style="width: 77%; justify-content: center; margin-top: 5px" onclick="OpenVideo({{$item.bvid}})">
|
<div style="width: 77%; justify-content: center; margin-top: 5px" onclick="OpenVideo({{$item.bvid}})">
|
||||||
<videoshow video="{{$item}}"></videoshow>
|
<videoshow video="{{$item}}"></videoshow>
|
||||||
</div>
|
</div>
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="swiper_box">
|
<div class="swiper_box">
|
||||||
<scroll if="{{typebar_if.c3}}" class="swiper_container" scroll-y="true" @scroll="ScrollOverviewCheck">
|
<scroll if="{{typebar_if.c3}}" class="swiper_container" scroll-y="true" @scroll="ScrollOverviewCheck">
|
||||||
<div style="width: 100%; height: 99px; justify-content: center; margin-top: 5px" for="{{search.users}}">
|
<div style="width: 100%; justify-content: center; margin-top: 5px" for="{{search.users}}">
|
||||||
<user user="{{$item}}"></user>
|
<user user="{{$item}}"></user>
|
||||||
</div>
|
</div>
|
||||||
<text style="margin-top: 30px"></text>
|
<text style="margin-top: 30px"></text>
|
||||||
|
|||||||
@@ -1,10 +1,3 @@
|
|||||||
.vidpic {
|
|
||||||
border-radius: 20px;
|
|
||||||
width: 320px;
|
|
||||||
height: 150px;
|
|
||||||
margin-top: 31px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vidtitle {
|
.vidtitle {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
<import name="title-bar" src="@components/TitleBar/TitleBar.ux"></import>
|
<import name="title-bar" src="@components/TitleBar/TitleBar.ux"></import>
|
||||||
<import name="full-screen-input" src="@components/FullScreenInput/FullScreenInput.ux"></import>
|
<import name="full-screen-input" src="@components/FullScreenInput/FullScreenInput.ux"></import>
|
||||||
<import name="default-button" src="@components/DefaultButton/DefaultButton.ux"></import>
|
<import name="default-button" src="@components/DefaultButton/DefaultButton.ux"></import>
|
||||||
|
<import name="online-image" src="@components/BetterOnlineImage/BetterOnlineImage.ux"></import>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<scroll scroll-y="true" class="scroll" bounces="true">
|
<scroll scroll-y="true" class="scroll" bounces="true">
|
||||||
<title-bar title="video.title"></title-bar>
|
<title-bar title="video.title"></title-bar>
|
||||||
<image src="{{vid.pic}}@319w_150h" alt="/common/grayloading_cn.png" class="vidpic"></image>
|
<online-image if="{{renderingCover}}" src="{{vid.pic}}@319w_150h" margin-top="30px" lock-size="true" width="320px" height="150px" loading-anim="true" @loaded="GenBlur"></online-image>
|
||||||
<text class="vidtitle">{{ vid.title }}</text>
|
<text class="vidtitle">{{ vid.title }}</text>
|
||||||
<div class="statbar">
|
<div class="statbar">
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
@@ -62,6 +63,8 @@
|
|||||||
import router from "@system.router"
|
import router from "@system.router"
|
||||||
import prompt from "@system.prompt"
|
import prompt from "@system.prompt"
|
||||||
|
|
||||||
|
import { kmeansCircleGradientImage } from "@src/image/jpegkmeans.ts"
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
public: {
|
public: {
|
||||||
bvid: 1 // 视频BVID
|
bvid: 1 // 视频BVID
|
||||||
@@ -74,7 +77,8 @@ export default {
|
|||||||
likesrc: "/common/vidtool_like.png",
|
likesrc: "/common/vidtool_like.png",
|
||||||
coinsrc: "/common/vidtool_coin.png",
|
coinsrc: "/common/vidtool_coin.png",
|
||||||
starsrc: "/common/vidtool_star.png",
|
starsrc: "/common/vidtool_star.png",
|
||||||
replymode: false
|
replymode: false,
|
||||||
|
renderingCover: false
|
||||||
},
|
},
|
||||||
onInit() {
|
onInit() {
|
||||||
global.logger.log("[videodetail] load bvid: " + this.bvid);
|
global.logger.log("[videodetail] load bvid: " + this.bvid);
|
||||||
@@ -88,6 +92,7 @@ export default {
|
|||||||
},
|
},
|
||||||
async GetVideoDetail() {
|
async GetVideoDetail() {
|
||||||
this.vid = await global.biliclient.getVideoInfoByBVID(this.bvid)
|
this.vid = await global.biliclient.getVideoInfoByBVID(this.bvid)
|
||||||
|
this.renderingCover = true
|
||||||
},
|
},
|
||||||
async UpdateVideoToolbarStatus() {
|
async UpdateVideoToolbarStatus() {
|
||||||
this.stared = await global.biliclient.isVideoStaredByBVID(this.bvid)
|
this.stared = await global.biliclient.isVideoStaredByBVID(this.bvid)
|
||||||
@@ -176,6 +181,13 @@ export default {
|
|||||||
prompt.showToast({message})
|
prompt.showToast({message})
|
||||||
this.UpdateVideoToolbarStatus()
|
this.UpdateVideoToolbarStatus()
|
||||||
},
|
},
|
||||||
|
async GenBlur(evt){
|
||||||
|
// 该Feature在现在任何设备上运行都会卡死,故注释
|
||||||
|
/*
|
||||||
|
global.logger.log(`\n[GenBlur] 开始处理封面取色模糊`);
|
||||||
|
await kmeansCircleGradientImage(evt.detail.uri)
|
||||||
|
*/
|
||||||
|
},
|
||||||
GoReplyArea() {
|
GoReplyArea() {
|
||||||
router.push({
|
router.push({
|
||||||
uri: "pages/reply/replys",
|
uri: "pages/reply/replys",
|
||||||
|
|||||||
+11
-60
@@ -1,4 +1,4 @@
|
|||||||
import { file } from "./tsimports";
|
import { asyncFile } from "./asyncapi/file";
|
||||||
|
|
||||||
// 定义文件存储的基本结构
|
// 定义文件存储的基本结构
|
||||||
interface StoredContent {
|
interface StoredContent {
|
||||||
@@ -15,64 +15,15 @@ let storageIndex: StoredContent[] = [];
|
|||||||
const baseUri = 'internal://files/bilisavedcontent/';
|
const baseUri = 'internal://files/bilisavedcontent/';
|
||||||
const indexFileUri = `${baseUri}index.json`; // 存储 storageIndex 的文件
|
const indexFileUri = `${baseUri}index.json`; // 存储 storageIndex 的文件
|
||||||
|
|
||||||
// 封装 file 接口的 Promise 方法
|
|
||||||
function fileWrite(uri: string, data: string): Promise<void> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
file.writeText({
|
|
||||||
uri,
|
|
||||||
text: data,
|
|
||||||
success: () => resolve(),
|
|
||||||
fail: (data, code) => reject(`Failed to write to ${uri}: ${code}`)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function fileRead(uri: string): Promise<string> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
file.readText({
|
|
||||||
uri,
|
|
||||||
success: (data) => resolve(data.text),
|
|
||||||
fail: (data, code) => reject(`Failed to read from ${uri}: ${code}`)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function fileList(dirUri: string): Promise<any[]> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
file.list({
|
|
||||||
uri: dirUri,
|
|
||||||
success: (data) => resolve(data.fileList),
|
|
||||||
fail: (data, code) => reject(`Failed to list files in ${dirUri}: ${code}`)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function fileAccess(uri: string): Promise<boolean> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
file.access({
|
|
||||||
uri,
|
|
||||||
success: () => resolve(true),
|
|
||||||
fail: () => resolve(false)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function fileDelete(uri: string): Promise<void> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
file.delete({
|
|
||||||
uri,
|
|
||||||
success: () => resolve(),
|
|
||||||
fail: (data, code) => reject(`Failed to delete ${uri}: ${code}`)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 同步 storageIndex 到本地文件
|
// 同步 storageIndex 到本地文件
|
||||||
async function saveStorageIndex(): Promise<void> {
|
async function saveStorageIndex(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
global.logger.log(storageIndex)
|
global.logger.log(storageIndex)
|
||||||
const indexData = JSON.stringify(storageIndex);
|
const indexData = JSON.stringify(storageIndex);
|
||||||
await fileWrite(indexFileUri, indexData);
|
await asyncFile.writeText({
|
||||||
|
uri: indexFileUri,
|
||||||
|
text: indexData
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
global.logger.error(`[SavedContentManager] saveStorageIndex Error: ${e.toString()}`);
|
global.logger.error(`[SavedContentManager] saveStorageIndex Error: ${e.toString()}`);
|
||||||
}
|
}
|
||||||
@@ -81,9 +32,9 @@ async function saveStorageIndex(): Promise<void> {
|
|||||||
// 从本地文件加载 storageIndex
|
// 从本地文件加载 storageIndex
|
||||||
async function loadStorageIndex(): Promise<void> {
|
async function loadStorageIndex(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const fileExists = await fileAccess(indexFileUri);
|
const fileExists = await asyncFile.access({ uri: indexFileUri });
|
||||||
if (fileExists) {
|
if (fileExists) {
|
||||||
const indexData = await fileRead(indexFileUri);
|
const indexData = await asyncFile.readText({ uri: indexFileUri });
|
||||||
storageIndex = JSON.parse(indexData);
|
storageIndex = JSON.parse(indexData);
|
||||||
} else {
|
} else {
|
||||||
storageIndex = [];
|
storageIndex = [];
|
||||||
@@ -116,7 +67,7 @@ export class SavedContentManager {
|
|||||||
const fileUri = `${baseUri}${id}.txt`;
|
const fileUri = `${baseUri}${id}.txt`;
|
||||||
|
|
||||||
// 写入文件
|
// 写入文件
|
||||||
await fileWrite(fileUri, data);
|
await asyncFile.writeText({ uri: fileUri, text: data });
|
||||||
|
|
||||||
// 更新 storageIndex 并保存
|
// 更新 storageIndex 并保存
|
||||||
storageIndex.push({ id, title, type, fileUri });
|
storageIndex.push({ id, title, type, fileUri });
|
||||||
@@ -135,10 +86,10 @@ export class SavedContentManager {
|
|||||||
if (!content) return null;
|
if (!content) return null;
|
||||||
|
|
||||||
// 读取文件内容
|
// 读取文件内容
|
||||||
const fileExists = await fileAccess(content.fileUri);
|
const fileExists = await asyncFile.access({ uri: content.fileUri });
|
||||||
if (!fileExists) throw new Error(`File does not exist: ${content.fileUri}`);
|
if (!fileExists) throw new Error(`File does not exist: ${content.fileUri}`);
|
||||||
|
|
||||||
return await fileRead(content.fileUri);
|
return await asyncFile.readText({ uri: content.fileUri });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
global.logger.error(`[SavedContentManager] getContent Error: ${e.toString()}`);
|
global.logger.error(`[SavedContentManager] getContent Error: ${e.toString()}`);
|
||||||
return null;
|
return null;
|
||||||
@@ -158,7 +109,7 @@ export class SavedContentManager {
|
|||||||
|
|
||||||
// 删除文件
|
// 删除文件
|
||||||
const fileUri = storageIndex[contentIndex].fileUri;
|
const fileUri = storageIndex[contentIndex].fileUri;
|
||||||
await fileDelete(fileUri);
|
await asyncFile.delete({ uri: fileUri });
|
||||||
|
|
||||||
// 从 storageIndex 中删除记录并保存
|
// 从 storageIndex 中删除记录并保存
|
||||||
storageIndex.splice(contentIndex, 1);
|
storageIndex.splice(contentIndex, 1);
|
||||||
|
|||||||
+11
-4
@@ -1,7 +1,8 @@
|
|||||||
import dayjs from "dayjs"
|
import dayjs from "dayjs"
|
||||||
import { fetch } from "./tsimports"
|
import { fetch } from "./tsimports"
|
||||||
|
|
||||||
const TRACKER_URL: string = "https://tracker.hyperbili.astralsight.space/trackreport"
|
//const TRACKER_URL: string = "https://tracker.hyperbili.astralsight.space/trackreport"
|
||||||
|
const TRACKER_URL: string = "http://192.168.1.247:4080/trackreport"
|
||||||
const TRACKER_SERVER_PROTOCOL_VERSION = "v1"
|
const TRACKER_SERVER_PROTOCOL_VERSION = "v1"
|
||||||
|
|
||||||
interface TrackedInfoUploadPacket {
|
interface TrackedInfoUploadPacket {
|
||||||
@@ -78,11 +79,17 @@ function uploadTrack(event: TIUPEvents, payload) {
|
|||||||
if (canUploadTrack()) {
|
if (canUploadTrack()) {
|
||||||
fetch.fetch({
|
fetch.fetch({
|
||||||
url: `${TRACKER_URL}/${TRACKER_SERVER_PROTOCOL_VERSION}/fetch`,
|
url: `${TRACKER_URL}/${TRACKER_SERVER_PROTOCOL_VERSION}/fetch`,
|
||||||
data
|
method: "POST",
|
||||||
|
data: JSON.stringify(data),
|
||||||
|
header: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
uploadTrack(TIUPEvents.ON_STARTUP, {
|
setTimeout(() => {
|
||||||
|
uploadTrack(TIUPEvents.ON_STARTUP, {
|
||||||
startTime: dayjs().format()
|
startTime: dayjs().format()
|
||||||
})
|
})
|
||||||
|
}, 3000)
|
||||||
@@ -5066,6 +5066,11 @@ jest-worker@^27.4.5:
|
|||||||
merge-stream "^2.0.0"
|
merge-stream "^2.0.0"
|
||||||
supports-color "^8.0.0"
|
supports-color "^8.0.0"
|
||||||
|
|
||||||
|
jpeg-js@^0.4.4:
|
||||||
|
version "0.4.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/jpeg-js/-/jpeg-js-0.4.4.tgz#a9f1c6f1f9f0fa80cdb3484ed9635054d28936aa"
|
||||||
|
integrity sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==
|
||||||
|
|
||||||
js-tokens@^4.0.0:
|
js-tokens@^4.0.0:
|
||||||
version "4.0.0"
|
version "4.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
|
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
|
||||||
|
|||||||
Reference in New Issue
Block a user