feat: implement Cloudflare KV utility class and React hooks
- Add CloudflareKVClient class with full CRUD operations - Support for batch operations, metadata, and key listing - Implement useCloudflareKV hook for React components - Add useKVValue hook for auto-loading specific keys - Include comprehensive error handling and loading states - Create demo component showing all functionality - Add detailed documentation and usage examples - Support for JSON parsing and custom configurations - Based on jm_video_ui.md specifications
This commit is contained in:
585
jm_video_ui.md
585
jm_video_ui.md
@@ -1,568 +1,17 @@
|
|||||||
import glob
|
config:
|
||||||
import mimetypes
|
cloudflare_api_key: str = "dlGquMNiAX-S7SV9pXne7YGfdH_fEgq3TfIGgNcQ"
|
||||||
import os
|
cloudflare_url: str = "https://api.cloudflare.com/client/v4/"
|
||||||
import threading
|
cloudflare_account_id: str = "67720b647ff2b55cf37ba3ef9e677083"
|
||||||
import time
|
cloudflare_kv_id: str = "995d7eff088547d7a7d8fea53e56115b"
|
||||||
import tkinter as tk
|
Authorization: `Bearer ${config.cloudflare_api_key}`
|
||||||
from tkinter import ttk, filedialog, scrolledtext, messagebox
|
|
||||||
|
kv api:
|
||||||
import requests
|
- put
|
||||||
from loguru import logger
|
curl put ${config.cloudflare_url}$/accounts/${config.cloudflare_account_id}/storage/kv/namespaces/${config.cloudflare_kv_id}/values/$KEY_NAME \
|
||||||
from qcloud_cos import CosConfig
|
-X PUT \
|
||||||
from qcloud_cos import CosS3Client
|
-H 'Content-Type: multipart/form-data' \
|
||||||
|
-d '{
|
||||||
cos_bucket_name = 'sucai-1324682537'
|
"value": "Some Value"
|
||||||
cos_secret_id = 'AKIDsrihIyjZOBsjimt8TsN8yvv1AMh5dB44'
|
}'
|
||||||
cos_secret_key = 'CPZcxdk6W39Jd4cGY95wvupoyMd0YFqW'
|
|
||||||
cos_region = 'ap-shanghai'
|
curl get ${config.cloudflare_url}$/accounts/${config.cloudflare_account_id}/storage/kv/namespaces/${config.cloudflare_kv_id}/values/$KEY_NAME
|
||||||
|
|
||||||
api_key = '21575c22-14aa-40ca-8aa8-f00ca27a3a17'
|
|
||||||
|
|
||||||
default_prompt = [
|
|
||||||
"女人扭动身体向摄像机展示身材,一只手撩了一下头发,镜头从左向右移动并放大画面",
|
|
||||||
# "女人扭动身体向摄像机展示身材,一只手撩了一下头发后放在了裤子上,镜头从左向右移动",
|
|
||||||
"时尚模特抬头自信的展示身材,扭动身体,一只手放在了头上,镜头逐渐放大聚焦在了下衣上",
|
|
||||||
"女人扭动身体向摄像机展示身材,一只手撩了一下头发后放在了裤子上,镜头从左向右移动",
|
|
||||||
"自信步伐跟拍模特,模特步伐自信地同时行走,镜头紧紧跟随。抬起手捋一捋头发。传递出自信与时尚的气息。",
|
|
||||||
"女生两只手捏着拳头轻盈的左右摇摆跳舞,动作幅度不大,然后把手摊开放在胸口再做出像popping心脏跳动的动作,左右身体都要非常协调",
|
|
||||||
"一个年轻女子自信地在相机前展示了她优美的身材,以自然的流体动作自由地摇摆,左手撩了一下头发之后停在了胸前。一个美女自拍跳舞扭动身体的视频,手从下到上最后放在胸前,妩媚的表情",
|
|
||||||
"美女向后退了一步站在那里展示服装,双手轻轻提了一下裤子两侧,镜头从上到下逐渐放大",
|
|
||||||
"女人低头看向裤子,向镜头展示身材,一只手放在了头上做pose动作",
|
|
||||||
"美女向后退了一步站在那里展示服装,低头并用手抚摸裤子,镜头从上到下逐渐放大",
|
|
||||||
"美女向后退了一步站在那里展示服装,双手从上到下整理衣服,自然扭动身体,自信的表情"
|
|
||||||
]
|
|
||||||
|
|
||||||
default_prompt_str = '\n'.join(default_prompt)
|
|
||||||
|
|
||||||
# pyinstaller -F -w --name seed_video jm_video_ui.py
|
|
||||||
class VideoUtils:
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def download_video(video_url: str, save_path: str) -> str:
|
|
||||||
try:
|
|
||||||
file_name = f'{int(time.time() * 1000)}.mp4'
|
|
||||||
response = requests.get(video_url, stream=True)
|
|
||||||
full_path = os.path.join(save_path, file_name)
|
|
||||||
with open(full_path, 'wb') as f:
|
|
||||||
for chunk in response.iter_content(chunk_size=1024 * 1024 * 5):
|
|
||||||
if chunk:
|
|
||||||
f.write(chunk)
|
|
||||||
return full_path
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(e)
|
|
||||||
pass
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def upload_file_to_cos(file_path: str, remove_src_file: bool = False):
|
|
||||||
resp_data = {'status': True, 'data': '', 'msg': ''}
|
|
||||||
mime_type, _ = mimetypes.guess_type(file_path)
|
|
||||||
category = mime_type.split('/')[0]
|
|
||||||
f_name = os.path.basename(file_path)
|
|
||||||
suffix = f_name.split('.')[-1]
|
|
||||||
real_name = f'{int(time.time() * 1000)}.{suffix}'
|
|
||||||
try:
|
|
||||||
object_key = f'tk/{category}/{real_name}'
|
|
||||||
config = CosConfig(Region=cos_region, SecretId=cos_secret_id, SecretKey=cos_secret_key)
|
|
||||||
client = CosS3Client(config)
|
|
||||||
_ = client.upload_file(
|
|
||||||
Bucket=cos_bucket_name,
|
|
||||||
Key=object_key,
|
|
||||||
LocalFilePath=file_path,
|
|
||||||
EnableMD5=False,
|
|
||||||
progress_callback=None
|
|
||||||
)
|
|
||||||
url = f'https://{cos_bucket_name}.cos.ap-shanghai.myqcloud.com/{object_key}'
|
|
||||||
resp_data['data'] = url
|
|
||||||
resp_data['msg'] = '上传成功'
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(e)
|
|
||||||
resp_data['status'] = False
|
|
||||||
resp_data['msg'] = str(e)
|
|
||||||
finally:
|
|
||||||
if remove_src_file:
|
|
||||||
os.remove(file_path)
|
|
||||||
return resp_data
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def submit_task(prompt: str, img_url: str, duration: str = '5', model_type:str='lite'):
|
|
||||||
"""
|
|
||||||
:param prompt: 生成视频的提示词
|
|
||||||
:param img_url:
|
|
||||||
:param duration:
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
if model_type == 'lite':
|
|
||||||
model = 'doubao-seedance-1-0-lite-i2v-250428'
|
|
||||||
resolution = '720p'
|
|
||||||
else:
|
|
||||||
model = 'doubao-seedance-1-0-pro-250528'
|
|
||||||
resolution = '1080p'
|
|
||||||
if duration not in ('5', '10'):
|
|
||||||
logger.error('Duration must be either 5 or 10')
|
|
||||||
try:
|
|
||||||
headers = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': f'Bearer {api_key}',
|
|
||||||
}
|
|
||||||
|
|
||||||
json_data = {
|
|
||||||
'model': model,
|
|
||||||
'content': [
|
|
||||||
{
|
|
||||||
'type': 'text',
|
|
||||||
'text': f'{prompt} --resolution {resolution} --dur {duration} --camerafixed false',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'type': 'image_url',
|
|
||||||
'image_url': {
|
|
||||||
'url': img_url,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
response = requests.post('https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks',
|
|
||||||
headers=headers,
|
|
||||||
json=json_data)
|
|
||||||
|
|
||||||
# 检查HTTP状态码
|
|
||||||
if response.status_code != 200:
|
|
||||||
error_msg = f"API请求失败,状态码: {response.status_code}, 响应: {response.text}"
|
|
||||||
logger.error(error_msg)
|
|
||||||
return {"status": False, 'msg': error_msg}
|
|
||||||
|
|
||||||
resp_json = response.json()
|
|
||||||
|
|
||||||
# 检查响应中是否包含id字段
|
|
||||||
if 'id' not in resp_json:
|
|
||||||
error_msg = f"API响应缺少id字段,响应内容: {resp_json}"
|
|
||||||
logger.error(error_msg)
|
|
||||||
return {"status": False, 'msg': error_msg}
|
|
||||||
|
|
||||||
job_id = resp_json['id']
|
|
||||||
return {"data": job_id, 'status': True}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(e)
|
|
||||||
return {"status": False, 'msg': str(e)}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def query_task_result(job_id, timeout: int = 180, interval: int = 2, progress_callback=None):
|
|
||||||
def query_status(t_id: str):
|
|
||||||
resp_dict = {'status': False, 'data': None, 'msg': ''}
|
|
||||||
try:
|
|
||||||
headers = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': f'Bearer {api_key}',
|
|
||||||
}
|
|
||||||
|
|
||||||
response = requests.get(f'https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks/{t_id}',
|
|
||||||
headers=headers)
|
|
||||||
resp_json = response.json()
|
|
||||||
resp_dict['status'] = resp_json['status'] == 'succeeded'
|
|
||||||
resp_dict['msg'] = resp_json['status']
|
|
||||||
resp_dict['data'] = resp_json['content']['video_url'] if 'content' in resp_json else None
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(e)
|
|
||||||
resp_dict['msg'] = str(e)
|
|
||||||
finally:
|
|
||||||
return resp_dict
|
|
||||||
|
|
||||||
end = time.time() + timeout
|
|
||||||
final_result = {"status": False, "data": None, "msg": ""}
|
|
||||||
success = False
|
|
||||||
wait_count = 0
|
|
||||||
|
|
||||||
# 添加开始查询的日志
|
|
||||||
if progress_callback:
|
|
||||||
progress_callback(f" 开始查询任务状态,任务ID: {job_id}")
|
|
||||||
|
|
||||||
while time.time() < end:
|
|
||||||
tmp_data = query_status(job_id)
|
|
||||||
if tmp_data['status']:
|
|
||||||
final_result['status'] = True
|
|
||||||
final_result['data'] = tmp_data['data']
|
|
||||||
final_result['msg'] = 'succeeded'
|
|
||||||
success = True
|
|
||||||
if progress_callback:
|
|
||||||
progress_callback(f" ✓ 视频生成完成!")
|
|
||||||
break
|
|
||||||
elif tmp_data['msg'] == 'running':
|
|
||||||
wait_count += 1
|
|
||||||
elapsed = wait_count * interval
|
|
||||||
remaining = max(0, timeout - elapsed)
|
|
||||||
progress_msg = f" ⏳ 任务运行中,已等待{elapsed}秒,预计剩余{remaining}秒..."
|
|
||||||
logger.info(progress_msg)
|
|
||||||
if progress_callback:
|
|
||||||
progress_callback(progress_msg)
|
|
||||||
time.sleep(interval)
|
|
||||||
elif tmp_data['msg'] == 'failed':
|
|
||||||
final_result['msg'] = '任务执行失败'
|
|
||||||
if progress_callback:
|
|
||||||
progress_callback(f" ✗ 任务执行失败")
|
|
||||||
break
|
|
||||||
elif tmp_data['msg'] == 'pending' or tmp_data['msg'] == 'queued':
|
|
||||||
wait_count += 1
|
|
||||||
elapsed = wait_count * interval
|
|
||||||
remaining = max(0, timeout - elapsed)
|
|
||||||
progress_msg = f" ⏳ 任务排队中,已等待{elapsed}秒,预计剩余{remaining}秒..."
|
|
||||||
logger.info(progress_msg)
|
|
||||||
if progress_callback:
|
|
||||||
progress_callback(progress_msg)
|
|
||||||
time.sleep(interval)
|
|
||||||
else:
|
|
||||||
# 其他未知状态继续等待
|
|
||||||
wait_count += 1
|
|
||||||
logger.info(f" 未知状态: {tmp_data['msg']},继续等待...")
|
|
||||||
if progress_callback:
|
|
||||||
progress_callback(f" ❔ 状态: {tmp_data['msg']},继续等待...")
|
|
||||||
time.sleep(interval)
|
|
||||||
|
|
||||||
if not success and final_result['msg'] == '':
|
|
||||||
final_result['msg'] = '任务超时'
|
|
||||||
if progress_callback:
|
|
||||||
progress_callback(f" ⏰ 任务查询超时({timeout}秒)")
|
|
||||||
return final_result
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def local_file_to_video(file_path: str, prompt: str, duration: str = '5', model_type: str = 'lite',
|
|
||||||
timeout: int = 180, interval: int = 2, save_path: str = None, progress_callback=None):
|
|
||||||
"""
|
|
||||||
将本地图片文件转换为视频
|
|
||||||
:param file_path: 本地图片文件路径
|
|
||||||
:param prompt: 视频生成提示词
|
|
||||||
:param duration: 视频时长 ('5' 或 '10')
|
|
||||||
:param model_type: 模型类型 ('lite' 或 'pro')
|
|
||||||
:param timeout: 任务超时时间(秒)
|
|
||||||
:param interval: 查询间隔(秒)
|
|
||||||
:param save_path: 视频保存目录
|
|
||||||
:return: {'status': bool, 'video_path': str, 'msg': str}
|
|
||||||
"""
|
|
||||||
result = {'status': False, 'video_path': '', 'msg': ''}
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 检查文件是否存在
|
|
||||||
if not os.path.exists(file_path):
|
|
||||||
result['msg'] = f'文件不存在: {file_path}'
|
|
||||||
logger.error(result['msg'])
|
|
||||||
return result
|
|
||||||
|
|
||||||
# 步骤1: 上传图片到COS
|
|
||||||
if progress_callback:
|
|
||||||
progress_callback(" 📤 正在上传图片到云存储...")
|
|
||||||
logger.info(f"正在上传图片到COS: {file_path}")
|
|
||||||
cos_dict = VideoUtils.upload_file_to_cos(file_path)
|
|
||||||
if not cos_dict['status']:
|
|
||||||
result['msg'] = f"上传图片失败: {cos_dict['msg']}"
|
|
||||||
logger.error(result['msg'])
|
|
||||||
return result
|
|
||||||
|
|
||||||
img_url = cos_dict['data']
|
|
||||||
if progress_callback:
|
|
||||||
progress_callback(" ✓ 图片上传成功")
|
|
||||||
logger.info(f"图片上传成功: {img_url}")
|
|
||||||
|
|
||||||
# 步骤2: 提交视频生成任务
|
|
||||||
if progress_callback:
|
|
||||||
progress_callback(" 🚀 正在提交视频生成任务...")
|
|
||||||
logger.info("正在提交视频生成任务...")
|
|
||||||
task_dict = VideoUtils.submit_task(prompt, img_url, duration, model_type)
|
|
||||||
if not task_dict['status']:
|
|
||||||
result['msg'] = f"提交任务失败: {task_dict['msg']}"
|
|
||||||
logger.error(result['msg'])
|
|
||||||
return result
|
|
||||||
|
|
||||||
task_id = task_dict['data']
|
|
||||||
if progress_callback:
|
|
||||||
progress_callback(f" ✓ 任务提交成功,任务ID: {task_id}")
|
|
||||||
logger.info(f"任务提交成功,任务ID: {task_id}")
|
|
||||||
|
|
||||||
# 步骤3: 查询任务结果
|
|
||||||
if progress_callback:
|
|
||||||
progress_callback(" ⏳ 正在等待视频生成完成...")
|
|
||||||
logger.info("正在等待视频生成完成...")
|
|
||||||
status_dict = VideoUtils.query_task_result(task_id, timeout=timeout, interval=interval,
|
|
||||||
progress_callback=progress_callback)
|
|
||||||
if not status_dict['status']:
|
|
||||||
result['msg'] = f"视频生成失败: {status_dict['msg']}"
|
|
||||||
logger.error(result['msg'])
|
|
||||||
return result
|
|
||||||
|
|
||||||
video_url = status_dict['data']
|
|
||||||
logger.info(f"视频生成成功: {video_url}")
|
|
||||||
|
|
||||||
# 步骤4: 下载视频到本地
|
|
||||||
if save_path:
|
|
||||||
if progress_callback:
|
|
||||||
progress_callback(" 📥 正在下载视频到本地...")
|
|
||||||
logger.info("正在下载视频到本地...")
|
|
||||||
video_path = VideoUtils.download_video(video_url, save_path)
|
|
||||||
if video_path and os.path.exists(video_path):
|
|
||||||
result['status'] = True
|
|
||||||
result['video_path'] = video_path
|
|
||||||
result['msg'] = '视频生成并下载成功'
|
|
||||||
if progress_callback:
|
|
||||||
progress_callback(f" ✓ 视频下载成功: {os.path.basename(video_path)}")
|
|
||||||
logger.info(f"视频下载成功: {video_path}")
|
|
||||||
else:
|
|
||||||
result['msg'] = '视频下载失败'
|
|
||||||
if progress_callback:
|
|
||||||
progress_callback(" ✗ 视频下载失败")
|
|
||||||
logger.error(result['msg'])
|
|
||||||
else:
|
|
||||||
result['status'] = True
|
|
||||||
result['video_path'] = video_url
|
|
||||||
result['msg'] = '视频生成成功'
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
result['msg'] = f'处理过程中发生异常: {str(e)}'
|
|
||||||
logger.error(result['msg'])
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def run_actual_script_logic(image_folder, prompt, output_video_dir, video_duration, model_type, add_log_entry_func,
|
|
||||||
other_params=None):
|
|
||||||
add_log_entry_func(f"开始处理任务...")
|
|
||||||
add_log_entry_func(f" 图片文件夹: {image_folder}")
|
|
||||||
add_log_entry_func(f" 提示词: \n{prompt[:100]}{'...' if len(prompt) > 100 else ''}")
|
|
||||||
add_log_entry_func(f" 视频保存目录: {output_video_dir}")
|
|
||||||
add_log_entry_func(f" 视频时长: {video_duration}秒")
|
|
||||||
add_log_entry_func(f" 模型类型: {model_type}")
|
|
||||||
|
|
||||||
if other_params:
|
|
||||||
for key, value in other_params.items():
|
|
||||||
add_log_entry_func(f" 额外参数 {key}: {value}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 筛选图片文件
|
|
||||||
# img_extensions = ['.jpg', '.jpeg', '.png', '.bmp', '.gif', '.tiff', '.webp']
|
|
||||||
img_list = glob.glob(f'{image_folder}/*')
|
|
||||||
add_log_entry_func(f'目录下找到 {len(img_list)} 张图片')
|
|
||||||
|
|
||||||
if len(img_list) == 0:
|
|
||||||
add_log_entry_func("警告: 未找到任何图片文件")
|
|
||||||
return
|
|
||||||
|
|
||||||
# 解析提示词列表
|
|
||||||
prompt_lines = [line.strip() for line in prompt.split('\n') if line.strip()]
|
|
||||||
if not prompt_lines:
|
|
||||||
add_log_entry_func("错误: 提示词不能为空")
|
|
||||||
return
|
|
||||||
|
|
||||||
add_log_entry_func(f'解析到 {len(prompt_lines)} 个提示词,将循环使用')
|
|
||||||
|
|
||||||
# 确保输出目录存在
|
|
||||||
if not os.path.exists(output_video_dir):
|
|
||||||
os.makedirs(output_video_dir)
|
|
||||||
add_log_entry_func(f"创建输出目录: {output_video_dir}")
|
|
||||||
|
|
||||||
success_count = 0
|
|
||||||
failed_count = 0
|
|
||||||
|
|
||||||
# 处理每个图片文件
|
|
||||||
for i, img_path in enumerate(img_list):
|
|
||||||
img_name = os.path.basename(img_path)
|
|
||||||
# 使用循环提示词
|
|
||||||
current_prompt = prompt_lines[i % len(prompt_lines)]
|
|
||||||
add_log_entry_func(f"[{i + 1}/{len(img_list)}] 处理图片: {img_name}")
|
|
||||||
add_log_entry_func(
|
|
||||||
f" 使用提示词[{i % len(prompt_lines) + 1}]: {current_prompt[:50]}{'...' if len(current_prompt) > 50 else ''}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 调用视频生成功能
|
|
||||||
result = VideoUtils.local_file_to_video(
|
|
||||||
file_path=img_path,
|
|
||||||
prompt=current_prompt,
|
|
||||||
duration=str(video_duration),
|
|
||||||
model_type=model_type,
|
|
||||||
timeout=300, # 5分钟超时
|
|
||||||
interval=3, # 3秒检查一次
|
|
||||||
save_path=output_video_dir,
|
|
||||||
progress_callback=add_log_entry_func
|
|
||||||
)
|
|
||||||
|
|
||||||
if result and result.get('status'):
|
|
||||||
success_count += 1
|
|
||||||
video_path = result.get('video_path', '')
|
|
||||||
add_log_entry_func(f" ✓ 视频生成成功: {os.path.basename(video_path)}")
|
|
||||||
else:
|
|
||||||
failed_count += 1
|
|
||||||
error_msg = result.get('msg', '未知错误') if result else '处理失败'
|
|
||||||
add_log_entry_func(f" ✗ 视频生成失败: {error_msg}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
failed_count += 1
|
|
||||||
add_log_entry_func(f" ✗ 处理图片时出错: {str(e)}")
|
|
||||||
|
|
||||||
# 输出最终统计
|
|
||||||
add_log_entry_func("=" * 50)
|
|
||||||
add_log_entry_func(f"处理完成!成功: {success_count}, 失败: {failed_count}")
|
|
||||||
if success_count > 0:
|
|
||||||
add_log_entry_func(f"生成的视频已保存到: {output_video_dir}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
add_log_entry_func(f"处理过程中发生错误: {str(e)}")
|
|
||||||
finally:
|
|
||||||
add_log_entry_func("任务处理结束。")
|
|
||||||
|
|
||||||
|
|
||||||
# --- 核心处理函数结束 ---
|
|
||||||
|
|
||||||
|
|
||||||
class App:
|
|
||||||
def __init__(self, root):
|
|
||||||
self.root = root
|
|
||||||
self.root.title("视频生成工具")
|
|
||||||
self.root.geometry("700x650")
|
|
||||||
self.root.resizable(False, False)
|
|
||||||
# --- 变量 ---
|
|
||||||
self.image_folder_var = tk.StringVar()
|
|
||||||
self.output_video_dir_var = tk.StringVar()
|
|
||||||
self.video_duration_var = tk.IntVar(value=5)
|
|
||||||
self.model_type_var = tk.StringVar(value='lite')
|
|
||||||
# --- 主框架 ---
|
|
||||||
main_frame = ttk.Frame(root, padding="10")
|
|
||||||
main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
|
|
||||||
root.grid_rowconfigure(0, weight=1)
|
|
||||||
root.grid_columnconfigure(0, weight=1)
|
|
||||||
|
|
||||||
# --- 组件 ---
|
|
||||||
row_idx = 0
|
|
||||||
general_pady = 5
|
|
||||||
|
|
||||||
# 1. 选择图片文件夹
|
|
||||||
ttk.Label(main_frame, text="选择图片文件夹:").grid(row=row_idx, column=0, sticky=tk.W, pady=(general_pady, 2))
|
|
||||||
self.image_folder_entry = ttk.Entry(main_frame, textvariable=self.image_folder_var, width=50)
|
|
||||||
self.image_folder_entry.grid(row=row_idx, column=1, sticky=(tk.W, tk.E), pady=(general_pady, 2), padx=5)
|
|
||||||
ttk.Button(main_frame, text="浏览...", command=self.select_image_folder).grid(row=row_idx, column=2,
|
|
||||||
sticky=tk.E,
|
|
||||||
pady=(general_pady, 2))
|
|
||||||
row_idx += 1
|
|
||||||
|
|
||||||
# 2. 保存视频存储目录
|
|
||||||
ttk.Label(main_frame, text="视频保存目录:").grid(row=row_idx, column=0, sticky=tk.W, pady=general_pady)
|
|
||||||
self.output_video_dir_entry = ttk.Entry(main_frame, textvariable=self.output_video_dir_var, width=50)
|
|
||||||
self.output_video_dir_entry.grid(row=row_idx, column=1, sticky=(tk.W, tk.E), pady=general_pady, padx=5)
|
|
||||||
ttk.Button(main_frame, text="浏览...", command=self.select_output_video_dir).grid(row=row_idx, column=2,
|
|
||||||
sticky=tk.E,
|
|
||||||
pady=general_pady)
|
|
||||||
row_idx += 1
|
|
||||||
|
|
||||||
# 3. 生图提示词
|
|
||||||
ttk.Label(main_frame, text="生成视频提示词:").grid(row=row_idx, column=0, sticky=(tk.W, tk.NW),
|
|
||||||
pady=(general_pady, 2))
|
|
||||||
self.prompt_text = scrolledtext.ScrolledText(main_frame, wrap=tk.WORD, width=60, height=8)
|
|
||||||
self.prompt_text.grid(row=row_idx, column=1, columnspan=2, sticky=(tk.W, tk.E), pady=(general_pady, 2), padx=5)
|
|
||||||
# 设置默认提示词
|
|
||||||
self.prompt_text.insert("1.0", default_prompt_str)
|
|
||||||
row_idx += 1
|
|
||||||
|
|
||||||
# 4. 视频时长
|
|
||||||
duration_frame = ttk.LabelFrame(main_frame, text="视频时长")
|
|
||||||
duration_frame.grid(row=row_idx, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=general_pady, padx=5)
|
|
||||||
ttk.Radiobutton(duration_frame, text="5秒", variable=self.video_duration_var, value=5).pack(side=tk.LEFT,
|
|
||||||
padx=10, pady=5)
|
|
||||||
ttk.Radiobutton(duration_frame, text="10秒", variable=self.video_duration_var, value=10).pack(side=tk.LEFT,
|
|
||||||
padx=10, pady=5)
|
|
||||||
row_idx += 1
|
|
||||||
|
|
||||||
# 5. 模型类型
|
|
||||||
model_frame = ttk.LabelFrame(main_frame, text="模型类型")
|
|
||||||
model_frame.grid(row=row_idx, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=general_pady, padx=5)
|
|
||||||
ttk.Radiobutton(model_frame, text="lite", variable=self.model_type_var, value='lite').pack(side=tk.LEFT,
|
|
||||||
padx=10, pady=5)
|
|
||||||
ttk.Radiobutton(model_frame, text="pro", variable=self.model_type_var, value='pro').pack(side=tk.LEFT,
|
|
||||||
padx=10, pady=5)
|
|
||||||
row_idx += 1
|
|
||||||
|
|
||||||
# 6. 运行按钮 (新位置)
|
|
||||||
self.run_button = ttk.Button(main_frame, text="运行", command=self.start_processing_thread)
|
|
||||||
# 增加上下边距,使其与上下组件有明显区隔
|
|
||||||
self.run_button.grid(row=row_idx, column=0, columnspan=3, pady=(general_pady + 10, general_pady + 5))
|
|
||||||
row_idx += 1
|
|
||||||
|
|
||||||
# 7. 运行日志 Label
|
|
||||||
ttk.Label(main_frame, text="运行日志:").grid(row=row_idx, column=0, sticky=tk.W,
|
|
||||||
pady=(general_pady, 0)) # 调整pady使其靠近下方的Text
|
|
||||||
row_idx += 1
|
|
||||||
|
|
||||||
# 8. 运行日志 ScrolledText
|
|
||||||
self.log_text = scrolledtext.ScrolledText(main_frame, wrap=tk.WORD, width=70, height=15, state='disabled')
|
|
||||||
self.log_text.grid(row=row_idx, column=0, columnspan=3, sticky=(tk.W, tk.E, tk.N, tk.S), pady=(2, general_pady),
|
|
||||||
padx=5)
|
|
||||||
main_frame.grid_rowconfigure(row_idx, weight=1) # 让日志区域可以扩展
|
|
||||||
# row_idx += 1 # 最后一个组件后面不需要再增加row_idx,除非还要添加东西
|
|
||||||
|
|
||||||
# 配置列的伸缩
|
|
||||||
main_frame.grid_columnconfigure(1, weight=1)
|
|
||||||
|
|
||||||
def select_image_folder(self):
|
|
||||||
folder_selected = filedialog.askdirectory()
|
|
||||||
if folder_selected:
|
|
||||||
self.image_folder_var.set(folder_selected)
|
|
||||||
self.add_log_entry(f"选择图片文件夹: {folder_selected}")
|
|
||||||
|
|
||||||
def select_output_video_dir(self):
|
|
||||||
folder_selected = filedialog.askdirectory()
|
|
||||||
if folder_selected:
|
|
||||||
self.output_video_dir_var.set(folder_selected)
|
|
||||||
self.add_log_entry(f"选择视频保存目录: {folder_selected}")
|
|
||||||
|
|
||||||
def add_log_entry(self, message):
|
|
||||||
self.log_text.configure(state='normal')
|
|
||||||
self.log_text.insert(tk.END, message + "\n")
|
|
||||||
self.log_text.configure(state='disabled')
|
|
||||||
self.log_text.see(tk.END)
|
|
||||||
|
|
||||||
def start_processing_thread(self):
|
|
||||||
image_folder = self.image_folder_var.get()
|
|
||||||
prompt = self.prompt_text.get("1.0", tk.END).strip()
|
|
||||||
output_video_dir = self.output_video_dir_var.get()
|
|
||||||
video_duration = self.video_duration_var.get()
|
|
||||||
model_type = self.model_type_var.get()
|
|
||||||
|
|
||||||
other_params = {}
|
|
||||||
|
|
||||||
if not image_folder:
|
|
||||||
messagebox.showerror("错误", "请选择图片文件夹!")
|
|
||||||
return
|
|
||||||
if not prompt:
|
|
||||||
messagebox.showerror("错误", "请输入生图提示词!")
|
|
||||||
return
|
|
||||||
if not output_video_dir:
|
|
||||||
messagebox.showerror("错误", "请选择视频保存目录!")
|
|
||||||
return
|
|
||||||
|
|
||||||
self.add_log_entry("=" * 30)
|
|
||||||
self.run_button.config(state="disabled")
|
|
||||||
|
|
||||||
thread = threading.Thread(target=self.run_script_in_background,
|
|
||||||
args=(image_folder, prompt, output_video_dir, video_duration, model_type, other_params))
|
|
||||||
thread.daemon = True
|
|
||||||
thread.start()
|
|
||||||
|
|
||||||
def run_script_in_background(self, image_folder, prompt, output_video_dir, video_duration, model_type, other_params):
|
|
||||||
try:
|
|
||||||
run_actual_script_logic(
|
|
||||||
image_folder,
|
|
||||||
prompt,
|
|
||||||
output_video_dir,
|
|
||||||
video_duration,
|
|
||||||
model_type,
|
|
||||||
self.add_log_entry,
|
|
||||||
other_params
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
self.add_log_entry(f"线程中发生未捕获的错误: {e}")
|
|
||||||
self.root.after(0, lambda: messagebox.showerror("线程错误", f"处理过程中发生错误:\n{e}"))
|
|
||||||
finally:
|
|
||||||
self.root.after(0, self.enable_run_button)
|
|
||||||
|
|
||||||
def enable_run_button(self):
|
|
||||||
self.run_button.config(state="normal")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
root = tk.Tk()
|
|
||||||
app = App(root)
|
|
||||||
root.mainloop()
|
|
||||||
@@ -35,7 +35,11 @@ class Settings(BaseSettings):
|
|||||||
default_fps: int = 30
|
default_fps: int = 30
|
||||||
default_video_codec: str = "libx264"
|
default_video_codec: str = "libx264"
|
||||||
default_audio_codec: str = "aac"
|
default_audio_codec: str = "aac"
|
||||||
|
# cloud flare
|
||||||
|
cloudflare_api_key: str = "dlGquMNiAX-S7SV9pXne7YGfdH_fEgq3TfIGgNcQ"
|
||||||
|
cloudflare_url: str = "https://api.cloudflare.com/client/v4/"
|
||||||
|
cloudflare_account_id: str = "67720b647ff2b55cf37ba3ef9e677083"
|
||||||
|
cloudflare_kv_id: str = "995d7eff088547d7a7d8fea53e56115b"
|
||||||
# Audio Processing
|
# Audio Processing
|
||||||
default_sample_rate: int = 44100
|
default_sample_rate: int = 44100
|
||||||
default_audio_bitrate: str = "192k"
|
default_audio_bitrate: str = "192k"
|
||||||
|
|||||||
287
src/components/CloudflareKVDemo.tsx
Normal file
287
src/components/CloudflareKVDemo.tsx
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
/**
|
||||||
|
* Cloudflare KV Demo Component
|
||||||
|
*
|
||||||
|
* This component demonstrates how to use the Cloudflare KV utilities
|
||||||
|
* in a React application with proper loading states and error handling.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { useCloudflareKV, useKVValue } from '../hooks/useCloudflareKV'
|
||||||
|
|
||||||
|
interface DemoData {
|
||||||
|
message: string
|
||||||
|
timestamp: string
|
||||||
|
counter: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CloudflareKVDemo: React.FC = () => {
|
||||||
|
const [inputKey, setInputKey] = useState('demo:test')
|
||||||
|
const [inputValue, setInputValue] = useState('Hello, Cloudflare KV!')
|
||||||
|
const [searchPrefix, setSearchPrefix] = useState('demo:')
|
||||||
|
|
||||||
|
// Use the general KV hook
|
||||||
|
const kv = useCloudflareKV()
|
||||||
|
|
||||||
|
// Use the specific value hook for a counter
|
||||||
|
const {
|
||||||
|
value: counterValue,
|
||||||
|
loading: counterLoading,
|
||||||
|
error: counterError,
|
||||||
|
save: saveCounter,
|
||||||
|
refresh: refreshCounter
|
||||||
|
} = useKVValue<number>('demo:counter', true, true)
|
||||||
|
|
||||||
|
// Handle putting a value
|
||||||
|
const handlePutValue = async () => {
|
||||||
|
if (!inputKey || !inputValue) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data: DemoData = {
|
||||||
|
message: inputValue,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
counter: (typeof counterValue === 'number' ? counterValue : 0) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
await kv.put(inputKey, data)
|
||||||
|
alert('✅ Value stored successfully!')
|
||||||
|
} catch (error) {
|
||||||
|
alert('❌ Failed to store value: ' + (error as Error).message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle getting a value
|
||||||
|
const handleGetValue = async () => {
|
||||||
|
if (!inputKey) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await kv.get<DemoData>(inputKey)
|
||||||
|
if (result) {
|
||||||
|
alert(`✅ Retrieved value: ${JSON.stringify(result, null, 2)}`)
|
||||||
|
} else {
|
||||||
|
alert('ℹ️ Key not found')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('❌ Failed to get value: ' + (error as Error).message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle deleting a value
|
||||||
|
const handleDeleteValue = async () => {
|
||||||
|
if (!inputKey) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await kv.delete(inputKey)
|
||||||
|
alert('✅ Value deleted successfully!')
|
||||||
|
} catch (error) {
|
||||||
|
alert('❌ Failed to delete value: ' + (error as Error).message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle listing keys
|
||||||
|
const handleListKeys = async () => {
|
||||||
|
try {
|
||||||
|
const result = await kv.listKeys(searchPrefix, 50)
|
||||||
|
const keyNames = result.result.keys.map(k => k.name).join('\n')
|
||||||
|
alert(`✅ Found keys:\n${keyNames || 'No keys found'}`)
|
||||||
|
} catch (error) {
|
||||||
|
alert('❌ Failed to list keys: ' + (error as Error).message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle incrementing counter
|
||||||
|
const handleIncrementCounter = async () => {
|
||||||
|
try {
|
||||||
|
const newValue = (typeof counterValue === 'number' ? counterValue : 0) + 1
|
||||||
|
await saveCounter(newValue)
|
||||||
|
} catch (error) {
|
||||||
|
alert('❌ Failed to increment counter: ' + (error as Error).message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle batch operations
|
||||||
|
const handleBatchPut = async () => {
|
||||||
|
try {
|
||||||
|
const entries = [
|
||||||
|
{ key: 'batch:item1', value: { name: 'Item 1', price: 10.99 } },
|
||||||
|
{ key: 'batch:item2', value: { name: 'Item 2', price: 15.50 } },
|
||||||
|
{ key: 'batch:item3', value: { name: 'Item 3', price: 8.75 } }
|
||||||
|
]
|
||||||
|
|
||||||
|
await kv.putBatch(entries)
|
||||||
|
alert('✅ Batch operation completed!')
|
||||||
|
} catch (error) {
|
||||||
|
alert('❌ Batch operation failed: ' + (error as Error).message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto p-6 space-y-8">
|
||||||
|
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-6">
|
||||||
|
Cloudflare KV Demo
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Loading indicator */}
|
||||||
|
{kv.isLoading && (
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600 mr-2"></div>
|
||||||
|
<span className="text-blue-800">Processing...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error display */}
|
||||||
|
{kv.hasError && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
|
||||||
|
<h3 className="text-red-800 font-medium mb-2">Errors:</h3>
|
||||||
|
<ul className="text-red-700 text-sm space-y-1">
|
||||||
|
{kv.errors.get && <li>Get: {kv.errors.get}</li>}
|
||||||
|
{kv.errors.put && <li>Put: {kv.errors.put}</li>}
|
||||||
|
{kv.errors.delete && <li>Delete: {kv.errors.delete}</li>}
|
||||||
|
{kv.errors.list && <li>List: {kv.errors.list}</li>}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Counter Section */}
|
||||||
|
<div className="bg-gray-50 rounded-lg p-4 mb-6">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 mb-4">
|
||||||
|
Auto-loaded Counter
|
||||||
|
</h2>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="text-lg">
|
||||||
|
Current value:
|
||||||
|
<span className="font-mono font-bold ml-2">
|
||||||
|
{counterLoading ? '...' : counterValue || 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleIncrementCounter}
|
||||||
|
disabled={counterLoading}
|
||||||
|
className="bg-blue-500 hover:bg-blue-600 disabled:bg-gray-400 text-white px-4 py-2 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Increment
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={refreshCounter}
|
||||||
|
disabled={counterLoading}
|
||||||
|
className="bg-gray-500 hover:bg-gray-600 disabled:bg-gray-400 text-white px-4 py-2 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{counterError && (
|
||||||
|
<p className="text-red-600 text-sm mt-2">{counterError}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Manual Operations Section */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900">
|
||||||
|
Manual Operations
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Key-Value Input */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Key
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={inputKey}
|
||||||
|
onChange={(e) => setInputKey(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
placeholder="Enter key name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Value
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
placeholder="Enter value"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handlePutValue}
|
||||||
|
disabled={kv.isLoading || !inputKey || !inputValue}
|
||||||
|
className="bg-green-500 hover:bg-green-600 disabled:bg-gray-400 text-white px-4 py-2 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Put Value
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleGetValue}
|
||||||
|
disabled={kv.isLoading || !inputKey}
|
||||||
|
className="bg-blue-500 hover:bg-blue-600 disabled:bg-gray-400 text-white px-4 py-2 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Get Value
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDeleteValue}
|
||||||
|
disabled={kv.isLoading || !inputKey}
|
||||||
|
className="bg-red-500 hover:bg-red-600 disabled:bg-gray-400 text-white px-4 py-2 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Delete Value
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List Keys Section */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Search Prefix
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchPrefix}
|
||||||
|
onChange={(e) => setSearchPrefix(e.target.value)}
|
||||||
|
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
placeholder="Enter prefix to search"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleListKeys}
|
||||||
|
disabled={kv.isLoading}
|
||||||
|
className="bg-purple-500 hover:bg-purple-600 disabled:bg-gray-400 text-white px-4 py-2 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
List Keys
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Batch Operations */}
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={handleBatchPut}
|
||||||
|
disabled={kv.isLoading}
|
||||||
|
className="bg-orange-500 hover:bg-orange-600 disabled:bg-gray-400 text-white px-4 py-2 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Batch Put Demo Items
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Clear States */}
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={kv.clearStates}
|
||||||
|
className="bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Clear All States
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CloudflareKVDemo
|
||||||
291
src/hooks/useCloudflareKV.ts
Normal file
291
src/hooks/useCloudflareKV.ts
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
/**
|
||||||
|
* React Hook for Cloudflare KV Operations
|
||||||
|
*
|
||||||
|
* This hook provides a convenient way to interact with Cloudflare KV
|
||||||
|
* from React components with loading states and error handling.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback, useEffect } from 'react'
|
||||||
|
import { cloudflareKV, CloudflareKVClient } from '../utils/cloudflareKV'
|
||||||
|
|
||||||
|
interface UseCloudflareKVOptions {
|
||||||
|
client?: CloudflareKVClient
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KVOperationState<T = any> {
|
||||||
|
data: T | null
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCloudflareKV(options: UseCloudflareKVOptions = {}) {
|
||||||
|
const client = options.client || cloudflareKV
|
||||||
|
|
||||||
|
// State for get operations
|
||||||
|
const [getState, setGetState] = useState<KVOperationState>({
|
||||||
|
data: null,
|
||||||
|
loading: false,
|
||||||
|
error: null
|
||||||
|
})
|
||||||
|
|
||||||
|
// State for put operations
|
||||||
|
const [putState, setPutState] = useState<KVOperationState>({
|
||||||
|
data: null,
|
||||||
|
loading: false,
|
||||||
|
error: null
|
||||||
|
})
|
||||||
|
|
||||||
|
// State for delete operations
|
||||||
|
const [deleteState, setDeleteState] = useState<KVOperationState>({
|
||||||
|
data: null,
|
||||||
|
loading: false,
|
||||||
|
error: null
|
||||||
|
})
|
||||||
|
|
||||||
|
// State for list operations
|
||||||
|
const [listState, setListState] = useState<KVOperationState>({
|
||||||
|
data: null,
|
||||||
|
loading: false,
|
||||||
|
error: null
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a value from KV store
|
||||||
|
*/
|
||||||
|
const get = useCallback(async <T = any>(key: string, parseJSON: boolean = true): Promise<T | string | null> => {
|
||||||
|
setGetState({ data: null, loading: true, error: null })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await client.get<T>(key, parseJSON)
|
||||||
|
setGetState({ data: result, loading: false, error: null })
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
setGetState({ data: null, loading: false, error: errorMessage })
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}, [client])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Put a value to KV store
|
||||||
|
*/
|
||||||
|
const put = useCallback(async (key: string, value: any, metadata?: Record<string, any>) => {
|
||||||
|
setPutState({ data: null, loading: true, error: null })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await client.put(key, value, metadata)
|
||||||
|
setPutState({ data: result, loading: false, error: null })
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
setPutState({ data: null, loading: false, error: errorMessage })
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}, [client])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a key from KV store
|
||||||
|
*/
|
||||||
|
const deleteKey = useCallback(async (key: string) => {
|
||||||
|
setDeleteState({ data: null, loading: true, error: null })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await client.delete(key)
|
||||||
|
setDeleteState({ data: result, loading: false, error: null })
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
setDeleteState({ data: null, loading: false, error: errorMessage })
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}, [client])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List keys from KV store
|
||||||
|
*/
|
||||||
|
const listKeys = useCallback(async (prefix?: string, limit: number = 1000, cursor?: string) => {
|
||||||
|
setListState({ data: null, loading: true, error: null })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await client.listKeys(prefix, limit, cursor)
|
||||||
|
setListState({ data: result, loading: false, error: null })
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
setListState({ data: null, loading: false, error: errorMessage })
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}, [client])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a key exists
|
||||||
|
*/
|
||||||
|
const exists = useCallback(async (key: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
return await client.exists(key)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking key existence:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}, [client])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Put multiple values in batch
|
||||||
|
*/
|
||||||
|
const putBatch = useCallback(async (entries: Array<{ key: string; value: any; metadata?: Record<string, any> }>) => {
|
||||||
|
setPutState({ data: null, loading: true, error: null })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await client.putBatch(entries)
|
||||||
|
setPutState({ data: result, loading: false, error: null })
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
setPutState({ data: null, loading: false, error: errorMessage })
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}, [client])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all operation states
|
||||||
|
*/
|
||||||
|
const clearStates = useCallback(() => {
|
||||||
|
setGetState({ data: null, loading: false, error: null })
|
||||||
|
setPutState({ data: null, loading: false, error: null })
|
||||||
|
setDeleteState({ data: null, loading: false, error: null })
|
||||||
|
setListState({ data: null, loading: false, error: null })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Operations
|
||||||
|
get,
|
||||||
|
put,
|
||||||
|
delete: deleteKey,
|
||||||
|
listKeys,
|
||||||
|
exists,
|
||||||
|
putBatch,
|
||||||
|
clearStates,
|
||||||
|
|
||||||
|
// States
|
||||||
|
getState,
|
||||||
|
putState,
|
||||||
|
deleteState,
|
||||||
|
listState,
|
||||||
|
|
||||||
|
// Convenience getters
|
||||||
|
isLoading: getState.loading || putState.loading || deleteState.loading || listState.loading,
|
||||||
|
hasError: !!(getState.error || putState.error || deleteState.error || listState.error),
|
||||||
|
errors: {
|
||||||
|
get: getState.error,
|
||||||
|
put: putState.error,
|
||||||
|
delete: deleteState.error,
|
||||||
|
list: listState.error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for managing a specific KV key with automatic loading and caching
|
||||||
|
*/
|
||||||
|
export function useKVValue<T = any>(key: string, parseJSON: boolean = true, autoLoad: boolean = true) {
|
||||||
|
const [value, setValue] = useState<T | string | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
|
||||||
|
|
||||||
|
const kv = useCloudflareKV()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the value from KV
|
||||||
|
*/
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!key) return
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await kv.get<T>(key, parseJSON)
|
||||||
|
setValue(result)
|
||||||
|
setLastUpdated(new Date())
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Failed to load value'
|
||||||
|
setError(errorMessage)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [key, parseJSON, kv])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a new value to KV
|
||||||
|
*/
|
||||||
|
const save = useCallback(async (newValue: T | string, metadata?: Record<string, any>) => {
|
||||||
|
if (!key) return
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await kv.put(key, newValue, metadata)
|
||||||
|
setValue(newValue)
|
||||||
|
setLastUpdated(new Date())
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Failed to save value'
|
||||||
|
setError(errorMessage)
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [key, kv])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the value from KV
|
||||||
|
*/
|
||||||
|
const remove = useCallback(async () => {
|
||||||
|
if (!key) return
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await kv.delete(key)
|
||||||
|
setValue(null)
|
||||||
|
setLastUpdated(new Date())
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Failed to delete value'
|
||||||
|
setError(errorMessage)
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [key, kv])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh the value from KV
|
||||||
|
*/
|
||||||
|
const refresh = useCallback(() => {
|
||||||
|
return load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
// Auto-load on mount if enabled
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoLoad && key) {
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
}, [autoLoad, key, load])
|
||||||
|
|
||||||
|
return {
|
||||||
|
value,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
lastUpdated,
|
||||||
|
load,
|
||||||
|
save,
|
||||||
|
remove,
|
||||||
|
refresh,
|
||||||
|
exists: value !== null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export types
|
||||||
|
export type { KVOperationState }
|
||||||
268
src/utils/README.md
Normal file
268
src/utils/README.md
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
# Cloudflare KV Utility
|
||||||
|
|
||||||
|
这是一个用于与 Cloudflare KV 存储交互的 TypeScript 工具类,提供了完整的 CRUD 操作和 React Hook 集成。
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
- ✅ 完整的 CRUD 操作(Create, Read, Update, Delete)
|
||||||
|
- ✅ 批量操作支持
|
||||||
|
- ✅ 元数据支持
|
||||||
|
- ✅ React Hook 集成
|
||||||
|
- ✅ TypeScript 类型安全
|
||||||
|
- ✅ 错误处理和加载状态
|
||||||
|
- ✅ 自动 JSON 解析
|
||||||
|
- ✅ 可配置的客户端
|
||||||
|
|
||||||
|
## 文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/utils/
|
||||||
|
├── cloudflareKV.ts # 核心 KV 客户端类
|
||||||
|
├── cloudflareKV.example.ts # 使用示例
|
||||||
|
└── README.md # 文档
|
||||||
|
|
||||||
|
src/hooks/
|
||||||
|
└── useCloudflareKV.ts # React Hook
|
||||||
|
|
||||||
|
src/components/
|
||||||
|
└── CloudflareKVDemo.tsx # 演示组件
|
||||||
|
```
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 1. 基本使用
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { cloudflareKV } from '../utils/cloudflareKV'
|
||||||
|
|
||||||
|
// 存储数据
|
||||||
|
await cloudflareKV.put('user:123', { name: 'John Doe', email: 'john@example.com' })
|
||||||
|
|
||||||
|
// 获取数据
|
||||||
|
const user = await cloudflareKV.get('user:123')
|
||||||
|
console.log(user) // { name: 'John Doe', email: 'john@example.com' }
|
||||||
|
|
||||||
|
// 删除数据
|
||||||
|
await cloudflareKV.delete('user:123')
|
||||||
|
|
||||||
|
// 检查键是否存在
|
||||||
|
const exists = await cloudflareKV.exists('user:123')
|
||||||
|
console.log(exists) // false
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 在 React 组件中使用
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useCloudflareKV } from '../hooks/useCloudflareKV'
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const kv = useCloudflareKV()
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
await kv.put('my-key', { message: 'Hello World' })
|
||||||
|
console.log('保存成功!')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{kv.isLoading && <p>加载中...</p>}
|
||||||
|
{kv.hasError && <p>错误: {kv.errors.put}</p>}
|
||||||
|
<button onClick={handleSave}>保存数据</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 使用自动加载的值
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useKVValue } from '../hooks/useCloudflareKV'
|
||||||
|
|
||||||
|
function CounterComponent() {
|
||||||
|
const {
|
||||||
|
value: counter,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
save,
|
||||||
|
refresh
|
||||||
|
} = useKVValue<number>('counter', true, true) // 自动加载
|
||||||
|
|
||||||
|
const increment = async () => {
|
||||||
|
await save((counter || 0) + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <div>加载中...</div>
|
||||||
|
if (error) return <div>错误: {error}</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p>计数器: {counter || 0}</p>
|
||||||
|
<button onClick={increment}>增加</button>
|
||||||
|
<button onClick={refresh}>刷新</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 参考
|
||||||
|
|
||||||
|
### CloudflareKVClient
|
||||||
|
|
||||||
|
#### 构造函数
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
new CloudflareKVClient(config?: Partial<CloudflareKVConfig>)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 方法
|
||||||
|
|
||||||
|
- `put(key: string, value: any, metadata?: Record<string, any>)` - 存储键值对
|
||||||
|
- `get<T>(key: string, parseJSON?: boolean)` - 获取值
|
||||||
|
- `delete(key: string)` - 删除键
|
||||||
|
- `listKeys(prefix?: string, limit?: number, cursor?: string)` - 列出键
|
||||||
|
- `exists(key: string)` - 检查键是否存在
|
||||||
|
- `putBatch(entries: Array<{key, value, metadata?}>)` - 批量存储
|
||||||
|
|
||||||
|
### useCloudflareKV Hook
|
||||||
|
|
||||||
|
返回对象包含:
|
||||||
|
|
||||||
|
- **操作方法**: `get`, `put`, `delete`, `listKeys`, `exists`, `putBatch`
|
||||||
|
- **状态**: `getState`, `putState`, `deleteState`, `listState`
|
||||||
|
- **便捷属性**: `isLoading`, `hasError`, `errors`
|
||||||
|
|
||||||
|
### useKVValue Hook
|
||||||
|
|
||||||
|
用于管理单个 KV 键的 Hook,返回:
|
||||||
|
|
||||||
|
- `value` - 当前值
|
||||||
|
- `loading` - 加载状态
|
||||||
|
- `error` - 错误信息
|
||||||
|
- `save(newValue)` - 保存新值
|
||||||
|
- `remove()` - 删除值
|
||||||
|
- `refresh()` - 刷新值
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
默认配置基于 `jm_video_ui.md` 文档:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
apiKey: "dlGquMNiAX-S7SV9pXne7YGfdH_fEgq3TfIGgNcQ",
|
||||||
|
baseUrl: "https://api.cloudflare.com/client/v4",
|
||||||
|
accountId: "67720b647ff2b55cf37ba3ef9e677083",
|
||||||
|
kvNamespaceId: "995d7eff088547d7a7d8fea53e56115b"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 自定义配置
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { CloudflareKVClient } from '../utils/cloudflareKV'
|
||||||
|
|
||||||
|
const customKV = new CloudflareKVClient({
|
||||||
|
apiKey: 'your-custom-api-key',
|
||||||
|
// 其他配置使用默认值
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用场景
|
||||||
|
|
||||||
|
### 1. 用户偏好设置
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 保存用户设置
|
||||||
|
await cloudflareKV.put('user:123:preferences', {
|
||||||
|
theme: 'dark',
|
||||||
|
language: 'zh-CN',
|
||||||
|
notifications: true
|
||||||
|
})
|
||||||
|
|
||||||
|
// 加载用户设置
|
||||||
|
const preferences = await cloudflareKV.get('user:123:preferences')
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 视频模板存储
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 存储视频模板
|
||||||
|
const template = {
|
||||||
|
id: 'template-001',
|
||||||
|
name: '现代介绍模板',
|
||||||
|
duration: 5000,
|
||||||
|
assets: [...],
|
||||||
|
settings: {...}
|
||||||
|
}
|
||||||
|
|
||||||
|
await cloudflareKV.put(`template:${template.id}`, template)
|
||||||
|
|
||||||
|
// 创建模板索引
|
||||||
|
await cloudflareKV.put(`template:index:${template.id}`, {
|
||||||
|
id: template.id,
|
||||||
|
name: template.name,
|
||||||
|
thumbnail: 'https://example.com/thumb.jpg'
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 缓存数据
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 缓存 API 响应
|
||||||
|
const cacheKey = `api:users:${userId}`
|
||||||
|
const cachedData = await cloudflareKV.get(cacheKey)
|
||||||
|
|
||||||
|
if (!cachedData) {
|
||||||
|
const freshData = await fetchUserData(userId)
|
||||||
|
await cloudflareKV.put(cacheKey, freshData, {
|
||||||
|
ttl: 3600 // 1小时过期
|
||||||
|
})
|
||||||
|
return freshData
|
||||||
|
}
|
||||||
|
|
||||||
|
return cachedData
|
||||||
|
```
|
||||||
|
|
||||||
|
## 错误处理
|
||||||
|
|
||||||
|
所有方法都会抛出错误,建议使用 try-catch 处理:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
try {
|
||||||
|
await cloudflareKV.put('key', 'value')
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message.includes('HTTP 401')) {
|
||||||
|
console.error('API 密钥无效')
|
||||||
|
} else if (error.message.includes('HTTP 429')) {
|
||||||
|
console.error('请求频率限制')
|
||||||
|
} else {
|
||||||
|
console.error('未知错误:', error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 演示
|
||||||
|
|
||||||
|
运行演示组件查看完整功能:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import CloudflareKVDemo from '../components/CloudflareKVDemo'
|
||||||
|
|
||||||
|
// 在你的应用中使用
|
||||||
|
<CloudflareKVDemo />
|
||||||
|
```
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **API 密钥安全**: 在生产环境中,请将 API 密钥存储在环境变量中
|
||||||
|
2. **请求限制**: Cloudflare KV 有请求频率限制,请合理使用
|
||||||
|
3. **数据大小**: 单个值最大 25MB
|
||||||
|
4. **键名限制**: 键名最大 512 字节
|
||||||
|
5. **一致性**: KV 是最终一致性存储,写入后可能需要时间同步
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
MIT License
|
||||||
223
src/utils/cloudflareKV.example.ts
Normal file
223
src/utils/cloudflareKV.example.ts
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
/**
|
||||||
|
* Cloudflare KV Usage Examples
|
||||||
|
*
|
||||||
|
* This file demonstrates how to use the CloudflareKVClient utility class
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { CloudflareKVClient, cloudflareKV } from './cloudflareKV'
|
||||||
|
|
||||||
|
// Example 1: Using the default instance
|
||||||
|
export async function basicUsageExample() {
|
||||||
|
try {
|
||||||
|
// Store a simple string value
|
||||||
|
await cloudflareKV.put('user:123', 'John Doe')
|
||||||
|
console.log('✅ Stored user name')
|
||||||
|
|
||||||
|
// Store a JSON object
|
||||||
|
const userData = {
|
||||||
|
id: 123,
|
||||||
|
name: 'John Doe',
|
||||||
|
email: 'john@example.com',
|
||||||
|
preferences: {
|
||||||
|
theme: 'dark',
|
||||||
|
language: 'en'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await cloudflareKV.put('user:123:profile', userData)
|
||||||
|
console.log('✅ Stored user profile')
|
||||||
|
|
||||||
|
// Retrieve the data
|
||||||
|
const name = await cloudflareKV.get('user:123', false) // Don't parse as JSON
|
||||||
|
const profile = await cloudflareKV.get('user:123:profile') // Parse as JSON
|
||||||
|
|
||||||
|
console.log('Retrieved name:', name)
|
||||||
|
console.log('Retrieved profile:', profile)
|
||||||
|
|
||||||
|
// Check if a key exists
|
||||||
|
const exists = await cloudflareKV.exists('user:123')
|
||||||
|
console.log('User exists:', exists)
|
||||||
|
|
||||||
|
// List keys with prefix
|
||||||
|
const keys = await cloudflareKV.listKeys('user:')
|
||||||
|
console.log('User keys:', keys.result.keys)
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error in basic usage example:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example 2: Using a custom configuration
|
||||||
|
export async function customConfigExample() {
|
||||||
|
const customKV = new CloudflareKVClient({
|
||||||
|
// You can override any configuration values
|
||||||
|
apiKey: 'your-custom-api-key',
|
||||||
|
// Other values will use defaults
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await customKV.put('custom:test', { message: 'Hello from custom config!' })
|
||||||
|
const result = await customKV.get('custom:test')
|
||||||
|
console.log('Custom config result:', result)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error in custom config example:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example 3: Batch operations
|
||||||
|
export async function batchOperationsExample() {
|
||||||
|
try {
|
||||||
|
// Store multiple values at once
|
||||||
|
const entries = [
|
||||||
|
{ key: 'batch:item1', value: { name: 'Item 1', price: 10.99 } },
|
||||||
|
{ key: 'batch:item2', value: { name: 'Item 2', price: 15.50 } },
|
||||||
|
{ key: 'batch:item3', value: { name: 'Item 3', price: 8.75 } }
|
||||||
|
]
|
||||||
|
|
||||||
|
const results = await cloudflareKV.putBatch(entries)
|
||||||
|
console.log('Batch put results:', results)
|
||||||
|
|
||||||
|
// Retrieve all batch items
|
||||||
|
for (const entry of entries) {
|
||||||
|
const item = await cloudflareKV.get(entry.key)
|
||||||
|
console.log(`Retrieved ${entry.key}:`, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error in batch operations example:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example 4: Working with metadata
|
||||||
|
export async function metadataExample() {
|
||||||
|
try {
|
||||||
|
const value = { content: 'Important data' }
|
||||||
|
const metadata = {
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
created_by: 'user:123',
|
||||||
|
version: '1.0'
|
||||||
|
}
|
||||||
|
|
||||||
|
await cloudflareKV.put('data:important', value, metadata)
|
||||||
|
console.log('✅ Stored data with metadata')
|
||||||
|
|
||||||
|
// Note: To retrieve metadata, you'd need to use the Cloudflare API directly
|
||||||
|
// as the get method only returns the value, not metadata
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error in metadata example:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example 5: Error handling and edge cases
|
||||||
|
export async function errorHandlingExample() {
|
||||||
|
try {
|
||||||
|
// Try to get a non-existent key
|
||||||
|
const nonExistent = await cloudflareKV.get('does-not-exist')
|
||||||
|
console.log('Non-existent key result:', nonExistent) // Should be null
|
||||||
|
|
||||||
|
// Store and then delete a key
|
||||||
|
await cloudflareKV.put('temp:data', 'temporary value')
|
||||||
|
console.log('✅ Stored temporary data')
|
||||||
|
|
||||||
|
const beforeDelete = await cloudflareKV.get('temp:data')
|
||||||
|
console.log('Before delete:', beforeDelete)
|
||||||
|
|
||||||
|
await cloudflareKV.delete('temp:data')
|
||||||
|
console.log('✅ Deleted temporary data')
|
||||||
|
|
||||||
|
const afterDelete = await cloudflareKV.get('temp:data')
|
||||||
|
console.log('After delete:', afterDelete) // Should be null
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error in error handling example:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example 6: Video template storage use case
|
||||||
|
export async function videoTemplateStorageExample() {
|
||||||
|
try {
|
||||||
|
const templateData = {
|
||||||
|
id: 'template-001',
|
||||||
|
name: 'Modern Intro Template',
|
||||||
|
description: 'A sleek modern intro template with animations',
|
||||||
|
duration: 5000, // 5 seconds
|
||||||
|
assets: [
|
||||||
|
{ type: 'video', url: 'https://example.com/intro.mp4' },
|
||||||
|
{ type: 'audio', url: 'https://example.com/music.mp3' }
|
||||||
|
],
|
||||||
|
settings: {
|
||||||
|
resolution: '1920x1080',
|
||||||
|
fps: 30,
|
||||||
|
format: 'mp4'
|
||||||
|
},
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store template
|
||||||
|
await cloudflareKV.put(`template:${templateData.id}`, templateData)
|
||||||
|
console.log('✅ Stored video template')
|
||||||
|
|
||||||
|
// Store template index for quick listing
|
||||||
|
const templateIndex = {
|
||||||
|
id: templateData.id,
|
||||||
|
name: templateData.name,
|
||||||
|
description: templateData.description,
|
||||||
|
thumbnail: 'https://example.com/thumbnail.jpg'
|
||||||
|
}
|
||||||
|
await cloudflareKV.put(`template:index:${templateData.id}`, templateIndex)
|
||||||
|
console.log('✅ Stored template index')
|
||||||
|
|
||||||
|
// Retrieve template
|
||||||
|
const retrievedTemplate = await cloudflareKV.get(`template:${templateData.id}`)
|
||||||
|
console.log('Retrieved template:', retrievedTemplate)
|
||||||
|
|
||||||
|
// List all templates
|
||||||
|
const templateKeys = await cloudflareKV.listKeys('template:index:', 100)
|
||||||
|
console.log('Template list:', templateKeys.result.keys)
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error in video template storage example:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run all examples
|
||||||
|
export async function runAllExamples() {
|
||||||
|
console.log('🚀 Running Cloudflare KV Examples...\n')
|
||||||
|
|
||||||
|
console.log('1. Basic Usage Example:')
|
||||||
|
await basicUsageExample()
|
||||||
|
console.log('\n')
|
||||||
|
|
||||||
|
console.log('2. Custom Config Example:')
|
||||||
|
await customConfigExample()
|
||||||
|
console.log('\n')
|
||||||
|
|
||||||
|
console.log('3. Batch Operations Example:')
|
||||||
|
await batchOperationsExample()
|
||||||
|
console.log('\n')
|
||||||
|
|
||||||
|
console.log('4. Metadata Example:')
|
||||||
|
await metadataExample()
|
||||||
|
console.log('\n')
|
||||||
|
|
||||||
|
console.log('5. Error Handling Example:')
|
||||||
|
await errorHandlingExample()
|
||||||
|
console.log('\n')
|
||||||
|
|
||||||
|
console.log('6. Video Template Storage Example:')
|
||||||
|
await videoTemplateStorageExample()
|
||||||
|
console.log('\n')
|
||||||
|
|
||||||
|
console.log('✅ All examples completed!')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export individual examples for selective testing
|
||||||
|
export {
|
||||||
|
basicUsageExample,
|
||||||
|
customConfigExample,
|
||||||
|
batchOperationsExample,
|
||||||
|
metadataExample,
|
||||||
|
errorHandlingExample,
|
||||||
|
videoTemplateStorageExample
|
||||||
|
}
|
||||||
256
src/utils/cloudflareKV.ts
Normal file
256
src/utils/cloudflareKV.ts
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
/**
|
||||||
|
* Cloudflare KV Storage Utility Class
|
||||||
|
*
|
||||||
|
* This utility class provides methods to interact with Cloudflare KV storage
|
||||||
|
* for storing and retrieving key-value pairs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface CloudflareKVConfig {
|
||||||
|
apiKey: string
|
||||||
|
baseUrl: string
|
||||||
|
accountId: string
|
||||||
|
kvNamespaceId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CloudflareKVResponse<T = any> {
|
||||||
|
success: boolean
|
||||||
|
errors: Array<{ code: number; message: string }>
|
||||||
|
messages: string[]
|
||||||
|
result: T
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CloudflareKVClient {
|
||||||
|
private config: CloudflareKVConfig
|
||||||
|
|
||||||
|
constructor(config?: Partial<CloudflareKVConfig>) {
|
||||||
|
this.config = {
|
||||||
|
apiKey: config?.apiKey || "dlGquMNiAX-S7SV9pXne7YGfdH_fEgq3TfIGgNcQ",
|
||||||
|
baseUrl: config?.baseUrl || "https://api.cloudflare.com/client/v4",
|
||||||
|
accountId: config?.accountId || "67720b647ff2b55cf37ba3ef9e677083",
|
||||||
|
kvNamespaceId: config?.kvNamespaceId || "995d7eff088547d7a7d8fea53e56115b"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the base URL for KV operations
|
||||||
|
*/
|
||||||
|
private getKVBaseUrl(): string {
|
||||||
|
return `${this.config.baseUrl}/accounts/${this.config.accountId}/storage/kv/namespaces/${this.config.kvNamespaceId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get common headers for API requests
|
||||||
|
*/
|
||||||
|
private getHeaders(): HeadersInit {
|
||||||
|
return {
|
||||||
|
'Authorization': `Bearer ${this.config.apiKey}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a value in Cloudflare KV
|
||||||
|
*
|
||||||
|
* @param key - The key to store the value under
|
||||||
|
* @param value - The value to store (will be JSON stringified if not a string)
|
||||||
|
* @param metadata - Optional metadata to associate with the key-value pair
|
||||||
|
* @returns Promise resolving to the API response
|
||||||
|
*/
|
||||||
|
async put(key: string, value: any, metadata?: Record<string, any>): Promise<CloudflareKVResponse> {
|
||||||
|
try {
|
||||||
|
const url = `${this.getKVBaseUrl()}/values/${encodeURIComponent(key)}`
|
||||||
|
|
||||||
|
const body: any = {
|
||||||
|
value: typeof value === 'string' ? value : JSON.stringify(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadata) {
|
||||||
|
body.metadata = JSON.stringify(metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${this.config.apiKey}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${result.errors?.[0]?.message || 'Unknown error'}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error putting value to Cloudflare KV:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve a value from Cloudflare KV
|
||||||
|
*
|
||||||
|
* @param key - The key to retrieve
|
||||||
|
* @param parseJSON - Whether to attempt JSON parsing of the retrieved value
|
||||||
|
* @returns Promise resolving to the stored value
|
||||||
|
*/
|
||||||
|
async get<T = any>(key: string, parseJSON: boolean = true): Promise<T | string | null> {
|
||||||
|
try {
|
||||||
|
const url = `${this.getKVBaseUrl()}/values/${encodeURIComponent(key)}`
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${this.config.apiKey}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status === 404) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json()
|
||||||
|
throw new Error(`HTTP ${response.status}: ${errorData.errors?.[0]?.message || 'Unknown error'}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const textValue = await response.text()
|
||||||
|
|
||||||
|
if (parseJSON) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(textValue) as T
|
||||||
|
} catch {
|
||||||
|
// If JSON parsing fails, return as string
|
||||||
|
return textValue as any
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return textValue as any
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting value from Cloudflare KV:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a key-value pair from Cloudflare KV
|
||||||
|
*
|
||||||
|
* @param key - The key to delete
|
||||||
|
* @returns Promise resolving to the API response
|
||||||
|
*/
|
||||||
|
async delete(key: string): Promise<CloudflareKVResponse> {
|
||||||
|
try {
|
||||||
|
const url = `${this.getKVBaseUrl()}/values/${encodeURIComponent(key)}`
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: this.getHeaders()
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${result.errors?.[0]?.message || 'Unknown error'}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting value from Cloudflare KV:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List keys in the KV namespace
|
||||||
|
*
|
||||||
|
* @param prefix - Optional prefix to filter keys
|
||||||
|
* @param limit - Maximum number of keys to return (default: 1000)
|
||||||
|
* @param cursor - Cursor for pagination
|
||||||
|
* @returns Promise resolving to the list of keys
|
||||||
|
*/
|
||||||
|
async listKeys(prefix?: string, limit: number = 1000, cursor?: string): Promise<CloudflareKVResponse<{
|
||||||
|
keys: Array<{
|
||||||
|
name: string
|
||||||
|
expiration?: number
|
||||||
|
metadata?: Record<string, any>
|
||||||
|
}>
|
||||||
|
list_complete: boolean
|
||||||
|
cursor?: string
|
||||||
|
}>> {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (prefix) params.append('prefix', prefix)
|
||||||
|
if (limit) params.append('limit', limit.toString())
|
||||||
|
if (cursor) params.append('cursor', cursor)
|
||||||
|
|
||||||
|
const url = `${this.getKVBaseUrl()}/keys?${params.toString()}`
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: this.getHeaders()
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${result.errors?.[0]?.message || 'Unknown error'}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error listing keys from Cloudflare KV:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a key exists in the KV store
|
||||||
|
*
|
||||||
|
* @param key - The key to check
|
||||||
|
* @returns Promise resolving to boolean indicating if key exists
|
||||||
|
*/
|
||||||
|
async exists(key: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const value = await this.get(key, false)
|
||||||
|
return value !== null
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking key existence in Cloudflare KV:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store multiple key-value pairs in batch
|
||||||
|
*
|
||||||
|
* @param entries - Array of key-value pairs to store
|
||||||
|
* @returns Promise resolving to array of results
|
||||||
|
*/
|
||||||
|
async putBatch(entries: Array<{ key: string; value: any; metadata?: Record<string, any> }>): Promise<CloudflareKVResponse[]> {
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
entries.map(entry => this.put(entry.key, entry.value, entry.metadata))
|
||||||
|
)
|
||||||
|
|
||||||
|
return results.map((result, index) => {
|
||||||
|
if (result.status === 'fulfilled') {
|
||||||
|
return result.value
|
||||||
|
} else {
|
||||||
|
console.error(`Failed to put key ${entries[index].key}:`, result.reason)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errors: [{ code: -1, message: result.reason.message || 'Unknown error' }],
|
||||||
|
messages: [],
|
||||||
|
result: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export a default instance with the default configuration
|
||||||
|
export const cloudflareKV = new CloudflareKVClient()
|
||||||
|
|
||||||
|
// Export types for external use
|
||||||
|
export type { CloudflareKVConfig, CloudflareKVResponse }
|
||||||
Reference in New Issue
Block a user