新增tracker&视频封面实时模糊(未启用)&搜索页面改进
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
/.nyc_output
|
||||
/coverage
|
||||
/node_modules
|
||||
**/node_modules/
|
||||
/sign
|
||||
/dist
|
||||
/build
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"dayjs": "^1.11.13",
|
||||
"jpeg-js": "^0.4.4",
|
||||
"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: {
|
||||
alias: {
|
||||
'@src': path.resolve(__dirname, 'src'),
|
||||
'@components': path.resolve(__dirname, 'src/components'),
|
||||
'@less': path.resolve(__dirname, 'src/less'),
|
||||
'@protobuf': path.resolve(__dirname, 'src/protobuf'),
|
||||
|
||||
@@ -13,6 +13,7 @@ import * as jumpcheck from "./jumpcheck"
|
||||
import * as htmlparser from "./htmlparser"
|
||||
import * as savedcontent from "./savedcontent"
|
||||
import * as bgimg from "./bgimg"
|
||||
import * as usertracker from "./usertracker"
|
||||
import logger from "./logger/logger"
|
||||
export default {
|
||||
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,
|
||||
success: (data) => {
|
||||
global.logger.log("[BetterOnlineImage] Image Saved in: " + data.uri)
|
||||
this.$emit('loaded', {
|
||||
uri: data.uri
|
||||
})
|
||||
this.localsrc = data.uri
|
||||
this.show_loading = false
|
||||
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 {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
height: 97%;
|
||||
}
|
||||
|
||||
.tiplabel {
|
||||
|
||||
@@ -25,14 +25,14 @@
|
||||
<div class="tiplabel" if="{{comprehensive_status.has_users_result}}">
|
||||
<text class="listtip">用户</text>
|
||||
</div>
|
||||
<div style="width: 100%; height: 99px; justify-content: center"
|
||||
<div style="width: 100%; justify-content: center"
|
||||
if="{{comprehensive_status.has_users_result}}">
|
||||
<user user="{{search.users[0]}}"></user>
|
||||
</div>
|
||||
<div class="tiplabel" style="margin-top: 10px" if="{{comprehensive_status.has_video_result}}">
|
||||
<text class="listtip">视频</text>
|
||||
</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}}">
|
||||
<div style="width: 77%; justify-content: center; margin-top: 5px" onclick="OpenVideo({{$item.bvid}})">
|
||||
<videoshow video="{{$item}}"></videoshow>
|
||||
@@ -43,7 +43,7 @@
|
||||
</div>
|
||||
<div class="swiper_box">
|
||||
<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}})">
|
||||
<videoshow video="{{$item}}"></videoshow>
|
||||
</div>
|
||||
@@ -53,7 +53,7 @@
|
||||
</div>
|
||||
<div class="swiper_box">
|
||||
<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>
|
||||
</div>
|
||||
<text style="margin-top: 30px"></text>
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
.vidpic {
|
||||
border-radius: 20px;
|
||||
width: 320px;
|
||||
height: 150px;
|
||||
margin-top: 31px;
|
||||
}
|
||||
|
||||
.vidtitle {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<import name="title-bar" src="@components/TitleBar/TitleBar.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="online-image" src="@components/BetterOnlineImage/BetterOnlineImage.ux"></import>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<scroll scroll-y="true" class="scroll" bounces="true">
|
||||
<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>
|
||||
<div class="statbar">
|
||||
<div class="stat">
|
||||
@@ -62,6 +63,8 @@
|
||||
import router from "@system.router"
|
||||
import prompt from "@system.prompt"
|
||||
|
||||
import { kmeansCircleGradientImage } from "@src/image/jpegkmeans.ts"
|
||||
|
||||
export default {
|
||||
public: {
|
||||
bvid: 1 // 视频BVID
|
||||
@@ -74,7 +77,8 @@ export default {
|
||||
likesrc: "/common/vidtool_like.png",
|
||||
coinsrc: "/common/vidtool_coin.png",
|
||||
starsrc: "/common/vidtool_star.png",
|
||||
replymode: false
|
||||
replymode: false,
|
||||
renderingCover: false
|
||||
},
|
||||
onInit() {
|
||||
global.logger.log("[videodetail] load bvid: " + this.bvid);
|
||||
@@ -88,6 +92,7 @@ export default {
|
||||
},
|
||||
async GetVideoDetail() {
|
||||
this.vid = await global.biliclient.getVideoInfoByBVID(this.bvid)
|
||||
this.renderingCover = true
|
||||
},
|
||||
async UpdateVideoToolbarStatus() {
|
||||
this.stared = await global.biliclient.isVideoStaredByBVID(this.bvid)
|
||||
@@ -176,6 +181,13 @@ export default {
|
||||
prompt.showToast({message})
|
||||
this.UpdateVideoToolbarStatus()
|
||||
},
|
||||
async GenBlur(evt){
|
||||
// 该Feature在现在任何设备上运行都会卡死,故注释
|
||||
/*
|
||||
global.logger.log(`\n[GenBlur] 开始处理封面取色模糊`);
|
||||
await kmeansCircleGradientImage(evt.detail.uri)
|
||||
*/
|
||||
},
|
||||
GoReplyArea() {
|
||||
router.push({
|
||||
uri: "pages/reply/replys",
|
||||
|
||||
+11
-60
@@ -1,4 +1,4 @@
|
||||
import { file } from "./tsimports";
|
||||
import { asyncFile } from "./asyncapi/file";
|
||||
|
||||
// 定义文件存储的基本结构
|
||||
interface StoredContent {
|
||||
@@ -15,64 +15,15 @@ let storageIndex: StoredContent[] = [];
|
||||
const baseUri = 'internal://files/bilisavedcontent/';
|
||||
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 到本地文件
|
||||
async function saveStorageIndex(): Promise<void> {
|
||||
try {
|
||||
global.logger.log(storageIndex)
|
||||
const indexData = JSON.stringify(storageIndex);
|
||||
await fileWrite(indexFileUri, indexData);
|
||||
await asyncFile.writeText({
|
||||
uri: indexFileUri,
|
||||
text: indexData
|
||||
});
|
||||
} catch (e) {
|
||||
global.logger.error(`[SavedContentManager] saveStorageIndex Error: ${e.toString()}`);
|
||||
}
|
||||
@@ -81,9 +32,9 @@ async function saveStorageIndex(): Promise<void> {
|
||||
// 从本地文件加载 storageIndex
|
||||
async function loadStorageIndex(): Promise<void> {
|
||||
try {
|
||||
const fileExists = await fileAccess(indexFileUri);
|
||||
const fileExists = await asyncFile.access({ uri: indexFileUri });
|
||||
if (fileExists) {
|
||||
const indexData = await fileRead(indexFileUri);
|
||||
const indexData = await asyncFile.readText({ uri: indexFileUri });
|
||||
storageIndex = JSON.parse(indexData);
|
||||
} else {
|
||||
storageIndex = [];
|
||||
@@ -116,7 +67,7 @@ export class SavedContentManager {
|
||||
const fileUri = `${baseUri}${id}.txt`;
|
||||
|
||||
// 写入文件
|
||||
await fileWrite(fileUri, data);
|
||||
await asyncFile.writeText({ uri: fileUri, text: data });
|
||||
|
||||
// 更新 storageIndex 并保存
|
||||
storageIndex.push({ id, title, type, fileUri });
|
||||
@@ -135,10 +86,10 @@ export class SavedContentManager {
|
||||
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}`);
|
||||
|
||||
return await fileRead(content.fileUri);
|
||||
return await asyncFile.readText({ uri: content.fileUri });
|
||||
} catch (e) {
|
||||
global.logger.error(`[SavedContentManager] getContent Error: ${e.toString()}`);
|
||||
return null;
|
||||
@@ -158,7 +109,7 @@ export class SavedContentManager {
|
||||
|
||||
// 删除文件
|
||||
const fileUri = storageIndex[contentIndex].fileUri;
|
||||
await fileDelete(fileUri);
|
||||
await asyncFile.delete({ uri: fileUri });
|
||||
|
||||
// 从 storageIndex 中删除记录并保存
|
||||
storageIndex.splice(contentIndex, 1);
|
||||
|
||||
+12
-5
@@ -1,7 +1,8 @@
|
||||
import dayjs from "dayjs"
|
||||
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"
|
||||
|
||||
interface TrackedInfoUploadPacket {
|
||||
@@ -78,11 +79,17 @@ function uploadTrack(event: TIUPEvents, payload) {
|
||||
if (canUploadTrack()) {
|
||||
fetch.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, {
|
||||
startTime: dayjs().format()
|
||||
})
|
||||
setTimeout(() => {
|
||||
uploadTrack(TIUPEvents.ON_STARTUP, {
|
||||
startTime: dayjs().format()
|
||||
})
|
||||
}, 3000)
|
||||
@@ -5066,6 +5066,11 @@ jest-worker@^27.4.5:
|
||||
merge-stream "^2.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:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
|
||||
|
||||
Reference in New Issue
Block a user